${escapeHtml(item.title)}
- ${pill(item.enabled ? t("state.enabled", "启用") : t("state.closed", "关闭"), item.enabled ? "ok" : "warn")} -${escapeHtml(item.description || "")}
-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(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.description || "")}
-${escapeHtml(item.detail)}
- ${button(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} -${escapeHtml(item.proposed_content || item.new_content || item.incremental_content || "").slice(0, 220)}
-${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}
-${escapeHtml(item.meaning || item.definition || item.review_detail || t("empty.definition", "暂无释义"))}
-${escapeHtml(item.text || item.detail || "").slice(0, 360)}
-${escapeHtml(item.delegated ? t("state.delegated", "已委托") : item.active ? t("state.available", "可用") : t("state.disabled", "未启用"))}
-${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(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)}
+${escapeHtml(item.description || "")}
+${escapeHtml(item.detail)}
+ ${button(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} +${escapeHtml(item.proposed_content || item.new_content || item.incremental_content || "").slice(0, 220)}
+${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}
+${escapeHtml(item.meaning || item.definition || item.review_detail || t("empty.definition", "暂无释义"))}
+${escapeHtml(item.text || item.detail || "").slice(0, 360)}
+${escapeHtml(item.delegated ? t("state.delegated", "已委托") : item.active ? t("state.available", "可用") : t("state.disabled", "未启用"))}
+${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