feat: 添加聊天最大输出 Token 和流式输出选项,优化 AI 聊天体验
This commit is contained in:
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
低库存阈值
|
||||
|
||||
Reference in New Issue
Block a user