@bolloon/bolloon-agent 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.auto-evolve-calls +1 -0
- package/.last-auto-evolve-baseline +1 -0
- package/.playwright-mcp/page-2026-06-16T07-51-45-706Z.yml +130 -0
- package/.playwright-mcp/page-2026-06-16T07-56-44-116Z.yml +131 -0
- package/Bolloon.md +144 -0
- package/Dive-into/CITATION.cff +17 -0
- package/Dive-into/LICENSE +25 -0
- package/Dive-into/README.md +598 -0
- package/Dive-into/README_zh.md +599 -0
- package/Dive-into/assets/context.png +0 -0
- package/Dive-into/assets/extensibility.png +0 -0
- package/Dive-into/assets/iteration.png +0 -0
- package/Dive-into/assets/layered_architecture.png +0 -0
- package/Dive-into/assets/main_structure.png +0 -0
- package/Dive-into/assets/permission.png +0 -0
- package/Dive-into/assets/session_compact.png +0 -0
- package/Dive-into/assets/subagent.png +0 -0
- package/Dive-into/paper/Dive_into_Claude_Code.pdf +0 -0
- package/README.md +1 -1
- package/dist/agents/p2p-chat-tools.js +6 -6
- package/dist/agents/permission-mode.js +115 -0
- package/dist/agents/pi-sdk.js +687 -25
- package/dist/agents/pre-tool-validator.js +194 -0
- package/dist/agents/workflow-pivot-loop.js +113 -12
- package/dist/bollharness/src/scripts/checks/check_doc_freshness.js +1 -1
- package/dist/bollharness/src/scripts/checks/check_doc_links.js +1 -1
- package/dist/bollharness/src/scripts/context_router.js +1 -1
- package/dist/bollharness/src/scripts/deploy-guard.js +1 -1
- package/dist/bollharness/src/scripts/guard_router.js +1 -1
- package/dist/bollharness/src/scripts/hooks/_hook_output.js +1 -1
- package/dist/bollharness/src/scripts/hooks/risk-tracker.js +1 -1
- package/dist/bollharness-integration/context-router.js +2 -2
- package/dist/bollharness-integration/guard-checker.js +1 -1
- package/dist/bootstrap/bootstrap.js +117 -0
- package/dist/bootstrap/context-collector.js +312 -0
- package/dist/bootstrap/context-hierarchy.js +218 -0
- package/dist/bootstrap/lifecycle-hooks.js +171 -0
- package/dist/bootstrap/project-context.js +158 -0
- package/dist/context-compaction/auto-compact.js +144 -0
- package/dist/context-compaction/budget-gate.js +28 -0
- package/dist/context-compaction/budget-reduce.js +35 -0
- package/dist/context-compaction/context-collapse.js +66 -0
- package/dist/context-compaction/index.js +21 -0
- package/dist/context-compaction/microcompact.js +51 -0
- package/dist/context-compaction/pipeline.js +123 -0
- package/dist/context-compaction/snip.js +45 -0
- package/dist/context-compaction/token-estimator.js +35 -0
- package/dist/context-compaction/types.js +19 -0
- package/dist/heartbeat/HealthMonitor.js +3 -2
- package/dist/index.js +49 -2
- package/dist/llm/llm-judgment-client.js +32 -30
- package/dist/llm/pi-ai.js +125 -28
- package/dist/llm/system-prompt/health.js +129 -0
- package/dist/llm/system-prompt/registry.js +246 -0
- package/dist/llm/system-prompt/strip-hibsml.js +51 -0
- package/dist/llm/tool-manifest/ask_user_input.js +35 -0
- package/dist/llm/tool-manifest/bash.js +23 -0
- package/dist/llm/tool-manifest/create_file.js +24 -0
- package/dist/llm/tool-manifest/fetch_sports_data.js +26 -0
- package/dist/llm/tool-manifest/image_search.js +24 -0
- package/dist/llm/tool-manifest/index.js +69 -0
- package/dist/llm/tool-manifest/mcp.js +43 -0
- package/dist/llm/tool-manifest/message_compose.js +31 -0
- package/dist/llm/tool-manifest/places.js +83 -0
- package/dist/llm/tool-manifest/present_files.js +21 -0
- package/dist/llm/tool-manifest/recipe.js +40 -0
- package/dist/llm/tool-manifest/recommend_apps.js +23 -0
- package/dist/llm/tool-manifest/str_replace.js +27 -0
- package/dist/llm/tool-manifest/types.js +7 -0
- package/dist/llm/tool-manifest/view.js +24 -0
- package/dist/llm/tool-manifest/weather.js +27 -0
- package/dist/llm/tool-manifest/web.js +51 -0
- package/dist/network/p2p-direct.js +23 -0
- package/dist/network/source-intent-broadcaster.js +203 -0
- package/dist/network/source-intent.js +100 -0
- package/dist/pi-ecosystem-judgment/adaptive-scan.js +279 -0
- package/dist/pi-ecosystem-judgment/causal-judge.js +449 -0
- package/dist/pi-ecosystem-judgment/detect-hook.js +168 -0
- package/dist/pi-ecosystem-judgment/distill-prompt.js +226 -0
- package/dist/pi-ecosystem-judgment/evolve-judgment.js +170 -0
- package/dist/pi-ecosystem-judgment/human-value-pipeline.js +21 -0
- package/dist/pi-ecosystem-judgment/human-value-store.js +283 -22
- package/dist/pi-ecosystem-judgment/injection-gate.js +208 -0
- package/dist/pi-ecosystem-judgment/monitor-gate.js +188 -0
- package/dist/pi-ecosystem-judgment/value-injection.js +8 -2
- package/dist/security/builtin-guards.js +124 -0
- package/dist/security/context-router-tool.js +106 -0
- package/dist/security/input-scanner.js +223 -0
- package/dist/security/react-harness.js +143 -0
- package/dist/security/tool-gate.js +235 -0
- package/dist/utils/auto-evolve-policy.js +117 -0
- package/dist/utils/clamp.js +7 -0
- package/dist/utils/double.js +6 -0
- package/dist/web/client.js +3810 -3830
- package/dist/web/components/p2p/P2PModal.js +188 -0
- package/dist/web/components/p2p/index.js +264 -226
- package/dist/web/components/p2p/p2p-modal.js +657 -0
- package/dist/web/components/p2p/p2p-tools.js +248 -0
- package/dist/web/index.html +60 -49
- package/dist/web/server.js +827 -124
- package/dist/web/style.css +531 -249
- package/dist/web/ui/message-renderer.js +463 -0
- package/dist/web/ui/step-timeline.js +375 -0
- package/lefthook.yml +33 -0
- package/package.json +3 -2
- package/scripts/auto-evolve-loop.ts +481 -0
- package/scripts/auto-evolve-oneshot.sh +155 -0
- package/scripts/auto-evolve-snapshot.sh +136 -0
- package/scripts/build-web.ts +35 -1
- package/scripts/detect-schema-changes.sh +48 -0
- package/scripts/diff-reviewer.ts +159 -0
- package/scripts/validate-system-prompt.ts +142 -0
- package/scripts/weekly-report.ts +364 -0
- package/src/agents/p2p-chat-tools.ts +6 -6
- package/src/agents/permission-mode.ts +127 -0
- package/src/agents/pi-sdk.ts +741 -30
- package/src/agents/pre-tool-validator.ts +213 -0
- package/src/agents/workflow-pivot-loop.ts +110 -19
- package/src/bollharness/CLAUDE.md +1 -1
- package/src/bollharness/README.md +2 -2
- package/src/bollharness/README.zh-CN.md +2 -2
- package/src/bollharness/reference/boll-reference/scripts/hooks/find-boll-root.sh +2 -2
- package/src/bollharness/scripts/context-fragments/truth-source-hierarchy.md +2 -2
- package/src/bollharness/scripts/context-fragments/version-sources.md +1 -1
- package/src/bollharness/scripts/hooks/find-project-root.sh +4 -4
- package/src/bollharness/src/scripts/checks/check_doc_freshness.ts +1 -1
- package/src/bollharness/src/scripts/checks/check_doc_links.ts +1 -1
- package/src/bollharness/src/scripts/context_router.ts +1 -1
- package/src/bollharness/src/scripts/deploy-guard.ts +1 -1
- package/src/bollharness/src/scripts/guard_router.ts +1 -1
- package/src/bollharness/src/scripts/hooks/_hook_output.ts +1 -1
- package/src/bollharness/src/scripts/hooks/risk-tracker.ts +1 -1
- package/src/bollharness/templates/scaffold/.gitignore.append +1 -1
- package/src/bollharness/templates/scaffold/CLAUDE.md +2 -2
- package/src/bollharness-integration/context-router.ts +2 -2
- package/src/bollharness-integration/guard-checker.ts +1 -1
- package/src/bootstrap/bootstrap.ts +135 -0
- package/src/bootstrap/context-collector.ts +371 -0
- package/src/bootstrap/context-hierarchy.ts +283 -0
- package/src/bootstrap/lifecycle-hooks.ts +289 -0
- package/src/bootstrap/project-context.ts +171 -0
- package/src/context-compaction/auto-compact.ts +153 -0
- package/src/context-compaction/budget-gate.ts +32 -0
- package/src/context-compaction/budget-reduce.ts +37 -0
- package/src/context-compaction/context-collapse.ts +72 -0
- package/src/context-compaction/index.ts +24 -0
- package/src/context-compaction/microcompact.ts +54 -0
- package/src/context-compaction/pipeline.ts +133 -0
- package/src/context-compaction/snip.ts +51 -0
- package/src/context-compaction/token-estimator.ts +36 -0
- package/src/context-compaction/types.ts +99 -0
- package/src/heartbeat/HealthMonitor.ts +3 -2
- package/src/index.ts +47 -2
- package/src/llm/llm-judgment-client.ts +36 -35
- package/src/llm/pi-ai.ts +135 -29
- package/src/llm/system-prompt/health.ts +159 -0
- package/src/llm/system-prompt/layers/channel/local.md +14 -0
- package/src/llm/system-prompt/layers/channel/p2p-agent.md +18 -0
- package/src/llm/system-prompt/layers/channel/p2p-visitor.md +19 -0
- package/src/llm/system-prompt/layers/core/artifacts_storage.md +89 -0
- package/src/llm/system-prompt/layers/core/evenhandedness.md +21 -0
- package/src/llm/system-prompt/layers/core/hibs_reminders.md +15 -0
- package/src/llm/system-prompt/layers/core/identity.md +37 -0
- package/src/llm/system-prompt/layers/core/knowledge.md +17 -0
- package/src/llm/system-prompt/layers/core/memory_system.md +12 -0
- package/src/llm/system-prompt/layers/core/network_filesystem.md +28 -0
- package/src/llm/system-prompt/layers/core/refusal.md +37 -0
- package/src/llm/system-prompt/layers/core/tone.md +31 -0
- package/src/llm/system-prompt/layers/core/tools.thin.md +13 -0
- package/src/llm/system-prompt/layers/core/wellbeing.md +41 -0
- package/src/llm/system-prompt/layers/role/architect.md +20 -0
- package/src/llm/system-prompt/layers/role/expert.md +19 -0
- package/src/llm/system-prompt/layers/role/implementer.md +15 -0
- package/src/llm/system-prompt/layers/role/security.md +15 -0
- package/src/llm/system-prompt/layers/tool/artifacts.md +72 -0
- package/src/llm/system-prompt/layers/tool/bash.md +25 -0
- package/src/llm/system-prompt/layers/tool/hibs_api.md +171 -0
- package/src/llm/system-prompt/layers/tool/image_search.md +70 -0
- package/src/llm/system-prompt/layers/tool/manifest.md +89 -0
- package/src/llm/system-prompt/layers/tool/mcp_apps.md +53 -0
- package/src/llm/system-prompt/layers/tool/web_search.md +83 -0
- package/src/llm/system-prompt/registry.ts +325 -0
- package/src/llm/system-prompt/strip-hibsml.ts +52 -0
- package/src/llm/tool-manifest/ask_user_input.ts +37 -0
- package/src/llm/tool-manifest/bash.ts +25 -0
- package/src/llm/tool-manifest/create_file.ts +26 -0
- package/src/llm/tool-manifest/fetch_sports_data.ts +28 -0
- package/src/llm/tool-manifest/image_search.ts +26 -0
- package/src/llm/tool-manifest/index.ts +88 -0
- package/src/llm/tool-manifest/mcp.ts +46 -0
- package/src/llm/tool-manifest/message_compose.ts +33 -0
- package/src/llm/tool-manifest/places.ts +86 -0
- package/src/llm/tool-manifest/present_files.ts +23 -0
- package/src/llm/tool-manifest/recipe.ts +42 -0
- package/src/llm/tool-manifest/recommend_apps.ts +25 -0
- package/src/llm/tool-manifest/str_replace.ts +29 -0
- package/src/llm/tool-manifest/types.ts +52 -0
- package/src/llm/tool-manifest/view.ts +26 -0
- package/src/llm/tool-manifest/weather.ts +29 -0
- package/src/llm/tool-manifest/web.ts +54 -0
- package/src/network/p2p-direct.ts +22 -0
- package/src/network/source-intent-broadcaster.ts +242 -0
- package/src/network/source-intent.ts +167 -0
- package/src/security/builtin-guards.ts +162 -0
- package/src/security/context-router-tool.ts +122 -0
- package/src/security/input-scanner.ts +287 -0
- package/src/security/react-harness.ts +177 -0
- package/src/security/tool-gate.ts +294 -0
- package/src/utils/auto-evolve-policy.ts +138 -0
- package/src/utils/clamp.ts +5 -0
- package/src/web/client.js +105 -2187
- package/src/web/client.ts +4326 -0
- package/src/web/index.html +60 -49
- package/src/web/server.ts +894 -148
- package/src/web/style.css +531 -249
- package/src/web/ui/message-renderer.ts +540 -0
- package/src/web/ui/step-timeline.ts +394 -0
- package/staging/auto-evolve/clean-001/.review-verdict +9 -0
- package/staging/auto-evolve/clean-001/clean-001.patch +14 -0
- package/staging/auto-evolve/e2e-001/.patch-id +1 -0
- package/staging/auto-evolve/e2e-001/.review-verdict +12 -0
- package/staging/auto-evolve/e2e-001/e2e-001.patch +11 -0
- package/staging/auto-evolve/test-bad/.review-verdict +12 -0
- package/staging/auto-evolve/test-bad/test-bad.patch +11 -0
- package/test-results/.last-run.json +6 -0
- package/test-results/src-test-web-loop-status-b-5734c-atus-bar-/346/230/276/347/244/272-spinner-/344/270/215/346/230/276/347/244/272/345/276/252/347/216/257/350/256/241/346/225/260/error-context.md +285 -0
|
@@ -0,0 +1,4326 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// marked 库可能从 CDN 加载失败, 这里做安全降级 (避免 ReferenceError 让 addMessage 整体崩溃)
|
|
3
|
+
if (typeof marked === 'undefined') {
|
|
4
|
+
window.marked = { parse: (text) => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>') };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// 2026-06-15: 拆出 message-renderer 模块 (TS, ESM 编译).
|
|
8
|
+
// 浏览器侧: <script type="module"> 加载, 模块主动挂到 window.MR
|
|
9
|
+
// 客户端顶层直接拿 window.MR (ESM deferred 但 client.js 顶层只取 ref, 实际调用延后到 DOMContentLoaded)
|
|
10
|
+
// tsx 跑测试: 走 require() 同名拿
|
|
11
|
+
let MR = {};
|
|
12
|
+
try { if (typeof require !== 'undefined') MR = require('./ui/message-renderer.js') || {}; } catch (e) { /* 浏览器没 require, 走 window.MR */ }
|
|
13
|
+
function _getMR() {
|
|
14
|
+
if (MR && MR.addMessage) return MR;
|
|
15
|
+
if (typeof window !== 'undefined' && window.MR) return window.MR;
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
const MR_addMessage = (...args) => _getMR().addMessage?.(...args);
|
|
19
|
+
const MR_handleStreamTokenEvent = (...args) => _getMR().handleStreamTokenEvent?.(...args);
|
|
20
|
+
const MR_finalizeTimelineAsMessage = (...args) => _getMR().finalizeTimelineAsMessage?.(...args);
|
|
21
|
+
const MR_handleStepEvent = (...args) => _getMR().handleStepEvent?.(...args);
|
|
22
|
+
const MR_getMessagesContainerForCurrent = (...args) => _getMR().getMessagesContainerForCurrent?.(...args);
|
|
23
|
+
const MR_escapeHtml = (s) => _getMR().escapeHtml?.(s);
|
|
24
|
+
const MR_resetRendererState = () => _getMR().resetRendererState?.();
|
|
25
|
+
|
|
26
|
+
// ctx 对象: 把全局状态打包, 避免硬引用 client.js 顶层 let
|
|
27
|
+
function getRendererCtx() {
|
|
28
|
+
return {
|
|
29
|
+
messagesEl,
|
|
30
|
+
messagesContainers,
|
|
31
|
+
currentChannelId,
|
|
32
|
+
lastUsedJudgmentIds,
|
|
33
|
+
openJudgmentsModalWithFilter, // 引用 client.js 函数, 通过参数注入避免循环 import
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2026-06-16: 实际入口是底部顶层调用的 init().
|
|
38
|
+
// 真实 init 流程: DOMContentLoaded → 模块顶层同步执行 → 注册所有 addEventListener → fire-and-forget init()
|
|
39
|
+
// init() 内部: loadTheme → loadChannels → checkApiConfig → selectChannel → connect (SSE)
|
|
40
|
+
// 之前的 ensureMRLoaded() 是死代码残留 (init 不 await 它, 也没人调用) — 2026-06-16 删除.
|
|
41
|
+
|
|
42
|
+
const messagesEl = document.getElementById('messages');
|
|
43
|
+
const input = document.getElementById('input');
|
|
44
|
+
const sendBtn = document.getElementById('send');
|
|
45
|
+
const sidebar = document.getElementById('sidebar');
|
|
46
|
+
// 2026-06-16 新增: 循环进度 status bar (渲染 tool='loop'|'compactor'|'recovery' 的 status 事件)
|
|
47
|
+
const loopStatusBar = document.getElementById('loop-status-bar') as HTMLElement | null;
|
|
48
|
+
const loopStatusText = document.getElementById('loop-status-text');
|
|
49
|
+
const loopStatusMeta = document.getElementById('loop-status-meta');
|
|
50
|
+
|
|
51
|
+
// 把 status 事件路由到 status bar; 只关心 system 级 (loop/compactor/recovery), 其他静默
|
|
52
|
+
const LOOP_STATUS_TOOLS = new Set(['loop', 'compactor', 'recovery', 'system']);
|
|
53
|
+
// 2026-06-16: 三态机 — loading (spinner+文本) / retrying (spinner + "自动重试中 X/N") / done (检查按钮)
|
|
54
|
+
// 重试是 server 端自动的 (pi-sdk.ts promptStream 包 retry 循环), 不暴露按钮给用户.
|
|
55
|
+
let loopBarState: 'loading' | 'retrying' | 'done' = 'loading';
|
|
56
|
+
let loopBarLastSummary: string = '';
|
|
57
|
+
function renderLoopStatusBar(tool: string | undefined, content: string | undefined): void {
|
|
58
|
+
if (!loopStatusBar || !loopStatusText) return;
|
|
59
|
+
const t = String(tool || '').toLowerCase();
|
|
60
|
+
if (!LOOP_STATUS_TOOLS.has(t)) {
|
|
61
|
+
// 非 system 级 status (例如 tool='shell_exec' 工具执行消息) — 仍走 console.log, 不画 UI
|
|
62
|
+
console.log('[SSE] status (tool=' + t + ', ignored by UI):', content?.slice(0, 80));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// 2026-06-16: 抽取 retry 信息 — "↻ 自动重试 loop 1/3" / "⛔ loop 自动重试 3 次后仍失败"
|
|
66
|
+
// 用 meta 显示, 不放到主文本里
|
|
67
|
+
const retryMatch = String(content || '').match(/自动重试(?: loop)?\s+(\d+)\/(\d+)/);
|
|
68
|
+
const retryFinal = /自动重试\s+\d+\s*次后仍失败/.test(String(content || ''));
|
|
69
|
+
|
|
70
|
+
loopStatusBar.hidden = false;
|
|
71
|
+
// 主文本去掉 emoji 和 retry 前缀, 只留干净的描述
|
|
72
|
+
let mainText = String(content || '')
|
|
73
|
+
.replace(/^[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}]\s*/u, '')
|
|
74
|
+
.replace(/^↻\s*/, '')
|
|
75
|
+
.replace(/^⛔\s*/, '')
|
|
76
|
+
.replace(/^⚠️\s*/, '')
|
|
77
|
+
.slice(0, 200);
|
|
78
|
+
loopStatusText.textContent = mainText;
|
|
79
|
+
|
|
80
|
+
// retry 态: spinner + "自动重试中 X/N" badge (橙色)
|
|
81
|
+
if (retryMatch) {
|
|
82
|
+
loopBarState = 'retrying';
|
|
83
|
+
const retryEl = document.getElementById('loop-status-retry');
|
|
84
|
+
if (retryEl) {
|
|
85
|
+
retryEl.hidden = false;
|
|
86
|
+
retryEl.textContent = `自动重试 ${retryMatch[1]}/${retryMatch[2]}`;
|
|
87
|
+
}
|
|
88
|
+
} else if (retryFinal) {
|
|
89
|
+
// 最终失败: 进入 done 态, 让用户点"检查"看错误详情 (替代手动重试按钮)
|
|
90
|
+
loopBarState = 'done';
|
|
91
|
+
const retryEl = document.getElementById('loop-status-retry');
|
|
92
|
+
if (retryEl) retryEl.hidden = true;
|
|
93
|
+
} else {
|
|
94
|
+
// 正常 status (loop/compactor/recovery/system) → loading 态
|
|
95
|
+
if (loopBarState !== 'loading') loopBarState = 'loading';
|
|
96
|
+
const retryEl = document.getElementById('loop-status-retry');
|
|
97
|
+
if (retryEl) retryEl.hidden = true;
|
|
98
|
+
}
|
|
99
|
+
applyLoopBarState();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2026-06-16: 完成态由 SSE `done` 事件触发; 不依赖 system 推 "✅ 完成"
|
|
103
|
+
function markLoopBarDone(summary?: string): void {
|
|
104
|
+
loopBarState = 'done';
|
|
105
|
+
if (summary) loopBarLastSummary = summary;
|
|
106
|
+
applyLoopBarState();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function applyLoopBarState(): void {
|
|
110
|
+
if (!loopStatusBar) return;
|
|
111
|
+
loopStatusBar.dataset.state = loopBarState;
|
|
112
|
+
const checkBtn = document.getElementById('loop-status-check') as HTMLButtonElement | null;
|
|
113
|
+
if (checkBtn) checkBtn.hidden = loopBarState !== 'done';
|
|
114
|
+
}
|
|
115
|
+
function hideLoopStatusBar(): void {
|
|
116
|
+
if (!loopStatusBar) return;
|
|
117
|
+
loopStatusBar.hidden = true;
|
|
118
|
+
loopBarState = 'loading';
|
|
119
|
+
loopBarLastSummary = '';
|
|
120
|
+
const retryEl = document.getElementById('loop-status-retry');
|
|
121
|
+
if (retryEl) retryEl.hidden = true;
|
|
122
|
+
applyLoopBarState();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2026-06-16: 完成后检查 — GET /api/loop/inspect 拉循环产出的工作记忆/工具结果, 弹 modal
|
|
126
|
+
async function inspectLoopResult(): Promise<void> {
|
|
127
|
+
const checkBtn = document.getElementById('loop-status-check') as HTMLButtonElement | null;
|
|
128
|
+
if (checkBtn) {
|
|
129
|
+
checkBtn.disabled = true;
|
|
130
|
+
checkBtn.textContent = '⏳ 加载...';
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const r = await fetch(`/api/loop/inspect?channelId=${encodeURIComponent(currentChannelId || '')}`);
|
|
134
|
+
const j = await r.json().catch(() => ({}));
|
|
135
|
+
openLoopInspectModal(j);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error('[inspect] error:', err);
|
|
138
|
+
if (typeof showSimpleToast === 'function') showSimpleToast('✗ 检查失败');
|
|
139
|
+
} finally {
|
|
140
|
+
if (checkBtn) {
|
|
141
|
+
checkBtn.disabled = false;
|
|
142
|
+
checkBtn.textContent = '✓ 检查';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2026-06-16: 检查结果 modal — 列循环产出的工具结果 / 压缩摘要 / 最终回复
|
|
148
|
+
function openLoopInspectModal(data: { summary?: string; steps?: Array<{ name: string; status: string; durationMs?: number; output?: string }>; finalReply?: string; tokens?: { input?: number; output?: number }; error?: string }): void {
|
|
149
|
+
const existing = document.getElementById('loop-inspect-modal');
|
|
150
|
+
if (existing) existing.remove();
|
|
151
|
+
|
|
152
|
+
const modal = document.createElement('div');
|
|
153
|
+
modal.id = 'loop-inspect-modal';
|
|
154
|
+
modal.className = 'modal active';
|
|
155
|
+
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;';
|
|
156
|
+
|
|
157
|
+
const panel = document.createElement('div');
|
|
158
|
+
panel.className = 'modal-panel';
|
|
159
|
+
panel.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:20px;max-width:720px;width:90%;max-height:80vh;overflow:auto;position:relative;';
|
|
160
|
+
|
|
161
|
+
const title = document.createElement('h3');
|
|
162
|
+
title.textContent = '🔍 循环检查';
|
|
163
|
+
title.style.cssText = 'margin:0 0 12px;font-size:16px;';
|
|
164
|
+
panel.appendChild(title);
|
|
165
|
+
|
|
166
|
+
const close = document.createElement('button');
|
|
167
|
+
close.textContent = '×';
|
|
168
|
+
close.style.cssText = 'position:absolute;top:8px;right:12px;background:transparent;border:0;font-size:24px;cursor:pointer;color:var(--text-secondary);';
|
|
169
|
+
close.onclick = () => modal.remove();
|
|
170
|
+
panel.appendChild(close);
|
|
171
|
+
|
|
172
|
+
if (data.error) {
|
|
173
|
+
const e = document.createElement('div');
|
|
174
|
+
e.style.cssText = 'padding:8px 12px;background:rgba(239,68,68,0.12);color:var(--error,#ef4444);border-radius:4px;margin-bottom:12px;font-size:13px;';
|
|
175
|
+
e.textContent = '⚠️ ' + data.error;
|
|
176
|
+
panel.appendChild(e);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (data.summary) {
|
|
180
|
+
const s = document.createElement('div');
|
|
181
|
+
s.style.cssText = 'padding:8px 12px;background:var(--bg-tertiary);border-radius:4px;margin-bottom:12px;font-size:13px;';
|
|
182
|
+
s.textContent = data.summary;
|
|
183
|
+
panel.appendChild(s);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (data.tokens && (data.tokens.input || data.tokens.output)) {
|
|
187
|
+
const t = document.createElement('div');
|
|
188
|
+
t.style.cssText = 'font-size:12px;color:var(--text-muted);margin-bottom:12px;';
|
|
189
|
+
t.textContent = `token: input ${data.tokens.input || 0} · output ${data.tokens.output || 0}`;
|
|
190
|
+
panel.appendChild(t);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (Array.isArray(data.steps) && data.steps.length > 0) {
|
|
194
|
+
const h = document.createElement('div');
|
|
195
|
+
h.textContent = `步骤 (${data.steps.length})`;
|
|
196
|
+
h.style.cssText = 'font-weight:600;margin-bottom:8px;';
|
|
197
|
+
panel.appendChild(h);
|
|
198
|
+
for (const step of data.steps) {
|
|
199
|
+
const row = document.createElement('div');
|
|
200
|
+
row.style.cssText = 'padding:6px 10px;margin-bottom:4px;background:var(--bg-secondary);border-left:3px solid var(--accent);border-radius:3px;font-size:12px;';
|
|
201
|
+
const icon = step.status === 'ok' || step.status === 'completed' ? '✓' : step.status === 'error' || step.status === 'failed' ? '✗' : '○';
|
|
202
|
+
const dur = step.durationMs ? ` (${(step.durationMs / 1000).toFixed(1)}s)` : '';
|
|
203
|
+
row.innerHTML = `<b>${icon} ${escapeHtml(step.name)}</b>${dur}`;
|
|
204
|
+
if (step.output) {
|
|
205
|
+
const pre = document.createElement('pre');
|
|
206
|
+
pre.style.cssText = 'margin:4px 0 0;padding:6px;background:var(--bg);border-radius:3px;font-size:11px;white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto;';
|
|
207
|
+
pre.textContent = String(step.output).slice(0, 800);
|
|
208
|
+
row.appendChild(pre);
|
|
209
|
+
}
|
|
210
|
+
panel.appendChild(row);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (data.finalReply) {
|
|
215
|
+
const h = document.createElement('div');
|
|
216
|
+
h.textContent = '最终回复';
|
|
217
|
+
h.style.cssText = 'font-weight:600;margin:12px 0 8px;';
|
|
218
|
+
panel.appendChild(h);
|
|
219
|
+
const r = document.createElement('div');
|
|
220
|
+
r.style.cssText = 'padding:8px 12px;background:var(--bg-secondary);border-radius:4px;font-size:13px;white-space:pre-wrap;word-break:break-word;';
|
|
221
|
+
r.textContent = data.finalReply;
|
|
222
|
+
panel.appendChild(r);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!data.error && !data.summary && (!data.steps || data.steps.length === 0) && !data.finalReply) {
|
|
226
|
+
const empty = document.createElement('div');
|
|
227
|
+
empty.style.cssText = 'text-align:center;padding:24px;color:var(--text-muted);font-size:13px;';
|
|
228
|
+
empty.textContent = '无循环产出 (可能已 abort, 或没产生 step)';
|
|
229
|
+
panel.appendChild(empty);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
modal.appendChild(panel);
|
|
233
|
+
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
|
234
|
+
document.body.appendChild(modal);
|
|
235
|
+
}
|
|
236
|
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
237
|
+
const themeToggle = document.getElementById('theme-toggle');
|
|
238
|
+
const channelList = document.getElementById('channel-list');
|
|
239
|
+
const newChannelBtn = document.getElementById('new-channel-btn');
|
|
240
|
+
const newChannelInput = document.getElementById('new-channel-input');
|
|
241
|
+
const channelNameEl = document.getElementById('channel-name');
|
|
242
|
+
|
|
243
|
+
let eventSources = new Map(); // channelId -> EventSource
|
|
244
|
+
let currentChannelId = null;
|
|
245
|
+
let currentAgentId = '';
|
|
246
|
+
let channels = [];
|
|
247
|
+
let remoteChannels = []; // v3: 远端 channel UI 元数据 (按 peer 分组)
|
|
248
|
+
let isSidebarCollapsed = false;
|
|
249
|
+
let reconnectAttempts = new Map(); // channelId -> attempts
|
|
250
|
+
let reconnectTimers = new Map(); // channelId -> timer
|
|
251
|
+
let heartbeatTimers = new Map(); // channelId -> setInterval handle (防止泄漏)
|
|
252
|
+
let lastUserCommand = ''; // 防止用户消息重复显示
|
|
253
|
+
|
|
254
|
+
// 2026-06-10: P2P peer-group 折叠状态持久化 (跨刷新)
|
|
255
|
+
// key = bolloon.p2p.collapsedPeers, value = JSON array of publicKey hex
|
|
256
|
+
const COLLAPSED_PEERS_KEY = 'bolloon.p2p.collapsedPeers';
|
|
257
|
+
const SEEN_PEERS_KEY = 'bolloon.p2p.seenPeers';
|
|
258
|
+
let collapsedPeers = (function loadCollapsed() {
|
|
259
|
+
try {
|
|
260
|
+
const raw = localStorage.getItem(COLLAPSED_PEERS_KEY);
|
|
261
|
+
return new Set(raw ? JSON.parse(raw) : []);
|
|
262
|
+
} catch { return new Set(); }
|
|
263
|
+
})();
|
|
264
|
+
let seenPeers = (function loadSeen() {
|
|
265
|
+
try {
|
|
266
|
+
const raw = localStorage.getItem(SEEN_PEERS_KEY);
|
|
267
|
+
return new Set(raw ? JSON.parse(raw) : []);
|
|
268
|
+
} catch { return new Set(); }
|
|
269
|
+
})();
|
|
270
|
+
function saveCollapsedPeers() {
|
|
271
|
+
try { localStorage.setItem(COLLAPSED_PEERS_KEY, JSON.stringify([...collapsedPeers])); } catch {}
|
|
272
|
+
}
|
|
273
|
+
function saveSeenPeers() {
|
|
274
|
+
try { localStorage.setItem(SEEN_PEERS_KEY, JSON.stringify([...seenPeers])); } catch {}
|
|
275
|
+
}
|
|
276
|
+
function togglePeerCollapsed(peerPk) {
|
|
277
|
+
if (collapsedPeers.has(peerPk)) {
|
|
278
|
+
collapsedPeers.delete(peerPk);
|
|
279
|
+
} else {
|
|
280
|
+
collapsedPeers.add(peerPk);
|
|
281
|
+
}
|
|
282
|
+
saveCollapsedPeers();
|
|
283
|
+
renderRemoteChannels();
|
|
284
|
+
// 2026-06-10: 通知 header 切换按钮同步图标
|
|
285
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
286
|
+
}
|
|
287
|
+
// 2026-06-10: 一键展开/折叠所有 P2P peer (header 按钮调用)
|
|
288
|
+
function expandAllPeers() {
|
|
289
|
+
// 从 remoteChannels + knownPeers 收集所有 publicKey
|
|
290
|
+
const allPks = new Set([
|
|
291
|
+
...knownPeers.map(p => p.publicKey),
|
|
292
|
+
...remoteChannels.map(g => g.peerId)
|
|
293
|
+
]);
|
|
294
|
+
for (const pk of allPks) collapsedPeers.delete(pk);
|
|
295
|
+
saveCollapsedPeers();
|
|
296
|
+
renderRemoteChannels();
|
|
297
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
298
|
+
}
|
|
299
|
+
function collapseAllPeers() {
|
|
300
|
+
const allPks = new Set([
|
|
301
|
+
...knownPeers.map(p => p.publicKey),
|
|
302
|
+
...remoteChannels.map(g => g.peerId)
|
|
303
|
+
]);
|
|
304
|
+
for (const pk of allPks) collapsedPeers.add(pk);
|
|
305
|
+
saveCollapsedPeers();
|
|
306
|
+
renderRemoteChannels();
|
|
307
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
308
|
+
}
|
|
309
|
+
let lastAiContent = ''; // 防止 AI 消息重复显示
|
|
310
|
+
let messagesContainers = new Map(); // channelId -> messages container div
|
|
311
|
+
let sessionMessages = new Map(); // channelId:sessionId -> messages array
|
|
312
|
+
let currentSessionId = null; // 当前显示的 session ID
|
|
313
|
+
let expandedAgents = new Set(); // 当前展开的 agent(channel) id 集合
|
|
314
|
+
|
|
315
|
+
function generateId() {
|
|
316
|
+
return crypto.randomUUID();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function applyTheme(theme) {
|
|
320
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function loadTheme() {
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch('/theme');
|
|
326
|
+
const data = await res.json();
|
|
327
|
+
applyTheme(data.theme);
|
|
328
|
+
if (data.agentId) {
|
|
329
|
+
currentAgentId = data.agentId;
|
|
330
|
+
}
|
|
331
|
+
return data;
|
|
332
|
+
} catch {
|
|
333
|
+
applyTheme('dark');
|
|
334
|
+
return { theme: 'dark', agentId: '' };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function saveTheme(theme, agentId) {
|
|
339
|
+
try {
|
|
340
|
+
await fetch('/theme', {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
343
|
+
body: JSON.stringify({ theme, agentId })
|
|
344
|
+
});
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error('Failed to save theme:', err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function toggleTheme() {
|
|
351
|
+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
352
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
353
|
+
applyTheme(next);
|
|
354
|
+
saveTheme(next, currentAgentId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function toggleSidebar() {
|
|
358
|
+
isSidebarCollapsed = !isSidebarCollapsed;
|
|
359
|
+
|
|
360
|
+
if (isSidebarCollapsed) {
|
|
361
|
+
sidebar.classList.add('collapsed');
|
|
362
|
+
} else {
|
|
363
|
+
sidebar.classList.remove('collapsed');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function expandSidebar() {
|
|
368
|
+
isSidebarCollapsed = false;
|
|
369
|
+
sidebar.classList.remove('collapsed');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function loadChannels() {
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch('/channels');
|
|
375
|
+
channels = await res.json();
|
|
376
|
+
console.log('[加载频道] 从服务器获取到', channels.length, '个频道');
|
|
377
|
+
channels.forEach((ch, i) => {
|
|
378
|
+
console.log(` [${i}] ${ch.name} - did: "${ch.did}"`);
|
|
379
|
+
});
|
|
380
|
+
// 2026-06-11: 全部默认不展开 (用户需要手动点 caret 展开 session 列表)
|
|
381
|
+
// 之前默认展开第一个会喧宾夺主, 用户看不到完整 channel 列表
|
|
382
|
+
renderChannels();
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.error('[加载频道] 失败:', err);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// v3: 全局 SSE 监听 (p2p-global channel) - 接收远端 chat.reply 等事件
|
|
389
|
+
let v3GlobalEventSource = null;
|
|
390
|
+
function startV3GlobalSSE() {
|
|
391
|
+
if (v3GlobalEventSource) return;
|
|
392
|
+
try {
|
|
393
|
+
v3GlobalEventSource = new EventSource('/events?channelId=p2p-global');
|
|
394
|
+
v3GlobalEventSource.onmessage = (e) => {
|
|
395
|
+
try {
|
|
396
|
+
const msg = JSON.parse(e.data);
|
|
397
|
+
if (msg.type === 'remote-chat-reply') {
|
|
398
|
+
// 2026-06-10: 复用本地 addMessage 渲染 — 自动 marked + 剥 think/env + 主题样式
|
|
399
|
+
// 之前是 textContent 硬编码灰底, 跟 Step 3 重写的 modal 风格不一致,
|
|
400
|
+
// 而且 SSE 异步回到时 modal 可能已被切到 thinking 占满, 用户看不到 reply.
|
|
401
|
+
const log = document.getElementById('rcm-log');
|
|
402
|
+
const thinkingEl = document.getElementById('rcm-thinking');
|
|
403
|
+
if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
|
|
404
|
+
// 也清掉 "对方正在思考..." 行 (流式 token 留下的)
|
|
405
|
+
const liveThinking = document.getElementById('rcm-thinking-live');
|
|
406
|
+
if (liveThinking) liveThinking.remove();
|
|
407
|
+
if (log) {
|
|
408
|
+
if (msg.error) {
|
|
409
|
+
// 错误用 sysmsg 样式 (跟 modal 风格一致)
|
|
410
|
+
const errEl = document.createElement('div');
|
|
411
|
+
errEl.className = 'remote-chat-sysmsg remote-chat-sysmsg-error';
|
|
412
|
+
errEl.textContent = `❌ 对方回复出错: ${msg.error}`;
|
|
413
|
+
log.appendChild(errEl);
|
|
414
|
+
} else {
|
|
415
|
+
// 走本地 addMessage, 跟主聊天框完全一致 (marked + think/env 折叠 + 主题色)
|
|
416
|
+
const prefix = `🤖 远端 AI 回复\n\n`;
|
|
417
|
+
addMessage(prefix + (msg.text || '(空回复)'), 'ai', false, log);
|
|
418
|
+
}
|
|
419
|
+
log.scrollTop = log.scrollHeight;
|
|
420
|
+
} else {
|
|
421
|
+
// modal 没开 → 用右下 toast 提示用户"对方回了, 打开聊天看"
|
|
422
|
+
if (typeof showSimpleToast === 'function') {
|
|
423
|
+
const preview = (msg.text || '').slice(0, 50);
|
|
424
|
+
showSimpleToast(`💬 远端 channel 有新回复: ${preview}${msg.text && msg.text.length > 50 ? '…' : ''}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else if (msg.type === 'remote-chat-thinking') {
|
|
428
|
+
// v3 新增: B 端实时显示 A 节点的思考过程
|
|
429
|
+
const phase = msg.phase;
|
|
430
|
+
const log = document.getElementById('rcm-log');
|
|
431
|
+
if (!log) return;
|
|
432
|
+
|
|
433
|
+
if (phase === 'start') {
|
|
434
|
+
// 头部插入"判断力依据"区块 (只第一次)
|
|
435
|
+
const judgments = msg.usedJudgments || { bound: [], candidates: [] };
|
|
436
|
+
const judgmentBlock = document.createElement('div');
|
|
437
|
+
judgmentBlock.className = 'rcm-judgment-block';
|
|
438
|
+
judgmentBlock.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
|
|
439
|
+
let jh = '<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方使用的判断力 (来自 ta 的 channel)</div>';
|
|
440
|
+
if (judgments.bound && judgments.bound.length > 0) {
|
|
441
|
+
jh += '<div style="color:#78350f;margin-bottom:4px;"><b>硬绑定</b> (必须遵循):</div>';
|
|
442
|
+
for (const j of judgments.bound) {
|
|
443
|
+
jh += `<div style="margin:2px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 80))}</b>${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 80)) + '</span>' : ''}</div>`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
447
|
+
jh += `<div style="color:#78350f;margin-top:4px;"><b>候选池</b> (${judgments.candidates.length} 条, LLM 自选)</div>`;
|
|
448
|
+
}
|
|
449
|
+
log.appendChild(judgmentBlock);
|
|
450
|
+
// "思考中" 区块
|
|
451
|
+
const thinkingEl = document.createElement('div');
|
|
452
|
+
thinkingEl.id = 'rcm-thinking-live';
|
|
453
|
+
thinkingEl.style.cssText = 'margin:6px 0;padding:8px 10px;background:#ede9fe;border-left:3px solid #8b5cf6;border-radius:4px;font-size:12px;color:#5b21b6;font-style:italic;';
|
|
454
|
+
thinkingEl.textContent = '💭 对方正在思考...';
|
|
455
|
+
log.appendChild(thinkingEl);
|
|
456
|
+
log.scrollTop = log.scrollHeight;
|
|
457
|
+
} else if (phase === 'token') {
|
|
458
|
+
// 实时更新思考中的 partial
|
|
459
|
+
const thinkingEl = document.getElementById('rcm-thinking-live');
|
|
460
|
+
if (thinkingEl) {
|
|
461
|
+
thinkingEl.textContent = '💭 对方正在思考: ' + (msg.partial || '').slice(-200);
|
|
462
|
+
log.scrollTop = log.scrollHeight;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else if (msg.type === 'cross-mention-received') {
|
|
466
|
+
// v3 新增: A 节点上, 某个 channel 的 LLM @-mention 了另一个 channel, SSE 推过来
|
|
467
|
+
// 在所有打开的 chat modal 上显示"AI 跨渠道 @-mention" 提示
|
|
468
|
+
const allModals = document.querySelectorAll('.rcm-mention-toast, [id^="rcm-log"]');
|
|
469
|
+
for (const log of allModals) {
|
|
470
|
+
if (!log.id) continue;
|
|
471
|
+
const toast = document.createElement('div');
|
|
472
|
+
toast.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fce7f3;border-left:3px solid #ec4899;border-radius:4px;font-size:12px;color:#831843;';
|
|
473
|
+
const fromTxt = msg.source === 'ai-mention-remote' ? `远端节点 ${(msg.fromPublicKey || '').substring(0, 8)}… 的 ${msg.originChannelName}` : `${msg.originChannelName} (本地)`;
|
|
474
|
+
toast.innerHTML = `📡 <b>${fromTxt}</b> @-mention → 当前 channel: <i>${escapeHtml((msg.text || '').slice(0, 100))}</i>${msg.text && msg.text.length > 100 ? '…' : ''}`;
|
|
475
|
+
log.appendChild(toast);
|
|
476
|
+
log.scrollTop = log.scrollHeight;
|
|
477
|
+
}
|
|
478
|
+
} else if (msg.type === 'remote-channel-update') {
|
|
479
|
+
// v3 新增: 远端节点发来新分享 / 删除 / 改名, 立即更新本地 cache
|
|
480
|
+
const peerId = msg.peerId;
|
|
481
|
+
const channels = msg.channels || [];
|
|
482
|
+
const peerName = msg.peerName || null; // 2026-06-10: 同步接收对方名字
|
|
483
|
+
let group = remoteChannels.find(g => g.peerId === peerId);
|
|
484
|
+
if (!group) {
|
|
485
|
+
group = { peerId, channels: [], peerName: peerName || ('peer-' + peerId.substring(0, 8)) };
|
|
486
|
+
remoteChannels.push(group);
|
|
487
|
+
} else if (peerName) {
|
|
488
|
+
group.peerName = peerName; // 更新名字
|
|
489
|
+
}
|
|
490
|
+
group.channels = channels;
|
|
491
|
+
// 2026-06-10: 如果对面告知名字, 同步刷新 knownPeers 列表, 避免陌生 peer 状态
|
|
492
|
+
if (peerName && !knownPeers.find(p => p.publicKey === peerId)) {
|
|
493
|
+
knownPeers.push({
|
|
494
|
+
publicKey: peerId,
|
|
495
|
+
name: peerName,
|
|
496
|
+
addedAt: new Date().toISOString(),
|
|
497
|
+
lastConnectedAt: new Date().toISOString(),
|
|
498
|
+
});
|
|
499
|
+
console.log(`[v3] 远端 ${peerId.substring(0,12)}... 自报名字 = ${peerName}, 已加到 knownPeers`);
|
|
500
|
+
}
|
|
501
|
+
renderRemoteChannels();
|
|
502
|
+
console.log(`[v3] 收到远端 ${peerId.substring(0,12)}... 的 ${channels.length} 个 channel 更新 (name=${peerName || '?'})`);
|
|
503
|
+
} else if (msg.type === 'friend-request') {
|
|
504
|
+
// v3 新增: 收到好友申请
|
|
505
|
+
showFriendRequestModal(msg);
|
|
506
|
+
} else if (msg.type === 'friend-request-ack') {
|
|
507
|
+
// 2026-06-10: 收到对方 ack, 给发送方提示"已送达"
|
|
508
|
+
const pending = window.__pendingFriendRequests;
|
|
509
|
+
if (pending && msg.requestId && pending.has(msg.requestId)) {
|
|
510
|
+
const { name } = pending.get(msg.requestId);
|
|
511
|
+
pending.delete(msg.requestId);
|
|
512
|
+
console.log(`[v3-friend] ✅ ack 收到: ${name} 已收到好友申请`);
|
|
513
|
+
// 简短 toast (右下角), 不阻塞
|
|
514
|
+
showSimpleToast(`📬 ${name} 已收到你的好友申请, 等对方接受`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch (err) {
|
|
518
|
+
console.error('[v3] 全局 SSE 解析失败:', err);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
v3GlobalEventSource.onerror = (e) => {
|
|
522
|
+
console.warn('[v3] 全局 SSE 错误');
|
|
523
|
+
};
|
|
524
|
+
} catch (err) {
|
|
525
|
+
console.error('[v3] 启动全局 SSE 失败:', err);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function createChannel(name) {
|
|
530
|
+
if (!name.trim()) return;
|
|
531
|
+
try {
|
|
532
|
+
const res = await fetch('/channels', {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: { 'Content-Type': 'application/json' },
|
|
535
|
+
body: JSON.stringify({ name: name.trim(), agentId: currentAgentId })
|
|
536
|
+
});
|
|
537
|
+
const channel = await res.json();
|
|
538
|
+
console.log('[创建频道] 服务器返回:', channel);
|
|
539
|
+
console.log('[创建频道] DID:', channel.did, 'CID:', channel.cid);
|
|
540
|
+
|
|
541
|
+
// 立即添加频道并切换(不等待 DID)
|
|
542
|
+
channels.push(channel);
|
|
543
|
+
renderChannels();
|
|
544
|
+
selectChannel(channel.id);
|
|
545
|
+
if (newChannelInput) newChannelInput.value = '';
|
|
546
|
+
|
|
547
|
+
// 后台更新 DID(如果还没有的话)
|
|
548
|
+
if (!channel.did || channel.did === 'undefined') {
|
|
549
|
+
console.log('[创建频道] 后台生成 DID...');
|
|
550
|
+
// 复用全局 channelRefreshTimer, 把多个刷新请求合并成一个 1.5s 后的请求
|
|
551
|
+
scheduleChannelsRefresh();
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error('Failed to create channel:', err);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function deleteChannel(channelId, e) {
|
|
559
|
+
e.stopPropagation();
|
|
560
|
+
if (!confirm('确定要删除该智能体及其所有会话吗?此操作不可撤销。')) return;
|
|
561
|
+
try {
|
|
562
|
+
await fetch(`/channels/${channelId}`, { method: 'DELETE' });
|
|
563
|
+
channels = channels.filter(c => c.id !== channelId);
|
|
564
|
+
expandedAgents.delete(channelId);
|
|
565
|
+
|
|
566
|
+
// 释放浏览器侧引用 + DOM, 避免长时间使用后内存累积
|
|
567
|
+
cleanupChannelState(channelId);
|
|
568
|
+
|
|
569
|
+
if (currentChannelId === channelId) {
|
|
570
|
+
currentChannelId = channels[0]?.id || null;
|
|
571
|
+
currentSessionId = null;
|
|
572
|
+
if (currentChannelId) {
|
|
573
|
+
const ch = channels.find(c => c.id === currentChannelId);
|
|
574
|
+
if (channelNameEl) channelNameEl.textContent = ch?.name || 'Bolloon Agent';
|
|
575
|
+
await selectChannel(currentChannelId);
|
|
576
|
+
} else {
|
|
577
|
+
messagesEl.innerHTML = '';
|
|
578
|
+
if (channelNameEl) channelNameEl.textContent = 'Bolloon Agent';
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
renderChannels();
|
|
582
|
+
} catch (err) {
|
|
583
|
+
console.error('Failed to delete channel:', err);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 释放一个 channel 在浏览器侧占用的所有资源 (DOM 容器, SSE, 缓存 session 消息) */
|
|
588
|
+
function cleanupChannelState(channelId) {
|
|
589
|
+
// 1. SSE 连接
|
|
590
|
+
if (eventSources.has(channelId)) {
|
|
591
|
+
try { eventSources.get(channelId).close(); } catch {}
|
|
592
|
+
eventSources.delete(channelId);
|
|
593
|
+
}
|
|
594
|
+
// 2. 心跳 + 重连 timer
|
|
595
|
+
if (heartbeatTimers.has(channelId)) {
|
|
596
|
+
clearInterval(heartbeatTimers.get(channelId));
|
|
597
|
+
heartbeatTimers.delete(channelId);
|
|
598
|
+
}
|
|
599
|
+
if (reconnectTimers.has(channelId)) {
|
|
600
|
+
clearTimeout(reconnectTimers.get(channelId));
|
|
601
|
+
reconnectTimers.delete(channelId);
|
|
602
|
+
}
|
|
603
|
+
reconnectAttempts.delete(channelId);
|
|
604
|
+
// 3. 消息容器 DOM — 真从 #messages 里移除, 不只是隐藏
|
|
605
|
+
const container = messagesContainers.get(channelId);
|
|
606
|
+
if (container && container.parentNode) {
|
|
607
|
+
container.parentNode.removeChild(container);
|
|
608
|
+
}
|
|
609
|
+
messagesContainers.delete(channelId);
|
|
610
|
+
// 4. 缓存的所有 session 消息 (按 channel:session 索引)
|
|
611
|
+
const prefix = `${channelId}:`;
|
|
612
|
+
for (const key of sessionMessages.keys()) {
|
|
613
|
+
if (key === channelId || key.startsWith(prefix)) {
|
|
614
|
+
sessionMessages.delete(key);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function createNewSession() {
|
|
620
|
+
if (!currentChannelId) {
|
|
621
|
+
console.log('[新会话] 没有选中的频道');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
// 保存当前 session 的消息
|
|
626
|
+
saveCurrentSessionMessages();
|
|
627
|
+
|
|
628
|
+
const res = await fetch(`/channels/${currentChannelId}/sessions`, {
|
|
629
|
+
method: 'POST'
|
|
630
|
+
});
|
|
631
|
+
const data = await res.json();
|
|
632
|
+
console.log('[新会话] 创建成功:', data);
|
|
633
|
+
|
|
634
|
+
// 更新本地频道数据
|
|
635
|
+
const channel = channels.find(c => c.id === currentChannelId);
|
|
636
|
+
if (channel) {
|
|
637
|
+
if (!channel.sessions) channel.sessions = [];
|
|
638
|
+
channel.sessions.push(data.session);
|
|
639
|
+
channel.currentSessionId = data.currentSessionId;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 切换到新 session
|
|
643
|
+
currentSessionId = data.currentSessionId;
|
|
644
|
+
|
|
645
|
+
// 清空容器并加载新 session
|
|
646
|
+
const container = messagesContainers.get(currentChannelId);
|
|
647
|
+
if (container) {
|
|
648
|
+
container.innerHTML = '';
|
|
649
|
+
showChannelView(currentChannelId);
|
|
650
|
+
addMessage('你好!新会话已开始,有什么我可以帮你的吗?', 'ai', false, container);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 展开当前智能体,刷新侧边栏让新会话显示出来
|
|
654
|
+
expandedAgents.add(currentChannelId);
|
|
655
|
+
renderChannels();
|
|
656
|
+
|
|
657
|
+
console.log('[新会话] 已切换到:', data.currentSessionId);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
console.error('Failed to create new session:', err);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function createNewSessionForChannel(channelId, e) {
|
|
664
|
+
if (e) e.stopPropagation();
|
|
665
|
+
if (!channelId) return;
|
|
666
|
+
|
|
667
|
+
// 给自己创建:复用统一的 createNewSession
|
|
668
|
+
if (channelId === currentChannelId) {
|
|
669
|
+
if (currentSessionId) saveCurrentSessionMessages();
|
|
670
|
+
await createNewSession();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 给别的智能体创建:后端建好后直接 re-fetch 一次保持本地与后端一致
|
|
675
|
+
try {
|
|
676
|
+
const res = await fetch(`/channels/${channelId}/sessions`, { method: 'POST' });
|
|
677
|
+
if (!res.ok) throw new Error('create session failed');
|
|
678
|
+
const data = await res.json();
|
|
679
|
+
const channel = channels.find(c => c.id === channelId);
|
|
680
|
+
if (channel) {
|
|
681
|
+
if (!channel.sessions) channel.sessions = [];
|
|
682
|
+
channel.sessions.push(data.session);
|
|
683
|
+
channel.currentSessionId = data.currentSessionId;
|
|
684
|
+
}
|
|
685
|
+
expandedAgents.add(channelId);
|
|
686
|
+
renderChannels();
|
|
687
|
+
} catch (err) {
|
|
688
|
+
console.error('Failed to create new session:', err);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function switchSession(channelId, sessionId, e) {
|
|
693
|
+
if (e) e.stopPropagation();
|
|
694
|
+
if (!channelId || !sessionId) return;
|
|
695
|
+
if (channelId === currentChannelId && sessionId === currentSessionId) return;
|
|
696
|
+
|
|
697
|
+
// 先保存当前 session 的本地消息
|
|
698
|
+
if (currentChannelId && currentSessionId) {
|
|
699
|
+
saveCurrentSessionMessages();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const res = await fetch(`/channels/${channelId}/sessions/${sessionId}/switch`, { method: 'POST' });
|
|
704
|
+
if (!res.ok) throw new Error('switch failed');
|
|
705
|
+
const channel = channels.find(c => c.id === channelId);
|
|
706
|
+
if (channel) {
|
|
707
|
+
channel.currentSessionId = sessionId;
|
|
708
|
+
await saveChannels();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 切换到目标 agent + session
|
|
712
|
+
await selectChannel(channelId, sessionId);
|
|
713
|
+
renderChannels();
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error('Failed to switch session:', err);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function deleteSession(channelId, sessionId, e) {
|
|
720
|
+
if (e) e.stopPropagation();
|
|
721
|
+
if (!confirm('确定要删除该会话吗?此操作不可撤销。')) return;
|
|
722
|
+
try {
|
|
723
|
+
const res = await fetch(`/channels/${channelId}/sessions/${sessionId}`, { method: 'DELETE' });
|
|
724
|
+
if (!res.ok) {
|
|
725
|
+
const err = await res.json().catch(() => ({}));
|
|
726
|
+
alert(err.error || '删除失败');
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const data = await res.json();
|
|
730
|
+
const channel = channels.find(c => c.id === channelId);
|
|
731
|
+
if (channel) {
|
|
732
|
+
if (channel.sessions) {
|
|
733
|
+
channel.sessions = channel.sessions.filter(s => s.id !== sessionId);
|
|
734
|
+
}
|
|
735
|
+
if (data.currentSessionId) {
|
|
736
|
+
channel.currentSessionId = data.currentSessionId;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// 如果删的是当前打开的会话,切换到新的当前会话
|
|
741
|
+
if (channelId === currentChannelId && sessionId === currentSessionId) {
|
|
742
|
+
if (data.currentSessionId) {
|
|
743
|
+
currentSessionId = data.currentSessionId;
|
|
744
|
+
const container = messagesContainers.get(channelId);
|
|
745
|
+
if (container) container.innerHTML = '';
|
|
746
|
+
await loadSession(channelId);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
renderChannels();
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.error('Failed to delete session:', err);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let _saveSessionMessagesDirty = false;
|
|
756
|
+
let _saveSessionMessagesTimer = null;
|
|
757
|
+
function saveCurrentSessionMessages() {
|
|
758
|
+
if (!currentChannelId || !currentSessionId) return;
|
|
759
|
+
// 内存保护: 多次快速调用合并成一个, 避免在切会话时反复 .textContent 读 DOM
|
|
760
|
+
// (每个 textContent 会序列化整棵子树, 200 条消息 = 几百 MB 临时字符串)
|
|
761
|
+
_saveSessionMessagesDirty = true;
|
|
762
|
+
if (_saveSessionMessagesTimer) return;
|
|
763
|
+
_saveSessionMessagesTimer = setTimeout(() => {
|
|
764
|
+
_saveSessionMessagesTimer = null;
|
|
765
|
+
if (!_saveSessionMessagesDirty) return;
|
|
766
|
+
_saveSessionMessagesDirty = false;
|
|
767
|
+
if (!currentChannelId || !currentSessionId) return;
|
|
768
|
+
const container = messagesContainers.get(currentChannelId);
|
|
769
|
+
if (!container) return;
|
|
770
|
+
const messages = Array.from(container.querySelectorAll('.message')).map(msg => ({
|
|
771
|
+
type: msg.classList.contains('message-user') ? 'user' : 'ai',
|
|
772
|
+
content: msg.querySelector('.message-content')?.textContent || ''
|
|
773
|
+
}));
|
|
774
|
+
if (messages.length > 0) {
|
|
775
|
+
sessionMessages.set(`${currentChannelId}:${currentSessionId}`, messages);
|
|
776
|
+
}
|
|
777
|
+
}, 50);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function saveChannels() {
|
|
781
|
+
// 简单地 re-fetch,保持本地 channels 与服务端一致
|
|
782
|
+
// 改成走 scheduleChannelsRefresh, 多个调用合并成一个请求 — 减少内存峰值和后端压力
|
|
783
|
+
scheduleChannelsRefresh();
|
|
784
|
+
await new Promise(r => setTimeout(r, 600));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let channelRefreshTimer = null;
|
|
788
|
+
let channelRefreshInFlight = null;
|
|
789
|
+
function scheduleChannelsRefresh() {
|
|
790
|
+
if (channelRefreshTimer) return;
|
|
791
|
+
channelRefreshTimer = setTimeout(async () => {
|
|
792
|
+
channelRefreshTimer = null;
|
|
793
|
+
if (channelRefreshInFlight) return channelRefreshInFlight;
|
|
794
|
+
channelRefreshInFlight = (async () => {
|
|
795
|
+
try {
|
|
796
|
+
const res = await fetch('/channels');
|
|
797
|
+
if (res.ok) {
|
|
798
|
+
const fresh = await res.json();
|
|
799
|
+
channels = fresh;
|
|
800
|
+
renderChannels();
|
|
801
|
+
}
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error('Failed to re-fetch channels:', err);
|
|
804
|
+
} finally {
|
|
805
|
+
channelRefreshInFlight = null;
|
|
806
|
+
}
|
|
807
|
+
})();
|
|
808
|
+
return channelRefreshInFlight;
|
|
809
|
+
}, 800);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function toggleAgentExpand(channelId, e) {
|
|
813
|
+
if (e) e.stopPropagation();
|
|
814
|
+
if (expandedAgents.has(channelId)) {
|
|
815
|
+
expandedAgents.delete(channelId);
|
|
816
|
+
} else {
|
|
817
|
+
expandedAgents.add(channelId);
|
|
818
|
+
}
|
|
819
|
+
renderChannels();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* 2026-06-11 性能优化: 切 channel 时用轻量 patch, 不重建整个 sidebar 列表
|
|
824
|
+
* 只更新: (1) active class (2) 当前 session label + count (3) expanded 状态
|
|
825
|
+
* 避免每次切 channel 都 innerHTML='' + 重建 ~10 个 channel 节点
|
|
826
|
+
*/
|
|
827
|
+
function renderChannelsLite(activeChannelId, activeSessionId) {
|
|
828
|
+
if (!channelList) return;
|
|
829
|
+
// 1. 更新所有 .agent-row 的 active class
|
|
830
|
+
channelList.querySelectorAll('.agent-row').forEach(row => {
|
|
831
|
+
const li = row.closest('.agent-group');
|
|
832
|
+
const chId = li?.dataset.channelId;
|
|
833
|
+
row.classList.toggle('active', chId === activeChannelId);
|
|
834
|
+
});
|
|
835
|
+
// 2. 当前 channel 的展开状态: 强制展开, 其他不动
|
|
836
|
+
if (activeChannelId) expandedAgents.add(activeChannelId);
|
|
837
|
+
// 3. 当前 channel 行展开 + 只切 session-item 的 active class (不再 innerHTML 重渲!)
|
|
838
|
+
// 原因: 重渲 innerHTML 会清掉原始 renderChannels 绑的 session-item click handler,
|
|
839
|
+
// 即使补绑也会因为 lite HTML 结构 (.session-dot + .session-msg-count) 跟原始不同
|
|
840
|
+
// 导致"第 1 次点不动 (原始), 第 2 次点才能用 (lite)" 现象
|
|
841
|
+
// 修法: 完全不动 session-list DOM, 只 toggle .active
|
|
842
|
+
const activeLi = channelList.querySelector(`.agent-group[data-channel-id="${activeChannelId}"]`);
|
|
843
|
+
if (activeLi) {
|
|
844
|
+
activeLi.classList.add('expanded');
|
|
845
|
+
// 只切 active class, 不动 innerHTML (避免清掉原始 click handler)
|
|
846
|
+
const ch = channels.find(c => c.id === activeChannelId);
|
|
847
|
+
// 2026-06-11: 原始 renderChannels 已经给 session-item 加了 data-session-id (line 791),
|
|
848
|
+
// 这里先清空所有 .active 再设新的, 避免多个 active 共存 (因为 renderChannels 初始 DOM
|
|
849
|
+
// 上会有一个 active 标记旧 session, 新切 session 容易出现两个 active)
|
|
850
|
+
activeLi.querySelectorAll('.session-item').forEach(sessLi => {
|
|
851
|
+
const sessId = sessLi.dataset.sessionId;
|
|
852
|
+
const shouldBeActive = sessId === activeSessionId;
|
|
853
|
+
sessLi.classList.toggle('active', shouldBeActive);
|
|
854
|
+
});
|
|
855
|
+
// 更新顶部 current session label
|
|
856
|
+
if (ch) {
|
|
857
|
+
const currentSess = Array.isArray(ch.sessions) ? ch.sessions.find(s => s.id === activeSessionId) : null;
|
|
858
|
+
const labelEl = activeLi.querySelector('.agent-current-session');
|
|
859
|
+
if (labelEl) {
|
|
860
|
+
labelEl.textContent = currentSess ? '· ' + formatSessionName(currentSess) : '';
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renderChannels() {
|
|
867
|
+
if (!channelList) return;
|
|
868
|
+
channelList.innerHTML = '';
|
|
869
|
+
|
|
870
|
+
const fragment = document.createDocumentFragment();
|
|
871
|
+
|
|
872
|
+
// 滚动可见性监听只绑定一次 (channelList 是同一个 DOM 节点,
|
|
873
|
+
// renderChannels 每次清空 innerHTML 都会重渲, 不能重复 addEventListener)
|
|
874
|
+
if (!channelList._scrollListenersBound) {
|
|
875
|
+
const onUserScroll = () => {
|
|
876
|
+
channelList.classList.add('is-scrolling');
|
|
877
|
+
if (channelList._scrollIdleTimer) clearTimeout(channelList._scrollIdleTimer);
|
|
878
|
+
channelList._scrollIdleTimer = setTimeout(() => {
|
|
879
|
+
channelList.classList.remove('is-scrolling');
|
|
880
|
+
}, 1200);
|
|
881
|
+
};
|
|
882
|
+
channelList.addEventListener('wheel', onUserScroll, { passive: true });
|
|
883
|
+
channelList.addEventListener('touchmove', onUserScroll, { passive: true });
|
|
884
|
+
channelList.addEventListener('keydown', (ev) => {
|
|
885
|
+
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(ev.key)) {
|
|
886
|
+
onUserScroll();
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
channelList._scrollListenersBound = true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
channels.forEach(ch => {
|
|
893
|
+
const li = document.createElement('li');
|
|
894
|
+
const isExpanded = expandedAgents.has(ch.id);
|
|
895
|
+
li.className = `agent-group ${isExpanded ? 'expanded' : ''}`;
|
|
896
|
+
li.dataset.channelId = ch.id;
|
|
897
|
+
|
|
898
|
+
// --- 智能体行 ---
|
|
899
|
+
const row = document.createElement('div');
|
|
900
|
+
row.className = `agent-row ${ch.id === currentChannelId ? 'active' : ''}`;
|
|
901
|
+
|
|
902
|
+
// 找到当前智能体(如果它是激活的)的当前 session
|
|
903
|
+
const currentSess = (ch.id === currentChannelId && Array.isArray(ch.sessions))
|
|
904
|
+
? ch.sessions.find(s => s.id === ch.currentSessionId)
|
|
905
|
+
: null;
|
|
906
|
+
const currentSessLabel = currentSess ? formatSessionName(currentSess) : '';
|
|
907
|
+
const sessionCount = Array.isArray(ch.sessions) ? ch.sessions.length : 0;
|
|
908
|
+
|
|
909
|
+
// 2026-06-10: 隐藏 channel 行右侧的勋章 (钱包 / 工具) — UI 简洁
|
|
910
|
+
const walletBadge = '';
|
|
911
|
+
const toolsBadge = '';
|
|
912
|
+
|
|
913
|
+
row.innerHTML = `
|
|
914
|
+
<svg class="agent-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
915
|
+
<polyline points="9 18 15 12 9 6"></polyline>
|
|
916
|
+
</svg>
|
|
917
|
+
<div class="channel-icon">💬</div>
|
|
918
|
+
<span class="channel-name" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
|
|
919
|
+
<span class="agent-row-meta">
|
|
920
|
+
${walletBadge}
|
|
921
|
+
${toolsBadge}
|
|
922
|
+
${sessionCount > 1 ? `<span class="agent-session-count" title="${sessionCount} 个会话">${sessionCount}</span>` : ''}
|
|
923
|
+
${currentSessLabel ? `<span class="agent-current-session" title="当前会话:${escapeHtml(currentSessLabel)}">· ${escapeHtml(currentSessLabel)}</span>` : ''}
|
|
924
|
+
<button class="agent-config-btn" title="配置智能体 (钱包 / 工具)">
|
|
925
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
926
|
+
<circle cx="12" cy="12" r="3"></circle>
|
|
927
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
928
|
+
</svg>
|
|
929
|
+
</button>
|
|
930
|
+
<button class="channel-delete" title="删除智能体">×</button>
|
|
931
|
+
</span>
|
|
932
|
+
`;
|
|
933
|
+
|
|
934
|
+
// 行点击:切换展开;点击名字/图标区域则切到该智能体
|
|
935
|
+
row.addEventListener('click', (ev) => {
|
|
936
|
+
// 如果点在删除/配置按钮上, 单独处理
|
|
937
|
+
if (ev.target.closest('.channel-delete')
|
|
938
|
+
|| ev.target.closest('.agent-config-btn')) return;
|
|
939
|
+
if (ev.target.closest('.agent-caret')) {
|
|
940
|
+
toggleAgentExpand(ch.id, ev);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
toggleAgentExpand(ch.id, ev);
|
|
944
|
+
if (ch.id !== currentChannelId) {
|
|
945
|
+
expandSidebar();
|
|
946
|
+
selectChannel(ch.id);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// 智能体删除
|
|
951
|
+
row.querySelector('.channel-delete').addEventListener('click', (ev) => deleteChannel(ch.id, ev));
|
|
952
|
+
// 配置按钮: 打开同一个 modal 编辑已有智能体
|
|
953
|
+
row.querySelector('.agent-config-btn').addEventListener('click', (ev) => {
|
|
954
|
+
ev.stopPropagation();
|
|
955
|
+
openAgentAddModal(ch);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
li.appendChild(row);
|
|
959
|
+
|
|
960
|
+
// --- Session 列表(仅展开时渲染 DOM)---
|
|
961
|
+
const sessionUl = document.createElement('ul');
|
|
962
|
+
sessionUl.className = 'session-list';
|
|
963
|
+
if (isExpanded) {
|
|
964
|
+
// "新建会话" 按钮 — 放在 session 列表最前面, 始终可见
|
|
965
|
+
const newSessLi = document.createElement('li');
|
|
966
|
+
newSessLi.className = 'session-new-item';
|
|
967
|
+
newSessLi.setAttribute('role', 'button');
|
|
968
|
+
newSessLi.setAttribute('tabindex', '0');
|
|
969
|
+
newSessLi.title = '新建会话';
|
|
970
|
+
newSessLi.innerHTML = `
|
|
971
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
972
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
973
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
974
|
+
</svg>
|
|
975
|
+
<span>新建会话</span>
|
|
976
|
+
`;
|
|
977
|
+
const onNewSession = (ev) => {
|
|
978
|
+
ev.stopPropagation();
|
|
979
|
+
createNewSessionForChannel(ch.id, ev);
|
|
980
|
+
};
|
|
981
|
+
newSessLi.addEventListener('click', onNewSession);
|
|
982
|
+
newSessLi.addEventListener('keydown', (ev) => {
|
|
983
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
984
|
+
ev.preventDefault();
|
|
985
|
+
onNewSession(ev);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
sessionUl.appendChild(newSessLi);
|
|
989
|
+
|
|
990
|
+
const sessions = Array.isArray(ch.sessions) ? ch.sessions : [];
|
|
991
|
+
sessions.forEach(sess => {
|
|
992
|
+
const sessLi = document.createElement('li');
|
|
993
|
+
const isActive = ch.id === currentChannelId && sess.id === ch.currentSessionId;
|
|
994
|
+
sessLi.className = `session-item ${isActive ? 'active' : ''}`;
|
|
995
|
+
sessLi.dataset.sessionId = sess.id; // 2026-06-11: 给 session-item 加上 data-session-id, renderChannelsLite 才能 toggle active class
|
|
996
|
+
sessLi.innerHTML = `
|
|
997
|
+
<span class="session-name" title="${escapeHtml(formatSessionName(sess))}">${escapeHtml(formatSessionName(sess))}</span>
|
|
998
|
+
<button class="session-delete" title="删除会话">×</button>
|
|
999
|
+
`;
|
|
1000
|
+
sessLi.addEventListener('click', (ev) => {
|
|
1001
|
+
if (ev.target.closest('.session-delete')) return;
|
|
1002
|
+
switchSession(ch.id, sess.id, ev);
|
|
1003
|
+
});
|
|
1004
|
+
sessLi.querySelector('.session-delete').addEventListener('click', (ev) => deleteSession(ch.id, sess.id, ev));
|
|
1005
|
+
sessionUl.appendChild(sessLi);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
li.appendChild(sessionUl);
|
|
1009
|
+
|
|
1010
|
+
fragment.appendChild(li);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
channelList.appendChild(fragment);
|
|
1014
|
+
|
|
1015
|
+
// header 钱包徽章计数: 只在 channels 变化时刷新, 避免每次 renderChannels 都重算
|
|
1016
|
+
refreshWalletBadge();
|
|
1017
|
+
|
|
1018
|
+
// 把当前激活的 channel 平滑滚到视口内 — 用户切换后不会看不到
|
|
1019
|
+
// 只在非用户主动滚动状态下执行, 避免与正在进行的滚动冲突
|
|
1020
|
+
if (currentChannelId) {
|
|
1021
|
+
requestAnimationFrame(() => scrollActiveChannelIntoView(false));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** 把当前激活的 channel 滚到侧边栏视口内 */
|
|
1026
|
+
function scrollActiveChannelIntoView(smooth = true) {
|
|
1027
|
+
if (!channelList || !currentChannelId) return;
|
|
1028
|
+
const active = channelList.querySelector(`.agent-group[data-channel-id="${currentChannelId}"]`);
|
|
1029
|
+
if (!active) return;
|
|
1030
|
+
const listRect = channelList.getBoundingClientRect();
|
|
1031
|
+
const itemRect = active.getBoundingClientRect();
|
|
1032
|
+
const margin = 24; // 视口上下各留 24px
|
|
1033
|
+
if (itemRect.top < listRect.top + margin) {
|
|
1034
|
+
channelList.scrollBy({ top: itemRect.top - listRect.top - margin, behavior: smooth ? 'smooth' : 'auto' });
|
|
1035
|
+
} else if (itemRect.bottom > listRect.bottom - margin) {
|
|
1036
|
+
channelList.scrollBy({ top: itemRect.bottom - listRect.bottom + margin, behavior: smooth ? 'smooth' : 'auto' });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function formatSessionName(sess) {
|
|
1041
|
+
if (!sess) return '新会话';
|
|
1042
|
+
if (sess.preview && sess.preview.trim()) return sess.preview.trim();
|
|
1043
|
+
const id = sess.id || '';
|
|
1044
|
+
return id ? `会话 ${id.slice(-6)}` : '新会话';
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// 2026-06-15: escapeHtml 已迁到 ui/message-renderer.js
|
|
1048
|
+
const escapeHtml = MR_escapeHtml || ((s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
1049
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
1050
|
+
}[c])));
|
|
1051
|
+
|
|
1052
|
+
function ensureMessageContainer(channelId) {
|
|
1053
|
+
if (!messagesContainers.has(channelId)) {
|
|
1054
|
+
const container = document.createElement('div');
|
|
1055
|
+
container.className = 'channel-messages';
|
|
1056
|
+
container.id = `channel-messages-${channelId}`;
|
|
1057
|
+
container.style.display = 'none';
|
|
1058
|
+
messagesEl.appendChild(container);
|
|
1059
|
+
messagesContainers.set(channelId, container);
|
|
1060
|
+
}
|
|
1061
|
+
return messagesContainers.get(channelId);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function showChannelView(channelId) {
|
|
1065
|
+
// Hide all channel message containers (不要 innerHTML='' 销毁, 保留以便快速切换)
|
|
1066
|
+
messagesContainers.forEach((container, cid) => {
|
|
1067
|
+
container.style.display = 'none';
|
|
1068
|
+
});
|
|
1069
|
+
// Show the selected channel's container
|
|
1070
|
+
const container = messagesContainers.get(channelId);
|
|
1071
|
+
if (container) {
|
|
1072
|
+
container.style.display = 'block';
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function selectChannel(channelId, targetSessionId = null) {
|
|
1077
|
+
console.log('[selectChannel] 开始切换到:', channelId, 'targetSession:', targetSessionId);
|
|
1078
|
+
|
|
1079
|
+
// 立即更新当前频道 ID
|
|
1080
|
+
currentChannelId = channelId;
|
|
1081
|
+
reconnectAttempts.set(channelId, 0);
|
|
1082
|
+
|
|
1083
|
+
// v3: 盾牌弹窗打开时, 切 channel 要刷列表 (tab 标题 + 已绑/未绑 分组)
|
|
1084
|
+
if (typeof judgmentsModal !== 'undefined' && judgmentsModal && judgmentsModal.classList.contains('active')) {
|
|
1085
|
+
if (typeof lastJudgmentsCache !== 'undefined') renderJudgments(lastJudgmentsCache);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// 找到当前频道和 session
|
|
1089
|
+
const channel = channels.find(c => c.id === channelId);
|
|
1090
|
+
if (channel) {
|
|
1091
|
+
if (channelNameEl) channelNameEl.textContent = channel.name;
|
|
1092
|
+
currentSessionId = targetSessionId || channel.currentSessionId || 'default';
|
|
1093
|
+
if (targetSessionId) {
|
|
1094
|
+
channel.currentSessionId = targetSessionId;
|
|
1095
|
+
}
|
|
1096
|
+
// 自动展开当前智能体的会话列表,让用户能切换会话
|
|
1097
|
+
expandedAgents.add(channelId);
|
|
1098
|
+
console.log('[selectChannel] 频道:', channel.name, 'session:', currentSessionId);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// 2026-06-11 提速: 切 channel 时 sidebar 渲染降级 — 只更新 active 样式, 不重渲整列表
|
|
1102
|
+
// renderChannels() 仍然要调 (current session label 等可能变了), 但加一层判断: 如果只是切 channel (没增删), 走 patch 路径
|
|
1103
|
+
const t0 = performance.now();
|
|
1104
|
+
renderChannelsLite(channelId, currentSessionId);
|
|
1105
|
+
console.log(`[selectChannel] renderChannelsLite 耗时 ${(performance.now() - t0).toFixed(1)}ms`);
|
|
1106
|
+
|
|
1107
|
+
// 确保该频道有消息容器
|
|
1108
|
+
const container = ensureMessageContainer(channelId);
|
|
1109
|
+
|
|
1110
|
+
// 切换到该频道的视图
|
|
1111
|
+
showChannelView(channelId);
|
|
1112
|
+
|
|
1113
|
+
// 如果还没有 SSE 连接,建立连接
|
|
1114
|
+
if (!eventSources.has(channelId)) {
|
|
1115
|
+
console.log('[selectChannel] 建立 SSE 连接');
|
|
1116
|
+
connect(channelId);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// 直接从 server 拉 session 消息 (container 跨 session 共享, 先清空再加载)
|
|
1120
|
+
container.innerHTML = '';
|
|
1121
|
+
try {
|
|
1122
|
+
const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(currentSessionId)}`);
|
|
1123
|
+
const session = await res.json();
|
|
1124
|
+
const msgs = session.messages || [];
|
|
1125
|
+
if (msgs.length > 0) {
|
|
1126
|
+
// 2026-06-11 提速: 用 DocumentFragment 一次性 append 避免多次 reflow
|
|
1127
|
+
const frag = document.createDocumentFragment();
|
|
1128
|
+
const tmpContainer = document.createElement('div');
|
|
1129
|
+
tmpContainer.style.display = 'none';
|
|
1130
|
+
for (const msg of msgs) {
|
|
1131
|
+
addMessage(msg.content, msg.type, false, tmpContainer, msg.metadata?.usedJudgmentIds || []);
|
|
1132
|
+
}
|
|
1133
|
+
while (tmpContainer.firstChild) {
|
|
1134
|
+
frag.appendChild(tmpContainer.firstChild);
|
|
1135
|
+
}
|
|
1136
|
+
container.appendChild(frag);
|
|
1137
|
+
} else {
|
|
1138
|
+
addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
|
|
1139
|
+
}
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.error('[selectChannel] 加载 session 失败:', err);
|
|
1142
|
+
addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function loadSession(channelId, sessionId = null) {
|
|
1147
|
+
const container = messagesContainers.get(channelId);
|
|
1148
|
+
if (!container) return;
|
|
1149
|
+
const targetSessionId = sessionId || currentSessionId || 'default';
|
|
1150
|
+
try {
|
|
1151
|
+
const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(targetSessionId)}`);
|
|
1152
|
+
const session = await res.json();
|
|
1153
|
+
container.innerHTML = '';
|
|
1154
|
+
if (session.messages && session.messages.length > 0) {
|
|
1155
|
+
session.messages.forEach(msg => {
|
|
1156
|
+
addMessage(msg.content, msg.type, false, container, msg.metadata?.usedJudgmentIds || []);
|
|
1157
|
+
});
|
|
1158
|
+
} else {
|
|
1159
|
+
addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
|
|
1160
|
+
}
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
console.error('Failed to load session:', err);
|
|
1163
|
+
container.innerHTML = '';
|
|
1164
|
+
addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// 2026-06-15: addMessage 委托给 ui/message-renderer.js, 客户端代码原位调用同名函数, 不感知拆分
|
|
1169
|
+
function addMessage(content, type, save = true, container, usedJudgmentIds = []) {
|
|
1170
|
+
return MR_addMessage(content, type, save, container, usedJudgmentIds, getRendererCtx());
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// 2026-06-15: stream / done 事件也委托给 ui/message-renderer.js.
|
|
1174
|
+
// 保留同名 wrapper, 让 SSE 分发代码不感知模块拆分。
|
|
1175
|
+
function handleStreamTokenEvent(data) {
|
|
1176
|
+
return MR_handleStreamTokenEvent(data, getRendererCtx());
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function finalizeTimelineAsMessage() {
|
|
1180
|
+
return MR_finalizeTimelineAsMessage(getRendererCtx());
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function handleStepEvent(data) {
|
|
1184
|
+
return MR_handleStepEvent(data, getRendererCtx());
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
// ============================================================
|
|
1189
|
+
// 2026-06-15: 旧 timeline panel + 3 状态机 + workflowDisplayEl 全部删除
|
|
1190
|
+
// 新组件: step-timeline (气泡内 4 状态步骤条, 见 src/web/ui/step-timeline.ts)
|
|
1191
|
+
// ============================================================
|
|
1192
|
+
|
|
1193
|
+
let lastUsedJudgmentIds = []; // 用于 finalizeTimelineAsMessage 给 addMessage 第 5 参
|
|
1194
|
+
|
|
1195
|
+
// ============================================================================
|
|
1196
|
+
// 2026-06-15: self_improve SSE handler — 之前 server 推 self_improve_triggered
|
|
1197
|
+
// / self_improve_result 但 client 完全没注册, 消息就丢了 (Bug 2).
|
|
1198
|
+
// 修: 即时 render 卡片到 messages 容器内 (Bug 4), 用主题色 var(--accent/--success/--warning) (Bug 3).
|
|
1199
|
+
// 失败重试: 卡片的 retry 按钮 → POST /self-improve 再触发.
|
|
1200
|
+
// ============================================================================
|
|
1201
|
+
let selfImproveCardSeq = 0;
|
|
1202
|
+
function getMessagesContainerForCurrent() {
|
|
1203
|
+
if (currentChannelId && messagesContainers.get(currentChannelId)) {
|
|
1204
|
+
return messagesContainers.get(currentChannelId);
|
|
1205
|
+
}
|
|
1206
|
+
return messagesEl; // fallback: 主 messages
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function makeSelfImproveCard(data) {
|
|
1210
|
+
const seq = ++selfImproveCardSeq;
|
|
1211
|
+
const id = `self-improve-card-${seq}`;
|
|
1212
|
+
// 用主题色 var(--accent/--success/--warning) 不写死 (Bug 3)
|
|
1213
|
+
const card = document.createElement('div');
|
|
1214
|
+
card.className = 'self-improve-card';
|
|
1215
|
+
card.id = id;
|
|
1216
|
+
card.dataset.seq = String(seq);
|
|
1217
|
+
card.style.cssText = 'margin:8px 12px;padding:10px 12px;border:1px solid var(--accent);border-left:3px solid var(--accent);border-radius:6px;background:var(--bg-hover);color:var(--text);font-size:12px;line-height:1.5;';
|
|
1218
|
+
card.innerHTML = `
|
|
1219
|
+
<div class="self-improve-header" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;">
|
|
1220
|
+
<span class="self-improve-caret" style="font-size:10px;color:var(--text-muted);">▾</span>
|
|
1221
|
+
<span class="self-improve-title" style="flex:1;font-weight:600;color:var(--accent);"></span>
|
|
1222
|
+
<span class="self-improve-status" style="font-size:10px;color:var(--text-muted);"></span>
|
|
1223
|
+
</div>
|
|
1224
|
+
<div class="self-improve-body" style="margin-top:6px;display:none;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;"></div>
|
|
1225
|
+
`;
|
|
1226
|
+
// 折叠 (Bug 4: 卡片内自带折叠, 跟对话消息同一容器)
|
|
1227
|
+
const header = card.querySelector('.self-improve-header');
|
|
1228
|
+
const body = card.querySelector('.self-improve-body');
|
|
1229
|
+
const caret = card.querySelector('.self-improve-caret');
|
|
1230
|
+
header.addEventListener('click', () => {
|
|
1231
|
+
const collapsed = body.style.display === 'none';
|
|
1232
|
+
body.style.display = collapsed ? 'block' : 'none';
|
|
1233
|
+
caret.style.transform = collapsed ? 'rotate(0deg)' : 'rotate(-90deg)';
|
|
1234
|
+
});
|
|
1235
|
+
return card;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function handleSelfImproveTriggered(data) {
|
|
1239
|
+
const container = getMessagesContainerForCurrent();
|
|
1240
|
+
if (!container) return;
|
|
1241
|
+
const card = makeSelfImproveCard(data);
|
|
1242
|
+
card.querySelector('.self-improve-title').textContent =
|
|
1243
|
+
`🧠 自迭代触发 · ${data.eventKind || 'unknown'}`;
|
|
1244
|
+
card.querySelector('.self-improve-status').textContent =
|
|
1245
|
+
new Date(data.ts || Date.now()).toLocaleTimeString();
|
|
1246
|
+
const body = card.querySelector('.self-improve-body');
|
|
1247
|
+
body.textContent = JSON.stringify({
|
|
1248
|
+
eventKind: data.eventKind,
|
|
1249
|
+
details: data.details,
|
|
1250
|
+
goal: data.goal,
|
|
1251
|
+
}, null, 2);
|
|
1252
|
+
container.appendChild(card);
|
|
1253
|
+
card.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function handleSelfImproveResult(data) {
|
|
1257
|
+
const container = getMessagesContainerForCurrent();
|
|
1258
|
+
if (!container) return;
|
|
1259
|
+
const card = makeSelfImproveCard(data);
|
|
1260
|
+
const ok = !!data.success;
|
|
1261
|
+
card.style.borderColor = ok ? 'var(--success)' : 'var(--warning)';
|
|
1262
|
+
card.style.borderLeftColor = ok ? 'var(--success)' : 'var(--warning)';
|
|
1263
|
+
card.querySelector('.self-improve-title').textContent =
|
|
1264
|
+
`${ok ? '✅' : '⚠️'} 自迭代完成 · ${ok ? '成功' : '失败'}`;
|
|
1265
|
+
card.querySelector('.self-improve-status').textContent =
|
|
1266
|
+
new Date(data.ts || Date.now()).toLocaleTimeString();
|
|
1267
|
+
const body = card.querySelector('.self-improve-body');
|
|
1268
|
+
body.textContent = (ok ? (data.output || '') : (data.error || '')) || '(no output)';
|
|
1269
|
+
// 失败 → 加 retry 按钮 (Bug 2 重试机制)
|
|
1270
|
+
if (!ok) {
|
|
1271
|
+
const btn = document.createElement('button');
|
|
1272
|
+
btn.textContent = '🔁 重试';
|
|
1273
|
+
btn.style.cssText = 'margin-top:6px;padding:4px 10px;background:var(--accent);color:var(--bg-main);border:none;border-radius:4px;cursor:pointer;font-size:11px;';
|
|
1274
|
+
btn.onclick = async () => {
|
|
1275
|
+
btn.disabled = true;
|
|
1276
|
+
btn.textContent = '⏳ 重试中...';
|
|
1277
|
+
try {
|
|
1278
|
+
const r = await fetch('/self-improve', {
|
|
1279
|
+
method: 'POST',
|
|
1280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1281
|
+
body: JSON.stringify({ reason: 'user retry from UI card' }),
|
|
1282
|
+
});
|
|
1283
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
1284
|
+
btn.textContent = '✓ 已重试';
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
btn.disabled = false;
|
|
1287
|
+
btn.textContent = '🔁 重试 (失败)';
|
|
1288
|
+
body.textContent += `\n[retry error] ${(err && err.message) || err}`;
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
body.appendChild(btn);
|
|
1292
|
+
}
|
|
1293
|
+
container.appendChild(card);
|
|
1294
|
+
card.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
// ============================================================
|
|
1300
|
+
// 2026-06-15: 发送按钮 ↔ 终止按钮 状态机
|
|
1301
|
+
// idle: sendMessage 入口, 飞机图标
|
|
1302
|
+
// abort: 流式期间, ▢ 图标 + 红边框, click 调 abortCurrentRun
|
|
1303
|
+
// aborting: 已点终止, 等待 server 反馈, 半透明
|
|
1304
|
+
// done/error 事件触发时回到 idle
|
|
1305
|
+
// ============================================================
|
|
1306
|
+
function setSendMode(mode) {
|
|
1307
|
+
if (!sendBtn) return;
|
|
1308
|
+
sendBtn.dataset.state = mode;
|
|
1309
|
+
sendBtn.title = mode === 'abort' ? '⏹ 终止当前生成 (Esc)' : '发送 (Enter)';
|
|
1310
|
+
// 切 svg 显示
|
|
1311
|
+
const sendIcon = sendBtn.querySelector('[data-mode="send"]');
|
|
1312
|
+
const abortIcon = sendBtn.querySelector('[data-mode="abort"]');
|
|
1313
|
+
if (sendIcon) sendIcon.style.display = mode === 'idle' ? '' : 'none';
|
|
1314
|
+
if (abortIcon) abortIcon.style.display = mode === 'idle' ? 'none' : '';
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function abortCurrentRun() {
|
|
1318
|
+
if (sendBtn && sendBtn.dataset.state === 'aborting') return; // 防双击
|
|
1319
|
+
setSendMode('aborting');
|
|
1320
|
+
try {
|
|
1321
|
+
const r = await fetch('/api/chat/abort', {
|
|
1322
|
+
method: 'POST',
|
|
1323
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1324
|
+
body: JSON.stringify({ channelId: currentChannelId || '' }),
|
|
1325
|
+
});
|
|
1326
|
+
const j = await r.json().catch(() => ({}));
|
|
1327
|
+
if (j.aborted) {
|
|
1328
|
+
if (typeof showSimpleToast === 'function') showSimpleToast('✓ 已终止');
|
|
1329
|
+
} else {
|
|
1330
|
+
if (typeof showSimpleToast === 'function') showSimpleToast('○ 当前无运行中');
|
|
1331
|
+
}
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
console.error('[abort] error:', err);
|
|
1334
|
+
if (typeof showSimpleToast === 'function') showSimpleToast('✗ 终止失败');
|
|
1335
|
+
}
|
|
1336
|
+
// 1.5s 后回到 idle (server 推 done/error 时会立即再切, 这里只是兜底)
|
|
1337
|
+
setTimeout(() => {
|
|
1338
|
+
if (sendBtn && sendBtn.dataset.state === 'aborting') setSendMode('idle');
|
|
1339
|
+
}, 1500);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
function connect(channelId) {
|
|
1345
|
+
const targetChannelId = channelId || currentChannelId;
|
|
1346
|
+
if (!targetChannelId) return;
|
|
1347
|
+
|
|
1348
|
+
// 清除该频道的重连定时器
|
|
1349
|
+
if (reconnectTimers.has(targetChannelId)) {
|
|
1350
|
+
clearTimeout(reconnectTimers.get(targetChannelId));
|
|
1351
|
+
reconnectTimers.delete(targetChannelId);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// 清除该频道的心跳定时器 (防止多次调用 connect 导致 setInterval 累积)
|
|
1355
|
+
if (heartbeatTimers.has(targetChannelId)) {
|
|
1356
|
+
clearInterval(heartbeatTimers.get(targetChannelId));
|
|
1357
|
+
heartbeatTimers.delete(targetChannelId);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// 关闭该频道的旧连接
|
|
1361
|
+
if (eventSources.has(targetChannelId)) {
|
|
1362
|
+
eventSources.get(targetChannelId).close();
|
|
1363
|
+
eventSources.delete(targetChannelId);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const sseUrl = `/events?channelId=${encodeURIComponent(targetChannelId)}`;
|
|
1367
|
+
console.log('[connect] 创建 SSE 连接:', sseUrl);
|
|
1368
|
+
|
|
1369
|
+
const eventSource = new EventSource(sseUrl);
|
|
1370
|
+
eventSources.set(targetChannelId, eventSource);
|
|
1371
|
+
|
|
1372
|
+
if (!reconnectAttempts.has(targetChannelId)) {
|
|
1373
|
+
reconnectAttempts.set(targetChannelId, 0);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
eventSource.onopen = () => {
|
|
1377
|
+
console.log('[SSE] 已连接 channelId:', targetChannelId);
|
|
1378
|
+
reconnectAttempts.set(targetChannelId, 0);
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// 心跳超时: 2026-06-16 收紧到 30s (配合 server 端 30s ping).
|
|
1382
|
+
// 之前 60s 在 mobile/sleep 唤醒后误判; 后端 ping 已改为 data: {"type":"ping"}, onmessage 会重置 lastEventTime.
|
|
1383
|
+
// 覆盖网络半开 / 浏览器没触发 onerror 的情况
|
|
1384
|
+
let lastEventTime = Date.now();
|
|
1385
|
+
const heartbeatTimer = setInterval(() => {
|
|
1386
|
+
if (!eventSources.has(targetChannelId)) {
|
|
1387
|
+
clearInterval(heartbeatTimer);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (Date.now() - lastEventTime > 30000) {
|
|
1391
|
+
console.warn('[SSE] 30s 无数据, 强制重建连接:', targetChannelId);
|
|
1392
|
+
clearInterval(heartbeatTimer);
|
|
1393
|
+
try { eventSource.close(); } catch {}
|
|
1394
|
+
eventSources.delete(targetChannelId);
|
|
1395
|
+
// 退避重连 (有上限)
|
|
1396
|
+
const attempts = (reconnectAttempts.get(targetChannelId) || 0) + 1;
|
|
1397
|
+
reconnectAttempts.set(targetChannelId, attempts);
|
|
1398
|
+
const delay = Math.min(1000 * Math.pow(2, attempts - 1), 15000);
|
|
1399
|
+
const timer = setTimeout(() => connect(targetChannelId), delay);
|
|
1400
|
+
reconnectTimers.set(targetChannelId, timer);
|
|
1401
|
+
}
|
|
1402
|
+
}, 10000);
|
|
1403
|
+
|
|
1404
|
+
// onerror: 不要手动 close — 浏览器 EventSource 会自动重连
|
|
1405
|
+
// 我们只需在 readyState 永久 CLOSED 时 (罕见) 才介入
|
|
1406
|
+
eventSource.onerror = () => {
|
|
1407
|
+
console.warn('[SSE] 错误, 浏览器自动重连中:', targetChannelId, 'readyState=', eventSource.readyState);
|
|
1408
|
+
if (eventSource.readyState === EventSource.CLOSED) {
|
|
1409
|
+
// 浏览器放弃重连, 我们接手
|
|
1410
|
+
clearInterval(heartbeatTimer);
|
|
1411
|
+
eventSources.delete(targetChannelId);
|
|
1412
|
+
const attempts = (reconnectAttempts.get(targetChannelId) || 0) + 1;
|
|
1413
|
+
reconnectAttempts.set(targetChannelId, attempts);
|
|
1414
|
+
const delay = Math.min(1000 * Math.pow(2, attempts - 1), 15000);
|
|
1415
|
+
const timer = setTimeout(() => connect(targetChannelId), delay);
|
|
1416
|
+
reconnectTimers.set(targetChannelId, timer);
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
eventSource.onmessage = (e) => {
|
|
1421
|
+
lastEventTime = Date.now();
|
|
1422
|
+
try {
|
|
1423
|
+
const data = JSON.parse(e.data);
|
|
1424
|
+
// 2026-06-16: server ping 走 data: {"type":"ping"}, onmessage 收到后重置 lastEventTime 后立即返回.
|
|
1425
|
+
// 不走通用路由 — 流式元素、流式容器、step-timeline 都不动, 避免 reset 误清.
|
|
1426
|
+
if (data && data.type === 'ping') {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const msgChannelId = data.channelId || targetChannelId;
|
|
1430
|
+
console.log('[SSE] 收到消息:', data.type, 'channelId:', msgChannelId);
|
|
1431
|
+
|
|
1432
|
+
// 路由消息到正确的频道
|
|
1433
|
+
// 只有 envelope.channelId 存在且与目标不同时才丢弃 (空/undefined 视为广播给自己)
|
|
1434
|
+
if (msgChannelId && msgChannelId !== targetChannelId) {
|
|
1435
|
+
console.log('[SSE] 忽略非目标频道消息');
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// 使用正确的消息容器
|
|
1440
|
+
const container = messagesContainers.get(msgChannelId) || messagesEl;
|
|
1441
|
+
|
|
1442
|
+
if (data.type === 'user') {
|
|
1443
|
+
// 2026-06-11 修: 不再走 showUserCommand (› 装饰条) 路径, 因为:
|
|
1444
|
+
// 1. sendMessage 已经在客户端 addMessage(text, 'user', true) 渲染成 .bubble-user 气泡
|
|
1445
|
+
// 2. SSE 推 user 又调 showUserCommand → 同时出现气泡 + 装饰条 (双重显示)
|
|
1446
|
+
// 3. 第二次切 channel 时, showUserCommand 会 remove 已有 .message-user 元素 (line 1477),
|
|
1447
|
+
// 但 .bubble-user class 不是 .message-user → 残留装饰条, 表现"模式变了"
|
|
1448
|
+
// 改法: SSE 收到 user 后, 跳过显示 (lastUserCommand 已经匹配, addMessage(save=true) 内部去重)
|
|
1449
|
+
// 但要确保 lastUserCommand 已经设过 — sendMessage 调 addMessage(true) 时会设
|
|
1450
|
+
// 远端 user (source === 'remote') 不会被 sendMessage 渲染, 需要走 addMessage 一次
|
|
1451
|
+
if (data.source === 'remote') {
|
|
1452
|
+
// 远端访客 (B 通过 P2P 发来的), sendMessage 没渲染它, 这里补上气泡
|
|
1453
|
+
addMessage(data.content, 'user', true, container);
|
|
1454
|
+
}
|
|
1455
|
+
// 本地 user 已经由 sendMessage 渲染 + 去重, 这里不再显示
|
|
1456
|
+
} else if (data.type === 'ai') {
|
|
1457
|
+
addMessage(data.content, 'ai', true, container, lastUsedJudgmentIds);
|
|
1458
|
+
} else if (data.type === 'stream') {
|
|
1459
|
+
if (data.streamType === 'thinking' || data.streamType === 'token') {
|
|
1460
|
+
handleStreamTokenEvent(data);
|
|
1461
|
+
}
|
|
1462
|
+
} else if (data.type === 'regenerating') {
|
|
1463
|
+
// 删旧的最后一条 AI 消息, 准备重新生成
|
|
1464
|
+
const messages = container.querySelectorAll('.message-ai');
|
|
1465
|
+
if (messages.length > 0) {
|
|
1466
|
+
const lastAiMsg = messages[messages.length - 1];
|
|
1467
|
+
lastAiMsg.remove();
|
|
1468
|
+
}
|
|
1469
|
+
// 重新进入 abort 模式 (新一次生成开始)
|
|
1470
|
+
setSendMode('abort');
|
|
1471
|
+
} else if (data.type === 'status') {
|
|
1472
|
+
// 2026-06-16: status 事件渲染到 system 级 status bar (tool=loop/compactor/recovery)
|
|
1473
|
+
// 旧 step_timeline 状态机走 step_* 事件, 不受影响
|
|
1474
|
+
renderLoopStatusBar(data.tool, data.content);
|
|
1475
|
+
} else if (data.type === 'step_start' || data.type === 'step_done' || data.type === 'step_error') {
|
|
1476
|
+
// 2026-06-15: 步骤状态机事件 — 推给 message-renderer 的 step-timeline
|
|
1477
|
+
handleStepEvent(data);
|
|
1478
|
+
} else if (data.type === 'done') {
|
|
1479
|
+
// AI 回复生成完, 从流式元素搬 token 文本到正式消息
|
|
1480
|
+
finalizeTimelineAsMessage();
|
|
1481
|
+
// 2026-06-16: 隐藏循环进度 status bar
|
|
1482
|
+
hideLoopStatusBar();
|
|
1483
|
+
// 2026-06-15: 切回 idle 模式 (用户可发下一条)
|
|
1484
|
+
setSendMode('idle');
|
|
1485
|
+
} else if (data.type === 'renamed') {
|
|
1486
|
+
const channel = channels.find(c => c.id === data.channelId);
|
|
1487
|
+
if (channel) {
|
|
1488
|
+
channel.name = data.newName;
|
|
1489
|
+
renderChannels();
|
|
1490
|
+
if (currentChannelId === data.channelId && channelNameEl) {
|
|
1491
|
+
channelNameEl.textContent = data.newName;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
} else if (data.type === 'error') {
|
|
1495
|
+
// 2026-06-15: 错误不再 addMessage 成 AI 气泡 — server 推 error 时, stream 的
|
|
1496
|
+
// 部分 token 已经被流式元素收下, finalizeTimelineAsMessage 也会被 done 触发
|
|
1497
|
+
// 再渲染一次, 跟错误文本混在一起就是用户看到的"多了一个气泡 + 半个 system context"
|
|
1498
|
+
// 改: error 走 toast 提示, 留给流式 finalize 处理残余
|
|
1499
|
+
if (typeof showSimpleToast === 'function') {
|
|
1500
|
+
const msg = String(data.content || '').slice(0, 200);
|
|
1501
|
+
showSimpleToast('⚠️ ' + msg);
|
|
1502
|
+
} else {
|
|
1503
|
+
console.error('[SSE] error:', data.content);
|
|
1504
|
+
}
|
|
1505
|
+
// 2026-06-16: error 触发时也隐藏 status bar (任务中止)
|
|
1506
|
+
hideLoopStatusBar();
|
|
1507
|
+
setSendMode('idle');
|
|
1508
|
+
} else if (data.type === 'task_status' || data.type === 'workflow_step' || data.type === 'workflow_loop') {
|
|
1509
|
+
// 2026-06-16: 旧工作流事件, 不再单独画 — server 仍可推, 客户端仅 log
|
|
1510
|
+
// 特别: server 端 token/thinking 流会同时推一份 workflow_step (title="AI 思考"),
|
|
1511
|
+
// 跟前端 message-renderer 的 think 折叠块重复, 这里统一丢弃.
|
|
1512
|
+
// 开发者模式想看原始事件, 打开 console.log 过滤 "[SSE] workflow"
|
|
1513
|
+
if (data.type === 'workflow_step' && (data.step === 'AI 思考' || data.step === '开始思考')) {
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
console.log('[SSE] workflow (deprecated for UI):', data.type, data.content?.slice(0, 80));
|
|
1517
|
+
} else if (data.type === 'phase') {
|
|
1518
|
+
// phase 事件 (注入门 / D 触发) 仍可推, 客户端仅 log
|
|
1519
|
+
console.log('[SSE] phase (no UI):', data.phase);
|
|
1520
|
+
} else if (data.type === 'queue_update') {
|
|
1521
|
+
// 队列事件无 UI 入口, 仅 log
|
|
1522
|
+
console.log('[SSE] queue_update (no UI):', data.queueLength);
|
|
1523
|
+
} else if (data.type === 'used_judgments' && Array.isArray(data.usedIds)) {
|
|
1524
|
+
// 注入门回传: 保存 usedIds, finalizeTimelineAsMessage 时给 addMessage
|
|
1525
|
+
lastUsedJudgmentIds = data.usedIds;
|
|
1526
|
+
} else if (data.type === 'self_improve_triggered') {
|
|
1527
|
+
// 2026-06-15: 即时 render (不再丢消息, 修 Bug 2)
|
|
1528
|
+
handleSelfImproveTriggered(data);
|
|
1529
|
+
} else if (data.type === 'self_improve_result') {
|
|
1530
|
+
// 2026-06-15: 即时 render + 失败 retry 按钮 (修 Bug 2/3)
|
|
1531
|
+
handleSelfImproveResult(data);
|
|
1532
|
+
}
|
|
1533
|
+
} catch (parseErr) {
|
|
1534
|
+
console.error('[SSE] 解析错误', parseErr);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async function sendMessage() {
|
|
1540
|
+
const text = input.value.trim();
|
|
1541
|
+
if (!text) return;
|
|
1542
|
+
|
|
1543
|
+
// 2026-06-11: 立即把用户消息渲染成气泡上屏 (走 .bubble-user, 跟本地聊天一致)
|
|
1544
|
+
// 之前只靠 SSE `type: user` 回调显示, 但 addMessage(user) 默认 save=true 走去重, 容易跟 SSE 二次显示冲突/丢失
|
|
1545
|
+
// 现在: sendMessage 自己上屏, SSE `user` 回调来时因为 lastUserCommand 已匹配, 自动跳过 → 不重复
|
|
1546
|
+
const container = messagesContainers.get(currentChannelId) || messagesEl;
|
|
1547
|
+
addMessage(text, 'user', true, container);
|
|
1548
|
+
// 滚动到底
|
|
1549
|
+
if (container) container.scrollTop = container.scrollHeight;
|
|
1550
|
+
|
|
1551
|
+
input.value = '';
|
|
1552
|
+
// 2026-06-15: 切到 abort 模式, 用户可点按钮或按 Esc 终止
|
|
1553
|
+
setSendMode('abort');
|
|
1554
|
+
|
|
1555
|
+
// 立即把用户消息落盘, 避免切走再切回时丢失
|
|
1556
|
+
persistLastMessageToServer('user', text);
|
|
1557
|
+
|
|
1558
|
+
// 获取当前频道的 DID
|
|
1559
|
+
const channel = channels.find(c => c.id === currentChannelId);
|
|
1560
|
+
const channelDid = channel?.did || '';
|
|
1561
|
+
console.log('[发送消息] 频道 DID:', channelDid);
|
|
1562
|
+
|
|
1563
|
+
try {
|
|
1564
|
+
const res = await fetch('/message', {
|
|
1565
|
+
method: 'POST',
|
|
1566
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1567
|
+
body: JSON.stringify({
|
|
1568
|
+
text,
|
|
1569
|
+
channelId: currentChannelId,
|
|
1570
|
+
channelDid
|
|
1571
|
+
})
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
if (!res.ok) {
|
|
1575
|
+
addMessage('发送失败', 'ai');
|
|
1576
|
+
setSendMode('idle');
|
|
1577
|
+
}
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
addMessage('连接错误', 'ai');
|
|
1580
|
+
console.error('Send error', err);
|
|
1581
|
+
setSendMode('idle');
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// 主动落盘: 把当前 channelId/sessionId 最后一条消息 PATCH 到 server
|
|
1586
|
+
// fire-and-forget, 失败只打日志, 不影响 UI
|
|
1587
|
+
function persistLastMessageToServer(type, content) {
|
|
1588
|
+
if (!currentChannelId || !currentSessionId) return;
|
|
1589
|
+
fetch(`/sessions/${currentChannelId}/${currentSessionId}`, {
|
|
1590
|
+
method: 'PATCH',
|
|
1591
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1592
|
+
body: JSON.stringify({
|
|
1593
|
+
message: { type, content, timestamp: new Date().toISOString() }
|
|
1594
|
+
})
|
|
1595
|
+
}).catch(err => {
|
|
1596
|
+
console.warn('[persist] 落盘失败:', err);
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// 2026-06-15: sendBtn click 分发 — 看 data-state 决定 send 还是 abort
|
|
1601
|
+
sendBtn.addEventListener('click', () => {
|
|
1602
|
+
if (sendBtn.dataset.state === 'abort' || sendBtn.dataset.state === 'aborting') {
|
|
1603
|
+
abortCurrentRun();
|
|
1604
|
+
} else {
|
|
1605
|
+
sendMessage();
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
input.addEventListener('keydown', (e) => {
|
|
1609
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1610
|
+
e.preventDefault();
|
|
1611
|
+
if (sendBtn.dataset.state === 'abort' || sendBtn.dataset.state === 'aborting') {
|
|
1612
|
+
// 流式期间按 Enter 也走终止 (跟按钮一致)
|
|
1613
|
+
abortCurrentRun();
|
|
1614
|
+
} else {
|
|
1615
|
+
sendMessage();
|
|
1616
|
+
}
|
|
1617
|
+
} else if (e.key === 'Escape' && (sendBtn.dataset.state === 'abort' || sendBtn.dataset.state === 'aborting')) {
|
|
1618
|
+
// 2026-06-15: Esc 终止 — Claude Code 风格
|
|
1619
|
+
e.preventDefault();
|
|
1620
|
+
abortCurrentRun();
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
// ============ v3 新增: @-mention 单选自动补全 (主聊天框 #input) ============
|
|
1625
|
+
let mentionChannels = []; // { id, name, source: 'local'|'remote', ownerPublicKey? }
|
|
1626
|
+
let mentionDropdownEl = null;
|
|
1627
|
+
let mentionHighlightIdx = -1;
|
|
1628
|
+
let mentionQuery = null;
|
|
1629
|
+
let mentionAnchor = -1; // @ 字符的绝对位置 (固定, 直到 dropdown 关闭)
|
|
1630
|
+
let mentionBlockEnd = -1; // 插入区块的终点 (单选模式下 = anchor + 1 + query)
|
|
1631
|
+
let mentionDocMousedownBound = false; // 防止重复注册 document mousedown
|
|
1632
|
+
|
|
1633
|
+
function ensureMentionDocMousedown() {
|
|
1634
|
+
if (mentionDocMousedownBound) return;
|
|
1635
|
+
mentionDocMousedownBound = true;
|
|
1636
|
+
document.addEventListener('mousedown', (e) => {
|
|
1637
|
+
if (mentionDropdownEl && !mentionDropdownEl.contains(e.target) && e.target !== input) {
|
|
1638
|
+
closeMentionDropdown();
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async function refreshMentionChannels() {
|
|
1644
|
+
try {
|
|
1645
|
+
const res = await fetch('/channels');
|
|
1646
|
+
const local = res.ok ? await res.json() : [];
|
|
1647
|
+
const r2 = await fetch('/api/remote-channels');
|
|
1648
|
+
const remoteData = r2.ok ? await r2.json() : { peers: [] };
|
|
1649
|
+
const remote = [];
|
|
1650
|
+
for (const p of (remoteData.peers || [])) {
|
|
1651
|
+
for (const c of (p.channels || [])) {
|
|
1652
|
+
remote.push({ id: c.id, name: c.name, source: 'remote', ownerPublicKey: p.peerId });
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
mentionChannels = [
|
|
1656
|
+
...(Array.isArray(local) ? local.map(c => ({ id: c.id, name: c.name, source: 'local' })) : []),
|
|
1657
|
+
...remote
|
|
1658
|
+
];
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
console.warn('[mention] 加载渠道列表失败:', err);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function closeMentionDropdown() {
|
|
1665
|
+
if (mentionDropdownEl) { mentionDropdownEl.remove(); mentionDropdownEl = null; }
|
|
1666
|
+
mentionHighlightIdx = -1;
|
|
1667
|
+
mentionQuery = null;
|
|
1668
|
+
mentionAnchor = -1;
|
|
1669
|
+
mentionBlockEnd = -1;
|
|
1670
|
+
// 不重置 mentionDocMousedownBound — 监听器是空操作 (mentionDropdownEl === null) 留着无妨, 避免重复绑
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function getCurrentMentionQuery() {
|
|
1674
|
+
const pos = input.selectionStart || input.value.length;
|
|
1675
|
+
const before = input.value.slice(0, pos);
|
|
1676
|
+
const m = before.match(/@([一-龥A-Za-z0-9_\-]{0,30})$/);
|
|
1677
|
+
return m ? { query: m[1], anchor: pos - m[0].length } : null;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function renderMentionDropdown(items) {
|
|
1681
|
+
if (!mentionDropdownEl) {
|
|
1682
|
+
mentionDropdownEl = document.createElement('div');
|
|
1683
|
+
mentionDropdownEl.id = 'mention-dropdown';
|
|
1684
|
+
mentionDropdownEl.style.cssText = 'position:fixed;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.15);max-height:240px;overflow-y:auto;z-index:10000;font-size:13px;min-width:240px;';
|
|
1685
|
+
document.body.appendChild(mentionDropdownEl);
|
|
1686
|
+
ensureMentionDocMousedown();
|
|
1687
|
+
}
|
|
1688
|
+
// v3 简化: 单选 + 立即填入输入框
|
|
1689
|
+
const headerHtml = `<div style="padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;align-items:center;">
|
|
1690
|
+
<span>💡 点击或回车选中 → 自动填入输入框</span>
|
|
1691
|
+
<span style="color:#9ca3af;">↑↓ 移动</span>
|
|
1692
|
+
</div>`;
|
|
1693
|
+
|
|
1694
|
+
if (items.length === 0) {
|
|
1695
|
+
mentionDropdownEl.innerHTML = headerHtml + '<div style="padding:10px 12px;color:#6b7280;font-size:12px;">没有匹配的渠道</div>';
|
|
1696
|
+
} else {
|
|
1697
|
+
const rows = items.map((c, i) => {
|
|
1698
|
+
const isLocal = c.source === 'local';
|
|
1699
|
+
const tag = isLocal ? '🏠 本地' : '🌐 远端';
|
|
1700
|
+
const owner = !isLocal && c.ownerPublicKey ? ` <span style="color:#9ca3af;font-size:11px;">(${c.ownerPublicKey.substring(0, 8)}…)</span>` : '';
|
|
1701
|
+
// 浅蓝 = 键盘高亮, 白 = 普通
|
|
1702
|
+
const bg = i === mentionHighlightIdx ? '#eff6ff' : '#fff';
|
|
1703
|
+
const borderLeft = i === mentionHighlightIdx ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1704
|
+
return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
|
|
1705
|
+
<span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
|
|
1706
|
+
<span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
|
|
1707
|
+
</div>`;
|
|
1708
|
+
}).join('');
|
|
1709
|
+
mentionDropdownEl.innerHTML = headerHtml + rows;
|
|
1710
|
+
mentionDropdownEl.querySelectorAll('.mention-item').forEach((el) => {
|
|
1711
|
+
const idx = parseInt(el.getAttribute('data-idx'));
|
|
1712
|
+
el.onclick = () => {
|
|
1713
|
+
// 单击 → 立即填入输入框 + 关闭 dropdown
|
|
1714
|
+
applyMention(items[idx]);
|
|
1715
|
+
};
|
|
1716
|
+
// v3 关键修复: mouseenter 只更新高亮, 不重建 dropdown — 否则用户实际点击的 element 被销毁,
|
|
1717
|
+
// click 事件落到新 element, 但实际触发的是新 element 的 onclick (空), 而不是被销毁前那个
|
|
1718
|
+
el.onmouseenter = () => {
|
|
1719
|
+
if (mentionHighlightIdx === idx) return;
|
|
1720
|
+
mentionHighlightIdx = idx;
|
|
1721
|
+
// 只更新背景色 + 左边框, 不重建 innerHTML
|
|
1722
|
+
const itemEls = mentionDropdownEl.querySelectorAll('.mention-item');
|
|
1723
|
+
itemEls.forEach((ie, ii) => {
|
|
1724
|
+
const isHi = ii === idx;
|
|
1725
|
+
ie.style.background = isHi ? '#eff6ff' : '#fff';
|
|
1726
|
+
ie.style.borderLeft = isHi ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1727
|
+
});
|
|
1728
|
+
};
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const rect = input.getBoundingClientRect();
|
|
1732
|
+
mentionDropdownEl.style.left = rect.left + 'px';
|
|
1733
|
+
mentionDropdownEl.style.top = 'auto';
|
|
1734
|
+
mentionDropdownEl.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** v3 单选: 把 @xxx 替换为 @渠道名 + 空格, 关闭 dropdown, 光标放空格后 */
|
|
1738
|
+
function applyMention(channel) {
|
|
1739
|
+
const anchor = mentionAnchor;
|
|
1740
|
+
const blockEnd = mentionBlockEnd >= 0 ? mentionBlockEnd : (anchor + 1 + (mentionQuery || '').length);
|
|
1741
|
+
if (anchor < 0 || anchor > input.value.length || input.value[anchor] !== '@') {
|
|
1742
|
+
closeMentionDropdown();
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
const before = input.value.slice(0, anchor); // 含 @
|
|
1746
|
+
const after = input.value.slice(blockEnd); // query 之后 (可能用户已输入正文)
|
|
1747
|
+
const insert = `@${channel.name} `;
|
|
1748
|
+
input.value = before + insert + after;
|
|
1749
|
+
const newPos = before.length + insert.length;
|
|
1750
|
+
input.focus();
|
|
1751
|
+
input.setSelectionRange(newPos, newPos);
|
|
1752
|
+
closeMentionDropdown();
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function updateMentionDropdown() {
|
|
1756
|
+
// 2026-06-10 修: 数组空时主动刷一次, 不再静默 return
|
|
1757
|
+
// 之前 `if (!mentionChannels.length) return` 导致初始化 0-8s 窗口按 @ 看不到任何 item
|
|
1758
|
+
if (!mentionChannels.length) {
|
|
1759
|
+
refreshMentionChannels().then(() => {
|
|
1760
|
+
// 拉完再重试一次 (异步, 不阻塞当前键击)
|
|
1761
|
+
if (mentionChannels.length) updateMentionDropdown();
|
|
1762
|
+
});
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const m = getCurrentMentionQuery();
|
|
1766
|
+
if (!m) { closeMentionDropdown(); return; }
|
|
1767
|
+
// 只在 dropdown 刚打开时设置 anchor (blockEnd 跟着 insert 走)
|
|
1768
|
+
if (mentionAnchor === -1) {
|
|
1769
|
+
mentionAnchor = m.anchor;
|
|
1770
|
+
mentionBlockEnd = m.anchor + 1 + (m.query || '').length;
|
|
1771
|
+
// dropdown 首次打开 → 强制刷一次, 保证 remote 列表最新
|
|
1772
|
+
refreshMentionChannels();
|
|
1773
|
+
}
|
|
1774
|
+
mentionQuery = m.query;
|
|
1775
|
+
const q = m.query.toLowerCase();
|
|
1776
|
+
const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1777
|
+
mentionHighlightIdx = items.length > 0 ? 0 : -1;
|
|
1778
|
+
renderMentionDropdown(items);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
input.addEventListener('input', () => {
|
|
1782
|
+
updateMentionDropdown();
|
|
1783
|
+
});
|
|
1784
|
+
input.addEventListener('keydown', (e) => {
|
|
1785
|
+
if (!mentionDropdownEl) return;
|
|
1786
|
+
const items = mentionDropdownEl.querySelectorAll('.mention-item');
|
|
1787
|
+
if (e.key === 'ArrowDown') {
|
|
1788
|
+
e.preventDefault();
|
|
1789
|
+
if (items.length === 0) return;
|
|
1790
|
+
mentionHighlightIdx = (mentionHighlightIdx + 1) % items.length;
|
|
1791
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1792
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1793
|
+
renderMentionDropdown(filtered);
|
|
1794
|
+
} else if (e.key === 'ArrowUp') {
|
|
1795
|
+
e.preventDefault();
|
|
1796
|
+
if (items.length === 0) return;
|
|
1797
|
+
mentionHighlightIdx = (mentionHighlightIdx - 1 + items.length) % items.length;
|
|
1798
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1799
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1800
|
+
renderMentionDropdown(filtered);
|
|
1801
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1802
|
+
// 单选: Enter/Tab 立即填入 + 关闭 dropdown
|
|
1803
|
+
if (items.length > 0) {
|
|
1804
|
+
e.preventDefault();
|
|
1805
|
+
e.stopPropagation();
|
|
1806
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1807
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1808
|
+
const cur = filtered[mentionHighlightIdx];
|
|
1809
|
+
if (cur) applyMention(cur);
|
|
1810
|
+
}
|
|
1811
|
+
} else if (e.key === 'Escape') {
|
|
1812
|
+
e.preventDefault();
|
|
1813
|
+
closeMentionDropdown();
|
|
1814
|
+
}
|
|
1815
|
+
}, true); // capture phase, 先于 sendMessage 那个 keydown
|
|
1816
|
+
|
|
1817
|
+
// 初始化
|
|
1818
|
+
refreshMentionChannels();
|
|
1819
|
+
// 定时刷新 (channel 列表可能变化)
|
|
1820
|
+
setInterval(refreshMentionChannels, 5000);
|
|
1821
|
+
// 远端 channel 列表变化时也刷新 (loadRemoteChannels 是 function declaration, 不能重新赋值)
|
|
1822
|
+
// 用 setInterval 兜底: 每 5s 刷一次 (已经有定时器, 这里不重复)
|
|
1823
|
+
// 实际上 refreshMentionChannels() 已经在 setInterval 里跑了
|
|
1824
|
+
|
|
1825
|
+
// v3 新增: 通用版 @-autocomplete (任意 input 元素都能挂, 比如 B 端的 #rcm-input)
|
|
1826
|
+
function setupMentionAutocomplete(inputEl) {
|
|
1827
|
+
if (!inputEl || inputEl.__mentionBound) return;
|
|
1828
|
+
inputEl.__mentionBound = true;
|
|
1829
|
+
let localQuery = null;
|
|
1830
|
+
let localAnchor = -1; // @ 字符的绝对位置 (固定, 直到 dropdown 关闭)
|
|
1831
|
+
let localBlockEnd = -1; // 插入区块的终点
|
|
1832
|
+
let localHighlight = -1;
|
|
1833
|
+
|
|
1834
|
+
function closeLocal() {
|
|
1835
|
+
if (inputEl.__mentionDD) { inputEl.__mentionDD.remove(); inputEl.__mentionDD = null; }
|
|
1836
|
+
localHighlight = -1; localQuery = null; localAnchor = -1; localBlockEnd = -1;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function detectQuery() {
|
|
1840
|
+
const pos = inputEl.selectionStart || inputEl.value.length;
|
|
1841
|
+
const before = inputEl.value.slice(0, pos);
|
|
1842
|
+
const m = before.match(/@([一-龥A-Za-z0-9_\-]{0,30})$/);
|
|
1843
|
+
return m ? { query: m[1], anchor: pos - m[0].length } : null;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// v3 单选: 点击 / Enter 立即填入输入框 + 关闭 dropdown
|
|
1847
|
+
function applyLocal(channel) {
|
|
1848
|
+
const anchor = localAnchor;
|
|
1849
|
+
const blockEnd = localBlockEnd >= 0 ? localBlockEnd : (anchor + 1 + (localQuery || '').length);
|
|
1850
|
+
if (anchor < 0 || anchor > inputEl.value.length || inputEl.value[anchor] !== '@') {
|
|
1851
|
+
closeLocal();
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
const before = inputEl.value.slice(0, anchor); // 含 @
|
|
1855
|
+
const after = inputEl.value.slice(blockEnd);
|
|
1856
|
+
const insert = `@${channel.name} `;
|
|
1857
|
+
inputEl.value = before + insert + after;
|
|
1858
|
+
const newPos = before.length + insert.length;
|
|
1859
|
+
inputEl.focus();
|
|
1860
|
+
inputEl.setSelectionRange(newPos, newPos);
|
|
1861
|
+
closeLocal();
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function renderLocal(items) {
|
|
1865
|
+
if (!inputEl.__mentionDD) {
|
|
1866
|
+
inputEl.__mentionDD = document.createElement('div');
|
|
1867
|
+
inputEl.__mentionDD.style.cssText = 'position:fixed;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.15);max-height:240px;overflow-y:auto;z-index:10001;font-size:13px;min-width:240px;';
|
|
1868
|
+
document.body.appendChild(inputEl.__mentionDD);
|
|
1869
|
+
}
|
|
1870
|
+
const headerHtml = `<div style="padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;align-items:center;">
|
|
1871
|
+
<span>💡 点击或回车选中 → 自动填入输入框</span>
|
|
1872
|
+
<span style="color:#9ca3af;">↑↓ 移动</span>
|
|
1873
|
+
</div>`;
|
|
1874
|
+
if (items.length === 0) {
|
|
1875
|
+
inputEl.__mentionDD.innerHTML = headerHtml + '<div style="padding:10px 12px;color:#6b7280;font-size:12px;">没有匹配的渠道</div>';
|
|
1876
|
+
} else {
|
|
1877
|
+
inputEl.__mentionDD.innerHTML = headerHtml + items.map((c, i) => {
|
|
1878
|
+
const isLocal = c.source === 'local';
|
|
1879
|
+
const tag = isLocal ? '🏠 本地' : '🌐 远端';
|
|
1880
|
+
const owner = !isLocal && c.ownerPublicKey ? ` <span style="color:#9ca3af;font-size:11px;">(${c.ownerPublicKey.substring(0, 8)}…)</span>` : '';
|
|
1881
|
+
const bg = i === localHighlight ? '#eff6ff' : '#fff';
|
|
1882
|
+
const borderLeft = i === localHighlight ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1883
|
+
return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
|
|
1884
|
+
<span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
|
|
1885
|
+
<span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
|
|
1886
|
+
</div>`;
|
|
1887
|
+
}).join('');
|
|
1888
|
+
inputEl.__mentionDD.querySelectorAll('.mention-item').forEach((el) => {
|
|
1889
|
+
const idx = parseInt(el.getAttribute('data-idx'));
|
|
1890
|
+
el.onclick = () => applyLocal(items[idx]);
|
|
1891
|
+
// v3 关键修复: mouseenter 只更新高亮, 不重建 dropdown (同主 input)
|
|
1892
|
+
el.onmouseenter = () => {
|
|
1893
|
+
if (localHighlight === idx) return;
|
|
1894
|
+
localHighlight = idx;
|
|
1895
|
+
const itemEls = inputEl.__mentionDD.querySelectorAll('.mention-item');
|
|
1896
|
+
itemEls.forEach((ie, ii) => {
|
|
1897
|
+
const isHi = ii === idx;
|
|
1898
|
+
ie.style.background = isHi ? '#eff6ff' : '#fff';
|
|
1899
|
+
ie.style.borderLeft = isHi ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1900
|
+
});
|
|
1901
|
+
};
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
const rect = inputEl.getBoundingClientRect();
|
|
1905
|
+
inputEl.__mentionDD.style.left = rect.left + 'px';
|
|
1906
|
+
inputEl.__mentionDD.style.top = 'auto';
|
|
1907
|
+
inputEl.__mentionDD.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function update() {
|
|
1911
|
+
// 2026-06-10 修: 与主输入框同步 — 数组空时主动刷新, 首次打开 dropdown 强制刷新
|
|
1912
|
+
if (!mentionChannels.length) {
|
|
1913
|
+
refreshMentionChannels().then(() => {
|
|
1914
|
+
if (mentionChannels.length) update();
|
|
1915
|
+
});
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
const m = detectQuery();
|
|
1919
|
+
if (!m) { closeLocal(); return; }
|
|
1920
|
+
if (localAnchor === -1) {
|
|
1921
|
+
localAnchor = m.anchor;
|
|
1922
|
+
localBlockEnd = m.anchor + 1 + (m.query || '').length;
|
|
1923
|
+
// dropdown 首次打开 → 强制刷一次保证 remote 最新
|
|
1924
|
+
refreshMentionChannels();
|
|
1925
|
+
}
|
|
1926
|
+
localQuery = m.query;
|
|
1927
|
+
const q = m.query.toLowerCase();
|
|
1928
|
+
const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1929
|
+
localHighlight = items.length > 0 ? 0 : -1;
|
|
1930
|
+
renderLocal(items);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
inputEl.addEventListener('input', update);
|
|
1934
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
1935
|
+
if (!inputEl.__mentionDD) return;
|
|
1936
|
+
const items = inputEl.__mentionDD.querySelectorAll('.mention-item');
|
|
1937
|
+
if (e.key === 'ArrowDown') {
|
|
1938
|
+
e.preventDefault();
|
|
1939
|
+
if (items.length === 0) return;
|
|
1940
|
+
localHighlight = (localHighlight + 1) % items.length;
|
|
1941
|
+
const q = (localQuery || '').toLowerCase();
|
|
1942
|
+
renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
|
|
1943
|
+
} else if (e.key === 'ArrowUp') {
|
|
1944
|
+
e.preventDefault();
|
|
1945
|
+
if (items.length === 0) return;
|
|
1946
|
+
localHighlight = (localHighlight - 1 + items.length) % items.length;
|
|
1947
|
+
const q = (localQuery || '').toLowerCase();
|
|
1948
|
+
renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
|
|
1949
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1950
|
+
if (items.length > 0) {
|
|
1951
|
+
e.preventDefault();
|
|
1952
|
+
e.stopPropagation();
|
|
1953
|
+
const q = (localQuery || '').toLowerCase();
|
|
1954
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1955
|
+
const cur = filtered[localHighlight];
|
|
1956
|
+
if (cur) applyLocal(cur);
|
|
1957
|
+
}
|
|
1958
|
+
} else if (e.key === 'Escape') {
|
|
1959
|
+
e.preventDefault();
|
|
1960
|
+
closeLocal();
|
|
1961
|
+
}
|
|
1962
|
+
}, true);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// 拖拽落点: 把判断库里的判断拖到输入框, 直接作为指令发给 AI (走"代我决定"路径).
|
|
1966
|
+
// 用户拖进来后输入框被预填, 点发送就把这条判断作为指令交给当前 agent.
|
|
1967
|
+
const inputArea = document.querySelector('.input-area');
|
|
1968
|
+
if (input && inputArea) {
|
|
1969
|
+
const onDragOver = (e) => {
|
|
1970
|
+
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('application/x-bolloon-judgment')) {
|
|
1971
|
+
e.preventDefault();
|
|
1972
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
1973
|
+
inputArea.classList.add('drop-target');
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
const onDragLeave = (e) => {
|
|
1977
|
+
if (e.target === inputArea || !inputArea.contains(e.relatedTarget)) {
|
|
1978
|
+
inputArea.classList.remove('drop-target');
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
const onDrop = (e) => {
|
|
1982
|
+
inputArea.classList.remove('drop-target');
|
|
1983
|
+
const raw = e.dataTransfer.getData('application/x-bolloon-judgment');
|
|
1984
|
+
if (!raw) return;
|
|
1985
|
+
e.preventDefault();
|
|
1986
|
+
try {
|
|
1987
|
+
const { id, decision } = JSON.parse(raw);
|
|
1988
|
+
// 预填输入框: 用户可改, 然后发出去 AI 就知道"按这条判断做"
|
|
1989
|
+
const prefix = input.value.trim() ? input.value.trim() + '\n' : '';
|
|
1990
|
+
input.value = `${prefix}按我的判断 #${id?.substring(0, 8) || ''} 执行: ${decision}`;
|
|
1991
|
+
input.focus();
|
|
1992
|
+
// 视觉提示
|
|
1993
|
+
input.style.transition = 'box-shadow 0.3s';
|
|
1994
|
+
input.style.boxShadow = '0 0 0 2px #2563eb';
|
|
1995
|
+
setTimeout(() => { input.style.boxShadow = ''; }, 800);
|
|
1996
|
+
} catch {}
|
|
1997
|
+
};
|
|
1998
|
+
inputArea.addEventListener('dragover', onDragOver);
|
|
1999
|
+
inputArea.addEventListener('dragleave', onDragLeave);
|
|
2000
|
+
inputArea.addEventListener('drop', onDrop);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
if (themeToggle) {
|
|
2004
|
+
themeToggle.addEventListener('click', toggleTheme);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
const apiConfigBtn = document.getElementById('api-config-btn');
|
|
2008
|
+
if (apiConfigBtn) {
|
|
2009
|
+
apiConfigBtn.addEventListener('click', () => {
|
|
2010
|
+
window.location.href = '/api-config';
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// 钱包管理按钮
|
|
2015
|
+
const walletBtn = document.getElementById('wallet-btn');
|
|
2016
|
+
const walletBadge = document.getElementById('wallet-badge');
|
|
2017
|
+
if (walletBtn) {
|
|
2018
|
+
walletBtn.addEventListener('click', openWalletModal);
|
|
2019
|
+
}
|
|
2020
|
+
/** 刷新 header 钱包徽章: 统计已绑定钱包的智能体数 */
|
|
2021
|
+
function refreshWalletBadge() {
|
|
2022
|
+
if (!walletBadge) return;
|
|
2023
|
+
const count = channels.filter(c => c.walletAddress).length;
|
|
2024
|
+
if (count > 0) {
|
|
2025
|
+
walletBadge.textContent = String(count);
|
|
2026
|
+
walletBadge.style.display = '';
|
|
2027
|
+
} else {
|
|
2028
|
+
walletBadge.style.display = 'none';
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (sidebarToggle) {
|
|
2033
|
+
sidebarToggle.addEventListener('click', toggleSidebar);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
if (newChannelBtn) {
|
|
2037
|
+
newChannelBtn.addEventListener('click', () => {
|
|
2038
|
+
createChannel('智能体');
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
if (newChannelInput) {
|
|
2043
|
+
newChannelInput.addEventListener('keydown', (e) => {
|
|
2044
|
+
if (e.key === 'Enter') {
|
|
2045
|
+
e.preventDefault();
|
|
2046
|
+
createChannel(newChannelInput.value);
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
async function checkApiConfig() {
|
|
2052
|
+
try {
|
|
2053
|
+
const res = await fetch('/api/llm-config');
|
|
2054
|
+
const config = await res.json();
|
|
2055
|
+
|
|
2056
|
+
// 检查是否有供应商已配置
|
|
2057
|
+
const hasConfigured = Object.values(config.providers).some(p => p.enabled && p.apiKey);
|
|
2058
|
+
|
|
2059
|
+
if (!hasConfigured) {
|
|
2060
|
+
// 显示 API 配置提示
|
|
2061
|
+
const hint = document.createElement('div');
|
|
2062
|
+
hint.className = 'api-config-hint';
|
|
2063
|
+
hint.innerHTML = `
|
|
2064
|
+
<div class="hint-icon">⚠️</div>
|
|
2065
|
+
<div class="hint-text">
|
|
2066
|
+
<strong>API 未配置</strong><br>
|
|
2067
|
+
请先配置 AI 模型才能开始对话
|
|
2068
|
+
</div>
|
|
2069
|
+
<button class="hint-btn" id="api-config-hint-btn">前往配置</button>
|
|
2070
|
+
`;
|
|
2071
|
+
document.body.appendChild(hint);
|
|
2072
|
+
|
|
2073
|
+
// 绑定事件处理器(避免内联 onclick)
|
|
2074
|
+
const hintBtn = document.getElementById('api-config-hint-btn');
|
|
2075
|
+
if (hintBtn) {
|
|
2076
|
+
hintBtn.addEventListener('click', () => {
|
|
2077
|
+
window.location.href = '/api-config';
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
console.error('Failed to check API config:', err);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
async function init() {
|
|
2087
|
+
const themeData = await loadTheme();
|
|
2088
|
+
currentAgentId = themeData.agentId || `agent_${generateId().substring(0, 8)}`;
|
|
2089
|
+
|
|
2090
|
+
if (!themeData.agentId) {
|
|
2091
|
+
await saveTheme(themeData.theme, currentAgentId);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
await loadChannels();
|
|
2095
|
+
await checkApiConfig();
|
|
2096
|
+
|
|
2097
|
+
if (channels.length > 0) {
|
|
2098
|
+
await selectChannel(channels[0].id);
|
|
2099
|
+
} else {
|
|
2100
|
+
await createChannel('默认会话');
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// P2P Network Modal - React Version
|
|
2105
|
+
const p2pNetworkBtn = document.getElementById('p2p-network-btn');
|
|
2106
|
+
|
|
2107
|
+
// 打开 P2P Modal (使用 React)
|
|
2108
|
+
if (p2pNetworkBtn) {
|
|
2109
|
+
p2pNetworkBtn.addEventListener('click', () => {
|
|
2110
|
+
if (typeof window.showP2PModal === 'function') {
|
|
2111
|
+
window.showP2PModal();
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// ==================== Judgments (v1 极简) ====================
|
|
2117
|
+
const judgmentsModal = document.getElementById('judgments-modal');
|
|
2118
|
+
const judgmentsBtn = document.getElementById('judgments-btn');
|
|
2119
|
+
const judgmentsModalClose = document.getElementById('judgments-modal-close');
|
|
2120
|
+
const judgmentDecision = document.getElementById('judgment-decision');
|
|
2121
|
+
const judgmentReason = document.getElementById('judgment-reason');
|
|
2122
|
+
const judgmentDomain = document.getElementById('judgment-domain');
|
|
2123
|
+
const judgmentStakes = document.getElementById('judgment-stakes');
|
|
2124
|
+
const judgmentSubmitBtn = document.getElementById('judgment-submit-btn');
|
|
2125
|
+
const judgmentError = document.getElementById('judgment-error');
|
|
2126
|
+
const judgmentsList = document.getElementById('judgments-list');
|
|
2127
|
+
const judgmentsBadge = document.getElementById('judgments-badge');
|
|
2128
|
+
|
|
2129
|
+
let judgmentsLoaded = false;
|
|
2130
|
+
|
|
2131
|
+
function showJudgmentsModal() {
|
|
2132
|
+
if (judgmentsModal) judgmentsModal.classList.add('active');
|
|
2133
|
+
if (!judgmentsLoaded) loadJudgments();
|
|
2134
|
+
else renderJudgments(lastJudgmentsCache); // 打开时按当前 channel / tab 重渲
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function switchJudgmentTab(tab) {
|
|
2138
|
+
currentJudgmentTab = tab;
|
|
2139
|
+
// 2026-06-16: 样式切 active 改用 CSS class, 不再写 style.borderBottomColor
|
|
2140
|
+
document.querySelectorAll('.judgment-tab').forEach(btn => {
|
|
2141
|
+
btn.classList.toggle('active', btn.dataset.tab === tab);
|
|
2142
|
+
});
|
|
2143
|
+
renderJudgments(lastJudgmentsCache);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function switchStatusFilter(status) {
|
|
2147
|
+
currentStatusFilter = status;
|
|
2148
|
+
// 2026-06-16: 样式切 active 改用 CSS class, 不再写 style.background
|
|
2149
|
+
document.querySelectorAll('.judgment-status-tab').forEach(btn => {
|
|
2150
|
+
btn.classList.toggle('active', btn.dataset.status === status);
|
|
2151
|
+
});
|
|
2152
|
+
loadJudgments();
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/**
|
|
2156
|
+
* P0.5: 打开判断力 modal 并 filter 到指定 ids
|
|
2157
|
+
* - 调 openJudgmentsModal() + 等 loadJudgments() 完成
|
|
2158
|
+
* - 然后用 ids filter lastJudgmentsCache
|
|
2159
|
+
*/
|
|
2160
|
+
function openJudgmentsModalWithFilter(ids) {
|
|
2161
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
2162
|
+
if (typeof openJudgmentsModal === 'function') {
|
|
2163
|
+
openJudgmentsModal();
|
|
2164
|
+
} else if (judgmentsModal) {
|
|
2165
|
+
judgmentsModal.classList.add('active');
|
|
2166
|
+
}
|
|
2167
|
+
// 等 loadJudgments 完成 (它会 await fetch 然后 renderJudgments)
|
|
2168
|
+
setTimeout(() => {
|
|
2169
|
+
if (typeof lastJudgmentsCache === 'undefined') return;
|
|
2170
|
+
lastJudgmentsCache = (lastJudgmentsCache || []).filter((j) => ids.includes(j.id));
|
|
2171
|
+
if (typeof renderJudgments === 'function') {
|
|
2172
|
+
renderJudgments(lastJudgmentsCache);
|
|
2173
|
+
}
|
|
2174
|
+
}, 150);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function hideJudgmentsModal() {
|
|
2178
|
+
if (judgmentsModal) judgmentsModal.classList.remove('active');
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
let currentJudgmentTab = 'channel'; // 'channel' | 'global'
|
|
2182
|
+
let currentStatusFilter = 'all'; // 'all' | 'active' | 'superseded' | 'violations'
|
|
2183
|
+
let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* v3 重做: 渲染判断力列表 (受 tab + 当前 channel 影响)
|
|
2187
|
+
* tab = 'channel': 拆为"已绑定" + "未绑定"两组, 每条带 + / × 按钮
|
|
2188
|
+
* tab = 'global': 全部 judgment 列表, 无 + / × 按钮
|
|
2189
|
+
* 如果没选 channel, 'channel' tab 自动显示提示 + 全部 judgment
|
|
2190
|
+
*/
|
|
2191
|
+
function renderJudgments(items) {
|
|
2192
|
+
if (!judgmentsList) return;
|
|
2193
|
+
const all = items || [];
|
|
2194
|
+
const titleEl = document.getElementById('judgments-list-title');
|
|
2195
|
+
const chNameEl = document.getElementById('judgments-tab-channel-name');
|
|
2196
|
+
const currentCh = currentChannelId
|
|
2197
|
+
? channels.find(c => c.id === currentChannelId)
|
|
2198
|
+
: null;
|
|
2199
|
+
|
|
2200
|
+
if (chNameEl) {
|
|
2201
|
+
chNameEl.textContent = currentCh ? `(${currentCh.name})` : '(未选)';
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (all.length === 0) {
|
|
2205
|
+
judgmentsList.innerHTML = '<div class="task-empty">还没有判断, 在上面记录第一条吧</div>';
|
|
2206
|
+
if (titleEl) titleEl.textContent = '本 channel 的判断力';
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
if (currentJudgmentTab === 'global') {
|
|
2211
|
+
// 全局 tab: 全部 judgment, 简单列表
|
|
2212
|
+
if (titleEl) titleEl.textContent = `全局判断力 (${all.length} 条)`;
|
|
2213
|
+
judgmentsList.innerHTML = renderJudgmentItems(all, { showBindToggle: false });
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// channel tab: 必须有 channel
|
|
2218
|
+
if (!currentCh) {
|
|
2219
|
+
if (titleEl) titleEl.textContent = '本 channel 的判断力';
|
|
2220
|
+
judgmentsList.innerHTML = `
|
|
2221
|
+
<div style="padding:24px 12px;text-align:center;color:#6b7280;font-size:13px;">
|
|
2222
|
+
请先在左侧选中一个 channel,<br>然后这里会显示已绑定和可加入的判断力。
|
|
2223
|
+
</div>
|
|
2224
|
+
`;
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const boundIds = new Set(
|
|
2229
|
+
Array.isArray(currentCh.bound_judgment_ids) ? currentCh.bound_judgment_ids : []
|
|
2230
|
+
);
|
|
2231
|
+
const bound = all.filter(j => boundIds.has(j.id));
|
|
2232
|
+
const unbound = all.filter(j => !boundIds.has(j.id));
|
|
2233
|
+
|
|
2234
|
+
if (titleEl) titleEl.textContent = `${currentCh.name} 的判断力 (已绑 ${bound.length} / 共 ${all.length})`;
|
|
2235
|
+
|
|
2236
|
+
let html = '';
|
|
2237
|
+
if (bound.length > 0) {
|
|
2238
|
+
html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;">已绑定 (${bound.length})</div>`;
|
|
2239
|
+
html += renderJudgmentItems(bound, { showBindToggle: true, isBound: true });
|
|
2240
|
+
}
|
|
2241
|
+
if (unbound.length > 0) {
|
|
2242
|
+
html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:14px 4px 4px;">未绑定 (${unbound.length})</div>`;
|
|
2243
|
+
html += renderJudgmentItems(unbound, { showBindToggle: true, isBound: false });
|
|
2244
|
+
}
|
|
2245
|
+
judgmentsList.innerHTML = html;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function renderJudgmentItems(items, opts) {
|
|
2249
|
+
const { showBindToggle, isBound } = opts || {};
|
|
2250
|
+
return items.map(j => {
|
|
2251
|
+
const reason = (j.reasons && j.reasons[0]) ? escapeHtml(j.reasons[0]) : '';
|
|
2252
|
+
const domain = (j.context && j.context.domain) ? escapeHtml(j.context.domain) : 'general';
|
|
2253
|
+
const stakes = (j.context && j.context.stakes) ? escapeHtml(j.context.stakes) : 'medium';
|
|
2254
|
+
const isSuperseded = j.status === 'superseded';
|
|
2255
|
+
const isRejected = j.status === 'rejected';
|
|
2256
|
+
const dimmedStyle = isSuperseded || isRejected
|
|
2257
|
+
? 'opacity:0.55;background:#f3f4f6;'
|
|
2258
|
+
: '';
|
|
2259
|
+
const statusTag = isSuperseded
|
|
2260
|
+
? `<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;" title="已被新判断力演化替代">已过时</span>`
|
|
2261
|
+
: isRejected
|
|
2262
|
+
? `<span style="display:inline-block;background:#fee2e2;color:#991b1b;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;">已拒绝</span>`
|
|
2263
|
+
: '';
|
|
2264
|
+
const evolveNote = isSuperseded && j.supersededBy
|
|
2265
|
+
? `<div style="font-size:10px;color:#6b7280;margin-top:2px;">被新条替代 · ${escapeHtml(j.evolutionReason || 'merged')} · ${escapeHtml(j.evolvedAt || '').substring(0,10)}</div>`
|
|
2266
|
+
: '';
|
|
2267
|
+
const bindBtn = showBindToggle
|
|
2268
|
+
? isBound
|
|
2269
|
+
? `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="unbind" title="从当前 channel 移除" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">× 移除</button>`
|
|
2270
|
+
: `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="bind" title="加进当前 channel" style="background:none;border:1px solid #6b7280;color:#6b7280;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">+ 加入</button>`
|
|
2271
|
+
: '';
|
|
2272
|
+
return `
|
|
2273
|
+
<div class="task-item completed judgment-row"
|
|
2274
|
+
data-judgment-id="${escapeHtml(j.id)}"
|
|
2275
|
+
draggable="true"
|
|
2276
|
+
style="cursor:grab;${dimmedStyle}">
|
|
2277
|
+
<div class="task-item-header">
|
|
2278
|
+
<label class="judgment-checkbox" style="display:flex;align-items:center;cursor:pointer;margin-right:8px;" onclick="event.stopPropagation();">
|
|
2279
|
+
<input type="checkbox" class="judgment-select-cb" data-id="${escapeHtml(j.id)}" style="cursor:pointer;" onclick="event.stopPropagation();">
|
|
2280
|
+
</label>
|
|
2281
|
+
<div class="task-item-title">
|
|
2282
|
+
<span class="judgment-decision">${escapeHtml(j.decision)}</span>${statusTag}
|
|
2283
|
+
</div>
|
|
2284
|
+
<span class="task-item-status completed">${stakes}</span>
|
|
2285
|
+
</div>
|
|
2286
|
+
${reason ? `<div class="task-item-desc" style="color:#555;font-size:13px;margin-top:4px;">理由: ${reason}</div>` : ''}
|
|
2287
|
+
${evolveNote}
|
|
2288
|
+
<div class="task-item-meta" style="color:#999;font-size:11px;margin-top:4px;display:flex;justify-content:space-between;align-items:center;">
|
|
2289
|
+
<span>${domain} · ${escapeHtml(j.timestamp)} · ${escapeHtml(j.id)}</span>
|
|
2290
|
+
<span style="display:flex;gap:4px;">
|
|
2291
|
+
${bindBtn}
|
|
2292
|
+
<button class="judgment-edit-btn" data-id="${escapeHtml(j.id)}" title="编辑判断" style="background:none;border:1px solid #d1d5db;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
|
|
2293
|
+
<button class="judgment-del-btn" data-id="${escapeHtml(j.id)}" title="删除判断" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">删除</button>
|
|
2294
|
+
</span>
|
|
2295
|
+
</div>
|
|
2296
|
+
</div>
|
|
2297
|
+
`;
|
|
2298
|
+
}).join('');
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
async function loadJudgments() {
|
|
2302
|
+
if (!judgmentsList) return;
|
|
2303
|
+
try {
|
|
2304
|
+
// P3: 违规记录走单独 API
|
|
2305
|
+
if (currentStatusFilter === 'violations') {
|
|
2306
|
+
const res = await fetch('/api/judgments/violations?limit=50');
|
|
2307
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2308
|
+
const data = await res.json();
|
|
2309
|
+
renderViolations(data.items || []);
|
|
2310
|
+
judgmentsLoaded = true;
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// 类 B: 自适应扫描建议
|
|
2315
|
+
if (currentStatusFilter === 'adaptive') {
|
|
2316
|
+
const res = await fetch('/api/judgments/adaptive-suggestions');
|
|
2317
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2318
|
+
const data = await res.json();
|
|
2319
|
+
renderAdaptiveSuggestions(data);
|
|
2320
|
+
judgmentsLoaded = true;
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// 阶段 2: causal-judge 因果分析
|
|
2325
|
+
if (currentStatusFilter === 'causal') {
|
|
2326
|
+
const res = await fetch('/api/judgments/causal/correlation?topN=10');
|
|
2327
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2328
|
+
const data = await res.json();
|
|
2329
|
+
renderCausalAnalysis(data.items || []);
|
|
2330
|
+
judgmentsLoaded = true;
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
const res = await fetch('/api/judgments?status=' + encodeURIComponent(currentStatusFilter));
|
|
2335
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2336
|
+
const data = await res.json();
|
|
2337
|
+
lastJudgmentsCache = data.judgments || [];
|
|
2338
|
+
renderJudgments(lastJudgmentsCache);
|
|
2339
|
+
if (judgmentsBadge) {
|
|
2340
|
+
// 徽章永远显示 active 数量; 不跟 filter 变
|
|
2341
|
+
// 当 filter=active 时, 主 fetch 已经返回的就是 active 列表, 直接用 data.count
|
|
2342
|
+
// 当 filter=all/superseded 时, 从主列表本地数 active 条 (status ?? 'active' 兼容老数据)
|
|
2343
|
+
let activeCount;
|
|
2344
|
+
if (currentStatusFilter === 'active') {
|
|
2345
|
+
activeCount = data.count;
|
|
2346
|
+
} else {
|
|
2347
|
+
activeCount = lastJudgmentsCache.filter((j) => (j.status ?? 'active') === 'active').length;
|
|
2348
|
+
}
|
|
2349
|
+
if (activeCount > 0) {
|
|
2350
|
+
judgmentsBadge.textContent = activeCount;
|
|
2351
|
+
judgmentsBadge.style.display = '';
|
|
2352
|
+
} else {
|
|
2353
|
+
judgmentsBadge.style.display = 'none';
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
judgmentsLoaded = true;
|
|
2357
|
+
} catch (e) {
|
|
2358
|
+
if (judgmentsList) judgmentsList.innerHTML = '<div class="task-empty">加载失败: ' + escapeHtml(e.message) + '</div>';
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* P3 渲染违规记录 (与 renderJudgments 同位置, 但内容不同)
|
|
2364
|
+
*/
|
|
2365
|
+
function renderViolations(items) {
|
|
2366
|
+
if (!judgmentsList) return;
|
|
2367
|
+
if (!items || items.length === 0) {
|
|
2368
|
+
judgmentsList.innerHTML = '<div class="task-empty">暂无违规记录 (AI 回复未违反注入原则).</div>';
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
judgmentsList.innerHTML = items.map((v) => {
|
|
2372
|
+
const ts = escapeHtml((v.ts || '').substring(0, 19).replace('T', ' '));
|
|
2373
|
+
const userPrev = escapeHtml(v.userInputPreview || '');
|
|
2374
|
+
const aiPrev = escapeHtml(v.aiReplyPreview || '');
|
|
2375
|
+
const principles = (v.result?.violatedPrinciples || []).map((p) =>
|
|
2376
|
+
`<div style="margin-top:3px;padding:4px 8px;background:#fef2f2;border-radius:3px;">
|
|
2377
|
+
<span style="color:#dc2626;">⚠</span> <strong>${escapeHtml(p.principle || '')}</strong>
|
|
2378
|
+
<span style="color:#991b1b;">— ${escapeHtml(p.reason || '')}</span>
|
|
2379
|
+
</div>`
|
|
2380
|
+
).join('');
|
|
2381
|
+
return `
|
|
2382
|
+
<div class="task-item" style="border-left:3px solid #dc2626;padding:8px 12px;background:#fffbfb;">
|
|
2383
|
+
<div style="font-size:11px;color:#6b7280;margin-bottom:4px;">${ts} · confidence=${escapeHtml(String(v.result?.confidence ?? 0))}</div>
|
|
2384
|
+
<div style="font-size:12px;color:#1f2937;"><strong>用户:</strong> ${userPrev}</div>
|
|
2385
|
+
<div style="font-size:12px;color:#1f2937;margin-top:2px;"><strong>AI:</strong> ${aiPrev}</div>
|
|
2386
|
+
<div style="margin-top:6px;">${principles}</div>
|
|
2387
|
+
</div>
|
|
2388
|
+
`;
|
|
2389
|
+
}).join('');
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* 类 B 自适应建议渲染
|
|
2394
|
+
* - rising (绿色 boost 标记): 7 天使用率高于 30 天均值的 1.5 倍
|
|
2395
|
+
* - stale (黄色 deprecate 标记): 90 天未用 + 总使用 < 3
|
|
2396
|
+
* - unused (灰色 review 标记): 30 天未用 + 总使用 < 5
|
|
2397
|
+
* 每条带 "✓ 接受" / "✗ 拒绝" 按钮, 接受会真改库, 拒绝只留痕
|
|
2398
|
+
*/
|
|
2399
|
+
function renderAdaptiveSuggestions(data) {
|
|
2400
|
+
if (!judgmentsList) return;
|
|
2401
|
+
const { judgmentsTotal, usageEntriesScanned, suggestions, scannedAt } = data;
|
|
2402
|
+
const ts = escapeHtml((scannedAt || '').substring(0, 19).replace('T', ' '));
|
|
2403
|
+
|
|
2404
|
+
if (!suggestions || suggestions.length === 0) {
|
|
2405
|
+
judgmentsList.innerHTML = `
|
|
2406
|
+
<div class="task-empty">📊 自适应扫描: 无建议
|
|
2407
|
+
<div style="margin-top:8px;font-size:11px;color:#6b7280;">扫了 ${judgmentsTotal} 条原则, ${usageEntriesScanned} 条使用记录, 都挺健康.</div>
|
|
2408
|
+
<div style="margin-top:4px;font-size:11px;color:#6b7280;">扫描于 ${ts}</div>
|
|
2409
|
+
</div>`;
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
const KIND_STYLE = {
|
|
2414
|
+
rising: { color: '#059669', bg: '#ecfdf5', label: '↑ rising', action: 'boost' },
|
|
2415
|
+
stale: { color: '#92400e', bg: '#fef3c7', label: '⏰ stale', action: 'deprecate' },
|
|
2416
|
+
unused: { color: '#6b7280', bg: '#f3f4f6', label: '👀 unused', action: 'review' },
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
const header = `
|
|
2420
|
+
<div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
|
|
2421
|
+
📊 扫描于 ${ts} · ${judgmentsTotal} 条原则 · ${usageEntriesScanned} 条使用记录 · <strong>${suggestions.length}</strong> 条建议
|
|
2422
|
+
<button class="rescan-btn" style="margin-left:8px;background:none;border:1px solid #6b7280;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新扫描</button>
|
|
2423
|
+
</div>
|
|
2424
|
+
`;
|
|
2425
|
+
|
|
2426
|
+
const rows = suggestions.map((s) => {
|
|
2427
|
+
const style = KIND_STYLE[s.kind] || KIND_STYLE.unused;
|
|
2428
|
+
const m = s.metrics || {};
|
|
2429
|
+
return `
|
|
2430
|
+
<div class="task-item" data-suggestion-key="${escapeHtml(s.key)}"
|
|
2431
|
+
style="border-left:3px solid ${style.color};padding:8px 12px;background:${style.bg};margin-bottom:6px;">
|
|
2432
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
|
2433
|
+
<span style="color:${style.color};font-weight:600;font-size:12px;">${style.label}</span>
|
|
2434
|
+
<span style="font-size:11px;color:#6b7280;">${s.action === 'boost' ? '建议加权' : s.action === 'deprecate' ? '建议废弃' : '建议审视'}</span>
|
|
2435
|
+
</div>
|
|
2436
|
+
<div style="font-size:12px;color:#1f2937;margin-bottom:4px;"><strong>${escapeHtml(s.decision)}</strong></div>
|
|
2437
|
+
<div style="font-size:11px;color:#6b7280;margin-bottom:6px;">${escapeHtml(s.reason)}</div>
|
|
2438
|
+
<div style="font-size:11px;color:#9ca3af;margin-bottom:6px;">
|
|
2439
|
+
7天 ${m.usage7d || 0} · 30天 ${m.usage30d || 0} · 共 ${m.totalUsage || 0} · 上次用 ${m.daysSinceLastUse || 0} 天前
|
|
2440
|
+
</div>
|
|
2441
|
+
<div style="display:flex;gap:6px;">
|
|
2442
|
+
<button class="adaptive-accept" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
|
|
2443
|
+
style="background:#059669;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✓ 接受</button>
|
|
2444
|
+
<button class="adaptive-reject" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
|
|
2445
|
+
style="background:none;border:1px solid #d1d5db;color:#6b7280;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✗ 拒绝</button>
|
|
2446
|
+
</div>
|
|
2447
|
+
</div>
|
|
2448
|
+
`;
|
|
2449
|
+
}).join('');
|
|
2450
|
+
|
|
2451
|
+
judgmentsList.innerHTML = header + rows;
|
|
2452
|
+
|
|
2453
|
+
// 绑定按钮
|
|
2454
|
+
const rescanBtn = judgmentsList.querySelector('.rescan-btn');
|
|
2455
|
+
if (rescanBtn) {
|
|
2456
|
+
rescanBtn.onclick = async () => {
|
|
2457
|
+
rescanBtn.disabled = true;
|
|
2458
|
+
rescanBtn.textContent = '🔄 扫描中...';
|
|
2459
|
+
try {
|
|
2460
|
+
const r = await fetch('/api/judgments/adaptive-suggestions?force=1');
|
|
2461
|
+
if (r.ok) renderAdaptiveSuggestions(await r.json());
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
console.error('[adaptive] rescan failed:', err);
|
|
2464
|
+
} finally {
|
|
2465
|
+
rescanBtn.disabled = false;
|
|
2466
|
+
rescanBtn.textContent = '🔄 重新扫描';
|
|
2467
|
+
}
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
judgmentsList.querySelectorAll('.adaptive-accept').forEach((btn) => {
|
|
2471
|
+
btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'accept');
|
|
2472
|
+
});
|
|
2473
|
+
judgmentsList.querySelectorAll('.adaptive-reject').forEach((btn) => {
|
|
2474
|
+
btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'reject');
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
async function applyAdaptiveSuggestion(key, judgmentId, actionKind, decision) {
|
|
2479
|
+
const row = judgmentsList?.querySelector(`[data-suggestion-key="${key}"]`);
|
|
2480
|
+
if (row) row.style.opacity = '0.5';
|
|
2481
|
+
try {
|
|
2482
|
+
const res = await fetch('/api/judgments/adaptive-apply', {
|
|
2483
|
+
method: 'POST',
|
|
2484
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2485
|
+
body: JSON.stringify({
|
|
2486
|
+
action: decision,
|
|
2487
|
+
suggestion: {
|
|
2488
|
+
key,
|
|
2489
|
+
judgmentId,
|
|
2490
|
+
kind: actionKind,
|
|
2491
|
+
action: actionKind,
|
|
2492
|
+
decision: '',
|
|
2493
|
+
reason: '',
|
|
2494
|
+
metrics: {},
|
|
2495
|
+
scannedAt: new Date().toISOString(),
|
|
2496
|
+
},
|
|
2497
|
+
}),
|
|
2498
|
+
});
|
|
2499
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2500
|
+
// 视觉反馈: 隐藏该行
|
|
2501
|
+
if (row) row.style.display = 'none';
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
if (row) row.style.opacity = '';
|
|
2504
|
+
console.error('[adaptive] apply failed:', err);
|
|
2505
|
+
alert('操作失败: ' + (err && err.message || 'unknown'));
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
// ============================================================
|
|
2510
|
+
// 阶段 2: causal-judge 渲染
|
|
2511
|
+
// ============================================================
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* 渲染关联分析 (top 5 互信息对)
|
|
2515
|
+
* - 显示每对: judgmentA ↔ judgmentB + 互信息 + co-occurrence + 因果方向
|
|
2516
|
+
* - 每条 judgment 旁加"🔬 跑 do-calculus"按钮, 异步显示 causalEffect
|
|
2517
|
+
*/
|
|
2518
|
+
function renderCausalAnalysis(items) {
|
|
2519
|
+
if (!judgmentsList) return;
|
|
2520
|
+
if (!items || items.length === 0) {
|
|
2521
|
+
judgmentsList.innerHTML = `
|
|
2522
|
+
<div class="task-empty">🔍 因果分析: 无高关联对
|
|
2523
|
+
<div style="margin-top:8px;font-size:11px;color:#6b7280;">usage 数据不足 (至少 3 条同现), 或 LLM 不可用. 多用 bolloon 一段时间后重试.</div>
|
|
2524
|
+
</div>`;
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const rows = items.map((p, idx) => `
|
|
2529
|
+
<div class="task-item" data-causal-idx="${idx}" data-judgment-a="${escapeHtml(p.judgmentA)}" data-judgment-b="${escapeHtml(p.judgmentB)}"
|
|
2530
|
+
style="border-left:3px solid #7c3aed;padding:8px 12px;background:#faf5ff;margin-bottom:6px;">
|
|
2531
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
|
2532
|
+
<span style="color:#7c3aed;font-weight:600;font-size:12px;">${escapeHtml(p.causalDirection)}</span>
|
|
2533
|
+
<span style="font-size:11px;color:#6b7280;">MI=${p.mutualInfo} · co=${p.coOccurrence}</span>
|
|
2534
|
+
</div>
|
|
2535
|
+
<div style="font-size:11px;color:#374151;margin-bottom:4px;">${escapeHtml(p.explanation || '(无 LLM 解释)')}</div>
|
|
2536
|
+
<div style="font-size:10px;color:#9ca3af;">A: ${escapeHtml(p.judgmentA)} ↔ B: ${escapeHtml(p.judgmentB)}</div>
|
|
2537
|
+
<div style="margin-top:6px;display:flex;gap:6px;">
|
|
2538
|
+
<button class="causal-intervention-a" data-jid="${escapeHtml(p.judgmentA)}"
|
|
2539
|
+
style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(A)</button>
|
|
2540
|
+
<button class="causal-intervention-b" data-jid="${escapeHtml(p.judgmentB)}"
|
|
2541
|
+
style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(B)</button>
|
|
2542
|
+
</div>
|
|
2543
|
+
<div class="causal-result" data-jid="" style="display:none;margin-top:6px;padding:6px;background:#f3e8ff;border-radius:3px;font-size:11px;"></div>
|
|
2544
|
+
</div>
|
|
2545
|
+
`).join('');
|
|
2546
|
+
|
|
2547
|
+
judgmentsList.innerHTML = `
|
|
2548
|
+
<div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
|
|
2549
|
+
🔍 关联分析 (top ${items.length} 互信息对) · <span style="color:#7c3aed;">LLM 推断方向</span>
|
|
2550
|
+
<button class="causal-refresh" style="margin-left:8px;background:none;border:1px solid #7c3aed;color:#7c3aed;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新跑</button>
|
|
2551
|
+
</div>
|
|
2552
|
+
${rows}
|
|
2553
|
+
`;
|
|
2554
|
+
|
|
2555
|
+
// 按钮: 重新跑
|
|
2556
|
+
const refresh = judgmentsList.querySelector('.causal-refresh');
|
|
2557
|
+
if (refresh) {
|
|
2558
|
+
refresh.onclick = async () => {
|
|
2559
|
+
refresh.disabled = true;
|
|
2560
|
+
refresh.textContent = '🔄 跑中...';
|
|
2561
|
+
try {
|
|
2562
|
+
const r = await fetch('/api/judgments/causal/correlation?topN=10');
|
|
2563
|
+
if (r.ok) renderCausalAnalysis((await r.json()).items || []);
|
|
2564
|
+
} finally {
|
|
2565
|
+
refresh.disabled = false;
|
|
2566
|
+
refresh.textContent = '🔄 重新跑';
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// 按钮: 跑 do-calculus
|
|
2572
|
+
judgmentsList.querySelectorAll('.causal-intervention-a, .causal-intervention-b').forEach((btn) => {
|
|
2573
|
+
btn.onclick = async () => {
|
|
2574
|
+
const jid = btn.getAttribute('data-jid');
|
|
2575
|
+
const resultDiv = btn.closest('.task-item')?.querySelector('.causal-result');
|
|
2576
|
+
if (!resultDiv) return;
|
|
2577
|
+
resultDiv.style.display = 'block';
|
|
2578
|
+
resultDiv.textContent = '🔬 跑 do-calculus (LLM 模拟反事实)...';
|
|
2579
|
+
btn.disabled = true;
|
|
2580
|
+
try {
|
|
2581
|
+
const r = await fetch(`/api/judgments/causal/intervention?judgmentId=${encodeURIComponent(jid)}`);
|
|
2582
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
2583
|
+
const data = await r.json();
|
|
2584
|
+
const effect = data.causalEffect;
|
|
2585
|
+
const sign = effect > 0 ? '+' : '';
|
|
2586
|
+
const color = Math.abs(effect) > 0.5 ? '#dc2626' : Math.abs(effect) > 0.2 ? '#d97706' : '#059669';
|
|
2587
|
+
resultDiv.innerHTML = `
|
|
2588
|
+
<div style="color:${color};font-weight:600;">do-calculus: causalEffect = ${sign}${effect} (${data.marginalContribution})</div>
|
|
2589
|
+
<div style="color:#374151;margin-top:4px;">${escapeHtml(data.reasoning)}</div>
|
|
2590
|
+
<div style="color:#9ca3af;margin-top:4px;">confidence=${data.confidence}</div>
|
|
2591
|
+
`;
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
resultDiv.innerHTML = `<div style="color:#dc2626;">失败: ${escapeHtml(err.message)}</div>`;
|
|
2594
|
+
} finally {
|
|
2595
|
+
btn.disabled = false;
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/** 把 judgment id 加进 / 移出当前 channel.bound_judgment_ids, 然后刷新两边 UI */
|
|
2602
|
+
async function toggleChannelJudgment(judgmentId, action) {
|
|
2603
|
+
if (!currentChannelId) {
|
|
2604
|
+
showJudgmentError('请先选中一个 channel');
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
const ch = channels.find(c => c.id === currentChannelId);
|
|
2608
|
+
if (!ch) return;
|
|
2609
|
+
const set = new Set(Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids : []);
|
|
2610
|
+
if (action === 'bind') set.add(judgmentId);
|
|
2611
|
+
else set.delete(judgmentId);
|
|
2612
|
+
const next = Array.from(set);
|
|
2613
|
+
try {
|
|
2614
|
+
const res = await fetch(`/channels/${currentChannelId}`, {
|
|
2615
|
+
method: 'PATCH',
|
|
2616
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2617
|
+
body: JSON.stringify({ bound_judgment_ids: next })
|
|
2618
|
+
});
|
|
2619
|
+
if (!res.ok) {
|
|
2620
|
+
const err = await res.json().catch(() => ({}));
|
|
2621
|
+
throw new Error(err.error || `HTTP ${res.status}`);
|
|
2622
|
+
}
|
|
2623
|
+
const updated = await res.json();
|
|
2624
|
+
const idx = channels.findIndex(c => c.id === currentChannelId);
|
|
2625
|
+
if (idx >= 0) channels[idx] = updated;
|
|
2626
|
+
// 弹窗开着就刷新, 关着就跳过
|
|
2627
|
+
if (judgmentsModal && judgmentsModal.classList.contains('active')) {
|
|
2628
|
+
renderJudgments(lastJudgmentsCache);
|
|
2629
|
+
}
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
showJudgmentError('绑定失败: ' + err.message);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// 列表内编辑/删除 + 拖拽 — 事件委托
|
|
2636
|
+
if (judgmentsList) {
|
|
2637
|
+
judgmentsList.addEventListener('click', async (e) => {
|
|
2638
|
+
const editBtn = e.target.closest && e.target.closest('.judgment-edit-btn');
|
|
2639
|
+
const delBtn = e.target.closest && e.target.closest('.judgment-del-btn');
|
|
2640
|
+
const toggleBtn = e.target.closest && e.target.closest('.judgment-toggle-btn');
|
|
2641
|
+
if (editBtn) {
|
|
2642
|
+
const id = editBtn.getAttribute('data-id');
|
|
2643
|
+
await editJudgment(id);
|
|
2644
|
+
} else if (delBtn) {
|
|
2645
|
+
const id = delBtn.getAttribute('data-id');
|
|
2646
|
+
if (!confirm('确定删除这条判断?')) return;
|
|
2647
|
+
try {
|
|
2648
|
+
const res = await fetch('/api/judgments/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
2649
|
+
const out = await res.json();
|
|
2650
|
+
if (!out.ok) throw new Error(out.error || 'delete failed');
|
|
2651
|
+
await loadJudgments();
|
|
2652
|
+
} catch (err) {
|
|
2653
|
+
showJudgmentError('删除失败: ' + err.message);
|
|
2654
|
+
}
|
|
2655
|
+
} else if (toggleBtn) {
|
|
2656
|
+
const id = toggleBtn.getAttribute('data-id');
|
|
2657
|
+
const action = toggleBtn.getAttribute('data-action');
|
|
2658
|
+
await toggleChannelJudgment(id, action);
|
|
2659
|
+
}
|
|
2660
|
+
});
|
|
2661
|
+
|
|
2662
|
+
// tab 切换
|
|
2663
|
+
document.querySelectorAll('.judgment-tab').forEach(btn => {
|
|
2664
|
+
btn.addEventListener('click', () => switchJudgmentTab(btn.dataset.tab));
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
// status 过滤
|
|
2668
|
+
document.querySelectorAll('.judgment-status-tab').forEach(btn => {
|
|
2669
|
+
btn.addEventListener('click', () => switchStatusFilter(btn.dataset.status));
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
// 拖拽: 每条 judgment 是 drag source, dataTransfer 装 decision text
|
|
2673
|
+
judgmentsList.addEventListener('dragstart', (e) => {
|
|
2674
|
+
const row = e.target.closest && e.target.closest('.judgment-row');
|
|
2675
|
+
if (!row) return;
|
|
2676
|
+
const decision = row.querySelector('.judgment-decision')?.textContent || '';
|
|
2677
|
+
const id = row.getAttribute('data-judgment-id') || '';
|
|
2678
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
2679
|
+
e.dataTransfer.setData('text/plain', decision);
|
|
2680
|
+
e.dataTransfer.setData('application/x-bolloon-judgment', JSON.stringify({ id, decision }));
|
|
2681
|
+
});
|
|
2682
|
+
|
|
2683
|
+
// 多选 checkbox 变化 → 更新工具栏
|
|
2684
|
+
judgmentsList.addEventListener('change', (e) => {
|
|
2685
|
+
if (e.target.classList && e.target.classList.contains('judgment-select-cb')) {
|
|
2686
|
+
updateBulkDeleteToolbar();
|
|
2687
|
+
}
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// 批量选择工具栏: 全选 / 计数 / 启用删除按钮
|
|
2692
|
+
const judgmentSelectAll = document.getElementById('judgment-select-all');
|
|
2693
|
+
const judgmentSelectedCount = document.getElementById('judgment-selected-count');
|
|
2694
|
+
const judgmentBulkDeleteBtn = document.getElementById('judgment-bulk-delete-btn');
|
|
2695
|
+
|
|
2696
|
+
function getSelectedJudgmentIds() {
|
|
2697
|
+
if (!judgmentsList) return [];
|
|
2698
|
+
return Array.from(judgmentsList.querySelectorAll('.judgment-select-cb'))
|
|
2699
|
+
.filter(cb => cb.checked)
|
|
2700
|
+
.map(cb => cb.getAttribute('data-id'))
|
|
2701
|
+
.filter(Boolean);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function updateBulkDeleteToolbar() {
|
|
2705
|
+
const ids = getSelectedJudgmentIds();
|
|
2706
|
+
if (judgmentSelectedCount) judgmentSelectedCount.textContent = `已选 ${ids.length}`;
|
|
2707
|
+
if (judgmentBulkDeleteBtn) {
|
|
2708
|
+
judgmentBulkDeleteBtn.disabled = ids.length === 0;
|
|
2709
|
+
judgmentBulkDeleteBtn.style.opacity = ids.length === 0 ? '0.5' : '1';
|
|
2710
|
+
judgmentBulkDeleteBtn.style.cursor = ids.length === 0 ? 'not-allowed' : 'pointer';
|
|
2711
|
+
}
|
|
2712
|
+
// 全选 checkbox 的 indeterminate / checked 状态同步
|
|
2713
|
+
if (judgmentSelectAll && judgmentsList) {
|
|
2714
|
+
const all = judgmentsList.querySelectorAll('.judgment-select-cb');
|
|
2715
|
+
const checked = Array.from(all).filter(cb => cb.checked);
|
|
2716
|
+
judgmentSelectAll.checked = all.length > 0 && checked.length === all.length;
|
|
2717
|
+
judgmentSelectAll.indeterminate = checked.length > 0 && checked.length < all.length;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
if (judgmentSelectAll) {
|
|
2722
|
+
judgmentSelectAll.addEventListener('change', (e) => {
|
|
2723
|
+
if (!judgmentsList) return;
|
|
2724
|
+
const checked = e.target.checked;
|
|
2725
|
+
judgmentsList.querySelectorAll('.judgment-select-cb').forEach(cb => { cb.checked = checked; });
|
|
2726
|
+
updateBulkDeleteToolbar();
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
if (judgmentBulkDeleteBtn) {
|
|
2731
|
+
judgmentBulkDeleteBtn.addEventListener('click', async () => {
|
|
2732
|
+
const ids = getSelectedJudgmentIds();
|
|
2733
|
+
if (ids.length === 0) return;
|
|
2734
|
+
if (!confirm(`确定删除选中的 ${ids.length} 条判断? 此操作不可撤销.`)) return;
|
|
2735
|
+
judgmentBulkDeleteBtn.disabled = true;
|
|
2736
|
+
try {
|
|
2737
|
+
const res = await fetch('/api/judgments/batch-delete', {
|
|
2738
|
+
method: 'POST',
|
|
2739
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2740
|
+
body: JSON.stringify({ ids }),
|
|
2741
|
+
});
|
|
2742
|
+
const out = await res.json();
|
|
2743
|
+
if (!out.ok) throw new Error(out.error || 'failed');
|
|
2744
|
+
showJudgmentOk(`✓ 批量删除 ${out.deleted} 条${out.notFound?.length ? ` (${out.notFound.length} 条未找到)` : ''}`);
|
|
2745
|
+
await loadJudgments();
|
|
2746
|
+
} catch (err) {
|
|
2747
|
+
showJudgmentError('批量删除失败: ' + err.message);
|
|
2748
|
+
} finally {
|
|
2749
|
+
if (judgmentBulkDeleteBtn) judgmentBulkDeleteBtn.disabled = false;
|
|
2750
|
+
}
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
async function editJudgment(id) {
|
|
2755
|
+
// 简单做法: 用 prompt 弹 3 个字段. 想要更好的体验就用 inline editor, 但 v1 不必.
|
|
2756
|
+
const all = await (await fetch('/api/judgments')).json();
|
|
2757
|
+
const j = (all.judgments || []).find(x => x.id === id);
|
|
2758
|
+
if (!j) { showJudgmentError('找不到该判断 (可能已删除)'); return; }
|
|
2759
|
+
const newDecision = prompt('修改判断 (decision):', j.decision);
|
|
2760
|
+
if (newDecision === null) return;
|
|
2761
|
+
const newReason = prompt('修改理由 (reason, 留空不改):', (j.reasons && j.reasons[0]) || '');
|
|
2762
|
+
const newStakes = prompt('修改风险 (low/medium/high/critical):', (j.context && j.context.stakes) || 'medium');
|
|
2763
|
+
const patch = {
|
|
2764
|
+
decision: newDecision.trim() || j.decision,
|
|
2765
|
+
reasons: newReason !== null ? [newReason.trim()].filter(Boolean) : j.reasons,
|
|
2766
|
+
context: newStakes ? { ...(j.context || {}), stakes: newStakes } : j.context,
|
|
2767
|
+
};
|
|
2768
|
+
try {
|
|
2769
|
+
const res = await fetch('/api/judgments/' + encodeURIComponent(id), {
|
|
2770
|
+
method: 'PATCH',
|
|
2771
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2772
|
+
body: JSON.stringify(patch),
|
|
2773
|
+
});
|
|
2774
|
+
const out = await res.json();
|
|
2775
|
+
if (!out.ok) throw new Error(out.error || 'update failed');
|
|
2776
|
+
showJudgmentOk('✓ 已更新');
|
|
2777
|
+
await loadJudgments();
|
|
2778
|
+
} catch (err) {
|
|
2779
|
+
showJudgmentError('更新失败: ' + err.message);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
async function submitJudgment() {
|
|
2784
|
+
if (!judgmentSubmitBtn) return;
|
|
2785
|
+
const decision = (judgmentDecision?.value || '').trim();
|
|
2786
|
+
const reason = (judgmentReason?.value || '').trim();
|
|
2787
|
+
if (!decision) {
|
|
2788
|
+
if (judgmentError) { judgmentError.textContent = '判断不能为空'; judgmentError.style.display = ''; }
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
judgmentSubmitBtn.disabled = true;
|
|
2792
|
+
if (judgmentError) judgmentError.style.display = 'none';
|
|
2793
|
+
try {
|
|
2794
|
+
const res = await fetch('/api/judgments', {
|
|
2795
|
+
method: 'POST',
|
|
2796
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2797
|
+
body: JSON.stringify({
|
|
2798
|
+
decision,
|
|
2799
|
+
reason: reason || undefined,
|
|
2800
|
+
context: { domain: judgmentDomain?.value, stakes: judgmentStakes?.value },
|
|
2801
|
+
}),
|
|
2802
|
+
});
|
|
2803
|
+
const out = await res.json();
|
|
2804
|
+
if (!out.ok) throw new Error(out.error || 'unknown');
|
|
2805
|
+
if (judgmentDecision) judgmentDecision.value = '';
|
|
2806
|
+
if (judgmentReason) judgmentReason.value = '';
|
|
2807
|
+
await loadJudgments();
|
|
2808
|
+
|
|
2809
|
+
// AI 自动委派: fire-and-forget. 根据 domain 找匹配的远端 agent, 触发 agent_delegate 协议.
|
|
2810
|
+
// 失败也不影响本次记录.
|
|
2811
|
+
try {
|
|
2812
|
+
const del = await fetch('/api/judgments/auto-delegate', {
|
|
2813
|
+
method: 'POST',
|
|
2814
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2815
|
+
body: JSON.stringify({
|
|
2816
|
+
judgmentId: out.judgment.id,
|
|
2817
|
+
capability: judgmentDomain?.value || 'general',
|
|
2818
|
+
instruction: `执行判断: ${out.judgment.decision}` + (reason ? ` (理由: ${reason})` : ''),
|
|
2819
|
+
}),
|
|
2820
|
+
});
|
|
2821
|
+
const delOut = await del.json();
|
|
2822
|
+
if (delOut.matched && delOut.sent) {
|
|
2823
|
+
showJudgmentOk(`✓ 已记录并自动委派给 ${delOut.targetAgent.name}`);
|
|
2824
|
+
} else if (delOut.matched) {
|
|
2825
|
+
showJudgmentOk(`✓ 已记录 (匹配到 ${delOut.targetAgent.name}, 但 ${delOut.reason || '未发送'})`);
|
|
2826
|
+
} else {
|
|
2827
|
+
showJudgmentOk('✓ 已记录 (本地, 无匹配远端 agent)');
|
|
2828
|
+
}
|
|
2829
|
+
} catch (e) {
|
|
2830
|
+
console.warn('[judgments] auto-delegate fire failed:', e);
|
|
2831
|
+
}
|
|
2832
|
+
} catch (e) {
|
|
2833
|
+
if (judgmentError) { judgmentError.textContent = '记录失败: ' + e.message; judgmentError.style.display = ''; }
|
|
2834
|
+
} finally {
|
|
2835
|
+
judgmentSubmitBtn.disabled = false;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
if (judgmentsBtn) judgmentsBtn.addEventListener('click', showJudgmentsModal);
|
|
2840
|
+
if (judgmentsModalClose) judgmentsModalClose.addEventListener('click', hideJudgmentsModal);
|
|
2841
|
+
if (judgmentsModal) {
|
|
2842
|
+
judgmentsModal.addEventListener('click', (e) => {
|
|
2843
|
+
if (e.target === judgmentsModal) hideJudgmentsModal();
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// --- 导入文件 (.json / .yaml / .md / .txt / .html) ---
|
|
2848
|
+
const judgmentImportBtn = document.getElementById('judgment-import-btn');
|
|
2849
|
+
const judgmentImportFile = document.getElementById('judgment-import-file');
|
|
2850
|
+
|
|
2851
|
+
function showJudgmentError(msg) {
|
|
2852
|
+
if (!judgmentError) return;
|
|
2853
|
+
judgmentError.textContent = msg;
|
|
2854
|
+
judgmentError.style.display = '';
|
|
2855
|
+
judgmentError.style.color = '#b91c1c';
|
|
2856
|
+
}
|
|
2857
|
+
function showJudgmentOk(msg) {
|
|
2858
|
+
if (!judgmentError) return;
|
|
2859
|
+
judgmentError.textContent = msg;
|
|
2860
|
+
judgmentError.style.display = '';
|
|
2861
|
+
judgmentError.style.color = '#15803d';
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
function fileToBase64(file) {
|
|
2865
|
+
return new Promise((resolve, reject) => {
|
|
2866
|
+
const r = new FileReader();
|
|
2867
|
+
r.onload = () => {
|
|
2868
|
+
// result is "data:<mime>;base64,<payload>" — strip prefix
|
|
2869
|
+
const s = String(r.result || '');
|
|
2870
|
+
const idx = s.indexOf(',');
|
|
2871
|
+
resolve(idx >= 0 ? s.substring(idx + 1) : s);
|
|
2872
|
+
};
|
|
2873
|
+
r.onerror = () => reject(r.error || new Error('read failed'));
|
|
2874
|
+
r.readAsDataURL(file);
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
async function importJudgmentFile(file) {
|
|
2879
|
+
if (!file) return;
|
|
2880
|
+
if (judgmentImportBtn) judgmentImportBtn.disabled = true;
|
|
2881
|
+
try {
|
|
2882
|
+
const content = await fileToBase64(file);
|
|
2883
|
+
const res = await fetch('/api/judgments/import', {
|
|
2884
|
+
method: 'POST',
|
|
2885
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2886
|
+
body: JSON.stringify({ filename: file.name, content }),
|
|
2887
|
+
});
|
|
2888
|
+
const out = await res.json();
|
|
2889
|
+
if (!out.ok) throw new Error(out.error || 'import failed');
|
|
2890
|
+
showJudgmentOk(`✓ 导入 ${out.imported} 条${out.failed ? `, ${out.failed} 条失败` : ''}`);
|
|
2891
|
+
await loadJudgments();
|
|
2892
|
+
} catch (e) {
|
|
2893
|
+
showJudgmentError('导入失败: ' + e.message);
|
|
2894
|
+
} finally {
|
|
2895
|
+
if (judgmentImportBtn) judgmentImportBtn.disabled = false;
|
|
2896
|
+
if (judgmentImportFile) judgmentImportFile.value = '';
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
if (judgmentImportBtn) {
|
|
2901
|
+
judgmentImportBtn.addEventListener('click', () => {
|
|
2902
|
+
if (judgmentImportFile) judgmentImportFile.click();
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
if (judgmentImportFile) {
|
|
2906
|
+
judgmentImportFile.addEventListener('change', (e) => {
|
|
2907
|
+
const f = e.target.files && e.target.files[0];
|
|
2908
|
+
if (f) importJudgmentFile(f);
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// --- 从对话里 "蒸馏为判断": 事件委托到消息容器, 匹配 .save-as-judgment ---
|
|
2913
|
+
// 两条路径:
|
|
2914
|
+
// 1. 有 data-channel-id → 调 /api/judgments/distill-from-conversation (B 触发, AI 蒸馏 + 演化对齐)
|
|
2915
|
+
// 2. 没有 channelId (老按钮 / 历史数据) → fallback 到老 /api/judgments (直存)
|
|
2916
|
+
document.addEventListener('click', async (e) => {
|
|
2917
|
+
const btn = e.target.closest && e.target.closest('.save-as-judgment');
|
|
2918
|
+
if (!btn) return;
|
|
2919
|
+
e.preventDefault();
|
|
2920
|
+
e.stopPropagation();
|
|
2921
|
+
|
|
2922
|
+
const channelId = btn.getAttribute('data-channel-id');
|
|
2923
|
+
const decision = (btn.getAttribute('data-decision') || '').trim();
|
|
2924
|
+
|
|
2925
|
+
if (channelId) {
|
|
2926
|
+
btn.classList.add('loading');
|
|
2927
|
+
btn.disabled = true;
|
|
2928
|
+
try {
|
|
2929
|
+
const res = await fetch('/api/judgments/distill-from-conversation', {
|
|
2930
|
+
method: 'POST',
|
|
2931
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2932
|
+
body: JSON.stringify({ channelId }),
|
|
2933
|
+
});
|
|
2934
|
+
const out = await res.json();
|
|
2935
|
+
if (!res.ok) throw new Error(out.error || 'HTTP ' + res.status);
|
|
2936
|
+
|
|
2937
|
+
if (!out.triggered) {
|
|
2938
|
+
btn.classList.remove('loading');
|
|
2939
|
+
btn.disabled = false;
|
|
2940
|
+
btn.title = '蒸馏失败: ' + (out.reason || '无内容');
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const j = out.judgment;
|
|
2945
|
+
const ev = out.evolved || { merged: 0, superseded: 0 };
|
|
2946
|
+
btn.classList.remove('loading');
|
|
2947
|
+
btn.classList.add('saved');
|
|
2948
|
+
btn.title = '已蒸馏为判断';
|
|
2949
|
+
|
|
2950
|
+
// inline 确认弹框 (在按钮下方出现, 5 秒后自动消失)
|
|
2951
|
+
showDistillConfirm(btn, {
|
|
2952
|
+
value: j.decision,
|
|
2953
|
+
evidence: (j.reasons && j.reasons[0]) || '',
|
|
2954
|
+
merged: ev.merged,
|
|
2955
|
+
superseded: ev.superseded,
|
|
2956
|
+
onEdit: async (newText) => {
|
|
2957
|
+
try {
|
|
2958
|
+
await fetch('/api/judgments/' + encodeURIComponent(j.id), {
|
|
2959
|
+
method: 'PATCH',
|
|
2960
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2961
|
+
body: JSON.stringify({ decision: newText }),
|
|
2962
|
+
});
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
console.error('[judgments] edit failed:', err);
|
|
2965
|
+
}
|
|
2966
|
+
},
|
|
2967
|
+
onReject: async () => {
|
|
2968
|
+
try {
|
|
2969
|
+
await fetch('/api/judgments/' + encodeURIComponent(j.id), {
|
|
2970
|
+
method: 'DELETE',
|
|
2971
|
+
});
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
console.error('[judgments] reject failed:', err);
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
// 刷新判断力库缓存
|
|
2979
|
+
setTimeout(() => loadJudgments(), 100);
|
|
2980
|
+
} catch (err) {
|
|
2981
|
+
console.error('[judgments] distill-from-chat failed:', err);
|
|
2982
|
+
btn.classList.remove('loading');
|
|
2983
|
+
btn.disabled = false;
|
|
2984
|
+
btn.title = '蒸馏失败: ' + err.message;
|
|
2985
|
+
}
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// 老路径 fallback (没有 channelId, 直接存原文)
|
|
2990
|
+
if (!decision) return;
|
|
2991
|
+
try {
|
|
2992
|
+
const res = await fetch('/api/judgments', {
|
|
2993
|
+
method: 'POST',
|
|
2994
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2995
|
+
body: JSON.stringify({ decision, reason: '从对话保存' }),
|
|
2996
|
+
});
|
|
2997
|
+
const out = await res.json();
|
|
2998
|
+
if (!out.ok) throw new Error(out.error || 'failed');
|
|
2999
|
+
btn.classList.add('saved');
|
|
3000
|
+
btn.title = '已存为判断';
|
|
3001
|
+
} catch (err) {
|
|
3002
|
+
console.error('[judgments] save-from-chat failed:', err);
|
|
3003
|
+
btn.title = '保存失败: ' + err.message;
|
|
3004
|
+
}
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* inline 蒸馏确认弹框 — 在按钮下方出现, 显示凝练结果 + 演化结果
|
|
3009
|
+
* 5 秒后自动消失, 用户可点 "编辑" / "拒绝"
|
|
3010
|
+
*/
|
|
3011
|
+
function showDistillConfirm(btn, opts) {
|
|
3012
|
+
const { value, evidence, merged, superseded, onEdit, onReject } = opts;
|
|
3013
|
+
const old = document.getElementById('distill-confirm-popup');
|
|
3014
|
+
if (old) old.remove();
|
|
3015
|
+
|
|
3016
|
+
const popup = document.createElement('div');
|
|
3017
|
+
popup.id = 'distill-confirm-popup';
|
|
3018
|
+
popup.style.cssText = `
|
|
3019
|
+
position:absolute; z-index:1000;
|
|
3020
|
+
background:#fff; border:1px solid #d1d5db; border-radius:6px;
|
|
3021
|
+
box-shadow:0 4px 12px rgba(0,0,0,0.1);
|
|
3022
|
+
padding:10px 12px; min-width:280px; max-width:380px;
|
|
3023
|
+
font-size:13px; color:#1f2937;
|
|
3024
|
+
`;
|
|
3025
|
+
let evolveNote = '';
|
|
3026
|
+
if (merged > 0 || superseded > 0) {
|
|
3027
|
+
evolveNote = `<div style="font-size:11px;color:#059669;margin-top:6px;">✓ 演化对齐: ${merged} 条已合并${superseded > 0 ? `, ${superseded} 条已淘汰` : ''}</div>`;
|
|
3028
|
+
}
|
|
3029
|
+
popup.innerHTML = `
|
|
3030
|
+
<div style="font-weight:600;margin-bottom:4px;">已蒸馏为判断力</div>
|
|
3031
|
+
<div style="background:#f9fafb;padding:6px 8px;border-radius:4px;line-height:1.4;">${escapeHtml(value)}</div>
|
|
3032
|
+
${evidence ? `<div style="font-size:11px;color:#6b7280;margin-top:4px;">证据: ${escapeHtml(evidence)}</div>` : ''}
|
|
3033
|
+
${evolveNote}
|
|
3034
|
+
<div style="display:flex;gap:6px;margin-top:8px;">
|
|
3035
|
+
<button class="dc-edit" style="background:none;border:1px solid #d1d5db;color:#374151;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
|
|
3036
|
+
<button class="dc-reject" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">拒绝</button>
|
|
3037
|
+
<button class="dc-close" style="margin-left:auto;background:none;border:none;color:#6b7280;cursor:pointer;font-size:14px;">×</button>
|
|
3038
|
+
</div>
|
|
3039
|
+
`;
|
|
3040
|
+
|
|
3041
|
+
// 定位
|
|
3042
|
+
const rect = btn.getBoundingClientRect();
|
|
3043
|
+
popup.style.top = (window.scrollY + rect.bottom + 4) + 'px';
|
|
3044
|
+
popup.style.left = (window.scrollX + rect.left) + 'px';
|
|
3045
|
+
document.body.appendChild(popup);
|
|
3046
|
+
|
|
3047
|
+
// 绑定按钮
|
|
3048
|
+
popup.querySelector('.dc-edit').onclick = () => {
|
|
3049
|
+
const newText = prompt('编辑判断力:', value);
|
|
3050
|
+
if (newText && newText.trim() && onEdit) onEdit(newText.trim());
|
|
3051
|
+
popup.remove();
|
|
3052
|
+
};
|
|
3053
|
+
popup.querySelector('.dc-reject').onclick = () => {
|
|
3054
|
+
if (onReject) onReject();
|
|
3055
|
+
popup.remove();
|
|
3056
|
+
};
|
|
3057
|
+
popup.querySelector('.dc-close').onclick = () => popup.remove();
|
|
3058
|
+
setTimeout(() => popup.remove(), 5000);
|
|
3059
|
+
}
|
|
3060
|
+
if (judgmentSubmitBtn) judgmentSubmitBtn.addEventListener('click', submitJudgment);
|
|
3061
|
+
|
|
3062
|
+
// 启动时拉一次, 让徽章显示总数 (不打开 modal 也能看到)
|
|
3063
|
+
loadJudgments();
|
|
3064
|
+
// 后台定期刷新 (与 modal 打开/关闭无关, 任何时候都保持徽章新鲜)
|
|
3065
|
+
setInterval(loadJudgments, 10000);
|
|
3066
|
+
|
|
3067
|
+
// ============================================================================
|
|
3068
|
+
// v3: P2P 好友 (known peers) + 收到的分享
|
|
3069
|
+
// ============================================================================
|
|
3070
|
+
|
|
3071
|
+
let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
|
|
3072
|
+
|
|
3073
|
+
async function loadRemoteChannels() {
|
|
3074
|
+
try {
|
|
3075
|
+
// 1) 拉 known peers (好友列表)
|
|
3076
|
+
const res = await fetch('/api/p2p-peers');
|
|
3077
|
+
if (res.ok) {
|
|
3078
|
+
const data = await res.json();
|
|
3079
|
+
knownPeers = Array.isArray(data.peers) ? data.peers : [];
|
|
3080
|
+
}
|
|
3081
|
+
// 2) 2026-06-10 修: 同时拉 /api/remote-channels, 兜底 SSE 推送漏掉的情况
|
|
3082
|
+
// (页面刷新后 remoteChannels[] = [], 必须主动拉一次才有数据)
|
|
3083
|
+
const r2 = await fetch('/api/remote-channels');
|
|
3084
|
+
if (r2.ok) {
|
|
3085
|
+
const data2 = await r2.json();
|
|
3086
|
+
const peers = Array.isArray(data2.peers) ? data2.peers : [];
|
|
3087
|
+
// 合并到 remoteChannels[]: 按 peerId 覆盖
|
|
3088
|
+
for (const p of peers) {
|
|
3089
|
+
let group = remoteChannels.find(g => g.peerId === p.peerId);
|
|
3090
|
+
if (!group) {
|
|
3091
|
+
group = { peerId: p.peerId, channels: [], peerName: ('peer-' + p.peerId.substring(0, 8)) };
|
|
3092
|
+
remoteChannels.push(group);
|
|
3093
|
+
}
|
|
3094
|
+
group.channels = p.channels || [];
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
renderRemoteChannels();
|
|
3098
|
+
// 3) 远端数据可能变化, 同步 @-mention 列表
|
|
3099
|
+
if (typeof refreshMentionChannels === 'function') {
|
|
3100
|
+
refreshMentionChannels();
|
|
3101
|
+
}
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
function renderRemoteChannels() {
|
|
3108
|
+
const list = document.getElementById('remote-channel-list');
|
|
3109
|
+
if (!list) return;
|
|
3110
|
+
|
|
3111
|
+
// 按 peerId 分组 channels
|
|
3112
|
+
const channelsByPeer = {};
|
|
3113
|
+
for (const p of remoteChannels) {
|
|
3114
|
+
channelsByPeer[p.peerId] = p.channels || [];
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
// 2026-06-10 修: 之前 UI 只渲染 knownPeers, 但对面 publicKey 可能跟本机 known_peers 不匹配
|
|
3118
|
+
// (例如对面重启 / 换 role / 第一次相连还没加为好友), 导致 remoteChannels 里有数据 UI 却空白.
|
|
3119
|
+
// 修法: 把 remoteChannels 里的 "陌生 peer" (不在 known_peers 里) 也渲染出来, 标记为未加好友.
|
|
3120
|
+
const knownPks = new Set(knownPeers.map(p => p.publicKey));
|
|
3121
|
+
const strangerPeers = remoteChannels
|
|
3122
|
+
.filter(p => !knownPks.has(p.peerId))
|
|
3123
|
+
.map(p => ({
|
|
3124
|
+
publicKey: p.peerId,
|
|
3125
|
+
name: p.peerName || ('未授权 ' + p.peerId.substring(0, 8)),
|
|
3126
|
+
lastConnectedAt: null,
|
|
3127
|
+
_isStranger: true
|
|
3128
|
+
}));
|
|
3129
|
+
const allPeers = [...knownPeers, ...strangerPeers];
|
|
3130
|
+
|
|
3131
|
+
if (allPeers.length === 0) {
|
|
3132
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
const html = allPeers.map(peer => {
|
|
3137
|
+
const peerChannels = channelsByPeer[peer.publicKey] || [];
|
|
3138
|
+
const lastConn = peer.lastConnectedAt
|
|
3139
|
+
? new Date(peer.lastConnectedAt).toLocaleDateString()
|
|
3140
|
+
: (peer._isStranger ? '陌生 peer' : '从未连接');
|
|
3141
|
+
const strangerStyle = peer._isStranger ? 'border:1px dashed var(--border-light);' : '';
|
|
3142
|
+
const strangerIcon = peer._isStranger ? '❔' : '👤';
|
|
3143
|
+
// 2026-06-11: 折叠逻辑 (全不展开)
|
|
3144
|
+
// - 所有 peer 首次见都默认 *折叠* (包括 known_peers 第一个) — 用户一进来看到完整 peer 列表
|
|
3145
|
+
// - 标题栏右侧 "X ch" 提示有内容, 用户点 caret 展开
|
|
3146
|
+
// - 已见过: 沿用 collapsedPeers (用户上次选择)
|
|
3147
|
+
// - "全部展开/折叠" 按钮在 P2P header (id=p2p-toggle-all-btn)
|
|
3148
|
+
if (!seenPeers.has(peer.publicKey)) {
|
|
3149
|
+
seenPeers.add(peer.publicKey);
|
|
3150
|
+
collapsedPeers.add(peer.publicKey); // 全部默认折叠
|
|
3151
|
+
saveSeenPeers();
|
|
3152
|
+
saveCollapsedPeers();
|
|
3153
|
+
}
|
|
3154
|
+
const isCollapsed = collapsedPeers.has(peer.publicKey);
|
|
3155
|
+
const caretChar = '▾'; // CSS rotate -90deg 处理折叠态
|
|
3156
|
+
return `
|
|
3157
|
+
<li class="remote-peer-group ${isCollapsed ? 'collapsed' : ''}" style="margin-bottom:10px;${strangerStyle}">
|
|
3158
|
+
<div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
|
|
3159
|
+
style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
|
|
3160
|
+
<button class="peer-caret-btn" data-toggle-peer="${escapeHtml(peer.publicKey)}" title="折叠/展开"
|
|
3161
|
+
style="background:var(--bg-active);border:1px solid var(--border);color:var(--text);cursor:pointer;width:22px;height:22px;border-radius:4px;font-size:12px;line-height:1;padding:0;display:flex;align-items:center;justify-content:center;flex:0 0 auto;">${caretChar}</button>
|
|
3162
|
+
<span style="font-size:13px;">${strangerIcon}</span>
|
|
3163
|
+
<span style="flex:1;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
|
|
3164
|
+
<span style="font-size:9px;color:var(--text-muted);">${peerChannels.length > 0 ? `${peerChannels.length} ch · ` : ''}${lastConn}</span>
|
|
3165
|
+
<button class="peer-share-btn" title="分享 channel 给 ${escapeHtml(peer.name)}"
|
|
3166
|
+
style="background:transparent;border:1px solid var(--border);color:var(--text);cursor:pointer;width:22px;height:22px;border-radius:4px;font-size:12px;line-height:1;padding:0;display:flex;align-items:center;justify-content:center;flex:0 0 auto;">📤</button>
|
|
3167
|
+
</div>
|
|
3168
|
+
<div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
|
|
3169
|
+
${peerChannels.length === 0
|
|
3170
|
+
? '<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;">(对方还没分享 channel 给你)</div>'
|
|
3171
|
+
: peerChannels.map(c => `
|
|
3172
|
+
<div class="remote-channel-row" data-peer-id="${escapeHtml(peer.publicKey)}" data-channel-id="${escapeHtml(c.id)}"
|
|
3173
|
+
style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
|
|
3174
|
+
<span>🤖</span>
|
|
3175
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
|
|
3176
|
+
</div>
|
|
3177
|
+
`).join('')
|
|
3178
|
+
}
|
|
3179
|
+
</div>
|
|
3180
|
+
</li>
|
|
3181
|
+
`;
|
|
3182
|
+
}).join('');
|
|
3183
|
+
list.innerHTML = html;
|
|
3184
|
+
|
|
3185
|
+
// 2026-06-10: 折叠按钮点击 → 切折叠 (stopPropagation 防止冒泡触发 header 的分享 modal)
|
|
3186
|
+
list.querySelectorAll('.peer-caret-btn[data-toggle-peer]').forEach(btn => {
|
|
3187
|
+
btn.addEventListener('click', (e) => {
|
|
3188
|
+
e.stopPropagation();
|
|
3189
|
+
const pk = btn.getAttribute('data-toggle-peer');
|
|
3190
|
+
togglePeerCollapsed(pk);
|
|
3191
|
+
});
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
// 绑定: 点击 channel → 弹聊天窗口
|
|
3195
|
+
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
3196
|
+
row.addEventListener('click', () => {
|
|
3197
|
+
const peerId = row.dataset.peerId;
|
|
3198
|
+
const channelId = row.dataset.channelId;
|
|
3199
|
+
const channelName = row.querySelector('span[title]')?.getAttribute('title') || channelId;
|
|
3200
|
+
console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
|
|
3201
|
+
openRemoteChannelChat(peerId, channelId, channelName);
|
|
3202
|
+
});
|
|
3203
|
+
});
|
|
3204
|
+
// 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
|
|
3205
|
+
// 2026-06-15 修正: 整块 click 改成显式 "📤 分享" 按钮触发, 避免 caret 折叠与分享 modal 冲突
|
|
3206
|
+
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
3207
|
+
const shareBtn = row.querySelector('.peer-share-btn');
|
|
3208
|
+
if (shareBtn) {
|
|
3209
|
+
shareBtn.addEventListener('click', (e) => {
|
|
3210
|
+
e.stopPropagation();
|
|
3211
|
+
const peerName = row.dataset.peerName;
|
|
3212
|
+
const peerPk = row.dataset.peerPk;
|
|
3213
|
+
openShareToPeerModal(peerName, peerPk);
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
// 2026-06-10: 每个 peer 头部双击 → 改名字 / 改备注
|
|
3219
|
+
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
3220
|
+
row.addEventListener('dblclick', (e) => {
|
|
3221
|
+
if (e.target.closest('.peer-caret-btn')) return;
|
|
3222
|
+
const peerName = row.dataset.peerName;
|
|
3223
|
+
const peerPk = row.dataset.peerPk;
|
|
3224
|
+
openEditPeerModal(peerName, peerPk);
|
|
3225
|
+
});
|
|
3226
|
+
});
|
|
3227
|
+
|
|
3228
|
+
// 2026-06-10: 渲染完成后同步 header 切换按钮图标
|
|
3229
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/** v3: 改 peer 名字 / 备注 modal (持久化到 known_peers.json) */
|
|
3233
|
+
async function openEditPeerModal(peerName, peerPublicKey) {
|
|
3234
|
+
document.getElementById('edit-peer-modal')?.remove();
|
|
3235
|
+
// 先读 known_peers 拿到现有 notes
|
|
3236
|
+
let currentNotes = '';
|
|
3237
|
+
let currentName = peerName;
|
|
3238
|
+
try {
|
|
3239
|
+
const r = await fetch('/api/p2p-peers');
|
|
3240
|
+
if (r.ok) {
|
|
3241
|
+
const d = await r.json();
|
|
3242
|
+
const entry = (d.peers || []).find(p => p.publicKey === peerPublicKey);
|
|
3243
|
+
if (entry) {
|
|
3244
|
+
currentName = entry.name || peerName;
|
|
3245
|
+
currentNotes = entry.notes || '';
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
} catch {}
|
|
3249
|
+
const html = `
|
|
3250
|
+
<div id="edit-peer-modal" class="friend-req-overlay">
|
|
3251
|
+
<div class="friend-req-shell" style="width:520px;">
|
|
3252
|
+
<div class="friend-req-header">
|
|
3253
|
+
<span style="font-size:18px;">✏️</span>
|
|
3254
|
+
<div style="flex:1;min-width:0;">
|
|
3255
|
+
<div class="friend-req-title">编辑好友</div>
|
|
3256
|
+
<div class="friend-req-meta">publicKey: ${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
3257
|
+
</div>
|
|
3258
|
+
</div>
|
|
3259
|
+
<div class="friend-req-body">
|
|
3260
|
+
<label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">显示名字</label>
|
|
3261
|
+
<input id="epm-name" type="text" value="${escapeHtml(currentName)}"
|
|
3262
|
+
style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-main);color:var(--text);font-family:inherit;font-size:13px;box-sizing:border-box;margin-bottom:12px;">
|
|
3263
|
+
<label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">备注 (自由文本, 例如合作领域 / 怎么认识的)</label>
|
|
3264
|
+
<textarea id="epm-notes" rows="4" placeholder="例如: 2026-06 合作 LLM 代发验证"
|
|
3265
|
+
style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-main);color:var(--text);font-family:inherit;font-size:13px;box-sizing:border-box;resize:vertical;">${escapeHtml(currentNotes)}</textarea>
|
|
3266
|
+
</div>
|
|
3267
|
+
<div class="friend-req-actions">
|
|
3268
|
+
<button id="epm-cancel" class="friend-req-btn-deny">取消</button>
|
|
3269
|
+
<button id="epm-save" class="friend-req-btn-accept">保存</button>
|
|
3270
|
+
</div>
|
|
3271
|
+
</div>
|
|
3272
|
+
</div>
|
|
3273
|
+
`;
|
|
3274
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3275
|
+
const close = () => document.getElementById('edit-peer-modal')?.remove();
|
|
3276
|
+
document.getElementById('epm-cancel').onclick = close;
|
|
3277
|
+
document.getElementById('epm-save').onclick = async () => {
|
|
3278
|
+
const newName = document.getElementById('epm-name').value.trim() || currentName;
|
|
3279
|
+
const newNotes = document.getElementById('epm-notes').value;
|
|
3280
|
+
try {
|
|
3281
|
+
const r = await fetch(`/api/p2p-peers/${encodeURIComponent(peerName)}`, {
|
|
3282
|
+
method: 'PATCH',
|
|
3283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3284
|
+
body: JSON.stringify({ name: newName, notes: newNotes })
|
|
3285
|
+
});
|
|
3286
|
+
const data = await r.json();
|
|
3287
|
+
if (!r.ok) throw new Error(data.error || 'save failed');
|
|
3288
|
+
console.log('[v3] 改 peer 成功:', newName, '备注:', newNotes);
|
|
3289
|
+
showSimpleToast(`✅ 已保存 ${newName}`);
|
|
3290
|
+
close();
|
|
3291
|
+
// 重新拉 known_peers + 远程 channels 重新渲染
|
|
3292
|
+
const r2 = await fetch('/api/p2p-peers');
|
|
3293
|
+
if (r2.ok) {
|
|
3294
|
+
const d2 = await r2.json();
|
|
3295
|
+
knownPeers = Array.isArray(d2.peers) ? d2.peers : [];
|
|
3296
|
+
}
|
|
3297
|
+
renderRemoteChannels();
|
|
3298
|
+
} catch (err) {
|
|
3299
|
+
console.error('[v3] 保存 peer 失败:', err);
|
|
3300
|
+
alert('保存失败: ' + (err.message || err));
|
|
3301
|
+
}
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
|
|
3306
|
+
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) — 2026-06-11 改用 Step 3 风格 class */
|
|
3307
|
+
async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
3308
|
+
document.getElementById('share-to-peer-modal')?.remove();
|
|
3309
|
+
let allChannels = [];
|
|
3310
|
+
try {
|
|
3311
|
+
const res = await fetch('/channels');
|
|
3312
|
+
if (res.ok) allChannels = await res.json();
|
|
3313
|
+
} catch (err) { console.error('openShareToPeerModal:', err); }
|
|
3314
|
+
const rows = allChannels.length === 0
|
|
3315
|
+
? '<div class="share-modal-empty">还没有 channel</div>'
|
|
3316
|
+
: allChannels.map(ch => {
|
|
3317
|
+
const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
3318
|
+
return `
|
|
3319
|
+
<label class="share-modal-row">
|
|
3320
|
+
<input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} class="share-modal-cb">
|
|
3321
|
+
<div class="share-modal-row-info">
|
|
3322
|
+
<div class="share-modal-row-name">${escapeHtml(ch.name)}</div>
|
|
3323
|
+
<div class="share-modal-row-meta">
|
|
3324
|
+
${isShared ? '✓ 已分享' : '未分享'} · ${escapeHtml(ch.id.slice(0, 24))}…
|
|
3325
|
+
</div>
|
|
3326
|
+
</div>
|
|
3327
|
+
</label>
|
|
3328
|
+
`;
|
|
3329
|
+
}).join('');
|
|
3330
|
+
const html = `
|
|
3331
|
+
<div id="share-to-peer-modal" class="friend-req-overlay">
|
|
3332
|
+
<div class="friend-req-shell share-modal-shell">
|
|
3333
|
+
<div class="friend-req-header">
|
|
3334
|
+
<span style="font-size:18px;">📤</span>
|
|
3335
|
+
<div style="flex:1;min-width:0;">
|
|
3336
|
+
<div class="friend-req-title">分享 channel 给 ${escapeHtml(peerName)}</div>
|
|
3337
|
+
<div class="friend-req-meta">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
3338
|
+
</div>
|
|
3339
|
+
<button id="spm-close" class="friend-req-btn-close">×</button>
|
|
3340
|
+
</div>
|
|
3341
|
+
<div class="share-modal-hint">勾选要分享的 channel, 对方才能看到</div>
|
|
3342
|
+
<div id="spm-list" class="share-modal-list">${rows}</div>
|
|
3343
|
+
<div class="friend-req-actions">
|
|
3344
|
+
<button id="spm-cancel" class="friend-req-btn-deny">取消</button>
|
|
3345
|
+
<button id="spm-save" class="friend-req-btn-accept">保存分享</button>
|
|
3346
|
+
</div>
|
|
3347
|
+
</div>
|
|
3348
|
+
</div>
|
|
3349
|
+
`;
|
|
3350
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3351
|
+
const overlay = document.getElementById('share-to-peer-modal');
|
|
3352
|
+
|
|
3353
|
+
// 关闭函数 — 集中处理 (ESC / backdrop / × / 取消 共用)
|
|
3354
|
+
const closeModal = () => {
|
|
3355
|
+
overlay.remove();
|
|
3356
|
+
document.removeEventListener('keydown', onEsc);
|
|
3357
|
+
};
|
|
3358
|
+
|
|
3359
|
+
// ESC 关闭
|
|
3360
|
+
const onEsc = (e) => {
|
|
3361
|
+
if (e.key === 'Escape') closeModal();
|
|
3362
|
+
};
|
|
3363
|
+
document.addEventListener('keydown', onEsc);
|
|
3364
|
+
|
|
3365
|
+
// × 关闭
|
|
3366
|
+
document.getElementById('spm-close').onclick = closeModal;
|
|
3367
|
+
// 取消关闭
|
|
3368
|
+
document.getElementById('spm-cancel').onclick = closeModal;
|
|
3369
|
+
// 点击 overlay 背景关闭 (点到 shell 不关)
|
|
3370
|
+
overlay.onclick = (e) => {
|
|
3371
|
+
if (e.target === overlay) closeModal();
|
|
3372
|
+
};
|
|
3373
|
+
document.getElementById('spm-save').onclick = async () => {
|
|
3374
|
+
const checkedIds = [...overlay.querySelectorAll('input[type=checkbox][data-cid]:checked')].map(el => el.dataset.cid);
|
|
3375
|
+
// 对每个 channel 单独 PATCH — 设 shared_with_peers 为 checked 列表
|
|
3376
|
+
let ok = 0, fail = 0;
|
|
3377
|
+
for (const ch of allChannels) {
|
|
3378
|
+
const shouldShare = checkedIds.includes(ch.id);
|
|
3379
|
+
const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
3380
|
+
if (shouldShare === wasShared) continue;
|
|
3381
|
+
const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
|
|
3382
|
+
if (shouldShare) newList.push(peerPublicKey);
|
|
3383
|
+
try {
|
|
3384
|
+
const res = await fetch(`/channels/${encodeURIComponent(ch.id)}`, {
|
|
3385
|
+
method: 'PATCH',
|
|
3386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3387
|
+
body: JSON.stringify({ shared_with_peers: newList })
|
|
3388
|
+
});
|
|
3389
|
+
if (res.ok) ok++; else fail++;
|
|
3390
|
+
} catch { fail++; }
|
|
3391
|
+
}
|
|
3392
|
+
showSimpleToast(`分享更新完成: 成功 ${ok}, 失败 ${fail}`, ok > 0 ? 'info' : (fail > 0 ? 'error' : 'info'));
|
|
3393
|
+
overlay.remove();
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/** v3: 跟远端 channel 聊天的简易弹窗
|
|
3398
|
+
* 2026-06-10 重写: UI 完全对齐本地聊天 (复用 addMessage / .messages / .bubble 整套样式),
|
|
3399
|
+
* marked.parse + cleanThink + cleanEnv 自动生效, 不再裸文本.
|
|
3400
|
+
*/
|
|
3401
|
+
function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
3402
|
+
// 移除已有 modal
|
|
3403
|
+
document.getElementById('remote-chat-modal')?.remove();
|
|
3404
|
+
const html = `
|
|
3405
|
+
<div id="remote-chat-modal" class="remote-chat-overlay">
|
|
3406
|
+
<div class="remote-chat-shell">
|
|
3407
|
+
<div class="remote-chat-header">
|
|
3408
|
+
<div style="flex:1;min-width:0;">
|
|
3409
|
+
<div class="remote-chat-title">🌐 跟 ${escapeHtml(channelName)} 聊天</div>
|
|
3410
|
+
<div class="remote-chat-meta">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
|
|
3411
|
+
</div>
|
|
3412
|
+
<button id="rcm-refresh-history" title="重新拉历史" class="remote-chat-btn-secondary">↻ 历史</button>
|
|
3413
|
+
<button id="rcm-close" class="remote-chat-btn-close">×</button>
|
|
3414
|
+
</div>
|
|
3415
|
+
<div id="rcm-thinking" class="remote-chat-thinking" style="display:none;">
|
|
3416
|
+
📥 正在从远端拉历史 + 判断力…
|
|
3417
|
+
</div>
|
|
3418
|
+
<div id="rcm-log" class="messages remote-chat-log"></div>
|
|
3419
|
+
<div class="remote-chat-input-row">
|
|
3420
|
+
<input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..." class="remote-chat-input">
|
|
3421
|
+
<button id="rcm-send" class="remote-chat-btn-send">发送</button>
|
|
3422
|
+
</div>
|
|
3423
|
+
</div>
|
|
3424
|
+
</div>
|
|
3425
|
+
`;
|
|
3426
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3427
|
+
|
|
3428
|
+
const log = document.getElementById('rcm-log');
|
|
3429
|
+
const inputEl = document.getElementById('rcm-input');
|
|
3430
|
+
const sendBtn = document.getElementById('rcm-send');
|
|
3431
|
+
const thinkingEl = document.getElementById('rcm-thinking');
|
|
3432
|
+
let historyRefreshTimer = null;
|
|
3433
|
+
document.getElementById('rcm-close').onclick = () => {
|
|
3434
|
+
if (historyRefreshTimer) { clearInterval(historyRefreshTimer); historyRefreshTimer = null; }
|
|
3435
|
+
document.getElementById('remote-chat-modal').remove();
|
|
3436
|
+
};
|
|
3437
|
+
document.getElementById('rcm-refresh-history').onclick = () => loadHistory(false);
|
|
3438
|
+
|
|
3439
|
+
// 2026-06-10 改: 直接复用本地 addMessage, 自动获得 marked + think 折叠 + env 折叠 + 主题变量
|
|
3440
|
+
const append = (text, role) => {
|
|
3441
|
+
addMessage(text, role === 'user' ? 'user' : 'ai', false, log);
|
|
3442
|
+
log.scrollTop = log.scrollHeight;
|
|
3443
|
+
};
|
|
3444
|
+
|
|
3445
|
+
// 系统提示用更轻量的样式 (不走 addMessage, 避免被当聊天记录裁剪)
|
|
3446
|
+
const appendSystem = (text, kind = 'info') => {
|
|
3447
|
+
const el = document.createElement('div');
|
|
3448
|
+
el.className = `remote-chat-sysmsg remote-chat-sysmsg-${kind}`;
|
|
3449
|
+
el.textContent = text;
|
|
3450
|
+
log.appendChild(el);
|
|
3451
|
+
log.scrollTop = log.scrollHeight;
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
// v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
|
|
3455
|
+
async function loadHistory(isSilent) {
|
|
3456
|
+
if (!document.getElementById('remote-chat-modal')) return; // modal 已关闭
|
|
3457
|
+
|
|
3458
|
+
if (isSilent) {
|
|
3459
|
+
try {
|
|
3460
|
+
const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
|
|
3461
|
+
if (!res.ok || !document.getElementById('remote-chat-modal')) return;
|
|
3462
|
+
const data = await res.json();
|
|
3463
|
+
const newMsgs = data.messages || [];
|
|
3464
|
+
const oldCount = log.querySelectorAll('.message').length;
|
|
3465
|
+
if (newMsgs.length === oldCount) return;
|
|
3466
|
+
const scrollWasAtBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
|
|
3467
|
+
renderHistory(data);
|
|
3468
|
+
if (scrollWasAtBottom) {
|
|
3469
|
+
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
3470
|
+
}
|
|
3471
|
+
} catch (_) { /* 静默失败 */ }
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
thinkingEl.style.display = 'block';
|
|
3476
|
+
log.innerHTML = '';
|
|
3477
|
+
try {
|
|
3478
|
+
const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
|
|
3479
|
+
const data = await res.json();
|
|
3480
|
+
if (!res.ok) {
|
|
3481
|
+
appendSystem(`拉取失败: ${data.error || 'unknown'}`, 'error');
|
|
3482
|
+
thinkingEl.style.display = 'none';
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
renderHistory(data);
|
|
3486
|
+
} catch (err) {
|
|
3487
|
+
appendSystem(`拉取异常: ${err.message}`, 'error');
|
|
3488
|
+
} finally {
|
|
3489
|
+
thinkingEl.style.display = 'none';
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function renderHistory(data) {
|
|
3494
|
+
log.innerHTML = '';
|
|
3495
|
+
|
|
3496
|
+
// 1. 显示 judgment 依据 (header) — 保留, 但用 class 化样式
|
|
3497
|
+
const judgments = data.judgments || { bound: [], candidates: [] };
|
|
3498
|
+
if (judgments.bound && judgments.bound.length > 0) {
|
|
3499
|
+
const jh = document.createElement('div');
|
|
3500
|
+
jh.className = 'remote-chat-judgments';
|
|
3501
|
+
let h = `<div class="remote-chat-judgments-title">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
|
|
3502
|
+
for (const j of judgments.bound) {
|
|
3503
|
+
h += `<div class="remote-chat-judgment-item">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span class="remote-chat-judgment-tag"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span class="remote-chat-judgment-reason">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
|
|
3504
|
+
}
|
|
3505
|
+
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
3506
|
+
h += `<div class="remote-chat-judgments-foot">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
|
|
3507
|
+
}
|
|
3508
|
+
jh.innerHTML = h;
|
|
3509
|
+
log.appendChild(jh);
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
// 2. 显示历史 messages — 完全复用本地 addMessage 渲染
|
|
3513
|
+
const msgs = data.messages || [];
|
|
3514
|
+
if (msgs.length === 0) {
|
|
3515
|
+
appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
|
|
3516
|
+
} else {
|
|
3517
|
+
for (const m of msgs) {
|
|
3518
|
+
// 远端 owner 的 user 消息 vs 远端访客 (B) 的 user 消息 vs A 的 LLM 回复
|
|
3519
|
+
// 全部走 addMessage, 让 marked/think/env 自动处理. 来源用一个小 prefix 标记.
|
|
3520
|
+
const type = m.type === 'user' ? 'user' : 'ai';
|
|
3521
|
+
let prefix = '';
|
|
3522
|
+
if (m.type === 'user') {
|
|
3523
|
+
if (m.source === 'remote') {
|
|
3524
|
+
prefix = `🌐 远端访客${m.fromPublicKey ? ' (' + m.fromPublicKey.substring(0, 8) + '…)' : ''}\n\n`;
|
|
3525
|
+
} else {
|
|
3526
|
+
prefix = `👤 A (内部 owner)\n\n`;
|
|
3527
|
+
}
|
|
3528
|
+
} else {
|
|
3529
|
+
prefix = `🤖 A 的 LLM\n\n`;
|
|
3530
|
+
}
|
|
3531
|
+
addMessage(prefix + (m.content || ''), type, false, log);
|
|
3532
|
+
}
|
|
3533
|
+
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
const doSend = async () => {
|
|
3538
|
+
const text = inputEl.value.trim();
|
|
3539
|
+
if (!text) return;
|
|
3540
|
+
append(text, 'user');
|
|
3541
|
+
inputEl.value = '';
|
|
3542
|
+
sendBtn.disabled = true;
|
|
3543
|
+
sendBtn.textContent = '...';
|
|
3544
|
+
try {
|
|
3545
|
+
const res = await fetch('/api/remote-channels/chat-send', {
|
|
3546
|
+
method: 'POST',
|
|
3547
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3548
|
+
body: JSON.stringify({ targetPublicKey: peerPublicKey, channelId, text })
|
|
3549
|
+
});
|
|
3550
|
+
const data = await res.json();
|
|
3551
|
+
if (!res.ok) throw new Error(data.error || 'send failed');
|
|
3552
|
+
// 不再 appendSystem('已发送...') —— 用户看到自己消息已上屏就知道, 系统提示是噪音
|
|
3553
|
+
} catch (err) {
|
|
3554
|
+
appendSystem('发送失败: ' + (err.message || err), 'error');
|
|
3555
|
+
} finally {
|
|
3556
|
+
sendBtn.disabled = false;
|
|
3557
|
+
sendBtn.textContent = '发送';
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
sendBtn.onclick = doSend;
|
|
3561
|
+
inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
|
|
3562
|
+
// v3 新增: B 端远端 chat 也支持 @-autocomplete
|
|
3563
|
+
setupMentionAutocomplete(inputEl);
|
|
3564
|
+
inputEl.focus();
|
|
3565
|
+
startV3GlobalSSE();
|
|
3566
|
+
|
|
3567
|
+
// 打开时立即拉历史
|
|
3568
|
+
loadHistory(false);
|
|
3569
|
+
|
|
3570
|
+
// 每 15 秒自动静默刷新, 同步远端 owner 或其他访客的新消息
|
|
3571
|
+
historyRefreshTimer = setInterval(() => loadHistory(true), 15000);
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
// Phase 3: 我的 ID 按钮 → 真 modal (避免 confirm 在某些环境被禁用)
|
|
3575
|
+
const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
|
|
3576
|
+
if (showMyIdBtn) {
|
|
3577
|
+
showMyIdBtn.addEventListener('click', async (e) => {
|
|
3578
|
+
e.stopPropagation();
|
|
3579
|
+
// 移除已有 modal
|
|
3580
|
+
document.getElementById('my-p2p-id-modal')?.remove();
|
|
3581
|
+
// 立即弹出 loading 状态 modal
|
|
3582
|
+
const html = `
|
|
3583
|
+
<div id="my-p2p-id-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10003;display:flex;align-items:center;justify-content:center;">
|
|
3584
|
+
<div style="background:#fff;border-radius:8px;width:480px;max-width:92vw;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
|
|
3585
|
+
<div style="padding:14px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
|
|
3586
|
+
<div style="font-size:15px;font-weight:600;">🪪 我的 P2P 身份</div>
|
|
3587
|
+
<button id="mpim-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
|
|
3588
|
+
</div>
|
|
3589
|
+
<div id="mpim-body" style="padding:16px 18px;">
|
|
3590
|
+
<div style="color:#6b7280;font-size:13px;margin-bottom:10px;">正在获取 publicKey…</div>
|
|
3591
|
+
</div>
|
|
3592
|
+
</div>
|
|
3593
|
+
</div>
|
|
3594
|
+
`;
|
|
3595
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3596
|
+
document.getElementById('mpim-close').onclick = () => document.getElementById('my-p2p-id-modal').remove();
|
|
3597
|
+
|
|
3598
|
+
try {
|
|
3599
|
+
const res = await fetch('/api/p2p-publickey');
|
|
3600
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3601
|
+
const data = await res.json();
|
|
3602
|
+
const pk = data.publicKey || '';
|
|
3603
|
+
const body = document.getElementById('mpim-body');
|
|
3604
|
+
if (!pk || pk.length !== 64) {
|
|
3605
|
+
body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ P2PDirect 还没启动, 刷新页面稍后再试</div>`;
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
body.innerHTML = `
|
|
3609
|
+
<div style="font-size:12px;color:#6b7280;margin-bottom:8px;">把下面这串发给好友, 好友在 P2P 好友区点 "+ 好友" 粘贴即可加你:</div>
|
|
3610
|
+
<div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;">
|
|
3611
|
+
<code id="mpim-pk" style="flex:1;padding:8px 10px;background:#f3f4f6;border:1px solid #d1d5db;border-radius:4px;font-family:monospace;font-size:11px;word-break:break-all;line-height:1.4;">${escapeHtml(pk)}</code>
|
|
3612
|
+
<button id="mpim-copy" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;white-space:nowrap;">📋 复制</button>
|
|
3613
|
+
</div>
|
|
3614
|
+
<div id="mpim-status" style="font-size:12px;color:#059669;min-height:16px;"></div>
|
|
3615
|
+
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #e5e7eb;font-size:11px;color:#6b7280;">
|
|
3616
|
+
💡 同一个 role 重启后 publicKey 不会变, 好友不需要重新加你.
|
|
3617
|
+
</div>
|
|
3618
|
+
`;
|
|
3619
|
+
document.getElementById('mpim-copy').onclick = async () => {
|
|
3620
|
+
const statusEl = document.getElementById('mpim-status');
|
|
3621
|
+
try {
|
|
3622
|
+
await navigator.clipboard.writeText(pk);
|
|
3623
|
+
statusEl.textContent = '✓ 已复制到剪贴板';
|
|
3624
|
+
} catch {
|
|
3625
|
+
const ta = document.createElement('textarea');
|
|
3626
|
+
ta.value = pk;
|
|
3627
|
+
ta.style.position = 'fixed';
|
|
3628
|
+
ta.style.opacity = '0';
|
|
3629
|
+
document.body.appendChild(ta);
|
|
3630
|
+
ta.select();
|
|
3631
|
+
try { document.execCommand('copy'); statusEl.textContent = '✓ 已复制 (fallback)'; }
|
|
3632
|
+
catch { statusEl.textContent = '✗ 复制失败, 请手动选中复制'; }
|
|
3633
|
+
document.body.removeChild(ta);
|
|
3634
|
+
}
|
|
3635
|
+
};
|
|
3636
|
+
} catch (err) {
|
|
3637
|
+
const body = document.getElementById('mpim-body');
|
|
3638
|
+
if (body) body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ 获取失败: ${escapeHtml(err.message || String(err))}</div>`;
|
|
3639
|
+
}
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
// Phase 3 重做: + 添加好友按钮 → 弹窗输入 publicKey + name, 同时 joinPeer
|
|
3644
|
+
const addPeerBtn = document.getElementById('add-p2p-peer-btn');
|
|
3645
|
+
if (addPeerBtn) {
|
|
3646
|
+
addPeerBtn.addEventListener('click', async (e) => {
|
|
3647
|
+
e.stopPropagation();
|
|
3648
|
+
const name = prompt('给这个 P2P 好友起个名字 (如: 同事-张磊)');
|
|
3649
|
+
if (!name) return;
|
|
3650
|
+
const publicKey = prompt('粘贴对方的 P2PDirect publicKey (64 字符 hex):\n\n获取方式: 对方在 http://localhost:54188/api/p2p-publickey');
|
|
3651
|
+
if (!publicKey) return;
|
|
3652
|
+
if (publicKey.length !== 64) {
|
|
3653
|
+
alert('publicKey 长度不对, 应该是 64 字符 hex');
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3656
|
+
try {
|
|
3657
|
+
// v3 新增: 改用 friend-request RPC — 不光 joinPeer, 还发申请到对方
|
|
3658
|
+
// 对方会收到 SSE friend-request 事件, 弹一个申请 modal
|
|
3659
|
+
const res = await fetch('/api/friend-request', {
|
|
3660
|
+
method: 'POST',
|
|
3661
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3662
|
+
body: JSON.stringify({ targetPublicKey: publicKey, name, message: '想加你为 P2P 好友, 共享 channel 协作' })
|
|
3663
|
+
});
|
|
3664
|
+
const data = await res.json();
|
|
3665
|
+
if (res.status === 502) {
|
|
3666
|
+
// 2026-06-10: 区分"对方不在线"和"写失败" — 让用户知道是否需要重试
|
|
3667
|
+
const reason = data.code === 'NO_CONN' ? '对方未在线或 P2P 握手超时' : '写入 P2P 通道失败';
|
|
3668
|
+
alert(`好友申请发送失败: ${reason}\n\n本地已记住对方 publicKey (${publicKey.substring(0,8)}...), 等对方上线后可在 P2P 面板手动重试.`);
|
|
3669
|
+
await loadRemoteChannels();
|
|
3670
|
+
return;
|
|
3671
|
+
}
|
|
3672
|
+
if (!res.ok) throw new Error(data.error || 'connect failed');
|
|
3673
|
+
// 成功 — 但不阻塞地等 ack (ack 经 SSE 'friend-request-ack' 推回, 由 v3GlobalEventSource 处理)
|
|
3674
|
+
window.__pendingFriendRequests = window.__pendingFriendRequests || new Map();
|
|
3675
|
+
if (data.requestId) {
|
|
3676
|
+
window.__pendingFriendRequests.set(data.requestId, { name, publicKey, at: Date.now() });
|
|
3677
|
+
// 8s 后还没 ack → 提示用户对方可能跑旧版 (无 ack 协议)
|
|
3678
|
+
setTimeout(() => {
|
|
3679
|
+
if (window.__pendingFriendRequests.has(data.requestId)) {
|
|
3680
|
+
window.__pendingFriendRequests.delete(data.requestId);
|
|
3681
|
+
console.warn(`[v3-friend] 申请超时未收到 ack (requestId=${data.requestId.substring(0,8)})`);
|
|
3682
|
+
showSimpleToast(`⚠️ 对方未确认收到 (可能是旧版客户端, 申请已发出但无法验证)`, 'warn');
|
|
3683
|
+
}
|
|
3684
|
+
}, 8000);
|
|
3685
|
+
}
|
|
3686
|
+
alert(`已发送好友申请给 ${name} (${publicKey.substring(0, 12)}...)\n对方收到后自己端弹申请 modal, 接受后会出现在 P2P 好友区.`);
|
|
3687
|
+
await loadRemoteChannels();
|
|
3688
|
+
} catch (err) {
|
|
3689
|
+
alert('申请失败: ' + (err.message || err));
|
|
3690
|
+
}
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/**
|
|
3695
|
+
* v3 新增: 收到好友申请时, 弹一个 modal 让用户接受或拒绝
|
|
3696
|
+
*/
|
|
3697
|
+
function showFriendRequestModal(req) {
|
|
3698
|
+
// 移除已有 modal
|
|
3699
|
+
document.getElementById('friend-request-modal')?.remove();
|
|
3700
|
+
// 2026-06-10: 同 Step 3 远端 chat modal 一样, 改用 class + CSS 变量, 跟本地风格统一
|
|
3701
|
+
const html = `
|
|
3702
|
+
<div id="friend-request-modal" class="friend-req-overlay">
|
|
3703
|
+
<div class="friend-req-shell">
|
|
3704
|
+
<div class="friend-req-header">
|
|
3705
|
+
<span style="font-size:20px;">🤝</span>
|
|
3706
|
+
<div style="flex:1;min-width:0;">
|
|
3707
|
+
<div class="friend-req-title">好友申请</div>
|
|
3708
|
+
<div class="friend-req-meta">来自 ${escapeHtml(req.fromName)} (${escapeHtml(req.fromPublicKey.substring(0, 16))}…)</div>
|
|
3709
|
+
</div>
|
|
3710
|
+
</div>
|
|
3711
|
+
<div class="friend-req-body">
|
|
3712
|
+
<p style="margin:0 0 8px;">${escapeHtml(req.message || '想加你为 P2P 好友')}</p>
|
|
3713
|
+
<p style="margin:0;color:var(--text-muted);font-size:11px;">接受后: 双方互加好友, 对方分享的 channel 会自动出现在 P2P 好友区.</p>
|
|
3714
|
+
</div>
|
|
3715
|
+
<div class="friend-req-actions">
|
|
3716
|
+
<button id="frm-deny" class="friend-req-btn-deny">拒绝</button>
|
|
3717
|
+
<button id="frm-accept" class="friend-req-btn-accept">接受</button>
|
|
3718
|
+
</div>
|
|
3719
|
+
</div>
|
|
3720
|
+
</div>
|
|
3721
|
+
`;
|
|
3722
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3723
|
+
const close = () => document.getElementById('friend-request-modal')?.remove();
|
|
3724
|
+
document.getElementById('frm-deny').onclick = close;
|
|
3725
|
+
document.getElementById('frm-accept').onclick = async () => {
|
|
3726
|
+
close();
|
|
3727
|
+
try {
|
|
3728
|
+
const res = await fetch('/api/friend-accept', {
|
|
3729
|
+
method: 'POST',
|
|
3730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3731
|
+
body: JSON.stringify({ fromPublicKey: req.fromPublicKey, name: req.fromName })
|
|
3732
|
+
});
|
|
3733
|
+
const data = await res.json();
|
|
3734
|
+
if (!res.ok) throw new Error(data.error || 'accept failed');
|
|
3735
|
+
console.log('[v3-friend] 接受了好友申请:', req.fromName);
|
|
3736
|
+
// 立刻拉一次 — 对方刚 accept, ta 的 channel 列表会被推到我们这
|
|
3737
|
+
setTimeout(loadRemoteChannels, 1000);
|
|
3738
|
+
showSimpleToast(`✅ 已接受 ${req.fromName} 的好友申请`);
|
|
3739
|
+
} catch (err) {
|
|
3740
|
+
console.error('[v3-friend] accept 失败:', err);
|
|
3741
|
+
alert('接受失败: ' + (err.message || err));
|
|
3742
|
+
}
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
/**
|
|
3747
|
+
* 2026-06-10: 简单的右下 toast, 3s 自动消失. 用于 ack / 接受好友 等非阻塞反馈
|
|
3748
|
+
*/
|
|
3749
|
+
function showSimpleToast(text, kind = 'info') {
|
|
3750
|
+
const containerId = 'simple-toast-container';
|
|
3751
|
+
let container = document.getElementById(containerId);
|
|
3752
|
+
if (!container) {
|
|
3753
|
+
container = document.createElement('div');
|
|
3754
|
+
container.id = containerId;
|
|
3755
|
+
container.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:10005;display:flex;flex-direction:column;gap:8px;max-width:320px;';
|
|
3756
|
+
document.body.appendChild(container);
|
|
3757
|
+
}
|
|
3758
|
+
const el = document.createElement('div');
|
|
3759
|
+
el.className = `simple-toast simple-toast-${kind}`;
|
|
3760
|
+
el.style.cssText = `background:var(--bg-sidebar);color:var(--text);border:1px solid var(--border);padding:10px 14px;border-radius:6px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,0.3);font-family:inherit;animation:toast-in .2s ease-out;`;
|
|
3761
|
+
el.textContent = text;
|
|
3762
|
+
container.appendChild(el);
|
|
3763
|
+
setTimeout(() => {
|
|
3764
|
+
el.style.transition = 'opacity .3s, transform .3s';
|
|
3765
|
+
el.style.opacity = '0';
|
|
3766
|
+
el.style.transform = 'translateX(20px)';
|
|
3767
|
+
setTimeout(() => el.remove(), 320);
|
|
3768
|
+
}, 3000);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
// 2026-06-10: P2P 全部展开/折叠切换按钮 (单按钮, 根据当前多数态切换)
|
|
3772
|
+
const p2pToggleAllBtn = document.getElementById('p2p-toggle-all-btn');
|
|
3773
|
+
if (p2pToggleAllBtn) {
|
|
3774
|
+
// 同步图标/文字: 多数 peer 折叠 → 显示 "⊞ 展开"; 多数展开 → 显示 "⊟ 折叠"
|
|
3775
|
+
function syncToggleAllBtn() {
|
|
3776
|
+
const allPks = new Set([
|
|
3777
|
+
...knownPeers.map(p => p.publicKey),
|
|
3778
|
+
...remoteChannels.map(g => g.peerId)
|
|
3779
|
+
]);
|
|
3780
|
+
if (allPks.size === 0) {
|
|
3781
|
+
p2pToggleAllBtn.textContent = '⊞ 展开';
|
|
3782
|
+
p2pToggleAllBtn.title = '切换全部展开/折叠';
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
let collapsedCount = 0;
|
|
3786
|
+
for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
|
|
3787
|
+
const majorityCollapsed = collapsedCount >= allPks.size / 2;
|
|
3788
|
+
if (majorityCollapsed) {
|
|
3789
|
+
p2pToggleAllBtn.textContent = '⊞ 展开';
|
|
3790
|
+
p2pToggleAllBtn.title = '点击展开所有 P2P 好友';
|
|
3791
|
+
} else {
|
|
3792
|
+
p2pToggleAllBtn.textContent = '⊟ 折叠';
|
|
3793
|
+
p2pToggleAllBtn.title = '点击折叠所有 P2P 好友';
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
p2pToggleAllBtn.addEventListener('click', (e) => {
|
|
3797
|
+
e.stopPropagation();
|
|
3798
|
+
const allPks = new Set([
|
|
3799
|
+
...knownPeers.map(p => p.publicKey),
|
|
3800
|
+
...remoteChannels.map(g => g.peerId)
|
|
3801
|
+
]);
|
|
3802
|
+
if (allPks.size === 0) return;
|
|
3803
|
+
// 多数折叠 → 全展开; 否则全折叠
|
|
3804
|
+
let collapsedCount = 0;
|
|
3805
|
+
for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
|
|
3806
|
+
const majorityCollapsed = collapsedCount >= allPks.size / 2;
|
|
3807
|
+
if (majorityCollapsed) {
|
|
3808
|
+
expandAllPeers();
|
|
3809
|
+
} else {
|
|
3810
|
+
collapseAllPeers();
|
|
3811
|
+
}
|
|
3812
|
+
syncToggleAllBtn();
|
|
3813
|
+
});
|
|
3814
|
+
// 暴露给 renderRemoteChannels 渲染后调用 (保持图标跟实际状态一致)
|
|
3815
|
+
window.__syncP2PToggleAllBtn = syncToggleAllBtn;
|
|
3816
|
+
syncToggleAllBtn(); // 首次同步
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
// v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
|
|
3820
|
+
const refreshSharedBtn = document.getElementById('refresh-shared-btn');
|
|
3821
|
+
if (refreshSharedBtn) {
|
|
3822
|
+
refreshSharedBtn.addEventListener('click', async (e) => {
|
|
3823
|
+
e.stopPropagation();
|
|
3824
|
+
const originalText = refreshSharedBtn.textContent;
|
|
3825
|
+
refreshSharedBtn.disabled = true;
|
|
3826
|
+
refreshSharedBtn.textContent = '...';
|
|
3827
|
+
try {
|
|
3828
|
+
const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
|
|
3829
|
+
const data = await res.json();
|
|
3830
|
+
if (!res.ok) throw new Error(data.error || 'refresh failed');
|
|
3831
|
+
// 等 1.5s 让 RPC 回复回来 (向所有 peer 广播)
|
|
3832
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
3833
|
+
await loadRemoteChannels();
|
|
3834
|
+
console.log(`[v3] 双向刷新: 向 ${data.peerCount || 0} 个好友发 list 请求`);
|
|
3835
|
+
} catch (err) {
|
|
3836
|
+
alert('刷新失败: ' + (err.message || err));
|
|
3837
|
+
} finally {
|
|
3838
|
+
refreshSharedBtn.disabled = false;
|
|
3839
|
+
refreshSharedBtn.textContent = originalText;
|
|
3840
|
+
}
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
// 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
|
|
3845
|
+
loadRemoteChannels();
|
|
3846
|
+
setInterval(loadRemoteChannels, 8000);
|
|
3847
|
+
// 全局 SSE — 接收 remote-channel-update / remote-chat-reply / friend-request
|
|
3848
|
+
startV3GlobalSSE();
|
|
3849
|
+
|
|
3850
|
+
// ============ v3: 折叠 + 拖拽分隔线 ============
|
|
3851
|
+
|
|
3852
|
+
// 给本地/远端 section 加 flex 修饰类 (CSS variable 驱动比例)
|
|
3853
|
+
const localSection = document.querySelector('.sidebar-section'); // 第一个 section = 本地 channel
|
|
3854
|
+
const remoteSection = document.getElementById('remote-agents-section');
|
|
3855
|
+
if (localSection) localSection.classList.add('local-flex');
|
|
3856
|
+
if (remoteSection) remoteSection.classList.add('remote-flex');
|
|
3857
|
+
|
|
3858
|
+
// 折叠: 点 header 切换 collapsed 类
|
|
3859
|
+
const remoteHeader = document.getElementById('remote-agents-header');
|
|
3860
|
+
if (remoteHeader && remoteSection) {
|
|
3861
|
+
remoteHeader.addEventListener('click', (e) => {
|
|
3862
|
+
// 阻止刷新按钮的事件冒泡在 refreshRemoteBtn 里已处理
|
|
3863
|
+
remoteSection.classList.toggle('collapsed');
|
|
3864
|
+
});
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
// 拖拽分隔线: 鼠标按下开始拖, mousemove 改 --local-flex / --remote-flex, mouseup 结束
|
|
3868
|
+
const splitHandle = document.getElementById('sidebar-split-handle');
|
|
3869
|
+
if (splitHandle && localSection && remoteSection) {
|
|
3870
|
+
// 初始化等分
|
|
3871
|
+
const updateFlexVars = (localRatio, remoteRatio) => {
|
|
3872
|
+
localSection.style.setProperty('--local-flex', String(localRatio));
|
|
3873
|
+
remoteSection.style.setProperty('--remote-flex', String(remoteRatio));
|
|
3874
|
+
};
|
|
3875
|
+
updateFlexVars(1, 1);
|
|
3876
|
+
|
|
3877
|
+
let isDragging = false;
|
|
3878
|
+
let dragStartY = 0;
|
|
3879
|
+
let startLocalFlex = 1;
|
|
3880
|
+
let startRemoteFlex = 1;
|
|
3881
|
+
let sidebarHeight = 0;
|
|
3882
|
+
|
|
3883
|
+
splitHandle.addEventListener('mousedown', (e) => {
|
|
3884
|
+
isDragging = true;
|
|
3885
|
+
splitHandle.classList.add('dragging');
|
|
3886
|
+
dragStartY = e.clientY;
|
|
3887
|
+
// 读当前 CSS variable 拿真实 flex 值
|
|
3888
|
+
const lf = parseFloat(getComputedStyle(localSection).getPropertyValue('--local-flex')) || 1;
|
|
3889
|
+
const rf = parseFloat(getComputedStyle(remoteSection).getPropertyValue('--remote-flex')) || 1;
|
|
3890
|
+
startLocalFlex = lf;
|
|
3891
|
+
startRemoteFlex = rf;
|
|
3892
|
+
// 父容器可用高度 = sidebar-section 总和 (本地+远端+handle)
|
|
3893
|
+
const sidebar = document.querySelector('.sidebar');
|
|
3894
|
+
if (sidebar) sidebarHeight = sidebar.clientHeight;
|
|
3895
|
+
e.preventDefault();
|
|
3896
|
+
document.body.style.cursor = 'ns-resize';
|
|
3897
|
+
});
|
|
3898
|
+
|
|
3899
|
+
document.addEventListener('mousemove', (e) => {
|
|
3900
|
+
if (!isDragging) return;
|
|
3901
|
+
const deltaY = e.clientY - dragStartY;
|
|
3902
|
+
if (sidebarHeight <= 0) return;
|
|
3903
|
+
// deltaY 正 = 鼠标下移 = 拉大本地 / 缩小远端
|
|
3904
|
+
// 转换: 1 像素 ≈ sidebarHeight 中 0.005 的比例
|
|
3905
|
+
const deltaRatio = deltaY / sidebarHeight * 4; // 4 倍灵敏
|
|
3906
|
+
let newLocal = Math.max(0.1, startLocalFlex + deltaRatio);
|
|
3907
|
+
let newRemote = Math.max(0.1, startRemoteFlex - deltaRatio);
|
|
3908
|
+
updateFlexVars(newLocal, newRemote);
|
|
3909
|
+
});
|
|
3910
|
+
|
|
3911
|
+
document.addEventListener('mouseup', () => {
|
|
3912
|
+
if (!isDragging) return;
|
|
3913
|
+
isDragging = false;
|
|
3914
|
+
splitHandle.classList.remove('dragging');
|
|
3915
|
+
document.body.style.cursor = '';
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3918
|
+
// 双击分隔线 = 重置为等分
|
|
3919
|
+
splitHandle.addEventListener('dblclick', () => {
|
|
3920
|
+
updateFlexVars(1, 1);
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
// 2026-06-16 修复: wallet modal 相关 const 在之前的死代码清理中误删
|
|
3925
|
+
// (Task 套件 L2145-2419 用 Python 行号删除时波及了钱包模块顶部声明)
|
|
3926
|
+
// 补回: walletModal / walletBindBtn / walletGenerateBtn / walletAutoTools /
|
|
3927
|
+
// walletUnbindBtn / walletNewInfo / walletListEl / walletBindAddress
|
|
3928
|
+
const walletModal = document.getElementById('wallet-modal');
|
|
3929
|
+
const walletModalClose = document.getElementById('wallet-modal-close');
|
|
3930
|
+
const walletBindAddress = document.getElementById('wallet-bind-address');
|
|
3931
|
+
const walletGenerateBtn = document.getElementById('wallet-generate-btn');
|
|
3932
|
+
const walletAutoTools = document.getElementById('wallet-auto-tools');
|
|
3933
|
+
const walletBindBtn = document.getElementById('wallet-bind-btn');
|
|
3934
|
+
const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
|
|
3935
|
+
const walletNewInfo = document.getElementById('wallet-new-info');
|
|
3936
|
+
const walletListEl = document.getElementById('wallet-list');
|
|
3937
|
+
|
|
3938
|
+
/** 本次会话生成的私钥/助记词, 仅用于本地签名, 永不上传 */
|
|
3939
|
+
let walletModalPendingSecret = null;
|
|
3940
|
+
let walletModalPendingMnemonic = null;
|
|
3941
|
+
|
|
3942
|
+
if (walletModalClose) {
|
|
3943
|
+
walletModalClose.addEventListener('click', closeWalletModal);
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
if (walletBindBtn) {
|
|
3947
|
+
walletBindBtn.addEventListener('click', async () => {
|
|
3948
|
+
if (!currentChannelId) {
|
|
3949
|
+
alert('请先在侧边栏选择一个智能体');
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
3952
|
+
const address = (walletBindAddress.value || '').trim();
|
|
3953
|
+
if (!address) {
|
|
3954
|
+
alert('请输入钱包地址或点击「生成」');
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
const ch = channels.find(c => c.id === currentChannelId);
|
|
3958
|
+
const did = ch?.did || '';
|
|
3959
|
+
if (!did || did === 'undefined' || did === 'null') {
|
|
3960
|
+
alert('当前智能体还没有生成 DID, 请稍等几秒后重试');
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
// 服务端会用 recoverMessage 校验签名, 因此必须用本会话生成的私钥签名
|
|
3965
|
+
// (已绑过的钱包重新签名也会过, 因为 challenge 里有 channelId + DID)
|
|
3966
|
+
if (!walletModalPendingSecret) {
|
|
3967
|
+
alert('请先在「钱包管理」面板点击「生成」或导入私钥, 临时私钥仅在本会话保留');
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
let challenge;
|
|
3971
|
+
try {
|
|
3972
|
+
challenge = await signDIDChallengeAsync(walletModalPendingSecret, did, currentChannelId);
|
|
3973
|
+
} catch (err) {
|
|
3974
|
+
alert('签名失败: ' + err.message);
|
|
3975
|
+
return;
|
|
3976
|
+
}
|
|
3977
|
+
if (challenge.address.toLowerCase() !== address.toLowerCase()) {
|
|
3978
|
+
alert(`签名地址 ${challenge.address} 与输入地址 ${address} 不一致, 拒绝绑定`);
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
try {
|
|
3983
|
+
const res = await fetch(`/channels/${currentChannelId}/bind-wallet`, {
|
|
3984
|
+
method: 'POST',
|
|
3985
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3986
|
+
body: JSON.stringify({
|
|
3987
|
+
walletAddress: challenge.address,
|
|
3988
|
+
signature: challenge.signature,
|
|
3989
|
+
message: challenge.message,
|
|
3990
|
+
did: challenge.did,
|
|
3991
|
+
autoInvokeTools: !!walletAutoTools.checked
|
|
3992
|
+
})
|
|
3993
|
+
});
|
|
3994
|
+
if (!res.ok) {
|
|
3995
|
+
const err = await res.json().catch(() => ({}));
|
|
3996
|
+
throw new Error(err.error || 'bind failed');
|
|
3997
|
+
}
|
|
3998
|
+
const updated = await res.json();
|
|
3999
|
+
const idx = channels.findIndex(c => c.id === currentChannelId);
|
|
4000
|
+
if (idx >= 0) channels[idx] = updated;
|
|
4001
|
+
renderChannels();
|
|
4002
|
+
renderWalletList();
|
|
4003
|
+
walletModalPendingSecret = null;
|
|
4004
|
+
walletModalPendingMnemonic = null;
|
|
4005
|
+
walletNewInfo.style.display = 'block';
|
|
4006
|
+
walletNewInfo.innerHTML =
|
|
4007
|
+
'✅ 绑定成功<br>' +
|
|
4008
|
+
'<strong>地址:</strong> <code>' + escapeHtml(updated.walletAddress) + '</code><br>' +
|
|
4009
|
+
'<strong>签名 DID:</strong> <code>' + escapeHtml(did) + '</code><br>' +
|
|
4010
|
+
'<small style="color:#9c9;">服务端已用 recoverMessage 校验签名, 证明你持有该钱包私钥。</small>';
|
|
4011
|
+
} catch (err) {
|
|
4012
|
+
alert('绑定失败: ' + err.message);
|
|
4013
|
+
}
|
|
4014
|
+
});
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
if (walletUnbindBtn) {
|
|
4018
|
+
walletUnbindBtn.addEventListener('click', async () => {
|
|
4019
|
+
if (!currentChannelId) {
|
|
4020
|
+
alert('请先选择一个智能体');
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
if (!confirm('解绑当前智能体的钱包?')) return;
|
|
4024
|
+
try {
|
|
4025
|
+
const res = await fetch(`/channels/${currentChannelId}`, {
|
|
4026
|
+
method: 'PATCH',
|
|
4027
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4028
|
+
body: JSON.stringify({ walletAddress: null })
|
|
4029
|
+
});
|
|
4030
|
+
if (!res.ok) throw new Error('unbind failed');
|
|
4031
|
+
const updated = await res.json();
|
|
4032
|
+
const idx = channels.findIndex(c => c.id === currentChannelId);
|
|
4033
|
+
if (idx >= 0) channels[idx] = updated;
|
|
4034
|
+
walletBindAddress.value = '';
|
|
4035
|
+
renderChannels();
|
|
4036
|
+
renderWalletList();
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
alert('解绑失败: ' + err.message);
|
|
4039
|
+
}
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
/** 2026-06-16 修复: openWalletModal 之前在 git 历史里被误删 (L2018 引用但未定义)
|
|
4044
|
+
* - 表现: 点击 header 钱包按钮 → ReferenceError → init() 之后的代码不执行
|
|
4045
|
+
* - 影响: sidebar channel / session 全部不渲染, 用户看到"按钮没反应 / channel 消失"
|
|
4046
|
+
* 修: 补回 openWalletModal + closeWalletModal */
|
|
4047
|
+
function openWalletModal() {
|
|
4048
|
+
if (walletModal) {
|
|
4049
|
+
walletModal.classList.add('active');
|
|
4050
|
+
if (currentChannelId && walletBindAddress && channels.find(c => c.id === currentChannelId)) {
|
|
4051
|
+
const ch = channels.find(c => c.id === currentChannelId);
|
|
4052
|
+
walletBindAddress.value = ch?.walletAddress || '';
|
|
4053
|
+
}
|
|
4054
|
+
renderWalletList();
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
function closeWalletModal() {
|
|
4058
|
+
if (walletModal) walletModal.classList.remove('active');
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
/** 渲染"所有已绑定钱包"列表 */
|
|
4062
|
+
function renderWalletList() {
|
|
4063
|
+
if (!walletListEl) return;
|
|
4064
|
+
const bound = channels.filter(c => c.walletAddress);
|
|
4065
|
+
if (bound.length === 0) {
|
|
4066
|
+
walletListEl.innerHTML = '<div class="wallet-empty">暂未绑定钱包</div>';
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
// 用 DocumentFragment 避免多次 reflow
|
|
4070
|
+
const frag = document.createDocumentFragment();
|
|
4071
|
+
bound.forEach(ch => {
|
|
4072
|
+
const isActive = ch.id === currentChannelId;
|
|
4073
|
+
const chain = detectChain(ch.walletAddress);
|
|
4074
|
+
const row = document.createElement('div');
|
|
4075
|
+
row.className = 'wallet-row' + (isActive ? ' is-active' : '');
|
|
4076
|
+
row.innerHTML = `
|
|
4077
|
+
<span class="wallet-chain">${escapeHtml(chain)}</span>
|
|
4078
|
+
<div class="wallet-info">
|
|
4079
|
+
<span class="wallet-agent" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
|
|
4080
|
+
<span class="wallet-address" title="${escapeHtml(ch.walletAddress)}">${escapeHtml(ch.walletAddress)}</span>
|
|
4081
|
+
</div>
|
|
4082
|
+
<div class="wallet-actions">
|
|
4083
|
+
<button class="wallet-mini-btn" data-action="copy" data-addr="${escapeHtml(ch.walletAddress)}" title="复制地址">
|
|
4084
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
4085
|
+
<rect x="9" y="9" width="13" height="13" rx="2"></rect>
|
|
4086
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
4087
|
+
</svg>
|
|
4088
|
+
</button>
|
|
4089
|
+
<button class="wallet-mini-btn" data-action="goto" data-id="${escapeHtml(ch.id)}" title="切换到该智能体">
|
|
4090
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
4091
|
+
<path d="M5 12h14M12 5l7 7-7 7"></path>
|
|
4092
|
+
</svg>
|
|
4093
|
+
</button>
|
|
4094
|
+
<button class="wallet-mini-btn" data-action="unbind" data-id="${escapeHtml(ch.id)}" title="解绑">
|
|
4095
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
4096
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
4097
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
4098
|
+
</svg>
|
|
4099
|
+
</button>
|
|
4100
|
+
</div>
|
|
4101
|
+
`;
|
|
4102
|
+
frag.appendChild(row);
|
|
4103
|
+
});
|
|
4104
|
+
walletListEl.innerHTML = '';
|
|
4105
|
+
walletListEl.appendChild(frag);
|
|
4106
|
+
|
|
4107
|
+
// 事件委托: 一次绑定处理三个动作
|
|
4108
|
+
walletListEl.onclick = async (ev) => {
|
|
4109
|
+
const btn = ev.target.closest('button[data-action]');
|
|
4110
|
+
if (!btn) return;
|
|
4111
|
+
const action = btn.dataset.action;
|
|
4112
|
+
if (action === 'copy') {
|
|
4113
|
+
try {
|
|
4114
|
+
await navigator.clipboard.writeText(btn.dataset.addr);
|
|
4115
|
+
btn.style.background = 'var(--accent)';
|
|
4116
|
+
btn.style.color = 'var(--bg)';
|
|
4117
|
+
setTimeout(() => { btn.style.background = ''; btn.style.color = ''; }, 800);
|
|
4118
|
+
} catch {}
|
|
4119
|
+
} else if (action === 'goto') {
|
|
4120
|
+
closeWalletModal();
|
|
4121
|
+
selectChannel(btn.dataset.id);
|
|
4122
|
+
} else if (action === 'unbind') {
|
|
4123
|
+
if (!confirm('解绑该智能体的钱包?')) return;
|
|
4124
|
+
try {
|
|
4125
|
+
const res = await fetch(`/channels/${btn.dataset.id}`, {
|
|
4126
|
+
method: 'PATCH',
|
|
4127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4128
|
+
body: JSON.stringify({ walletAddress: null })
|
|
4129
|
+
});
|
|
4130
|
+
if (!res.ok) throw new Error('unbind failed');
|
|
4131
|
+
const updated = await res.json();
|
|
4132
|
+
const idx = channels.findIndex(c => c.id === btn.dataset.id);
|
|
4133
|
+
if (idx >= 0) channels[idx] = updated;
|
|
4134
|
+
renderChannels();
|
|
4135
|
+
renderWalletList();
|
|
4136
|
+
if (btn.dataset.id === currentChannelId) walletBindAddress.value = '';
|
|
4137
|
+
} catch (err) {
|
|
4138
|
+
alert('解绑失败: ' + err.message);
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
function detectChain(addr) {
|
|
4145
|
+
if (!addr) return '?';
|
|
4146
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(addr)) return 'EVM';
|
|
4147
|
+
if (/^0x[0-9a-fA-F]{64}$/.test(addr)) return 'SUI';
|
|
4148
|
+
if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return 'SOL';
|
|
4149
|
+
return '?';
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
// 启动应用
|
|
4153
|
+
init();
|
|
4154
|
+
|
|
4155
|
+
// =====================================================
|
|
4156
|
+
// 智能体目录:钱包注册 + 自动工具调用 (2026-06-11: catalog-add-btn 已删除)
|
|
4157
|
+
// =====================================================
|
|
4158
|
+
const agentAddModal = document.getElementById('agent-add-modal');
|
|
4159
|
+
const agentAddTitle = document.getElementById('agent-add-title');
|
|
4160
|
+
const agentAddModalClose = document.getElementById('agent-add-modal-close');
|
|
4161
|
+
const agentAddName = document.getElementById('agent-add-name');
|
|
4162
|
+
const agentAddWallet = document.getElementById('agent-add-wallet');
|
|
4163
|
+
const agentAddAutoTools = document.getElementById('agent-add-auto-tools');
|
|
4164
|
+
const agentAddConfirmBtn = document.getElementById('agent-add-confirm-btn');
|
|
4165
|
+
const agentAddCancelBtn = document.getElementById('agent-add-cancel-btn');
|
|
4166
|
+
const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
|
|
4167
|
+
const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
|
|
4168
|
+
|
|
4169
|
+
/** 客户端只为提示, 不向服务端发送私钥 */
|
|
4170
|
+
let pendingWalletSecret = null; // 本会话待绑定的私钥, 仅浏览器内存
|
|
4171
|
+
let pendingWalletMnemonic = null; // 本会话待绑定的助记词, 仅浏览器内存
|
|
4172
|
+
|
|
4173
|
+
function openAgentAddModal(existingChannel) {
|
|
4174
|
+
if (!agentAddModal) return;
|
|
4175
|
+
if (existingChannel) {
|
|
4176
|
+
agentAddTitle.textContent = '配置智能体:' + existingChannel.name;
|
|
4177
|
+
agentAddName.value = existingChannel.name || '';
|
|
4178
|
+
agentAddName.readOnly = false; // v3: 名字改成可编辑 (PATCH 支持更新 name)
|
|
4179
|
+
agentAddName.placeholder = '输入新名称';
|
|
4180
|
+
agentAddWallet.value = existingChannel.walletAddress || '';
|
|
4181
|
+
agentAddAutoTools.checked = !!existingChannel.autoInvokeTools;
|
|
4182
|
+
agentAddConfirmBtn.dataset.mode = 'update';
|
|
4183
|
+
agentAddConfirmBtn.dataset.channelId = existingChannel.id;
|
|
4184
|
+
agentAddConfirmBtn.dataset.originalName = existingChannel.name || '';
|
|
4185
|
+
} else {
|
|
4186
|
+
agentAddTitle.textContent = '添加智能体';
|
|
4187
|
+
agentAddName.value = '';
|
|
4188
|
+
agentAddName.readOnly = false;
|
|
4189
|
+
agentAddName.placeholder = '例如: 交易助手';
|
|
4190
|
+
agentAddWallet.value = '';
|
|
4191
|
+
agentAddAutoTools.checked = true;
|
|
4192
|
+
agentAddConfirmBtn.dataset.mode = 'create';
|
|
4193
|
+
delete agentAddConfirmBtn.dataset.channelId;
|
|
4194
|
+
delete agentAddConfirmBtn.dataset.originalName;
|
|
4195
|
+
}
|
|
4196
|
+
agentAddWalletInfo.style.display = 'none';
|
|
4197
|
+
agentAddWalletInfo.innerHTML = '';
|
|
4198
|
+
pendingWalletSecret = null;
|
|
4199
|
+
agentAddModal.classList.add('active');
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
function closeAgentAddModal() {
|
|
4203
|
+
if (!agentAddModal) return;
|
|
4204
|
+
agentAddModal.classList.remove('active');
|
|
4205
|
+
pendingWalletSecret = null;
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
/** 生成真实 EVM 钱包: BIP-39 助记词 + secp256k1 私钥 + EIP-55 校验和地址
|
|
4209
|
+
* 通过 window.WalletViem (来自 components/wallet-viem.mjs, viem 驱动) 调用.
|
|
4210
|
+
* 失败时降级到旧的演示模式, 但 UI 会提示用户.
|
|
4211
|
+
*/
|
|
4212
|
+
async function generateRealWalletAsync() {
|
|
4213
|
+
if (!window.WalletViem) {
|
|
4214
|
+
throw new Error('钱包模块尚未加载, 请稍后重试');
|
|
4215
|
+
}
|
|
4216
|
+
return window.WalletViem.generateRealWallet();
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
async function importRealWalletByPrivateKey(privateKeyHex) {
|
|
4220
|
+
if (!window.WalletViem) {
|
|
4221
|
+
throw new Error('钱包模块尚未加载, 请稍后重试');
|
|
4222
|
+
}
|
|
4223
|
+
return window.WalletViem.importEVMWallet(privateKeyHex);
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
async function signDIDChallengeAsync(privateKeyHex, did, channelId) {
|
|
4227
|
+
if (!window.WalletViem) {
|
|
4228
|
+
throw new Error('钱包模块尚未加载, 请稍后重试');
|
|
4229
|
+
}
|
|
4230
|
+
return window.WalletViem.signDIDChallenge(privateKeyHex, did, channelId);
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
function formatWalletInfoHtml({ address, privateKey, mnemonic }) {
|
|
4234
|
+
const parts = [
|
|
4235
|
+
'✓ 已生成真实 EVM 钱包 (BIP-39 + secp256k1 + EIP-55)',
|
|
4236
|
+
'<strong>地址:</strong> <code>' + escapeHtml(address) + '</code>',
|
|
4237
|
+
];
|
|
4238
|
+
if (mnemonic) {
|
|
4239
|
+
parts.push(
|
|
4240
|
+
'<strong>助记词 (12 词, 请抄写保存):</strong>',
|
|
4241
|
+
'<code style="color:#fc6;word-break:break-all;">' + escapeHtml(mnemonic) + '</code>'
|
|
4242
|
+
);
|
|
4243
|
+
}
|
|
4244
|
+
parts.push(
|
|
4245
|
+
'<strong>私钥 (0x + 32 字节):</strong>',
|
|
4246
|
+
'<code style="color:#f88;word-break:break-all;">' + escapeHtml(privateKey) + '</code>',
|
|
4247
|
+
'<small style="color:#f88;">⚠ 助记词 + 私钥均仅在本浏览器内存, 关闭页面后无法找回。</small>',
|
|
4248
|
+
'<small style="color:#999;">签名绑定到 channel DID (EIP-191 personal_sign) 会发送到服务端, 用于证明钱包所有权。</small>'
|
|
4249
|
+
);
|
|
4250
|
+
return parts.join('<br>');
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
if (agentGenerateWalletBtn) {
|
|
4254
|
+
agentGenerateWalletBtn.addEventListener('click', async () => {
|
|
4255
|
+
agentAddWalletInfo.style.display = 'block';
|
|
4256
|
+
agentAddWalletInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
|
|
4257
|
+
try {
|
|
4258
|
+
const wallet = await generateRealWalletAsync();
|
|
4259
|
+
agentAddWallet.value = wallet.address;
|
|
4260
|
+
pendingWalletSecret = wallet.privateKey;
|
|
4261
|
+
pendingWalletMnemonic = wallet.mnemonic;
|
|
4262
|
+
agentAddWalletInfo.innerHTML = formatWalletInfoHtml(wallet);
|
|
4263
|
+
} catch (err) {
|
|
4264
|
+
agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
|
|
4265
|
+
}
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
if (agentAddModalClose) agentAddModalClose.addEventListener('click', closeAgentAddModal);
|
|
4270
|
+
if (agentAddCancelBtn) agentAddCancelBtn.addEventListener('click', closeAgentAddModal);
|
|
4271
|
+
|
|
4272
|
+
if (agentAddConfirmBtn) {
|
|
4273
|
+
agentAddConfirmBtn.addEventListener('click', async () => {
|
|
4274
|
+
const mode = agentAddConfirmBtn.dataset.mode || 'create';
|
|
4275
|
+
const name = (agentAddName.value || '').trim();
|
|
4276
|
+
if (!name && mode === 'create') {
|
|
4277
|
+
alert('请输入智能体名称');
|
|
4278
|
+
return;
|
|
4279
|
+
}
|
|
4280
|
+
const walletAddress = (agentAddWallet.value || '').trim();
|
|
4281
|
+
const autoInvokeTools = !!agentAddAutoTools.checked;
|
|
4282
|
+
|
|
4283
|
+
try {
|
|
4284
|
+
if (mode === 'create') {
|
|
4285
|
+
const res = await fetch('/channels', {
|
|
4286
|
+
method: 'POST',
|
|
4287
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4288
|
+
body: JSON.stringify({
|
|
4289
|
+
name,
|
|
4290
|
+
agentId: currentAgentId,
|
|
4291
|
+
walletAddress: walletAddress || undefined,
|
|
4292
|
+
autoInvokeTools
|
|
4293
|
+
})
|
|
4294
|
+
});
|
|
4295
|
+
if (!res.ok) throw new Error('create failed');
|
|
4296
|
+
const channel = await res.json();
|
|
4297
|
+
channels.push(channel);
|
|
4298
|
+
renderChannels();
|
|
4299
|
+
selectChannel(channel.id);
|
|
4300
|
+
} else {
|
|
4301
|
+
// update
|
|
4302
|
+
const channelId = agentAddConfirmBtn.dataset.channelId;
|
|
4303
|
+
const originalName = agentAddConfirmBtn.dataset.originalName || '';
|
|
4304
|
+
// v3 新增: 名字改了才发 (没改就不发, 保持原状)
|
|
4305
|
+
const body = { walletAddress: walletAddress || null, autoInvokeTools };
|
|
4306
|
+
if (name && name !== originalName) {
|
|
4307
|
+
body.name = name;
|
|
4308
|
+
}
|
|
4309
|
+
const res = await fetch(`/channels/${channelId}`, {
|
|
4310
|
+
method: 'PATCH',
|
|
4311
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4312
|
+
body: JSON.stringify(body)
|
|
4313
|
+
});
|
|
4314
|
+
if (!res.ok) throw new Error('update failed');
|
|
4315
|
+
const updated = await res.json();
|
|
4316
|
+
const idx = channels.findIndex(c => c.id === channelId);
|
|
4317
|
+
if (idx >= 0) channels[idx] = updated;
|
|
4318
|
+
renderChannels();
|
|
4319
|
+
}
|
|
4320
|
+
closeAgentAddModal();
|
|
4321
|
+
} catch (err) {
|
|
4322
|
+
console.error('Failed to save agent:', err);
|
|
4323
|
+
alert('保存失败: ' + err.message);
|
|
4324
|
+
}
|
|
4325
|
+
});
|
|
4326
|
+
}
|