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:
2026-08-07 14:55:25 +08:00
commit 641e33b834
39 changed files with 5254 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* app.js — 公共逻辑:路由、公共组件、API 封装
*/
// ===== 全局常量(供 rag.js / settings.js 共享) =====
const PROMPT_DEFAULTS = {
system_prompt: '你是法律助手,根据提供的法规条文回答问题,必须引用法规名和条号。',
template_simple: '你是一位耐心的法律科普助手。请根据以下法规条文,用通俗易懂的语言回答用户问题。\n要求:\n1. 避免法律术语,用日常语言解释\n2. 必须引用依据的法规名和条号,格式如"根据《XX法》第X条"\n3. 如果条文不足以回答,明确说明"根据现有法规无法完全回答"\n4. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
template_professional: '你是一位专业的法律分析助手。请根据以下法规条文,对用户问题进行专业分析。\n要求:\n1. 使用规范法律术语\n2. 必须引用依据的法规名和条号,格式如"依据《XX法》第X条"\n3. 分析条文的适用条件、法律后果\n4. 如有多条相关,对比分析\n5. 指出条文的适用边界和可能的争议点\n6. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
template_compare: '你是一位法规研究助手。请根据以下法规条文,对比分析不同法规对同一问题的规定。\n要求:\n1. 列出每条相关法规的具体规定\n2. 对比规定的异同\n3. 标注法规名、条号、发布日期\n4. 指出适用范围差异(如全国性 vs 地方性)\n5. 不要编造法规\n\n【相关法规条文】\n{context}\n\n【用户问题】\n{question}',
};
const TEMPLATES = [
{ value: 'simple', label: '通俗解释' },
{ value: 'professional', label: '专业分析' },
{ value: 'compare', label: '对比条文' },
];
// ===== 路由 =====
const PAGES = ['rag', 'search', 'browse', 'history', 'settings'];
let currentPage = 'rag';
/** 渲染指定页面(不修改 URL) */
function renderPage(page) {
if (!PAGES.includes(page)) page = 'search';
currentPage = page;
// 更新导航激活态
document.querySelectorAll('.nav-item').forEach(btn => {
if (btn.dataset.page === page) {
btn.classList.remove('nav-inactive');
btn.classList.add('nav-active');
} else {
btn.classList.remove('nav-active');
btn.classList.add('nav-inactive');
}
});
// 渲染页面
const content = document.getElementById('content');
if (page === 'search') content.innerHTML = SearchPage.render();
if (page === 'rag') content.innerHTML = RagPage.render();
if (page === 'browse') content.innerHTML = BrowsePage.render();
if (page === 'history') content.innerHTML = HistoryPage.render();
if (page === 'settings') content.innerHTML = SettingsPage.render();
// 页面初始化
if (page === 'search') SearchPage.init();
if (page === 'rag') RagPage.init();
if (page === 'browse') BrowsePage.init();
if (page === 'history') HistoryPage.init();
if (page === 'settings') SettingsPage.init();
// 关闭移动端菜单
document.getElementById('sidebar').classList.remove('open');
}
/** 导航到指定页面(修改 URL + 渲染) */
function navigate(page) {
if (!PAGES.includes(page)) return;
if (page === currentPage) return;
// 更新 URL(不触发 popstate)
history.pushState({ page }, '', `/${page}`);
renderPage(page);
}
/** 从 URL 路径解析当前页面 */
function pageFromPath() {
const path = window.location.pathname.replace(/^\//, '').replace(/\/$/, '');
return PAGES.includes(path) ? path : 'rag';
}
// 监听浏览器前进/后退
window.addEventListener('popstate', (e) => {
const page = (e.state && e.state.page) || pageFromPath();
renderPage(page);
});
// ===== 公共组件 =====
const UI = {
/** 加载中 spinner */
spinner(size = 16) {
return `<div class="spinner" style="width:${size}px;height:${size}px"></div>`;
},
/** 空状态 */
emptyState(title, desc, icon = '📋') {
return `
<div class="flex flex-col items-center justify-center py-16 text-gray-400">
<div class="text-4xl mb-3">${icon}</div>
<div class="text-sm font-medium text-gray-600">${title}</div>
<div class="text-xs mt-1">${desc}</div>
</div>`;
},
/** 错误状态 */
errorState(msg, onRetry) {
const retryBtn = onRetry ? `<button onclick="${onRetry}" class="mt-3 px-3 py-1.5 text-xs bg-rose-50 text-rose-600 rounded-lg hover:bg-rose-100">重试</button>` : '';
return `
<div class="flex flex-col items-center justify-center py-16 text-rose-500">
<div class="text-3xl mb-2">⚠️</div>
<div class="text-sm font-medium">${msg}</div>
${retryBtn}
</div>`;
},
/** 类别标签 */
categoryBadge(category) {
const colors = {
'法律': 'bg-blue-50 text-blue-700',
'行政法规': 'bg-purple-50 text-purple-700',
'监察法规': 'bg-amber-50 text-amber-700',
'司法解释': 'bg-teal-50 text-teal-700',
'地方性法规': 'bg-emerald-50 text-emerald-700',
};
const cls = colors[category] || 'bg-gray-100 text-gray-600';
return `<span class="inline-block px-2 py-0.5 text-xs rounded ${cls}">${category}</span>`;
},
/** 分页 */
pagination(page, pageSize, total, onChange) {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return '';
const prev = page > 1 ? `<button onclick="${onChange}(${page-1})" class="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">上一页</button>` : '';
const next = page < totalPages ? `<button onclick="${onChange}(${page+1})" class="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">下一页</button>` : '';
return `
<div class="flex items-center justify-between mt-4">
<div class="text-xs text-gray-500">共 ${total} 条,第 ${page}/${totalPages} 页</div>
<div class="flex gap-2">${prev}${next}</div>
</div>`;
},
/** 条文卡片 */
clauseCard(item, highlight = false) {
const badge = UI.categoryBadge(item.category);
const province = item.province ? `<span class="text-xs text-gray-400">[${item.province}${item.city ? ' · ' + item.city : ''}]</span>` : '';
const chapter = item.chapter ? `<span class="text-xs text-gray-400">${item.chapter}</span>` : '';
const clauseNo = item.clause_no ? `<span class="text-sm font-semibold text-gray-700">${item.clause_no}</span>` : '';
const score = item.score !== undefined ? `<span class="text-xs mono text-gray-400">相似度 ${item.score.toFixed(4)}</span>` : '';
const cls = highlight ? 'clause-highlight' : 'border border-gray-200 bg-white';
return `
<div class="${cls} rounded-lg p-3 mb-2">
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<span class="text-sm font-medium text-gray-900">${item.law_name}</span>
${badge}
${clauseNo}
${chapter}
${province}
${score}
</div>
<div class="text-sm text-gray-600 leading-relaxed">${item.content.substring(0, 200)}${item.content.length > 200 ? '...' : ''}</div>
${item.law_id ? `<button onclick="UI.viewLaw(${item.law_id})" class="mt-2 text-xs text-gray-500 hover:text-gray-900 underline">查看原文</button>` : ''}
</div>`;
},
/** 跳转查看原文 */
viewLaw(lawId) {
navigate('browse');
setTimeout(() => BrowsePage.viewLaw(lawId), 100);
},
};
// ===== API 封装 =====
const API = {
async get(url) {
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
},
async post(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp;
},
async put(url, body) {
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
},
async delete(url) {
const resp = await fetch(url, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
},
/** 检索 */
search(params) {
const qs = new URLSearchParams(params).toString();
return API.get(`/api/search?${qs}`);
},
/** 法规列表 */
listLaws(params) {
const qs = new URLSearchParams(params).toString();
return API.get(`/api/laws?${qs}`);
},
/** 法规详情 */
getLaw(lawId) {
return API.get(`/api/law/${lawId}`);
},
/** 统计 */
stats() {
return API.get('/api/stats');
},
};
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', () => {
// 导航绑定
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => navigate(btn.dataset.page));
});
// 移动端菜单
document.getElementById('mobile-menu-btn').addEventListener('click', () => {
document.getElementById('sidebar').classList.toggle('open');
});
// 加载统计
loadStats();
// 从 URL 决定初始页
const initialPage = pageFromPath();
// 替换当前历史记录,确保有 state
history.replaceState({ page: initialPage }, '', `/${initialPage === 'rag' ? '' : initialPage}`);
renderPage(initialPage);
});
async function loadStats() {
try {
const resp = await API.stats();
const s = resp.data;
if (s && s.ready) {
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-emerald-500';
document.getElementById('status-text').textContent = '就绪';
document.getElementById('stat-clauses').textContent = s.total_clauses.toLocaleString();
document.getElementById('stat-laws').textContent = s.total_laws.toLocaleString();
} else {
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-amber-400';
document.getElementById('status-text').textContent = '索引未就绪';
}
} catch (e) {
document.getElementById('status-dot').className = 'w-2 h-2 rounded-full bg-rose-500';
document.getElementById('status-text').textContent = '服务不可用';
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* browse.js — 法规浏览页 + 原文详情
*/
const BrowsePage = {
state: { view: 'list', laws: [], total: 0, page: 1, pageSize: 50, category: '', province: '', keyword: '', loading: false, error: null, currentLaw: null },
render() {
if (this.state.view === 'detail' && this.state.currentLaw) {
return this.renderDetail();
}
return this.renderList();
},
renderList() {
return `
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<h2 class="text-lg font-semibold mb-4">法规浏览</h2>
<!-- 筛选栏 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div class="flex gap-3 flex-wrap">
<input id="browse-keyword" type="text" placeholder="法规名关键词..."
class="flex-1 min-w-[200px] rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
value="${this.state.keyword}" onkeydown="if(event.key==='Enter')BrowsePage.doBrowse(1)">
<select id="browse-category" class="rounded-lg border border-gray-200 px-3 py-2 text-sm" onchange="BrowsePage.onFilterChange()">
<option value="">全部类别</option>
<option value="法律">法律</option>
<option value="行政法规">行政法规</option>
<option value="监察法规">监察法规</option>
<option value="司法解释">司法解释</option>
<option value="地方性法规">地方性法规</option>
</select>
<select id="browse-province" class="rounded-lg border border-gray-200 px-3 py-2 text-sm" onchange="BrowsePage.onFilterChange()">
<option value="">全部省份</option>
</select>
<button onclick="BrowsePage.doBrowse(1)" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800">查询</button>
</div>
</div>
<!-- 列表 -->
<div id="browse-list">
${UI.emptyState('输入条件浏览法规', '可按类别、省份、关键词筛选')}
</div>
</div>`;
},
renderDetail() {
const law = this.state.currentLaw;
const badge = UI.categoryBadge(law.category);
const province = law.province ? `<span class="text-xs text-gray-400">[${law.province}${law.city ? ' · ' + law.city : ''}]</span>` : '';
const date = law.publish_date ? `<span class="text-xs text-gray-400">发布: ${law.publish_date}</span>` : '';
// 目录树(章节 + 条号)
const toc = (law.clauses || []).map(c => {
const chapter = c.chapter ? c.chapter : '';
return `<div class="text-xs py-1 px-2 hover:bg-gray-50 cursor-pointer rounded" onclick="BrowsePage.scrollToClause(${c.clause_id})">
<span class="text-gray-400">${chapter}</span>
<span class="text-gray-700">${c.clause_no || ''}</span>
</div>`;
}).join('');
return `
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<button onclick="BrowsePage.backToList()" class="mb-4 text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
返回列表
</button>
<div class="flex items-center gap-2 mb-2 flex-wrap">
<h2 class="text-lg font-semibold">${law.name}</h2>
${badge}
${province}
${date}
</div>
<div class="flex gap-4">
<!-- 目录树 -->
${toc ? `<aside class="w-48 shrink-0 hidden md:block">
<div class="bg-white rounded-xl border border-gray-200 p-3 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto scroll-thin">
<div class="text-xs font-semibold text-gray-500 mb-2">目录</div>
${toc}
</div>
</aside>` : ''}
<!-- 原文 -->
<div class="flex-1 bg-white rounded-xl border border-gray-200 p-6">
<div class="md-content text-sm leading-relaxed">${marked.parse(law.content)}</div>
</div>
</div>
</div>`;
},
init() {
this.loadProvinces();
if (this.state.category) document.getElementById('browse-category').value = this.state.category;
if (this.state.province) document.getElementById('browse-province').value = this.state.province;
if (this.state.laws.length === 0) this.doBrowse(1);
},
provinces: ['北京市','天津市','河北省','山西省','内蒙古自治区','辽宁省','吉林省','黑龙江省','上海市','江苏省','浙江省','安徽省','福建省','江西省','山东省','河南省','湖北省','湖南省','广东省','广西壮族自治区','海南省','重庆市','四川省','贵州省','云南省','西藏自治区','陕西省','甘肃省','青海省','宁夏回族自治区','新疆维吾尔自治区'],
loadProvinces() {
const sel = document.getElementById('browse-province');
if (!sel) return;
this.provinces.forEach(p => {
const opt = document.createElement('option');
opt.value = p; opt.textContent = p;
sel.appendChild(opt);
});
},
onFilterChange() {
this.state.category = document.getElementById('browse-category').value;
this.state.province = document.getElementById('browse-province').value;
},
async doBrowse(page) {
const kwInput = document.getElementById('browse-keyword');
if (kwInput) this.state.keyword = kwInput.value.trim();
this.state.page = page || 1;
this.state.view = 'list';
this.state.loading = true;
this.state.error = null;
this.renderListState();
try {
const params = { page: this.state.page, page_size: this.state.pageSize };
if (this.state.category) params.category = this.state.category;
if (this.state.province) params.province = this.state.province;
if (this.state.keyword) params.keyword = this.state.keyword;
const resp = await API.listLaws(params);
this.state.laws = resp.data.results || [];
this.state.total = resp.data.total || 0;
this.state.loading = false;
this.renderListState();
} catch (e) {
this.state.loading = false;
this.state.error = e.message;
this.renderListState();
}
},
renderListState() {
const el = document.getElementById('browse-list');
if (!el) return;
if (this.state.loading) {
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载中...</span></div>`;
return;
}
if (this.state.error) {
el.innerHTML = UI.errorState(this.state.error, 'BrowsePage.doBrowse()');
return;
}
if (!this.state.laws.length) {
el.innerHTML = UI.emptyState('无法规', '试试调整筛选条件');
return;
}
const rows = this.state.laws.map(l => `
<div class="bg-white border border-gray-200 rounded-lg p-3 mb-2 hover:border-gray-300 transition cursor-pointer" onclick="BrowsePage.viewLaw(${l.law_id})">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-gray-900">${l.name}</span>
${UI.categoryBadge(l.category)}
${l.province ? `<span class="text-xs text-gray-400">[${l.province}${l.city ? ' · ' + l.city : ''}]</span>` : ''}
${l.publish_date ? `<span class="text-xs text-gray-400">${l.publish_date}</span>` : ''}
<span class="text-xs text-gray-400 ml-auto">${l.clause_count} 条</span>
</div>
</div>`).join('');
const pager = UI.pagination(this.state.page, this.state.pageSize, this.state.total, 'BrowsePage.doBrowse');
el.innerHTML = `<div class="mb-3 text-sm text-gray-500">共 ${this.state.total} 篇法规</div>${rows}${pager}`;
},
async viewLaw(lawId) {
this.state.view = 'detail';
this.state.currentLaw = null;
const content = document.getElementById('content');
content.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载原文...</span></div>`;
try {
const resp = await API.getLaw(lawId);
this.state.currentLaw = resp.data;
content.innerHTML = this.renderDetail();
} catch (e) {
content.innerHTML = UI.errorState(e.message, `BrowsePage.viewLaw(${lawId})`);
}
},
backToList() {
this.state.view = 'list';
this.state.currentLaw = null;
const content = document.getElementById('content');
content.innerHTML = this.renderList();
this.init();
this.renderListState();
},
scrollToClause(clauseId) {
// 简单实现:滚动到条文内容(原文中条文已渲染)
// TODO: 更精确的定位需要解析原文中的条号
const el = document.getElementById(`clause-${clauseId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('clause-highlight');
setTimeout(() => el.classList.remove('clause-highlight'), 2000);
}
},
};
+332
View File
@@ -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, '&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;
},
};
+207
View File
@@ -0,0 +1,207 @@
/**
* rag.js — 智能问答页(RAG + SSE 流式)
*/
const RagPage = {
state: {
question: '', template: 'simple', answer: '', citations: [], streaming: false, error: null,
thinking: '', // thinking 内容
thinkingEnabled: true, // thinking 开关(从 API 加载)
},
render() {
const templates = [
{ value: 'simple', label: '通俗解释' },
{ value: 'professional', label: '专业分析' },
{ value: 'compare', label: '对比条文' },
];
return `
<div class="p-4 md:p-6 max-w-4xl mx-auto">
<h2 class="text-lg font-semibold mb-4">智能问答</h2>
<!-- 输入区 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<textarea id="rag-input" rows="3" placeholder="输入你的问题,如:个人信息保护法对敏感个人信息有什么规定?"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
maxlength="1000" oninput="RagPage.onInput()">${this.state.question}</textarea>
<div class="flex items-center justify-between mt-3">
<div class="flex gap-2">
${templates.map(t => `
<button onclick="RagPage.setTemplate('${t.value}')"
class="px-3 py-1.5 text-xs rounded-lg ${this.state.template === t.value ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
${t.label}
</button>`).join('')}
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">${this.state.question.length}/1000</span>
<button id="rag-submit" onclick="RagPage.submit()" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 disabled:opacity-50">
提问
</button>
</div>
</div>
</div>
<!-- 答案区 -->
<div id="rag-answer">
${UI.emptyState('提问开始对话', '系统将检索相关法规条文并由 AI 生成带引用的答案')}
</div>
</div>`;
},
init() {
// 加载 LLM 参数(用于 thinking 状态显示)
this.loadLLMParams();
},
async loadLLMParams() {
try {
const resp = await API.get('/api/prompts');
this.state.thinkingEnabled = resp.data.llm_params.thinking_enabled;
} catch (e) {
console.error('加载 LLM 参数失败:', e);
}
},
onInput() {
const input = document.getElementById('rag-input');
if (input) this.state.question = input.value;
},
setTemplate(t) {
this.state.template = t;
const content = document.getElementById('content');
content.innerHTML = this.render();
},
async submit() {
const input = document.getElementById('rag-input');
if (input) this.state.question = input.value.trim();
if (!this.state.question || this.state.streaming) return;
this.state.answer = '';
this.state.thinking = '';
this.state.citations = [];
this.state.streaming = true;
this.state.error = null;
this.renderAnswer();
try {
const resp = await fetch('/api/rag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: this.state.question,
template: this.state.template,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
// SSE 流式读取
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的行
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === 'answer') {
this.state.answer += evt.content;
this.renderAnswer();
} else if (evt.type === 'thinking') {
this.state.thinking += evt.content;
this.renderAnswer();
} else if (evt.type === 'params') {
this.state.thinkingEnabled = evt.thinking_enabled;
} else if (evt.type === 'citations') {
this.state.citations = evt.clauses || [];
this.renderAnswer();
} else if (evt.type === 'no_result') {
this.state.error = evt.message;
this.renderAnswer();
} else if (evt.type === 'error') {
this.state.error = evt.message;
this.renderAnswer();
} else if (evt.type === 'done') {
// 完成
}
} catch (e) { /* 忽略解析错误 */ }
}
}
this.state.streaming = false;
this.renderAnswer();
} catch (e) {
this.state.streaming = false;
this.state.error = e.message;
this.renderAnswer();
}
},
renderAnswer() {
const el = document.getElementById('rag-answer');
if (!el) return;
if (this.state.error) {
el.innerHTML = UI.errorState(this.state.error, 'RagPage.submit()');
return;
}
if (!this.state.answer && !this.state.citations.length && this.state.streaming) {
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">检索法规并生成答案中...</span></div>`;
return;
}
if (!this.state.answer && !this.state.thinking && !this.state.citations.length) {
el.innerHTML = UI.emptyState('提问开始对话', '系统将检索相关法规条文并由 AI 生成带引用的答案');
return;
}
// thinking 折叠区(仅 thinking 开启且有内容时显示)
const thinkingHtml = this.state.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">${this.state.thinking.length} 字</span>
${this.state.streaming && !this.state.answer ? '<span class="text-emerald-500">思考中...</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(this.state.thinking)}</pre>
</div>
</details>`
: '';
const answerHtml = this.state.answer
? `<div class="md-content text-sm leading-relaxed ${this.state.streaming ? 'stream-cursor' : ''}">${marked.parse(this.state.answer)}</div>`
: (this.state.streaming && this.state.thinking ? '<div class="text-xs text-gray-400 py-2">等待生成答案...</div>' : '');
const citationsHtml = this.state.citations.length
? `<div class="mt-6 pt-4 border-t border-gray-200"><div class="text-xs font-semibold text-gray-500 mb-3">引用条文(${this.state.citations.length})</div>${this.state.citations.map(c => UI.clauseCard(c)).join('')}</div>`
: '';
el.innerHTML = `
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="text-xs font-semibold text-gray-500 mb-3">AI 回答</div>
${thinkingHtml}
${answerHtml}
${this.state.streaming ? `<div class="flex items-center gap-2 mt-3 text-xs text-gray-400">${UI.spinner(14)} 生成中...</div>` : ''}
${citationsHtml}
</div>`;
},
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
};
+168
View File
@@ -0,0 +1,168 @@
/**
* search.js — 语义检索页
*/
const SearchPage = {
state: { query: '', mode: 'semantic', category: '', province: '', city: '', results: [], total: 0, page: 1, pageSize: 20, loading: false, error: null },
render() {
const isKeyword = this.state.mode === 'keyword';
return `
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<h2 class="text-lg font-semibold mb-4">法规检索</h2>
<!-- 筛选栏 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<!-- 模式切换 -->
<div class="flex gap-2 mb-3">
<button onclick="SearchPage.setMode('semantic')"
class="px-3 py-1.5 text-xs rounded-lg ${!isKeyword ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
语义检索
</button>
<button onclick="SearchPage.setMode('keyword')"
class="px-3 py-1.5 text-xs rounded-lg ${isKeyword ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
精确查找
</button>
<span class="text-xs text-gray-400 self-center ml-1">
${isKeyword ? '按法规名+条号精确匹配,如"郑州市劳动用工条例 第三十二条"' : '自然语言语义搜索,如"加班工资计算基数"'}
</span>
</div>
<div class="flex gap-3 mb-3">
<input id="search-input" type="text"
placeholder="${isKeyword ? '输入法规名和条号,如:郑州市劳动用工条例 第三十二条' : '输入查询,如:个人信息保护、竞业协议补偿金...'}"
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900"
value="${this.state.query}" maxlength="1000"
onkeydown="if(event.key==='Enter')SearchPage.doSearch()">
<button onclick="SearchPage.doSearch()" class="px-4 py-2 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
搜索
</button>
</div>
<div class="flex gap-3 flex-wrap text-sm">
<select id="filter-category" class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm" onchange="SearchPage.onFilterChange()">
<option value="">全部类别</option>
<option value="法律">法律</option>
<option value="行政法规">行政法规</option>
<option value="监察法规">监察法规</option>
<option value="司法解释">司法解释</option>
<option value="地方性法规">地方性法规</option>
</select>
<select id="filter-province" class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm" onchange="SearchPage.onFilterChange()">
<option value="">全部省份</option>
</select>
<span class="text-xs text-gray-400 self-center">${this.state.query.length}/1000</span>
</div>
</div>
<!-- 结果区 -->
<div id="search-results">
${UI.emptyState('输入查询开始检索', isKeyword ? '按法规名+条号精确匹配' : '支持自然语言查询,如"个人信息跨境传输的规定"')}
</div>
</div>`;
},
setMode(mode) {
this.state.mode = mode;
this.state.results = [];
this.state.total = 0;
const content = document.getElementById('content');
content.innerHTML = this.render();
this.init();
},
init() {
// 加载省份列表(从统计或固定列表)
this.loadProvinces();
// 恢复筛选状态
if (this.state.category) document.getElementById('filter-category').value = this.state.category;
if (this.state.province) document.getElementById('filter-province').value = this.state.province;
},
provinces: ['北京市','天津市','河北省','山西省','内蒙古自治区','辽宁省','吉林省','黑龙江省','上海市','江苏省','浙江省','安徽省','福建省','江西省','山东省','河南省','湖北省','湖南省','广东省','广西壮族自治区','海南省','重庆市','四川省','贵州省','云南省','西藏自治区','陕西省','甘肃省','青海省','宁夏回族自治区','新疆维吾尔自治区'],
loadProvinces() {
const sel = document.getElementById('filter-province');
if (!sel) return;
this.provinces.forEach(p => {
const opt = document.createElement('option');
opt.value = p; opt.textContent = p;
sel.appendChild(opt);
});
},
onFilterChange() {
this.state.category = document.getElementById('filter-category').value;
this.state.province = document.getElementById('filter-province').value;
if (this.state.query) this.doSearch();
},
async doSearch(page) {
const input = document.getElementById('search-input');
if (input) this.state.query = input.value.trim();
if (!this.state.query) return;
// 自动判定模式:包含"第X条"且看起来像法规名+条号时,自动用精确查找
const hasClauseNo = /第[一二三四五六七八九十百千零\d]+条/.test(this.state.query);
const autoMode = hasClauseNo ? 'keyword' : this.state.mode;
this.state.page = page || 1;
this.state.loading = true;
this.state.error = null;
this.renderResults();
try {
const params = {
query: this.state.query,
mode: autoMode,
top_k: 20,
page: this.state.page,
page_size: this.state.pageSize,
};
if (this.state.category) params.category = this.state.category;
if (this.state.province) params.province = this.state.province;
const resp = await API.search(params);
this.state.results = resp.data.results || [];
this.state.total = resp.data.total || 0;
this.state.loading = false;
this.renderResults();
} catch (e) {
this.state.loading = false;
this.state.error = e.message;
this.renderResults();
}
},
renderResults() {
const el = document.getElementById('search-results');
if (!el) return;
if (this.state.loading) {
el.innerHTML = `<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">检索中...</span></div>`;
return;
}
if (this.state.error) {
el.innerHTML = UI.errorState(this.state.error, 'SearchPage.doSearch()');
return;
}
if (!this.state.results.length && this.state.query) {
el.innerHTML = UI.emptyState('无检索结果', '试试调整查询词或筛选条件');
return;
}
if (!this.state.results.length) {
el.innerHTML = UI.emptyState('输入查询开始检索', '支持自然语言查询');
return;
}
const cards = this.state.results.map(r => UI.clauseCard(r)).join('');
const pager = UI.pagination(this.state.page, this.state.pageSize, this.state.total, 'SearchPage.doSearch');
// 显示当前检索模式
const hasClauseNo = /第[一二三四五六七八九十百千零\d]+条/.test(this.state.query);
const autoMode = hasClauseNo ? 'keyword' : this.state.mode;
const modeLabel = autoMode === 'keyword' ? '精确查找' : '语义检索';
const autoHint = hasClauseNo && this.state.mode === 'semantic' ? ' <span class="text-xs text-amber-500">(检测到条号,自动切换精确查找)</span>' : '';
el.innerHTML = `<div class="mb-3 text-sm text-gray-500">找到 ${this.state.total} 条结果 <span class="text-xs text-gray-400">[${modeLabel}]</span>${autoHint}</div>${cards}${pager}`;
},
};
+258
View File
@@ -0,0 +1,258 @@
/**
* settings.js — 设置页(系统提示词 + 模板正文 + LLM 参数)
* 所有配置持久化到 SQLite,通过 /api/prompts 读写
*/
const SettingsPage = {
state: {
template: 'simple',
configs: null, // 从 API 加载的配置(含自定义值)
llmParams: null, // LLM 参数
systemPrompt: '', // 系统提示词(编辑框值,空=使用默认)
templateText: '', // 模板正文(编辑框值,空=使用默认)
saveStatus: '', // 保存状态提示
loading: true,
error: null,
},
render() {
if (this.state.loading) {
return `<div class="p-4 md:p-6 max-w-3xl mx-auto">
<h2 class="text-lg font-semibold mb-4">设置</h2>
<div class="flex items-center justify-center py-16 gap-3 text-gray-400">${UI.spinner(20)}<span class="text-sm">加载配置中...</span></div>
</div>`;
}
if (this.state.error) {
return `<div class="p-4 md:p-6 max-w-3xl mx-auto">
<h2 class="text-lg font-semibold mb-4">设置</h2>
${UI.errorState(this.state.error, 'SettingsPage.init()')}
</div>`;
}
const tpl = TEMPLATES.find(t => t.value === this.state.template);
const llm = this.state.llmParams || {};
return `
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<h2 class="text-lg font-semibold mb-1">设置</h2>
<p class="text-xs text-gray-400 mb-6">配置系统提示词、问答模板和 LLM 参数,所有设置持久化到数据库。</p>
<!-- 系统提示词 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-semibold text-gray-700">系统提示词(System Prompt)</label>
<button onclick="SettingsPage.resetSystemPrompt()" class="text-xs text-gray-400 hover:text-gray-600">恢复默认</button>
</div>
<textarea id="settings-system-prompt" rows="2"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
placeholder="留空使用默认:你是法律助手,根据提供的法规条文回答问题..."
oninput="SettingsPage.saveSystemPrompt(this.value)">${this._escapeHtml(this.state.systemPrompt)}</textarea>
<p class="text-xs text-gray-400 mt-1">定义 AI 的角色和基本行为。留空使用默认值。</p>
</div>
<!-- 模板选择 + 正文 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-semibold text-gray-700">问答模板</label>
<button onclick="SettingsPage.resetTemplateText()" class="text-xs text-gray-400 hover:text-gray-600">恢复默认</button>
</div>
<!-- 模板切换 -->
<div class="flex gap-2 mb-3">
${TEMPLATES.map(t => `
<button onclick="SettingsPage.setTemplate('${t.value}')"
class="px-3 py-1.5 text-xs rounded-lg ${this.state.template === t.value ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
${t.label}
</button>`).join('')}
</div>
<textarea id="settings-template-text" rows="8"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-none"
placeholder="留空使用当前模板的预设正文。自定义时必须包含 {context} 和 {question} 占位符。"
oninput="SettingsPage.saveTemplateText(this.value)">${this._escapeHtml(this.state.templateText)}</textarea>
<p class="text-xs text-gray-400 mt-1">自定义模板正文,必须包含 <code class="bg-gray-100 px-1 rounded">{context}</code> 和 <code class="bg-gray-100 px-1 rounded">{question}</code> 占位符。留空使用预设模板。</p>
<!-- 预设模板参考 -->
<details class="text-xs text-gray-400 mt-3">
<summary class="cursor-pointer hover:text-gray-600">查看预设模板参考(当前: ${tpl?.label || ''})</summary>
<pre class="mt-2 p-3 bg-gray-50 rounded-lg text-xs overflow-x-auto scroll-thin whitespace-pre-wrap">${this._escapeHtml(PROMPT_DEFAULTS['template_' + this.state.template] || '')}</pre>
</details>
</div>
<!-- LLM 参数 -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div class="text-sm font-semibold text-gray-700 mb-3">LLM 参数</div>
<div class="grid grid-cols-2 gap-4">
<!-- thinking 开关 -->
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<label class="text-xs text-gray-600">Thinking 思考</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="settings-thinking-toggle" class="sr-only peer"
${llm.thinking_enabled ? 'checked' : ''}
onchange="SettingsPage.saveLLMParam('llm_thinking_enabled', this.checked ? 'true' : 'false')">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-gray-900 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-gray-900"></div>
</label>
</div>
<!-- temperature -->
<div class="bg-gray-50 rounded-lg px-3 py-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-gray-600">Temperature</label>
<span id="settings-temperature-val" class="text-xs text-gray-900 font-mono">${llm.temperature ?? 0.3}</span>
</div>
<input type="range" id="settings-temperature" min="0" max="1" step="0.1"
value="${llm.temperature ?? 0.3}"
class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-gray-900"
oninput="document.getElementById('settings-temperature-val').textContent=this.value"
onchange="SettingsPage.saveLLMParam('llm_temperature', this.value)">
</div>
<!-- max_tokens -->
<div class="bg-gray-50 rounded-lg px-3 py-2">
<label class="text-xs text-gray-600 block mb-1">Max Tokens(含 thinking)</label>
<input type="number" id="settings-max-tokens" min="256" max="32768" step="256"
value="${llm.max_tokens ?? 4096}"
class="w-full rounded border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-gray-900"
onchange="SettingsPage.saveLLMParam('llm_max_tokens', this.value)">
</div>
<!-- thinking_budget -->
<div class="bg-gray-50 rounded-lg px-3 py-2">
<label class="text-xs text-gray-600 block mb-1">Thinking 预算(token)</label>
<input type="number" id="settings-thinking-budget" min="0" max="16384" step="256"
value="${llm.thinking_budget ?? 2048}"
class="w-full rounded border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-gray-900"
onchange="SettingsPage.saveLLMParam('llm_thinking_budget', this.value)">
</div>
</div>
<p class="text-xs text-gray-400 mt-2">Thinking 开启时,AI 会先思考再回答(可折叠查看)。Max Tokens 是总 token 上限(含 thinking)。</p>
</div>
<!-- 保存状态 -->
<div id="settings-save-status" class="text-xs text-emerald-600 transition-opacity text-center" style="opacity:0"></div>
</div>
`;
},
async init() {
await this.loadConfigs();
},
async loadConfigs() {
this.state.loading = true;
const content = document.getElementById('content');
if (content) content.innerHTML = this.render();
try {
const resp = await API.get('/api/prompts');
this.state.configs = resp.data.configs;
this.state.llmParams = resp.data.llm_params;
// 系统提示词:直接显示数据库值(空则用默认值填充)
this.state.systemPrompt = this.state.configs.system_prompt || PROMPT_DEFAULTS.system_prompt;
// 当前模板正文:直接显示数据库值(空则用默认值填充)
this._loadTemplateText();
this.state.loading = false;
this.state.error = null;
if (content) content.innerHTML = this.render();
} catch (e) {
this.state.loading = false;
this.state.error = e.message;
if (content) content.innerHTML = this.render();
}
},
_loadTemplateText() {
const tplKey = `template_${this.state.template}`;
this.state.templateText = this.state.configs[tplKey] || PROMPT_DEFAULTS[tplKey];
},
setTemplate(t) {
this.state.template = t;
if (this.state.configs) this._loadTemplateText();
const content = document.getElementById('content');
content.innerHTML = this.render();
},
// ===== 系统提示词 =====
async saveSystemPrompt(val) {
this.state.systemPrompt = val;
this._showSaveStatus('保存中...');
try {
await API.put('/api/prompts', { system_prompt: val });
this._showSaveStatus('已保存');
} catch (e) {
this._showSaveStatus('保存失败');
}
},
async resetSystemPrompt() {
this.state.systemPrompt = '';
this._showSaveStatus('重置中...');
try {
await API.put('/api/prompts', { system_prompt: '' });
const el = document.getElementById('settings-system-prompt');
if (el) el.value = '';
this._showSaveStatus('已恢复默认');
} catch (e) {
this._showSaveStatus('重置失败');
}
},
// ===== 模板正文 =====
async saveTemplateText(val) {
this.state.templateText = val;
this._showSaveStatus('保存中...');
try {
const body = {};
body[`template_${this.state.template}`] = val;
await API.put('/api/prompts', body);
this._showSaveStatus('已保存');
} catch (e) {
this._showSaveStatus('保存失败');
}
},
async resetTemplateText() {
this.state.templateText = '';
this._showSaveStatus('重置中...');
try {
const body = {};
body[`template_${this.state.template}`] = '';
await API.put('/api/prompts', body);
const el = document.getElementById('settings-template-text');
if (el) el.value = '';
this._showSaveStatus('已恢复默认');
} catch (e) {
this._showSaveStatus('重置失败');
}
},
// ===== LLM 参数 =====
async saveLLMParam(key, value) {
this._showSaveStatus('保存中...');
try {
const body = {};
body[key] = String(value);
await API.put('/api/prompts', body);
this._showSaveStatus('已保存');
// 重新加载参数
const resp = await API.get('/api/prompts');
this.state.llmParams = resp.data.llm_params;
} catch (e) {
this._showSaveStatus('保存失败');
}
},
// ===== 工具方法 =====
_showSaveStatus(msg) {
this.state.saveStatus = msg;
const el = document.getElementById('settings-save-status');
if (el) {
el.textContent = msg;
el.style.opacity = '1';
setTimeout(() => { el.style.opacity = '0'; }, 2000);
}
},
_escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
};