特性:添加支持数据库查询的 AI 聊天功能

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

326
templates/ai_chat.html Normal file
View File

@@ -0,0 +1,326 @@
<!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>

View File

@@ -92,6 +92,7 @@
<div class="ai-panel-head">
<h2>AI补货建议</h2>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('ai_chat_page') }}">聊天</a>
<a class="btn btn-light" href="{{ url_for('system_logs_page') }}">日志</a>
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">参数</a>
</div>