Files
inventory/templates/ai_chat.html
wangbeihong 21ad22a105 特性:添加支持数据库查询的 AI 聊天功能
- 实现了一个新的 AI 聊天页面,用于自然语言查询,该页面会生成用于库存数据的只读 SQL 查询。
- 添加了本地内存存储,用于用户交互,允许 AI 记住最近的对话和笔记。
- 增强了聊天界面,增加了网络搜索和数据库查询执行选项。
- 更新了 README,包含了关于新 AI 聊天功能和其使用方法的详细信息。
- 引入了新的 CSS 样式以改善聊天界面的用户体验。
- 修改了现有模板以集成新的聊天功能,并提供从库存概览页面轻松访问。
2026-03-14 01:34:29 +08:00

327 lines
15 KiB
HTML
Raw 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') }}">
</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">
<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 history = [];
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 renderMarkdown(text) {
var source = String(text || '').replace(/\r\n?/g, '\n');
var codeBlocks = [];
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;
});
var escaped = escapeHtml(source);
var lines = escaped.split('\n');
var htmlParts = [];
var paragraph = [];
var inList = false;
function flushParagraph() {
if (!paragraph.length) {
return;
}
htmlParts.push('<p>' + renderInlineMarkdown(paragraph.join('<br>')) + '</p>');
paragraph = [];
}
function closeList() {
if (inList) {
htmlParts.push('</ul>');
inList = false;
}
}
lines.forEach(function (line) {
var trimmed = line.trim();
if (!trimmed) {
flushParagraph();
closeList();
return;
}
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 + '>');
return;
}
var listItem = trimmed.match(/^[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
if (!inList) {
htmlParts.push('<ul>');
inList = true;
}
htmlParts.push('<li>' + renderInlineMarkdown(listItem[1]) + '</li>');
return;
}
closeList();
paragraph.push(trimmed);
});
flushParagraph();
closeList();
var html = htmlParts.join('');
codeBlocks.forEach(function (block, index) {
html = html.replace('@@CODEBLOCK_' + 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);
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>';
}
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);
if (useDb && useWeb) {
statusNode.textContent = 'AI 正在查询数据库并联网补充,请稍候...';
} else if (useDb) {
statusNode.textContent = 'AI 正在查询数据库,请稍候...';
} else if (useWeb) {
statusNode.textContent = 'AI 正在通用问答并联网补充,请稍候...';
} else {
statusNode.textContent = 'AI 正在通用问答,请稍候...';
}
appendMessage('user', '你', question, '');
fetch('{{ url_for("ai_chat_query") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: question,
history: toHistoryPayload(),
allow_web_search: useWeb,
allow_db_query: useDb
})
})
.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;
}
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 || []);
appendMessage('assistant', 'AI助手', data.answer || '已完成查询。', extra);
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;
});
});
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>