544 lines
25 KiB
HTML
544 lines
25 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<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">
|
||
<div>
|
||
<h1>AI数据库聊天</h1>
|
||
<p>用自然语言提问,系统会生成只读 SQL 查询库存数据并给出结论</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">AI参数</a>
|
||
<a class="btn btn-light" href="{{ url_for('system_logs_page') }}">系统日志</a>
|
||
{% include '_account_menu.html' %}
|
||
</div>
|
||
</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">
|
||
<p class="hint" id="ai-chat-status">提示:默认是通用聊天 + 联网补充;勾选“启用数据库查询”后再做库存数据分析。</p>
|
||
<textarea id="ai-chat-input" class="batch-input" rows="4" placeholder="请输入问题,例如:低库存里哪些器件近30天出库最快?"></textarea>
|
||
<label>
|
||
<input type="checkbox" id="ai-chat-web-search" checked>
|
||
允许联网补充(回答中会区分“数据库结论”和“联网参考”)
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="ai-chat-db-query">
|
||
启用数据库查询(不勾选则不执行 SQL,仅通用问答)
|
||
</label>
|
||
<div class="actions">
|
||
<button class="btn" id="ai-chat-send" type="button">发送</button>
|
||
<button class="btn btn-light" id="ai-chat-clear" type="button">清空会话</button>
|
||
<button class="btn btn-light" id="ai-chat-clear-memory" type="button">清空本地记忆</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
(function () {
|
||
var sendBtn = document.getElementById('ai-chat-send');
|
||
var clearBtn = document.getElementById('ai-chat-clear');
|
||
var clearMemoryBtn = document.getElementById('ai-chat-clear-memory');
|
||
var input = document.getElementById('ai-chat-input');
|
||
var webSearchToggle = document.getElementById('ai-chat-web-search');
|
||
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) {
|
||
return ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[ch];
|
||
});
|
||
}
|
||
|
||
function renderInlineMarkdown(escapedText) {
|
||
var html = String(escapedText || '');
|
||
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
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');
|
||
var body = escapeHtml(code || '');
|
||
var token = '@@CODEBLOCK_' + codeBlocks.length + '@@';
|
||
codeBlocks.push('<pre class="md-code"><code class="lang-' + language + '">' + body + '</code></pre>');
|
||
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;
|
||
}
|
||
htmlParts.push('<p>' + renderInlineMarkdown(paragraph.join('<br>')) + '</p>');
|
||
paragraph = [];
|
||
}
|
||
|
||
function closeList() {
|
||
if (inList) {
|
||
htmlParts.push('</ul>');
|
||
inList = false;
|
||
}
|
||
}
|
||
|
||
var i = 0;
|
||
while (i < lines.length) {
|
||
var line = lines[i];
|
||
var trimmed = line.trim();
|
||
if (!trimmed) {
|
||
flushParagraph();
|
||
closeList();
|
||
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+(.+)$/);
|
||
if (heading) {
|
||
flushParagraph();
|
||
closeList();
|
||
var level = heading[1].length;
|
||
htmlParts.push('<h' + level + '>' + renderInlineMarkdown(heading[2]) + '</h' + level + '>');
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
var listItem = trimmed.match(/^[-*]\s+(.+)$/);
|
||
if (listItem) {
|
||
flushParagraph();
|
||
if (!inList) {
|
||
htmlParts.push('<ul>');
|
||
inList = true;
|
||
}
|
||
htmlParts.push('<li>' + renderInlineMarkdown(listItem[1]) + '</li>');
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
closeList();
|
||
paragraph.push(trimmed);
|
||
i += 1;
|
||
}
|
||
|
||
flushParagraph();
|
||
closeList();
|
||
|
||
var html = htmlParts.join('');
|
||
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>';
|
||
}
|
||
|
||
function appendMessage(role, title, content, extraHtml) {
|
||
var card = document.createElement('article');
|
||
card.className = 'ai-chat-item ' + role;
|
||
var bodyHtml = role === 'assistant'
|
||
? '<div class="md-content">' + renderMarkdown(content) + '</div>'
|
||
: '<p>' + escapeHtml(content) + '</p>';
|
||
card.innerHTML =
|
||
'<h3>' + escapeHtml(title) + '</h3>' +
|
||
bodyHtml +
|
||
(extraHtml || '');
|
||
messagesNode.appendChild(card);
|
||
if (role === 'assistant') {
|
||
renderMathInNode(card.querySelector('.md-content'));
|
||
}
|
||
messagesNode.scrollTop = messagesNode.scrollHeight;
|
||
}
|
||
|
||
function toHistoryPayload() {
|
||
return history.slice(-8).map(function (item) {
|
||
return { role: item.role, content: item.content };
|
||
});
|
||
}
|
||
|
||
function renderWebContextHtml(contexts) {
|
||
if (!Array.isArray(contexts) || !contexts.length) {
|
||
return '';
|
||
}
|
||
|
||
var blocks = contexts.map(function (ctx) {
|
||
var query = escapeHtml(ctx.query || '');
|
||
var sources = Array.isArray(ctx.sources) ? ctx.sources : [];
|
||
var itemsHtml = sources.map(function (src) {
|
||
var title = escapeHtml(src.title || '未命名来源');
|
||
var snippet = escapeHtml(src.snippet || '');
|
||
var url = escapeHtml(src.url || '');
|
||
var badgeLabel = escapeHtml(src.reliability_label || '中可信');
|
||
var badgeLevel = escapeHtml(src.reliability_level || 'medium');
|
||
var badgeClass = 'source-' + badgeLevel;
|
||
var reason = escapeHtml(src.reliability_reason || '来源类型未知,建议人工确认');
|
||
var domain = escapeHtml(src.domain || '-');
|
||
var linkHtml = url ? '<a href="' + url + '" target="_blank" rel="noopener noreferrer">查看来源</a>' : '';
|
||
|
||
return (
|
||
'<li>' +
|
||
'<div class="source-head">' +
|
||
'<strong>' + title + '</strong>' +
|
||
'<span class="source-badge ' + badgeClass + '">' + badgeLabel + '</span>' +
|
||
'</div>' +
|
||
'<p>' + snippet + '</p>' +
|
||
'<p>依据: ' + reason + ' | 域名: ' + domain + '</p>' +
|
||
linkHtml +
|
||
'</li>'
|
||
);
|
||
}).join('');
|
||
|
||
return (
|
||
'<section class="panel">' +
|
||
'<h4>检索词: ' + query + '</h4>' +
|
||
'<ul class="ai-source-items">' + itemsHtml + '</ul>' +
|
||
'</section>'
|
||
);
|
||
}).join('');
|
||
|
||
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) {
|
||
statusNode.textContent = '请输入问题后再发送。';
|
||
return;
|
||
}
|
||
|
||
sendBtn.disabled = true;
|
||
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 = streamEnabled
|
||
? 'AI 正在通用问答并联网补充(流式输出中)...'
|
||
: 'AI 正在通用问答并联网补充,请稍候...';
|
||
} else {
|
||
statusNode.textContent = streamEnabled
|
||
? 'AI 正在通用问答(流式输出中)...'
|
||
: 'AI 正在通用问答,请稍候...';
|
||
}
|
||
appendMessage('user', '你', question, '');
|
||
|
||
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(requestBody)
|
||
})
|
||
.then(function (resp) {
|
||
if (!resp.ok || !resp.body) {
|
||
throw new Error('HTTP ' + resp.status);
|
||
}
|
||
var reader = resp.body.getReader();
|
||
var decoder = new TextDecoder();
|
||
|
||
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();
|
||
});
|
||
}
|
||
return pump();
|
||
})
|
||
.catch(function () {
|
||
if (!streamFinished) {
|
||
contentDiv.innerHTML = '<p>网络请求失败,请稍后重试。</p>';
|
||
statusNode.textContent = '提问失败';
|
||
}
|
||
})
|
||
.finally(function () {
|
||
var cursor = contentDiv.querySelector('.ai-typing-cursor');
|
||
if (cursor) cursor.remove();
|
||
sendBtn.disabled = false;
|
||
});
|
||
});
|
||
|
||
clearBtn.addEventListener('click', function () {
|
||
history = [];
|
||
messagesNode.innerHTML = '';
|
||
input.value = '';
|
||
statusNode.textContent = '会话已清空。';
|
||
});
|
||
|
||
if (clearMemoryBtn) {
|
||
clearMemoryBtn.addEventListener('click', function () {
|
||
clearMemoryBtn.disabled = true;
|
||
fetch('{{ url_for("ai_chat_memory_clear") }}', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: '{}'
|
||
})
|
||
.then(function (resp) {
|
||
return resp.json().then(function (data) {
|
||
return { ok: resp.ok, data: data || {} };
|
||
});
|
||
})
|
||
.then(function (result) {
|
||
if (!result.ok || !result.data.ok) {
|
||
statusNode.textContent = '清空本地记忆失败';
|
||
return;
|
||
}
|
||
statusNode.textContent = '本地记忆已清空。';
|
||
})
|
||
.catch(function () {
|
||
statusNode.textContent = '清空本地记忆失败';
|
||
})
|
||
.finally(function () {
|
||
clearMemoryBtn.disabled = false;
|
||
});
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|