641e33b834
- 语义检索(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>
333 lines
12 KiB
JavaScript
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, '"')})"
|
|
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;
|
|
},
|
|
};
|