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,2 @@
|
||||
/* app.css — Tailwind 之外的补充样式 */
|
||||
/* 目前所有样式已在 index.html 的 <style> 中定义,此文件预留扩展 */
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>千源法规知识库 — QYLAW</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css?v=20260807">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
:root { --radius: 0.625rem; }
|
||||
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
.mono { font-family: 'JetBrains Mono', 'SF Mono', monospace; }
|
||||
.scroll-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.scroll-thin::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
.scroll-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
/* 导航激活态 */
|
||||
.nav-active { background: #f1f5f9; color: #0f172a; font-weight: 600; }
|
||||
.nav-inactive { color: #64748b; }
|
||||
.nav-inactive:hover { background: #f8fafc; color: #334155; }
|
||||
/* Markdown 渲染 */
|
||||
.md-content h1 { font-size: 1.4em; font-weight: 700; margin: 12px 0 8px; }
|
||||
.md-content h2 { font-size: 1.2em; font-weight: 600; margin: 10px 0 6px; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
|
||||
.md-content h3 { font-size: 1.05em; font-weight: 600; margin: 8px 0 4px; }
|
||||
.md-content p { margin: 6px 0; line-height: 1.7; }
|
||||
.md-content ul { list-style: disc; padding-left: 1.5em; margin: 6px 0; }
|
||||
.md-content ol { list-style: decimal; padding-left: 1.5em; margin: 6px 0; }
|
||||
.md-content li { margin: 3px 0; line-height: 1.6; }
|
||||
.md-content blockquote { border-left: 3px solid #cbd5e1; padding: 4px 12px; margin: 8px 0; background: #f8fafc; border-radius: 0 6px 6px 0; color: #475569; }
|
||||
.md-content code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; font-family: 'JetBrains Mono', monospace; color: #e11d48; }
|
||||
.md-content pre { background: #1e293b; color: #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 8px 0; overflow-x: auto; font-size: 13px; }
|
||||
.md-content pre code { background: none; color: inherit; padding: 0; }
|
||||
.md-content table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
|
||||
.md-content th { background: #f8fafc; border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; font-weight: 600; }
|
||||
.md-content td { border: 1px solid #e2e8f0; padding: 6px 10px; }
|
||||
.md-content tr:nth-child(even) { background: #f8fafc; }
|
||||
.md-content strong { font-weight: 600; }
|
||||
.md-content a { color: #0f172a; text-decoration: underline; }
|
||||
/* 条文高亮 */
|
||||
.clause-highlight { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 8px 12px; margin: 4px 0; border-radius: 0 4px 4px 0; }
|
||||
/* 加载动画 */
|
||||
.spinner { border: 2px solid #e2e8f0; border-top: 2px solid #0f172a; border-radius: 50%; width: 16px; height: 16px; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
/* 流式光标 */
|
||||
.stream-cursor::after { content: '▊'; animation: blink 1s infinite; color: #0f172a; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
/* 响应式:移动端 Sidebar */
|
||||
@media (max-width: 767px) {
|
||||
#sidebar { display: none; }
|
||||
#sidebar.open { display: flex; position: fixed; top: 56px; left: 0; bottom: 0; z-index: 50; }
|
||||
#mobile-menu-btn { display: flex; }
|
||||
}
|
||||
#mobile-menu-btn { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#f8f9fb] text-gray-900 h-screen flex flex-col overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="h-14 bg-white/95 backdrop-blur border-b border-gray-200 flex items-center justify-between px-4 md:px-6 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobile-menu-btn" class="md:hidden p-2 rounded-lg hover:bg-gray-100" aria-label="菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="w-8 h-8 rounded-lg bg-gray-900 flex items-center justify-center text-white font-bold text-sm">法</div>
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold">千源法规知识库</h1>
|
||||
<p class="text-xs text-gray-500">17291 篇现行有效法律法规 · 语义检索 + RAG 问答</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="stats-badge" class="text-xs text-gray-500 hidden md:flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300" id="status-dot"></span>
|
||||
<span id="status-text">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="w-52 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
<button data-page="rag" class="nav-item nav-active w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>智能问答</span>
|
||||
</button>
|
||||
<button data-page="search" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||
<svg width="18" height="18" 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>
|
||||
<span>语义检索</span>
|
||||
</button>
|
||||
<button data-page="browse" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
<span>法规浏览</span>
|
||||
</button>
|
||||
<button data-page="history" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span>历史记录</span>
|
||||
</button>
|
||||
<button data-page="settings" class="nav-item nav-inactive w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-gray-100">
|
||||
<div id="sidebar-stats" class="text-xs text-gray-400 space-y-1">
|
||||
<div>索引: <span id="stat-clauses">-</span> 条文</div>
|
||||
<div>法规: <span id="stat-laws">-</span> 篇</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main id="content" class="flex-1 overflow-y-auto scroll-thin">
|
||||
<!-- 页面动态渲染 -->
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js?v=20260807"></script>
|
||||
<script src="/static/js/search.js?v=20260807"></script>
|
||||
<script src="/static/js/rag.js?v=20260807"></script>
|
||||
<script src="/static/js/browse.js?v=20260807"></script>
|
||||
<script src="/static/js/history.js?v=20260807"></script>
|
||||
<script src="/static/js/settings.js?v=20260807"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '服务不可用';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user