641e33b834
- 语义检索(FAISS + embedding)+ 精确查找(法规名+条号) - RAG 问答(SSE 流式,支持 thinking 折叠显示) - 法规浏览(原文阅读) - 历史记录(检索+对话持久化到 SQLite) - 设置页(系统提示词/模板/LLM 参数可配置) - 检索质量评估脚本 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
/**
|
|
* 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 = '服务不可用';
|
|
}
|
|
}
|