Files
law-kb/app/static/js/history.js
T
freedak 641e33b834 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>
2026-08-07 14:55:25 +08:00

333 lines
12 KiB
JavaScript

/**
* 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, '&quot;')})"
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;
},
};