feat: 添加聊天最大输出 Token 和流式输出选项,优化 AI 聊天体验

This commit is contained in:
2026-03-14 12:48:31 +08:00
parent 21ad22a105
commit f97fad81e6
5 changed files with 694 additions and 50 deletions

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI数据库聊天</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
</head>
<body>
<header class="hero slim">
@@ -21,6 +24,7 @@
</header>
<main class="container">
<div id="ai-chat-config" data-stream-enabled="{{ '1' if settings.chat_stream_enabled else '0' }}" hidden></div>
<section class="panel ai-chat-shell">
<div class="ai-chat-messages" id="ai-chat-messages"></div>
<div class="ai-chat-form-wrap">
@@ -53,7 +57,9 @@
var dbQueryToggle = document.getElementById('ai-chat-db-query');
var statusNode = document.getElementById('ai-chat-status');
var messagesNode = document.getElementById('ai-chat-messages');
var configNode = document.getElementById('ai-chat-config');
var history = [];
var streamEnabled = !!(configNode && configNode.getAttribute('data-stream-enabled') === '1');
function escapeHtml(text) {
return String(text || '').replace(/[&<>"']/g, function (ch) {
@@ -69,9 +75,44 @@
return html;
}
function splitTableRow(line) {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(function (cell) { return cell.trim(); });
}
function isTableDelimiterLine(line) {
var cells = splitTableRow(line);
if (!cells.length) {
return false;
}
return cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderMathInNode(node) {
if (!node || typeof window.renderMathInElement !== 'function') {
return;
}
window.renderMathInElement(node, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false }
],
throwOnError: false,
strict: 'ignore',
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
});
}
function renderMarkdown(text) {
var source = String(text || '').replace(/\r\n?/g, '\n');
var codeBlocks = [];
var mathBlocks = [];
source = source.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var language = escapeHtml(lang || 'text');
@@ -81,12 +122,68 @@
return token;
});
source = source.replace(/\$\$([\s\S]*?)\$\$/g, function (_, expr) {
var token = '@@MATHBLOCK_' + mathBlocks.length + '@@';
mathBlocks.push('<div class="md-math-block">$$' + escapeHtml((expr || '').trim()) + '$$</div>');
return token;
});
var escaped = escapeHtml(source);
var lines = escaped.split('\n');
var htmlParts = [];
var paragraph = [];
var inList = false;
function flushTableFrom(startIdx) {
var headerCells = splitTableRow(lines[startIdx]);
var alignCells = splitTableRow(lines[startIdx + 1]);
var tableHtml = ['<div class="md-table-wrap"><table><thead><tr>'];
headerCells.forEach(function (cell, idx) {
var align = alignCells[idx] || '';
var style = '';
if (/^:-+:$/.test(align)) {
style = ' style="text-align:center"';
} else if (/^-+:$/.test(align)) {
style = ' style="text-align:right"';
} else if (/^:-+$/.test(align)) {
style = ' style="text-align:left"';
}
tableHtml.push('<th' + style + '>' + renderInlineMarkdown(cell) + '</th>');
});
tableHtml.push('</tr></thead><tbody>');
var idx = startIdx + 2;
while (idx < lines.length) {
var rowLine = (lines[idx] || '').trim();
if (!rowLine || rowLine.indexOf('|') === -1) {
break;
}
var rowCells = splitTableRow(lines[idx]);
if (rowCells.length < 2) {
break;
}
tableHtml.push('<tr>');
rowCells.forEach(function (cell, cidx) {
var align = alignCells[cidx] || '';
var style = '';
if (/^:-+:$/.test(align)) {
style = ' style="text-align:center"';
} else if (/^-+:$/.test(align)) {
style = ' style="text-align:right"';
} else if (/^:-+$/.test(align)) {
style = ' style="text-align:left"';
}
tableHtml.push('<td' + style + '>' + renderInlineMarkdown(cell) + '</td>');
});
tableHtml.push('</tr>');
idx += 1;
}
tableHtml.push('</tbody></table></div>');
htmlParts.push(tableHtml.join(''));
return idx;
}
function flushParagraph() {
if (!paragraph.length) {
return;
@@ -102,12 +199,26 @@
}
}
lines.forEach(function (line) {
var i = 0;
while (i < lines.length) {
var line = lines[i];
var trimmed = line.trim();
if (!trimmed) {
flushParagraph();
closeList();
return;
i += 1;
continue;
}
var isTableStart = i + 1 < lines.length
&& trimmed.indexOf('|') !== -1
&& (lines[i + 1] || '').trim().indexOf('|') !== -1
&& isTableDelimiterLine(lines[i + 1]);
if (isTableStart) {
flushParagraph();
closeList();
i = flushTableFrom(i);
continue;
}
var heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
@@ -116,7 +227,8 @@
closeList();
var level = heading[1].length;
htmlParts.push('<h' + level + '>' + renderInlineMarkdown(heading[2]) + '</h' + level + '>');
return;
i += 1;
continue;
}
var listItem = trimmed.match(/^[-*]\s+(.+)$/);
@@ -127,12 +239,14 @@
inList = true;
}
htmlParts.push('<li>' + renderInlineMarkdown(listItem[1]) + '</li>');
return;
i += 1;
continue;
}
closeList();
paragraph.push(trimmed);
});
i += 1;
}
flushParagraph();
closeList();
@@ -141,6 +255,9 @@
codeBlocks.forEach(function (block, index) {
html = html.replace('@@CODEBLOCK_' + index + '@@', block);
});
mathBlocks.forEach(function (block, index) {
html = html.replace('@@MATHBLOCK_' + index + '@@', block);
});
return html || '<p></p>';
}
@@ -155,6 +272,9 @@
bodyHtml +
(extraHtml || '');
messagesNode.appendChild(card);
if (role === 'assistant') {
renderMathInNode(card.querySelector('.md-content'));
}
messagesNode.scrollTop = messagesNode.scrollHeight;
}
@@ -207,6 +327,28 @@
return '<details class="box-overview"><summary>联网来源明细</summary><div class="ai-source-list">' + blocks + '</div></details>';
}
function buildExtraHtml(data) {
var extra = '';
if (data.sql) {
extra += '<details class="box-overview"><summary>本次SQL</summary><pre class="ai-panel-content">' + escapeHtml(data.sql) + '</pre></details>';
}
if (data.planner_reason && data.chat_mode !== 'general') {
extra += '<p class="hint">SQL思路: ' + escapeHtml(data.planner_reason) + '</p>';
}
if (typeof data.row_count === 'number' && data.chat_mode !== 'general') {
extra += '<p class="hint">返回行数: ' + data.row_count + '</p>';
}
if (data.chat_mode === 'general') {
extra += '<p class="hint">模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '</p>';
} else if (data.allow_web_search) {
extra += '<p class="hint">模式: 数据库 + 联网补充</p>';
} else {
extra += '<p class="hint">模式: 仅数据库</p>';
}
extra += renderWebContextHtml(data.web_context || []);
return extra;
}
sendBtn.addEventListener('click', function () {
var question = (input.value || '').trim();
if (!question) {
@@ -218,69 +360,144 @@
input.value = '';
var useWeb = !!(webSearchToggle && webSearchToggle.checked);
var useDb = !!(dbQueryToggle && dbQueryToggle.checked);
var requestBody = {
question: question,
history: toHistoryPayload(),
allow_web_search: useWeb,
allow_db_query: useDb
};
if (useDb && useWeb) {
statusNode.textContent = 'AI 正在查询数据库并联网补充,请稍候...';
} else if (useDb) {
statusNode.textContent = 'AI 正在查询数据库,请稍候...';
} else if (useWeb) {
statusNode.textContent = 'AI 正在通用问答并联网补充,请稍候...';
statusNode.textContent = streamEnabled
? 'AI 正在通用问答并联网补充(流式输出中)...'
: 'AI 正在通用问答并联网补充,请稍候...';
} else {
statusNode.textContent = 'AI 正在通用问答,请稍候...';
statusNode.textContent = streamEnabled
? 'AI 正在通用问答(流式输出中)...'
: 'AI 正在通用问答,请稍候...';
}
appendMessage('user', '你', question, '');
fetch('{{ url_for("ai_chat_query") }}', {
if (!streamEnabled) {
fetch('{{ url_for("ai_chat_query") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(function (resp) {
return resp.json().then(function (data) {
return { ok: resp.ok, data: data || {} };
});
})
.then(function (result) {
var data = result.data || {};
if (!result.ok || !data.ok) {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', data.message || '请求失败,请稍后重试。', '');
return;
}
appendMessage('assistant', 'AI助手', data.answer || '已完成查询。', buildExtraHtml(data));
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: data.answer || '' });
statusNode.textContent = '已完成本轮查询。';
})
.catch(function () {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', '网络请求失败,请稍后重试。', '');
})
.finally(function () {
sendBtn.disabled = false;
});
return;
}
// 流式输出模式
var card = document.createElement('article');
card.className = 'ai-chat-item assistant';
var contentDiv = document.createElement('div');
contentDiv.className = 'md-content';
contentDiv.innerHTML = '<span class="ai-typing-cursor">▍</span>';
card.innerHTML = '<h3>AI助手</h3>';
card.appendChild(contentDiv);
messagesNode.appendChild(card);
messagesNode.scrollTop = messagesNode.scrollHeight;
var accumulatedText = '';
var metaData = null;
var sseBuffer = '';
var streamFinished = false;
function processSSELine(line) {
if (!line.startsWith('data: ')) return;
var dataStr = line.slice(6).trim();
if (!dataStr || dataStr === '[DONE]') return;
var evt;
try { evt = JSON.parse(dataStr); } catch (e) { return; }
if (evt.type === 'meta') {
metaData = evt;
} else if (evt.type === 'chunk') {
accumulatedText += (evt.text || '');
contentDiv.innerHTML = escapeHtml(accumulatedText) + '<span class="ai-typing-cursor">▍</span>';
messagesNode.scrollTop = messagesNode.scrollHeight;
} else if (evt.type === 'done') {
streamFinished = true;
contentDiv.innerHTML = renderMarkdown(accumulatedText || '已完成查询。');
renderMathInNode(contentDiv);
if (metaData) {
card.insertAdjacentHTML('beforeend', buildExtraHtml(metaData));
}
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: accumulatedText });
statusNode.textContent = '已完成本轮查询。';
messagesNode.scrollTop = messagesNode.scrollHeight;
} else if (evt.type === 'error') {
streamFinished = true;
contentDiv.innerHTML = '<p>' + escapeHtml(evt.message || '请求失败,请稍后重试。') + '</p>';
statusNode.textContent = '提问失败';
}
}
fetch('{{ url_for("ai_chat_stream") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: question,
history: toHistoryPayload(),
allow_web_search: useWeb,
allow_db_query: useDb
})
body: JSON.stringify(requestBody)
})
.then(function (resp) {
return resp.json().then(function (data) {
return { ok: resp.ok, data: data || {} };
});
})
.then(function (result) {
var data = result.data || {};
if (!result.ok || !data.ok) {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', data.message || '请求失败,请稍后重试。', '');
return;
if (!resp.ok || !resp.body) {
throw new Error('HTTP ' + resp.status);
}
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var extra = '';
if (data.sql) {
extra += '<details class="box-overview"><summary>本次SQL</summary><pre class="ai-panel-content">' + escapeHtml(data.sql) + '</pre></details>';
function pump() {
return reader.read().then(function (result) {
if (result.done) {
if (sseBuffer.trim()) processSSELine(sseBuffer.trim());
return;
}
sseBuffer += decoder.decode(result.value, { stream: true });
var lines = sseBuffer.split('\n');
sseBuffer = lines.pop();
lines.forEach(processSSELine);
return pump();
});
}
if (data.planner_reason && data.chat_mode !== 'general') {
extra += '<p class="hint">SQL思路: ' + escapeHtml(data.planner_reason) + '</p>';
}
if (typeof data.row_count === 'number' && data.chat_mode !== 'general') {
extra += '<p class="hint">返回行数: ' + data.row_count + '</p>';
}
if (data.chat_mode === 'general') {
extra += '<p class="hint">模式: 通用对话' + (data.allow_web_search ? ' + 联网补充' : '') + '</p>';
} else if (data.allow_web_search) {
extra += '<p class="hint">模式: 数据库 + 联网补充</p>';
} else {
extra += '<p class="hint">模式: 仅数据库</p>';
}
extra += renderWebContextHtml(data.web_context || []);
appendMessage('assistant', 'AI助手', data.answer || '已完成查询。', extra);
history.push({ role: 'user', content: question });
history.push({ role: 'assistant', content: data.answer || '' });
statusNode.textContent = '已完成本轮查询。';
return pump();
})
.catch(function () {
statusNode.textContent = '提问失败';
appendMessage('assistant', 'AI助手', '网络请求失败,请稍后重试。', '');
if (!streamFinished) {
contentDiv.innerHTML = '<p>网络请求失败,请稍后重试。</p>';
statusNode.textContent = '提问失败';
}
})
.finally(function () {
var cursor = contentDiv.querySelector('.ai-typing-cursor');
if (cursor) cursor.remove();
sendBtn.disabled = false;
});
});

View File

@@ -46,6 +46,14 @@
超时(秒)
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
</label>
<label>
聊天最大输出 Token
<input type="number" name="chat_max_tokens" min="256" value="{{ settings.chat_max_tokens }}">
</label>
<label class="full">
<input type="checkbox" name="chat_stream_enabled" value="1" {% if settings.chat_stream_enabled %}checked{% endif %}>
启用聊天流式输出(边生成边展示)
</label>
<p class="hint full">若使用较慢模型(如 GLM-5、较大推理模型生成补货建议超时可先将这里调到 60-90 秒再重试。</p>
<label>
低库存阈值