HTML 之 ollama流式API客户端 -完整版

环境变量设置如下:

set OLLAMA_HOST=0.0.0.0:11434
set OLLAMA_ORIGINS=*

ollama流式API客户端

<!DOCTYPE html>
<html lang="zh-CN">
	<head>
		<meta charset="UTF-8">
		<!-- 添加viewport标签确保移动端正确缩放 -->
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<title>AI-聊天模式</title>
		<style>
			body {
				font-family: 'Segoe UI', Arial, sans-serif;
				background: #f0f2f5;
				margin: 0;
				min-height: 100vh;
				/* 改为min-height避免内容溢出 */
				display: flex;
				justify-content: center;
				align-items: center;
			}

			.container {
				width: 95vw;
				/* 增加宽度占比 */
				height: 95vh;
				display: flex;
				flex-direction: column;
				gap: 10px;
				/* 缩小间隙 */
				padding: 5px;
				/* 添加内边距 */
			}

			#output {
				flex: 1;
				width: calc(100% - 10px);
				/* 考虑内边距 */
				min-height: 50%;
				padding: 12px;
				font-size: 16px;
				/* 增大字体 */
				border: 2px solid #e3e8ee;
				border-radius: 6px;
				background: white;
				resize: none;
				overflow-y: auto;
				/* 确保滚动条可用 */
			}

			.input-group {
				display: flex;
				flex-direction: column;
				/* 改为垂直布局 */
				gap: 8px;
				height: auto;
				/* 自动高度 */
			}

			#input {
				width: calc(100% - 10px);
				min-height: 80px;
				/* 更适合移动端的高度 */
				padding: 10px;
				font-size: 16px;
				border: 2px solid #e3e8ee;
				border-radius: 6px;
				resize: vertical;
				/* 允许垂直调整 */
			}

			button {
				padding: 12px 20px;
				background: #007bff;
				color: white;
				border: none;
				border-radius: 6px;
				font-size: 16px;
				cursor: pointer;
				transition: background 0.2s;
				touch-action: manipulation;
				/* 优化触摸响应 */
			}

			/* 新增按钮容器样式 */
			.button-row {
				display: flex;
				gap: 8px;
				width: 100%;
			}

			/* 发送按钮宽度设置 */
			button[onclick="sendMessage()"] {
				flex: 1;
				/* 占据剩余空间 */
				width: 80%;
			}

			button:active {
				background: #0056b3;
			}

			/* 新增图标按钮样式 */
			button.icon-button {
				padding: 12px;
				width: 20%;
				min-width: 60px;
				background: #28a745;
				display: flex;
				justify-content: center;
				align-items: center;
			}

			/* 调整按钮组间距 */
			.button-group {
				display: flex;
				gap: 8px;
				margin-top: 8px;
			}

			.icon-button {
				position: relative;
			}

			/* 喇叭图标样式 */
			.icon-button svg {
				width: 24px;
				height: 24px;
				fill: white;
				transition: opacity 0.3s;
			}

			.icon-button .off-icon {
				position: absolute;
				opacity: 0;
			}

			/* 激活状态 */
			.icon-button.active .on-icon {
				opacity: 0;
			}

			.icon-button.active .off-icon {
				opacity: 1;
			}

			/* 颜色变化 */
			.icon-button.active {
				background: #dc3545;
			}

			/* 手机端响应式调整 */
			@media (max-width: 480px) {
				.container {
					width: 100vw;
					height: 100vh;
					padding: 5px;
				}

				#output {
					font-size: 15px;
					padding: 10px;
				}

				#input {
					font-size: 15px;
					min-height: 70px;
				}

				button {
					padding: 15px 20px;
					/* 增大点击区域 */
					font-size: 15px;
				}

				button.icon-button {
					padding: 10px;
					width: 44px;
				}

				.icon-button svg {
					width: 22px;
					height: 22px;
				}

				.tab-buttons {
					gap: 4px;
					padding-bottom: 6px;
				}

				.tab-button {
					padding: 6px 12px;
					font-size: 13px;
				}

				.file-input-wrapper {
					max-width: 100%;
					min-width: 60px;
				}

				.file-input-button {
					padding: 10px;
					height: 40px;
					border-radius: 4px;
				}

				.file-input-button svg {
					width: 22px;
					height: 22px;
				}
			}

			/* 高对比度模式适配 */
			@media (prefers-contrast: more) {
				.file-input-button {
					border: 2px solid currentColor;
				}
			}

			#fileInput {
				position: absolute;
				opacity: 0;
				width: 100%;
				left: 0;
				top: 0;
				width: 100%;
				height: 100%;
				cursor: pointer;
				z-index: 1;
				-webkit-tap-highlight-color: transparent;
			}

			.file-input-wrapper {
				position: relative;
				display: inline-block;
				flex: 1;
				max-width: 120px;
				min-width: 80px;
				touch-action: manipulation;
			}

			.file-input-button {
				padding: 12px;
				background: #6c757d;
				color: white;
				border-radius: 6px;
				position: relative;
				z-index: 0;
				display: flex;
				justify-content: center;
				align-items: center;
				width: 100%;
				border: 1px solid #dee2e6;
				transition:
					background 0.2s,
					border-color 0.2s;/
			}

			.file-input-button:active {
				background: #5a6268;
				border-color: #5a6268;
			}

			.file-input-wrapper:active .file-input-button {
				transform: scale(0.98);
			}


			.file-input-button svg {
				width: 24px;
				height: 24px;
				fill: white;
			}

			.file-input-button:hover {
				background: #5a6268;
			}

			/* 禁用状态 */
			#fileInput:disabled+.file-input-button {
				opacity: 0.6;
				pointer-events: none;
			}

			/* 新增的 Tab 按钮样式 */
			.tab-buttons {
				display: flex;
				gap: 6px;
				margin-bottom: 12px;
				border-bottom: 2px solid #e3e8ee;
				padding-bottom: 8px;
			}

			.tab-button {
				padding: 8px 16px;
				border: none;
				border-radius: 4px 4px 0 0;
				background: #f0f2f5;
				color: #495057;
				font-size: 14px;
				cursor: pointer;
				transition: all 0.2s;
				position: relative;
				bottom: -2px;
			}

			.tab-button.active {
				background: white;
				color: #007bff;
				border-bottom: 3px solid #007bff;
				font-weight: 500;
			}

			.tab-button:hover:not(.active) {
				background: #e9ecef;
				color: #212529;
			}
		</style>
	</head>
	<body>
		<div class="container">
			<textarea id="output" readonly placeholder="结果将显示在这里..."></textarea>
			<!-- 按钮容器 -->
			<div class="tab-buttons">
				<button id="chat" class="tab-button active" data-target="chat">💬 聊天</button>
				<button id="generate" class="tab-button" data-target="generate">🔍 查询</button>
			</div>
			<div class="input-group">
				<textarea id="input" rows="2" placeholder="输入命令(/clear 清空)Shift+Enter换行"></textarea>
				<div class="button-row">
					<button onclick="sendMessage()">发送</button>
					<button class="icon-button" onclick="playSound(this)">
						<svg class="on-icon" viewBox="0 0 24 24">
							<path fill="currentColor"
								d="M15 3v18l-5-4H4V7h6l5-4zm3.5 5.5c1-1 2.5-1.5 4-1.5v3c-.6 0-1.2.2-1.7.5l-2.3-2zm2.3 7.7c.8-.6 1.5-1.5 1.9-2.7h-3c-.1.5-.3 1-.7 1.4l1.8 1.3z" />
						</svg>

						<svg class="off-icon" viewBox="0 0 24 24">
							<path fill="currentColor"
								d="M15 3v18l-5-4H4V7h6l5-4zm7.1 14.7l-1.4-1.4-3.6-3.6-3.6 3.6-1.4-1.4 3.6-3.6-3.6-3.6 1.4-1.4 3.6 3.6 3.6-3.6 1.4 1.4-3.6 3.6 3.6 3.6z" />
						</svg>
					</button>
					<div class="file-input-wrapper">
						<button class="file-input-button">
							<svg viewBox="0 0 24 24">
								<path fill="currentColor"
									d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11zm-3-9H7v-2h8v2zm-8 4h8v-2H7v2z" />
							</svg>
						</button>
						<input type="file" id="fileInput" accept=".txt" />
					</div>
				</div>
			</div>
		</div>

		<script>
			if (localStorage.getItem('baseURL') === null) {
				localStorage.setItem('baseURL', 'http://127.0.0.1:11434');
			}
			if (localStorage.getItem('baseModel') === null) {
				localStorage.setItem('baseModel', 'modelscope.cn/modelscope/Qwen2.5-7B-Instruct-1M-GGUF:latest');
			}
			const outputDiv = document.getElementById('output');
			let baseURL = localStorage.getItem('baseURL');
			let baseModel = localStorage.getItem('baseModel');
			let isSpeaking = false;
			let isSending = null;
			let currentUtterance = null;
			let fullText = null;

			document.addEventListener("DOMContentLoaded", function() {
				document.addEventListener("keydown", function(event) {
					if (event.key === "Enter") {
						sendMessage();
						event.preventDefault();
					}
				});
			});

			let targetId = 'chat';
			const buttons = document.querySelectorAll('.tab-button');
			buttons.forEach(button => {
				button.addEventListener('click', function() {
					buttons.forEach(btn => btn.classList.remove('active'));
					this.classList.add('active');
					targetId = this.dataset.target;
					document.title = `AI-${targetId === 'chat' ? "聊天" : "查询"}模式`;
					console.log('当前选项卡:', targetId);
					document.querySelectorAll('.content').forEach(content => {
						content.classList.remove('active');
					});
					if (isSending === null) {
						document.getElementById(targetId).classList.add('active');
						outputDiv.value = '';
						if (targetId === 'chat') {
							let i = 1;
							while (i < messages.length) {
								outputDiv.value +=
									`\n\n您:${messages[i]['content']}\n\nAI:${messages[i+1]['content']}`;
								i += 2;
							}
							outputDiv.scrollTop = outputDiv.scrollHeight;
						}
					}
				});
			});

			// 可选:添加键盘导航支持Alt+1
			document.addEventListener('keydown', (e) => {
				if (e.altKey) {
					if (e.key === '1') document.querySelector('[data-target="chat"]').click();
					if (e.key === '2') document.querySelector('[data-target="generate"]').click();
				}
			});

			function playSound(btn) {
				return new Promise((resolve) => { // 返回 Promise
					const content = outputDiv.value.split('AI:');
					const aiText = content[content.length - 1]?.trim() || '';
					if (!isSpeaking) {
						if (content) {
							// 创建语音实例
							currentUtterance = new SpeechSynthesisUtterance(aiText);
							currentUtterance.lang = 'zh-CN';

							// 语音结束回调
							currentUtterance.onend = () => {
								isSpeaking = false;
								btn.classList.toggle('active');
								resolve(); // 异步完成,通知外部
							};
							// 处理播放错误
							currentUtterance.onerror = () => {
								isSpeaking = false;
								btn.classList.remove('active');
								resolve();
							};

							window.speechSynthesis.speak(currentUtterance);
							isSpeaking = true;
							btn.classList.toggle('active');
						} else {
							resolve(); // 无内容时直接 resolve
						}
					} else {
						window.speechSynthesis.cancel();
						isSpeaking = false;
						btn.classList.toggle('active');
						resolve(); // 异步完成,通知外部
					}
				});
			}

			// 添加 HTML 转义函数
			const sanitizeHTML = (str) => {
				const div = document.createElement('div');
				div.textContent = str.replace(/\s/g, '');
				return div.innerHTML;
			};

			let data = null;
			let messages = [{
				role: "system",
				content: "You are a warm-hearted assistant, and you only speak Chinese."
			}];
			if (localStorage.getItem('messages') !== null) {
				messages = JSON.parse(localStorage.getItem("messages")) || [];
				document.querySelector('[data-target="chat"]').click();
			}

			function sendMessage() {
				let input = document.getElementById('input').value;
				document.getElementById('input').value = '';
				if (isSending) {
					alert('请勿打扰,思考中。。。。。。');
					return;
				}
				if (input.trim() === '/clear') {
					outputDiv.value = '';
					messages.splice(1);
					localStorage.setItem("messages", JSON.stringify(messages));
					fullText = '';
					return;
					//localStorage.removeItem('user_theme');
					//localStorage.clear();
				}
				if (input.trim().slice(0, 4) === '/url') {
					const urlRegex =
						/^https?:\/\/((1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|\d)\.){3}(1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|\d)(:\d+)?(\/.*)?$/;
					if (urlRegex.test((input.trim().slice(4)).trim())) {
						localStorage.setItem('baseURL', (input.trim().slice(4)).trim());
						baseURL = localStorage.getItem('baseURL');
					} else {
						alert('请按下面格式输入。\n输入命令(/url http://127.0.0.1:11434)');
					}
					return;
				}
				if (input.trim().slice(0, 6) === '/model') {
					if (input.trim().length > 6) {
						localStorage.setItem('baseModel', (input.trim().slice(6)).trim());
						baseModel = localStorage.getItem('baseModel');
					} else {
						alert('请按下面格式输入。\n输入命令(/model deepseek-r1:8b)');
					}
					return;
				}
				if (input.trim().length === 0) {
					alert('输入为空!');
					return;
				}
				if (input.trim().slice(0, 1) === '/' || input.trim() === '/help') {
					alert('下面是常用命令。\n清空 /clear\nIP:PORT /url http://127.0.0.1:11434\n模型 /model deepseek-r1:8b');
					return;
				}
				outputDiv.value += `\n\n您:${sanitizeHTML(input)}\n\nAI:\n`;
				outputDiv.scrollTop = outputDiv.scrollHeight;
				isSending = targetId;
				if (isSending === 'chat') {
					messages.push({
						role: "user",
						content: input
					});
					data = {
						model: baseModel,
						messages: messages,
						stream: true
					};
				} else {
					if (typeof fullText === 'string' && fullText.trim().length > 0) {
						input = `基于以下上下文:\n${fullText}\n\n请回答:${input}`;
						console.log(input);
					}
					data = {
						model: baseModel,
						prompt: input
					};
				}
				const url = `${baseURL}/api/${isSending}`;
				console.log(url);
				fetch(url, {
						method: 'POST',
						headers: {
							'Content-Type': 'application/json'
						},
						body: JSON.stringify(data),
						mode: 'cors'
					})
					.then(response => {
						if (!response.ok) throw new Error(`HTTP错误: ${response.status}`);

						const reader = response.body.getReader();
						const decoder = new TextDecoder('utf-8');
						let prevChunk = '';

						function processChunk({
							done,
							value
						}) {
							if (done) {
								// 处理流结束后的剩余数据
								if (prevChunk) {
									try {
										const parsed = JSON.parse(prevChunk);
										appendToOutput(`${targetId === 'chat' ? parsed.message.content : parsed.response}`);
									} catch (e) {
										appendToOutput(`解析错误: ${e.message}`, true);
									}
								}
								return; // 结束递归
							}

							const chunk = decoder.decode(value, {
								stream: true
							});
							prevChunk += chunk;
							const lines = prevChunk.split('\n');
							prevChunk = lines.pop() || ''; // 保存未完成的行

							for (const line of lines) {
								try {
									const parsed = JSON.parse(line);
									appendToOutput(`${isSending === 'chat' ? parsed.message.content : parsed.response}`);
								} catch (e) {
									appendToOutput(`解析错误: ${e.message}`, true);
								}
							}

							// 继续读取下一个chunk
							return reader.read().then(processChunk);
						}

						// 辅助函数:更新界面并滚动
						function appendToOutput(text, isError = false) {
							outputDiv.value += text + (isError ? '\n' : '');
							outputDiv.scrollTop = outputDiv.scrollHeight;
						}

						// 开始读取流
						return reader.read().then(processChunk);
					})
					.then(() => {
						console.log('流式处理完成');
						if (targetId === 'chat') {
							if (outputDiv.value.trim()) {
								const content = outputDiv.value.split('AI:');
								messages.push({
									role: "assistant",
									content: content[content.length - 1]
								});
								if (messages.length > 21) messages.splice(1, 2);
							}
							localStorage.setItem("messages", JSON.stringify(messages));
							document.querySelector('.icon-button').click();
						}
						isSending = null;
					})
					.catch(error => {
						messages.splice(-1, 1);
						outputDiv.value += `错误:${error.message}\n`;
						outputDiv.scrollTop = outputDiv.scrollHeight;
						isSending = null;
					});
			}
			// 添加到JavaScript中
			document.getElementById('fileInput').addEventListener('change', function(e) {
				const file = event.target.files[0];
				if (file && file.name.endsWith('.txt')) {
					fileName = file.name;
					const reader = new FileReader();

					reader.readAsText(file, 'UTF-8');
					reader.onload = (e) => {
						const utf8Text = e.target.result;
						if (!/[\\u4E00-\\u9FA5]/.test(utf8Text)) { // 检测是否存在中文
							reader.readAsText(file, 'GBK'); // 尝试 GBK 编码
							reader.onload = (e) => {
								splitText(e);
							};
						} else {
							splitText(e);
						}
					};
				} else {
					alert('请选择一个有效的 .txt 文件。');
				}
			});

			function splitText(e) {
				fullText = e.target.result;
				console.log(fullText);
			}
		</script>
	</body>
</html>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值