环境变量设置如下:
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>