feat: 新增对话测试页面和SQL实验室页面

- 在 bai-chat-index.html 中实现了对话测试功能,支持用户与AI的交互,包含消息发送、接收和显示。
- 在 sql-lab-index.html 中实现了SQL实验室功能,支持数据库表的查询和显示,包含表结构探测、SQL语句执行和结果展示。
- 添加了动态样式和交互效果,提升用户体验。
This commit is contained in:
2026-04-01 17:22:49 +08:00
parent b97cc6d00f
commit f793cb3cdd
9 changed files with 1374 additions and 19 deletions

View File

@@ -4,3 +4,6 @@ require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/product-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)
require(`${__hooks}/bai_chat_alm_hooks/bai-ai-manage-main.pb.js`)
require(`${__hooks}/bai_chat_alm_hooks/bai-chat.pb.js`)
require(`${__hooks}/bai_chat_alm_hooks/bai-sql-lab.pb.js`)

View File

@@ -0,0 +1,122 @@
// ==============================================================================
// 模块: BAI 业务审计子系统 (v1.8 完整功能稳态版)
// ==============================================================================
routerAdd("GET", "/bai-ai-manage", (c) => {
try {
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/ai-manage-index.html").render({});
const safeHtml = String(html || "")
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
const guardedHtml = safeHtml.indexOf("</head>") !== -1
? safeHtml.replace("</head>", guardScript + "</head>")
: guardScript + safeHtml
return c.html(200, guardedHtml);
} catch (err) {
return c.json(500, { message: "HTML 模板渲染失败: " + err.message });
}
});
// 1. 获取用户列表
routerAdd("GET", "/pb-api-v1/audit/users", (c) => {
try {
const info = c.requestInfo();
const days = parseInt(info.query["days"] || "0");
// 保持原有的时间过滤逻辑
let timeClause = (days > 0) ? `AND c.createdAt > ${new Date().getTime() - (days * 24 * 60 * 1000)}` : "";
// 【核心修正】SQL 采用 LEFT JOIN 确保 thread_id 为空的 API 对话不被漏掉
// 注意:这里去掉了可能引起语法错误的注释,使用了探针兼容的 SQL
const sql = `
SELECT
COALESCE(t.slug, t.name, 'API_USER') as phone,
COUNT(c.id) as chat_count,
MAX(c.createdAt) as last_active
FROM workspace_chats c
LEFT JOIN workspace_threads t ON c.thread_id = t.id
WHERE 1=1 ${timeClause}
GROUP BY phone
ORDER BY last_active DESC
`;
// 保持对 3010 探针的调用
const res = $http.send({
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
method: "POST",
body: JSON.stringify({ "sql": sql, "params": [] }),
headers: { "Content-Type": "application/json" }
});
// 保持原有的 PB 用户姓名映射逻辑
let phoneToNameMap = {};
const pbRecords = $app.findAllRecords("tbl_auth_users");
pbRecords.forEach(r => {
phoneToNameMap[r.getString("users_phone")] = r.getString("users_name");
});
const data = (res.json.data || []).map(u => ({
...u,
name: phoneToNameMap[u.phone] || (u.phone === 'API_USER' ? '微信测试用户' : "")
}));
return c.json(200, { code: 200, data });
} catch (err) {
return c.json(500, { message: "Users API Error: " + err.message });
}
});
// 2. 获取流水记录
routerAdd("GET", "/pb-api-v1/audit/chat-list", (c) => {
try {
const info = c.requestInfo();
const phone = info.query["phone"];
const page = parseInt(info.query["page"] || "1");
const days = parseInt(info.query["days"] || "0");
const offset = (page - 1) * 50;
let timeClause = (days > 0) ? `AND c.createdAt > ${new Date().getTime() - (days * 24 * 60 * 60 * 1000)}` : "";
// 【核心修正】针对 API_USER 的特殊查询逻辑
let whereClause = "";
if (phone === "API_USER") {
whereClause = `WHERE c.thread_id IS NULL`;
} else {
whereClause = `WHERE t.slug = '${phone}' OR t.name = '${phone}'`;
}
const sql = `
SELECT c.id, w.name AS workspace_name, c.prompt, c.response, c.createdAt
FROM workspace_chats c
LEFT JOIN workspace_threads t ON c.thread_id = t.id
LEFT JOIN workspaces w ON c.workspaceId = w.id
${whereClause} ${timeClause}
ORDER BY c.createdAt DESC
LIMIT 50 OFFSET ${offset}
`;
const res = $http.send({
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
method: "POST",
body: JSON.stringify({ "sql": sql, "params": [] }),
headers: { "Content-Type": "application/json" }
});
return c.json(200, { code: 200, data: res.json.data || [] });
} catch (err) {
return c.json(500, { message: "Chat List Error: " + err.message });
}
});
// 3. 全量导出保持不变
routerAdd("GET", "/pb-api-v1/audit/export-all", (c) => {
try {
const info = c.requestInfo();
const phone = info.query["phone"];
let whereClause = (phone === "API_USER") ? "WHERE c.thread_id IS NULL" : `WHERE t.slug = '${phone}' OR t.name = '${phone}'`;
const sql = `SELECT c.*, w.name AS workspace_name FROM workspace_chats c LEFT JOIN workspace_threads t ON c.thread_id = t.id LEFT JOIN workspaces w ON c.workspaceId = w.id ${whereClause} ORDER BY c.createdAt DESC;`;
const res = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/query", method: "POST", body: JSON.stringify({ "sql": sql, "params": [] }), headers: { "Content-Type": "application/json" } });
return c.json(200, { code: 200, data: res.json.data || [] });
} catch (err) { return c.json(500, { message: err.message }); }
});

View File

@@ -0,0 +1,62 @@
// ==============================================================================
// 模块: BAI 微信风聊天测试页 (v1.4 修复版)
// ==============================================================================
// 1. 渲染页面路由
routerAdd("GET", "/bai-chat", (c) => {
try {
// 确保 views/bai-chat-index.html 路径存在
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/bai-chat-index.html").render({});
const safeHtml = String(html || "")
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
const guardedHtml = safeHtml.indexOf("</head>") !== -1
? safeHtml.replace("</head>", guardScript + "</head>")
: guardScript + safeHtml
return c.html(200, guardedHtml);
} catch (e) {
return c.json(500, { "message": "HTML 模板渲染失败: " + e.message });
}
});
// 2. 聊天转发路由
routerAdd("POST", "/pb-api-v1/chat/send", (c) => {
try {
const info = c.requestInfo();
// 增加安全取值逻辑
const body = info.body || {};
const userMsg = body.message || "";
if (!userMsg) {
return c.json(400, { "message": "消息内容不能为空" });
}
// 使用 3001 端口直连 AnythingLLM
// 如果 8888 还没创建成功API 会自动回落到默认线程,保证不报错
const res = $http.send({
url: "http://bai-anythingllm:3001/api/v1/workspace/ai/chat",
method: "POST",
body: JSON.stringify({
"message": userMsg,
"mode": "chat",
"sessionId": "8888"
}),
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer SEC082V-99D48HT-GB96EZ5-BS34AMS"
},
timeout: 120
});
// 解析返回结果
const resData = res.json || {};
const replyText = resData.textResponse || resData.content || resData.text || "AI 未能返回有效内容";
return c.json(200, {
"code": 200,
"reply": replyText
});
} catch (err) {
return c.json(500, { "message": "转发请求失败: " + err.message });
}
});

View File

@@ -0,0 +1,79 @@
// ==============================================================================
// 模块: BAI SQL 实验室 (底层探勘工具) - 拆分独立版
// 路由: /bai-ai-sql-lab
// 注意: 绝不能引入 dynamic_model防止引擎崩溃
// ==============================================================================
// 1. 页面 UI 挂载 (路径更新为 views 目录)
routerAdd("GET", "/bai-ai-sql-lab", (c) => {
try {
const html = $template.loadFiles(__hooks + "/bai_chat_alm_hooks/views/sql-lab-index.html").render({});
const safeHtml = String(html || "")
const guardScript = '<script>(function(){var token=localStorage.getItem("pb_manage_token")||"";var isLoggedIn=localStorage.getItem("pb_manage_logged_in")==="1";if(!token||!isLoggedIn){window.location.replace("/pb/manage/login");}})()</script>'
const guardedHtml = safeHtml.indexOf("</head>") !== -1
? safeHtml.replace("</head>", guardScript + "</head>")
: guardScript + safeHtml
return c.html(200, guardedHtml);
} catch (err) {
return c.json(500, { "error": "Template Error: " + err.message });
}
});
// 2. 全链路体检探针 (保持原样,属于 SQL Lab 的基础能力)
routerAdd("GET", "/pb-api-v1/diagnostics", (c) => {
let report = { pb_status: "PocketBase OK", py_network: "WAIT", sqlite_storage: "WAIT", sqlite_version: "" };
try {
const res1 = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/py-alive-check", method: "GET", timeout: 5 });
report.py_network = res1.statusCode === 200 ? "ALIVE" : "ERROR";
const res2 = $http.send({ url: "http://bai-alm-audit-api:3010/api/v1/audit/sqlite-read-check", method: "GET", timeout: 5 });
report.sqlite_storage = res2.statusCode === 200 ? "READY" : "ERROR";
if (res2.statusCode === 200 && res2.json.message) {
const match = res2.json.message.match(/版本:\s*([\d\.]+)/);
report.sqlite_version = match ? match[1] : "未知";
report.sqlite_detail = res2.json.message;
} else {
report.sqlite_detail = res2.raw;
}
return c.json(200, report);
} catch (err) {
report.py_network = "ERROR";
report.sqlite_storage = "ERROR";
report.sqlite_detail = err.message;
return c.json(500, report);
}
});
// 3. 万能 SQL 执行透传通道 (彻底修复 Body 读取方式)
routerAdd("POST", "/pb-api-v1/sql-ops", (c) => {
try {
// PB v0.23+ 官方推荐的无依赖 Body 读取方式
const reqBody = c.requestInfo().body;
const sqlStatement = reqBody.sql || "";
if (!sqlStatement) {
return c.json(400, { "message": "SQL 指令不能为空" });
}
const res = $http.send({
url: "http://bai-alm-audit-api:3010/api/v1/audit/query",
method: "POST",
body: JSON.stringify({ "sql": sqlStatement, "params": [] }),
headers: { "Content-Type": "application/json" },
timeout: 15
});
if (res.statusCode !== 200) {
return c.json(res.statusCode, {
code: res.statusCode,
message: res.json?.detail || res.raw || "探针层执行报错"
});
}
return c.json(200, { code: 200, data: res.json.data || [] });
} catch (err) {
return c.json(500, { code: 500, message: "PB穿透异常: " + err.message });
}
});

View File

@@ -0,0 +1,464 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>BAI 业务审计控制台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; background-color: #0b0f1a; }
.font-mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
::-webkit-scrollbar-track { background: transparent; }
.prose-audit { font-size: 14px; line-height: 1.7; color: #64748b !important; transition: all 0.3s ease; }
.prose-audit p { margin-bottom: 0.85rem; }
.prose-audit strong { color: #94a3b8 !important; font-weight: 600; }
.prose-audit table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1rem 0; -webkit-overflow-scrolling: touch; }
.prose-audit th, .prose-audit td { border: 1px solid rgba(100, 116, 139, 0.2); padding: 8px 12px; min-width: 80px; }
.prose-audit hr { border: 0; border-top: 1px solid #64748b; opacity: 0.3; margin: 1.2rem 0; }
.prose-audit pre { background: #020617; padding: 1rem; border-radius: 0.75rem; border: 1px solid #1e293b; color: #94a3b8; margin: 1rem 0; overflow-x: auto; }
.chat-card-container:hover .prose-audit, .chat-card-container:hover .prose-audit p { color: #94a3b8 !important; }
.chat-card-container:hover .prose-audit strong { color: #cbd5e1 !important; }
.chat-card-container:hover .response-box { background-color: rgba(15, 23, 42, 0.6) !important; border-color: rgba(255, 255, 255, 0.1) !important; }
/* 去除 details 默认的黑色三角形 */
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
[x-cloak] { display: none !important; }
</style>
</head>
<body class="text-slate-400 h-screen flex flex-col overflow-hidden" x-data="aiManage()">
<header class="h-14 bg-slate-950/50 backdrop-blur-md border-b border-white/5 flex items-center px-4 lg:px-6 justify-between shrink-0 z-20">
<div class="flex items-center space-x-3 lg:space-x-8">
<button x-show="activePhone && isMobile" @click="backToList()" class="lg:hidden p-2 -ml-2 text-emerald-500 hover:bg-white/5 rounded-full">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
<div class="flex items-center space-x-2">
<div class="w-1.5 h-5 bg-emerald-500 rounded-full"></div>
<span class="text-white font-bold text-sm lg:text-base tracking-tight uppercase">Audit <span class="hidden lg:inline text-emerald-500 font-normal">v2.2</span></span>
</div>
<div class="flex bg-black/40 rounded-lg p-0.5 border border-white/5 scale-90 lg:scale-100 origin-left">
<template x-for="opt in timeOptions">
<button @click="setTimeFilter(opt.days)"
class="px-2 lg:px-4 py-1 text-[10px] lg:text-[11px] rounded-md transition-all font-medium"
:class="selectedDays === opt.days ? 'bg-emerald-600 text-white shadow-lg' : 'text-slate-500'"
x-text="opt.label"></button>
</template>
</div>
</div>
<div class="hidden lg:flex items-center space-x-4 text-[11px] font-medium">
<span class="text-emerald-500 font-mono" x-text="users.length"></span>
<a href="/pb/bai-ai-sql-lab" target="_blank" class="text-slate-500 hover:text-emerald-400 border border-white/10 px-3 py-1 rounded transition-all">TERMINAL</a>
</div>
</header>
<div class="flex-1 flex overflow-hidden relative">
<aside class="absolute lg:relative z-10 w-full lg:w-80 h-full bg-[#06080f] border-r border-white/5 flex flex-col shrink-0 transition-transform duration-300"
:class="isMobile && activePhone && !showSidebar ? '-translate-x-full' : 'translate-x-0'">
<div class="p-3 bg-black/20 flex space-x-2 border-b border-white/5">
<input type="text" x-model="searchQuery" placeholder="Quick search..."
class="flex-1 bg-slate-900/50 border border-white/5 rounded-lg px-3 py-2 text-xs text-slate-200 outline-none">
<button @click="fetchUsers()" class="p-2 hover:bg-white/5 rounded-lg text-slate-500">
<svg class="w-4 h-4" :class="{'animate-spin text-emerald-500': loadingUsers}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke-width="2" stroke-linecap="round"></path></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-2">
<template x-for="user in filteredUsers" :key="user.phone">
<div @click="selectUser(user)"
class="p-4 rounded-xl mb-1 cursor-pointer transition-all border border-transparent"
:class="activePhone === user.phone ? 'bg-emerald-500/5 border-emerald-500/20' : 'hover:bg-white/[0.03]'">
<div class="flex justify-between items-start">
<div class="flex flex-col flex-1 truncate pr-2">
<span class="text-[15px] truncate" :class="user.name ? 'text-slate-100 font-semibold' : 'text-slate-600 font-normal'" x-text="user.name || '-'"></span>
<span class="text-slate-500 text-[11px] mt-1 font-mono tracking-wider" x-text="user.phone"></span>
</div>
<div class="flex flex-col items-end shrink-0">
<span class="text-[14px] font-bold font-mono" :class="activePhone === user.phone ? 'text-emerald-500' : 'text-slate-400'" x-text="user.chat_count"></span>
<span class="text-[10px] text-slate-600 mt-0.5 font-mono" x-text="formatDateShort(user.last_active)"></span>
</div>
</div>
</div>
</template>
</div>
</aside>
<main class="flex-1 flex flex-col bg-[#0b0f1a] overflow-hidden relative z-0"
:class="isMobile && (!activePhone || showSidebar) ? 'hidden' : 'flex'">
<template x-if="activePhone">
<div class="flex-1 flex flex-col overflow-hidden">
<div class="px-4 lg:px-6 py-3 lg:py-4 bg-slate-950/30 border-b border-white/5 flex justify-between items-center shrink-0">
<div class="flex flex-col lg:flex-row lg:items-baseline lg:space-x-3">
<h2 class="text-lg lg:text-xl tracking-tight truncate max-w-[150px] lg:max-w-none" :class="activeName !== '-' ? 'text-white font-bold' : 'text-slate-600'" x-text="activeName"></h2>
<span class="text-slate-500 text-[10px] font-mono lg:bg-slate-900 lg:px-3 lg:py-0.5 lg:rounded-full" x-text="activePhone"></span>
</div>
<div class="flex items-center space-x-2">
<button @click="fetchChats()" class="p-2 hover:bg-white/5 rounded-xl">
<svg class="w-5 h-5" :class="{'animate-spin text-emerald-500': loadingChats}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke-width="2" stroke-linecap="round"></path></svg>
</button>
<button @click="downloadCSV()" class="p-2 lg:px-4 lg:py-1.5 lg:bg-emerald-600 text-emerald-500 lg:text-white rounded-xl lg:text-xs font-bold transition-all">
<span class="hidden lg:inline">EXPORT</span>
<svg class="w-5 h-5 lg:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-6 lg:space-y-8 bg-[#0b0f1a]">
<template x-for="chat in chats" :key="chat.id">
<div class="flex flex-col chat-card-container transition-all duration-300">
<div class="flex justify-between items-center mb-3 px-1">
<div class="flex items-center space-x-3">
<span class="text-slate-600 font-mono text-[10px]">#<span x-text="chat.id"></span></span>
<span class="text-blue-400 font-bold text-[12px]" x-text="chat.workspace_name"></span>
</div>
<div class="flex items-center space-x-2 text-right">
<span class="text-emerald-700 font-bold text-[10px] font-mono" x-text="formatTime(chat.createdAt)"></span>
<button @click="copySingleChat(chat)" class="p-1.5 text-slate-700 hover:text-emerald-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" stroke-width="2"></path></svg>
</button>
</div>
</div>
<div class="space-y-4">
<div class="flex flex-col items-end">
<div class="max-w-[90%] lg:max-w-[80%] bg-[#1a233b] border border-blue-500/10 text-slate-200 px-4 lg:px-5 py-3 rounded-2xl rounded-tr-none text-sm leading-relaxed" x-text="chat.prompt"></div>
</div>
<div class="flex flex-col items-start" x-data="{ parsedData: parseResponse(chat.response) }">
<div class="max-w-full lg:max-w-[95%] w-full bg-slate-950/40 border border-white/5 px-4 lg:px-6 py-4 rounded-3xl rounded-tl-none response-box transition-all">
<template x-if="parsedData.think">
<details class="mb-4 group">
<summary class="cursor-pointer text-xs font-mono text-slate-500 hover:text-emerald-500 transition-colors flex items-center select-none outline-none">
<svg class="w-4 h-4 mr-1 transform transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
AI 思考逻辑 (Think Process)
</summary>
<div class="mt-3 pl-4 border-l-2 border-slate-800 text-slate-400 prose-audit opacity-80" x-html="parsedData.think"></div>
</details>
</template>
<div class="prose-audit max-w-none w-full" x-html="parsedData.text"></div>
<template x-if="parsedData.sources && parsedData.sources.length > 0">
<details class="mt-4 group border-t border-white/5 pt-3">
<summary class="cursor-pointer text-xs font-mono text-slate-500 hover:text-blue-400 transition-colors flex items-center select-none outline-none">
<svg class="w-4 h-4 mr-1 transform transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
知识库召回溯源 (<span x-text="parsedData.sources.length"></span>)
</summary>
<div class="mt-3 grid grid-cols-2 lg:grid-cols-3 gap-2.5">
<template x-for="(src, index) in parsedData.sources" :key="index">
<div @click="openSourceModal(chat, src)"
class="bg-[#0f172a]/80 border border-white/5 hover:border-blue-500/40 hover:bg-blue-500/10 rounded-lg p-2.5 cursor-pointer transition-all flex flex-col justify-center h-16 shadow-sm group/card">
<div class="flex items-center space-x-1.5 w-full mb-1">
<svg class="w-3.5 h-3.5 text-blue-400/70 shrink-0 group-hover/card:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<span class="text-[11px] font-medium text-slate-300 truncate" x-text="src.title || 'Unknown Source'" :title="src.title"></span>
</div>
<div class="text-[9px] font-mono text-emerald-500/70 pl-5">
Match: <span x-text="src.score ? src.score.toFixed(4) : 'N/A'"></span>
</div>
</div>
</template>
</div>
</details>
</template>
<template x-if="parsedData.metrics && parsedData.metrics.model">
<div class="mt-5 pt-3 border-t border-white/5 flex flex-wrap gap-2 lg:gap-3 text-[9px] lg:text-[10px] font-mono text-slate-500 uppercase tracking-wider">
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Model Used">
<svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
<span class="text-slate-400" x-text="parsedData.metrics.model"></span>
</div>
<template x-if="parsedData.metrics.total_tokens">
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Tokens (Prompt + Completion)">
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
<span class="text-slate-600">IN:</span> <span class="text-emerald-500/80 mr-1" x-text="parsedData.metrics.prompt_tokens"></span>
<span class="text-slate-600">OUT:</span> <span class="text-blue-400/80 mr-1" x-text="parsedData.metrics.completion_tokens"></span>
<span class="text-slate-600">TOT:</span> <span class="text-purple-400/80" x-text="parsedData.metrics.total_tokens"></span>
</div>
</template>
<template x-if="parsedData.metrics.duration">
<div class="flex items-center space-x-1.5 bg-black/40 px-2.5 py-1 rounded-md border border-white/5" title="Time taken">
<svg class="w-3 h-3 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span class="text-amber-500/80" x-text="(parsedData.metrics.duration).toFixed(2) + 's'"></span>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
<div class="p-3 lg:p-4 bg-slate-950 border-t border-white/5 flex justify-center items-center space-x-6 lg:space-x-12 shrink-0">
<button @click="changePage(navPage - 1)" :disabled="navPage <= 1" class="text-[10px] px-4 py-2 bg-slate-900 border border-white/5 rounded-xl disabled:opacity-20 font-bold uppercase">Prev</button>
<span class="text-emerald-500 font-bold font-mono text-base" x-text="navPage"></span>
<button @click="changePage(navPage + 1)" :disabled="chats.length < 50" class="text-[10px] px-4 py-2 bg-slate-900 border border-white/5 rounded-xl disabled:opacity-20 font-bold uppercase">Next</button>
</div>
</div>
</template>
<div x-show="!activePhone" class="flex-1 items-center justify-center opacity-10 flex flex-col">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<p class="text-xs uppercase tracking-widest">Select to begin</p>
</div>
</main>
<div x-show="showSourceModal" style="display: none;" class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" x-cloak>
<div x-show="showSourceModal" x-transition.opacity class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="closeSourceModal()"></div>
<div x-show="showSourceModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative bg-[#0b0f1a] border border-slate-700 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="px-5 py-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center shrink-0">
<div class="flex items-center space-x-3 truncate pr-4">
<div class="p-2 bg-blue-500/10 rounded-lg">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
</div>
<div class="flex flex-col truncate">
<h3 class="text-sm font-bold text-slate-200 truncate" x-text="activeSource?.title"></h3>
<span class="text-[10px] text-emerald-500 font-mono mt-0.5" x-text="'匹配度 (Score): ' + (activeSource?.score ? activeSource.score.toFixed(4) : 'N/A')"></span>
</div>
</div>
<button @click="closeSourceModal()" class="text-slate-500 hover:text-white transition-colors p-1 bg-white/5 hover:bg-white/10 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-5 lg:p-6 bg-[#06080f]">
<h4 class="text-xs font-mono text-slate-500 mb-3 uppercase tracking-wider flex items-center">
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
召回的文档切片内容
</h4>
<div class="text-sm text-slate-300 leading-relaxed font-mono bg-[#0b0f1a] p-4 lg:p-5 rounded-xl border border-white/5 whitespace-pre-wrap selection:bg-blue-500/30" x-text="activeSource?.text"></div>
</div>
<div class="px-5 py-4 border-t border-slate-800 bg-slate-900/80 flex justify-between items-center shrink-0">
<span class="text-xs text-slate-500 font-mono hidden sm:inline-block">
Workspace: <span class="text-slate-400 font-bold" x-text="activeChatForSource?.workspace_name"></span>
</span>
<div class="flex space-x-3 w-full sm:w-auto">
<button @click="closeSourceModal()" class="flex-1 sm:flex-none px-4 py-2 bg-slate-800 text-slate-300 hover:bg-slate-700 rounded-lg transition-all font-medium text-sm">
关闭
</button>
<button @click="markIssueFromModal()" class="flex-1 sm:flex-none px-4 py-2 bg-rose-500/10 text-rose-400 hover:bg-rose-500 hover:text-white border border-rose-500/20 rounded-lg transition-all flex items-center justify-center shadow-sm font-medium text-sm">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"></path></svg>
标记纠错工单
</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('aiManage', () => ({
users: [], chats: [], loadingUsers: false, loadingChats: false,
searchQuery: '', activePhone: '', activeName: '',
navPage: 1, selectedDays: 0,
showSidebar: true,
isMobile: window.innerWidth < 1024,
timeOptions: [{ label: '1D', days: 1 }, { label: '3D', days: 3 }, { label: '7D', days: 7 }, { label: 'ALL', days: 0 }],
// ================= 新增:溯源弹窗相关状态 =================
activeSource: null,
activeChatForSource: null,
showSourceModal: false,
openSourceModal(chat, src) {
this.activeChatForSource = chat;
this.activeSource = src;
this.showSourceModal = true;
},
closeSourceModal() {
this.showSourceModal = false;
// 延迟清空数据,等待退出动画执行完毕
setTimeout(() => {
this.activeSource = null;
this.activeChatForSource = null;
}, 300);
},
markIssueFromModal() {
if (!this.activeSource || !this.activeChatForSource) return;
const suggestion = prompt(`🚩 标记知识库错误\n\n来源文档:${this.activeSource.title}\n相似度得分:${this.activeSource.score ? this.activeSource.score.toFixed(4) : 'N/A'}\n\n请输入修改建议或错误说明:`);
if (suggestion) {
// 【占位】后续替换为向 tbl_kb_tickets 写入记录的 API 请求
console.log("【生成工单】", {
chat_id: this.activeChatForSource.id,
workspace: this.activeChatForSource.workspace_name,
doc_title: this.activeSource.title,
original_text: this.activeSource.text,
suggestion: suggestion
});
alert("✅ 纠错工单已生成!\n\n您可以在工单中心直接打开该 MD 文件进行编辑。");
this.closeSourceModal(); // 提交后自动关闭弹窗
}
},
// =======================================================
get apiBase() { return (window.location.pathname.startsWith('/pb') ? '/pb' : '') + '/pb-api-v1'; },
get filteredUsers() {
const q = this.searchQuery.toLowerCase();
return this.users.filter(u => u.phone.includes(q) || (u.name && u.name.toLowerCase().includes(q)));
},
init() {
this.fetchUsers();
window.addEventListener('resize', () => {
this.isMobile = window.innerWidth < 1024;
});
},
setTimeFilter(d) { this.selectedDays = d; this.navPage = 1; this.fetchUsers(); if (this.activePhone) this.fetchChats(); },
async fetchUsers() {
this.loadingUsers = true;
try {
const res = await fetch(`${this.apiBase}/audit/users?days=${this.selectedDays}`);
const json = await res.json();
if (json.code === 200) this.users = json.data;
} finally { this.loadingUsers = false; }
},
selectUser(user) {
this.activePhone = user.phone;
this.activeName = user.name || '-';
this.navPage = 1;
this.showSidebar = false;
this.fetchChats();
},
backToList() {
this.showSidebar = true;
},
changePage(p) { this.navPage = p; this.fetchChats(); },
async fetchChats() {
if (!this.activePhone) return;
this.loadingChats = true;
try {
const res = await fetch(`${this.apiBase}/audit/chat-list?phone=${this.activePhone}&page=${this.navPage}&days=${this.selectedDays}`);
const json = await res.json();
if (json.code === 200) this.chats = json.data;
} finally { this.loadingChats = false; }
},
parseResponse(raw) {
let result = { text: '', think: '', metrics: {}, sources: [] };
try {
const parsed = JSON.parse(raw);
let rawText = parsed.text || raw;
result.metrics = parsed.metrics || {};
result.sources = parsed.sources || [];
const thinkMatch = rawText.match(/<think>([\s\S]*?)<\/think>/i);
if (thinkMatch) {
result.think = marked.parse(thinkMatch[1].trim());
rawText = rawText.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
}
result.text = marked.parse(rawText);
} catch (e) {
const thinkMatch = raw.match(/<think>([\s\S]*?)<\/think>/i);
let rawText = raw;
if (thinkMatch) {
result.think = marked.parse(thinkMatch[1].trim());
rawText = raw.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
}
result.text = marked.parse(rawText);
}
return result;
},
formatTime(ms) {
const d = new Date(parseInt(ms));
const pad = (n, l=2) => String(n).padStart(l, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
formatDateShort(ms) {
const d = new Date(parseInt(ms));
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
},
copySingleChat(chat) {
let answer = "";
try {
const parsed = JSON.parse(chat.response).text;
answer = parsed.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
} catch(e) {
answer = chat.response.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
const content = `[USER]: ${chat.prompt}\n\n[AI]: ${answer}`;
navigator.clipboard.writeText(content).then(() => alert("Entry copied."));
},
copyAsMarkdown() {
let md = "| ID | TIMESTAMP | WORKSPACE | PROMPT | RESPONSE |\n| --- | --- | --- | --- | --- |\n";
this.chats.forEach(c => {
let ans = "";
try {
ans = JSON.parse(c.response).text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
} catch(e) {
ans = c.response.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
const cleanPrompt = c.prompt.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
const cleanResponse = ans.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
md += `| ${c.id} | ${this.formatTime(c.createdAt)} | ${c.workspace_name} | ${cleanPrompt} | ${cleanResponse} |\n`;
});
navigator.clipboard.writeText(md).then(() => alert("Copied."));
},
async downloadCSV() {
const res = await fetch(`${this.apiBase}/audit/export-all?phone=${this.activePhone}`);
const json = await res.json();
if (!json.data || json.data.length === 0) return alert("No data.");
const headers = Object.keys(json.data[0]);
let csv = "\uFEFF" + headers.join(",") + "\n";
json.data.forEach(row => {
const line = headers.map(h => {
let val = row[h];
if (val === null) return '""';
return '"' + String(val).replace(/"/g, '""') + '"';
});
csv += line.join(",") + "\n";
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `audit_${this.activePhone}.csv`;
link.click();
}
}));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>对话测试</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
/* 使用 dvh (Dynamic Viewport Height) 解决移动端工具栏遮挡高度的问题 */
:root { --app-height: 100dvh; }
body {
background-color: #f5f5f5;
font-family: -apple-system, system-ui, sans-serif;
height: var(--app-height);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 气泡尖角 */
.bubble-left::before {
content: ""; position: absolute; width: 0; height: 0;
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
border-right: 7px solid white; left: -7px; top: 12px;
}
.bubble-right::after {
content: ""; position: absolute; width: 0; height: 0;
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
border-left: 8px solid #95ec69; right: -8px; top: 12px;
}
/* 隐藏滚动条但保持滚动 */
.chat-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.chat-scroll::-webkit-scrollbar { display: none; }
/* Markdown 内容样式 */
.prose-chat { font-size: 16px; color: #191919; line-height: 1.6; word-break: break-word; }
.prose-chat p { margin-bottom: 0.5rem; }
.prose-chat p:last-child { margin-bottom: 0; }
.prose-chat pre { background: #f8f8f8; padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; font-size: 13px; }
.prose-chat code { font-family: 'JetBrains Mono', monospace; background: rgba(0,0,0,0.05); padding: 2px 4px; border-radius: 3px; }
/* 动画 */
.msg-fade { animation: fadeIn 0.3s ease-out forwards; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body x-data="weChat()">
<header class="h-14 bg-[#ededed] border-b border-[#dbdbdb] flex items-center px-4 shrink-0 z-20">
<div class="flex-1 flex items-center">
<svg @click="window.history.back()" class="w-6 h-6 text-[#191919] cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
<span class="ml-2 text-[17px] font-bold text-[#191919]">对话测试</span>
</div>
<div class="flex items-center">
<svg class="w-6 h-6 text-[#191919]" fill="currentColor" viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></svg>
</div>
</header>
<main id="chat-container" class="chat-scroll p-4 space-y-6 bg-[#f5f5f5]">
<div class="text-center py-2">
<span class="bg-[#dadada]/60 text-white text-[11px] px-2 py-0.5 rounded-sm" x-text="chatTime"></span>
</div>
<template x-for="(msg, index) in messages" :key="index">
<div :class="msg.role === 'user' ? 'flex flex-row-reverse' : 'flex flex-row'" class="msg-fade">
<div class="w-11 h-11 rounded-md shrink-0 overflow-hidden shadow-sm" :class="msg.role === 'user' ? 'ml-3' : 'mr-3'">
<img :src="msg.role === 'user' ? '/assets/images/user-avatar.svg?v=1' : '/assets/images/ai-avatar.svg?v=1'"
class="w-full h-full bg-[#F3E5F5] object-cover"
@error="$el.src='https://api.dicebear.com/7.x/avataaars/svg?seed=Felix'">
</div>
<div class="relative max-w-[80%] px-3 py-2.5 rounded-md shadow-sm border border-black/[0.02]"
:class="msg.role === 'user' ? 'bg-[#95ec69] bubble-right' : 'bg-white bubble-left'">
<div class="prose-chat" x-html="renderContent(msg.content)"></div>
</div>
</div>
</template>
<div x-show="isTyping" class="flex flex-row msg-fade">
<div class="w-11 h-11 bg-white rounded-md mr-3 flex items-center justify-center text-slate-200">AI</div>
<div class="bg-white bubble-left px-4 py-3 rounded-md shadow-sm flex items-center space-x-1.5">
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce"></div>
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:0.2s]"></div>
<div class="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</main>
<footer class="bg-[#f7f7f7] border-t border-[#dbdbdb] px-4 pt-3 shrink-0"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 15px); min-height: 85px;">
<div class="flex items-end space-x-3">
<textarea
x-model="inputMsg"
@input="autoResize($el)"
@keydown.enter.prevent="sendMessage()"
class="flex-1 bg-white border border-[#e2e2e2] rounded-lg px-4 py-2.5 text-[16px] leading-snug outline-none resize-none max-h-32 min-h-[44px] focus:ring-0 shadow-inner"
rows="1"
placeholder="请输入消息..."></textarea>
<button
@click="sendMessage()"
:disabled="!inputMsg.trim() || isTyping"
class="mb-1 px-5 py-2 rounded-lg font-bold text-[15px] transition-all transform active:scale-95"
:class="inputMsg.trim() ? 'bg-[#07c160] text-white' : 'bg-[#e1e1e1] text-[#b2b2b2]'"
x-text="isTyping ? '...' : '发送'">
</button>
</div>
</footer>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('weChat', () => ({
messages: [
{ role: 'ai', content: '您好!我是您的专属助理小慧。有什么可以帮到您?' }
],
inputMsg: '',
isTyping: false,
chatTime: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
autoResize(el) {
el.style.height = '44px';
el.style.height = el.scrollHeight + 'px';
},
renderContent(raw) {
if (!raw) return '';
// 过滤思考过程
let cleanText = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
// 过滤多余的推理前缀逻辑
const filtered = cleanText.replace(/^用户(再次)?要求我[\s\S]*?。/g, '').trim();
return marked.parse(filtered.length > 2 ? filtered : cleanText);
},
scrollToBottom() {
setTimeout(() => {
const container = document.getElementById('chat-container');
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}, 100);
},
async sendMessage() {
const msg = this.inputMsg.trim();
if (!msg || this.isTyping) return;
// 用户消息上屏
this.messages.push({ role: 'user', content: msg });
this.inputMsg = '';
const ta = document.querySelector('textarea');
if(ta) ta.style.height = '44px';
this.isTyping = true;
this.scrollToBottom();
try {
const res = await fetch('/pb/pb-api-v1/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg })
});
const json = await res.json();
if (json.code === 200) {
const fullReply = json.reply || "";
const targetIndex = this.messages.length;
// 预留 AI 气泡位置
this.messages.push({ role: 'ai', content: '' });
// 模拟前端打字机效果
let i = 0;
const timer = setInterval(() => {
if (i < fullReply.length) {
this.messages[targetIndex].content += fullReply.charAt(i);
i++;
this.scrollToBottom();
} else {
clearInterval(timer);
}
}, 25);
} else {
this.messages.push({ role: 'ai', content: '⚠️ 抱歉,我刚才走神了,请重试。' });
}
} catch (e) {
this.messages.push({ role: 'ai', content: '❌ 信号似乎不太好,请稍后再试。' });
} finally {
this.isTyping = false;
this.scrollToBottom();
}
}
}));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>BAI SQL 实验室 - 数据库探勘</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; }
/* 针对表头拖拽改变宽度的特化样式 */
.resizable-th { resize: horizontal; overflow: hidden; min-width: 100px; padding-bottom: 2px; }
</style>
</head>
<body class="bg-slate-900 text-slate-300 h-screen flex flex-col font-mono" x-data="sqlLab()">
<header class="h-14 bg-slate-950 border-b border-slate-800 flex items-center px-6 justify-between shrink-0 z-10 shadow-sm">
<div class="flex items-center space-x-4">
<span class="text-emerald-500 font-bold text-lg tracking-widest">BAI SQL LAB</span>
<div class="flex space-x-3 text-xs bg-slate-900 px-3 py-1.5 rounded-md border border-slate-800 items-center">
<div class="flex items-center" title="Python 探针网络状态">
<span class="w-2 h-2 rounded-full mr-2 transition-colors duration-300" :class="diag.py_network === 'ALIVE' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : (diag.py_network === 'WAIT' ? 'bg-amber-500 animate-pulse' : 'bg-red-500')"></span>
<span class="font-bold">PYTHON</span>
</div>
<div class="flex items-center ml-4" title="SQLite 核心读取状态">
<span class="w-2 h-2 rounded-full mr-2 transition-colors duration-300" :class="diag.sqlite_storage === 'READY' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : (diag.sqlite_storage === 'WAIT' ? 'bg-amber-500 animate-pulse' : 'bg-red-500')"></span>
<span class="font-bold">SQLITE <span x-show="diag.sqlite_version" x-text="'v' + diag.sqlite_version" class="text-slate-400 ml-1 font-normal"></span></span>
</div>
<div class="h-4 border-l border-slate-700 ml-3 mr-1"></div>
<button @click="runDiagnostics()" :disabled="checking" class="text-slate-400 hover:text-emerald-400 transition-colors disabled:opacity-50" title="手动刷新探针状态">
<svg class="w-4 h-4" :class="{'animate-spin text-emerald-500': checking}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
</div>
<div class="text-[10px] text-slate-500 max-w-xs truncate" :title="diag.sqlite_detail" x-text="diag.sqlite_detail"></div>
</header>
<div class="flex-1 flex overflow-hidden">
<aside class="w-[300px] bg-slate-950 border-r border-slate-800 flex flex-col shrink-0">
<div class="p-3 border-b border-slate-800 flex justify-between items-center bg-slate-900">
<div class="flex flex-col">
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider">物理表清单</h3>
<span class="text-[10px] text-amber-500 mt-0.5" x-show="totalDbSize">估算总容量: <span x-text="totalDbSize"></span></span>
</div>
<button @click="fetchTables()" :disabled="loadingTables" class="text-emerald-500 hover:text-emerald-400" title="刷新表结构">
<svg class="w-4 h-4" :class="{'animate-spin': loadingTables}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-0.5">
<div x-show="loadingTables" class="text-xs text-slate-500 p-2 text-center animate-pulse">正在测绘数据库地形...</div>
<template x-for="tbl in tables" :key="tbl.name">
<div
@click="loadTable(tbl.name, 1)"
class="flex p-2 rounded cursor-pointer hover:bg-slate-800 border border-transparent hover:border-slate-700 transition-all text-xs"
:class="{'bg-slate-800 border-slate-700': navTable === tbl.name}"
>
<span class="text-emerald-400 truncate w-full" :title="tbl.name" x-text="`${tbl.name} (${tbl.count})(${tbl.size})`"></span>
</div>
</template>
</div>
</aside>
<main class="flex-1 flex flex-col bg-slate-900 overflow-hidden relative">
<div class="p-4 border-b border-slate-800 bg-[#0d1117] shrink-0 flex flex-col shadow-inner">
<textarea
x-model="sql"
@input="navTable = ''"
class="w-full h-32 bg-transparent border-none text-emerald-400 text-sm outline-none focus:ring-0 resize-none font-mono"
placeholder="-- 输入标准的 SQLite 语句..."
@keydown.ctrl.enter="execute()"
></textarea>
<div class="mt-2 flex justify-between items-center border-t border-slate-800 pt-3">
<span class="text-[11px] text-slate-500">快捷键: <kbd class="bg-slate-800 px-1 rounded text-slate-300">Ctrl</kbd> + <kbd class="bg-slate-800 px-1 rounded text-slate-300">Enter</kbd> 执行</span>
<button
@click="execute()"
:disabled="loading"
class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-1.5 rounded font-bold text-xs disabled:opacity-50 transition-all flex items-center shadow-lg shadow-emerald-900/20"
>
<span x-show="!loading">▶ 执行 (Run)</span>
<span x-show="loading" class="animate-pulse">穿透中...</span>
</button>
</div>
</div>
<div class="flex-1 overflow-hidden flex flex-col bg-slate-950 relative">
<div class="p-4 overflow-auto flex-1 flex flex-col relative">
<div x-show="error" class="bg-red-950/50 border border-red-800 text-red-400 p-4 rounded-lg text-sm mb-4 break-words shrink-0" x-text="error"></div>
<div x-show="results.length > 0" class="flex flex-col flex-1 min-h-0">
<div class="flex justify-between items-end mb-2 shrink-0">
<span class="text-xs text-slate-500">本页返回: <span class="text-emerald-500 font-bold" x-text="results.length"></span></span>
<button
@click="copyToClipboard"
class="flex items-center gap-1 px-3 py-1 text-xs font-medium text-emerald-400 bg-emerald-900/30 border border-emerald-800/50 rounded hover:bg-emerald-800/50 transition-colors shadow-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
复制 Markdown
</button>
</div>
<div class="border border-slate-800 rounded overflow-auto bg-[#0f172a] shadow-inner flex-1 max-h-full">
<table class="min-w-full divide-y divide-slate-800 text-xs w-max">
<thead class="bg-slate-900 sticky top-0 z-10 shadow">
<tr>
<template x-for="col in Object.keys(results[0] || {})">
<th class="border-r border-slate-800 align-top bg-slate-900">
<div class="resizable-th px-3 py-2 text-left text-slate-400 font-bold uppercase tracking-wider" x-text="col"></div>
</th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
<template x-for="(row, idx) in results" :key="idx">
<tr class="hover:bg-slate-800/70 transition-colors">
<template x-for="val in Object.values(row)">
<td class="px-3 py-1.5 text-slate-300 border-r border-slate-800/50 max-w-[500px] truncate" :title="formatValue(val)" x-text="formatValue(val)" :class="{'text-slate-500 italic': val === null}"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div x-show="!loading && !error && executed && results.length === 0" class="flex items-center justify-center h-full text-slate-600 text-sm italic">
SQL 穿透成功,但返回了 0 行数据。
</div>
</div>
<div x-show="navTable" class="bg-slate-950 border-t border-slate-800 p-2 px-4 flex justify-between items-center shrink-0">
<span class="text-xs text-slate-500">当前表: <span class="text-emerald-400 font-bold" x-text="navTable"></span> | 第 <span class="text-white bg-slate-800 px-1.5 rounded" x-text="navPage"></span></span>
<div class="flex space-x-2 text-xs">
<button @click="loadTable(navTable, navPage - 1)" :disabled="navPage <= 1" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded text-slate-300 disabled:opacity-30 transition-colors">上一页</button>
<button @click="loadTable(navTable, navPage + 1)" :disabled="results.length < 50" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded text-slate-300 disabled:opacity-30 transition-colors">下一页</button>
</div>
</div>
</div>
</main>
<aside class="w-56 bg-slate-950 border-l border-slate-800 p-4 flex flex-col space-y-6 overflow-y-auto shrink-0">
<div>
<h3 class="text-[11px] font-bold text-slate-500 mb-2 uppercase tracking-wider">结构探测库</h3>
<div class="space-y-1.5 text-xs">
<button @click="runQuery('SELECT name, sql FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\';')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-emerald-400 rounded transition-colors border border-slate-800">所有物理表 DDL</button>
<button @click="runQuery('PRAGMA database_list;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-blue-400 rounded transition-colors border border-slate-800">数据库绑定列表</button>
<button @click="runQuery('PRAGMA foreign_key_list(workspace_chats);')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-purple-400 rounded transition-colors border border-slate-800">Chats 外键约束</button>
<button @click="runQuery('PRAGMA foreign_key_list(workspace_threads);')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-purple-400 rounded transition-colors border border-slate-800">Threads 外键约束</button>
</div>
</div>
<div>
<h3 class="text-[11px] font-bold text-slate-500 mb-2 uppercase tracking-wider">系统变量</h3>
<div class="space-y-1.5 text-xs">
<button @click="runQuery('PRAGMA page_size;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-amber-400 rounded transition-colors border border-slate-800">页大小 (Page Size)</button>
<button @click="runQuery('PRAGMA page_count;')" class="w-full text-left px-2 py-1.5 bg-slate-900 hover:bg-slate-800 text-amber-400 rounded transition-colors border border-slate-800">总页数 (Page Count)</button>
</div>
</div>
</aside>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('sqlLab', () => ({
diag: { py_network: 'WAIT', sqlite_storage: 'WAIT', sqlite_version: '', sqlite_detail: 'Connecting...' },
checking: false,
sql: 'SELECT name FROM sqlite_master WHERE type=\'table\';',
results: [],
tables: [],
totalDbSize: '',
loading: false,
loadingTables: false,
error: '',
executed: false,
// 分页状态管理
navTable: '',
navPage: 1,
get apiBase() {
return (window.location.pathname.startsWith('/pb') ? '/pb' : '') + '/pb-api-v1';
},
async init() {
this.runDiagnostics();
this.fetchTables();
},
async runDiagnostics() {
if (this.checking) return;
this.checking = true;
this.diag.py_network = 'WAIT';
this.diag.sqlite_storage = 'WAIT';
try {
const res = await fetch(this.apiBase + '/diagnostics');
const json = await res.json();
this.diag = json;
} catch (e) {
this.diag.py_network = 'ERROR';
this.diag.sqlite_storage = 'ERROR';
this.diag.sqlite_detail = '失联: ' + e.message;
} finally {
setTimeout(() => { this.checking = false; }, 500);
}
},
// 时间与数据格式化引擎 (UTC+8 毫秒转换)
formatValue(val) {
if (val === null) return 'NULL';
// 嗅探 13 位数字 (大概范围从 2001年 到 2049年)
if (typeof val === 'number' && val > 1000000000000 && val < 2500000000000) {
const d = new Date(val);
// 强制换算到 UTC+8 抵消本地宿主机可能的时区干扰
const utcTime = d.getTime() + (d.getTimezoneOffset() * 60000);
const utc8Time = new Date(utcTime + (8 * 3600000));
const pad = (n, len=2) => String(n).padStart(len, '0');
return `${utc8Time.getFullYear()}-${pad(utc8Time.getMonth()+1)}-${pad(utc8Time.getDate())} ${pad(utc8Time.getHours())}:${pad(utc8Time.getMinutes())}:${pad(utc8Time.getSeconds())}.${pad(utc8Time.getMilliseconds(), 3)}`;
}
if (typeof val === 'object') return JSON.stringify(val);
return String(val);
},
async runRawQuery(querySql) {
try {
const res = await fetch(this.apiBase + '/sql-ops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql: querySql })
});
const json = await res.json();
return (res.status === 200 && json.code === 200) ? json.data : null;
} catch (e) { return null; }
},
// 格式化文件大小
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + sizes[i];
},
async fetchTables() {
this.loadingTables = true;
try {
const rawTables = await this.runRawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;");
if (!rawTables || rawTables.length === 0) return;
const tableNames = rawTables.map(t => t.name);
const countQueries = tableNames.map(name => `SELECT '${name}' AS tableName, COUNT(*) AS rowCount FROM "${name}"`);
const masterCountSql = countQueries.join(' UNION ALL ');
const countsData = await this.runRawQuery(masterCountSql);
if (countsData) {
let totalBytes = 0;
this.tables = countsData.map(row => {
const count = row.rowCount;
const estBytes = count * 512;
totalBytes += estBytes;
return { name: row.tableName, count: count, size: this.formatSize(estBytes) };
});
this.totalDbSize = this.formatSize(totalBytes);
}
} catch (e) {
console.error("加载表结构失败", e);
} finally {
this.loadingTables = false;
}
},
// 点击左侧表名的单表分页加载器
loadTable(tableName, page = 1) {
this.navTable = tableName;
this.navPage = page;
const offset = (page - 1) * 50;
this.sql = `SELECT * FROM "${tableName}" ORDER BY id DESC LIMIT 50 OFFSET ${offset};`;
this.execute(true); // true 代表内部调用,不清理分页状态
},
runQuery(query) {
this.navTable = ''; // 手动执行其他按钮清理分页状态
this.sql = query;
this.execute();
},
async execute(isFromNav = false) {
if (!this.sql.trim()) return;
// 如果是编辑器手动按Ctrl+Enter或者点击执行按钮清理当前分页绑定
if (!isFromNav) this.navTable = '';
this.loading = true; this.error = ''; this.results = []; this.executed = true;
try {
const res = await fetch(this.apiBase + '/sql-ops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql: this.sql })
});
const json = await res.json();
if (res.status === 200 && json.code === 200) {
this.results = json.data;
} else {
this.error = `[探针阻断 HTTP ${res.status}] ${json.message}`;
}
} catch (e) {
this.error = "网络异常: " + e.message;
} finally {
this.loading = false;
}
},
copyToClipboard() {
if (!this.results || this.results.length === 0) return;
const headers = Object.keys(this.results[0]);
let mdTable = '| ' + headers.join(' | ') + ' |\n';
mdTable += '| ' + headers.map(() => '---').join(' | ') + ' |\n';
this.results.forEach(row => {
const rowData = headers.map(header => {
let cellData = row[header];
if (cellData === null) return '`NULL`';
if (typeof cellData === 'object') cellData = JSON.stringify(cellData);
return String(cellData).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
});
mdTable += '| ' + rowData.join(' | ') + ' |\n';
});
navigator.clipboard.writeText(mdTable).then(() => {
const btn = document.activeElement;
const originalText = btn.innerHTML;
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> 复制成功`;
btn.classList.add('text-white', 'bg-emerald-600');
setTimeout(() => { btn.innerHTML = originalText; btn.classList.remove('text-white', 'bg-emerald-600'); }, 1500);
});
}
}));
});
</script>
</body>
</html>

View File

@@ -19,6 +19,8 @@ routerAdd('GET', '/manage', function (e) {
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
h1 { margin: 0 0 14px; font-size: 30px; }
.module + .module { margin-top: 18px; }
.module-title { margin: 0 0 10px; font-size: 22px; color: #0f172a; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
.card h2 { margin: 0 0 8px; font-size: 19px; }
@@ -33,24 +35,44 @@ routerAdd('GET', '/manage', function (e) {
<main class="wrap">
<section class="hero">
<h1>管理主页</h1>
<div class="grid">
<article class="card">
<h2>字典管理</h2>
<a class="btn" href="/pb/manage/dictionary-manage">进入字典管理</a>
</article>
<article class="card">
<h2>文档管理</h2>
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
</article>
<article class="card">
<h2>产品管理</h2>
<a class="btn" href="/pb/manage/product-manage">进入产品管理</a>
</article>
<article class="card">
<h2>SDK 权限管理</h2>
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
</article>
</div>
<section class="module">
<h2 class="module-title">平台管理</h2>
<div class="grid">
<article class="card">
<h2>字典管理</h2>
<a class="btn" href="/pb/manage/dictionary-manage">进入字典管理</a>
</article>
<article class="card">
<h2>文档管理</h2>
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
</article>
<article class="card">
<h2>产品管理</h2>
<a class="btn" href="/pb/manage/product-manage">进入产品管理</a>
</article>
<article class="card">
<h2>SDK 权限管理</h2>
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
</article>
</div>
</section>
<section class="module">
<h2 class="module-title">AI 管理</h2>
<div class="grid">
<article class="card">
<h2>AI 审计管理</h2>
<a class="btn" href="/pb/bai-ai-manage">进入审计管理</a>
</article>
<article class="card">
<h2>AI 聊天测试</h2>
<a class="btn" href="/pb/bai-chat">进入聊天测试</a>
</article>
<article class="card">
<h2>SQL 实验室</h2>
<a class="btn" href="/pb/bai-ai-sql-lab">进入 SQL 实验室</a>
</article>
</div>
</section>
<div class="actions">
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>
</div>

View File

@@ -197,13 +197,14 @@ routerAdd('GET', '/manage/product-manage', function (e) {
</table>
</div>
<div style="margin-top:10px;">
<label for="paramsJsonInput">批量导入 JSON</label>
<label for="paramsJsonInput">批量导入/导出 JSON</label>
<textarea id="paramsJsonInput" placeholder='示例:{"属性名":"属性值","电压":"220v"}'></textarea>
<div class="hint">仅支持 JSON 对象格式。导入时按属性名增量合并:同名覆盖、不同名新增,不会清空当前参数表。</div>
</div>
<div class="param-actions" style="margin-top:10px;">
<button class="btn btn-light" id="addParamBtn" type="button">新增参数行</button>
<button class="btn btn-secondary" id="importParamsBtn" type="button">批量导入</button>
<button class="btn btn-light" id="exportParamsBtn" type="button">批量导出</button>
</div>
</div>
<div class="full">
@@ -878,6 +879,42 @@ routerAdd('GET', '/manage/product-manage', function (e) {
return result
}
async function exportParametersToJson() {
const rows = collectParameterArray()
const exportObject = {}
for (let i = 0; i < rows.length; i += 1) {
const name = normalizeText(rows[i].name)
if (!name) {
continue
}
exportObject[name] = rows[i].value === null || typeof rows[i].value === 'undefined'
? ''
: String(rows[i].value)
}
const keys = Object.keys(exportObject)
if (!keys.length) {
setStatus('当前参数表为空,暂无可导出的内容。', 'error')
return
}
const jsonText = JSON.stringify(exportObject, null, 2)
fields.paramsJsonInput.value = jsonText
let copied = false
try {
if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(jsonText)
copied = true
}
} catch (_error) {}
setStatus(copied
? '参数已批量导出,已写入导入框并复制到剪贴板。'
: '参数已批量导出到导入框,可直接复制后用于一键导入。', 'success')
}
function normalizeParameterRows(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return []
@@ -1367,6 +1404,13 @@ routerAdd('GET', '/manage/product-manage', function (e) {
importParametersFromJson()
})
const exportParamsBtn = document.getElementById('exportParamsBtn')
if (exportParamsBtn) {
exportParamsBtn.addEventListener('click', function () {
exportParametersToJson()
})
}
if (fields.sort) {
fields.sort.addEventListener('input', function () {
renderSortRankHint()