feat: QYLAW 法律法规知识库
- 语义检索(FAISS + embedding)+ 精确查找(法规名+条号) - RAG 问答(SSE 流式,支持 thinking 折叠显示) - 法规浏览(原文阅读) - 历史记录(检索+对话持久化到 SQLite) - 设置页(系统提示词/模板/LLM 参数可配置) - 检索质量评估脚本 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* history.js — 历史记录页(检索历史 + 对话历史)
|
||||
*/
|
||||
const HistoryPage = {
|
||||
state: {
|
||||
tab: 'chat', // 'chat' 或 'search'
|
||||
chatPage: 1,
|
||||
chatPageSize: 20,
|
||||
chatData: null,
|
||||
searchPage: 1,
|
||||
searchPageSize: 20,
|
||||
searchData: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
detailChat: null, // 查看详情的对话
|
||||
},
|
||||
|
||||
render() {
|
||||
const tab = this.state.tab;
|
||||
return `
|
||||
<div class="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<h2 class="text-lg font-semibold mb-4">历史记录</h2>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button onclick="HistoryPage.setTab('chat')"
|
||||
class="px-4 py-2 text-sm rounded-lg ${tab === 'chat' ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}">
|
||||
对话历史
|
||||
</button>
|
||||
<button onclick="HistoryPage.setTab('search')"
|
||||
class="px-4 py-2 text-sm rounded-lg ${tab === 'search' ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'}">
|
||||
检索历史
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button onclick="HistoryPage.clearAll()"
|
||||
class="px-3 py-2 text-xs text-rose-500 hover:bg-rose-50 rounded-lg">
|
||||
清空${tab === 'chat' ? '对话' : '检索'}历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div id="history-content">
|
||||
${this._renderLoading()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadList();
|
||||
},
|
||||
|
||||
_renderLoading() {
|
||||
return `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载中...</span></div>`;
|
||||
},
|
||||
|
||||
setTab(tab) {
|
||||
this.state.tab = tab;
|
||||
this.state.error = null;
|
||||
this.state.detailChat = null;
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = this.render();
|
||||
this.loadList();
|
||||
},
|
||||
|
||||
async loadList() {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
const el = document.getElementById('history-content');
|
||||
if (el) el.innerHTML = this._renderLoading();
|
||||
|
||||
try {
|
||||
if (this.state.tab === 'chat') {
|
||||
const resp = await API.get(`/api/history/chat?page=${this.state.chatPage}&page_size=${this.state.chatPageSize}`);
|
||||
this.state.chatData = resp.data;
|
||||
} else {
|
||||
const resp = await API.get(`/api/history/search?page=${this.state.searchPage}&page_size=${this.state.searchPageSize}`);
|
||||
this.state.searchData = resp.data;
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
this.state.loading = false;
|
||||
this.state.error = e.message;
|
||||
this.renderList();
|
||||
}
|
||||
},
|
||||
|
||||
renderList() {
|
||||
const el = document.getElementById('history-content');
|
||||
if (!el) return;
|
||||
|
||||
if (this.state.error) {
|
||||
el.innerHTML = UI.errorState(this.state.error, 'HistoryPage.loadList()');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.tab === 'chat') {
|
||||
el.innerHTML = this._renderChatList();
|
||||
} else {
|
||||
el.innerHTML = this._renderSearchList();
|
||||
}
|
||||
},
|
||||
|
||||
_renderChatList() {
|
||||
const data = this.state.chatData;
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
return UI.emptyState('暂无对话历史', '在智能问答页提问后会自动记录');
|
||||
}
|
||||
|
||||
const items = data.items.map(item => {
|
||||
const tplLabel = { simple: '通俗解释', professional: '专业分析', compare: '对比条文' }[item.template] || item.template;
|
||||
const statusBadge = item.status === 'error'
|
||||
? '<span class="text-xs text-rose-500">错误</span>'
|
||||
: item.status === 'streaming'
|
||||
? '<span class="text-xs text-amber-500">生成中</span>'
|
||||
: '';
|
||||
const answerPreview = item.answer
|
||||
? item.answer.substring(0, 120).replace(/\n/g, ' ') + (item.answer.length > 120 ? '...' : '')
|
||||
: '<span class="text-gray-400 italic">无答案</span>';
|
||||
const citationCount = item.citations ? item.citations.length : 0;
|
||||
|
||||
return `
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-3 hover:border-gray-300 transition">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span class="text-xs text-gray-400">${item.created_at}</span>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">${tplLabel}</span>
|
||||
${item.thinking_enabled ? '<span class="px-1.5 py-0.5 text-xs rounded bg-indigo-50 text-indigo-600">Thinking</span>' : ''}
|
||||
${statusBadge}
|
||||
${citationCount ? `<span class="text-xs text-gray-400">引用 ${citationCount} 条</span>` : ''}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900 mb-1">${this._escapeHtml(item.question)}</div>
|
||||
<div class="text-xs text-gray-500 leading-relaxed">${this._escapeHtml(answerPreview)}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 shrink-0">
|
||||
<button onclick="HistoryPage.viewChat(${item.id})"
|
||||
class="px-2 py-1 text-xs text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded">查看</button>
|
||||
<button onclick="HistoryPage.deleteChat(${item.id})"
|
||||
class="px-2 py-1 text-xs text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const pagination = UI.pagination(
|
||||
data.page, data.page_size, data.total,
|
||||
'HistoryPage.changeChatPage'
|
||||
);
|
||||
|
||||
return items + pagination;
|
||||
},
|
||||
|
||||
_renderSearchList() {
|
||||
const data = this.state.searchData;
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
return UI.emptyState('暂无检索历史', '在语义检索页查询后会自动记录');
|
||||
}
|
||||
|
||||
const items = data.items.map(item => {
|
||||
const filters = [];
|
||||
if (item.category) filters.push(UI.categoryBadge(item.category));
|
||||
if (item.province) filters.push(`<span class="text-xs text-gray-400">[${item.province}]</span>`);
|
||||
return `
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-3 mb-2 hover:border-gray-300 transition">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="text-xs text-gray-400">${item.created_at}</span>
|
||||
${filters.join('')}
|
||||
<span class="text-xs text-gray-400">${item.result_count} 条结果</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-900 truncate">${this._escapeHtml(item.query)}</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<button onclick="HistoryPage.replaySearch(${JSON.stringify(item).replace(/"/g, '"')})"
|
||||
class="px-2 py-1 text-xs text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded">重查</button>
|
||||
<button onclick="HistoryPage.deleteSearch(${item.id})"
|
||||
class="px-2 py-1 text-xs text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const pagination = UI.pagination(
|
||||
data.page, data.page_size, data.total,
|
||||
'HistoryPage.changeSearchPage'
|
||||
);
|
||||
|
||||
return items + pagination;
|
||||
},
|
||||
|
||||
changeChatPage(page) {
|
||||
this.state.chatPage = page;
|
||||
this.loadList();
|
||||
},
|
||||
|
||||
changeSearchPage(page) {
|
||||
this.state.searchPage = page;
|
||||
this.loadList();
|
||||
},
|
||||
|
||||
async viewChat(id) {
|
||||
try {
|
||||
const resp = await API.get(`/api/history/chat/${id}`);
|
||||
this.state.detailChat = resp.data;
|
||||
this._renderChatDetail();
|
||||
} catch (e) {
|
||||
this.state.error = e.message;
|
||||
this.renderList();
|
||||
}
|
||||
},
|
||||
|
||||
_renderChatDetail() {
|
||||
const chat = this.state.detailChat;
|
||||
if (!chat) return;
|
||||
|
||||
const tplLabel = { simple: '通俗解释', professional: '专业分析', compare: '对比条文' }[chat.template] || chat.template;
|
||||
const thinkingHtml = chat.thinking
|
||||
? `<details class="mb-4 group">
|
||||
<summary class="cursor-pointer flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 py-2">
|
||||
<svg class="w-3 h-3 transition-transform group-open:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<span>Thinking 思考过程</span>
|
||||
<span class="text-gray-300">${chat.thinking.length} 字</span>
|
||||
</summary>
|
||||
<div class="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-100 max-h-96 overflow-y-auto scroll-thin">
|
||||
<pre class="text-xs text-gray-500 whitespace-pre-wrap font-mono leading-relaxed">${this._escapeHtml(chat.thinking)}</pre>
|
||||
</div>
|
||||
</details>`
|
||||
: '';
|
||||
const answerHtml = chat.answer
|
||||
? `<div class="md-content text-sm leading-relaxed">${marked.parse(chat.answer)}</div>`
|
||||
: '<div class="text-sm text-gray-400 italic">无答案</div>';
|
||||
const citationsHtml = chat.citations && chat.citations.length
|
||||
? `<div class="mt-6 pt-4 border-t border-gray-200"><div class="text-xs font-semibold text-gray-500 mb-3">引用条文(${chat.citations.length})</div>${chat.citations.map(c => UI.clauseCard(c)).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const el = document.getElementById('history-content');
|
||||
el.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<button onclick="HistoryPage.closeDetail()"
|
||||
class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span class="text-xs text-gray-400">${chat.created_at}</span>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">${tplLabel}</span>
|
||||
${chat.thinking_enabled ? '<span class="px-1.5 py-0.5 text-xs rounded bg-indigo-50 text-indigo-600">Thinking</span>' : ''}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-900 mb-3 pb-3 border-b border-gray-100">${this._escapeHtml(chat.question)}</div>
|
||||
<div class="text-xs font-semibold text-gray-500 mb-3">AI 回答</div>
|
||||
${thinkingHtml}
|
||||
${answerHtml}
|
||||
${citationsHtml}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
closeDetail() {
|
||||
this.state.detailChat = null;
|
||||
this.renderList();
|
||||
},
|
||||
|
||||
async deleteChat(id) {
|
||||
if (!confirm('确认删除这条对话历史?')) return;
|
||||
try {
|
||||
await fetch(`/api/history/chat/${id}`, { method: 'DELETE' });
|
||||
this.loadList();
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSearch(id) {
|
||||
if (!confirm('确认删除这条检索历史?')) return;
|
||||
try {
|
||||
await fetch(`/api/history/search/${id}`, { method: 'DELETE' });
|
||||
this.loadList();
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
const type = this.state.tab === 'chat' ? '对话' : '检索';
|
||||
if (!confirm(`确认清空全部${type}历史?此操作不可恢复。`)) return;
|
||||
try {
|
||||
const url = this.state.tab === 'chat' ? '/api/history/chat' : '/api/history/search';
|
||||
await fetch(url, { method: 'DELETE' });
|
||||
this.loadList();
|
||||
} catch (e) {
|
||||
alert('清空失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
replaySearch(item) {
|
||||
// 跳转到检索页并填充查询条件
|
||||
navigate('search');
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('search-input');
|
||||
if (input) input.value = item.query;
|
||||
if (SearchPage.state) {
|
||||
SearchPage.state.query = item.query;
|
||||
SearchPage.state.category = item.category || '';
|
||||
SearchPage.state.province = item.province || '';
|
||||
if (item.category) {
|
||||
const sel = document.getElementById('search-category');
|
||||
if (sel) sel.value = item.category;
|
||||
}
|
||||
if (item.province) {
|
||||
const sel = document.getElementById('search-province');
|
||||
if (sel) sel.value = item.province;
|
||||
}
|
||||
}
|
||||
// 自动触发检索
|
||||
if (SearchPage.submit) SearchPage.submit();
|
||||
}, 100);
|
||||
},
|
||||
|
||||
_escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user