Files
inventory/templates/ai_chat.html

544 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[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>