diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index b2a089b9..3116c60a 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -1,2035 +1,2063 @@ -(() => { - "use strict"; - - const PAGE_META = { - home: ["page.home.kicker", "page.home.title", "Dashboard", "完整内嵌 WebUI"], - insights: ["page.insights.kicker", "page.insights.title", "Insights", "AI 巡检"], - monitoring: ["page.monitoring.kicker", "page.monitoring.title", "Monitoring", "运行监控"], - reviews: ["page.reviews.kicker", "page.reviews.title", "Reviews", "审查队列"], - "jargon-learning": ["page.jargon.kicker", "page.jargon.title", "Jargon", "黑话学习"], - "expression-learning": ["page.style.kicker", "page.style.title", "Expression", "表达方式学习"], - "persona-learning": ["page.persona.kicker", "page.persona.title", "Persona", "人格学习"], - content: ["page.content.kicker", "page.content.title", "Content", "学习内容"], - graphs: ["page.graphs.kicker", "page.graphs.title", "Graphs", "图谱"], - "reply-strategy": ["page.reply.kicker", "page.reply.title", "Reply", "回复策略"], - integrations: ["page.integrations.kicker", "page.integrations.title", "Integrations", "功能融合"], - settings: ["page.settings.kicker", "page.settings.title", "Settings", "设置"], - }; - const I18N_ROOT = "pages.dashboard"; - const GRAPH_SAFE_PADDING = 34; - const GRAPH_HOME_STRENGTH = 0.0064; - const GRAPH_CENTER_STRENGTH = 0.00016; - const GRAPH_LINK_STRENGTH = 0.000035; - - const state = { - page: "home", - ready: false, - dashboard: null, - overview: null, - pageData: {}, - selectedReviews: { - persona: new Set(), - style: new Set(), - jargon: new Set(), - }, - selectedJargon: new Set(), - contentType: "dialogues", - settingsGroup: null, - dirtySettings: new Map(), - graph: { - nodes: [], - links: [], - running: false, - dragged: null, - hovered: null, - type: "memory", - width: 0, - height: 0, - canvasBound: false, - }, - toastTimer: null, - }; - - const physics = { - particles: [], - pointer: { x: 0, y: 0, active: false }, - running: false, - last: 0, - }; - - const $ = (id) => document.getElementById(id); - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - - function locale() { - try { - const bridge = window.AstrBotPluginPage; - return bridge?.getLocale?.() || bridge?.getContext?.()?.locale || document.documentElement.lang || "zh-CN"; - } catch (_) { - return document.documentElement.lang || "zh-CN"; - } - } - - function t(key, fallback = "") { - const fullKey = key.startsWith("pages.") || key.startsWith("metadata.") || key.startsWith("config.") - ? key - : `${I18N_ROOT}.${key}`; - try { - const bridge = window.AstrBotPluginPage; - const value = bridge?.t?.(fullKey, fallback); - if (value !== undefined && value !== null && value !== fullKey) return String(value); - } catch (_) {} - return String(fallback || ""); - } - - function configT(path, fallback = "") { - return t(`config.${path}`, fallback); - } - - function applyStaticI18n(root = document) { - document.documentElement.lang = locale(); - qsa("[data-i18n]", root).forEach((el) => { - el.textContent = t(el.dataset.i18n, el.textContent); - }); - qsa("[data-i18n-title]", root).forEach((el) => { - el.setAttribute("title", t(el.dataset.i18nTitle, el.getAttribute("title") || "")); - }); - qsa("[data-i18n-aria-label]", root).forEach((el) => { - el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel, el.getAttribute("aria-label") || "")); - }); - qsa("[data-i18n-placeholder]", root).forEach((el) => { - el.setAttribute("placeholder", t(el.dataset.i18nPlaceholder, el.getAttribute("placeholder") || "")); - }); - } - - function endpoint(path) { - return `page/${String(path || "").replace(/^\/+/, "").replace(/\/+/g, "/")}`; - } - - async function bridgeReady() { - const bridge = window.AstrBotPluginPage; - if (!bridge) { - throw new Error(t("errors.bridgeMissing", "AstrBot 插件页桥接 SDK 未加载")); - } - const context = await bridge.ready(); - state.ready = true; - return context; - } - - async function apiGet(path, params) { - const bridge = window.AstrBotPluginPage; - await bridgeReady(); - return unwrap(await bridge.apiGet(endpoint(path), params || {})); - } - - async function apiPost(path, body) { - const bridge = window.AstrBotPluginPage; - await bridgeReady(); - return unwrap(await bridge.apiPost(endpoint(path), body || {})); - } - - function unwrap(response) { - const body = response && response.data && response.data.status ? response.data : response; - if (body && body.status === "ok") { - return body.data || {}; - } - if (body && body.status === "error") { - throw new Error(body.message || t("errors.requestFailed", "请求失败")); - } - if (body && body.success === false) { - throw new Error(body.message || body.error || t("errors.requestFailed", "请求失败")); - } - return body || {}; - } - - function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); - } - - function localNavigationHost(hostname) { - const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); - if (!host) return true; - return host === "localhost" - || host === "0.0.0.0" - || host === "::" - || host === "::1" - || host === "0:0:0:0:0:0:0:0" - || host === "0:0:0:0:0:0:0:1" - || /^127(?:\.\d{1,3}){3}$/.test(host); - } - - function hostForUrl(hostname) { - const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1"); - return host.includes(":") ? `[${host}]` : host; - } - - function resolveHostUrl(value) { - const raw = String(value || "").trim(); - if (!raw || raw === "#") return raw || "#"; - if (raw.startsWith("#")) return raw; - - let parsed; - try { - parsed = new URL(raw, window.location.href); - } catch (_) { - return raw; - } - - if (!/^https?:$/.test(parsed.protocol) || !localNavigationHost(parsed.hostname)) { - return raw; - } - - const browserHost = window.location.hostname; - if (!browserHost) return raw; - const replacementHost = hostForUrl(browserHost); - parsed.host = parsed.port ? `${replacementHost}:${parsed.port}` : replacementHost; - return parsed.href; - } - - function fmt(value, digits = 1) { - const num = Number(value || 0); - if (!Number.isFinite(num)) return "0"; - return new Intl.NumberFormat(locale(), { maximumFractionDigits: digits }).format(num); - } - - function normalizeScore(value) { - const num = Number(value || 0); - if (!Number.isFinite(num)) return 0; - return Math.max(0, Math.min(100, num <= 1 ? num * 100 : num)); - } - - function setText(id, value) { - const el = $(id); - if (el) el.textContent = value; - } - - function setHtml(id, html) { - const el = $(id); - if (el) el.innerHTML = html; - } - - function empty(text = t("empty.default", "暂无数据")) { - return `
${escapeHtml(text)}
`; - } - - function pill(text, tone = "") { - return `${escapeHtml(text)}`; - } - - function button(label, attrs = "", cls = "ghost-button") { - return ``; - } - - function normalizeId(id) { - return String(id ?? "").trim(); - } - - function selectedReviewSet(kind) { - if (!state.selectedReviews[kind]) state.selectedReviews[kind] = new Set(); - return state.selectedReviews[kind]; - } - - function selectedReviewIds(kind) { - return Array.from(selectedReviewSet(kind)).filter(Boolean); - } - - function isReviewSelected(kind, id) { - return selectedReviewSet(kind).has(normalizeId(id)); - } - - function isJargonSelected(id) { - return state.selectedJargon.has(normalizeId(id)); - } - - function pruneSelection(selection, validIds) { - const valid = new Set((validIds || []).map(normalizeId).filter(Boolean)); - Array.from(selection).forEach((id) => { - if (!valid.has(id)) selection.delete(id); - }); - } - - function reviewCheckbox(kind, id) { - const safeId = normalizeId(id); - return ``; - } - - function jargonCheckbox(id) { - const safeId = normalizeId(id); - return ``; - } - - function reviewCountText(kind, total) { - const selected = selectedReviewIds(kind).length; - return selected ? `${fmt(selected, 0)}/${fmt(total, 0)}` : fmt(total, 0); - } - - function visibleReviewIds(kind) { - const reviews = state.pageData.reviews || {}; - if (kind === "persona") { - return ((reviews.persona_pending || {}).updates || []) - .filter((item) => item && item.review_source !== "style_learning") - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - if (kind === "style") { - return ((reviews.style_reviews || {}).reviews || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - if (kind === "jargon") { - return (((reviews.jargon_pending || {}).jargon_list) || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - return []; - } - - function stylePageReviewIds() { - const style = state.pageData.style || {}; - return (((style.reviews || {}).reviews) || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - - function jargonPageIds() { - return (state.pageData.lastJargonItems || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - - function refreshSelectionLabels() { - ["persona", "style", "jargon"].forEach((kind) => { - const ids = visibleReviewIds(kind); - setText(`${kind}-review-count`, reviewCountText(kind, ids.length)); - }); - setText("expression-review-count", t("selection.selectedCount", "已选 {count}").replace("{count}", fmt(selectedReviewIds("style").length, 0))); - const selectedJargon = Array.from(state.selectedJargon).filter(Boolean).length; - const visibleJargon = jargonPageIds().length; - setText("jargon-selection-count", t("selection.selectedOfTotal", "已选 {selected}/{total}") - .replace("{selected}", fmt(selectedJargon, 0)) - .replace("{total}", fmt(visibleJargon, 0))); - } - - function setBusy(label = t("status.loading", "加载中")) { - setText("runtime-status", label); - setText("hero-status", label); - } - - function showToast(message, tone = "ok") { - const region = $("toast-region"); - if (!region) return; - if (state.toastTimer) { - clearTimeout(state.toastTimer); - state.toastTimer = null; - } - region.replaceChildren(); - const el = document.createElement("div"); - el.className = `toast ${tone}`; - const text = document.createElement("span"); - text.textContent = message; - const close = document.createElement("button"); - close.className = "toast-close"; - close.type = "button"; - close.setAttribute("aria-label", t("actions.closeToast", "关闭提示")); - close.textContent = "×"; - close.addEventListener("click", () => { - if (state.toastTimer) clearTimeout(state.toastTimer); - el.remove(); - }); - el.append(text, close); - region.appendChild(el); - state.toastTimer = setTimeout(() => { - el.classList.add("leaving"); - setTimeout(() => { - el.remove(); - if (state.toastTimer) state.toastTimer = null; - }, 220); - }, 3200); - } - - function warningListHtml(warnings) { - const items = Array.isArray(warnings) - ? warnings - .map((message) => String(message).trim()) - .filter(Boolean) - : []; - return items.map((message) => ` -
- 成本提醒 - ${escapeHtml(message)} -
- `).join(""); - } - - function showErrors(errors) { - const panel = $("error-panel"); - if (!panel) return; - const entries = Object.entries(errors || {}); - panel.hidden = entries.length === 0; - panel.innerHTML = entries - .map(([key, value]) => `

${escapeHtml(key)}: ${escapeHtml(value)}

`) - .join(""); - } - - function showModal(title, html) { - const modal = $("detail-modal"); - setText("modal-title", title); - setHtml("modal-body", html); - if (!modal) return; - if (modal.open && typeof modal.close === "function") { - modal.close(); - } - if (typeof modal.showModal === "function") { - try { - modal.showModal(); - return; - } catch (_) {} - } - modal.setAttribute("open", ""); - } - - function closeModal() { - const modal = $("detail-modal"); - if (!modal) return; - if (typeof modal.close === "function") modal.close(); - else modal.removeAttribute("open"); - } - - function resolvePageFromHash() { - const raw = window.location.hash.replace(/^#\/?/, ""); - return PAGE_META[raw] ? raw : "home"; - } - - function navigateToPage(page, options = {}) { - const next = PAGE_META[page] ? page : "home"; - state.page = next; - if (!options.skipHash) { - window.location.hash = `#/${next}`; - } - qsa(".page").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); - qsa(".nav-item").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); - const meta = PAGE_META[next] || PAGE_META.home; - setText("page-kicker", t(meta[0], meta[2])); - setText("page-title", t(meta[1], meta[3])); - loadPageData(next, { force: !!options.force }); - } - - async function loadDashboard(force = false) { - if (state.dashboard && !force) { - renderDashboard(state.dashboard); - return state.dashboard; - } - setBusy(t("status.syncing", "同步中")); - try { - const data = await apiGet("dashboard"); - state.dashboard = data; - state.overview = data.overview || data; - renderDashboard(data); - return data; - } catch (error) { - showToast(error.message || String(error), "error"); - showErrors({ bridge: error.message || String(error) }); - throw error; - } - } - - async function loadPageData(page, options = {}) { - const force = !!options.force; - try { - if (page === "home" || page === "insights") { - const data = await loadDashboard(force); - if (page === "insights") renderInsights(data); - return; - } - if (page === "monitoring") return renderMonitoring(await cached("monitoring", () => apiGet("monitoring"), force)); - if (page === "reviews") return renderReviews(await cached("reviews", () => apiGet("reviews", { limit: 50 }), force)); - if (page === "jargon-learning") return loadJargon(force); - if (page === "expression-learning") return renderStyle(await cached("style", () => apiGet("style", { limit: 50 }), force)); - if (page === "persona-learning") return renderPersona(await cached("persona", () => apiGet("persona", { group_id: "default", limit: 30 }), force)); - if (page === "content") return renderContent(await cached("content", () => apiGet("content", { page: 1, page_size: 20 }), force)); - if (page === "graphs") return loadGraphs(force); - if (page === "reply-strategy") return renderReplyStrategy(await cached("integrations", () => apiGet("integrations"), force)); - if (page === "integrations") return renderIntegrations(await cached("integrations", () => apiGet("integrations"), force)); - if (page === "settings") return renderSettings(await cached("settings", () => apiGet("settings", { schema: "true" }), force)); - } catch (error) { - showToast(error.message || String(error), "error"); - } - } - - async function cached(key, loader, force) { - if (!force && state.pageData[key]) return state.pageData[key]; - setBusy(t("status.loading", "加载中")); - const data = await loader(); - state.pageData[key] = data; - return data; - } - - function renderDashboard(data) { - const overview = data.overview || data; - const runtime = overview.runtime || {}; - const webui = overview.webui || {}; - const learning = overview.learning_stats || {}; - const jargon = overview.jargon || {}; - const styleStats = ((overview.style || {}).statistics) || {}; - const persona = overview.persona || {}; - const errors = data.errors || overview.errors || {}; - const degraded = runtime.database_degraded || Object.keys(errors).length > 0; - - const statusLabel = degraded ? t("status.partial", "部分可用") : t("status.healthy", "运行正常"); - const resolvedDashboardUrl = resolveHostUrl(webui.dashboard_url || ""); - const summary = degraded - ? t("status.degradedSummary", "嵌入式页面已载入,部分服务处于降级状态。") - : t("status.connectedSummary", "已连接官方插件页 API,独立 WebUI: {url}") - .replace("{url}", resolvedDashboardUrl || t("status.notConfigured", "未配置")); - setText("runtime-status", statusLabel); - setText("hero-status", statusLabel); - setText("runtime-summary", summary); - setText("hero-summary", summary); - $("runtime-status")?.classList.toggle("warn", degraded); - $("hero-status")?.classList.toggle("warn", degraded); - - const fullLink = $("full-dashboard-link"); - if (fullLink && resolvedDashboardUrl) fullLink.href = resolvedDashboardUrl; - - setText("stat-messages", fmt(learning.total_messages_collected)); - setText("stat-jargon", fmt(jargon.confirmed_jargon)); - setText("stat-style", fmt(styleStats.unique_styles || styleStats.total_samples)); - setText("stat-persona", fmt(learning.persona_updates || persona.begin_dialog_count)); - - renderQuickActions(overview.quick_links || []); - renderModuleCards(overview.modules || []); - renderModuleChart(overview.modules || []); - renderIntelligence(overview.metrics || {}); - renderInsights(data); - showErrors(errors); - } - - function renderQuickActions(links) { - const html = links.map((link) => { - const url = resolveHostUrl(link.url || "#"); - const external = /^https?:\/\//.test(String(url || "")); - return ` - ${escapeHtml(link.label || t("actions.entry", "入口"))} - ${escapeHtml(link.description || "")} - `; - }).join(""); - setHtml("quick-actions", html); - } - - function renderModuleCards(modules) { - const html = modules.map((item) => ` -
-
-

${escapeHtml(item.title)}

- ${pill(item.enabled ? t("state.enabled", "启用") : t("state.closed", "关闭"), item.enabled ? "ok" : "warn")} -
-

${escapeHtml(item.description || "")}

-
- ${escapeHtml(fmt(item.metric))} - ${escapeHtml(item.metric_label || "")} -
-
- `).join(""); - setHtml("module-card-grid", html || empty()); - } - - function renderModuleChart(modules) { - const maxValue = Math.max(1, ...modules.map((item) => Number(item.metric || 0))); - const html = modules.map((item) => { - const value = Math.max(4, Math.min(100, (Number(item.metric || 0) / maxValue) * 100)); - return `
- ${escapeHtml(item.title)} -
- ${escapeHtml(fmt(item.metric))} -
`; - }).join(""); - setHtml("module-chart", html || empty()); - } - - function renderIntelligence(metrics) { - const score = normalizeScore(metrics.overall_score); - $("intelligence-ring")?.style.setProperty("--value", String(score)); - setText("intelligence-score", fmt(score)); - const dimCount = metrics.dimensions && typeof metrics.dimensions === "object" - ? Object.keys(metrics.dimensions).length - : 0; - setText("metrics-summary", dimCount - ? t("home.metricsSummary", "已有 {count} 个维度参与评估。").replace("{count}", fmt(dimCount, 0)) - : t("home.noMetrics", "智能指标服务暂未产生维度数据。")); - } - - function buildInsights(data) { - const overview = data.overview || {}; - const reviews = data.reviews || {}; - const monitoring = data.monitoring || {}; - const integrations = data.integrations || {}; - const errors = data.errors || {}; - const items = []; - const push = (severity, title, detail, target) => items.push({ severity, title, detail, target }); - - if ((overview.runtime || {}).database_degraded) { - push("warn", t("insights.databaseDegraded", "数据库处于降级状态"), (overview.runtime || {}).database_error || t("insights.databaseNotReady", "数据库服务未完整启动。"), "monitoring"); - } - const pendingPersona = ((reviews.persona_pending || {}).updates || []).length; - const pendingStyle = ((reviews.style_reviews || {}).reviews || []).length; - const pendingJargon = (((reviews.jargon_pending || {}).jargon_list) || []).length; - const totalBacklog = pendingPersona + pendingStyle + pendingJargon; - if (totalBacklog > 0) { - push("action", t("insights.reviewBacklog", "审查队列有积压"), t("insights.reviewBacklogDetail", "当前有 {count} 条学习结果等待确认。").replace("{count}", fmt(totalBacklog, 0)), "reviews"); - } - const score = normalizeScore(((overview.metrics || {}).overall_score)); - if (score > 0 && score < 60) { - push("warn", t("insights.lowScore", "智能评分偏低"), t("insights.lowScoreDetail", "综合评分 {score},建议查看表达样本和学习批次。").replace("{score}", fmt(score)), "metrics"); - } - const health = (monitoring.health || {}).overall; - if (health && health !== "healthy") { - push("warn", t("insights.healthWarn", "健康检查提示异常"), t("insights.healthWarnDetail", "当前健康状态为 {status}。").replace("{status}", health), "monitoring"); - } - const delegation = integrations.delegation || {}; - if (delegation.memory_delegated || delegation.reply_delegated) { - push("ok", t("insights.delegationEnabled", "伴随插件委托已启用"), t("insights.delegationDetail", "记忆委托: {memory},回复委托: {reply}。") - .replace("{memory}", delegation.memory_delegated ? t("state.yes", "是") : t("state.no", "否")) - .replace("{reply}", delegation.reply_delegated ? t("state.yes", "是") : t("state.no", "否")), "integrations"); - } - Object.entries(errors).forEach(([key, value]) => { - push("warn", t("insights.moduleFailed", "模块 {name} 读取失败").replace("{name}", key), String(value), "monitoring"); - }); - if (!items.length) { - push("ok", t("insights.noIssues", "暂无高优先级问题"), t("insights.noIssuesDetail", "核心学习、审查和监控模块均已返回可用数据。"), "home"); - } - return items; - } - - function renderInsights(data) { - const insights = buildInsights(data || state.dashboard || {}); - const html = insights.map((item) => ` -
- ${escapeHtml(item.severity === "ok" ? "OK" : item.severity === "action" ? "ACTION" : "WARN")} -

${escapeHtml(item.title)}

-

${escapeHtml(item.detail)}

- ${button(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} -
- `).join(""); - setHtml("ai-insight-list", html); - } - - function renderMonitoring(data) { - const health = data.health || {}; - const checks = health.checks || {}; - const healthHtml = Object.entries(checks).map(([key, item]) => ` -
- ${escapeHtml(key)} - ${escapeHtml(item.status || "unknown")} - ${escapeHtml(summarizeObject(item.detail || {}))} -
- `).join(""); - setHtml("health-grid", healthHtml || empty(t("empty.healthChecks", "暂无健康检查数据"))); - - const functions = ((data.functions || {}).functions || []).slice(0, 20); - const fnHtml = functions.map((item) => ` -
- ${escapeHtml(shortName(item.name))} - ${escapeHtml(fmt((item.duration || {}).avg || 0, 4))}s - ${escapeHtml(fmt(item.calls || 0, 0))} calls -
- `).join(""); - setHtml("function-list", fnHtml || empty((data.functions || {}).debug_mode ? t("empty.functionStats", "暂无函数性能数据") : t("empty.debugDisabled", "debug_mode 未启用"))); - showErrors(data.errors || {}); - } - - async function loadJargon(force) { - const confirmed = $("jargon-confirmed")?.value || ""; - const filter = $("jargon-filter")?.value || ""; - const keyword = $("jargon-keyword")?.value || ""; - const params = { page: 1, page_size: 30 }; - if (confirmed) params.confirmed = confirmed; - if (filter) params.filter = filter; - if (keyword) params.keyword = keyword; - const data = await cached(`jargon:${JSON.stringify(params)}`, () => apiGet("jargon", params), force); - renderJargon(data); - } - - function renderJargon(data) { - const stats = data.stats || {}; - setHtml("jargon-stat-grid", statCards([ - [t("jargon.stats.candidates", "候选词"), stats.total_candidates], - [t("jargon.stats.confirmed", "已确认"), stats.confirmed_jargon], - [t("jargon.stats.inferred", "推断完成"), stats.completed_inference], - [t("jargon.stats.activeGroups", "活跃群组"), stats.active_groups], - ])); - const items = ((data.list || {}).jargon_list || []); - pruneSelection(state.selectedJargon, items.map((item) => item.id)); - const html = items.map((item) => ` -
- ${jargonCheckbox(item.id)} -
- ${escapeHtml(item.term || item.content || `#${item.id}`)} - ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))} -
- ${escapeHtml(item.group_id || "global")} - ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")} - ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))} -
- ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.confirm", "确认"), `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.reject", "驳回"), `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)} - ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
-
- `).join(""); - setHtml("jargon-list", html || empty(t("empty.jargon", "暂无黑话数据"))); - state.pageData.lastJargonItems = items; - state.pageData.currentJargonData = data; - refreshSelectionLabels(); - showErrors(data.errors || {}); - } - - function renderStyle(data) { - const stats = ((data.results || {}).statistics) || {}; - setHtml("style-stat-grid", statCards([ - [t("style.stats.samples", "风格样本"), stats.unique_styles || stats.total_samples], - [t("style.stats.avgConfidence", "平均置信度"), stats.avg_confidence], - [t("style.stats.totalSamples", "总样本"), stats.total_samples], - [t("style.stats.latestUpdate", "最近更新"), stats.latest_update ? t("state.yes", "有") : t("state.none", "无")], - ])); - const patterns = data.patterns || {}; - const patternGroups = [ - [t("style.patterns.emotion", "情绪模式"), patterns.emotion_patterns || []], - [t("style.patterns.language", "语言模式"), patterns.language_patterns || []], - [t("style.patterns.topic", "话题模式"), patterns.topic_patterns || []], - ]; - setHtml("style-pattern-columns", patternGroups.map(([title, list]) => ` -
-

${escapeHtml(title)}

- ${(list || []).slice(0, 12).map((item) => `${escapeHtml(item.name || item.pattern || item.text || "")}`).join("") || empty(t("empty.patterns", "暂无模式"))} -
- `).join("")); - const chartItems = patternGroups.map(([title, list]) => ({ title, metric: (list || []).length, accent: "#4169e1" })); - renderGenericBarChart("style-pattern-chart", chartItems); - const reviews = ((data.reviews || {}).reviews || []); - pruneSelection(selectedReviewSet("style"), reviews.map((item) => item.id)); - setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); - state.pageData.lastStyleItems = reviews; - refreshSelectionLabels(); - } - - function renderReviews(data) { - const personaPending = ((data.persona_pending || {}).updates || []) - .filter((item) => item && item.review_source !== "style_learning"); - const personaReviewed = ((data.persona_reviewed || {}).updates || []); - const styleReviews = ((data.style_reviews || {}).reviews || []); - const pendingJargon = (((data.jargon_pending || {}).jargon_list) || []); - pruneSelection(selectedReviewSet("persona"), personaPending.map((item) => item.id)); - pruneSelection(selectedReviewSet("style"), styleReviews.map((item) => item.id)); - pruneSelection(selectedReviewSet("jargon"), pendingJargon.map((item) => item.id)); - setText("persona-review-count", reviewCountText("persona", personaPending.length)); - setText("style-review-count", reviewCountText("style", styleReviews.length)); - setText("jargon-review-count", reviewCountText("jargon", pendingJargon.length)); - setText("reviewed-count", fmt(personaReviewed.length, 0)); - setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty(t("empty.personaUpdates", "暂无人格更新"))); - setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); - setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty(t("empty.jargonCandidates", "暂无黑话候选"))); - state.pageData.lastStyleItems = styleReviews; - setHtml("reviewed-persona-list", personaReviewed.slice(0, 12).map((item) => ` -
- ${escapeHtml(item.id)} - ${escapeHtml(item.status || item.review_status || "reviewed")} - ${escapeHtml(item.reason || item.update_type || item.review_source || "")} -
- `).join("") || empty(t("empty.reviewed", "暂无已审查记录"))); - showErrors(data.errors || {}); - } - - function personaReviewHtml(item) { - const id = item.id; - return `
- ${reviewCheckbox("persona", id)} -
- ${escapeHtml(item.update_type || item.review_source || t("reviews.personaUpdates", "人格更新"))} - ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.reason || item.description || "")} -

${escapeHtml(item.proposed_content || item.new_content || item.incremental_content || "").slice(0, 220)}

-
-
- ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="persona" data-id="${escapeAttr(id)}"`)} - ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="persona" data-id="${escapeAttr(id)}"`, "solid-button")} - ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="persona" data-id="${escapeAttr(id)}"`)} - ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="persona" data-id="${escapeAttr(id)}"`, "danger-button")} -
-
`; - } - - function styleReviewHtml(item) { - return `
- ${reviewCheckbox("style", item.id)} -
- ${escapeHtml(item.description || t("style.title", "表达方式学习"))} - ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.status || "pending")} -

${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}

-
-
- ${button(t("actions.edit", "编辑"), `data-style-action="edit" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} - ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="style" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
-
`; - } - - function jargonReviewHtml(item) { - return `
- ${reviewCheckbox("jargon", item.id)} -
- ${escapeHtml(item.term || item.content || `#${item.id}`)} - ${escapeHtml(item.group_id || "global")} · ${escapeHtml(t("units.times", "{count} 次").replace("{count}", fmt(item.occurrences || item.count, 0)))} -

${escapeHtml(item.meaning || item.definition || item.review_detail || t("empty.definition", "暂无释义"))}

-
-
- ${button(t("actions.confirm", "确认"), `data-review-action="approve" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "solid-button")} - ${button(t("actions.rejectBack", "驳回"), `data-review-action="reject" data-kind="jargon" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
-
`; - } - - function renderPersona(data) { - const current = data.current || {}; - const persona = current.persona || {}; - setHtml("persona-state-stats", statCards([ - [t("persona.stats.promptLength", "提示词字数"), current.prompt_length], - [t("persona.stats.beginDialogs", "开场对话"), current.begin_dialog_count], - [t("persona.stats.toolCount", "工具数量"), current.tool_count], - [t("persona.stats.currentGroup", "当前群组"), current.group_id || "default"], - ])); - setText("persona-prompt-preview", current.prompt_preview || persona.system_prompt || persona.prompt || t("empty.personaPrompt", "暂无人格提示词")); - - const personas = data.personas || []; - setHtml("persona-list", personas.map((item) => { - const id = item.persona_id || item.id || item.name; - return `
- ${escapeHtml(id)} - ${escapeHtml(item.name || id)} -
- ${button(t("actions.edit", "编辑"), `data-persona-action="edit" data-persona-id="${escapeAttr(id)}"`)} - ${button(t("actions.export", "导出"), `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} - ${button(t("actions.delete", "删除"), `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")} -
-
`; - }).join("") || empty(t("empty.personaList", "暂无人格列表"))); - state.pageData.lastPersonaItems = personas; - - const backups = ((data.backups || {}).backups || []); - setText("persona-backup-count", fmt(backups.length, 0)); - setHtml("persona-backup-list", backups.map((item) => ` -
-
- ${escapeHtml(item.backup_name || t("persona.backupName", "备份 {id}").replace("{id}", item.id))} - ${escapeHtml(item.reason_short || item.reason || t("empty.noRemark", "无备注"))} -
- ${escapeHtml(item.group_id || "default")} - ${escapeHtml(item.timestamp || item.created_at || "")} -
- ${button(t("actions.view", "查看"), `data-persona-action="backup_detail" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`)} - ${button(t("actions.restore", "恢复"), `data-persona-action="backup_restore" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "solid-button")} - ${button(t("actions.delete", "删除"), `data-persona-action="backup_delete" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "danger-button")} -
-
- `).join("") || empty(t("empty.personaBackups", "暂无人格备份"))); - showErrors(data.errors || {}); - } - - function renderContent(data) { - const content = data.content || {}; - const items = content[state.contentType] || []; - qsa("#content-tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.contentType === state.contentType)); - setHtml("learning-content-list", items.map((item) => ` -
-
- ${escapeHtml(item.title || item.type || `#${item.id}`)} - ${escapeHtml(item.timestamp || "")} ${escapeHtml(item.metadata || "")} -

${escapeHtml(item.text || item.detail || "").slice(0, 360)}

-
- ${button(t("actions.delete", "删除"), `data-content-action="delete_content" data-bucket="${escapeAttr(state.contentType)}" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
- `).join("") || empty(t("empty.learningContent", "暂无学习内容"))); - - const batches = ((data.batches || {}).batches || []); - setHtml("batch-list", batches.map((item) => ` -
- ${escapeHtml(item.batch_name || item.batch_id || item.id)} - ${escapeHtml(item.status || (item.success ? "success" : "unknown"))} - ${escapeHtml(fmt(item.quality_score || 0, 3))} - ${button(t("actions.delete", "删除"), `data-content-action="delete_batch" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
- `).join("") || empty(t("empty.batchHistory", "暂无批次历史"))); - showErrors(data.errors || {}); - } - - async function loadGraphs(force) { - const type = $("graph-type")?.value || "memory"; - state.graph.type = type; - const data = await cached(`graphs:${type}`, () => apiGet("graphs", { type, limit: 140 }), force); - renderGraphs(data); - } - - function renderGraphs(data) { - const graph = data[state.graph.type] || data.memory || data.knowledge || {}; - const canvas = $("graph-canvas"); - const size = canvas - ? syncGraphCanvasSize(canvas, { force: true }) - : { width: 960, height: 520 }; - const rawNodes = Array.isArray(graph.nodes) ? graph.nodes : []; - state.graph.nodes = rawNodes.map((node, index) => - createGraphNode(node, index, rawNodes.length, size.width, size.height), - ); - state.graph.links = normalizeGraphLinks(graph.links || []); - settleGraphLayout(state.graph.nodes, state.graph.links, size.width, size.height); - state.graph.dragged = null; - state.graph.hovered = null; - setHtml("graph-stat-grid", statCards([ - [t("graphs.stats.nodes", "节点"), (graph.stats || {}).nodes || state.graph.nodes.length], - [t("graphs.stats.links", "连线"), (graph.stats || {}).links || state.graph.links.length], - [t("graphs.stats.groups", "群组"), (graph.stats || {}).groups || (graph.groups || []).length], - [t("graphs.stats.source", "来源"), graph.data_source || "self_learning"], - ])); - setHtml("graph-node-list", state.graph.nodes.slice(0, 18).map((node) => ` -
- ${escapeHtml(node.name || node.id)} - ${escapeHtml(node.category_name || node.category || t("graphs.node", "节点"))} - ${escapeHtml(node.detail || "")} -
- `).join("") || empty(t("empty.graphNodes", "暂无图谱节点"))); - startGraphRender(); - } - - function createGraphNode(node, index, total, width, height) { - const id = graphValueKey(node.id ?? node.name ?? node.label ?? `${state.graph.type}-${index}`); - const radius = graphNodeRadius(node); - const safeWidth = Math.max(320, width || 960); - const safeHeight = Math.max(320, height || 520); - const home = graphHomePosition(id, index, total, safeWidth, safeHeight, radius); - return { - ...node, - id, - label: node.name || node.label || id, - radius, - x: home.x, - y: home.y, - homeX: home.x, - homeY: home.y, - vx: 0, - vy: 0, - pinned: false, - }; - } - - function graphHomePosition(id, index, total, width, height, radius) { - const margin = graphNodeMargin(radius); - const centerX = width / 2; - const centerY = height / 2; - const seed = graphStableSeed(id); - const angleOffset = state.graph.type === "knowledge" ? 0.72 : 0; - const angle = index * 2.399963229728653 + angleOffset + seed * 0.0007; - const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); - const spreadX = Math.max(86, (width - margin * 2) * 0.36); - const spreadY = Math.max(72, (height - margin * 2) * 0.34); - return { - x: clamp(centerX + Math.cos(angle) * spreadX * ring, margin, width - margin), - y: clamp(centerY + Math.sin(angle) * spreadY * ring, margin, height - margin), - }; - } - - function settleGraphLayout(nodes, links, width, height) { - if (!nodes.length) return; - const byId = new Map(nodes.map((node) => [String(node.id), node])); - for (let iteration = 0; iteration < 18; iteration += 1) { - links.slice(0, 220).forEach((link) => { - const source = byId.get(String(link.source)); - const target = byId.get(String(link.target)); - if (!source || !target) return; - const dx = target.x - source.x; - const dy = target.y - source.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); - const adjust = (dist - desired) * 0.0035; - const nx = dx / dist; - const ny = dy / dist; - if (!source.pinned) { - source.x += nx * adjust; - source.y += ny * adjust; - } - if (!target.pinned) { - target.x -= nx * adjust; - target.y -= ny * adjust; - } - }); - - for (let i = 0; i < nodes.length; i += 1) { - for (let j = i + 1; j < Math.min(nodes.length, i + 42); j += 1) { - separateGraphNodes(nodes[i], nodes[j], 0.45); - } - } - - nodes.forEach((node) => { - if (!node.pinned) { - node.x += (node.homeX - node.x) * 0.12; - node.y += (node.homeY - node.y) * 0.12; - } - clampGraphNode(node, width, height); - }); - } - } - - function normalizeGraphLinks(links) { - if (!Array.isArray(links)) return []; - return links.map((link) => ({ - ...link, - source: graphValueKey(link.source ?? link.from), - target: graphValueKey(link.target ?? link.to), - })).filter((link) => link.source && link.target); - } - - function renderReplyStrategy(data) { - const cards = (data.dashboards || []).filter((item) => item.id === "group_chat_plus"); - setHtml("reply-strategy-cards", cards.map(integrationCardHtml).join("") || empty(t("empty.groupChatPlus", "未检测到 Group Chat Plus"))); - } - - function renderIntegrations(data) { - setHtml("integration-cards", (data.dashboards || []).map(integrationCardHtml).join("") || empty(t("empty.integrations", "暂无融合状态"))); - setHtml("integration-warnings", warningListHtml(data.warnings || [])); - const settings = data.settings || {}; - setHtml("integration-settings", Object.entries(settings).map(([key, value]) => ` -
- ${escapeHtml(key)} - ${escapeHtml(value === true ? t("state.on", "开启") : value === false ? t("state.off", "关闭") : value ?? t("state.unset", "未设置"))} -
- `).join("") || empty(t("empty.integrationSettings", "暂无融合设置"))); - renderMaiBotImportPreview(data.maibot_learning || null); - } - - function integrationCardHtml(item) { - const dash = item.dashboard || {}; - const url = resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || "#"); - const disabled = !dash.available || !url || url === "#"; - return `
-
- ${escapeHtml(item.role || "")} -

${escapeHtml(item.title || item.id)}

-

${escapeHtml(item.delegated ? t("state.delegated", "已委托") : item.active ? t("state.available", "可用") : t("state.disabled", "未启用"))}

-
- ${escapeHtml(dash.label || t("actions.open", "打开"))} - ${escapeHtml((item.dev_api || {}).mode || "")} -
`; - } - - function collectMaiBotPayload() { - const payload = { - maibot_root: $("maibot-root-input")?.value?.trim() || "", - db_path: $("maibot-db-input")?.value?.trim() || "", - memorix_db_path: $("maibot-memorix-input")?.value?.trim() || "", - default_group_id: $("maibot-default-group-input")?.value?.trim() || "global", - import_expressions: Boolean($("maibot-import-expressions")?.checked), - import_jargons: Boolean($("maibot-import-jargons")?.checked), - import_memories: Boolean($("maibot-import-memories")?.checked), - approve_checked_expressions: Boolean($("maibot-approve-checked")?.checked), - }; - if (!payload.maibot_root && !payload.db_path) { - throw new Error(t("maibot.missingPath", "请填写 MaiBot 项目目录或主数据库路径")); - } - return payload; - } - - function renderMaiBotImportPreview(summary) { - const output = $("maibot-import-output"); - if (!output || !summary) return; - const counts = summary.counts || {}; - const breakdown = summary.review_breakdown || {}; - const destinations = summary.destinations || {}; - const lines = []; - if (Object.keys(counts).length) { - lines.push(t("maibot.previewCounts", "预览: 表达 {expressions} · 黑话 {jargons} · 记忆 {memories}") - .replace("{expressions}", fmt(counts.expressions, 0)) - .replace("{jargons}", fmt(counts.jargons, 0)) - .replace("{memories}", fmt(counts.memories, 0))); - } - if (Object.keys(breakdown).length) { - lines.push(t("maibot.importCounts", "导入: 表达审查 {style} · 黑话候选 {jargon} · 记忆审查 {memory}") - .replace("{style}", fmt(breakdown.style_learning_reviews, 0)) - .replace("{jargon}", fmt(breakdown.jargon_candidates, 0)) - .replace("{memory}", fmt(breakdown.persona_memory_reviews, 0))); - } - if (Object.keys(destinations).length) { - lines.push(t("maibot.destinations", "分类去向: 表达 -> {expressions}; 黑话 -> {jargons}; 记忆 -> {memories}") - .replace("{expressions}", destinations.expressions) - .replace("{jargons}", destinations.jargons) - .replace("{memories}", destinations.memories)); - } - output.textContent = `${lines.join("\n")}${lines.length ? "\n\n" : ""}${JSON.stringify(summary, null, 2)}`; - } - - function currentBatchReviewIds(kind) { - const selected = selectedReviewIds(kind); - return selected.length ? selected : visibleReviewIds(kind); - } - - async function handleBatchReviewAction(kind, action) { - const ids = currentBatchReviewIds(kind); - if (!ids.length) { - showToast(t("reviews.noBatchItems", "当前页没有可批量处理的审查项"), "error"); - return; - } - const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项"); - const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); - const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页"); - if (!window.confirm(t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") - .replace("{action}", actionText) - .replace("{scope}", scopeText) - .replace("{count}", fmt(ids.length, 0)) - .replace("{type}", typeText))) return; - - const payload = { - action: action === "delete" - ? kind === "persona" ? "batch_delete" : kind === "style" ? "batch_delete_style" : "batch_delete_jargon" - : kind === "persona" ? "batch_review" : kind === "style" ? "batch_review_style" : "batch_review_jargon", - ids, - decision: action, - }; - const result = await apiPost("reviews/action", payload); - showToast(result.message || t("messages.batchDone", "批量操作完成"), result.success ? "ok" : "error"); - selectedReviewSet(kind).clear(); - state.pageData.reviews = null; - await loadPageData(state.page, { force: true }); - } - - async function runMaiBotImportAction(action) { - const buttonEl = action === "maibot_import" ? $("maibot-import-button") : $("maibot-preview-button"); - const originalLabel = buttonEl?.textContent || ""; - try { - const payload = collectMaiBotPayload(); - if (buttonEl) { - buttonEl.disabled = true; - buttonEl.classList.add("is-busy"); - buttonEl.textContent = action === "maibot_import" ? t("actions.importing", "导入中") : t("actions.previewing", "预览中"); - } - setText("maibot-import-output", t("maibot.reading", "正在读取 MaiBot 学习数据...")); - const result = await apiPost("integrations/action", { action, ...payload }); - const detail = result.preview || result.result || result.payload || result; - renderMaiBotImportPreview(detail); - showToast(result.message || t("maibot.done", "MaiBot 学习数据操作完成"), result.success !== false ? "ok" : "error"); - if (action === "maibot_import") { - state.pageData = {}; - await loadDashboard(true); - } - } catch (error) { - const message = error.message || String(error); - setText("maibot-import-output", message); - showToast(message, "error"); - } finally { - if (buttonEl) { - buttonEl.disabled = false; - buttonEl.classList.remove("is-busy"); - buttonEl.textContent = originalLabel; - } - } - } - - function renderSettings(data) { - const schema = data.schema || {}; - setHtml("settings-warnings", warningListHtml(schema.warnings || data.warnings || [])); - const groups = schema.groups || []; - if (!state.settingsGroup && groups.length) state.settingsGroup = groups[0].key; - setHtml("settings-groups", groups.map((group) => ` - - `).join("") || empty(t("empty.configSchema", "配置 schema 暂不可用"))); - - const active = groups.find((group) => group.key === state.settingsGroup) || groups[0] || { fields: [] }; - setHtml("config-form", (active.fields || []).map(fieldHtml).join("") || empty(t("empty.selectConfigGroup", "请选择配置分组"))); - renderPipMirrors(data.pip_mirrors || {}); - } - - function fieldHtml(field) { - const value = state.dirtySettings.has(field.key) ? state.dirtySettings.get(field.key) : field.value; - const common = `data-config-field="${escapeAttr(field.key)}" data-config-type="${escapeAttr(field.type)}" ${field.editable ? "" : "disabled"}`; - const groupKey = field.group_key || activeSettingsGroupKeyForField(field.key); - const label = configT(`${groupKey}.${field.key}.description`, field.label || field.key); - const hint = configT(`${groupKey}.${field.key}.hint`, field.hint || ""); - let control = ""; - if (field.widget === "toggle") { - control = ``; - } else if (field.widget === "select" || field.widget === "provider") { - const options = field.options || []; - control = ``; - } else if (field.widget === "textarea" || field.type === "list") { - const textValue = Array.isArray(value) ? value.join("\n") : value ?? ""; - control = ``; - } else { - const inputType = field.widget === "number" || field.type === "int" || field.type === "float" ? "number" : "text"; - const step = field.type === "float" ? "0.01" : "1"; - control = ``; - } - return ``; - } - - function activeSettingsGroupKeyForField(fieldKey) { - const groups = ((state.pageData.settings || {}).schema || {}).groups || []; - const group = groups.find((item) => (item.fields || []).some((field) => field.key === fieldKey)); - return group?.key || state.settingsGroup || ""; - } - - function renderPipMirrors(mirrors) { - const select = $("pip-mirror-select"); - if (!select || select.childElementCount) return; - select.innerHTML = Object.entries(mirrors).map(([key, item]) => ``).join(""); - } - - function statCards(items) { - return items.map(([label, value]) => `
- ${escapeHtml(label)} - ${escapeHtml(fmt(value, typeof value === "number" ? 1 : 0))} -
`).join(""); - } - - function renderGenericBarChart(id, items) { - const maxValue = Math.max(1, ...items.map((item) => Number(item.metric || 0))); - setHtml(id, items.map((item) => { - const value = Math.max(4, Math.min(100, Number(item.metric || 0) / maxValue * 100)); - return `
- ${escapeHtml(item.title)} -
- ${escapeHtml(fmt(item.metric, 0))} -
`; - }).join("") || empty()); - } - - function summarizeObject(obj) { - const entries = Object.entries(obj || {}).slice(0, 3); - return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`).join(" · "); - } - - function shortName(name) { - const text = String(name || ""); - return text.length > 58 ? `...${text.slice(-55)}` : text; - } - - function findReviewItem(kind, id) { - const reviews = state.pageData.reviews || {}; - const style = state.pageData.style || {}; - if (kind === "persona") return ((reviews.persona_pending || {}).updates || []).find((item) => String(item.id) === String(id)); - if (kind === "style") { - return ( - ((reviews.style_reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) - || ((style.reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) - ); - } - return (((reviews.jargon_pending || {}).jargon_list || []).find((item) => String(item.id) === String(id))); - } - - async function handleReviewAction(kind, id, action) { - if (action === "detail") { - showModal(t("modal.reviewDetails", "审查详情"), `
${escapeHtml(JSON.stringify(findReviewItem(kind, id) || {}, null, 2))}
`); - return; - } - let payload; - if (kind === "persona") { - payload = action === "delete" - ? { action: "delete", id } - : { action: "review", id, decision: action }; - } else if (kind === "style") { - payload = { action: `style_${action}`, id }; - } else { - payload = { action: `jargon_${action}`, id }; - } - const result = await apiPost("reviews/action", payload); - showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - selectedReviewSet(kind).delete(normalizeId(id)); - state.pageData.reviews = null; - await loadPageData(state.page, { force: true }); - } - - async function handleJargonAction(action, id) { - if (action === "edit") { - const item = (state.pageData.lastJargonItems || []).find((entry) => String(entry.id) === String(id)) || {}; - showModal(t("modal.editJargon", "编辑黑话"), ` - - - - `); - return; - } - const result = await apiPost("jargon/action", { action, id }); - showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - state.selectedJargon.delete(normalizeId(id)); - state.pageData = {}; - await loadPageData(state.page, { force: true }); - } - - async function handleJargonBatchAction(action) { - const visibleIds = jargonPageIds(); - if (action === "select_all") { - visibleIds.forEach((id) => state.selectedJargon.add(id)); - renderJargon(state.pageData.currentJargonData || {}); - return; - } - if (action === "clear") { - state.selectedJargon.clear(); - renderJargon(state.pageData.currentJargonData || {}); - return; - } - - const ids = Array.from(state.selectedJargon).filter(Boolean); - if (!ids.length) { - showToast(t("jargon.selectFirst", "请先选择黑话条目"), "error"); - return; - } - const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除"); - if (!window.confirm(t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return; - - const result = await apiPost("jargon/action", { - action: action === "delete" ? "batch_delete" : "batch_review", - ids, - decision: action, - }); - showToast(result.message || t("jargon.batchDone", "批量黑话操作完成"), result.success ? "ok" : "error"); - state.selectedJargon.clear(); - state.pageData = {}; - await loadPageData("jargon-learning", { force: true }); - } - - function modalFieldValue(id) { - return $(id)?.value ?? ""; - } - - function parseModalJson(id, fallback) { - const raw = modalFieldValue(id).trim(); - if (!raw) return fallback; - try { - return JSON.parse(raw); - } catch (_) { - return raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); - } - } - - async function handleStyleAction(action, id) { - if (action === "edit") { - const item = (state.pageData.lastStyleItems || []).find((entry) => String(entry.id) === String(id)) || {}; - const patterns = typeof item.learned_patterns === "string" - ? item.learned_patterns - : JSON.stringify(item.learned_patterns || [], null, 2); - showModal(t("modal.editStyle", "编辑表达方式"), ` - - - - - `); - } - } - - async function handleStyleBatchAction(action) { - const visibleIds = stylePageReviewIds(); - const selected = selectedReviewSet("style"); - if (action === "select_all") { - visibleIds.forEach((id) => selected.add(id)); - renderStyle(state.pageData.style || {}); - return; - } - if (action === "clear") { - selected.clear(); - renderStyle(state.pageData.style || {}); - return; - } - - const ids = selectedReviewIds("style"); - if (!ids.length) { - showToast(t("style.selectFirst", "请先选择表达审查项"), "error"); - return; - } - const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); - if (!window.confirm(t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return; - - const result = await apiPost("style/action", { - action: action === "delete" ? "batch_delete" : "batch_review", - ids, - decision: action, - }); - showToast(result.message || t("style.batchDone", "批量表达审查完成"), result.success ? "ok" : "error"); - selected.clear(); - state.pageData.style = null; - state.pageData.lastStyleItems = []; - await loadPageData("expression-learning", { force: true }); - } - - async function handlePersonaAction(buttonEl) { - const action = buttonEl.dataset.personaAction; - if (action === "edit") { - const personaId = buttonEl.dataset.personaId; - const item = (state.pageData.lastPersonaItems || []).find((entry) => String(entry.persona_id || entry.id || entry.name) === String(personaId)) || {}; - const beginDialogs = JSON.stringify(item.begin_dialogs || [], null, 2); - showModal(t("modal.editPersona", "编辑人格"), ` - - - - - - `); - return; - } - const body = { - action, - id: buttonEl.dataset.id, - group_id: buttonEl.dataset.groupId, - persona_id: buttonEl.dataset.personaId, - }; - const result = await apiPost("persona/action", body); - if (action === "backup_detail" || action === "export") { - showModal(action === "export" ? t("modal.personaExport", "人格导出") : t("modal.backupDetails", "备份详情"), `
${escapeHtml(JSON.stringify(result.persona || result.backup || result, null, 2))}
`); - return; - } - showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - state.pageData.persona = null; - await loadPageData("persona-learning", { force: true }); - } - - async function handleContentAction(buttonEl) { - const result = await apiPost("content/action", { - action: buttonEl.dataset.contentAction, - bucket: buttonEl.dataset.bucket, - id: buttonEl.dataset.id, - }); - showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - state.pageData.content = null; - await loadPageData("content", { force: true }); - } - - function collectConfigPayload() { - const payload = Object.fromEntries(state.dirtySettings.entries()); - qsa("[data-config-field]").forEach((field) => { - const key = field.dataset.configField; - const type = field.dataset.configType; - let value; - if (field.type === "checkbox") value = field.checked; - else if (type === "int") value = Number.parseInt(field.value || "0", 10); - else if (type === "float") value = Number.parseFloat(field.value || "0"); - else if (type === "list") { - const raw = field.value.trim(); - try { - value = raw.startsWith("[") ? JSON.parse(raw) : raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); - } catch (_) { - value = raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); - } - } else value = field.value; - payload[key] = value; - }); - return payload; - } - - function bindEvents() { - $("refresh-button")?.addEventListener("click", () => loadPageData(state.page, { force: true })); - $("modal-close")?.addEventListener("click", closeModal); - $("jargon-search-button")?.addEventListener("click", () => { - Object.keys(state.pageData).filter((key) => key.startsWith("jargon:")).forEach((key) => delete state.pageData[key]); - loadJargon(true); - }); - $("copy-insight-context")?.addEventListener("click", async () => { - const text = JSON.stringify(state.dashboard || {}, null, 2); - try { - await navigator.clipboard.writeText(text); - showToast(t("messages.contextCopied", "巡检上下文已复制")); - } catch (_) { - showModal(t("modal.insightContext", "巡检上下文"), `
${escapeHtml(text)}
`); - } - }); - $("relearn-button")?.addEventListener("click", async () => { - const result = await apiPost("content/action", { action: "relearn", group_id: "default" }); - showToast(result.message || t("messages.relearnSubmitted", "重新学习已提交"), result.success ? "ok" : "error"); - }); - $("graph-type")?.addEventListener("change", () => loadGraphs(true)); - $("config-save-button")?.addEventListener("click", async () => { - const result = await apiPost("settings/action", { action: "save", config: collectConfigPayload() }); - showToast(result.message || t("messages.settingsSaved", "设置已保存"), result.success ? "ok" : "error"); - state.pageData.settings = null; - await loadPageData("settings", { force: true }); - }); - $("dependency-install-button")?.addEventListener("click", async () => { - const installButton = $("dependency-install-button"); - const originalLabel = installButton?.textContent || t("actions.manualInstall", "手动安装"); - const settings = state.pageData.settings || {}; - if (installButton) { - installButton.disabled = true; - installButton.classList.add("is-busy"); - installButton.textContent = t("actions.installing", "安装中"); - } - setText("dependency-output", t("settings.installingDeps", "正在调用 pip 安装依赖,请等待命令输出...")); - try { - const result = await apiPost("settings/action", { - action: "install_dependencies", - manual_confirmed: true, - source: settings.manual_dependency_source || "system_settings", - tier: $("dependency-tier")?.value || "full", - pip_mirror: $("pip-mirror-select")?.value || "default", - }); - const detail = result.result || result; - setText("dependency-output", detail.output || detail.message || result.message || t("settings.installDone", "依赖安装任务结束")); - showToast(result.message || detail.message || t("settings.installDone", "依赖安装任务结束"), result.success !== false ? "ok" : "error"); - } catch (error) { - const message = error.message || String(error); - setText("dependency-output", message); - showToast(message, "error"); - } finally { - if (installButton) { - installButton.disabled = false; - installButton.classList.remove("is-busy"); - installButton.textContent = originalLabel; - } - } - }); - $("maibot-preview-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_preview")); - $("maibot-import-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_import")); - - document.addEventListener("click", async (event) => { - const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-batch-review-kind],[data-jargon-action],[data-jargon-batch-action],[data-style-action],[data-style-batch-action],[data-persona-action],[data-content-action],[data-settings-group]"); - if (!target) return; - if (target.dataset.routeCard) navigateToPage(target.dataset.routeCard); - if (target.dataset.refreshPage) loadPageData(target.dataset.refreshPage, { force: true }); - if (target.dataset.reviewAction) await handleReviewAction(target.dataset.kind, target.dataset.id, target.dataset.reviewAction); - if (target.dataset.batchReviewKind) await handleBatchReviewAction(target.dataset.batchReviewKind, target.dataset.batchReviewAction || "approve"); - if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); - if (target.dataset.jargonBatchAction) await handleJargonBatchAction(target.dataset.jargonBatchAction); - if (target.dataset.styleAction) await handleStyleAction(target.dataset.styleAction, target.dataset.id); - if (target.dataset.styleBatchAction) await handleStyleBatchAction(target.dataset.styleBatchAction); - if (target.dataset.personaAction) await handlePersonaAction(target); - if (target.dataset.contentAction) await handleContentAction(target); - if (target.dataset.settingsGroup) { - state.settingsGroup = target.dataset.settingsGroup; - renderSettings(state.pageData.settings || {}); - } - }); - - document.addEventListener("change", (event) => { - const reviewSelect = event.target.closest("[data-review-select-kind]"); - if (reviewSelect) { - const selection = selectedReviewSet(reviewSelect.dataset.reviewSelectKind); - const id = normalizeId(reviewSelect.dataset.reviewSelectId); - if (reviewSelect.checked) selection.add(id); - else selection.delete(id); - refreshSelectionLabels(); - return; - } - const jargonSelect = event.target.closest("[data-jargon-select-id]"); - if (jargonSelect) { - const id = normalizeId(jargonSelect.dataset.jargonSelectId); - if (jargonSelect.checked) state.selectedJargon.add(id); - else state.selectedJargon.delete(id); - refreshSelectionLabels(); - return; - } - const field = event.target.closest("[data-config-field]"); - if (!field) return; - state.dirtySettings.set(field.dataset.configField, field.type === "checkbox" ? field.checked : field.value); - }); - - document.addEventListener("click", async (event) => { - const save = event.target.closest("#modal-jargon-save"); - if (!save) return; - const result = await apiPost("jargon/action", { - action: "update", - id: save.dataset.id, - content: $("modal-jargon-content")?.value, - meaning: $("modal-jargon-meaning")?.value, - }); - closeModal(); - showToast(result.message || t("messages.jargonUpdated", "黑话已更新"), result.success ? "ok" : "error"); - state.pageData = {}; - await loadPageData("jargon-learning", { force: true }); - }); - - document.addEventListener("click", async (event) => { - const save = event.target.closest("#modal-style-save"); - if (!save) return; - const result = await apiPost("style/action", { - action: "update", - id: save.dataset.id, - description: modalFieldValue("modal-style-description"), - few_shots_content: modalFieldValue("modal-style-few-shots"), - learned_patterns: parseModalJson("modal-style-patterns", []), - }); - closeModal(); - showToast(result.message || t("messages.styleUpdated", "表达方式已更新"), result.success ? "ok" : "error"); - state.pageData.style = null; - state.pageData.lastStyleItems = []; - await loadPageData("expression-learning", { force: true }); - }); - - document.addEventListener("click", async (event) => { - const save = event.target.closest("#modal-persona-save"); - if (!save) return; - const personaId = save.dataset.personaId; - const result = await apiPost("persona/action", { - action: "update", - persona_id: personaId, - persona: { - persona_id: personaId, - name: modalFieldValue("modal-persona-name"), - system_prompt: modalFieldValue("modal-persona-prompt"), - prompt: modalFieldValue("modal-persona-prompt"), - begin_dialogs: parseModalJson("modal-persona-dialogs", []), - }, - }); - closeModal(); - showToast(result.message || t("messages.personaUpdated", "人格已更新"), result.success ? "ok" : "error"); - state.pageData.persona = null; - state.pageData.lastPersonaItems = []; - await loadPageData("persona-learning", { force: true }); - }); - - qsa(".nav-item").forEach((item) => { - item.addEventListener("click", (event) => { - event.preventDefault(); - navigateToPage(item.dataset.page || "home"); - }); - }); - qsa("#content-tabs button").forEach((buttonEl) => { - buttonEl.addEventListener("click", () => { - state.contentType = buttonEl.dataset.contentType || "dialogues"; - renderContent(state.pageData.content || {}); - }); - }); - window.addEventListener("hashchange", () => navigateToPage(resolvePageFromHash(), { skipHash: true })); - } - - function setThemeFromBridge() { - try { - const bridge = window.AstrBotPluginPage; - const apply = (ctx) => { - if (ctx && typeof ctx.isDark === "boolean") { - document.documentElement.setAttribute("data-theme", ctx.isDark ? "dark" : "light"); - } - }; - apply(bridge && bridge.getContext && bridge.getContext()); - if (bridge && bridge.onContextChange) bridge.onContextChange(apply); - if (bridge && bridge.onContext) bridge.onContext(apply); - } catch (_) {} - } - - function setI18nFromBridge() { - const rerender = () => { - applyStaticI18n(); - const meta = PAGE_META[state.page] || PAGE_META.home; - setText("page-kicker", t(meta[0], meta[2])); - setText("page-title", t(meta[1], meta[3])); - if (state.dashboard) renderDashboard(state.dashboard); - const data = state.pageData[state.page] || state.pageData[state.page === "expression-learning" ? "style" : state.page]; - if (state.page === "monitoring" && state.pageData.monitoring) renderMonitoring(state.pageData.monitoring); - else if (state.page === "reviews" && state.pageData.reviews) renderReviews(state.pageData.reviews); - else if (state.page === "jargon-learning" && state.pageData.currentJargonData) renderJargon(state.pageData.currentJargonData); - else if (state.page === "expression-learning" && state.pageData.style) renderStyle(state.pageData.style); - else if (state.page === "persona-learning" && state.pageData.persona) renderPersona(state.pageData.persona); - else if (state.page === "content" && state.pageData.content) renderContent(state.pageData.content); - else if (state.page === "graphs" && data) renderGraphs(data); - else if ((state.page === "reply-strategy" || state.page === "integrations") && state.pageData.integrations) { - if (state.page === "reply-strategy") renderReplyStrategy(state.pageData.integrations); - else renderIntegrations(state.pageData.integrations); - } else if (state.page === "settings" && state.pageData.settings) renderSettings(state.pageData.settings); - }; - try { - const bridge = window.AstrBotPluginPage; - if (bridge && bridge.onContextChange) bridge.onContextChange(rerender); - if (bridge && bridge.onContext) bridge.onContext(rerender); - } catch (_) {} - rerender(); - } - - function initSpringMotion() { - const stage = qs(".spring-stage"); - const canvas = $("physics-canvas"); - if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; - if (!stage || !canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const resize = () => { - const rect = stage.getBoundingClientRect(); - canvas.width = Math.max(1, Math.floor(rect.width * devicePixelRatio)); - canvas.height = Math.max(1, Math.floor(rect.height * devicePixelRatio)); - canvas.style.width = `${rect.width}px`; - canvas.style.height = `${rect.height}px`; - ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); - }; - resize(); - window.addEventListener("resize", resize); - physics.particles = qsa(".spring-node:not(.node-core)", stage).map((el, index) => ({ - el, x: 0, y: 0, vx: 0, vy: 0, seed: index * 2.3, - })); - stage.addEventListener("pointermove", (event) => { - const rect = stage.getBoundingClientRect(); - physics.pointer.x = event.clientX - rect.left; - physics.pointer.y = event.clientY - rect.top; - physics.pointer.active = true; - }); - stage.addEventListener("pointerleave", () => { physics.pointer.active = false; }); - if (!physics.running) { - physics.running = true; - physics.last = performance.now(); - requestAnimationFrame(tickSpringMotion); - } - } - - function tickSpringMotion(now) { - const stage = qs(".spring-stage"); - const canvas = $("physics-canvas"); - if (!stage || !canvas) return; - const ctx = canvas.getContext("2d"); - const rect = stage.getBoundingClientRect(); - const dt = Math.min(0.033, Math.max(0.001, (now - physics.last) / 1000)); - physics.last = now; - ctx.clearRect(0, 0, rect.width, rect.height); - ctx.strokeStyle = "rgba(65, 105, 225, 0.14)"; - ctx.lineWidth = 1.2; - - const core = { x: rect.width / 2, y: rect.height / 2 }; - physics.particles.forEach((point) => { - const own = point.el.getBoundingClientRect(); - const baseX = own.left - rect.left + own.width / 2 - point.x; - const baseY = own.top - rect.top + own.height / 2 - point.y; - let targetX = Math.sin(now / 1350 + point.seed) * 6; - let targetY = Math.cos(now / 1500 + point.seed) * 5; - if (physics.pointer.active) { - const cx = baseX + point.x; - const cy = baseY + point.y; - const dx = cx - physics.pointer.x; - const dy = cy - physics.pointer.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const force = Math.max(0, 96 - dist) / 96; - targetX += dx / dist * force * 18; - targetY += dy / dist * force * 18; - } - point.vx += (targetX - point.x) * 28 * dt; - point.vy += (targetY - point.y) * 28 * dt; - point.vx *= Math.max(0, 1 - 14 * dt); - point.vy *= Math.max(0, 1 - 14 * dt); - point.x += point.vx * dt * 60; - point.y += point.vy * dt * 60; - const px = baseX + point.x; - const py = baseY + point.y; - ctx.beginPath(); - ctx.moveTo(core.x, core.y); - ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 8, px, py); - ctx.stroke(); - point.el.style.transform = `translate3d(${point.x.toFixed(2)}px, ${point.y.toFixed(2)}px, 0)`; - }); - requestAnimationFrame(tickSpringMotion); - } - - function startGraphRender() { - const canvas = $("graph-canvas"); - if (!canvas) return; - bindGraphCanvas(canvas); - syncGraphCanvasSize(canvas, { force: true }); - if (!state.graph.running) { - state.graph.running = true; - requestAnimationFrame(tickGraph); - } - } - - function bindGraphCanvas(canvas) { - if (state.graph.canvasBound) return; - state.graph.canvasBound = true; - - canvas.addEventListener("pointerdown", (event) => { - const point = graphPointer(event, canvas); - const node = hitGraphNode(point.x, point.y); - if (!node) return; - event.preventDefault(); - canvas.setPointerCapture?.(event.pointerId); - node.pinned = true; - node.vx = 0; - node.vy = 0; - state.graph.dragged = { - node, - pointerId: event.pointerId, - offsetX: node.x - point.x, - offsetY: node.y - point.y, - }; - canvas.classList.add("is-dragging"); - }); - - canvas.addEventListener("pointermove", (event) => { - const point = graphPointer(event, canvas); - const drag = state.graph.dragged; - if (drag && drag.pointerId === event.pointerId) { - const min = graphNodeMargin(drag.node.radius || graphNodeRadius(drag.node)); - drag.node.x = clamp(point.x + drag.offsetX, min, state.graph.width - min); - drag.node.y = clamp(point.y + drag.offsetY, min, state.graph.height - min); - drag.node.homeX = drag.node.x; - drag.node.homeY = drag.node.y; - drag.node.vx = 0; - drag.node.vy = 0; - event.preventDefault(); - return; - } - state.graph.hovered = hitGraphNode(point.x, point.y); - canvas.classList.toggle("has-hover", Boolean(state.graph.hovered)); - }); - - const releaseDrag = (event) => { - const drag = state.graph.dragged; - if (drag && drag.pointerId === event.pointerId) { - drag.node.vx = 0; - drag.node.vy = 0; - state.graph.dragged = null; - canvas.classList.remove("is-dragging"); - canvas.releasePointerCapture?.(event.pointerId); - } - }; - canvas.addEventListener("pointerup", releaseDrag); - canvas.addEventListener("pointercancel", releaseDrag); - canvas.addEventListener("pointerleave", () => { - state.graph.hovered = null; - canvas.classList.remove("has-hover"); - }); - - window.addEventListener("resize", () => { - syncGraphCanvasSize(canvas, { force: true }); - }); - } - - function tickGraph() { - const canvas = $("graph-canvas"); - if (!canvas) { - state.graph.running = false; - return; - } - const ctx = canvas.getContext("2d"); - const { width, height, ratio } = syncGraphCanvasSize(canvas); - const nodes = state.graph.nodes; - const links = state.graph.links; - ctx.setTransform(ratio, 0, 0, ratio, 0, 0); - ctx.clearRect(0, 0, width, height); - const byId = new Map(nodes.map((node) => [String(node.id), node])); - - links.slice(0, 260).forEach((link) => { - const source = byId.get(String(link.source)); - const target = byId.get(String(link.target)); - if (!source || !target) return; - const dx = target.x - source.x; - const dy = target.y - source.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); - const force = (dist - desired) * GRAPH_LINK_STRENGTH; - if (!source.pinned) { - source.vx += (dx / dist) * force; - source.vy += (dy / dist) * force; - } - if (!target.pinned) { - target.vx -= (dx / dist) * force; - target.vy -= (dy / dist) * force; - } - ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; - ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); - ctx.beginPath(); - ctx.moveTo(source.x, source.y); - ctx.lineTo(target.x, target.y); - ctx.stroke(); - }); - - for (let i = 0; i < nodes.length; i += 1) { - for (let j = i + 1; j < Math.min(nodes.length, i + 45); j += 1) { - separateGraphNodes(nodes[i], nodes[j], 0.022); - } - } - - nodes.forEach((node, index) => { - const cx = width / 2 + Math.sin(index) * 30; - const cy = height / 2 + Math.cos(index) * 24; - if (!node.pinned) { - node.vx += ((node.homeX || cx) - node.x) * GRAPH_HOME_STRENGTH + (cx - node.x) * GRAPH_CENTER_STRENGTH; - node.vy += ((node.homeY || cy) - node.y) * GRAPH_HOME_STRENGTH + (cy - node.y) * GRAPH_CENTER_STRENGTH; - node.vx *= 0.74; - node.vy *= 0.74; - node.x += node.vx; - node.y += node.vy; - } - const radius = node.radius || graphNodeRadius(node); - if (clampGraphNode(node, width, height) && !node.pinned) { - node.vx *= 0.12; - node.vy *= 0.12; - } - const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; - if (isHovered) { - ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; - ctx.beginPath(); - ctx.arc(node.x, node.y, radius + 10, 0, Math.PI * 2); - ctx.fill(); - } - ctx.fillStyle = node.source === "livingmemory" ? "#0f9f8f" : index % 3 === 0 ? "#4169e1" : index % 3 === 1 ? "#d97706" : "#e11d48"; - ctx.beginPath(); - ctx.arc(node.x, node.y, isHovered ? radius + 2 : radius, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--text").trim() || "#162033"; - ctx.font = "12px system-ui"; - const label = String(node.name || node.label || "").slice(0, 12); - const labelWidth = ctx.measureText(label).width; - const labelX = clamp(node.x + radius + 4, 6, width - labelWidth - 6); - const labelY = clamp(node.y + 4, 14, height - 6); - ctx.fillText(label, labelX, labelY); - }); - requestAnimationFrame(tickGraph); - } - - function syncGraphCanvasSize(canvas, options = {}) { - const rect = canvas.getBoundingClientRect(); - const width = Math.max(320, Math.floor(rect.width || canvas.clientWidth || state.graph.width || 960)); - const height = Math.max(320, Math.floor(rect.height || canvas.clientHeight || state.graph.height || 520)); - const ratio = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); - const nextWidth = Math.floor(width * ratio); - const nextHeight = Math.floor(height * ratio); - const resized = canvas.width !== nextWidth || canvas.height !== nextHeight; - if (resized || options.force) { - const oldWidth = state.graph.width || width; - const oldHeight = state.graph.height || height; - canvas.width = nextWidth; - canvas.height = nextHeight; - state.graph.nodes.forEach((node, index) => { - const radius = node.radius || graphNodeRadius(node); - const min = graphNodeMargin(radius); - const home = graphHomePosition(node.id, index, state.graph.nodes.length, width, height, radius); - node.x = clamp((node.x / oldWidth) * width, min, width - min); - node.y = clamp((node.y / oldHeight) * height, min, height - min); - node.homeX = home.x; - node.homeY = home.y; - if (options.force && !node.pinned) { - node.x = node.x * 0.55 + home.x * 0.45; - node.y = node.y * 0.55 + home.y * 0.45; - } - }); - } - state.graph.width = width; - state.graph.height = height; - return { width, height, ratio }; - } - - function graphPointer(event, canvas) { - const rect = canvas.getBoundingClientRect(); - return { - x: clamp(event.clientX - rect.left, 0, state.graph.width || rect.width), - y: clamp(event.clientY - rect.top, 0, state.graph.height || rect.height), - }; - } - - function hitGraphNode(x, y) { - for (let index = state.graph.nodes.length - 1; index >= 0; index -= 1) { - const node = state.graph.nodes[index]; - const radius = (node.radius || graphNodeRadius(node)) + 8; - if (Math.hypot(node.x - x, node.y - y) <= radius) { - return node; - } - } - return null; - } - - function graphNodeRadius(node) { - const raw = Number(node.symbolSize || node.value || node.weight || 12); - return Math.max(9, Math.min(24, Number.isFinite(raw) ? raw : 12)); - } - - function graphNodeMargin(radius) { - return Math.max(52, radius + GRAPH_SAFE_PADDING); - } - - function clampGraphNode(node, width, height) { - const radius = node.radius || graphNodeRadius(node); - const min = graphNodeMargin(radius); - const nextX = clamp(node.x, min, width - min); - const nextY = clamp(node.y, min, height - min); - const clamped = nextX !== node.x || nextY !== node.y; - node.x = nextX; - node.y = nextY; - return clamped; - } - - function separateGraphNodes(a, b, strength) { - const dx = b.x - a.x; - const dy = b.y - a.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const minDist = (a.radius || graphNodeRadius(a)) + (b.radius || graphNodeRadius(b)) + 20; - if (dist >= minDist) return; - const shift = (minDist - dist) / minDist * strength; - const nx = dx / dist; - const ny = dy / dist; - if (!a.pinned) { - a.vx -= nx * shift; - a.vy -= ny * shift; - a.x -= nx * shift * 6; - a.y -= ny * shift * 6; - } - if (!b.pinned) { - b.vx += nx * shift; - b.vy += ny * shift; - b.x += nx * shift * 6; - b.y += ny * shift * 6; - } - } - - function graphStableSeed(value) { - let hash = 0; - const text = String(value || ""); - for (let index = 0; index < text.length; index += 1) { - hash = (hash * 31 + text.charCodeAt(index)) >>> 0; - } - return hash % 997; - } - - function graphValueKey(value) { - if (value && typeof value === "object") { - return String(value.id ?? value.name ?? value.label ?? ""); - } - return String(value ?? ""); - } - - function clamp(value, min, max) { - if (max < min) return min; - return Math.max(min, Math.min(max, value)); - } - - async function init() { - setThemeFromBridge(); - bindEvents(); - initSpringMotion(); - try { - await bridgeReady(); - setI18nFromBridge(); - navigateToPage(resolvePageFromHash(), { skipHash: true, force: true }); - } catch (error) { - showToast(error.message || String(error), "error"); - setText("runtime-status", t("status.bridgeFailed", "桥接失败")); - setText("runtime-summary", error.message || String(error)); - } - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -})(); +(() => { + "use strict"; + + const PAGE_META = { + home: ["page.home.kicker", "page.home.title", "Dashboard", "完整内嵌 WebUI"], + insights: ["page.insights.kicker", "page.insights.title", "Insights", "AI 巡检"], + monitoring: ["page.monitoring.kicker", "page.monitoring.title", "Monitoring", "运行监控"], + reviews: ["page.reviews.kicker", "page.reviews.title", "Reviews", "审查队列"], + "jargon-learning": ["page.jargon.kicker", "page.jargon.title", "Jargon", "黑话学习"], + "expression-learning": ["page.style.kicker", "page.style.title", "Expression", "表达方式学习"], + "persona-learning": ["page.persona.kicker", "page.persona.title", "Persona", "人格学习"], + content: ["page.content.kicker", "page.content.title", "Content", "学习内容"], + graphs: ["page.graphs.kicker", "page.graphs.title", "Graphs", "图谱"], + "reply-strategy": ["page.reply.kicker", "page.reply.title", "Reply", "回复策略"], + integrations: ["page.integrations.kicker", "page.integrations.title", "Integrations", "功能融合"], + settings: ["page.settings.kicker", "page.settings.title", "Settings", "设置"], + }; + const I18N_ROOT = "pages.dashboard"; + const GRAPH_SAFE_PADDING = 34; + const GRAPH_HOME_STRENGTH = 0.0064; + const GRAPH_CENTER_STRENGTH = 0.00016; + const GRAPH_LINK_STRENGTH = 0.000035; + + const state = { + page: "home", + ready: false, + dashboard: null, + overview: null, + pageData: {}, + selectedReviews: { + persona: new Set(), + style: new Set(), + jargon: new Set(), + }, + selectedJargon: new Set(), + contentType: "dialogues", + settingsGroup: null, + dirtySettings: new Map(), + graph: { + nodes: [], + links: [], + running: false, + dragged: null, + hovered: null, + type: "memory", + width: 0, + height: 0, + canvasBound: false, + }, + toastTimer: null, + }; + + const physics = { + particles: [], + pointer: { x: 0, y: 0, active: false }, + running: false, + last: 0, + }; + + const $ = (id) => document.getElementById(id); + const qs = (selector, root = document) => root.querySelector(selector); + const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + + function locale() { + try { + const bridge = window.AstrBotPluginPage; + return bridge?.getLocale?.() || bridge?.getContext?.()?.locale || document.documentElement.lang || "zh-CN"; + } catch (_) { + return document.documentElement.lang || "zh-CN"; + } + } + + function t(key, fallback = "") { + const fullKey = key.startsWith("pages.") || key.startsWith("metadata.") || key.startsWith("config.") + ? key + : `${I18N_ROOT}.${key}`; + try { + const bridge = window.AstrBotPluginPage; + const value = bridge?.t?.(fullKey, fallback); + if (value !== undefined && value !== null && value !== fullKey) return String(value); + } catch (_) {} + return String(fallback || ""); + } + + function configT(path, fallback = "") { + return t(`config.${path}`, fallback); + } + + function applyStaticI18n(root = document) { + document.documentElement.lang = locale(); + qsa("[data-i18n]", root).forEach((el) => { + el.textContent = t(el.dataset.i18n, el.textContent); + }); + qsa("[data-i18n-title]", root).forEach((el) => { + el.setAttribute("title", t(el.dataset.i18nTitle, el.getAttribute("title") || "")); + }); + qsa("[data-i18n-aria-label]", root).forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel, el.getAttribute("aria-label") || "")); + }); + qsa("[data-i18n-placeholder]", root).forEach((el) => { + el.setAttribute("placeholder", t(el.dataset.i18nPlaceholder, el.getAttribute("placeholder") || "")); + }); + } + + function endpoint(path) { + return `page/${String(path || "").replace(/^\/+/, "").replace(/\/+/g, "/")}`; + } + + async function bridgeReady() { + const bridge = window.AstrBotPluginPage; + if (!bridge) { + throw new Error(t("errors.bridgeMissing", "AstrBot 插件页桥接 SDK 未加载")); + } + const context = await bridge.ready(); + state.ready = true; + return context; + } + + async function apiGet(path, params) { + const bridge = window.AstrBotPluginPage; + await bridgeReady(); + return unwrap(await bridge.apiGet(endpoint(path), params || {})); + } + + async function apiPost(path, body) { + const bridge = window.AstrBotPluginPage; + await bridgeReady(); + return unwrap(await bridge.apiPost(endpoint(path), body || {})); + } + + function unwrap(response) { + const body = response && response.data && response.data.status ? response.data : response; + if (body && body.status === "ok") { + return body.data || {}; + } + if (body && body.status === "error") { + throw new Error(body.message || t("errors.requestFailed", "请求失败")); + } + if (body && body.success === false) { + throw new Error(body.message || body.error || t("errors.requestFailed", "请求失败")); + } + return body || {}; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, "`"); + } + + function localNavigationHost(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); + if (!host) return true; + return host === "localhost" + || host === "0.0.0.0" + || host === "::" + || host === "::1" + || host === "0:0:0:0:0:0:0:0" + || host === "0:0:0:0:0:0:0:1" + || /^127(?:\.\d{1,3}){3}$/.test(host); + } + + function hostForUrl(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1"); + return host.includes(":") ? `[${host}]` : host; + } + + function resolveHostUrl(value) { + const raw = String(value || "").trim(); + if (!raw || raw === "#") return raw || "#"; + if (raw.startsWith("#")) return raw; + + let parsed; + try { + parsed = new URL(raw, window.location.href); + } catch (_) { + return raw; + } + + if (!/^https?:$/.test(parsed.protocol) || !localNavigationHost(parsed.hostname)) { + return raw; + } + + const browserHost = window.location.hostname; + if (!browserHost) return raw; + const replacementHost = hostForUrl(browserHost); + parsed.host = parsed.port ? `${replacementHost}:${parsed.port}` : replacementHost; + return parsed.href; + } + + function fmt(value, digits = 1) { + const num = Number(value || 0); + if (!Number.isFinite(num)) return "0"; + return new Intl.NumberFormat(locale(), { maximumFractionDigits: digits }).format(num); + } + + function normalizeScore(value) { + const num = Number(value || 0); + if (!Number.isFinite(num)) return 0; + return Math.max(0, Math.min(100, num <= 1 ? num * 100 : num)); + } + + function setText(id, value) { + const el = $(id); + if (el) el.textContent = value; + } + + function setHtml(id, html) { + const el = $(id); + if (el) el.innerHTML = html; + } + + function empty(text = t("empty.default", "暂无数据")) { + return `
${escapeHtml(text)}
`; + } + + function pill(text, tone = "") { + return `${escapeHtml(text)}`; + } + + function button(label, attrs = "", cls = "ghost-button") { + return ``; + } + + function normalizeId(id) { + return String(id ?? "").trim(); + } + + function selectedReviewSet(kind) { + if (!state.selectedReviews[kind]) state.selectedReviews[kind] = new Set(); + return state.selectedReviews[kind]; + } + + function selectedReviewIds(kind) { + return Array.from(selectedReviewSet(kind)).filter(Boolean); + } + + function isReviewSelected(kind, id) { + return selectedReviewSet(kind).has(normalizeId(id)); + } + + function isJargonSelected(id) { + return state.selectedJargon.has(normalizeId(id)); + } + + function pruneSelection(selection, validIds) { + const valid = new Set((validIds || []).map(normalizeId).filter(Boolean)); + Array.from(selection).forEach((id) => { + if (!valid.has(id)) selection.delete(id); + }); + } + + function reviewCheckbox(kind, id) { + const safeId = normalizeId(id); + return ``; + } + + function jargonCheckbox(id) { + const safeId = normalizeId(id); + return ``; + } + + function reviewCountText(kind, total) { + const selected = selectedReviewIds(kind).length; + return selected ? `${fmt(selected, 0)}/${fmt(total, 0)}` : fmt(total, 0); + } + + function visibleReviewIds(kind) { + const reviews = state.pageData.reviews || {}; + if (kind === "persona") { + return ((reviews.persona_pending || {}).updates || []) + .filter((item) => item && item.review_source !== "style_learning") + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + if (kind === "style") { + return ((reviews.style_reviews || {}).reviews || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + if (kind === "jargon") { + return (((reviews.jargon_pending || {}).jargon_list) || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + return []; + } + + function stylePageReviewIds() { + const style = state.pageData.style || {}; + return (((style.reviews || {}).reviews) || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + + function jargonPageIds() { + return (state.pageData.lastJargonItems || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + + function refreshSelectionLabels() { + ["persona", "style", "jargon"].forEach((kind) => { + const ids = visibleReviewIds(kind); + setText(`${kind}-review-count`, reviewCountText(kind, ids.length)); + }); + setText("expression-review-count", t("selection.selectedCount", "已选 {count}").replace("{count}", fmt(selectedReviewIds("style").length, 0))); + const selectedJargon = Array.from(state.selectedJargon).filter(Boolean).length; + const visibleJargon = jargonPageIds().length; + setText("jargon-selection-count", t("selection.selectedOfTotal", "已选 {selected}/{total}") + .replace("{selected}", fmt(selectedJargon, 0)) + .replace("{total}", fmt(visibleJargon, 0))); + } + + function setBusy(label = t("status.loading", "加载中")) { + setText("runtime-status", label); + setText("hero-status", label); + } + + function showToast(message, tone = "ok") { + const region = $("toast-region"); + if (!region) return; + if (state.toastTimer) { + clearTimeout(state.toastTimer); + state.toastTimer = null; + } + region.replaceChildren(); + const el = document.createElement("div"); + el.className = `toast ${tone}`; + const text = document.createElement("span"); + text.textContent = message; + const close = document.createElement("button"); + close.className = "toast-close"; + close.type = "button"; + close.setAttribute("aria-label", t("actions.closeToast", "关闭提示")); + close.textContent = "×"; + close.addEventListener("click", () => { + if (state.toastTimer) clearTimeout(state.toastTimer); + el.remove(); + }); + el.append(text, close); + region.appendChild(el); + state.toastTimer = setTimeout(() => { + el.classList.add("leaving"); + setTimeout(() => { + el.remove(); + if (state.toastTimer) state.toastTimer = null; + }, 220); + }, 3200); + } + + function warningListHtml(warnings) { + const items = Array.isArray(warnings) + ? warnings + .map((message) => String(message).trim()) + .filter(Boolean) + : []; + return items.map((message) => ` +
+ 成本提醒 + ${escapeHtml(message)} +
+ `).join(""); + } + + function showErrors(errors) { + const panel = $("error-panel"); + if (!panel) return; + const entries = Object.entries(errors || {}); + panel.hidden = entries.length === 0; + panel.innerHTML = entries + .map(([key, value]) => `

${escapeHtml(key)}: ${escapeHtml(value)}

`) + .join(""); + } + + function showModal(title, html) { + const modal = $("detail-modal"); + setText("modal-title", title); + setHtml("modal-body", html); + if (!modal) return; + if (modal.open && typeof modal.close === "function") { + modal.close(); + } + if (typeof modal.showModal === "function") { + try { + modal.showModal(); + return; + } catch (_) {} + } + modal.setAttribute("open", ""); + } + + function closeModal() { + const modal = $("detail-modal"); + if (!modal) return; + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); + } + + function showConfirm(title, message, confirmText) { + return new Promise((resolve) => { + const modal = $("detail-modal"); + if (!modal) { resolve(window.confirm(message)); return; } + setText("modal-title", title); + setHtml("modal-body", ` +

${escapeHtml(message)}

+
+ + +
+ `); + const done = (result) => { + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); + resolve(result); + }; + $("confirm-ok").addEventListener("click", () => done(true), { once: true }); + $("confirm-cancel").addEventListener("click", () => done(false), { once: true }); + $("modal-close").addEventListener("click", () => done(false), { once: true }); + modal.addEventListener("cancel", (e) => { e.preventDefault(); done(false); }, { once: true }); + if (typeof modal.showModal === "function") { + try { modal.showModal(); return; } catch (_) {} + } + modal.setAttribute("open", ""); + }); + } + + function resolvePageFromHash() { + const raw = window.location.hash.replace(/^#\/?/, ""); + return PAGE_META[raw] ? raw : "home"; + } + + function navigateToPage(page, options = {}) { + const next = PAGE_META[page] ? page : "home"; + state.page = next; + if (!options.skipHash) { + window.location.hash = `#/${next}`; + } + qsa(".page").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); + qsa(".nav-item").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); + const meta = PAGE_META[next] || PAGE_META.home; + setText("page-kicker", t(meta[0], meta[2])); + setText("page-title", t(meta[1], meta[3])); + loadPageData(next, { force: !!options.force }); + } + + async function loadDashboard(force = false) { + if (state.dashboard && !force) { + renderDashboard(state.dashboard); + return state.dashboard; + } + setBusy(t("status.syncing", "同步中")); + try { + const data = await apiGet("dashboard"); + state.dashboard = data; + state.overview = data.overview || data; + renderDashboard(data); + return data; + } catch (error) { + showToast(error.message || String(error), "error"); + showErrors({ bridge: error.message || String(error) }); + throw error; + } + } + + async function loadPageData(page, options = {}) { + const force = !!options.force; + try { + if (page === "home" || page === "insights") { + const data = await loadDashboard(force); + if (page === "insights") renderInsights(data); + return; + } + if (page === "monitoring") return renderMonitoring(await cached("monitoring", () => apiGet("monitoring"), force)); + if (page === "reviews") return renderReviews(await cached("reviews", () => apiGet("reviews", { limit: 50 }), force)); + if (page === "jargon-learning") return loadJargon(force); + if (page === "expression-learning") return renderStyle(await cached("style", () => apiGet("style", { limit: 50 }), force)); + if (page === "persona-learning") return renderPersona(await cached("persona", () => apiGet("persona", { group_id: "default", limit: 30 }), force)); + if (page === "content") return renderContent(await cached("content", () => apiGet("content", { page: 1, page_size: 20 }), force)); + if (page === "graphs") return loadGraphs(force); + if (page === "reply-strategy") return renderReplyStrategy(await cached("integrations", () => apiGet("integrations"), force)); + if (page === "integrations") return renderIntegrations(await cached("integrations", () => apiGet("integrations"), force)); + if (page === "settings") return renderSettings(await cached("settings", () => apiGet("settings", { schema: "true" }), force)); + } catch (error) { + showToast(error.message || String(error), "error"); + } + } + + async function cached(key, loader, force) { + if (!force && state.pageData[key]) return state.pageData[key]; + setBusy(t("status.loading", "加载中")); + const data = await loader(); + state.pageData[key] = data; + return data; + } + + function renderDashboard(data) { + const overview = data.overview || data; + const runtime = overview.runtime || {}; + const webui = overview.webui || {}; + const learning = overview.learning_stats || {}; + const jargon = overview.jargon || {}; + const styleStats = ((overview.style || {}).statistics) || {}; + const persona = overview.persona || {}; + const errors = data.errors || overview.errors || {}; + const degraded = runtime.database_degraded || Object.keys(errors).length > 0; + + const statusLabel = degraded ? t("status.partial", "部分可用") : t("status.healthy", "运行正常"); + const resolvedDashboardUrl = resolveHostUrl(webui.dashboard_url || ""); + const summary = degraded + ? t("status.degradedSummary", "嵌入式页面已载入,部分服务处于降级状态。") + : t("status.connectedSummary", "已连接官方插件页 API,独立 WebUI: {url}") + .replace("{url}", resolvedDashboardUrl || t("status.notConfigured", "未配置")); + setText("runtime-status", statusLabel); + setText("hero-status", statusLabel); + setText("runtime-summary", summary); + setText("hero-summary", summary); + $("runtime-status")?.classList.toggle("warn", degraded); + $("hero-status")?.classList.toggle("warn", degraded); + + const fullLink = $("full-dashboard-link"); + if (fullLink && resolvedDashboardUrl) fullLink.href = resolvedDashboardUrl; + + setText("stat-messages", fmt(learning.total_messages_collected)); + setText("stat-jargon", fmt(jargon.confirmed_jargon)); + setText("stat-style", fmt(styleStats.unique_styles || styleStats.total_samples)); + setText("stat-persona", fmt(learning.persona_updates || persona.begin_dialog_count)); + + renderQuickActions(overview.quick_links || []); + renderModuleCards(overview.modules || []); + renderModuleChart(overview.modules || []); + renderIntelligence(overview.metrics || {}); + renderInsights(data); + showErrors(errors); + } + + function renderQuickActions(links) { + const html = links.map((link) => { + const url = resolveHostUrl(link.url || "#"); + const external = /^https?:\/\//.test(String(url || "")); + return ` + ${escapeHtml(link.label || t("actions.entry", "入口"))} + ${escapeHtml(link.description || "")} + `; + }).join(""); + setHtml("quick-actions", html); + } + + function renderModuleCards(modules) { + const html = modules.map((item) => ` +
+
+

${escapeHtml(item.title)}

+ ${pill(item.enabled ? t("state.enabled", "启用") : t("state.closed", "关闭"), item.enabled ? "ok" : "warn")} +
+

${escapeHtml(item.description || "")}

+
+ ${escapeHtml(fmt(item.metric))} + ${escapeHtml(item.metric_label || "")} +
+
+ `).join(""); + setHtml("module-card-grid", html || empty()); + } + + function renderModuleChart(modules) { + const maxValue = Math.max(1, ...modules.map((item) => Number(item.metric || 0))); + const html = modules.map((item) => { + const value = Math.max(4, Math.min(100, (Number(item.metric || 0) / maxValue) * 100)); + return `
+ ${escapeHtml(item.title)} +
+ ${escapeHtml(fmt(item.metric))} +
`; + }).join(""); + setHtml("module-chart", html || empty()); + } + + function renderIntelligence(metrics) { + const score = normalizeScore(metrics.overall_score); + $("intelligence-ring")?.style.setProperty("--value", String(score)); + setText("intelligence-score", fmt(score)); + const dimCount = metrics.dimensions && typeof metrics.dimensions === "object" + ? Object.keys(metrics.dimensions).length + : 0; + setText("metrics-summary", dimCount + ? t("home.metricsSummary", "已有 {count} 个维度参与评估。").replace("{count}", fmt(dimCount, 0)) + : t("home.noMetrics", "智能指标服务暂未产生维度数据。")); + } + + function buildInsights(data) { + const overview = data.overview || {}; + const reviews = data.reviews || {}; + const monitoring = data.monitoring || {}; + const integrations = data.integrations || {}; + const errors = data.errors || {}; + const items = []; + const push = (severity, title, detail, target) => items.push({ severity, title, detail, target }); + + if ((overview.runtime || {}).database_degraded) { + push("warn", t("insights.databaseDegraded", "数据库处于降级状态"), (overview.runtime || {}).database_error || t("insights.databaseNotReady", "数据库服务未完整启动。"), "monitoring"); + } + const pendingPersona = ((reviews.persona_pending || {}).updates || []).length; + const pendingStyle = ((reviews.style_reviews || {}).reviews || []).length; + const pendingJargon = (((reviews.jargon_pending || {}).jargon_list) || []).length; + const totalBacklog = pendingPersona + pendingStyle + pendingJargon; + if (totalBacklog > 0) { + push("action", t("insights.reviewBacklog", "审查队列有积压"), t("insights.reviewBacklogDetail", "当前有 {count} 条学习结果等待确认。").replace("{count}", fmt(totalBacklog, 0)), "reviews"); + } + const score = normalizeScore(((overview.metrics || {}).overall_score)); + if (score > 0 && score < 60) { + push("warn", t("insights.lowScore", "智能评分偏低"), t("insights.lowScoreDetail", "综合评分 {score},建议查看表达样本和学习批次。").replace("{score}", fmt(score)), "metrics"); + } + const health = (monitoring.health || {}).overall; + if (health && health !== "healthy") { + push("warn", t("insights.healthWarn", "健康检查提示异常"), t("insights.healthWarnDetail", "当前健康状态为 {status}。").replace("{status}", health), "monitoring"); + } + const delegation = integrations.delegation || {}; + if (delegation.memory_delegated || delegation.reply_delegated) { + push("ok", t("insights.delegationEnabled", "伴随插件委托已启用"), t("insights.delegationDetail", "记忆委托: {memory},回复委托: {reply}。") + .replace("{memory}", delegation.memory_delegated ? t("state.yes", "是") : t("state.no", "否")) + .replace("{reply}", delegation.reply_delegated ? t("state.yes", "是") : t("state.no", "否")), "integrations"); + } + Object.entries(errors).forEach(([key, value]) => { + push("warn", t("insights.moduleFailed", "模块 {name} 读取失败").replace("{name}", key), String(value), "monitoring"); + }); + if (!items.length) { + push("ok", t("insights.noIssues", "暂无高优先级问题"), t("insights.noIssuesDetail", "核心学习、审查和监控模块均已返回可用数据。"), "home"); + } + return items; + } + + function renderInsights(data) { + const insights = buildInsights(data || state.dashboard || {}); + const html = insights.map((item) => ` +
+ ${escapeHtml(item.severity === "ok" ? "OK" : item.severity === "action" ? "ACTION" : "WARN")} +

${escapeHtml(item.title)}

+

${escapeHtml(item.detail)}

+ ${button(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} +
+ `).join(""); + setHtml("ai-insight-list", html); + } + + function renderMonitoring(data) { + const health = data.health || {}; + const checks = health.checks || {}; + const healthHtml = Object.entries(checks).map(([key, item]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(item.status || "unknown")} + ${escapeHtml(summarizeObject(item.detail || {}))} +
+ `).join(""); + setHtml("health-grid", healthHtml || empty(t("empty.healthChecks", "暂无健康检查数据"))); + + const functions = ((data.functions || {}).functions || []).slice(0, 20); + const fnHtml = functions.map((item) => ` +
+ ${escapeHtml(shortName(item.name))} + ${escapeHtml(fmt((item.duration || {}).avg || 0, 4))}s + ${escapeHtml(fmt(item.calls || 0, 0))} calls +
+ `).join(""); + setHtml("function-list", fnHtml || empty((data.functions || {}).debug_mode ? t("empty.functionStats", "暂无函数性能数据") : t("empty.debugDisabled", "debug_mode 未启用"))); + showErrors(data.errors || {}); + } + + async function loadJargon(force) { + const confirmed = $("jargon-confirmed")?.value || ""; + const filter = $("jargon-filter")?.value || ""; + const keyword = $("jargon-keyword")?.value || ""; + const params = { page: 1, page_size: 30 }; + if (confirmed) params.confirmed = confirmed; + if (filter) params.filter = filter; + if (keyword) params.keyword = keyword; + const data = await cached(`jargon:${JSON.stringify(params)}`, () => apiGet("jargon", params), force); + renderJargon(data); + } + + function renderJargon(data) { + const stats = data.stats || {}; + setHtml("jargon-stat-grid", statCards([ + [t("jargon.stats.candidates", "候选词"), stats.total_candidates], + [t("jargon.stats.confirmed", "已确认"), stats.confirmed_jargon], + [t("jargon.stats.inferred", "推断完成"), stats.completed_inference], + [t("jargon.stats.activeGroups", "活跃群组"), stats.active_groups], + ])); + const items = ((data.list || {}).jargon_list || []); + pruneSelection(state.selectedJargon, items.map((item) => item.id)); + const html = items.map((item) => ` +
+ ${jargonCheckbox(item.id)} +
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))} +
+ ${escapeHtml(item.group_id || "global")} + ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")} + ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))} +
+ ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)} + ${item.is_confirmed ? "" : button(t("actions.confirm", "确认"), `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${item.is_confirmed ? "" : button(t("actions.reject", "驳回"), `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)} + ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
+ `).join(""); + setHtml("jargon-list", html || empty(t("empty.jargon", "暂无黑话数据"))); + state.pageData.lastJargonItems = items; + state.pageData.currentJargonData = data; + refreshSelectionLabels(); + showErrors(data.errors || {}); + } + + function renderStyle(data) { + const stats = ((data.results || {}).statistics) || {}; + setHtml("style-stat-grid", statCards([ + [t("style.stats.samples", "风格样本"), stats.unique_styles || stats.total_samples], + [t("style.stats.avgConfidence", "平均置信度"), stats.avg_confidence], + [t("style.stats.totalSamples", "总样本"), stats.total_samples], + [t("style.stats.latestUpdate", "最近更新"), stats.latest_update ? t("state.yes", "有") : t("state.none", "无")], + ])); + const patterns = data.patterns || {}; + const patternGroups = [ + [t("style.patterns.emotion", "情绪模式"), patterns.emotion_patterns || []], + [t("style.patterns.language", "语言模式"), patterns.language_patterns || []], + [t("style.patterns.topic", "话题模式"), patterns.topic_patterns || []], + ]; + setHtml("style-pattern-columns", patternGroups.map(([title, list]) => ` +
+

${escapeHtml(title)}

+ ${(list || []).slice(0, 12).map((item) => `${escapeHtml(item.name || item.pattern || item.text || "")}`).join("") || empty(t("empty.patterns", "暂无模式"))} +
+ `).join("")); + const chartItems = patternGroups.map(([title, list]) => ({ title, metric: (list || []).length, accent: "#4169e1" })); + renderGenericBarChart("style-pattern-chart", chartItems); + const reviews = ((data.reviews || {}).reviews || []); + pruneSelection(selectedReviewSet("style"), reviews.map((item) => item.id)); + setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); + state.pageData.lastStyleItems = reviews; + refreshSelectionLabels(); + } + + function renderReviews(data) { + const personaPending = ((data.persona_pending || {}).updates || []) + .filter((item) => item && item.review_source !== "style_learning"); + const personaReviewed = ((data.persona_reviewed || {}).updates || []); + const styleReviews = ((data.style_reviews || {}).reviews || []); + const pendingJargon = (((data.jargon_pending || {}).jargon_list) || []); + pruneSelection(selectedReviewSet("persona"), personaPending.map((item) => item.id)); + pruneSelection(selectedReviewSet("style"), styleReviews.map((item) => item.id)); + pruneSelection(selectedReviewSet("jargon"), pendingJargon.map((item) => item.id)); + setText("persona-review-count", reviewCountText("persona", personaPending.length)); + setText("style-review-count", reviewCountText("style", styleReviews.length)); + setText("jargon-review-count", reviewCountText("jargon", pendingJargon.length)); + setText("reviewed-count", fmt(personaReviewed.length, 0)); + setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty(t("empty.personaUpdates", "暂无人格更新"))); + setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); + setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty(t("empty.jargonCandidates", "暂无黑话候选"))); + state.pageData.lastStyleItems = styleReviews; + setHtml("reviewed-persona-list", personaReviewed.slice(0, 12).map((item) => ` +
+ ${escapeHtml(item.id)} + ${escapeHtml(item.status || item.review_status || "reviewed")} + ${escapeHtml(item.reason || item.update_type || item.review_source || "")} +
+ `).join("") || empty(t("empty.reviewed", "暂无已审查记录"))); + showErrors(data.errors || {}); + } + + function personaReviewHtml(item) { + const id = item.id; + return `
+ ${reviewCheckbox("persona", id)} +
+ ${escapeHtml(item.update_type || item.review_source || t("reviews.personaUpdates", "人格更新"))} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.reason || item.description || "")} +

${escapeHtml(item.proposed_content || item.new_content || item.incremental_content || "").slice(0, 220)}

+
+
+ ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="persona" data-id="${escapeAttr(id)}"`, "solid-button")} + ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="persona" data-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; + } + + function styleReviewHtml(item) { + return `
+ ${reviewCheckbox("style", item.id)} +
+ ${escapeHtml(item.description || t("style.title", "表达方式学习"))} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.status || "pending")} +

${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}

+
+
+ ${button(t("actions.edit", "编辑"), `data-style-action="edit" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="style" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
`; + } + + function jargonReviewHtml(item) { + return `
+ ${reviewCheckbox("jargon", item.id)} +
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.group_id || "global")} · ${escapeHtml(t("units.times", "{count} 次").replace("{count}", fmt(item.occurrences || item.count, 0)))} +

${escapeHtml(item.meaning || item.definition || item.review_detail || t("empty.definition", "暂无释义"))}

+
+
+ ${button(t("actions.confirm", "确认"), `data-review-action="approve" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button(t("actions.rejectBack", "驳回"), `data-review-action="reject" data-kind="jargon" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
`; + } + + function renderPersona(data) { + const current = data.current || {}; + const persona = current.persona || {}; + setHtml("persona-state-stats", statCards([ + [t("persona.stats.promptLength", "提示词字数"), current.prompt_length], + [t("persona.stats.beginDialogs", "开场对话"), current.begin_dialog_count], + [t("persona.stats.toolCount", "工具数量"), current.tool_count], + [t("persona.stats.currentGroup", "当前群组"), current.group_id || "default"], + ])); + setText("persona-prompt-preview", current.prompt_preview || persona.system_prompt || persona.prompt || t("empty.personaPrompt", "暂无人格提示词")); + + const personas = data.personas || []; + setHtml("persona-list", personas.map((item) => { + const id = item.persona_id || item.id || item.name; + return `
+ ${escapeHtml(id)} + ${escapeHtml(item.name || id)} +
+ ${button(t("actions.edit", "编辑"), `data-persona-action="edit" data-persona-id="${escapeAttr(id)}"`)} + ${button(t("actions.export", "导出"), `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} + ${button(t("actions.delete", "删除"), `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; + }).join("") || empty(t("empty.personaList", "暂无人格列表"))); + state.pageData.lastPersonaItems = personas; + + const backups = ((data.backups || {}).backups || []); + setText("persona-backup-count", fmt(backups.length, 0)); + setHtml("persona-backup-list", backups.map((item) => ` +
+
+ ${escapeHtml(item.backup_name || t("persona.backupName", "备份 {id}").replace("{id}", item.id))} + ${escapeHtml(item.reason_short || item.reason || t("empty.noRemark", "无备注"))} +
+ ${escapeHtml(item.group_id || "default")} + ${escapeHtml(item.timestamp || item.created_at || "")} +
+ ${button(t("actions.view", "查看"), `data-persona-action="backup_detail" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`)} + ${button(t("actions.restore", "恢复"), `data-persona-action="backup_restore" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "solid-button")} + ${button(t("actions.delete", "删除"), `data-persona-action="backup_delete" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "danger-button")} +
+
+ `).join("") || empty(t("empty.personaBackups", "暂无人格备份"))); + showErrors(data.errors || {}); + } + + function renderContent(data) { + const content = data.content || {}; + const items = content[state.contentType] || []; + qsa("#content-tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.contentType === state.contentType)); + setHtml("learning-content-list", items.map((item) => ` +
+
+ ${escapeHtml(item.title || item.type || `#${item.id}`)} + ${escapeHtml(item.timestamp || "")} ${escapeHtml(item.metadata || "")} +

${escapeHtml(item.text || item.detail || "").slice(0, 360)}

+
+ ${button(t("actions.delete", "删除"), `data-content-action="delete_content" data-bucket="${escapeAttr(state.contentType)}" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty(t("empty.learningContent", "暂无学习内容"))); + + const batches = ((data.batches || {}).batches || []); + setHtml("batch-list", batches.map((item) => ` +
+ ${escapeHtml(item.batch_name || item.batch_id || item.id)} + ${escapeHtml(item.status || (item.success ? "success" : "unknown"))} + ${escapeHtml(fmt(item.quality_score || 0, 3))} + ${button(t("actions.delete", "删除"), `data-content-action="delete_batch" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty(t("empty.batchHistory", "暂无批次历史"))); + showErrors(data.errors || {}); + } + + async function loadGraphs(force) { + const type = $("graph-type")?.value || "memory"; + state.graph.type = type; + const data = await cached(`graphs:${type}`, () => apiGet("graphs", { type, limit: 140 }), force); + renderGraphs(data); + } + + function renderGraphs(data) { + const graph = data[state.graph.type] || data.memory || data.knowledge || {}; + const canvas = $("graph-canvas"); + const size = canvas + ? syncGraphCanvasSize(canvas, { force: true }) + : { width: 960, height: 520 }; + const rawNodes = Array.isArray(graph.nodes) ? graph.nodes : []; + state.graph.nodes = rawNodes.map((node, index) => + createGraphNode(node, index, rawNodes.length, size.width, size.height), + ); + state.graph.links = normalizeGraphLinks(graph.links || []); + settleGraphLayout(state.graph.nodes, state.graph.links, size.width, size.height); + state.graph.dragged = null; + state.graph.hovered = null; + setHtml("graph-stat-grid", statCards([ + [t("graphs.stats.nodes", "节点"), (graph.stats || {}).nodes || state.graph.nodes.length], + [t("graphs.stats.links", "连线"), (graph.stats || {}).links || state.graph.links.length], + [t("graphs.stats.groups", "群组"), (graph.stats || {}).groups || (graph.groups || []).length], + [t("graphs.stats.source", "来源"), graph.data_source || "self_learning"], + ])); + setHtml("graph-node-list", state.graph.nodes.slice(0, 18).map((node) => ` +
+ ${escapeHtml(node.name || node.id)} + ${escapeHtml(node.category_name || node.category || t("graphs.node", "节点"))} + ${escapeHtml(node.detail || "")} +
+ `).join("") || empty(t("empty.graphNodes", "暂无图谱节点"))); + startGraphRender(); + } + + function createGraphNode(node, index, total, width, height) { + const id = graphValueKey(node.id ?? node.name ?? node.label ?? `${state.graph.type}-${index}`); + const radius = graphNodeRadius(node); + const safeWidth = Math.max(320, width || 960); + const safeHeight = Math.max(320, height || 520); + const home = graphHomePosition(id, index, total, safeWidth, safeHeight, radius); + return { + ...node, + id, + label: node.name || node.label || id, + radius, + x: home.x, + y: home.y, + homeX: home.x, + homeY: home.y, + vx: 0, + vy: 0, + pinned: false, + }; + } + + function graphHomePosition(id, index, total, width, height, radius) { + const margin = graphNodeMargin(radius); + const centerX = width / 2; + const centerY = height / 2; + const seed = graphStableSeed(id); + const angleOffset = state.graph.type === "knowledge" ? 0.72 : 0; + const angle = index * 2.399963229728653 + angleOffset + seed * 0.0007; + const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); + const spreadX = Math.max(86, (width - margin * 2) * 0.36); + const spreadY = Math.max(72, (height - margin * 2) * 0.34); + return { + x: clamp(centerX + Math.cos(angle) * spreadX * ring, margin, width - margin), + y: clamp(centerY + Math.sin(angle) * spreadY * ring, margin, height - margin), + }; + } + + function settleGraphLayout(nodes, links, width, height) { + if (!nodes.length) return; + const byId = new Map(nodes.map((node) => [String(node.id), node])); + for (let iteration = 0; iteration < 18; iteration += 1) { + links.slice(0, 220).forEach((link) => { + const source = byId.get(String(link.source)); + const target = byId.get(String(link.target)); + if (!source || !target) return; + const dx = target.x - source.x; + const dy = target.y - source.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); + const adjust = (dist - desired) * 0.0035; + const nx = dx / dist; + const ny = dy / dist; + if (!source.pinned) { + source.x += nx * adjust; + source.y += ny * adjust; + } + if (!target.pinned) { + target.x -= nx * adjust; + target.y -= ny * adjust; + } + }); + + for (let i = 0; i < nodes.length; i += 1) { + for (let j = i + 1; j < Math.min(nodes.length, i + 42); j += 1) { + separateGraphNodes(nodes[i], nodes[j], 0.45); + } + } + + nodes.forEach((node) => { + if (!node.pinned) { + node.x += (node.homeX - node.x) * 0.12; + node.y += (node.homeY - node.y) * 0.12; + } + clampGraphNode(node, width, height); + }); + } + } + + function normalizeGraphLinks(links) { + if (!Array.isArray(links)) return []; + return links.map((link) => ({ + ...link, + source: graphValueKey(link.source ?? link.from), + target: graphValueKey(link.target ?? link.to), + })).filter((link) => link.source && link.target); + } + + function renderReplyStrategy(data) { + const cards = (data.dashboards || []).filter((item) => item.id === "group_chat_plus"); + setHtml("reply-strategy-cards", cards.map(integrationCardHtml).join("") || empty(t("empty.groupChatPlus", "未检测到 Group Chat Plus"))); + } + + function renderIntegrations(data) { + setHtml("integration-cards", (data.dashboards || []).map(integrationCardHtml).join("") || empty(t("empty.integrations", "暂无融合状态"))); + setHtml("integration-warnings", warningListHtml(data.warnings || [])); + const settings = data.settings || {}; + setHtml("integration-settings", Object.entries(settings).map(([key, value]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(value === true ? t("state.on", "开启") : value === false ? t("state.off", "关闭") : value ?? t("state.unset", "未设置"))} +
+ `).join("") || empty(t("empty.integrationSettings", "暂无融合设置"))); + renderMaiBotImportPreview(data.maibot_learning || null); + } + + function integrationCardHtml(item) { + const dash = item.dashboard || {}; + const url = resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || "#"); + const disabled = !dash.available || !url || url === "#"; + return `
+
+ ${escapeHtml(item.role || "")} +

${escapeHtml(item.title || item.id)}

+

${escapeHtml(item.delegated ? t("state.delegated", "已委托") : item.active ? t("state.available", "可用") : t("state.disabled", "未启用"))}

+
+ ${escapeHtml(dash.label || t("actions.open", "打开"))} + ${escapeHtml((item.dev_api || {}).mode || "")} +
`; + } + + function collectMaiBotPayload() { + const payload = { + maibot_root: $("maibot-root-input")?.value?.trim() || "", + db_path: $("maibot-db-input")?.value?.trim() || "", + memorix_db_path: $("maibot-memorix-input")?.value?.trim() || "", + default_group_id: $("maibot-default-group-input")?.value?.trim() || "global", + import_expressions: Boolean($("maibot-import-expressions")?.checked), + import_jargons: Boolean($("maibot-import-jargons")?.checked), + import_memories: Boolean($("maibot-import-memories")?.checked), + approve_checked_expressions: Boolean($("maibot-approve-checked")?.checked), + }; + if (!payload.maibot_root && !payload.db_path) { + throw new Error(t("maibot.missingPath", "请填写 MaiBot 项目目录或主数据库路径")); + } + return payload; + } + + function renderMaiBotImportPreview(summary) { + const output = $("maibot-import-output"); + if (!output || !summary) return; + const counts = summary.counts || {}; + const breakdown = summary.review_breakdown || {}; + const destinations = summary.destinations || {}; + const lines = []; + if (Object.keys(counts).length) { + lines.push(t("maibot.previewCounts", "预览: 表达 {expressions} · 黑话 {jargons} · 记忆 {memories}") + .replace("{expressions}", fmt(counts.expressions, 0)) + .replace("{jargons}", fmt(counts.jargons, 0)) + .replace("{memories}", fmt(counts.memories, 0))); + } + if (Object.keys(breakdown).length) { + lines.push(t("maibot.importCounts", "导入: 表达审查 {style} · 黑话候选 {jargon} · 记忆审查 {memory}") + .replace("{style}", fmt(breakdown.style_learning_reviews, 0)) + .replace("{jargon}", fmt(breakdown.jargon_candidates, 0)) + .replace("{memory}", fmt(breakdown.persona_memory_reviews, 0))); + } + if (Object.keys(destinations).length) { + lines.push(t("maibot.destinations", "分类去向: 表达 -> {expressions}; 黑话 -> {jargons}; 记忆 -> {memories}") + .replace("{expressions}", destinations.expressions) + .replace("{jargons}", destinations.jargons) + .replace("{memories}", destinations.memories)); + } + output.textContent = `${lines.join("\n")}${lines.length ? "\n\n" : ""}${JSON.stringify(summary, null, 2)}`; + } + + function currentBatchReviewIds(kind) { + const selected = selectedReviewIds(kind); + return selected.length ? selected : visibleReviewIds(kind); + } + + async function handleBatchReviewAction(kind, action) { + const ids = currentBatchReviewIds(kind); + if (!ids.length) { + showToast(t("reviews.noBatchItems", "当前页没有可批量处理的审查项"), "error"); + return; + } + const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项"); + const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); + const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页"); + if (!await showConfirm(t("reviews.batchConfirmTitle", "批量操作确认"), t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") + .replace("{action}", actionText) + .replace("{scope}", scopeText) + .replace("{count}", fmt(ids.length, 0)) + .replace("{type}", typeText), actionText)) return; + + const payload = { + action: action === "delete" + ? kind === "persona" ? "batch_delete" : kind === "style" ? "batch_delete_style" : "batch_delete_jargon" + : kind === "persona" ? "batch_review" : kind === "style" ? "batch_review_style" : "batch_review_jargon", + ids, + decision: action, + }; + const result = await apiPost("reviews/action", payload); + showToast(result.message || t("messages.batchDone", "批量操作完成"), result.success ? "ok" : "error"); + selectedReviewSet(kind).clear(); + state.pageData.reviews = null; + await loadPageData(state.page, { force: true }); + } + + async function runMaiBotImportAction(action) { + const buttonEl = action === "maibot_import" ? $("maibot-import-button") : $("maibot-preview-button"); + const originalLabel = buttonEl?.textContent || ""; + try { + const payload = collectMaiBotPayload(); + if (buttonEl) { + buttonEl.disabled = true; + buttonEl.classList.add("is-busy"); + buttonEl.textContent = action === "maibot_import" ? t("actions.importing", "导入中") : t("actions.previewing", "预览中"); + } + setText("maibot-import-output", t("maibot.reading", "正在读取 MaiBot 学习数据...")); + const result = await apiPost("integrations/action", { action, ...payload }); + const detail = result.preview || result.result || result.payload || result; + renderMaiBotImportPreview(detail); + showToast(result.message || t("maibot.done", "MaiBot 学习数据操作完成"), result.success !== false ? "ok" : "error"); + if (action === "maibot_import") { + state.pageData = {}; + await loadDashboard(true); + } + } catch (error) { + const message = error.message || String(error); + setText("maibot-import-output", message); + showToast(message, "error"); + } finally { + if (buttonEl) { + buttonEl.disabled = false; + buttonEl.classList.remove("is-busy"); + buttonEl.textContent = originalLabel; + } + } + } + + function renderSettings(data) { + const schema = data.schema || {}; + setHtml("settings-warnings", warningListHtml(schema.warnings || data.warnings || [])); + const groups = schema.groups || []; + if (!state.settingsGroup && groups.length) state.settingsGroup = groups[0].key; + setHtml("settings-groups", groups.map((group) => ` + + `).join("") || empty(t("empty.configSchema", "配置 schema 暂不可用"))); + + const active = groups.find((group) => group.key === state.settingsGroup) || groups[0] || { fields: [] }; + setHtml("config-form", (active.fields || []).map(fieldHtml).join("") || empty(t("empty.selectConfigGroup", "请选择配置分组"))); + renderPipMirrors(data.pip_mirrors || {}); + } + + function fieldHtml(field) { + const value = state.dirtySettings.has(field.key) ? state.dirtySettings.get(field.key) : field.value; + const common = `data-config-field="${escapeAttr(field.key)}" data-config-type="${escapeAttr(field.type)}" ${field.editable ? "" : "disabled"}`; + const groupKey = field.group_key || activeSettingsGroupKeyForField(field.key); + const label = configT(`${groupKey}.${field.key}.description`, field.label || field.key); + const hint = configT(`${groupKey}.${field.key}.hint`, field.hint || ""); + let control = ""; + if (field.widget === "toggle") { + control = ``; + } else if (field.widget === "select" || field.widget === "provider") { + const options = field.options || []; + control = ``; + } else if (field.widget === "textarea" || field.type === "list") { + const textValue = Array.isArray(value) ? value.join("\n") : value ?? ""; + control = ``; + } else { + const inputType = field.widget === "number" || field.type === "int" || field.type === "float" ? "number" : "text"; + const step = field.type === "float" ? "0.01" : "1"; + control = ``; + } + return ``; + } + + function activeSettingsGroupKeyForField(fieldKey) { + const groups = ((state.pageData.settings || {}).schema || {}).groups || []; + const group = groups.find((item) => (item.fields || []).some((field) => field.key === fieldKey)); + return group?.key || state.settingsGroup || ""; + } + + function renderPipMirrors(mirrors) { + const select = $("pip-mirror-select"); + if (!select || select.childElementCount) return; + select.innerHTML = Object.entries(mirrors).map(([key, item]) => ``).join(""); + } + + function statCards(items) { + return items.map(([label, value]) => `
+ ${escapeHtml(label)} + ${escapeHtml(fmt(value, typeof value === "number" ? 1 : 0))} +
`).join(""); + } + + function renderGenericBarChart(id, items) { + const maxValue = Math.max(1, ...items.map((item) => Number(item.metric || 0))); + setHtml(id, items.map((item) => { + const value = Math.max(4, Math.min(100, Number(item.metric || 0) / maxValue * 100)); + return `
+ ${escapeHtml(item.title)} +
+ ${escapeHtml(fmt(item.metric, 0))} +
`; + }).join("") || empty()); + } + + function summarizeObject(obj) { + const entries = Object.entries(obj || {}).slice(0, 3); + return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`).join(" · "); + } + + function shortName(name) { + const text = String(name || ""); + return text.length > 58 ? `...${text.slice(-55)}` : text; + } + + function findReviewItem(kind, id) { + const reviews = state.pageData.reviews || {}; + const style = state.pageData.style || {}; + if (kind === "persona") return ((reviews.persona_pending || {}).updates || []).find((item) => String(item.id) === String(id)); + if (kind === "style") { + return ( + ((reviews.style_reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) + || ((style.reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) + ); + } + return (((reviews.jargon_pending || {}).jargon_list || []).find((item) => String(item.id) === String(id))); + } + + async function handleReviewAction(kind, id, action) { + if (action === "detail") { + showModal(t("modal.reviewDetails", "审查详情"), `
${escapeHtml(JSON.stringify(findReviewItem(kind, id) || {}, null, 2))}
`); + return; + } + let payload; + if (kind === "persona") { + payload = action === "delete" + ? { action: "delete", id } + : { action: "review", id, decision: action }; + } else if (kind === "style") { + payload = { action: `style_${action}`, id }; + } else { + payload = { action: `jargon_${action}`, id }; + } + const result = await apiPost("reviews/action", payload); + showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + selectedReviewSet(kind).delete(normalizeId(id)); + state.pageData.reviews = null; + await loadPageData(state.page, { force: true }); + } + + async function handleJargonAction(action, id) { + if (action === "edit") { + const item = (state.pageData.lastJargonItems || []).find((entry) => String(entry.id) === String(id)) || {}; + showModal(t("modal.editJargon", "编辑黑话"), ` + + + + `); + return; + } + const result = await apiPost("jargon/action", { action, id }); + showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + state.selectedJargon.delete(normalizeId(id)); + state.pageData = {}; + await loadPageData(state.page, { force: true }); + } + + async function handleJargonBatchAction(action) { + const visibleIds = jargonPageIds(); + if (action === "select_all") { + visibleIds.forEach((id) => state.selectedJargon.add(id)); + renderJargon(state.pageData.currentJargonData || {}); + return; + } + if (action === "clear") { + state.selectedJargon.clear(); + renderJargon(state.pageData.currentJargonData || {}); + return; + } + + const ids = Array.from(state.selectedJargon).filter(Boolean); + if (!ids.length) { + showToast(t("jargon.selectFirst", "请先选择黑话条目"), "error"); + return; + } + const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除"); + if (!await showConfirm(t("jargon.batchConfirmTitle", "批量操作确认"), t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; + + const result = await apiPost("jargon/action", { + action: action === "delete" ? "batch_delete" : "batch_review", + ids, + decision: action, + }); + showToast(result.message || t("jargon.batchDone", "批量黑话操作完成"), result.success ? "ok" : "error"); + state.selectedJargon.clear(); + state.pageData = {}; + await loadPageData("jargon-learning", { force: true }); + } + + function modalFieldValue(id) { + return $(id)?.value ?? ""; + } + + function parseModalJson(id, fallback) { + const raw = modalFieldValue(id).trim(); + if (!raw) return fallback; + try { + return JSON.parse(raw); + } catch (_) { + return raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } + } + + async function handleStyleAction(action, id) { + if (action === "edit") { + const item = (state.pageData.lastStyleItems || []).find((entry) => String(entry.id) === String(id)) || {}; + const patterns = typeof item.learned_patterns === "string" + ? item.learned_patterns + : JSON.stringify(item.learned_patterns || [], null, 2); + showModal(t("modal.editStyle", "编辑表达方式"), ` + + + + + `); + } + } + + async function handleStyleBatchAction(action) { + const visibleIds = stylePageReviewIds(); + const selected = selectedReviewSet("style"); + if (action === "select_all") { + visibleIds.forEach((id) => selected.add(id)); + renderStyle(state.pageData.style || {}); + return; + } + if (action === "clear") { + selected.clear(); + renderStyle(state.pageData.style || {}); + return; + } + + const ids = selectedReviewIds("style"); + if (!ids.length) { + showToast(t("style.selectFirst", "请先选择表达审查项"), "error"); + return; + } + const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); + if (!await showConfirm(t("style.batchConfirmTitle", "批量操作确认"), t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; + + const result = await apiPost("style/action", { + action: action === "delete" ? "batch_delete" : "batch_review", + ids, + decision: action, + }); + showToast(result.message || t("style.batchDone", "批量表达审查完成"), result.success ? "ok" : "error"); + selected.clear(); + state.pageData.style = null; + state.pageData.lastStyleItems = []; + await loadPageData("expression-learning", { force: true }); + } + + async function handlePersonaAction(buttonEl) { + const action = buttonEl.dataset.personaAction; + if (action === "edit") { + const personaId = buttonEl.dataset.personaId; + const item = (state.pageData.lastPersonaItems || []).find((entry) => String(entry.persona_id || entry.id || entry.name) === String(personaId)) || {}; + const beginDialogs = JSON.stringify(item.begin_dialogs || [], null, 2); + showModal(t("modal.editPersona", "编辑人格"), ` + + + + + + `); + return; + } + const body = { + action, + id: buttonEl.dataset.id, + group_id: buttonEl.dataset.groupId, + persona_id: buttonEl.dataset.personaId, + }; + const result = await apiPost("persona/action", body); + if (action === "backup_detail" || action === "export") { + showModal(action === "export" ? t("modal.personaExport", "人格导出") : t("modal.backupDetails", "备份详情"), `
${escapeHtml(JSON.stringify(result.persona || result.backup || result, null, 2))}
`); + return; + } + showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + state.pageData.persona = null; + await loadPageData("persona-learning", { force: true }); + } + + async function handleContentAction(buttonEl) { + const result = await apiPost("content/action", { + action: buttonEl.dataset.contentAction, + bucket: buttonEl.dataset.bucket, + id: buttonEl.dataset.id, + }); + showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + state.pageData.content = null; + await loadPageData("content", { force: true }); + } + + function collectConfigPayload() { + const payload = Object.fromEntries(state.dirtySettings.entries()); + qsa("[data-config-field]").forEach((field) => { + const key = field.dataset.configField; + const type = field.dataset.configType; + let value; + if (field.type === "checkbox") value = field.checked; + else if (type === "int") value = Number.parseInt(field.value || "0", 10); + else if (type === "float") value = Number.parseFloat(field.value || "0"); + else if (type === "list") { + const raw = field.value.trim(); + try { + value = raw.startsWith("[") ? JSON.parse(raw) : raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } catch (_) { + value = raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } + } else value = field.value; + payload[key] = value; + }); + return payload; + } + + function bindEvents() { + $("refresh-button")?.addEventListener("click", () => loadPageData(state.page, { force: true })); + $("modal-close")?.addEventListener("click", closeModal); + $("jargon-search-button")?.addEventListener("click", () => { + Object.keys(state.pageData).filter((key) => key.startsWith("jargon:")).forEach((key) => delete state.pageData[key]); + loadJargon(true); + }); + $("copy-insight-context")?.addEventListener("click", async () => { + const text = JSON.stringify(state.dashboard || {}, null, 2); + try { + await navigator.clipboard.writeText(text); + showToast(t("messages.contextCopied", "巡检上下文已复制")); + } catch (_) { + showModal(t("modal.insightContext", "巡检上下文"), `
${escapeHtml(text)}
`); + } + }); + $("relearn-button")?.addEventListener("click", async () => { + const result = await apiPost("content/action", { action: "relearn", group_id: "default" }); + showToast(result.message || t("messages.relearnSubmitted", "重新学习已提交"), result.success ? "ok" : "error"); + }); + $("graph-type")?.addEventListener("change", () => loadGraphs(true)); + $("config-save-button")?.addEventListener("click", async () => { + const result = await apiPost("settings/action", { action: "save", config: collectConfigPayload() }); + showToast(result.message || t("messages.settingsSaved", "设置已保存"), result.success ? "ok" : "error"); + state.pageData.settings = null; + await loadPageData("settings", { force: true }); + }); + $("dependency-install-button")?.addEventListener("click", async () => { + const installButton = $("dependency-install-button"); + const originalLabel = installButton?.textContent || t("actions.manualInstall", "手动安装"); + const settings = state.pageData.settings || {}; + if (installButton) { + installButton.disabled = true; + installButton.classList.add("is-busy"); + installButton.textContent = t("actions.installing", "安装中"); + } + setText("dependency-output", t("settings.installingDeps", "正在调用 pip 安装依赖,请等待命令输出...")); + try { + const result = await apiPost("settings/action", { + action: "install_dependencies", + manual_confirmed: true, + source: settings.manual_dependency_source || "system_settings", + tier: $("dependency-tier")?.value || "full", + pip_mirror: $("pip-mirror-select")?.value || "default", + }); + const detail = result.result || result; + setText("dependency-output", detail.output || detail.message || result.message || t("settings.installDone", "依赖安装任务结束")); + showToast(result.message || detail.message || t("settings.installDone", "依赖安装任务结束"), result.success !== false ? "ok" : "error"); + } catch (error) { + const message = error.message || String(error); + setText("dependency-output", message); + showToast(message, "error"); + } finally { + if (installButton) { + installButton.disabled = false; + installButton.classList.remove("is-busy"); + installButton.textContent = originalLabel; + } + } + }); + $("maibot-preview-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_preview")); + $("maibot-import-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_import")); + + document.addEventListener("click", async (event) => { + const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-batch-review-kind],[data-jargon-action],[data-jargon-batch-action],[data-style-action],[data-style-batch-action],[data-persona-action],[data-content-action],[data-settings-group]"); + if (!target) return; + if (target.dataset.routeCard) navigateToPage(target.dataset.routeCard); + if (target.dataset.refreshPage) loadPageData(target.dataset.refreshPage, { force: true }); + if (target.dataset.reviewAction) await handleReviewAction(target.dataset.kind, target.dataset.id, target.dataset.reviewAction); + if (target.dataset.batchReviewKind) await handleBatchReviewAction(target.dataset.batchReviewKind, target.dataset.batchReviewAction || "approve"); + if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); + if (target.dataset.jargonBatchAction) await handleJargonBatchAction(target.dataset.jargonBatchAction); + if (target.dataset.styleAction) await handleStyleAction(target.dataset.styleAction, target.dataset.id); + if (target.dataset.styleBatchAction) await handleStyleBatchAction(target.dataset.styleBatchAction); + if (target.dataset.personaAction) await handlePersonaAction(target); + if (target.dataset.contentAction) await handleContentAction(target); + if (target.dataset.settingsGroup) { + state.settingsGroup = target.dataset.settingsGroup; + renderSettings(state.pageData.settings || {}); + } + }); + + document.addEventListener("change", (event) => { + const reviewSelect = event.target.closest("[data-review-select-kind]"); + if (reviewSelect) { + const selection = selectedReviewSet(reviewSelect.dataset.reviewSelectKind); + const id = normalizeId(reviewSelect.dataset.reviewSelectId); + if (reviewSelect.checked) selection.add(id); + else selection.delete(id); + refreshSelectionLabels(); + return; + } + const jargonSelect = event.target.closest("[data-jargon-select-id]"); + if (jargonSelect) { + const id = normalizeId(jargonSelect.dataset.jargonSelectId); + if (jargonSelect.checked) state.selectedJargon.add(id); + else state.selectedJargon.delete(id); + refreshSelectionLabels(); + return; + } + const field = event.target.closest("[data-config-field]"); + if (!field) return; + state.dirtySettings.set(field.dataset.configField, field.type === "checkbox" ? field.checked : field.value); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-jargon-save"); + if (!save) return; + const result = await apiPost("jargon/action", { + action: "update", + id: save.dataset.id, + content: $("modal-jargon-content")?.value, + meaning: $("modal-jargon-meaning")?.value, + }); + closeModal(); + showToast(result.message || t("messages.jargonUpdated", "黑话已更新"), result.success ? "ok" : "error"); + state.pageData = {}; + await loadPageData("jargon-learning", { force: true }); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-style-save"); + if (!save) return; + const result = await apiPost("style/action", { + action: "update", + id: save.dataset.id, + description: modalFieldValue("modal-style-description"), + few_shots_content: modalFieldValue("modal-style-few-shots"), + learned_patterns: parseModalJson("modal-style-patterns", []), + }); + closeModal(); + showToast(result.message || t("messages.styleUpdated", "表达方式已更新"), result.success ? "ok" : "error"); + state.pageData.style = null; + state.pageData.lastStyleItems = []; + await loadPageData("expression-learning", { force: true }); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-persona-save"); + if (!save) return; + const personaId = save.dataset.personaId; + const result = await apiPost("persona/action", { + action: "update", + persona_id: personaId, + persona: { + persona_id: personaId, + name: modalFieldValue("modal-persona-name"), + system_prompt: modalFieldValue("modal-persona-prompt"), + prompt: modalFieldValue("modal-persona-prompt"), + begin_dialogs: parseModalJson("modal-persona-dialogs", []), + }, + }); + closeModal(); + showToast(result.message || t("messages.personaUpdated", "人格已更新"), result.success ? "ok" : "error"); + state.pageData.persona = null; + state.pageData.lastPersonaItems = []; + await loadPageData("persona-learning", { force: true }); + }); + + qsa(".nav-item").forEach((item) => { + item.addEventListener("click", (event) => { + event.preventDefault(); + navigateToPage(item.dataset.page || "home"); + }); + }); + qsa("#content-tabs button").forEach((buttonEl) => { + buttonEl.addEventListener("click", () => { + state.contentType = buttonEl.dataset.contentType || "dialogues"; + renderContent(state.pageData.content || {}); + }); + }); + window.addEventListener("hashchange", () => navigateToPage(resolvePageFromHash(), { skipHash: true })); + } + + function setThemeFromBridge() { + try { + const bridge = window.AstrBotPluginPage; + const apply = (ctx) => { + if (ctx && typeof ctx.isDark === "boolean") { + document.documentElement.setAttribute("data-theme", ctx.isDark ? "dark" : "light"); + } + }; + apply(bridge && bridge.getContext && bridge.getContext()); + if (bridge && bridge.onContextChange) bridge.onContextChange(apply); + if (bridge && bridge.onContext) bridge.onContext(apply); + } catch (_) {} + } + + function setI18nFromBridge() { + const rerender = () => { + applyStaticI18n(); + const meta = PAGE_META[state.page] || PAGE_META.home; + setText("page-kicker", t(meta[0], meta[2])); + setText("page-title", t(meta[1], meta[3])); + if (state.dashboard) renderDashboard(state.dashboard); + const data = state.pageData[state.page] || state.pageData[state.page === "expression-learning" ? "style" : state.page]; + if (state.page === "monitoring" && state.pageData.monitoring) renderMonitoring(state.pageData.monitoring); + else if (state.page === "reviews" && state.pageData.reviews) renderReviews(state.pageData.reviews); + else if (state.page === "jargon-learning" && state.pageData.currentJargonData) renderJargon(state.pageData.currentJargonData); + else if (state.page === "expression-learning" && state.pageData.style) renderStyle(state.pageData.style); + else if (state.page === "persona-learning" && state.pageData.persona) renderPersona(state.pageData.persona); + else if (state.page === "content" && state.pageData.content) renderContent(state.pageData.content); + else if (state.page === "graphs" && data) renderGraphs(data); + else if ((state.page === "reply-strategy" || state.page === "integrations") && state.pageData.integrations) { + if (state.page === "reply-strategy") renderReplyStrategy(state.pageData.integrations); + else renderIntegrations(state.pageData.integrations); + } else if (state.page === "settings" && state.pageData.settings) renderSettings(state.pageData.settings); + }; + try { + const bridge = window.AstrBotPluginPage; + if (bridge && bridge.onContextChange) bridge.onContextChange(rerender); + if (bridge && bridge.onContext) bridge.onContext(rerender); + } catch (_) {} + rerender(); + } + + function initSpringMotion() { + const stage = qs(".spring-stage"); + const canvas = $("physics-canvas"); + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + if (!stage || !canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const resize = () => { + const rect = stage.getBoundingClientRect(); + canvas.width = Math.max(1, Math.floor(rect.width * devicePixelRatio)); + canvas.height = Math.max(1, Math.floor(rect.height * devicePixelRatio)); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + }; + resize(); + window.addEventListener("resize", resize); + physics.particles = qsa(".spring-node:not(.node-core)", stage).map((el, index) => ({ + el, x: 0, y: 0, vx: 0, vy: 0, seed: index * 2.3, + })); + stage.addEventListener("pointermove", (event) => { + const rect = stage.getBoundingClientRect(); + physics.pointer.x = event.clientX - rect.left; + physics.pointer.y = event.clientY - rect.top; + physics.pointer.active = true; + }); + stage.addEventListener("pointerleave", () => { physics.pointer.active = false; }); + if (!physics.running) { + physics.running = true; + physics.last = performance.now(); + requestAnimationFrame(tickSpringMotion); + } + } + + function tickSpringMotion(now) { + const stage = qs(".spring-stage"); + const canvas = $("physics-canvas"); + if (!stage || !canvas) return; + const ctx = canvas.getContext("2d"); + const rect = stage.getBoundingClientRect(); + const dt = Math.min(0.033, Math.max(0.001, (now - physics.last) / 1000)); + physics.last = now; + ctx.clearRect(0, 0, rect.width, rect.height); + ctx.strokeStyle = "rgba(65, 105, 225, 0.14)"; + ctx.lineWidth = 1.2; + + const core = { x: rect.width / 2, y: rect.height / 2 }; + physics.particles.forEach((point) => { + const own = point.el.getBoundingClientRect(); + const baseX = own.left - rect.left + own.width / 2 - point.x; + const baseY = own.top - rect.top + own.height / 2 - point.y; + let targetX = Math.sin(now / 1350 + point.seed) * 6; + let targetY = Math.cos(now / 1500 + point.seed) * 5; + if (physics.pointer.active) { + const cx = baseX + point.x; + const cy = baseY + point.y; + const dx = cx - physics.pointer.x; + const dy = cy - physics.pointer.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const force = Math.max(0, 96 - dist) / 96; + targetX += dx / dist * force * 18; + targetY += dy / dist * force * 18; + } + point.vx += (targetX - point.x) * 28 * dt; + point.vy += (targetY - point.y) * 28 * dt; + point.vx *= Math.max(0, 1 - 14 * dt); + point.vy *= Math.max(0, 1 - 14 * dt); + point.x += point.vx * dt * 60; + point.y += point.vy * dt * 60; + const px = baseX + point.x; + const py = baseY + point.y; + ctx.beginPath(); + ctx.moveTo(core.x, core.y); + ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 8, px, py); + ctx.stroke(); + point.el.style.transform = `translate3d(${point.x.toFixed(2)}px, ${point.y.toFixed(2)}px, 0)`; + }); + requestAnimationFrame(tickSpringMotion); + } + + function startGraphRender() { + const canvas = $("graph-canvas"); + if (!canvas) return; + bindGraphCanvas(canvas); + syncGraphCanvasSize(canvas, { force: true }); + if (!state.graph.running) { + state.graph.running = true; + requestAnimationFrame(tickGraph); + } + } + + function bindGraphCanvas(canvas) { + if (state.graph.canvasBound) return; + state.graph.canvasBound = true; + + canvas.addEventListener("pointerdown", (event) => { + const point = graphPointer(event, canvas); + const node = hitGraphNode(point.x, point.y); + if (!node) return; + event.preventDefault(); + canvas.setPointerCapture?.(event.pointerId); + node.pinned = true; + node.vx = 0; + node.vy = 0; + state.graph.dragged = { + node, + pointerId: event.pointerId, + offsetX: node.x - point.x, + offsetY: node.y - point.y, + }; + canvas.classList.add("is-dragging"); + }); + + canvas.addEventListener("pointermove", (event) => { + const point = graphPointer(event, canvas); + const drag = state.graph.dragged; + if (drag && drag.pointerId === event.pointerId) { + const min = graphNodeMargin(drag.node.radius || graphNodeRadius(drag.node)); + drag.node.x = clamp(point.x + drag.offsetX, min, state.graph.width - min); + drag.node.y = clamp(point.y + drag.offsetY, min, state.graph.height - min); + drag.node.homeX = drag.node.x; + drag.node.homeY = drag.node.y; + drag.node.vx = 0; + drag.node.vy = 0; + event.preventDefault(); + return; + } + state.graph.hovered = hitGraphNode(point.x, point.y); + canvas.classList.toggle("has-hover", Boolean(state.graph.hovered)); + }); + + const releaseDrag = (event) => { + const drag = state.graph.dragged; + if (drag && drag.pointerId === event.pointerId) { + drag.node.vx = 0; + drag.node.vy = 0; + state.graph.dragged = null; + canvas.classList.remove("is-dragging"); + canvas.releasePointerCapture?.(event.pointerId); + } + }; + canvas.addEventListener("pointerup", releaseDrag); + canvas.addEventListener("pointercancel", releaseDrag); + canvas.addEventListener("pointerleave", () => { + state.graph.hovered = null; + canvas.classList.remove("has-hover"); + }); + + window.addEventListener("resize", () => { + syncGraphCanvasSize(canvas, { force: true }); + }); + } + + function tickGraph() { + const canvas = $("graph-canvas"); + if (!canvas) { + state.graph.running = false; + return; + } + const ctx = canvas.getContext("2d"); + const { width, height, ratio } = syncGraphCanvasSize(canvas); + const nodes = state.graph.nodes; + const links = state.graph.links; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + const byId = new Map(nodes.map((node) => [String(node.id), node])); + + links.slice(0, 260).forEach((link) => { + const source = byId.get(String(link.source)); + const target = byId.get(String(link.target)); + if (!source || !target) return; + const dx = target.x - source.x; + const dy = target.y - source.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); + const force = (dist - desired) * GRAPH_LINK_STRENGTH; + if (!source.pinned) { + source.vx += (dx / dist) * force; + source.vy += (dy / dist) * force; + } + if (!target.pinned) { + target.vx -= (dx / dist) * force; + target.vy -= (dy / dist) * force; + } + ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; + ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); + ctx.beginPath(); + ctx.moveTo(source.x, source.y); + ctx.lineTo(target.x, target.y); + ctx.stroke(); + }); + + for (let i = 0; i < nodes.length; i += 1) { + for (let j = i + 1; j < Math.min(nodes.length, i + 45); j += 1) { + separateGraphNodes(nodes[i], nodes[j], 0.022); + } + } + + nodes.forEach((node, index) => { + const cx = width / 2 + Math.sin(index) * 30; + const cy = height / 2 + Math.cos(index) * 24; + if (!node.pinned) { + node.vx += ((node.homeX || cx) - node.x) * GRAPH_HOME_STRENGTH + (cx - node.x) * GRAPH_CENTER_STRENGTH; + node.vy += ((node.homeY || cy) - node.y) * GRAPH_HOME_STRENGTH + (cy - node.y) * GRAPH_CENTER_STRENGTH; + node.vx *= 0.74; + node.vy *= 0.74; + node.x += node.vx; + node.y += node.vy; + } + const radius = node.radius || graphNodeRadius(node); + if (clampGraphNode(node, width, height) && !node.pinned) { + node.vx *= 0.12; + node.vy *= 0.12; + } + const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; + if (isHovered) { + ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; + ctx.beginPath(); + ctx.arc(node.x, node.y, radius + 10, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = node.source === "livingmemory" ? "#0f9f8f" : index % 3 === 0 ? "#4169e1" : index % 3 === 1 ? "#d97706" : "#e11d48"; + ctx.beginPath(); + ctx.arc(node.x, node.y, isHovered ? radius + 2 : radius, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--text").trim() || "#162033"; + ctx.font = "12px system-ui"; + const label = String(node.name || node.label || "").slice(0, 12); + const labelWidth = ctx.measureText(label).width; + const labelX = clamp(node.x + radius + 4, 6, width - labelWidth - 6); + const labelY = clamp(node.y + 4, 14, height - 6); + ctx.fillText(label, labelX, labelY); + }); + requestAnimationFrame(tickGraph); + } + + function syncGraphCanvasSize(canvas, options = {}) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(320, Math.floor(rect.width || canvas.clientWidth || state.graph.width || 960)); + const height = Math.max(320, Math.floor(rect.height || canvas.clientHeight || state.graph.height || 520)); + const ratio = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + const nextWidth = Math.floor(width * ratio); + const nextHeight = Math.floor(height * ratio); + const resized = canvas.width !== nextWidth || canvas.height !== nextHeight; + if (resized || options.force) { + const oldWidth = state.graph.width || width; + const oldHeight = state.graph.height || height; + canvas.width = nextWidth; + canvas.height = nextHeight; + state.graph.nodes.forEach((node, index) => { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const home = graphHomePosition(node.id, index, state.graph.nodes.length, width, height, radius); + node.x = clamp((node.x / oldWidth) * width, min, width - min); + node.y = clamp((node.y / oldHeight) * height, min, height - min); + node.homeX = home.x; + node.homeY = home.y; + if (options.force && !node.pinned) { + node.x = node.x * 0.55 + home.x * 0.45; + node.y = node.y * 0.55 + home.y * 0.45; + } + }); + } + state.graph.width = width; + state.graph.height = height; + return { width, height, ratio }; + } + + function graphPointer(event, canvas) { + const rect = canvas.getBoundingClientRect(); + return { + x: clamp(event.clientX - rect.left, 0, state.graph.width || rect.width), + y: clamp(event.clientY - rect.top, 0, state.graph.height || rect.height), + }; + } + + function hitGraphNode(x, y) { + for (let index = state.graph.nodes.length - 1; index >= 0; index -= 1) { + const node = state.graph.nodes[index]; + const radius = (node.radius || graphNodeRadius(node)) + 8; + if (Math.hypot(node.x - x, node.y - y) <= radius) { + return node; + } + } + return null; + } + + function graphNodeRadius(node) { + const raw = Number(node.symbolSize || node.value || node.weight || 12); + return Math.max(9, Math.min(24, Number.isFinite(raw) ? raw : 12)); + } + + function graphNodeMargin(radius) { + return Math.max(52, radius + GRAPH_SAFE_PADDING); + } + + function clampGraphNode(node, width, height) { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const nextX = clamp(node.x, min, width - min); + const nextY = clamp(node.y, min, height - min); + const clamped = nextX !== node.x || nextY !== node.y; + node.x = nextX; + node.y = nextY; + return clamped; + } + + function separateGraphNodes(a, b, strength) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const minDist = (a.radius || graphNodeRadius(a)) + (b.radius || graphNodeRadius(b)) + 20; + if (dist >= minDist) return; + const shift = (minDist - dist) / minDist * strength; + const nx = dx / dist; + const ny = dy / dist; + if (!a.pinned) { + a.vx -= nx * shift; + a.vy -= ny * shift; + a.x -= nx * shift * 6; + a.y -= ny * shift * 6; + } + if (!b.pinned) { + b.vx += nx * shift; + b.vy += ny * shift; + b.x += nx * shift * 6; + b.y += ny * shift * 6; + } + } + + function graphStableSeed(value) { + let hash = 0; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 31 + text.charCodeAt(index)) >>> 0; + } + return hash % 997; + } + + function graphValueKey(value) { + if (value && typeof value === "object") { + return String(value.id ?? value.name ?? value.label ?? ""); + } + return String(value ?? ""); + } + + function clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); + } + + async function init() { + setThemeFromBridge(); + bindEvents(); + initSpringMotion(); + try { + await bridgeReady(); + setI18nFromBridge(); + navigateToPage(resolvePageFromHash(), { skipHash: true, force: true }); + } catch (error) { + showToast(error.message || String(error), "error"); + setText("runtime-status", t("status.bridgeFailed", "桥接失败")); + setText("runtime-summary", error.message || String(error)); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css index 9d5a301e..e7b90af9 100644 --- a/pages/dashboard/styles.css +++ b/pages/dashboard/styles.css @@ -1,1310 +1,1310 @@ -:root { - --bg: #f5f7fb; - --surface: rgba(255, 255, 255, 0.86); - --surface-strong: #ffffff; - --surface-muted: #eef2f7; - --border: rgba(15, 23, 42, 0.11); - --text: #162033; - --muted: #687386; - --primary: #4169e1; - --teal: #0f9f8f; - --amber: #d97706; - --rose: #e11d48; - --green: #16a34a; - --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); - --spring: cubic-bezier(0.22, 1, 0.36, 1); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -html[data-theme="dark"] { - --bg: #121720; - --surface: rgba(27, 34, 47, 0.86); - --surface-strong: #202939; - --surface-muted: #16202d; - --border: rgba(255, 255, 255, 0.12); - --text: #f8fafc; - --muted: #a8b2c4; - --shadow: 0 18px 44px rgba(0, 0, 0, 0.24); -} - -* { - box-sizing: border-box; -} - -html { - min-height: 100%; - background: var(--bg); -} - -body { - margin: 0; - min-height: 100vh; - color: var(--text); - background: - linear-gradient(130deg, rgba(15, 159, 143, 0.11), transparent 32%), - linear-gradient(40deg, rgba(217, 119, 6, 0.08), transparent 36%), - var(--bg); -} - -button, -input, -select, -textarea, -a { - font: inherit; -} - -button { - color: inherit; -} - -.app-shell { - min-height: 100vh; - display: grid; - grid-template-columns: 268px minmax(0, 1fr); -} - -.sidebar { - position: sticky; - top: 0; - height: 100vh; - display: flex; - flex-direction: column; - gap: 18px; - padding: 18px; - border-right: 1px solid var(--border); - background: color-mix(in srgb, var(--surface), var(--bg) 20%); - backdrop-filter: blur(22px); -} - -.brand-block { - display: grid; - grid-template-columns: 42px minmax(0, 1fr); - gap: 12px; - align-items: center; -} - -.brand-mark { - width: 42px; - height: 42px; - display: grid; - place-items: center; - border-radius: 8px; - color: #ffffff; - background: linear-gradient(145deg, var(--primary), var(--teal)); - font-weight: 850; - box-shadow: 0 12px 24px rgba(65, 105, 225, 0.24); -} - -.eyebrow { - margin: 0 0 3px; - color: var(--muted); - font-size: 11px; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; -} - -h1, -h2, -h3, -h4, -p { - margin-top: 0; -} - -h1 { - margin-bottom: 0; - font-size: 20px; - line-height: 1.1; - letter-spacing: 0; -} - -.nav-list { - display: grid; - gap: 5px; - overflow-y: auto; - padding-right: 2px; -} - -.nav-item { - min-height: 38px; - display: flex; - align-items: center; - padding: 0 12px; - border: 1px solid transparent; - border-radius: 8px; - color: var(--muted); - text-decoration: none; - transition: transform 180ms var(--spring), background 180ms ease, color 180ms ease; -} - -.nav-item:hover { - color: var(--text); - background: color-mix(in srgb, var(--surface-strong), transparent 18%); -} - -.nav-item.active { - color: #ffffff; - background: var(--primary); - box-shadow: 0 12px 24px rgba(65, 105, 225, 0.22); -} - -.sidebar-footer { - margin-top: auto; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); -} - -.sidebar-footer p { - margin: 9px 0 0; - color: var(--muted); - font-size: 12px; - line-height: 1.5; -} - -.workspace { - min-width: 0; - width: min(1280px, calc(100vw - 268px)); - margin: 0 auto; - padding: 22px 24px 36px; -} - -.topbar, -.page-titlebar, -.panel-heading, -.inline-actions, -.top-actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.topbar { - margin-bottom: 16px; -} - -.topbar h2 { - margin-bottom: 0; - font-size: 30px; - line-height: 1.15; - letter-spacing: 0; -} - -.top-actions, -.inline-actions { - flex-wrap: wrap; -} - -.compact-actions { - justify-content: flex-end; - gap: 6px; -} - -.compact-actions .ghost-button, -.compact-actions .danger-button { - min-height: 28px; - padding: 0 8px; - font-size: 12px; -} - -.icon-button, -.ghost-button, -.solid-button, -.danger-button, -.quick-entry, -.settings-group { - min-height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); - color: var(--text); - text-decoration: none; - cursor: pointer; - transition: transform 160ms var(--spring), border-color 160ms ease, background 160ms ease, opacity 160ms ease; -} - -.icon-button { - width: 40px; - padding: 0; - font-size: 19px; -} - -.ghost-button, -.solid-button, -.danger-button { - padding: 0 12px; - white-space: nowrap; -} - -.solid-button { - color: #ffffff; - border-color: var(--primary); - background: var(--primary); -} - -.danger-button { - color: #ffffff; - border-color: var(--rose); - background: var(--rose); -} - -.disabled { - pointer-events: none; - opacity: 0.55; -} - -button:disabled, -button.is-busy { - cursor: wait; - opacity: 0.64; - transform: none; -} - -.icon-button:hover, -.ghost-button:hover, -.solid-button:hover, -.danger-button:hover, -.quick-entry:hover, -.settings-group:hover { - transform: translateY(-1px); -} - -.status-pill, -.mini-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 26px; - padding: 3px 9px; - border-radius: 999px; - color: #075985; - background: rgba(14, 165, 233, 0.13); - font-size: 12px; - font-weight: 800; -} - -.status-pill.warn, -.mini-badge.warn { - color: #9a3412; - background: rgba(217, 119, 6, 0.15); -} - -.mini-badge.ok { - color: #166534; - background: rgba(22, 163, 74, 0.13); -} - -.page { - display: none; - animation: pageIn 190ms var(--spring); -} - -.page.active { - display: block; -} - -@keyframes pageIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.dashboard-hero { - display: grid; - grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.95fr); - gap: 14px; - min-height: 292px; - margin-bottom: 14px; -} - -.spring-stage { - position: relative; - min-height: 292px; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 8px; - background: - linear-gradient(90deg, rgba(65, 105, 225, 0.13), transparent), - linear-gradient(180deg, color-mix(in srgb, var(--surface-strong), transparent 6%), color-mix(in srgb, var(--surface-muted), transparent 12%)); - box-shadow: var(--shadow); -} - -#physics-canvas { - position: absolute; - inset: 0; -} - -.spring-node { - position: absolute; - width: 32px; - height: 32px; - border-radius: 8px; - background: var(--primary); - box-shadow: 0 16px 32px rgba(65, 105, 225, 0.28); - will-change: transform; -} - -.node-core { - left: 50%; - top: 50%; - width: 72px; - height: 72px; - margin: -36px 0 0 -36px; - background: linear-gradient(145deg, var(--primary), var(--teal)); -} - -.node-a { - left: 22%; - top: 28%; - background: var(--teal); -} - -.node-b { - left: 68%; - top: 22%; - background: var(--amber); -} - -.node-c { - left: 72%; - top: 70%; - background: var(--rose); -} - -.hero-copy, -.panel, -.stat-card, -.module-card, -.insight-card, -.integration-card, -.errors { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); - box-shadow: var(--shadow); -} - -.hero-copy { - display: flex; - flex-direction: column; - justify-content: center; - padding: 24px; -} - -.hero-copy h3 { - margin: 14px 0 9px; - font-size: 28px; - letter-spacing: 0; -} - -.hero-copy p, -.page-titlebar p, -.module-card p, -.insight-card p, -.integration-card p, -.review-main p { - color: var(--muted); - line-height: 1.55; -} - -.quick-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 16px; -} - -.quick-entry { - flex-direction: column; - align-items: flex-start; - padding: 10px 12px; -} - -.quick-entry small { - color: var(--muted); - font-size: 12px; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 14px; -} - -.stat-grid.compact { - grid-template-columns: repeat(4, minmax(120px, 1fr)); -} - -.stat-card { - min-height: 96px; - padding: 14px; - background: var(--surface-strong); -} - -.stat-card.small { - min-height: 78px; - box-shadow: none; -} - -.stat-card span, -.mini-label, -.table-row small, -.content-item small, -.review-main small, -.integration-card span, -.config-field small { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.stat-card strong { - display: block; - margin-top: 8px; - font-size: 27px; - letter-spacing: 0; -} - -.home-grid, -.content-grid, -.graph-grid, -.settings-grid { - display: grid; - gap: 14px; -} - -.home-grid { - grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); -} - -.content-grid.two-col { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.graph-grid { - grid-template-columns: minmax(0, 1fr) 360px; -} - -.settings-grid { - grid-template-columns: 300px minmax(0, 1fr); - margin-bottom: 14px; -} - -.settings-warning-list { - display: grid; - gap: 8px; - margin: 0 0 14px; -} - -.settings-warning { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - align-items: start; - padding: 10px 12px; - border: 1px solid rgba(217, 119, 6, 0.28); - border-radius: 8px; - color: #92400e; - background: rgba(217, 119, 6, 0.10); -} - -.settings-warning strong { - white-space: nowrap; - font-size: 12px; -} - -.settings-warning span { - min-width: 0; - overflow-wrap: anywhere; - font-size: 12px; - line-height: 1.55; -} - -html[data-theme="dark"] .settings-warning { - color: #fbbf24; - background: rgba(217, 119, 6, 0.16); -} - -.panel { - padding: 16px; -} - -.panel h3, -.page-titlebar h3 { - margin-bottom: 0; - font-size: 19px; - letter-spacing: 0; -} - -.page-titlebar { - margin-bottom: 14px; -} - -.page-titlebar p { - margin: 5px 0 0; -} - -.module-card-grid, -.insight-grid, -.review-layout, -.integration-cards, -.pattern-columns { - display: grid; - gap: 10px; -} - -.module-card-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.review-layout { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-bottom: 14px; -} - -.integration-cards { - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-bottom: 14px; -} - -.insight-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.pattern-columns { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.module-card, -.insight-card, -.integration-card { - min-height: 148px; - padding: 15px; - background: var(--surface-strong); -} - -.module-card { - cursor: pointer; - border-left: 4px solid var(--accent, var(--primary)); - transition: transform 180ms var(--spring), border-color 180ms ease; -} - -.module-card:hover { - transform: translateY(-1px); -} - -.module-card-head { - display: flex; - justify-content: space-between; - gap: 8px; -} - -.module-card h3, -.insight-card h3, -.integration-card h3 { - margin: 0 0 7px; - font-size: 17px; - letter-spacing: 0; -} - -.module-card p { - min-height: 46px; - margin-bottom: 12px; -} - -.metric-line { - display: flex; - align-items: end; - justify-content: space-between; - gap: 12px; -} - -.metric-line strong { - font-size: 25px; -} - -.metric-line span { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.bar-chart { - display: grid; - gap: 10px; -} - -.bar-row { - display: grid; - grid-template-columns: 84px minmax(0, 1fr) 48px; - align-items: center; - gap: 10px; - color: var(--muted); - font-size: 12px; -} - -.bar-track { - height: 10px; - overflow: hidden; - border-radius: 999px; - background: color-mix(in srgb, var(--muted), transparent 84%); -} - -.bar-fill { - width: calc(var(--value, 0) * 1%); - height: 100%; - border-radius: inherit; - background: var(--accent, var(--primary)); - transition: width 520ms var(--spring); -} - -.ring-row { - display: flex; - align-items: center; - gap: 14px; - margin-top: 18px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); -} - -.ring-chart { - width: 86px; - height: 86px; - display: grid; - place-items: center; - flex: 0 0 auto; - border-radius: 50%; - background: - radial-gradient(circle at center, var(--surface-strong) 57%, transparent 58%), - conic-gradient(var(--primary) calc(var(--value, 0) * 1%), color-mix(in srgb, var(--muted), transparent 84%) 0); -} - -.ring-chart span { - font-weight: 850; -} - -.insight-card.ok { - border-left: 4px solid var(--green); -} - -.insight-card.warn { - border-left: 4px solid var(--amber); -} - -.insight-card.action { - border-left: 4px solid var(--primary); -} - -.health-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.health-card { - min-height: 98px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); -} - -.health-card strong { - display: block; - margin: 8px 0 6px; -} - -.health-card.healthy strong { - color: var(--green); -} - -.health-card.degraded strong, -.health-card.unhealthy strong { - color: var(--amber); -} - -.review-list, -.function-list, -.compact-table, -.content-list, -.config-form { - min-width: 0; - display: grid; - gap: 8px; -} - -.review-item, -.content-item, -.table-row, -.config-field, -.pattern-column { - min-width: 0; - display: grid; - gap: 9px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); -} - -.review-item, -.content-item { - align-items: start; -} - -.review-item.selectable { - grid-template-columns: auto minmax(0, 1fr); - align-items: start; -} - -.review-item.selectable .row-actions { - grid-column: 2; -} - -.review-main p, -.content-item p { - margin-bottom: 0; - overflow-wrap: anywhere; -} - -.table-row { - grid-template-columns: minmax(120px, 1fr) auto minmax(72px, auto) auto; - align-items: center; -} - -.rich-row { - grid-template-columns: minmax(180px, 1fr) auto auto minmax(118px, auto); -} - -.table-row.selectable-row { - grid-template-columns: auto minmax(180px, 1fr) auto auto auto minmax(118px, auto); -} - -.table-row > *, -.rich-row > *, -.config-field > *, -.panel > * { - min-width: 0; -} - -.table-row span, -.table-row strong, -.table-row small, -.rich-row span, -.rich-row strong, -.rich-row small { - overflow-wrap: anywhere; -} - -.row-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.select-cell { - width: 22px; - min-width: 22px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.select-cell input { - width: 16px; - min-height: 16px; - padding: 0; - margin: 0; - cursor: pointer; -} - -.batch-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.toolbar-panel { - display: grid; - grid-template-columns: minmax(180px, 1fr) 150px 150px auto; - gap: 8px; - margin-bottom: 14px; -} - -input, -select, -textarea { - width: 100%; - min-height: 36px; - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 10px; - color: var(--text); - background: var(--surface-strong); -} - -textarea { - resize: vertical; -} - -.segment-control { - display: inline-flex; - gap: 4px; - padding: 4px; - margin-bottom: 14px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); -} - -.segment-control button { - min-height: 32px; - padding: 0 12px; - border: 0; - border-radius: 7px; - color: var(--muted); - background: transparent; - cursor: pointer; -} - -.segment-control button.active { - color: #ffffff; - background: var(--primary); -} - -.code-preview { - max-height: 360px; - overflow: auto; - margin: 0; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - background: var(--surface-muted); - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; -} - -.graph-panel { - container-type: inline-size; - padding: 10px; - overflow: hidden; -} - -#graph-canvas { - width: 100%; - height: clamp(320px, 56.25cqw, 520px); - aspect-ratio: 16 / 9; - max-height: 58vh; - min-height: 320px; - display: block; - touch-action: none; - border-radius: 8px; - cursor: grab; - background: - linear-gradient(var(--border) 1px, transparent 1px), - linear-gradient(90deg, var(--border) 1px, transparent 1px), - color-mix(in srgb, var(--surface-strong), transparent 4%); - background-size: 34px 34px; -} - -@supports not (height: 1cqw) { - #graph-canvas { - height: clamp(320px, 48vw, 520px); - } -} - -.persona-layout, -.persona-state-panel, -.persona-list-panel, -.persona-backup-panel { - min-width: 0; -} - -.persona-layout { - grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); -} - -.persona-state-panel .stat-grid.compact { - grid-template-columns: repeat(2, minmax(120px, 1fr)); -} - -.persona-state-panel .code-preview { - max-height: min(42vh, 420px); -} - -.persona-list-panel .table-row, -.persona-backup-panel .table-row { - grid-template-columns: minmax(0, 1fr); - align-items: start; -} - -.persona-list-panel .row-actions, -.persona-backup-panel .row-actions { - justify-content: flex-start; -} - -#graph-canvas.has-hover { - cursor: grab; -} - -#graph-canvas.is-dragging { - cursor: grabbing; -} - -.settings-sidebar { - align-content: start; - display: grid; - gap: 8px; -} - -.settings-group { - width: 100%; - min-height: 54px; - justify-content: flex-start; - flex-direction: column; - align-items: flex-start; - padding: 10px 12px; -} - -.settings-group.active { - color: #ffffff; - border-color: var(--primary); - background: var(--primary); -} - -.settings-group.active small { - color: rgba(255, 255, 255, 0.78); -} - -.config-field { - grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 1fr); - align-items: center; -} - -.switch { - position: relative; - width: 54px; - height: 30px; - justify-self: start; -} - -.switch input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.switch span { - position: absolute; - inset: 0; - border-radius: 999px; - background: color-mix(in srgb, var(--muted), transparent 74%); - transition: background 180ms ease; -} - -.switch span::after { - content: ""; - position: absolute; - left: 3px; - top: 3px; - width: 24px; - height: 24px; - border-radius: 50%; - background: #ffffff; - box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); - transition: transform 220ms var(--spring); -} - -.switch input:checked + span { - background: var(--green); -} - -.switch input:checked + span::after { - transform: translateX(24px); -} - -.dependency-panel { - margin-top: 14px; -} - -.dependency-panel .code-preview { - margin-top: 12px; - min-height: 80px; -} - -.maibot-import-panel { - margin-bottom: 14px; -} - -.maibot-import-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-top: 12px; -} - -.maibot-import-panel .config-field { - align-items: center; -} - -.toggle-row { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin: 12px 0; -} - -.toggle-row label { - min-height: 34px; - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); - color: var(--muted); - font-size: 13px; - font-weight: 700; -} - -.maibot-import-panel .code-preview { - min-height: 136px; -} - -.empty-state { - min-height: 54px; - display: grid; - place-items: center; - padding: 14px; - border: 1px dashed var(--border); - border-radius: 8px; - color: var(--muted); - background: color-mix(in srgb, var(--surface-strong), transparent 28%); -} - -.errors { - margin-top: 14px; - padding: 14px; - color: #9a3412; - background: rgba(217, 119, 6, 0.12); -} - -.toast-region { - position: fixed; - right: 18px; - top: 18px; - z-index: 20; - display: grid; - gap: 8px; -} - -.toast { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - max-width: min(360px, calc(100vw - 36px)); - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - background: var(--surface-strong); - box-shadow: var(--shadow); - animation: toastIn 180ms var(--spring); -} - -.toast-close { - width: 24px; - height: 24px; - padding: 0; - border: 0; - color: var(--muted); - background: transparent; - line-height: 1; - cursor: pointer; -} - -.toast-close:hover { - color: var(--text); -} - -.toast.error { - color: #991b1b; -} - -.toast.leaving { - opacity: 0; - transform: translateY(-4px); -} - -@keyframes toastIn { - from { - opacity: 0; - transform: translateY(-6px); - } -} - -.modal { - width: min(760px, calc(100vw - 28px)); - padding: 0; - border: 0; - background: transparent; -} - -.modal::backdrop { - background: rgba(15, 23, 42, 0.46); - backdrop-filter: blur(5px); -} - -.modal-panel { - padding: 16px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); - box-shadow: var(--shadow); -} - -@media (max-width: 1120px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: relative; - height: auto; - border-right: 0; - border-bottom: 1px solid var(--border); - } - - .nav-list { - grid-auto-flow: column; - grid-auto-columns: max-content; - overflow-x: auto; - overflow-y: hidden; - } - - .workspace { - width: min(100vw, 1280px); - } - - .sidebar-footer { - display: none; - } - - .review-layout, - .integration-cards, - .settings-grid, - .maibot-import-grid, - .graph-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 860px) { - .workspace { - padding: 16px 14px 28px; - } - - .dashboard-hero, - .home-grid, - .content-grid.two-col { - grid-template-columns: 1fr; - } - - .stat-grid, - .stat-grid.compact, - .module-card-grid, - .insight-grid, - .pattern-columns, - .health-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .toolbar-panel { - grid-template-columns: 1fr 1fr; - } - - .table-row, - .rich-row, - .config-field { - grid-template-columns: 1fr; - } - - .row-actions { - justify-content: flex-start; - } - - #graph-canvas { - min-height: 300px; - max-height: none; - } -} - -@media (max-width: 560px) { - .topbar, - .page-titlebar, - .panel-heading { - align-items: flex-start; - flex-direction: column; - } - - .topbar h2 { - font-size: 24px; - } - - .stat-grid, - .stat-grid.compact, - .module-card-grid, - .insight-grid, - .pattern-columns, - .health-grid, - .toolbar-panel { - grid-template-columns: 1fr; - } - - .spring-stage { - min-height: 220px; - } - - .hero-copy h3 { - font-size: 24px; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - transition-duration: 1ms !important; - } - - .spring-node { - will-change: auto; - transform: none !important; - } -} +:root { + --bg: #f5f7fb; + --surface: rgba(255, 255, 255, 0.86); + --surface-strong: #ffffff; + --surface-muted: #eef2f7; + --border: rgba(15, 23, 42, 0.11); + --text: #162033; + --muted: #687386; + --primary: #4169e1; + --teal: #0f9f8f; + --amber: #d97706; + --rose: #e11d48; + --green: #16a34a; + --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); + --spring: cubic-bezier(0.22, 1, 0.36, 1); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +html[data-theme="dark"] { + --bg: #121720; + --surface: rgba(27, 34, 47, 0.86); + --surface-strong: #202939; + --surface-muted: #16202d; + --border: rgba(255, 255, 255, 0.12); + --text: #f8fafc; + --muted: #a8b2c4; + --shadow: 0 18px 44px rgba(0, 0, 0, 0.24); +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: var(--bg); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + linear-gradient(130deg, rgba(15, 159, 143, 0.11), transparent 32%), + linear-gradient(40deg, rgba(217, 119, 6, 0.08), transparent 36%), + var(--bg); +} + +button, +input, +select, +textarea, +a { + font: inherit; +} + +button { + color: inherit; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 268px minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 18px; + padding: 18px; + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--surface), var(--bg) 20%); + backdrop-filter: blur(22px); +} + +.brand-block { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 12px; + align-items: center; +} + +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 8px; + color: #ffffff; + background: linear-gradient(145deg, var(--primary), var(--teal)); + font-weight: 850; + box-shadow: 0 12px 24px rgba(65, 105, 225, 0.24); +} + +.eyebrow { + margin: 0 0 3px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +h3, +h4, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: 20px; + line-height: 1.1; + letter-spacing: 0; +} + +.nav-list { + display: grid; + gap: 5px; + overflow-y: auto; + padding-right: 2px; +} + +.nav-item { + min-height: 38px; + display: flex; + align-items: center; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--muted); + text-decoration: none; + transition: transform 180ms var(--spring), background 180ms ease, color 180ms ease; +} + +.nav-item:hover { + color: var(--text); + background: color-mix(in srgb, var(--surface-strong), transparent 18%); +} + +.nav-item.active { + color: #ffffff; + background: var(--primary); + box-shadow: 0 12px 24px rgba(65, 105, 225, 0.22); +} + +.sidebar-footer { + margin-top: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.sidebar-footer p { + margin: 9px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.workspace { + min-width: 0; + width: min(1280px, calc(100vw - 268px)); + margin: 0 auto; + padding: 22px 24px 36px; +} + +.topbar, +.page-titlebar, +.panel-heading, +.inline-actions, +.top-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.topbar { + margin-bottom: 16px; +} + +.topbar h2 { + margin-bottom: 0; + font-size: 30px; + line-height: 1.15; + letter-spacing: 0; +} + +.top-actions, +.inline-actions { + flex-wrap: wrap; +} + +.compact-actions { + justify-content: flex-end; + gap: 6px; +} + +.compact-actions .ghost-button, +.compact-actions .danger-button { + min-height: 28px; + padding: 0 8px; + font-size: 12px; +} + +.icon-button, +.ghost-button, +.solid-button, +.danger-button, +.quick-entry, +.settings-group { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + text-decoration: none; + cursor: pointer; + transition: transform 160ms var(--spring), border-color 160ms ease, background 160ms ease, opacity 160ms ease; +} + +.icon-button { + width: 40px; + padding: 0; + font-size: 19px; +} + +.ghost-button, +.solid-button, +.danger-button { + padding: 0 12px; + white-space: nowrap; +} + +.solid-button { + color: #ffffff; + border-color: var(--primary); + background: var(--primary); +} + +.danger-button { + color: #ffffff; + border-color: var(--rose); + background: var(--rose); +} + +.disabled { + pointer-events: none; + opacity: 0.55; +} + +button:disabled, +button.is-busy { + cursor: wait; + opacity: 0.64; + transform: none; +} + +.icon-button:hover, +.ghost-button:hover, +.solid-button:hover, +.danger-button:hover, +.quick-entry:hover, +.settings-group:hover { + transform: translateY(-1px); +} + +.status-pill, +.mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 3px 9px; + border-radius: 999px; + color: #075985; + background: rgba(14, 165, 233, 0.13); + font-size: 12px; + font-weight: 800; +} + +.status-pill.warn, +.mini-badge.warn { + color: #9a3412; + background: rgba(217, 119, 6, 0.15); +} + +.mini-badge.ok { + color: #166534; + background: rgba(22, 163, 74, 0.13); +} + +.page { + display: none; + animation: pageIn 190ms var(--spring); +} + +.page.active { + display: block; +} + +@keyframes pageIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-hero { + display: grid; + grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.95fr); + gap: 14px; + min-height: 292px; + margin-bottom: 14px; +} + +.spring-stage { + position: relative; + min-height: 292px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(65, 105, 225, 0.13), transparent), + linear-gradient(180deg, color-mix(in srgb, var(--surface-strong), transparent 6%), color-mix(in srgb, var(--surface-muted), transparent 12%)); + box-shadow: var(--shadow); +} + +#physics-canvas { + position: absolute; + inset: 0; +} + +.spring-node { + position: absolute; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--primary); + box-shadow: 0 16px 32px rgba(65, 105, 225, 0.28); + will-change: transform; +} + +.node-core { + left: 50%; + top: 50%; + width: 72px; + height: 72px; + margin: -36px 0 0 -36px; + background: linear-gradient(145deg, var(--primary), var(--teal)); +} + +.node-a { + left: 22%; + top: 28%; + background: var(--teal); +} + +.node-b { + left: 68%; + top: 22%; + background: var(--amber); +} + +.node-c { + left: 72%; + top: 70%; + background: var(--rose); +} + +.hero-copy, +.panel, +.stat-card, +.module-card, +.insight-card, +.integration-card, +.errors { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.hero-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: 24px; +} + +.hero-copy h3 { + margin: 14px 0 9px; + font-size: 28px; + letter-spacing: 0; +} + +.hero-copy p, +.page-titlebar p, +.module-card p, +.insight-card p, +.integration-card p, +.review-main p { + color: var(--muted); + line-height: 1.55; +} + +.quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.quick-entry { + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; +} + +.quick-entry small { + color: var(--muted); + font-size: 12px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.stat-grid.compact { + grid-template-columns: repeat(4, minmax(120px, 1fr)); +} + +.stat-card { + min-height: 96px; + padding: 14px; + background: var(--surface-strong); +} + +.stat-card.small { + min-height: 78px; + box-shadow: none; +} + +.stat-card span, +.mini-label, +.table-row small, +.content-item small, +.review-main small, +.integration-card span, +.config-field small { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.stat-card strong { + display: block; + margin-top: 8px; + font-size: 27px; + letter-spacing: 0; +} + +.home-grid, +.content-grid, +.graph-grid, +.settings-grid { + display: grid; + gap: 14px; +} + +.home-grid { + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); +} + +.content-grid.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.graph-grid { + grid-template-columns: minmax(0, 1fr) 360px; +} + +.settings-grid { + grid-template-columns: 300px minmax(0, 1fr); + margin-bottom: 14px; +} + +.settings-warning-list { + display: grid; + gap: 8px; + margin: 0 0 14px; +} + +.settings-warning { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid rgba(217, 119, 6, 0.28); + border-radius: 8px; + color: #92400e; + background: rgba(217, 119, 6, 0.10); +} + +.settings-warning strong { + white-space: nowrap; + font-size: 12px; +} + +.settings-warning span { + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.55; +} + +html[data-theme="dark"] .settings-warning { + color: #fbbf24; + background: rgba(217, 119, 6, 0.16); +} + +.panel { + padding: 16px; +} + +.panel h3, +.page-titlebar h3 { + margin-bottom: 0; + font-size: 19px; + letter-spacing: 0; +} + +.page-titlebar { + margin-bottom: 14px; +} + +.page-titlebar p { + margin: 5px 0 0; +} + +.module-card-grid, +.insight-grid, +.review-layout, +.integration-cards, +.pattern-columns { + display: grid; + gap: 10px; +} + +.module-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.review-layout { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 14px; +} + +.integration-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 14px; +} + +.insight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.pattern-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.module-card, +.insight-card, +.integration-card { + min-height: 148px; + padding: 15px; + background: var(--surface-strong); +} + +.module-card { + cursor: pointer; + border-left: 4px solid var(--accent, var(--primary)); + transition: transform 180ms var(--spring), border-color 180ms ease; +} + +.module-card:hover { + transform: translateY(-1px); +} + +.module-card-head { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.module-card h3, +.insight-card h3, +.integration-card h3 { + margin: 0 0 7px; + font-size: 17px; + letter-spacing: 0; +} + +.module-card p { + min-height: 46px; + margin-bottom: 12px; +} + +.metric-line { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; +} + +.metric-line strong { + font-size: 25px; +} + +.metric-line span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.bar-chart { + display: grid; + gap: 10px; +} + +.bar-row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr) 48px; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 12px; +} + +.bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--muted), transparent 84%); +} + +.bar-fill { + width: calc(var(--value, 0) * 1%); + height: 100%; + border-radius: inherit; + background: var(--accent, var(--primary)); + transition: width 520ms var(--spring); +} + +.ring-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 18px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.ring-chart { + width: 86px; + height: 86px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; + background: + radial-gradient(circle at center, var(--surface-strong) 57%, transparent 58%), + conic-gradient(var(--primary) calc(var(--value, 0) * 1%), color-mix(in srgb, var(--muted), transparent 84%) 0); +} + +.ring-chart span { + font-weight: 850; +} + +.insight-card.ok { + border-left: 4px solid var(--green); +} + +.insight-card.warn { + border-left: 4px solid var(--amber); +} + +.insight-card.action { + border-left: 4px solid var(--primary); +} + +.health-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.health-card { + min-height: 98px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.health-card strong { + display: block; + margin: 8px 0 6px; +} + +.health-card.healthy strong { + color: var(--green); +} + +.health-card.degraded strong, +.health-card.unhealthy strong { + color: var(--amber); +} + +.review-list, +.function-list, +.compact-table, +.content-list, +.config-form { + min-width: 0; + display: grid; + gap: 8px; +} + +.review-item, +.content-item, +.table-row, +.config-field, +.pattern-column { + min-width: 0; + display: grid; + gap: 9px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.review-item, +.content-item { + align-items: start; +} + +.review-item.selectable { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; +} + +.review-item.selectable .row-actions { + grid-column: 2; +} + +.review-main p, +.content-item p { + margin-bottom: 0; + overflow-wrap: anywhere; +} + +.table-row { + grid-template-columns: minmax(120px, 1fr) auto minmax(72px, auto) auto; + align-items: center; +} + +.rich-row { + grid-template-columns: minmax(180px, 1fr) auto auto minmax(118px, auto); +} + +.table-row.selectable-row { + grid-template-columns: auto minmax(180px, 1fr) auto auto auto minmax(118px, auto); +} + +.table-row > *, +.rich-row > *, +.config-field > *, +.panel > * { + min-width: 0; +} + +.table-row span, +.table-row strong, +.table-row small, +.rich-row span, +.rich-row strong, +.rich-row small { + overflow-wrap: anywhere; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.select-cell { + width: 22px; + min-width: 22px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.select-cell input { + width: 16px; + min-height: 16px; + padding: 0; + margin: 0; + cursor: pointer; +} + +.batch-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.toolbar-panel { + display: grid; + grid-template-columns: minmax(180px, 1fr) 150px 150px auto; + gap: 8px; + margin-bottom: 14px; +} + +input, +select, +textarea { + width: 100%; + min-height: 36px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + color: var(--text); + background: var(--surface-strong); +} + +textarea { + resize: vertical; +} + +.segment-control { + display: inline-flex; + gap: 4px; + padding: 4px; + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.segment-control button { + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.segment-control button.active { + color: #ffffff; + background: var(--primary); +} + +.code-preview { + max-height: 360px; + overflow: auto; + margin: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--surface-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.graph-panel { + container-type: inline-size; + padding: 10px; + overflow: hidden; +} + +#graph-canvas { + width: 100%; + height: clamp(320px, 56.25cqw, 520px); + aspect-ratio: 16 / 9; + max-height: 58vh; + min-height: 320px; + display: block; + touch-action: none; + border-radius: 8px; + cursor: grab; + background: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px), + color-mix(in srgb, var(--surface-strong), transparent 4%); + background-size: 34px 34px; +} + +@supports not (height: 1cqw) { + #graph-canvas { + height: clamp(320px, 48vw, 520px); + } +} + +.persona-layout, +.persona-state-panel, +.persona-list-panel, +.persona-backup-panel { + min-width: 0; +} + +.persona-layout { + grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); +} + +.persona-state-panel .stat-grid.compact { + grid-template-columns: repeat(2, minmax(120px, 1fr)); +} + +.persona-state-panel .code-preview { + max-height: min(42vh, 420px); +} + +.persona-list-panel .table-row, +.persona-backup-panel .table-row { + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.persona-list-panel .row-actions, +.persona-backup-panel .row-actions { + justify-content: flex-start; +} + +#graph-canvas.has-hover { + cursor: grab; +} + +#graph-canvas.is-dragging { + cursor: grabbing; +} + +.settings-sidebar { + align-content: start; + display: grid; + gap: 8px; +} + +.settings-group { + width: 100%; + min-height: 54px; + justify-content: flex-start; + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; +} + +.settings-group.active { + color: #ffffff; + border-color: var(--primary); + background: var(--primary); +} + +.settings-group.active small { + color: rgba(255, 255, 255, 0.78); +} + +.config-field { + grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 1fr); + align-items: center; +} + +.switch { + position: relative; + width: 54px; + height: 30px; + justify-self: start; +} + +.switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch span { + position: absolute; + inset: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--muted), transparent 74%); + transition: background 180ms ease; +} + +.switch span::after { + content: ""; + position: absolute; + left: 3px; + top: 3px; + width: 24px; + height: 24px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); + transition: transform 220ms var(--spring); +} + +.switch input:checked + span { + background: var(--green); +} + +.switch input:checked + span::after { + transform: translateX(24px); +} + +.dependency-panel { + margin-top: 14px; +} + +.dependency-panel .code-preview { + margin-top: 12px; + min-height: 80px; +} + +.maibot-import-panel { + margin-bottom: 14px; +} + +.maibot-import-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.maibot-import-panel .config-field { + align-items: center; +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 12px 0; +} + +.toggle-row label { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.maibot-import-panel .code-preview { + min-height: 136px; +} + +.empty-state { + min-height: 54px; + display: grid; + place-items: center; + padding: 14px; + border: 1px dashed var(--border); + border-radius: 8px; + color: var(--muted); + background: color-mix(in srgb, var(--surface-strong), transparent 28%); +} + +.errors { + margin-top: 14px; + padding: 14px; + color: #9a3412; + background: rgba(217, 119, 6, 0.12); +} + +.toast-region { + position: fixed; + right: 18px; + top: 18px; + z-index: 20; + display: grid; + gap: 8px; +} + +.toast { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + max-width: min(360px, calc(100vw - 36px)); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--surface-strong); + box-shadow: var(--shadow); + animation: toastIn 180ms var(--spring); +} + +.toast-close { + width: 24px; + height: 24px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + line-height: 1; + cursor: pointer; +} + +.toast-close:hover { + color: var(--text); +} + +.toast.error { + color: #991b1b; +} + +.toast.leaving { + opacity: 0; + transform: translateY(-4px); +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(-6px); + } +} + +.modal { + width: min(900px, calc(100vw - 28px)); + padding: 0; + border: 0; + background: transparent; +} + +.modal::backdrop { + background: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(5px); +} + +.modal-panel { + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); + box-shadow: var(--shadow); +} + +@media (max-width: 1120px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .nav-list { + grid-auto-flow: column; + grid-auto-columns: max-content; + overflow-x: auto; + overflow-y: hidden; + } + + .workspace { + width: min(100vw, 1280px); + } + + .sidebar-footer { + display: none; + } + + .review-layout, + .integration-cards, + .settings-grid, + .maibot-import-grid, + .graph-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .workspace { + padding: 16px 14px 28px; + } + + .dashboard-hero, + .home-grid, + .content-grid.two-col { + grid-template-columns: 1fr; + } + + .stat-grid, + .stat-grid.compact, + .module-card-grid, + .insight-grid, + .pattern-columns, + .health-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toolbar-panel { + grid-template-columns: 1fr 1fr; + } + + .table-row, + .rich-row, + .config-field { + grid-template-columns: 1fr; + } + + .row-actions { + justify-content: flex-start; + } + + #graph-canvas { + min-height: 300px; + max-height: none; + } +} + +@media (max-width: 560px) { + .topbar, + .page-titlebar, + .panel-heading { + align-items: flex-start; + flex-direction: column; + } + + .topbar h2 { + font-size: 24px; + } + + .stat-grid, + .stat-grid.compact, + .module-card-grid, + .insight-grid, + .pattern-columns, + .health-grid, + .toolbar-panel { + grid-template-columns: 1fr; + } + + .spring-stage { + min-height: 220px; + } + + .hero-copy h3 { + font-size: 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } + + .spring-node { + will-change: auto; + transform: none !important; + } +} diff --git a/services/core_learning/v2_learning_integration.py b/services/core_learning/v2_learning_integration.py index 5f0a1c85..4f90397c 100644 --- a/services/core_learning/v2_learning_integration.py +++ b/services/core_learning/v2_learning_integration.py @@ -1,936 +1,951 @@ -""" -V2 learning integration layer. - -Wires together the v2-architecture modules and provides a unified -interface for the ``MaiBotEnhancedLearningManager`` to delegate to. -When v2 features are enabled in ``PluginConfig`` the learning manager -instantiates this class and calls its ``process_message`` and -``get_enhanced_context`` methods alongside (or instead of) the legacy -code paths. - -Modules orchestrated: - * ``TieredLearningTrigger`` — per-message / batch operation scheduling - * ``LightRAGKnowledgeManager`` — knowledge graph (replaces legacy) - * ``Mem0MemoryManager`` — memory management (replaces legacy) - * ``ExemplarLibrary`` — few-shot style exemplar retrieval - * ``SocialGraphAnalyzer`` — community detection / influence ranking - * ``JargonStatisticalFilter`` — statistical jargon pre-filter - * ``IRerankProvider`` — cross-source context reranking - -Design notes: - - All module construction is guarded by the relevant config flags so - that unused modules are never instantiated. - - ``start()`` / ``stop()`` manage the full lifecycle of every active - v2 module. - - Each module that can fail during construction logs a warning and - falls back gracefully (the integration layer keeps working with - the remaining modules). - - Thread-safe for single-event-loop asyncio usage. -""" - -import asyncio -import hashlib -import time -from collections import defaultdict -from typing import Any, Dict, List, Optional, Tuple - -from astrbot.api import logger - -from ...config import PluginConfig -from ...core.interfaces import MessageData -from ...utils.cache_manager import get_cache_manager -from ..monitoring.instrumentation import monitored -from ..quality import ( - BatchTriggerPolicy, - TieredLearningTrigger, - TriggerResult, -) - -# Minimum message length to consider for LLM-heavy ingestion operations. -_MIN_INGESTION_LENGTH = 15 - -# Maximum buffered messages per group before force-flushing. -_INGESTION_BUFFER_MAX = 10 - - -class V2LearningIntegration: - """Facade that initialises, wires, and exposes v2 learning modules. - - Usage:: - - v2 = V2LearningIntegration(config, llm_adapter, db_manager, context) - await v2.start() - result = await v2.process_message(message, group_id) - context = await v2.get_enhanced_context("query", group_id) - await v2.stop() - """ - - def __init__( - self, - config: PluginConfig, - llm_adapter: Optional[Any] = None, - db_manager: Optional[Any] = None, - context: Optional[Any] = None, - feature_delegation: Optional[Any] = None, - ) -> None: - self._config = config - self._llm = llm_adapter - self._db = db_manager - self._context = context - self._feature_delegation = feature_delegation - self._started = False - self._provider_retry_lock = asyncio.Lock() - self._last_provider_retry: float = 0.0 - self._provider_retry_interval: float = max( - 0.1, - float(getattr(config, "provider_retry_interval_seconds", 10.0) or 10.0), - ) - self._knowledge_manager_retryable = True - self._memory_manager_retryable = True - - # --- Resolve framework providers via factories --------------- - self._embedding_provider = self._create_embedding_provider() - self._rerank_provider = self._create_rerank_provider() - - # --- Instantiate v2 modules ---------------------------------- - self._knowledge_manager = self._create_knowledge_manager() - self._memory_manager = self._create_memory_manager() - self._exemplar_library = self._create_exemplar_library() - self._social_analyzer = self._create_social_analyzer() - self._jargon_filter = self._create_jargon_filter() - - # --- Query result cache via CacheManager ---------------------- - self._cache = get_cache_manager() - - # --- Message buffer for batch ingestion ----------------------- - # Knowledge and memory ingestion are LLM-heavy operations and - # must not run per-message. Instead, messages are buffered here - # and flushed as a batch in a Tier 2 operation. - self._ingestion_buffer: Dict[str, List[MessageData]] = defaultdict(list) - - # --- Tiered trigger ------------------------------------------ - self._trigger = TieredLearningTrigger() - self._register_trigger_operations() - - logger.info( - "[V2Integration] Initialised — " - f"knowledge={self._config.knowledge_engine}, " - f"memory={self._config.memory_engine}, " - f"memory_delegated={self._memory_delegated()}, " - f"embedding={'yes' if self._embedding_provider else 'no'}, " - f"reranker={'yes' if self._rerank_provider else 'no'}" - ) - - # Lifecycle - - async def start(self) -> None: - """Start all active v2 modules that expose a ``start`` method.""" - await self.refresh_provider_bindings(force=True) - - await asyncio.gather(*( - self._start_one(name, module) - for name, module in self._active_modules() - if module and hasattr(module, "start") - )) - self._started = True - logger.info("[V2Integration] All modules started") - - async def refresh_provider_bindings(self, *, force: bool = False) -> bool: - """Retry framework provider binding and create dependent modules. - - AstrBot can load plugins before provider registries are populated. This - lets startup, warmup, and first-use paths bind providers later without a - manual plugin reload. - """ - if not self._needs_provider_or_module_retry(): - return False - - if not force and not self._provider_retry_due(): - return False - - async with self._provider_retry_lock: - if not self._needs_provider_or_module_retry(): - return False - - if not force and not self._provider_retry_due(): - return False - self._last_provider_retry = time.monotonic() - - changed = False - modules_to_start: List[Tuple[str, Any]] = [] - - if not self._embedding_provider and self._embedding_provider_configured(): - provider = self._create_embedding_provider() - if provider: - self._embedding_provider = provider - changed = True - - if not self._rerank_provider and self._rerank_provider_configured(): - provider = self._create_rerank_provider() - if provider: - self._rerank_provider = provider - changed = True - - if self._embedding_provider: - if self._knowledge_manager is None: - self._knowledge_manager = self._create_knowledge_manager() - if self._knowledge_manager: - changed = True - modules_to_start.append(( - "knowledge_manager", - self._knowledge_manager, - )) - - if self._memory_manager is None: - self._memory_manager = self._create_memory_manager() - if self._memory_manager: - changed = True - modules_to_start.append(( - "memory_manager", - self._memory_manager, - )) - - if self._exemplar_library_needs_embedding_refresh(): - self._exemplar_library = self._create_exemplar_library() - changed = True - - if changed: - self._register_trigger_operations() - if self._started and modules_to_start: - await asyncio.gather(*( - self._start_one(name, module) - for name, module in modules_to_start - if module and hasattr(module, "start") - )) - logger.info( - "[V2Integration] Provider bindings refreshed — " - f"embedding={'yes' if self._embedding_provider else 'no'}, " - f"reranker={'yes' if self._rerank_provider else 'no'}" - ) - return changed - - def _provider_retry_due(self) -> bool: - return ( - time.monotonic() - self._last_provider_retry - >= self._provider_retry_interval - ) - - async def _start_one(self, name: str, module: Any) -> None: - try: - await module.start() - except Exception as exc: - logger.warning( - f"[V2Integration] {name} start failed: {exc}" - ) - - def _active_modules(self) -> List[Tuple[str, Any]]: - return [ - ("knowledge_manager", self._knowledge_manager), - ("memory_manager", self._memory_manager), - ("exemplar_library", self._exemplar_library), - ("social_analyzer", self._social_analyzer), - ("jargon_filter", self._jargon_filter), - ] - - def _needs_provider_or_module_retry(self) -> bool: - if self._embedding_provider_configured() and not self._embedding_provider: - return True - if self._rerank_provider_configured() and not self._rerank_provider: - return True - if self._embedding_provider and self._knowledge_manager is None: - return ( - self._config.knowledge_engine == "lightrag" - and self._knowledge_manager_retryable - ) - if self._embedding_provider and self._memory_manager is None: - return ( - self._config.memory_engine == "mem0" - and not self._memory_delegated() - and self._memory_manager_retryable - ) - return self._exemplar_library_needs_embedding_refresh() - - def _embedding_provider_configured(self) -> bool: - return bool( - str(getattr(self._config, "embedding_provider_id", "") or "").strip() - ) - - def _rerank_provider_configured(self) -> bool: - return bool( - str(getattr(self._config, "rerank_provider_id", "") or "").strip() - ) - - def _exemplar_library_needs_embedding_refresh(self) -> bool: - if not (self._db and self._embedding_provider and self._exemplar_library): - return False - return getattr(self._exemplar_library, "_embedding", None) is None - - async def warmup(self, group_ids: List[str]) -> None: - """Pre-warm heavyweight module instances for *group_ids*. - - Should be called shortly after ``start()`` once active group IDs - are known. Currently only LightRAG benefits from pre-warming - (each cold-start avoids a 12-15s initialisation penalty on the - first user query). - """ - await self.refresh_provider_bindings() - if ( - self._knowledge_manager - and hasattr(self._knowledge_manager, "warmup_instances") - ): - try: - await self._knowledge_manager.warmup_instances(group_ids) - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge warmup failed: {exc}" - ) - - async def stop(self) -> None: - """Stop all active v2 modules and release resources. - - Attempts to flush remaining buffered messages with a per-group - timeout. Timed-out buffers are discarded to avoid blocking - the shutdown sequence. - """ - _flush_timeout = self._config.task_cancel_timeout - - for group_id in list(self._ingestion_buffer.keys()): - try: - await asyncio.wait_for( - self._flush_ingestion_buffer(group_id), - timeout=_flush_timeout, - ) - except asyncio.TimeoutError: - dropped = len(self._ingestion_buffer.pop(group_id, [])) - logger.warning( - f"[V2Integration] Buffer flush timeout for group " - f"{group_id}, dropped {dropped} messages" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] Buffer flush failed on stop " - f"for group {group_id}: {exc}" - ) - - modules: List[Tuple[str, Any]] = [ - ("knowledge_manager", self._knowledge_manager), - ("memory_manager", self._memory_manager), - ("exemplar_library", self._exemplar_library), - ("social_analyzer", self._social_analyzer), - ("jargon_filter", self._jargon_filter), - ] - - async def _stop_one(name: str, module: Any) -> None: - try: - await module.stop() - except Exception as exc: - logger.warning( - f"[V2Integration] {name} stop failed: {exc}" - ) - - async def _close_reranker() -> None: - try: - await self._rerank_provider.close() - except Exception as exc: - logger.warning(f"[V2Integration] Reranker close failed: {exc}") - - tasks = [ - _stop_one(name, module) - for name, module in modules - if module and hasattr(module, "stop") - ] - if self._rerank_provider and hasattr(self._rerank_provider, "close"): - tasks.append(_close_reranker()) - - await asyncio.gather(*tasks) - logger.info("[V2Integration] All modules stopped") - - # Public API - - @monitored - async def process_message( - self, message: MessageData, group_id: str - ) -> TriggerResult: - """Process an incoming message through the tiered trigger. - - Tier 1 operations run concurrently on every message. Tier 2 - operations fire when their policies are satisfied. - """ - await self.refresh_provider_bindings() - return await self._trigger.process_message(message, group_id) - - @monitored - async def get_enhanced_context( - self, - query: str, - group_id: str, - top_k: int = 5, - ) -> Dict[str, Any]: - """Retrieve v2 enhanced context for response generation. - - Returns a dict with optional keys: - * ``knowledge_context`` (str): Retrieved knowledge graph context. - * ``related_memories`` (List[str]): Semantically related memories. - * ``few_shot_examples`` (List[str]): Style exemplar texts - (not reranked; returned as-is). - * ``graph_stats`` (dict): Social graph summary statistics. - - When a reranker is available, knowledge and memory candidates are - reranked by relevance and only the top-k are returned. Few-shot - exemplars and graph stats are returned unmodified. - - Results are cached per (group_id, query_hash) with a configurable - TTL to avoid redundant retrieval on repeated or similar queries. - - All retrieval tasks run concurrently via ``asyncio.gather`` to - minimise total latency. - """ - await self.refresh_provider_bindings() - - # --- Check query result cache --- - cache_key = self._make_cache_key(query, group_id) - cached_result = self._cache.get("context", cache_key) - if cached_result is not None: - logger.debug( - f"[V2Integration] Context cache hit (group={group_id})" - ) - return cached_result - - context: Dict[str, Any] = {} - - # --- Build concurrent retrieval tasks --- - - async def _fetch_knowledge() -> None: - if not self._knowledge_manager: - return - try: - if hasattr(self._knowledge_manager, "query_knowledge"): - ctx = await self._knowledge_manager.query_knowledge( - query, group_id, - mode=self._config.lightrag_query_mode, - ) - elif hasattr( - self._knowledge_manager, - "answer_question_with_knowledge_graph", - ): - ctx = ( - await self._knowledge_manager - .answer_question_with_knowledge_graph(query, group_id) - ) - else: - ctx = "" - if ctx: - context["knowledge_context"] = ctx - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge retrieval failed: {exc}" - ) - - async def _fetch_memories() -> None: - if self._memory_delegated(): - logger.debug("[V2Integration] Memory retrieval delegated to LivingMemory") - return - if not self._memory_manager: - return - try: - memories = await self._memory_manager.get_related_memories( - query, group_id - ) - if memories: - context["related_memories"] = memories - except Exception as exc: - logger.debug( - f"[V2Integration] Memory retrieval failed: {exc}" - ) - - async def _fetch_exemplars() -> None: - if not self._exemplar_library: - return - try: - examples = await self._exemplar_library.get_few_shot_examples( - query, group_id, k=top_k - ) - if examples: - context["few_shot_examples"] = examples - except Exception as exc: - logger.debug( - f"[V2Integration] Exemplar retrieval failed: {exc}" - ) - - async def _fetch_graph_stats() -> None: - if not self._social_analyzer: - return - try: - stats = await self._social_analyzer.get_graph_statistics( - group_id - ) - if stats and stats.get("node_count", 0) > 0: - context["graph_stats"] = stats - except Exception as exc: - logger.debug( - f"[V2Integration] Social graph stats failed: {exc}" - ) - - # --- Run all retrievals concurrently --- - await asyncio.gather( - _fetch_knowledge(), - _fetch_memories(), - _fetch_exemplars(), - _fetch_graph_stats(), - ) - - # --- Conditional reranking --- - # Only invoke the reranker when there are enough candidates to - # justify the additional API round-trip latency. - rerank_candidates = len(context.get("related_memories", [])) - if "knowledge_context" in context: - rerank_candidates += 1 - min_candidates = getattr( - self._config, "rerank_min_candidates", 3 - ) - if self._rerank_provider and rerank_candidates >= min_candidates: - context = await self._rerank_context(query, context, top_k) - - # --- Store result in cache --- - self._cache.set("context", cache_key, context) - - return context - - # Cache helpers - - @staticmethod - def _make_cache_key(query: str, group_id: str) -> str: - """Generate a compact cache key from query text and group ID.""" - query_hash = hashlib.md5(query.encode("utf-8")).hexdigest()[:12] - return f"{group_id}:{query_hash}" - - def get_trigger_stats(self, group_id: str) -> Dict[str, Any]: - """Return tiered trigger statistics for a group.""" - return self._trigger.get_group_stats(group_id) - - # Module factories - - def _create_embedding_provider(self) -> Optional[Any]: - """Resolve embedding provider from the framework.""" - try: - from ..embedding.factory import EmbeddingProviderFactory - return EmbeddingProviderFactory.create(self._config, self._context) - except Exception as exc: - logger.debug( - f"[V2Integration] Embedding provider unavailable: {exc}" - ) - return None - - def _create_rerank_provider(self) -> Optional[Any]: - """Resolve reranker provider from the framework.""" - try: - from ..reranker.factory import RerankProviderFactory - return RerankProviderFactory.create(self._config, self._context) - except Exception as exc: - logger.debug(f"[V2Integration] Reranker unavailable: {exc}") - return None - - def _create_knowledge_manager(self) -> Optional[Any]: - """Create knowledge manager based on configured engine.""" - if self._config.knowledge_engine == "lightrag": - if not self._embedding_provider: - if self._embedding_provider_configured(): - logger.info( - "[V2Integration] LightRAG is waiting for the " - "embedding provider registry to become ready" - ) - else: - logger.warning( - "[V2Integration] LightRAG requires an embedding " - "provider; configure embedding_provider_id or use " - "the legacy knowledge engine" - ) - return None - try: - from ..integration import LightRAGKnowledgeManager - return LightRAGKnowledgeManager( - self._config, self._llm, self._embedding_provider - ) - except ImportError: - self._knowledge_manager_retryable = False - logger.warning( - "[V2Integration] lightrag-hku not installed, " - "falling back to legacy knowledge engine" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] LightRAG init failed: {exc}" - ) - logger.debug( - "[V2Integration] LightRAG traceback:", exc_info=True - ) - return None - - def _create_memory_manager(self) -> Optional[Any]: - """Create memory manager based on configured engine.""" - if self._memory_delegated(): - logger.info("[V2Integration] Memory engine skipped: delegated to LivingMemory") - return None - if self._config.memory_engine == "mem0": - if not self._embedding_provider: - if self._embedding_provider_configured(): - logger.info( - "[V2Integration] Mem0 is waiting for the embedding " - "provider registry to become ready" - ) - else: - logger.warning( - "[V2Integration] Mem0 requires an embedding provider; " - "configure embedding_provider_id or use the legacy " - "memory engine" - ) - return None - try: - from ..integration import Mem0MemoryManager - return Mem0MemoryManager( - self._config, self._llm, self._embedding_provider - ) - except ImportError: - self._memory_manager_retryable = False - logger.warning( - "[V2Integration] mem0ai not installed, " - "falling back to legacy memory engine" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] Mem0 init failed: {exc}" - ) - logger.debug( - "[V2Integration] Mem0 traceback:", exc_info=True - ) - return None - - def _create_exemplar_library(self) -> Optional[Any]: - """Create exemplar library if DB and embedding are available.""" - if not self._db: - return None - try: - from ..integration import ExemplarLibrary - return ExemplarLibrary(self._db, self._embedding_provider) - except Exception as exc: - logger.debug( - f"[V2Integration] ExemplarLibrary init failed: {exc}" - ) - return None - - def _create_social_analyzer(self) -> Optional[Any]: - """Create social graph analyzer.""" - try: - from ..social import SocialGraphAnalyzer - return SocialGraphAnalyzer(self._llm, self._db) - except Exception as exc: - logger.debug( - f"[V2Integration] SocialGraphAnalyzer init failed: {exc}" - ) - return None - - def _create_jargon_filter(self) -> Optional[Any]: - """Create jargon statistical filter.""" - try: - from ..jargon import JargonStatisticalFilter - return JargonStatisticalFilter() - except Exception as exc: - logger.debug( - f"[V2Integration] JargonStatisticalFilter init failed: {exc}" - ) - return None - - # Trigger wiring - - def _register_trigger_operations(self) -> None: - """Register all available modules with the tiered trigger. - - Architecture: - Tier 1 (per-message, sub-millisecond): - - jargon_stats: in-memory statistical counters - - ingestion_buffer: append message to buffer (no I/O) - - exemplar: embedding + DB insert (< 1s) - - Tier 2 (batch, LLM-gated, cooldown-protected): - - ingestion_flush: batch-process buffered messages through - LightRAG and Mem0, amortising LLM overhead across - multiple messages - - jargon: LLM-based jargon meaning inference - - social: community detection and influence ranking - - Knowledge graph ingestion (LightRAG) and memory ingestion (Mem0) - are intentionally registered as Tier 2 batch operations rather - than Tier 1 per-message callbacks because they each invoke one - or more LLM round-trips (entity extraction, fact extraction) - that take 3-10 seconds per message. Running them per-message - would dominate the event loop and block subsequent processing. - """ - - # ---- Tier 1: per-message lightweight operations ---- - - if self._jargon_filter: - jf = self._jargon_filter - - async def _jargon_update( - message: MessageData, group_id: str - ) -> None: - jf.update_from_message(message.message, group_id, message.sender_id) - - self._trigger.register_tier1("jargon_stats", _jargon_update) - - # Buffer messages for batch ingestion (knowledge + memory). - # This replaces the previous per-message LightRAG/Mem0 callbacks - # with a sub-millisecond append operation. - if self._knowledge_manager or self._memory_manager: - buf = self._ingestion_buffer - - async def _buffer_message( - message: MessageData, group_id: str - ) -> None: - if ( - message.message - and len(message.message.strip()) >= _MIN_INGESTION_LENGTH - ): - buf[group_id].append(message) - - self._trigger.register_tier1("ingestion_buffer", _buffer_message) - - if self._exemplar_library: - lib = self._exemplar_library - - async def _exemplar_add( - message: MessageData, group_id: str - ) -> None: - await lib.add_exemplar( - message.message, group_id, message.sender_id - ) - - self._trigger.register_tier1("exemplar", _exemplar_add) - - # ---- Tier 2: batch operations (LLM-heavy) ---- - - # Batch ingestion: flush buffered messages through LightRAG - # and Mem0. Fires every 5 messages or 60 seconds, whichever - # comes first. This amortises the per-message LLM overhead - # and reduces total API calls. - if self._knowledge_manager or self._memory_manager: - self._trigger.register_tier2( - "ingestion_flush", - self._flush_ingestion_buffer, - BatchTriggerPolicy( - message_threshold=5, cooldown_seconds=60 - ), - ) - - if self._jargon_filter: - jf2 = self._jargon_filter - llm = self._llm - db = self._db - - async def _jargon_batch(group_id: str) -> None: - candidates = jf2.get_jargon_candidates(group_id, top_k=20) - if not candidates or not llm: - return - for candidate in candidates[:10]: - try: - meaning = await llm.generate_response( - f"Explain the slang/jargon term " - f"'{candidate['term']}' in the context of an " - f"online chat group. Return a concise definition.", - model_type="filter", - ) - if ( - meaning - and db - and hasattr(db, "save_or_update_jargon") - ): - await db.save_or_update_jargon( - group_id, - candidate["term"], - { - "meaning": meaning, - "raw_content": "[]", - "is_jargon": True, - "count": 1, - "is_complete": True, - }, - ) - except Exception as exc: - logger.debug( - f"[V2Integration] Jargon inference failed " - f"for '{candidate['term']}': {exc}" - ) - - self._trigger.register_tier2( - "jargon", - _jargon_batch, - BatchTriggerPolicy( - message_threshold=20, cooldown_seconds=180 - ), - ) - - if self._social_analyzer: - sa = self._social_analyzer - - async def _social_batch(group_id: str) -> None: - # Execute independently so one failure does not skip the other. - try: - await sa.detect_communities(group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] detect_communities failed: {exc}" - ) - try: - await sa.get_influence_ranking(group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] get_influence_ranking failed: {exc}" - ) - - self._trigger.register_tier2( - "social", - _social_batch, - BatchTriggerPolicy( - message_threshold=50, cooldown_seconds=600 - ), - ) - - # Batch ingestion - - async def _flush_ingestion_buffer(self, group_id: str) -> None: - """Flush buffered messages for a group through knowledge and memory. - - Processes all buffered messages concurrently through LightRAG and - Mem0 in a single batch operation, then clears the buffer. Messages - within each engine are processed sequentially to avoid overwhelming - the underlying LLM providers with concurrent requests. - """ - messages = self._ingestion_buffer.pop(group_id, []) - if not messages: - return - - logger.debug( - f"[V2Integration] Flushing ingestion buffer: " - f"group={group_id}, count={len(messages)}" - ) - - async def _ingest_knowledge() -> None: - if not self._knowledge_manager: - return - method = None - if hasattr( - self._knowledge_manager, - "process_message_for_knowledge_graph", - ): - method = self._knowledge_manager.process_message_for_knowledge_graph - elif hasattr( - self._knowledge_manager, "process_message_for_knowledge" - ): - method = self._knowledge_manager.process_message_for_knowledge - if not method: - return - for msg in messages: - try: - await method(msg, group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge ingestion failed: {exc}" - ) - - async def _ingest_memory() -> None: - if self._memory_delegated(): - logger.debug("[V2Integration] Memory ingestion delegated to LivingMemory") - return - if not self._memory_manager: - return - for msg in messages: - try: - await self._memory_manager.add_memory_from_message( - msg, group_id - ) - except Exception as exc: - logger.debug( - f"[V2Integration] Memory ingestion failed: {exc}" - ) - - # Run knowledge and memory ingestion concurrently across engines, - # but sequentially within each engine to avoid provider overload. - await asyncio.gather( - _ingest_knowledge(), - _ingest_memory(), - ) - - def _memory_delegated(self) -> bool: - delegation = self._feature_delegation - if not delegation or not hasattr(delegation, "should_delegate_memory"): - return False - try: - return bool(delegation.should_delegate_memory()) - except Exception: - return False - - # Reranking - - @monitored - async def _rerank_context( - self, - query: str, - context: Dict[str, Any], - top_k: int, - ) -> Dict[str, Any]: - """Rerank knowledge and memory candidates by relevance. - - Few-shot exemplars and graph stats are returned unmodified. - """ - try: - documents: List[str] = [] - sources: List[str] = [] - - if "knowledge_context" in context: - documents.append(context["knowledge_context"]) - sources.append("knowledge") - - for mem in context.get("related_memories", []): - documents.append(mem) - sources.append("memory") - - if not documents: - return context - - results = await self._rerank_provider.rerank( - query, documents, top_n=top_k - ) - - # Rebuild context with reranked order. - reranked_memories: List[str] = [] - reranked_knowledge = "" - for r in results: - if r.index >= len(documents): - logger.debug( - f"[V2Integration] Reranker returned out-of-range " - f"index {r.index} (len={len(documents)}); skipping" - ) - continue - src = sources[r.index] - doc = documents[r.index] - if src == "knowledge": - reranked_knowledge = doc - elif src == "memory": - reranked_memories.append(doc) - - if reranked_knowledge: - context["knowledge_context"] = reranked_knowledge - elif "knowledge_context" in context: - del context["knowledge_context"] - - if reranked_memories: - context["related_memories"] = reranked_memories - elif "related_memories" in context: - del context["related_memories"] - - except Exception as exc: - logger.debug( - f"[V2Integration] Reranking failed, using unranked: {exc}" - ) - - return context +""" +V2 learning integration layer. + +Wires together the v2-architecture modules and provides a unified +interface for the ``MaiBotEnhancedLearningManager`` to delegate to. +When v2 features are enabled in ``PluginConfig`` the learning manager +instantiates this class and calls its ``process_message`` and +``get_enhanced_context`` methods alongside (or instead of) the legacy +code paths. + +Modules orchestrated: + * ``TieredLearningTrigger`` — per-message / batch operation scheduling + * ``LightRAGKnowledgeManager`` — knowledge graph (replaces legacy) + * ``Mem0MemoryManager`` — memory management (replaces legacy) + * ``ExemplarLibrary`` — few-shot style exemplar retrieval + * ``SocialGraphAnalyzer`` — community detection / influence ranking + * ``JargonStatisticalFilter`` — statistical jargon pre-filter + * ``IRerankProvider`` — cross-source context reranking + +Design notes: + - All module construction is guarded by the relevant config flags so + that unused modules are never instantiated. + - ``start()`` / ``stop()`` manage the full lifecycle of every active + v2 module. + - Each module that can fail during construction logs a warning and + falls back gracefully (the integration layer keeps working with + the remaining modules). + - Thread-safe for single-event-loop asyncio usage. +""" + +import asyncio +import hashlib +import time +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +from astrbot.api import logger + +from ...config import PluginConfig +from ...core.interfaces import MessageData +from ...utils.cache_manager import get_cache_manager +from ..monitoring.instrumentation import monitored +from ..quality import ( + BatchTriggerPolicy, + TieredLearningTrigger, + TriggerResult, +) + +# Minimum message length to consider for LLM-heavy ingestion operations. +_MIN_INGESTION_LENGTH = 15 + +# Maximum buffered messages per group before force-flushing. +_INGESTION_BUFFER_MAX = 10 + + +class V2LearningIntegration: + """Facade that initialises, wires, and exposes v2 learning modules. + + Usage:: + + v2 = V2LearningIntegration(config, llm_adapter, db_manager, context) + await v2.start() + result = await v2.process_message(message, group_id) + context = await v2.get_enhanced_context("query", group_id) + await v2.stop() + """ + + def __init__( + self, + config: PluginConfig, + llm_adapter: Optional[Any] = None, + db_manager: Optional[Any] = None, + context: Optional[Any] = None, + feature_delegation: Optional[Any] = None, + ) -> None: + self._config = config + self._llm = llm_adapter + self._db = db_manager + self._context = context + self._feature_delegation = feature_delegation + self._started = False + self._provider_retry_lock = asyncio.Lock() + self._last_provider_retry: float = 0.0 + self._provider_retry_interval: float = max( + 0.1, + float(getattr(config, "provider_retry_interval_seconds", 10.0) or 10.0), + ) + self._knowledge_manager_retryable = True + self._memory_manager_retryable = True + + # --- Resolve framework providers via factories --------------- + self._embedding_provider = self._create_embedding_provider() + self._rerank_provider = self._create_rerank_provider() + + # --- Instantiate v2 modules ---------------------------------- + self._knowledge_manager = self._create_knowledge_manager() + self._memory_manager = self._create_memory_manager() + self._exemplar_library = self._create_exemplar_library() + self._social_analyzer = self._create_social_analyzer() + self._jargon_filter = self._create_jargon_filter() + + # --- Query result cache via CacheManager ---------------------- + self._cache = get_cache_manager() + + # --- Message buffer for batch ingestion ----------------------- + # Knowledge and memory ingestion are LLM-heavy operations and + # must not run per-message. Instead, messages are buffered here + # and flushed as a batch in a Tier 2 operation. + self._ingestion_buffer: Dict[str, List[MessageData]] = defaultdict(list) + + # --- Tiered trigger ------------------------------------------ + self._trigger = TieredLearningTrigger() + self._register_trigger_operations() + + logger.info( + "[V2Integration] Initialised — " + f"knowledge={self._config.knowledge_engine}, " + f"memory={self._config.memory_engine}, " + f"memory_delegated={self._memory_delegated()}, " + f"embedding={'yes' if self._embedding_provider else 'no'}, " + f"reranker={'yes' if self._rerank_provider else 'no'}" + ) + + # Lifecycle + + async def start(self) -> None: + """Start all active v2 modules that expose a ``start`` method.""" + await self.refresh_provider_bindings(force=True) + + await asyncio.gather(*( + self._start_one(name, module) + for name, module in self._active_modules() + if module and hasattr(module, "start") + )) + self._started = True + logger.info("[V2Integration] All modules started") + + async def refresh_provider_bindings(self, *, force: bool = False) -> bool: + """Retry framework provider binding and create dependent modules. + + AstrBot can load plugins before provider registries are populated. This + lets startup, warmup, and first-use paths bind providers later without a + manual plugin reload. + """ + if not self._needs_provider_or_module_retry(): + return False + + if not force and not self._provider_retry_due(): + return False + + async with self._provider_retry_lock: + if not self._needs_provider_or_module_retry(): + return False + + if not force and not self._provider_retry_due(): + return False + self._last_provider_retry = time.monotonic() + + changed = False + modules_to_start: List[Tuple[str, Any]] = [] + + if not self._embedding_provider and self._embedding_provider_configured(): + provider = self._create_embedding_provider() + if provider: + self._embedding_provider = provider + changed = True + + if not self._rerank_provider and self._rerank_provider_configured(): + provider = self._create_rerank_provider() + if provider: + self._rerank_provider = provider + changed = True + + if self._embedding_provider: + if self._knowledge_manager is None: + self._knowledge_manager = self._create_knowledge_manager() + if self._knowledge_manager: + changed = True + modules_to_start.append(( + "knowledge_manager", + self._knowledge_manager, + )) + + if self._memory_manager is None: + self._memory_manager = self._create_memory_manager() + if self._memory_manager: + changed = True + modules_to_start.append(( + "memory_manager", + self._memory_manager, + )) + + if self._exemplar_library_needs_embedding_refresh(): + self._exemplar_library = self._create_exemplar_library() + changed = True + + if changed: + self._register_trigger_operations() + if self._started and modules_to_start: + await asyncio.gather(*( + self._start_one(name, module) + for name, module in modules_to_start + if module and hasattr(module, "start") + )) + logger.info( + "[V2Integration] Provider bindings refreshed — " + f"embedding={'yes' if self._embedding_provider else 'no'}, " + f"reranker={'yes' if self._rerank_provider else 'no'}" + ) + return changed + + def _provider_retry_due(self) -> bool: + return ( + time.monotonic() - self._last_provider_retry + >= self._provider_retry_interval + ) + + async def _start_one(self, name: str, module: Any) -> None: + try: + await module.start() + except Exception as exc: + logger.warning( + f"[V2Integration] {name} start failed: {exc}" + ) + + def _active_modules(self) -> List[Tuple[str, Any]]: + return [ + ("knowledge_manager", self._knowledge_manager), + ("memory_manager", self._memory_manager), + ("exemplar_library", self._exemplar_library), + ("social_analyzer", self._social_analyzer), + ("jargon_filter", self._jargon_filter), + ] + + def _needs_provider_or_module_retry(self) -> bool: + if self._embedding_provider_configured() and not self._embedding_provider: + return True + if self._rerank_provider_configured() and not self._rerank_provider: + return True + if self._embedding_provider and self._knowledge_manager is None: + return ( + self._config.knowledge_engine == "lightrag" + and self._knowledge_manager_retryable + ) + if self._embedding_provider and self._memory_manager is None: + return ( + self._config.memory_engine == "mem0" + and not self._memory_delegated() + and self._memory_manager_retryable + ) + return self._exemplar_library_needs_embedding_refresh() + + def _embedding_provider_configured(self) -> bool: + return bool( + str(getattr(self._config, "embedding_provider_id", "") or "").strip() + ) + + def _rerank_provider_configured(self) -> bool: + return bool( + str(getattr(self._config, "rerank_provider_id", "") or "").strip() + ) + + def _exemplar_library_needs_embedding_refresh(self) -> bool: + if not (self._db and self._embedding_provider and self._exemplar_library): + return False + return getattr(self._exemplar_library, "_embedding", None) is None + + async def warmup(self, group_ids: List[str]) -> None: + """Pre-warm heavyweight module instances for *group_ids*. + + Should be called shortly after ``start()`` once active group IDs + are known. Currently only LightRAG benefits from pre-warming + (each cold-start avoids a 12-15s initialisation penalty on the + first user query). + """ + await self.refresh_provider_bindings() + if ( + self._knowledge_manager + and hasattr(self._knowledge_manager, "warmup_instances") + ): + try: + await self._knowledge_manager.warmup_instances(group_ids) + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge warmup failed: {exc}" + ) + + async def stop(self) -> None: + """Stop all active v2 modules and release resources. + + Attempts to flush remaining buffered messages with a per-group + timeout. Timed-out buffers are discarded to avoid blocking + the shutdown sequence. + """ + _flush_timeout = self._config.task_cancel_timeout + + for group_id in list(self._ingestion_buffer.keys()): + try: + await asyncio.wait_for( + self._flush_ingestion_buffer(group_id), + timeout=_flush_timeout, + ) + except asyncio.TimeoutError: + dropped = len(self._ingestion_buffer.pop(group_id, [])) + logger.warning( + f"[V2Integration] Buffer flush timeout for group " + f"{group_id}, dropped {dropped} messages" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] Buffer flush failed on stop " + f"for group {group_id}: {exc}" + ) + + modules: List[Tuple[str, Any]] = [ + ("knowledge_manager", self._knowledge_manager), + ("memory_manager", self._memory_manager), + ("exemplar_library", self._exemplar_library), + ("social_analyzer", self._social_analyzer), + ("jargon_filter", self._jargon_filter), + ] + + async def _stop_one(name: str, module: Any) -> None: + try: + await module.stop() + except Exception as exc: + logger.warning( + f"[V2Integration] {name} stop failed: {exc}" + ) + + async def _close_reranker() -> None: + try: + await self._rerank_provider.close() + except Exception as exc: + logger.warning(f"[V2Integration] Reranker close failed: {exc}") + + tasks = [ + _stop_one(name, module) + for name, module in modules + if module and hasattr(module, "stop") + ] + if self._rerank_provider and hasattr(self._rerank_provider, "close"): + tasks.append(_close_reranker()) + + await asyncio.gather(*tasks) + logger.info("[V2Integration] All modules stopped") + + # Public API + + @monitored + async def process_message( + self, message: MessageData, group_id: str + ) -> TriggerResult: + """Process an incoming message through the tiered trigger. + + Tier 1 operations run concurrently on every message. Tier 2 + operations fire when their policies are satisfied. + """ + await self.refresh_provider_bindings() + return await self._trigger.process_message(message, group_id) + + @monitored + async def get_enhanced_context( + self, + query: str, + group_id: str, + top_k: int = 5, + ) -> Dict[str, Any]: + """Retrieve v2 enhanced context for response generation. + + Returns a dict with optional keys: + * ``knowledge_context`` (str): Retrieved knowledge graph context. + * ``related_memories`` (List[str]): Semantically related memories. + * ``few_shot_examples`` (List[str]): Style exemplar texts + (not reranked; returned as-is). + * ``graph_stats`` (dict): Social graph summary statistics. + + When a reranker is available, knowledge and memory candidates are + reranked by relevance and only the top-k are returned. Few-shot + exemplars and graph stats are returned unmodified. + + Results are cached per (group_id, query_hash) with a configurable + TTL to avoid redundant retrieval on repeated or similar queries. + + All retrieval tasks run concurrently via ``asyncio.gather`` to + minimise total latency. + """ + await self.refresh_provider_bindings() + + # --- Check query result cache --- + cache_key = self._make_cache_key(query, group_id) + cached_result = self._cache.get("context", cache_key) + if cached_result is not None: + logger.debug( + f"[V2Integration] Context cache hit (group={group_id})" + ) + return cached_result + + context: Dict[str, Any] = {} + + # --- Build concurrent retrieval tasks --- + + async def _fetch_knowledge() -> None: + if not self._knowledge_manager: + return + try: + if hasattr(self._knowledge_manager, "query_knowledge"): + ctx = await self._knowledge_manager.query_knowledge( + query, group_id, + mode=self._config.lightrag_query_mode, + ) + elif hasattr( + self._knowledge_manager, + "answer_question_with_knowledge_graph", + ): + ctx = ( + await self._knowledge_manager + .answer_question_with_knowledge_graph(query, group_id) + ) + else: + ctx = "" + if ctx: + context["knowledge_context"] = ctx + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge retrieval failed: {exc}" + ) + + async def _fetch_memories() -> None: + if self._memory_delegated(): + logger.debug("[V2Integration] Memory retrieval delegated to LivingMemory") + return + if not self._memory_manager: + return + try: + memories = await self._memory_manager.get_related_memories( + query, group_id + ) + if memories: + context["related_memories"] = memories + except Exception as exc: + logger.debug( + f"[V2Integration] Memory retrieval failed: {exc}" + ) + + async def _fetch_exemplars() -> None: + if not self._exemplar_library: + return + try: + examples = await self._exemplar_library.get_few_shot_examples( + query, group_id, k=top_k + ) + if examples: + context["few_shot_examples"] = examples + except Exception as exc: + logger.debug( + f"[V2Integration] Exemplar retrieval failed: {exc}" + ) + + async def _fetch_graph_stats() -> None: + if not self._social_analyzer: + return + try: + stats = await self._social_analyzer.get_graph_statistics( + group_id + ) + if stats and stats.get("node_count", 0) > 0: + context["graph_stats"] = stats + except Exception as exc: + logger.debug( + f"[V2Integration] Social graph stats failed: {exc}" + ) + + # --- Run all retrievals concurrently --- + await asyncio.gather( + _fetch_knowledge(), + _fetch_memories(), + _fetch_exemplars(), + _fetch_graph_stats(), + ) + + # --- Conditional reranking --- + # Only invoke the reranker when there are enough candidates to + # justify the additional API round-trip latency. + rerank_candidates = len(context.get("related_memories", [])) + if "knowledge_context" in context: + rerank_candidates += 1 + min_candidates = getattr( + self._config, "rerank_min_candidates", 3 + ) + if self._rerank_provider and rerank_candidates >= min_candidates: + context = await self._rerank_context(query, context, top_k) + + # --- Store result in cache --- + self._cache.set("context", cache_key, context) + + return context + + # Cache helpers + + @staticmethod + def _make_cache_key(query: str, group_id: str) -> str: + """Generate a compact cache key from query text and group ID.""" + query_hash = hashlib.md5(query.encode("utf-8")).hexdigest()[:12] + return f"{group_id}:{query_hash}" + + def get_trigger_stats(self, group_id: str) -> Dict[str, Any]: + """Return tiered trigger statistics for a group.""" + return self._trigger.get_group_stats(group_id) + + # Module factories + + def _create_embedding_provider(self) -> Optional[Any]: + """Resolve embedding provider from the framework.""" + try: + from ..embedding.factory import EmbeddingProviderFactory + return EmbeddingProviderFactory.create(self._config, self._context) + except Exception as exc: + logger.debug( + f"[V2Integration] Embedding provider unavailable: {exc}" + ) + return None + + def _create_rerank_provider(self) -> Optional[Any]: + """Resolve reranker provider from the framework.""" + try: + from ..reranker.factory import RerankProviderFactory + return RerankProviderFactory.create(self._config, self._context) + except Exception as exc: + logger.debug(f"[V2Integration] Reranker unavailable: {exc}") + return None + + def _create_knowledge_manager(self) -> Optional[Any]: + """Create knowledge manager based on configured engine.""" + if self._config.knowledge_engine == "lightrag": + if not self._embedding_provider: + if self._embedding_provider_configured(): + logger.info( + "[V2Integration] LightRAG is waiting for the " + "embedding provider registry to become ready" + ) + else: + logger.warning( + "[V2Integration] LightRAG requires an embedding " + "provider; configure embedding_provider_id or use " + "the legacy knowledge engine" + ) + return None + try: + from ..integration import LightRAGKnowledgeManager + return LightRAGKnowledgeManager( + self._config, self._llm, self._embedding_provider + ) + except ImportError: + self._knowledge_manager_retryable = False + logger.warning( + "[V2Integration] lightrag-hku not installed, " + "falling back to legacy knowledge engine" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] LightRAG init failed: {exc}" + ) + logger.debug( + "[V2Integration] LightRAG traceback:", exc_info=True + ) + return None + + def _create_memory_manager(self) -> Optional[Any]: + """Create memory manager based on configured engine.""" + if self._memory_delegated(): + logger.info("[V2Integration] Memory engine skipped: delegated to LivingMemory") + return None + if self._config.memory_engine == "mem0": + if not self._embedding_provider: + if self._embedding_provider_configured(): + logger.info( + "[V2Integration] Mem0 is waiting for the embedding " + "provider registry to become ready" + ) + else: + logger.warning( + "[V2Integration] Mem0 requires an embedding provider; " + "configure embedding_provider_id or use the legacy " + "memory engine" + ) + return None + try: + from ..integration import Mem0MemoryManager + return Mem0MemoryManager( + self._config, self._llm, self._embedding_provider + ) + except ImportError: + self._memory_manager_retryable = False + logger.warning( + "[V2Integration] mem0ai not installed, " + "falling back to legacy memory engine" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] Mem0 init failed: {exc}" + ) + logger.debug( + "[V2Integration] Mem0 traceback:", exc_info=True + ) + return None + + def _create_exemplar_library(self) -> Optional[Any]: + """Create exemplar library if DB and embedding are available.""" + if not self._db: + return None + try: + from ..integration import ExemplarLibrary + return ExemplarLibrary(self._db, self._embedding_provider) + except Exception as exc: + logger.debug( + f"[V2Integration] ExemplarLibrary init failed: {exc}" + ) + return None + + def _create_social_analyzer(self) -> Optional[Any]: + """Create social graph analyzer.""" + try: + from ..social import SocialGraphAnalyzer + return SocialGraphAnalyzer(self._llm, self._db) + except Exception as exc: + logger.debug( + f"[V2Integration] SocialGraphAnalyzer init failed: {exc}" + ) + return None + + def _create_jargon_filter(self) -> Optional[Any]: + """Create jargon statistical filter.""" + try: + from ..jargon import JargonStatisticalFilter + return JargonStatisticalFilter() + except Exception as exc: + logger.debug( + f"[V2Integration] JargonStatisticalFilter init failed: {exc}" + ) + return None + + # Trigger wiring + + def _register_trigger_operations(self) -> None: + """Register all available modules with the tiered trigger. + + Architecture: + Tier 1 (per-message, sub-millisecond): + - jargon_stats: in-memory statistical counters + - ingestion_buffer: append message to buffer (no I/O) + - exemplar: embedding + DB insert (< 1s) + + Tier 2 (batch, LLM-gated, cooldown-protected): + - ingestion_flush: batch-process buffered messages through + LightRAG and Mem0, amortising LLM overhead across + multiple messages + - jargon: LLM-based jargon meaning inference + - social: community detection and influence ranking + + Knowledge graph ingestion (LightRAG) and memory ingestion (Mem0) + are intentionally registered as Tier 2 batch operations rather + than Tier 1 per-message callbacks because they each invoke one + or more LLM round-trips (entity extraction, fact extraction) + that take 3-10 seconds per message. Running them per-message + would dominate the event loop and block subsequent processing. + """ + + # ---- Tier 1: per-message lightweight operations ---- + + if self._jargon_filter: + jf = self._jargon_filter + + async def _jargon_update( + message: MessageData, group_id: str + ) -> None: + jf.update_from_message(message.message, group_id, message.sender_id) + + self._trigger.register_tier1("jargon_stats", _jargon_update) + + # Buffer messages for batch ingestion (knowledge + memory). + # This replaces the previous per-message LightRAG/Mem0 callbacks + # with a sub-millisecond append operation. + if self._knowledge_manager or self._memory_manager: + buf = self._ingestion_buffer + + async def _buffer_message( + message: MessageData, group_id: str + ) -> None: + if ( + message.message + and len(message.message.strip()) >= _MIN_INGESTION_LENGTH + ): + buf[group_id].append(message) + + self._trigger.register_tier1("ingestion_buffer", _buffer_message) + + if self._exemplar_library: + lib = self._exemplar_library + + async def _exemplar_add( + message: MessageData, group_id: str + ) -> None: + await lib.add_exemplar( + message.message, group_id, message.sender_id + ) + + self._trigger.register_tier1("exemplar", _exemplar_add) + + # ---- Tier 2: batch operations (LLM-heavy) ---- + + # Batch ingestion: flush buffered messages through LightRAG + # and Mem0. Fires every 5 messages or 60 seconds, whichever + # comes first. This amortises the per-message LLM overhead + # and reduces total API calls. + if self._knowledge_manager or self._memory_manager: + self._trigger.register_tier2( + "ingestion_flush", + self._flush_ingestion_buffer, + BatchTriggerPolicy( + message_threshold=5, cooldown_seconds=60 + ), + ) + + if self._jargon_filter: + jf2 = self._jargon_filter + llm = self._llm + db = self._db + + async def _jargon_batch(group_id: str) -> None: + candidates = jf2.get_jargon_candidates(group_id, top_k=20) + if not candidates or not llm: + return + for candidate in candidates[:10]: + try: + # 跳过已被手动编辑或已完成推断的黑话,避免覆盖用户修改 + if db and hasattr(db, "get_jargon"): + existing = await db.get_jargon(group_id, candidate["term"]) + if existing and existing.get("is_complete"): + logger.debug( + f"[V2Integration] 跳过已完成的黑话: " + f"'{candidate['term']}'" + ) + continue + + meaning = await llm.generate_response( + f"Explain the slang/jargon term " + f"'{candidate['term']}' in the context of an " + f"online chat group. Return a concise definition.", + model_type="filter", + ) + if ( + meaning + and db + and hasattr(db, "save_or_update_jargon") + ): + jargon_data = { + "meaning": meaning, + "raw_content": "[]", + "is_jargon": True, + "is_complete": True, + } + # 已有记录时保留原始计数,不重置为1 + if existing: + jargon_data["count"] = existing.get("count", 1) + else: + jargon_data["count"] = 1 + await db.save_or_update_jargon( + group_id, + candidate["term"], + jargon_data, + ) + except Exception as exc: + logger.debug( + f"[V2Integration] Jargon inference failed " + f"for '{candidate['term']}': {exc}" + ) + + self._trigger.register_tier2( + "jargon", + _jargon_batch, + BatchTriggerPolicy( + message_threshold=20, cooldown_seconds=180 + ), + ) + + if self._social_analyzer: + sa = self._social_analyzer + + async def _social_batch(group_id: str) -> None: + # Execute independently so one failure does not skip the other. + try: + await sa.detect_communities(group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] detect_communities failed: {exc}" + ) + try: + await sa.get_influence_ranking(group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] get_influence_ranking failed: {exc}" + ) + + self._trigger.register_tier2( + "social", + _social_batch, + BatchTriggerPolicy( + message_threshold=50, cooldown_seconds=600 + ), + ) + + # Batch ingestion + + async def _flush_ingestion_buffer(self, group_id: str) -> None: + """Flush buffered messages for a group through knowledge and memory. + + Processes all buffered messages concurrently through LightRAG and + Mem0 in a single batch operation, then clears the buffer. Messages + within each engine are processed sequentially to avoid overwhelming + the underlying LLM providers with concurrent requests. + """ + messages = self._ingestion_buffer.pop(group_id, []) + if not messages: + return + + logger.debug( + f"[V2Integration] Flushing ingestion buffer: " + f"group={group_id}, count={len(messages)}" + ) + + async def _ingest_knowledge() -> None: + if not self._knowledge_manager: + return + method = None + if hasattr( + self._knowledge_manager, + "process_message_for_knowledge_graph", + ): + method = self._knowledge_manager.process_message_for_knowledge_graph + elif hasattr( + self._knowledge_manager, "process_message_for_knowledge" + ): + method = self._knowledge_manager.process_message_for_knowledge + if not method: + return + for msg in messages: + try: + await method(msg, group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge ingestion failed: {exc}" + ) + + async def _ingest_memory() -> None: + if self._memory_delegated(): + logger.debug("[V2Integration] Memory ingestion delegated to LivingMemory") + return + if not self._memory_manager: + return + for msg in messages: + try: + await self._memory_manager.add_memory_from_message( + msg, group_id + ) + except Exception as exc: + logger.debug( + f"[V2Integration] Memory ingestion failed: {exc}" + ) + + # Run knowledge and memory ingestion concurrently across engines, + # but sequentially within each engine to avoid provider overload. + await asyncio.gather( + _ingest_knowledge(), + _ingest_memory(), + ) + + def _memory_delegated(self) -> bool: + delegation = self._feature_delegation + if not delegation or not hasattr(delegation, "should_delegate_memory"): + return False + try: + return bool(delegation.should_delegate_memory()) + except Exception: + return False + + # Reranking + + @monitored + async def _rerank_context( + self, + query: str, + context: Dict[str, Any], + top_k: int, + ) -> Dict[str, Any]: + """Rerank knowledge and memory candidates by relevance. + + Few-shot exemplars and graph stats are returned unmodified. + """ + try: + documents: List[str] = [] + sources: List[str] = [] + + if "knowledge_context" in context: + documents.append(context["knowledge_context"]) + sources.append("knowledge") + + for mem in context.get("related_memories", []): + documents.append(mem) + sources.append("memory") + + if not documents: + return context + + results = await self._rerank_provider.rerank( + query, documents, top_n=top_k + ) + + # Rebuild context with reranked order. + reranked_memories: List[str] = [] + reranked_knowledge = "" + for r in results: + if r.index >= len(documents): + logger.debug( + f"[V2Integration] Reranker returned out-of-range " + f"index {r.index} (len={len(documents)}); skipping" + ) + continue + src = sources[r.index] + doc = documents[r.index] + if src == "knowledge": + reranked_knowledge = doc + elif src == "memory": + reranked_memories.append(doc) + + if reranked_knowledge: + context["knowledge_context"] = reranked_knowledge + elif "knowledge_context" in context: + del context["knowledge_context"] + + if reranked_memories: + context["related_memories"] = reranked_memories + elif "related_memories" in context: + del context["related_memories"] + + except Exception as exc: + logger.debug( + f"[V2Integration] Reranking failed, using unranked: {exc}" + ) + + return context