From 1fa9ef68a4c62b47a0d4ac6d2ff55ff19b097e4c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 19:56:28 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix(memory):=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E9=94=AE=E5=BD=92=E4=B8=80=E5=8C=96=20+=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E6=9C=80=E5=BC=B1=E7=BB=B4=E5=BA=A6=EF=BC=8C=E4=BF=AE=E5=8F=8C?= =?UTF-8?q?=E8=83=9E=E8=83=8E=E4=B8=8E=E7=A9=BA=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的 auto:chan:qq+part:morning 累积到 strength=270、6 个 features、 0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning 则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系 各活各的,谁也发现不了谁。 病因:键构造不唯一。 - Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。 "+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法, key UNIQUE 约束拦不住。 - Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测 morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。 这本就是加权 Jaccard 的正常行为,但键名不该写进时段。 - createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_", 造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找 auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。 修法: - Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey; 全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能 没有名字 —— 没名字就没有键,场景根本长不出来)。 - createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空 时拒建无名场景。 - 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。 判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面 ⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据 在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。 其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧 那条路本身是通的,坏的只是键的构造。 记忆 8 包 + agent/core 全绿。 --- docs/zh/scene-memory-fix-plan.md | 116 +++++++++++++++ internal/memory/scene_emerge.go | 70 +++++++-- internal/memory/scene_key_test.go | 238 ++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 docs/zh/scene-memory-fix-plan.md create mode 100644 internal/memory/scene_key_test.go diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md new file mode 100644 index 0000000..7e552c9 --- /dev/null +++ b/docs/zh/scene-memory-fix-plan.md @@ -0,0 +1,116 @@ +# 场景式记忆修复 plan + +> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。 +> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning` +> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。 +> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。 + +--- + +## 根因(三条,逐条修) + +### R1 建键不过归一化(病因) + +`internal/memory/scene_emerge.go:329` + +```go +base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化 +``` + +而同一层的另外三条路**都**过了归一化: + +| 位置 | 是否归一化 | +|---|---| +| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ | +| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ | +| `RecallByScene`(读侧召回)`scene.go:328` | ✅ | +| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ | + +`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。 +`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。 + +实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名): + +``` +auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs) +auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc +auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc +auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc +auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_... +auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务 +``` + +### R2 `part`(时段,权重 0.2)污染主键与门槛 + +`Label(2)` 取权重最高的 2 个特征。特征顺序是 +`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`, +只有当前 2 个强特征不足时 `part` 才会进键——于是出现 +`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。 + +**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`), +所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。 + +实测证据(键名与实际指纹自相矛盾): + +``` +场景键: auto:chan:qq+part:morning strength=270 +实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning +``` + +这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、 +并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。 + +### R3 图整理心跳漏扫 `scenes` + +`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查 +`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。 +所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。 + +**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。 + +--- + +## 步骤 + +### 步骤 1:修 R1 + R2(源头,不碰存量) + +- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey` +- [ ] `createSceneLocked` 的 `base` 用归一化后的 label +- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半) +- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行) + +### 步骤 2:修 R3(让图整理覆盖全库) + +- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环 + (理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮; + 场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准) +- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面) +- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个 + +### 步骤 3:清理现网垃圾场景,让它重新生成 + +用户明确要求:**直接清理,重新生成**(不做保守迁移)。 + +- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照) +- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs` + (**保留 `origin='declared'`**:那些是插件声明的,不是垃圾) +- [ ] 同步清 `situation_evidence` +- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现 +- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs + +### 步骤 4:验证与收口 + +- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿 +- [ ] `go build ./...` + 全仓 `go test ./...` +- [ ] 现网观察:日志中场景命中后能查到 refs(非 0) +- [ ] `git_release_check.sh` 无新增红项 + +--- + +## 不做的事(防反复挂账) + +- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向", + 场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。 +- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。 +- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮), + 混进本次修复会让 diff 失焦。单独开条目。 diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go index ce14ea3..3c8dca3 100644 --- a/internal/memory/scene_emerge.go +++ b/internal/memory/scene_emerge.go @@ -132,17 +132,52 @@ func (s Situation) Keys() []string { // Empty 表示指纹里没有任何可判定的信号。 func (s Situation) Empty() bool { return len(s.Features) == 0 } -// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。 -// 只用于人看,不参与匹配——匹配永远走特征集合。 +// labelFeatureWeight 是参与**场景身份**的最低特征权重。 +// +// 为什么设门槛:part(时段)权重只有 0.2,是场面里最弱的维度—— +// 「在 QQ 上」和「在 QQ 上且是早上」是同一个场面,时段不该把它切成两个。 +// 早期实现直接取 Label(2),只有 chan 一个强特征时 part 必然挤进第二位, +// 于是键名变成 auto:chan:qq+part:morning:既是「时段成了身份」, +// 又让加权 Jaccard 把它当成另一个场面(实测:morning 场景吞掉 evening 指纹, +// 共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4=0.714 > 0.5)。 +const labelFeatureWeight = 0.5 + +// Label 用权重达标的主导特征给场景起个**可读名**(`chan:qq+tool:qq_get_message`)。 +// +// 两条硬约束(缺一就会造出写侧匹配不上的键): +// 1. 只取权重 ≥ labelFeatureWeight 的特征:时段/话题不进身份。 +// 2. 结果**必须过 NormalizeSceneKey**:'+' 会被 normalizeSceneSegment 归一成 +// '_',而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。 +// 建键路径漏掉这一步,库中就会并存 auto:chan:qq+part:morning 与 +// auto:chan:qq_part:morning 两个键——key UNIQUE 拦不住(两个不同字符串), +// 于是「有 features 却 0 条记忆」与「有记忆却不参与聚类」两个半死节点并存 +// (生产实测 strength=270 / 6 features / 0 refs 对 strength=1 / 0 / 201)。 func (s Situation) Label(max int) string { if max <= 0 { max = 2 } - keys := s.Keys() - if len(keys) > max { - keys = keys[:max] + var picked []string + for _, f := range s.Features { + if f.Weight() < labelFeatureWeight { + continue + } + picked = append(picked, f.Key()) + if len(picked) >= max { + break + } } - return strings.Join(keys, "+") + if len(picked) == 0 { + // 全部特征都弱于门槛(纯 topic/part 的轮次):退回最强的一批特征, + // 宁可名字信息量低,也不要没有名字——没名字就没有键,场景根本长不出来。 + n := max + if n > len(s.Features) { + n = len(s.Features) + } + for _, f := range s.Features[:n] { + picked = append(picked, f.Key()) + } + } + return NormalizeSceneKey(strings.Join(picked, "+")) } // emergentScene 是一次聚类计算中的场景视图。 @@ -326,7 +361,15 @@ func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error { // createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。 func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { - base := "auto:" + sig.Label(2) + // Label 已保证:过滤弱特征 + 过 NormalizeSceneKey。 + // 这里再过一次防御性归一化:键的唯一性是整个场景层的地基, + // 不能依赖「上游一定调对了 Label」——生产库里已经存在双胞胎键, + // 任何一条新路径再漏归一化就会再生产一批(见 Label 的注释)。 + base := NormalizeSceneKey("auto:" + sig.Label(2)) + if base == "auto:" { + // Label 退化到空(指纹被裁空):不建无主场景,否则所有空指纹会堆进同一行。 + return "", fmt.Errorf("situation label 为空,拒绝建无名场景") + } key := base tx, err := g.db.Begin() @@ -336,6 +379,13 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { defer tx.Rollback() // 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。 + // + // ★ 这里加出来的 #N 后缀**必须与原键一样合法**:它会被写进 scenes.key, + // 而写侧(effectiveScenes)与读侧(RecallByScene)都会对它做归一化。 + // '#' 不在 normalizeSceneSegment 的白名单里,会被归一成 '_'—— + // 于是 auto:chan:qq#2 在库里存在,而写侧归一化后去找 auto:chan:qq_2, + // 又是一对匹配不上的双胞胎(生产库已有 auto:chan:mc:event+topic:mc#2 这类)。 + // 所以后缀改用不会触发归一化改写的字符。 for i := 2; ; i++ { var exists int if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil { @@ -344,7 +394,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { if exists == 0 { break } - key = fmt.Sprintf("%s#%d", base, i) + key = fmt.Sprintf("%s.%d", base, i) } res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key) @@ -372,8 +422,8 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { // recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。 // -// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定, -// 不参与后续匹配——匹配永远走 EnterScene 的相似度。 +// 用 Label(2) 做粗聚类桶(Label 已过归一化、不含时段),只服务于 +// 「首次不建场景」的门槛判定,不参与后续匹配——匹配永远走 EnterScene 的相似度。 func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) { label := sig.Label(2) if _, err := g.db.Exec( diff --git a/internal/memory/scene_key_test.go b/internal/memory/scene_key_test.go new file mode 100644 index 0000000..759054a --- /dev/null +++ b/internal/memory/scene_key_test.go @@ -0,0 +1,238 @@ +package memory + +// 场景键唯一性的判据(修复 R1/R2 的红测试)。 +// +// 背景(生产实测):scenes.key 有 UNIQUE 约束,但同一场面仍裂成两个键—— +// auto:chan:qq+part:morning strength=270 6 features 0 refs +// auto:chan:qq_part:morning strength=1 0 features 201 refs +// 病因:createSceneLocked 用 sig.Label(2) 建键且不过 NormalizeSceneKey, +// 而 EnsureScene / effectiveScenes / RecallByScene 三处都过了。 +// +// 本组测试的判据在**生产代码之外**:期望值是「同一场面 ⇒ 同一个键」这条 +// 不变量,不引用任何被测实现细节。判据自身也做了双向检查: +// 先用「改实现 ⇒ 必须变红」验证过它真的在跑。 + +import ( + "os" + "strings" + "testing" +) + +// sigWithPart 只有 chan + part 两个特征(part 权重 0.2,是最弱维度)。 +func sigWithPart(chanName, part string) Situation { + return NewSituation( + SituationFeature{Kind: "chan", Value: chanName}, + SituationFeature{Kind: "part", Value: part}, + ) +} + +// R1:同一指纹反复出现,键必须唯一,不能每次都造新行。 +func TestSceneKeyIsUniqueForSameSituation(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + + // 连喂 6 次。minSceneEvidence=2 ⇒ 第 2 次建场景、之后只强化。 + // 要断言的**不是**哪一次 created,而是:全程只允许存在一个键。 + keys := map[string]int{} + for i := 0; i < 6; i++ { + key, _, err := g.EnterScene(sig) + if err != nil { + t.Fatalf("第 %d 次 EnterScene 出错: %v", i+1, err) + } + if key == "" { + continue + } + keys[key]++ + } + + if len(keys) == 0 { + t.Fatal("6 次重复交互后仍未建出场景,与 minSceneEvidence=2 的设计矛盾") + } + if len(keys) > 1 { + t.Fatalf("同一指纹造出了 %d 个不同场景键: %v —— 键构造不唯一", len(keys), keys) + } + if n := len(sceneKeys(t, g)); n != 1 { + t.Fatalf("scenes 表里有 %d 行,应为 1(重复交互不得增殖场景行)", n) + } +} + +// 门槛本身:第 1 次只留足迹、第 2 次建场景。这是 minSceneEvidence 的语义, +// 单独钉住,免得修键时把门槛一起改掉。 +func TestSceneEvidenceThresholdIsTwo(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + + if key, _, err := g.EnterScene(sig); err != nil { + t.Fatal(err) + } else if key != "" { + t.Fatalf("第 1 次就建了场景 %q,minSceneEvidence=2 失效", key) + } + + key, created, err := g.EnterScene(sig) + if err != nil { + t.Fatal(err) + } + if key == "" || !created { + t.Fatalf("第 2 次应建出新场景(key=%q created=%v)", key, created) + } + + // 第 3 次起只强化,不再新建 + if _, created, err := g.EnterScene(sig); err != nil { + t.Fatal(err) + } else if created { + t.Fatal("第 3 次不该再新建场景") + } +} + +// R1(生产现场形态):带 + 的键与带 _ 的键必须归一到同一个。 +// 这是双胞胎的直接复现:现状下 auto:chan:qq+part:morning 与 +// auto:chan:qq_part:morning 会同时存在于 scenes 表。 +func TestSceneKeyNormalized_NoPlusVersusUnderscore(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 造两次:强特征相同、只有 part 不同(现实中「早上在 QQ」与「晚上在 QQ」) + for _, part := range []string{"morning", "evening"} { + sig := sigWithPart("qq", part) + for i := 0; i < 3; i++ { + if _, _, err := g.EnterScene(sig); err != nil { + t.Fatalf("EnterScene(%s) 出错: %v", part, err) + } + } + } + + keys := sceneKeys(t, g) + if len(keys) == 0 { + t.Fatal("未建出任何场景") + } + for _, k := range keys { + if strings.Contains(k, "+") { + t.Errorf("场景键 %q 含未归一化的 '+';其余三条路径(EnsureScene/"+ + "effectiveScenes/RecallByScene)都用 NormalizeSceneKey,"+ + "只有建键路径漏了 ⇒ 写侧永远匹配不上", k) + } + } +} + +// R2:part(权重 0.2,最弱维度)不该成为场景身份的一部分。 +func TestSceneKeyExcludesWeakPartFeature(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 只有 chan 一个强特征,part 必然挤进 Label(2) 的第二位。 + for i := 0; i < 3; i++ { + if _, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil { + t.Fatal(err) + } + } + + keys := sceneKeys(t, g) + if len(keys) == 0 { + t.Fatal("未建出场景") + } + for _, k := range keys { + if strings.Contains(k, "part") { + t.Errorf("场景键 %q 把时段(part, 权重 0.2)写进了身份。"+ + "时段是最弱维度:生产库实测出现「morning」场景吞掉 evening 指纹"+ + "(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)", k) + } + } +} + +// R2 的另一半:part 不该影响「是否建场景」的门槛桶。 +// 同一个场面在 morning 出现两次、evening 出现一次,应该只算两次 +// (minSceneEvidence=2 已满足),而不是按时段分桶各数一次。 +func TestEvidenceBucketIgnoresPart(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 第一轮 morning:只登记足迹,不建场景 + if key, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil { + t.Fatal(err) + } else if key != "" { + t.Fatalf("首次不该建场景,却建了 %q", key) + } + + // 第二轮换成 evening:若门槛按 part 分桶,这里就又要再等一次 + key, _, err := g.EnterScene(sigWithPart("qq", "evening")) + if err != nil { + t.Fatal(err) + } + if key == "" { + t.Fatal("同一场面(只差时段)第 2 次仍未建场景 —— 时段把证据桶拆开了") + } +} + +// R1 完整复现:模拟生产库里「写入侧归一化、建键侧不归一化」的分裂。 +// 断言:写侧挂的记忆,最终能通过读侧召回回到同一个场景。 +func TestWrittenRefReachableFromItsScene(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sig := mkSig("qq", "", "") + var key string + for i := 0; i < 3 && key == ""; i++ { + k, _, err := g.EnterScene(sig) + if err != nil { + t.Fatal(err) + } + key = k + } + if key == "" { + t.Fatal("未建出场景") + } + + // 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径 + ec, rc, err := g.Commit([]Triple{{ + Subject: "老大", Relation: "偏好", Object: "咖啡", + Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _ + }}, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatal("三元组未写入") + } + + // 读侧也走归一化(RecallByScene 内部会做) + res, err := g.RecallByScene([]string{key}, 8) + if err != nil { + t.Fatal(err) + } + if len(res.Relations) == 0 { + t.Fatalf("刚写进场景 %q 的关系,经同键召回却取不回 —— "+ + "建键与写/读两侧对「同一个键」的认定不一致", key) + } +} + +// sceneKeys 直查 scenes 表(不引用被测统计函数,独立复算)。 +func sceneKeys(t *testing.T, g *GraphDB) []string { + t.Helper() + rows, err := g.db.Query(`SELECT key FROM scenes ORDER BY id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + t.Fatal(err) + } + out = append(out, k) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} From 6e9420965d33b39211424d10217b647ae6db03de Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 19:58:00 +0800 Subject: [PATCH 02/13] =?UTF-8?q?docs(memory):=20=E8=A1=A5=20R4=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=9D=A2=E4=B8=8E=20R5=20=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E6=A1=B6=E4=B8=A4=E6=9D=A1=E6=A0=B9=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4:现网 65 个场景键里 peer 主导 0 个、topic 主导 0 个,36 个 auto:chan + 26 个 chan: 全部锚在输入通道。两个独立原因: 采集侧全仓无插件在 InjectInput 填 peer/group_id/user_id/chat_id (日志中 peer 出现 0 次);排序侧 chan 与 peer 权重同为 1.0 而 NewSituation 稳定排序让 chan 恒在前,即使采集到也进不了 Label(2)。 这与 R1/R2 是不同层面:R1 修完只会得到「正确的单一维度」。 R5:createSceneLocked 新场景成立即 DELETE FROM situation_evidence (全表清),会连带清掉别的场景尚未攒够门槛的证据。 --- docs/zh/scene-memory-fix-plan.md | 52 +++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md index 7e552c9..67c0f79 100644 --- a/docs/zh/scene-memory-fix-plan.md +++ b/docs/zh/scene-memory-fix-plan.md @@ -68,6 +68,41 @@ auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务 **R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。 +### R4 场景身份只有「输入通道」一个可靠维度 + +现网 65 个场景键的维度分布(实测): + +``` +auto:chan(涌现·通道主导) 36 +chan: (输入通道) 26 +tool: (工具) 3 +peer 主导 0 +topic 主导 0 +``` + +两个独立原因叠加: + +1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找 + `peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput + 时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。 + ⇒ 日志中 `peer` 特征出现次数为 **0**。 +2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重 + 降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer, + `Label(2)` 也轮不到它。 + +后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了 +场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。 + +**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达 +什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`, +但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。 + +### R5 `situation_evidence` 全表清空 + +`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence` +(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未 +攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。 + --- ## 步骤 @@ -98,11 +133,26 @@ auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务 - [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现 - [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs -### 步骤 4:验证与收口 +### 步骤 4:修 R4(覆盖面:让 peer 进得来)— 需跨插件,等用户确认范围 + +- [ ] 先查各插件 InjectInput 时手上**有没有** peer 信息可用(发信侧有 + `meta.group_id`/`user_id`,收信侧是否拿得到要逐个确认) +- [ ] 有则补:插件填 `payload["group_id"]`/`["user_id"]` +- [ ] 排序侧:`peer` 权重提到高于 `chan`,或 `NewSituation` 排序时 peer 优先 + (两者都要,否则采集到了也进不了身份) +- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景 + +### 步骤 5:修 R5(证据桶别全表清) + +- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删 +- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在 + +### 步骤 6:验证与收口 - [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿 - [ ] `go build ./...` + 全仓 `go test ./...` - [ ] 现网观察:日志中场景命中后能查到 refs(非 0) +- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账) - [ ] `git_release_check.sh` 无新增红项 --- From f441574b80f6a96579697ff7549a90ee5f309624 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 20:03:51 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docs(memory):=20=E8=A1=A5=20R6/R7=20?= =?UTF-8?q?=E4=B8=8E=20SDK=20=E5=A3=B0=E6=98=8E=E9=A1=B9=E6=96=B9=E6=A1=88?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=8E=92=E4=B8=BA=207=20=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6:ChannelDef 的记忆声明已有三件套(NoMemory/ContextPolicy/ RecallPolicy),唯独没有「这条通道是否参与场面识别」,现状是无条件 参与 ⇒ chan:system/kernel/timer 等内部信噪通道也在场面聚类里。 穷举确认非查漏:go.mod replace 指向 third_party/homeagent-sdk, plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' 为空。 R7:payload[scene] 的层级键(chan:qq/peer:group_1)已支持前缀 召回(RecallByScene scene.go:351),但因 R6 无人使用而闲置。 步骤重排为 7 步:SDK 声明项提到步骤 2(用户已授权动公开 SDK, 开发阶段非 release 阶段),并把「peer 覆盖面」拆到步骤 6 单列, 先只读调查插件手上有什么再动。 --- docs/zh/scene-memory-fix-plan.md | 100 ++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md index 67c0f79..2ada97d 100644 --- a/docs/zh/scene-memory-fix-plan.md +++ b/docs/zh/scene-memory-fix-plan.md @@ -103,18 +103,64 @@ topic 主导 0 (不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未 攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。 +### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口) + +`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴: + +``` +NoMemory 进不进记忆计算 +ContextPolicy 裁不裁上下文(破坏性,默认关) +RecallPolicy 召不召回记忆(只读,默认开) +``` + +**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**: +`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征, +没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类 +**纯内部信噪通道也在参与场面聚类**。 + +穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace), +`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。 + +已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件 +在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。 +现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。 + +### R7 渠道本身可以覆盖多个场景 + +`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时, +`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`) +支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。 + --- ## 步骤 -### 步骤 1:修 R1 + R2(源头,不碰存量) +### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成 -- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey` -- [ ] `createSceneLocked` 的 `base` 用归一化后的 label -- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半) -- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行) +- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey` +- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景 +- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源) +- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿) +- 提交:`1fa9ef6` -### 步骤 2:修 R3(让图整理覆盖全库) +### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动) + +按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状): + +- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"` +- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认) +- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty` +- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认) +- [ ] 内核接线: + - `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy` + - `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产—— + 一个不参与场面识别的通道不该留下时段噪声) + - 声明路 `sceneKeysFor` 同样受 `none` 约束 +- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变 +- [ ] 存量标注:给 `system` / `kernel` / `timer` / `healthcheck` 等内部信噪通道 + 标 `ScenePolicyNone`(**逐个确认后再标**,不批量猜) + +### 步骤 3:修 R3(让图整理覆盖全库) - [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环 (理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮; @@ -122,40 +168,46 @@ topic 主导 0 - [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面) - [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个 -### 步骤 3:清理现网垃圾场景,让它重新生成 +### 步骤 4:清理现网垃圾场景,让它重新生成 用户明确要求:**直接清理,重新生成**(不做保守迁移)。 - [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照) - [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs` - (**保留 `origin='declared'`**:那些是插件声明的,不是垃圾) - [ ] 同步清 `situation_evidence` - [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现 -- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs +- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs -### 步骤 4:修 R4(覆盖面:让 peer 进得来)— 需跨插件,等用户确认范围 - -- [ ] 先查各插件 InjectInput 时手上**有没有** peer 信息可用(发信侧有 - `meta.group_id`/`user_id`,收信侧是否拿得到要逐个确认) -- [ ] 有则补:插件填 `payload["group_id"]`/`["user_id"]` -- [ ] 排序侧:`peer` 权重提到高于 `chan`,或 `NewSituation` 排序时 peer 优先 - (两者都要,否则采集到了也进不了身份) -- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景 +> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在 +> `relations`/`entities` 表,与 `scenes` 无外键依赖。 +> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留, +> 涌现重建期间它继续承担 QQ 场景召回。 ### 步骤 5:修 R5(证据桶别全表清) - [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删 - [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在 -### 步骤 6:验证与收口 +### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来) + +前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。 +`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。 + +- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么 +- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序), + 即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权 +- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景 + +### 步骤 7:验证与收口 -- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿 - [ ] `go build ./...` + 全仓 `go test ./...` -- [ ] 现网观察:日志中场景命中后能查到 refs(非 0) +- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿 +- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化 + **已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、 + 老插件行为不变」 +- [ ] 现网观察:场景命中后能查到 refs(非 0) - [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账) -- [ ] `git_release_check.sh` 无新增红项 - ---- +- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期) ## 不做的事(防反复挂账) @@ -164,3 +216,5 @@ topic 主导 0 - **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。 - **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮), 混进本次修复会让 diff 失焦。单独开条目。 +- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义: + 新增项是纯追加,不借机改旧行为。 From 8887e06274c5a819211f9258dfed152332300d0f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 20:09:17 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(sdk+memory):=20=E8=A1=A5=20ScenePoli?= =?UTF-8?q?cy=20=E5=A3=B0=E6=98=8E=E9=A1=B9=EF=BC=8C=E8=AE=A9=E9=80=9A?= =?UTF-8?q?=E9=81=93=E8=83=BD=E9=80=80=E5=87=BA=E5=9C=BA=E9=9D=A2=E8=AF=86?= =?UTF-8?q?=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺口(R6):ChannelDef 的记忆声明已有三件套——NoMemory 管「进不进 记忆计算」、ContextPolicy 管「裁不裁上下文」、RecallPolicy 管「召不召回 记忆」,唯独没有「这条输入算不算一场戏的一部分」。现状是无条件参与: situationFeaturesFor 里只要 evt.Source != "" 就产出一个 chan 特征,没有 可关的开关 ⇒ chan:system / chan:kernel / chan:timer 这类纯内部信噪通道 也在撑场面,每次触发都让不相干的场景长出来或变强,召回时又会把 「内核在跑定时器」当成「用户在这类场景下说过的话」取回。 穷举确认不是查漏:go.mod replace 指向 third_party/homeagent-sdk, plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' -- sdk/ 为空。 SDK(纯追加,老插件行为逐字节不变): - 常量 ScenePolicyAuto / ScenePolicyNone + ValidScenePolicy,形状与 ContextPolicy / RecallPolicy 完全一致 - ChannelDef.ScenePolicy 与 InjectOptions.ScenePolicy,均带 omitempty - 默认取 auto(参与)而非 none:场景只附加检索路、不改记忆本体, 默认关会让存量通道突然失去召回;「关」是少数意图。与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 内核: - applyInjectOpts 搬运 scene_policy(与另外三个标志位同面) - sceneSuppressed 完全照 recallDeclared 的形状:注入点 payload > 通道定义 > 默认。none 时连时段(part)特征都不产,也不派生场景键 (只停指纹采集而留声明路,等于给这个口子开后门) - situationFeaturesFor / sceneKeysFor 由包级函数改为 Agent 方法 (需要 a.io 查通道定义),23 个调用点同步 判据:scenepolicy_test.go 7 例,改前编译期红(undefined: pubsdk.ScenePolicyAuto),改后全绿。其中两例专门护住「未声明时行为 逐字节不变」,是纯追加承诺的护栏。 记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。 存量通道标注待定:kernel/timer/offload-*/child/* 是纯 0-refs 信噪, 可直接标 none;但 mc:system(12 refs) 与 system(3 refs) 带真实记忆, 性质不明,不擅自标。 --- internal/agent/core/eventloop.go | 2 +- internal/agent/core/memorypass.go | 46 +++++++- internal/agent/core/scene_test.go | 19 ++-- internal/agent/core/scenepolicy_test.go | 144 ++++++++++++++++++++++++ internal/agent/core/task.go | 2 +- internal/agent/core/tooldefs.go | 2 +- internal/agent/io/channel.go | 5 +- third_party/homeagent-sdk/sdk/plugin.go | 39 ++++++- 8 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 internal/agent/core/scenepolicy_test.go diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index c30ca78..48428d1 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -430,7 +430,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int { if !a.pruneDeclared(evt) { return 0 } - return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived + return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, a.sceneKeysFor(evt, "")).Archived } // pruneDeclared 判定这次输入是否显式声明了裁剪。 diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index a2da104..e20179c 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -7,6 +7,7 @@ import ( agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) // sceneKeysFor 推导本轮输入的**当前场景**。 @@ -19,8 +20,13 @@ import ( // // 多个场景是**并列命中**(取回任一场景的记忆),不是交集: // 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。 -func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string { +func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string { var keys []string + // 通道/注入点声明不参与场面识别时,**连派生场景键也不给**。 + // 只停掉指纹采集而留着声明路,等于给「不参与场面」这个口子开了后门。 + if a.sceneSuppressed(evt) { + return nil + } seen := make(map[string]bool) add := func(k string) { // 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重, @@ -129,11 +135,41 @@ func (a *Agent) pruneByQuery(query string) int { // 已有量,不需要模型配合,也不需要人工标注。 // ────────────────────────────────────────────── -// situationFeaturesFor 采集一轮交互的场面指纹。 +// sceneSuppressed 报告本次输入是否被声明为**不参与场面识别**。 +// +// 读取面与其它记忆声明完全一致:先看注入点 payload(单次覆盖), +// 再看通道定义(ChannelDef.ScenePolicy),都没声明 = 参与(保持既有行为)。 +// 优先级与 pruneDeclared / recallDeclared 同构。 +// +// 为什么要一个显式开关:场面指纹只要 evt.Source != "" 就无条件产出一个 chan +// 特征,于是内核自循环(system)、心跳(timer)、内部状态汇报(kernel)这类 +// **纯信噪通道**也在撑场面——它们每次触发都让一个不相干的场景长出来或变强, +// 而召回时又会把「内核在跑定时器」当成「用户在这类场景下说过的话」取回。 +func (a *Agent) sceneSuppressed(evt *agentIO.InputEvent) bool { + if evt == nil { + return false + } + if p, ok := evt.Payload["scene_policy"].(string); ok && p != "" { + return p == pubsdk.ScenePolicyNone + } + if a.io != nil { + if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.ScenePolicy != "" { + return chDef.ScenePolicy == pubsdk.ScenePolicyNone + } + } + return false +} + +// sceneFeaturesFor 采集一轮交互的场面指纹。 // // 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是 // 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。 -func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature { +func (a *Agent) situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature { + // 声明不参与场面识别:连时段特征都不产——一个不参与的面孔 + // 不该在 situation_evidence / scene_features 里留下任何足迹。 + if a.sceneSuppressed(evt) { + return nil + } var feats []memory.SituationFeature if evt != nil { if evt.Source != "" { @@ -225,8 +261,8 @@ func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene { return f.turnScene } - declared := sceneKeysFor(evtOf(f), tool) - feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool) + declared := a.sceneKeysFor(evtOf(f), tool) + feats := a.situationFeaturesFor(evtOf(f), cleanInputOf(f), tool) sig := memory.NewSituation(feats...) turn, err := a.memory.EnterSceneWithHint(sig, declared) diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go index 8170037..f735b73 100644 --- a/internal/agent/core/scene_test.go +++ b/internal/agent/core/scene_test.go @@ -11,8 +11,9 @@ import ( // TestSceneKeysFor 钉住当前场景的推导优先级: // 注入点显式声明 > 通道 > 工具;并列命中且去重。 func TestSceneKeysFor(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} // 通道 + 工具:两个都能独立成立的触发条件,都要带上 - got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message") + got := a.sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message") want := []string{"chan:qq", "tool:qq_get_message"} if len(got) != len(want) { t.Fatalf("sceneKeysFor = %v, want %v", got, want) @@ -28,7 +29,7 @@ func TestSceneKeysFor(t *testing.T) { Source: "QQ", Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "}, } - got = sceneKeysFor(evt, "") + got = a.sceneKeysFor(evt, "") if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" { t.Errorf("显式声明应排最前且通道场景归一: %v", got) } @@ -38,18 +39,18 @@ func TestSceneKeysFor(t *testing.T) { Source: "webui", Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}}, } - got = sceneKeysFor(evt, "") + got = a.sceneKeysFor(evt, "") if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" { t.Errorf("数组声明未生效: %v", got) } // nil 事件不 panic - if got := sceneKeysFor(nil, ""); len(got) != 0 { + if got := a.sceneKeysFor(nil, ""); len(got) != 0 { t.Errorf("nil 事件应无场景: %v", got) } // 未声明的 payload 键不影响 evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}} - if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" { + if got := a.sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" { t.Errorf("无 scene 声明时应只有通道场景: %v", got) } } @@ -57,11 +58,12 @@ func TestSceneKeysFor(t *testing.T) { // TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量, // 不需要模型配合也不需要人工标注。 func TestSituationFeaturesFor(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} evt := &agentIO.InputEvent{ Source: "QQ", Payload: map[string]interface{}{"group_id": float64(1027993713)}, } - feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message") + feats := a.situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message") kinds := map[string]int{} for _, f := range feats { kinds[f.Kind]++ @@ -98,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) { } // 无事件时不 panic,且只有工具特征时也成立 - if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 { + if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 { t.Errorf("仅工具场景应有 1 个特征: %+v", feats) } } @@ -106,9 +108,10 @@ func TestSituationFeaturesFor(t *testing.T) { // TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」: // 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。 func TestTurnSceneKeysBothPaths(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} // 声明的通道场景与工具场景都在,涌现键(若有)追加在后 evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}} - got := sceneKeysFor(evt, "qq_get_message") + got := a.sceneKeysFor(evt, "qq_get_message") want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"} if len(got) != len(want) { t.Fatalf("声明侧场景数不对: %v want %v", got, want) diff --git a/internal/agent/core/scenepolicy_test.go b/internal/agent/core/scenepolicy_test.go new file mode 100644 index 0000000..15cd8c7 --- /dev/null +++ b/internal/agent/core/scenepolicy_test.go @@ -0,0 +1,144 @@ +package core + +// ScenePolicy 声明项的判据(R6)。 +// +// 缺口事实(穷举确认,非查漏):ChannelDef 已有 NoMemory/ContextPolicy/ +// RecallPolicy 三件套,唯独没有「这条通道是否参与场面识别」; +// situationFeaturesFor 里只要 evt.Source != "" 就无条件塞 chan 特征。 +// ⇒ chan:system / chan:kernel / chan:timer 这类内部信噪通道 +// 也在参与场面聚类(现网 65 个键里就有 chan:system、chan:kernel、chan:timer)。 +// +// 判据参照物在生产代码之外:期望值是「声明 none 的输入不产生任何场面特征」、 +// 「未声明的输入行为逐字节不变」这两条不变量,不引用被测实现。 + +import ( + "testing" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +func scenePolicyNoneEvent() *agentIO.InputEvent { + return &agentIO.InputEvent{ + Source: "system", + Payload: map[string]interface{}{"scene_policy": "none"}, + } +} + +// R6 核心:声明 none 的输入不产生任何场面特征。 +func TestScenePolicyNone_NoSituationFeatures(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + evt := scenePolicyNoneEvent() + + if feats := a.situationFeaturesFor(evt, "帮我看下定时器", ""); len(feats) != 0 { + t.Fatalf("声明 scene_policy=none 的输入仍产出了 %d 个场面特征: %+v —— "+ + "内部信噪通道会参与场面聚类,把无关场面撑出来", len(feats), feats) + } +} + +// none 必须连时段(part) 都不产:一个不参与场面识别的通道 +// 不该在 situation_evidence / scene_features 里留下任何足迹。 +func TestScenePolicyNone_NoPartFeatureLeak(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + feats := a.situationFeaturesFor(scenePolicyNoneEvent(), "任意内容", "") + for _, f := range feats { + if f.Kind == "part" { + t.Errorf("scene_policy=none 仍产出了时段特征 %q", f.Key()) + } + } +} + +// none 时工具场景也不该派生。 +func TestScenePolicyNone_NoToolScene(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + keys := a.sceneKeysFor(scenePolicyNoneEvent(), "cmd_run") + for _, k := range keys { + if k == "tool:cmd_run" || k == "chan:system" { + t.Errorf("scene_policy=none 仍派生了场景键 %q", k) + } + } +} + +// 声明 none 时,显式 payload["scene"] 也不该被采纳—— +// 否则通道声明形同虚设(注入点声明与通道声明必须一致,通道是更宽的闸)。 +func TestScenePolicyNone_OverridesExplicitScene(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + evt := scenePolicyNoneEvent() + evt.Payload["scene"] = "chan:qq" + + keys := a.sceneKeysFor(evt, "") + for _, k := range keys { + if k == "chan:qq" { + t.Error("通道声明 scene_policy=none 后,注入点显式声明的 chan:qq 仍被采纳;" + + "通道级闸门应覆盖注入点级声明") + } + } + if feats := a.situationFeaturesFor(evt, "", ""); len(feats) != 0 { + t.Errorf("scene_policy=none 仍产出特征 %+v", feats) + } +} + +// 未声明时行为必须逐字节不变(零值 = 保持现状 = 参与)。 +// 这是「纯追加」承诺的护栏:老插件不填 ScenePolicy,行为不能有任何变化。 +func TestScenePolicyUnset_BehavesExactlyAsBefore(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + evt := &agentIO.InputEvent{ + Source: "qq", + Payload: map[string]interface{}{}, + } + + feats := a.situationFeaturesFor(evt, "老大在吗", "") + if len(feats) == 0 { + t.Fatal("未声明 scene_policy 的输入不应失去场面特征(零值必须等价既有行为)") + } + var hasChan bool + for _, f := range feats { + if f.Kind == "chan" { + hasChan = true + } + } + if !hasChan { + t.Errorf("未声明时应照旧产出 chan 特征,实际: %+v", feats) + } + + keys := a.sceneKeysFor(evt, "") + if len(keys) == 0 || keys[0] != "chan:qq" { + t.Errorf("未声明时应照旧派生 chan:qq,实际: %v", keys) + } +} + +// 显式 auto 与未声明等价。 +func TestScenePolicyAuto_SameAsUnset(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + mk := func(policy string) []string { + payload := map[string]interface{}{} + if policy != "" { + payload["scene_policy"] = policy + } + return a.sceneKeysFor(&agentIO.InputEvent{Source: "qq", Payload: payload}, "") + } + unset, auto := mk(""), mk(pubsdk.ScenePolicyAuto) + + if len(unset) != len(auto) { + t.Fatalf("auto 与未声明不等价: unset=%v auto=%v", unset, auto) + } + if len(auto) == 0 || auto[0] != "chan:qq" { + t.Fatalf("auto 应照旧派生 chan:qq,实际: %v", auto) + } +} + +// SDK 常量与校验函数:形状须与 ContextPolicy/RecallPolicy 一致。 +func TestScenePolicySDKShape(t *testing.T) { + if !pubsdk.ValidScenePolicy("") { + t.Error("空串应等价默认,校验须通过") + } + if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyNone) { + t.Error("none 应合法") + } + if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyAuto) { + t.Error("auto 应合法") + } + if pubsdk.ValidScenePolicy("prune") { + t.Error("未知取值应被拒(照 ContextPolicy 的严格度)") + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index ae975aa..70e6253 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -788,7 +788,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { // 与这一步工具本身(如 tool:qq_get_message)。带上工具场景, // 才能让「凡是要回 QQ 消息」这类规则在该步被取回。 // 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景 - scenes := sceneKeysFor(f.Evt, tc.Name) + scenes := a.sceneKeysFor(f.Evt, tc.Name) turn := a.resolveTurnScenes(f, tc.Name) for _, k := range turn.Keys { scenes = append(scenes, k) diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 6a8fdf3..c89e7c7 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -48,7 +48,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) // query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义: // 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。 func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string { - scenes := sceneKeysFor(evtOf(f), "") + scenes := a.sceneKeysFor(evtOf(f), "") if f == nil { return a.recallText(input, "input", maxTokens, scenes) } diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index 14e76c4..c8cced7 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -341,7 +341,7 @@ type InjectOptions = pubsdk.InjectOptions // applyInjectOpts 把注入标志位写进事件 payload。 // // 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核 -// (不认识这两个键)都不会受影响。 +// (不认识这些键)都不会受影响。 // // 为什么不把标志位当独立参数传到底:eventloop 与各注入路径都按 payload 取字段 // (no_memory 本来就是这么走的),payload 是这里唯一已有的携带面。 @@ -355,6 +355,9 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) { if opts.RecallPolicy != "" { payload["recall_policy"] = opts.RecallPolicy } + if opts.ScenePolicy != "" { + payload["scene_policy"] = opts.ScenePolicy + } if opts.CleanerName != "" { payload["cleaner_name"] = opts.CleanerName } diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 6852696..93edf9b 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -74,6 +74,36 @@ func ValidRecallPolicy(policy string) bool { return false } +// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。 +// +// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管 +// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是 +// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹 +// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。 +// +// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自 +// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因: +// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量 +// 通道突然失去场景召回; +// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认—— +// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 +// +// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。 +// 它们每次触发都在撑一个「场面」,会把不相干的交互聚到一起。 +const ( + ScenePolicyAuto = "auto" + ScenePolicyNone = "none" +) + +// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。 +func ValidScenePolicy(policy string) bool { + switch policy { + case "", ScenePolicyAuto, ScenePolicyNone: + return true + } + return false +} + // InjectOptions 声明一次注入行为在记忆层与上下文层的表现。 // // 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致, @@ -102,7 +132,11 @@ type InjectOptions struct { // 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为); // RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。 RecallPolicy string - CleanerName string + // ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。 + // 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭, + // 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。 + ScenePolicy string + CleanerName string // Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。 // @@ -132,6 +166,7 @@ const ( // Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 // ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) // RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回) +// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与) // // JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 // 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— @@ -142,6 +177,8 @@ type ChannelDef struct { ContextPolicy string `json:"context_policy,omitempty"` // RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。 RecallPolicy string `json:"recall_policy,omitempty"` + // ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。 + ScenePolicy string `json:"scene_policy,omitempty"` } // StageContext provides context for stage handlers. From 758ec11832375158b6a21dfd8da2c2cdddb3b6ad Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 20:16:11 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(memory):=20=E5=9B=BE=E6=95=B4?= =?UTF-8?q?=E5=A4=87=E8=A6=86=E7=9B=96=20scenes=20+=20=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E6=A1=B6=E6=8C=89=E6=A1=B6=E6=B8=85=EF=BC=88R3/R5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。 Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表 (graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现: auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs, 孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features (聚类只读 scene_features,所以它永远不被看见)。 新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、 特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行, 跨 origin 也合。接到 mergeLoop 尾部。 为什么不塞进 detectEntityMerge 的双重循环: - 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、 ~224GB 瞬时分配每轮,是独立问题); - 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同 本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的 职责,不是这里的事。 只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险 的,去重不是「把像的一律合并」。 生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产): 65 个场景 → 合并 8 组 → 57 个; auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。 R5:createSceneLocked 新场景成立时执行的是 `DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的 计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli 尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零, 于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。 改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。 判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、 scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是 sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键), 第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。 记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。 --- docs/zh/scene-memory-fix-plan.md | 7 +- internal/agent/core/distill.go | 26 +++ internal/memory/scene.go | 143 ++++++++++++ internal/memory/scene_dedupe_test.go | 285 ++++++++++++++++++++++++ internal/memory/scene_emerge.go | 13 +- internal/memory/scene_evidence_test.go | 129 +++++++++++ third_party/homeagent-sdk/sdk/plugin.go | 6 +- 7 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 internal/memory/scene_dedupe_test.go create mode 100644 internal/memory/scene_evidence_test.go diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md index 2ada97d..33ca9ba 100644 --- a/docs/zh/scene-memory-fix-plan.md +++ b/docs/zh/scene-memory-fix-plan.md @@ -157,8 +157,11 @@ RecallPolicy 召不召回记忆(只读,默认开) 一个不参与场面识别的通道不该留下时段噪声) - 声明路 `sceneKeysFor` 同样受 `none` 约束 - [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变 -- [ ] 存量标注:给 `system` / `kernel` / `timer` / `healthcheck` 等内部信噪通道 - 标 `ScenePolicyNone`(**逐个确认后再标**,不批量猜) +- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响, + 少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆, + 召回返回空;且 declared 场景**不进**相似度空间 + (`loadEmergentScenesLocked` 只取 `origin='emergent'`), + 故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。 ### 步骤 3:修 R3(让图整理覆盖全库) diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 03c54b0..0be526b 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -106,6 +106,7 @@ func (a *Agent) mergeLoop() { case <-ticker.C: log.Printf("[agent] heartbeat merge tick") a.detectEntityMerge() + a.dedupeScenes() case <-a.ctx.Done(): return } @@ -312,6 +313,31 @@ func (a *Agent) detectEntityMerge() { } } +// dedupeScenes 是图整备的场景侧去重:把「同一个场面的两个键」合成一个。 +// +// 为什么与 detectEntityMerge 分开而不塞进它的双重循环: +// - 实体:全库两两 bigram + LLM 裁决。1 万实体实测 5000 万次配对、 +// ~224GB 瞬时分配每轮(见 plan「不做的事」),已是独立问题。 +// - 场景:判重口径是**归一化后是否同名**——同名即同一场面,**不需要 LLM +// 裁决**(键相同本身就是证据)。且 declared 场景不进相似度空间, +// 场景之间「像不像」由 EnterScene 的聚类负责,不是这里的事。 +// +// 所以这里只做确定性的同键合并,不做相似度合并:把「chan:qq 与 +// chan:webui 很像」也合并是危险的,那会把不同场面糊成一个。 +func (a *Agent) dedupeScenes() { + if a.memory == nil { + return + } + merged, err := a.memory.DedupeScenes() + if err != nil { + log.Printf("[agent] 场景去重失败(下轮重试): %v", err) + return + } + if merged > 0 { + log.Printf("[agent] 场景去重:合并 %d 组同键场景(同一场面的重复键)", merged) + } +} + // ────────────────────────────────────────────── // 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences // ────────────────────────────────────────────── diff --git a/internal/memory/scene.go b/internal/memory/scene.go index 2ab7e6a..b865ea2 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -580,3 +580,146 @@ func (g *GraphDB) TagSceneDocument(sceneKey, docID string) error { } return tx.Commit() } + +// ────────────────────────────────────────────── +// 场景去重(整备):把「同一个场面的两个键」合成一个 +// ────────────────────────────────────────────── + +// DedupeScenes 合并归一化后同名的场景,返回合并组数。 +// +// 存在的原因:scenes.key 有 UNIQUE 约束,但**归一化口径曾经不统一**—— +// 建键路径用 "auto:" + Label(2) 而 Label 拼的是 "+",不过 NormalizeSceneKey; +// 而写侧(effectiveScenes)、读侧(RecallByScene)、声明建键(EnsureScene) +// 三处都过了归一化。于是 "+" 与 "_" 成为两个都合法的主键,UNIQUE 拦不住: +// +// auto:chan:qq+part:morning strength=270 6 features 0 refs +// auto:chan:qq_part:morning strength=1 0 features 201 refs +// +// 两个节点互不可见:聚类只读 scene_features,所以 0-features 的那个 +// 永远不被看见;而 0-refs 的那个收不到任何写侧记忆。实测这对双胞胎 +// 从建库起累积到 strength=270 都没人发现——因为图整理心跳(mergeLoop) +// 的遍历入口 Recall(nil,nil,1,"") 只查 entities 与 relations,scenes +// 不在其中。 +// +// 合并口径:**只有归一化后完全同名才算重复**。相似但不同的场面 +// (chan:qq 与 chan:webui)绝不合并——去重不是"把像的一律合并"。 +// 场景之间的相似度判定是 EnterScene 的聚类职责,那是另一件事。 +// +// 存活规则:保留 id 最小的那一行(先来者),其余并入它。 +// 强度相加、特征取并集(权重取大)、引用全部重定向。 +// 跨 origin 也合:现网存在 origin='emergent' 却长得像声明键的 +// chan:context_archived。 +func (g *GraphDB) DedupeScenes() (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + rows, err := g.db.Query(`SELECT id, key FROM scenes ORDER BY id`) + if err != nil { + return 0, err + } + type row struct { + id int64 + key string + } + var all []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.key); err != nil { + rows.Close() + return 0, err + } + all = append(all, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + if len(all) < 2 { + return 0, nil + } + + // 按归一化后的键分组,组内 id 最小者为存活者。 + groups := make(map[string][]row) + order := make([]string, 0, len(all)) + for _, r := range all { + nk := NormalizeSceneKey(r.key) + if nk == "" { + // 键归一化后为空:无法判定它与谁重复,跳过(不擅自删数据)。 + continue + } + if _, seen := groups[nk]; !seen { + order = append(order, nk) + } + groups[nk] = append(groups[nk], r) + } + + merged := 0 + for _, nk := range order { + gp := groups[nk] + if len(gp) < 2 { + continue + } + keep := gp[0] // ORDER BY id ⇒ 最早创建的那行 + + tx, err := g.db.Begin() + if err != nil { + return merged, err + } + err = func() error { + for _, dup := range gp[1:] { + // 强度相加。 + if _, err := tx.Exec( + `UPDATE scenes SET strength = COALESCE(strength,1) + + COALESCE((SELECT strength FROM scenes WHERE id = ?), 0), + updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, dup.id, keep.id); err != nil { + return err + } + // 特征取并集,权重取大。 + if _, err := tx.Exec( + `INSERT INTO scene_features (scene_id, feature, weight) + SELECT ?, feature, weight FROM scene_features WHERE scene_id = ? + ON CONFLICT(scene_id, feature) DO UPDATE + SET weight = MAX(weight, excluded.weight)`, + keep.id, dup.id); err != nil { + return err + } + if _, err := tx.Exec( + `DELETE FROM scene_features WHERE scene_id = ?`, dup.id); err != nil { + return err + } + // 引用重定向。UNIQUE(scene_id,kind,ref_id,ref_text) 会与存活者 + // 上的同一条冲突——冲突即同一条记忆,取权重大的那条。 + if _, err := tx.Exec( + `INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) + SELECT ?, kind, ref_id, ref_text, weight FROM scene_refs WHERE scene_id = ? + ON CONFLICT(scene_id, kind, ref_id, ref_text) DO UPDATE + SET weight = MAX(weight, excluded.weight)`, + keep.id, dup.id); err != nil { + return err + } + if _, err := tx.Exec( + `DELETE FROM scene_refs WHERE scene_id = ?`, dup.id); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM scenes WHERE id = ?`, dup.id); err != nil { + return err + } + merged++ + } + // 存活者的键也归一化,避免下次又认不出自己。 + if _, err := tx.Exec(`UPDATE scenes SET key = ? WHERE id = ?`, nk, keep.id); err != nil { + return err + } + return nil + }() + if err != nil { + tx.Rollback() + return merged, err + } + if err := tx.Commit(); err != nil { + return merged, err + } + } + return merged, nil +} diff --git a/internal/memory/scene_dedupe_test.go b/internal/memory/scene_dedupe_test.go new file mode 100644 index 0000000..6b0161c --- /dev/null +++ b/internal/memory/scene_dedupe_test.go @@ -0,0 +1,285 @@ +package memory + +// 场景去重(R3)的判据。 +// +// 缺口事实:图整理心跳(mergeLoop → detectEntityMerge)唯一的遍历入口是 +// Recall(nil,nil,1,""),而该全量路径只查 entities(graph.go:609) 与 +// relations(graph.go:631) —— scenes 不在其中。于是同一个场面的双胞胎键 +// (auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning)从建库起 +// 无人发现:强度一路涨到 270、6 个 features、0 条记忆,而孪生的那个 +// 持有 201 条记忆却有 0 features。两套特征体系各活各的。 +// +// 判据参照物在生产代码之外:期望值是「归一化后同名的场景必须合成一个」 +// 与「合并后记忆/特征/强度都不丢」这两条不变量,不引用被测实现。 + +import ( + "os" + "strings" + "testing" +) + +// insertSceneRow 直写一行 scenes(绕开建键路径),用来复现历史双胞胎。 +func insertSceneRow(t *testing.T, g *GraphDB, key, origin string, strength int) int64 { + t.Helper() + res, err := g.db.Exec( + `INSERT INTO scenes (key, strength, origin) VALUES (?, ?, ?)`, + key, strength, origin) + if err != nil { + t.Fatal(err) + } + id, _ := res.LastInsertId() + return id +} + +func addFeature(t *testing.T, g *GraphDB, sceneID int64, feature string, weight float64) { + t.Helper() + if _, err := g.db.Exec( + `INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)`, + sceneID, feature, weight); err != nil { + t.Fatal(err) + } +} + +func addRef(t *testing.T, g *GraphDB, sceneID int64, kind string, refID int64, weight float64) { + t.Helper() + if _, err := g.db.Exec( + `INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?)`, + sceneID, kind, refID, weight); err != nil { + t.Fatal(err) + } +} + +func sceneRowCount(t *testing.T, g *GraphDB) int { + t.Helper() + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func sceneRefCount(t *testing.T, g *GraphDB) int { + t.Helper() + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// R3 核心:归一化后同名的双胞胎必须合成一个。 +func TestDedupeScenes_MergesNormalizedTwins(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 复现现网形态:一个带 +(有 features、无 refs),一个带 _(有 refs、无 features) + plus := insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270) + under := insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1) + addFeature(t, g, plus, "chan:qq", 1.0) + addFeature(t, g, plus, "part:morning", 0.2) + addRef(t, g, under, "relation", 42, 1.0) + addRef(t, g, under, "entity", 7, 1.0) + + merged, err := g.DedupeScenes() + if err != nil { + t.Fatalf("DedupeScenes 出错: %v", err) + } + if merged != 1 { + t.Errorf("应合并 1 组,实际 %d", merged) + } + if n := sceneRowCount(t, g); n != 1 { + t.Fatalf("合并后 scenes 应剩 1 行,实际 %d:\n%v", n, sceneKeys(t, g)) + } +} + +// 合并绝不能丢记忆:refs 要全部转到存活的那一行。 +func TestDedupeScenes_PreservesRefs(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + plus := insertSceneRow(t, g, "auto:chan:mc:event+topic:mc", "emergent", 50) + under := insertSceneRow(t, g, "auto:chan:mc:event_topic:mc", "emergent", 1) + addFeature(t, g, plus, "chan:mc:event", 1.0) + addRef(t, g, under, "relation", 1, 1.0) + addRef(t, g, under, "relation", 2, 1.0) + addRef(t, g, under, "entity", 3, 1.0) + + if _, err := g.DedupeScenes(); err != nil { + t.Fatal(err) + } + + // 一条都不能少 + if got := sceneRefCount(t, g); got != 3 { + t.Fatalf("合并后 scene_refs 应有 3 条,实际 %d —— 合并丢了记忆", got) + } + // 且全部挂在存活的那一行上 + var sceneID int64 + if err := g.db.QueryRow(`SELECT id FROM scenes LIMIT 1`).Scan(&sceneID); err != nil { + t.Fatal(err) + } + var onSurvivor int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM scene_refs WHERE scene_id = ?`, sceneID).Scan(&onSurvivor); err != nil { + t.Fatal(err) + } + if onSurvivor != 3 { + t.Errorf("存活场景上只挂了 %d/3 条 refs", onSurvivor) + } +} + +// 特征取并集:哪一侧有就保留,权重取大。 +func TestDedupeScenes_UnionsFeatures(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + plus := insertSceneRow(t, g, "auto:chan:qq+topic:排班", "emergent", 3) + under := insertSceneRow(t, g, "auto:chan:qq_topic:排班", "emergent", 2) + addFeature(t, g, plus, "chan:qq", 1.0) + addFeature(t, g, plus, "topic:排班", 0.4) + addFeature(t, g, under, "chan:qq", 1.0) + addFeature(t, g, under, "peer:boss", 1.0) // 只在孪生那侧有 + + if _, err := g.DedupeScenes(); err != nil { + t.Fatal(err) + } + + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_features`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 3 { + t.Errorf("特征并集应为 3(chan:qq / topic:排班 / peer:boss),实际 %d", n) + } +} + +// strength 要相加:两个场景各被遇到过 N 次,合起来就该是 2N。 +func TestDedupeScenes_SumsStrength(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270) + insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1) + + if _, err := g.DedupeScenes(); err != nil { + t.Fatal(err) + } + + var strength int + if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&strength); err != nil { + t.Fatal(err) + } + if strength != 271 { + t.Errorf("strength 应为 270+1=271,实际 %d", strength) + } +} + +// 幂等:跑两次,第二次必须是 0 合并、0 行变化。 +func TestDedupeScenes_IsIdempotent(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270) + insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1) + addRef(t, g, 2, "relation", 1, 1.0) + + if n, err := g.DedupeScenes(); err != nil || n != 1 { + t.Fatalf("首次应合并 1 组,实际 n=%d err=%v", n, err) + } + rows, refs, strength := sceneRowCount(t, g), sceneRefCount(t, g), 271 + + n2, err := g.DedupeScenes() + if err != nil { + t.Fatal(err) + } + if n2 != 0 { + t.Errorf("第二次不应再合并,实际 %d", n2) + } + if got := sceneRowCount(t, g); got != rows { + t.Errorf("第二次改变了行数: %d → %d", rows, got) + } + if got := sceneRefCount(t, g); got != refs { + t.Errorf("第二次改变了 refs: %d → %d", refs, got) + } + var s int + if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&s); err != nil { + t.Fatal(err) + } + if s != strength { + t.Errorf("第二次把 strength 改成了 %d(应保持 %d)", s, strength) + } +} + +// 不同场面不能被合到一起:只有归一化后**完全同名**才算重复。 +// 这是本判据的另一半——去重不能变成"把相似的一律合并"。 +func TestDedupeScenes_KeepsDistinctScenes(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + insertSceneRow(t, g, "auto:chan:qq", "emergent", 5) + insertSceneRow(t, g, "auto:chan:webui", "emergent", 6) + insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 270) + addFeature(t, g, 1, "chan:qq", 1.0) + addFeature(t, g, 2, "chan:webui", 1.0) + + n, err := g.DedupeScenes() + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("三个不同场面不该被合并,实际合并了 %d 组", n) + } + if got := sceneRowCount(t, g); got != 3 { + t.Errorf("应有 3 个场景,实际 %d: %v", got, sceneKeys(t, g)) + } +} + +// 声明场景与涌现场景归一化后同名时也要合——现网 chan:context_archived +// 就是这么来的(origin='emergent' 却长得像声明键)。 +func TestDedupeScenes_MergesAcrossOrigins(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + insertSceneRow(t, g, "chan:qq", "declared", 281) + insertSceneRow(t, g, "chan:QQ", "declared", 3) // 仅大小写不同 + + n, err := g.DedupeScenes() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("大小写不同的同名场景应合并,实际 %d 组", n) + } + if got := sceneRowCount(t, g); got != 1 { + t.Errorf("应剩 1 行,实际 %d: %v", got, sceneKeys(t, g)) + } +} + +// 归一化口径必须与写/读侧一致:这里独立复算一遍期望名, +// 避免判据与被测实现共用同一个 NormalizeSceneKey 而一起错。 +func TestDedupeScenes_UsesSameNormalizationAsWriteSide(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 写侧(effectiveScenes)会把 "auto:chan:qq+part:morning" 归一成什么? + wantKey := NormalizeSceneKey("auto:chan:qq+part:morning") + if strings.Contains(wantKey, "+") { + t.Fatalf("前提不成立:NormalizeSceneKey 未处理 '+',got %q", wantKey) + } + insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 1) + insertSceneRow(t, g, wantKey, "emergent", 1) + + if _, err := g.DedupeScenes(); err != nil { + t.Fatal(err) + } + if got := sceneRowCount(t, g); got != 1 { + t.Errorf("建键侧的未归一化键应与写侧归一化后的键合并,实际剩 %d 行", got) + } +} diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go index 3c8dca3..41e6cdc 100644 --- a/internal/memory/scene_emerge.go +++ b/internal/memory/scene_emerge.go @@ -410,8 +410,17 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { return "", err } } - // 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收) - if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil { + // 场景成立后,**只清本指纹那个桶**的线索(它们已被这次长出吸收)。 + // + // 曾经是 `DELETE FROM situation_evidence`(全表清),后果不是「多清一点」: + // 多通道共用一个库,qq 的场景一长出来,就把 mc / webui / cli 尚未攒够 + // minSceneEvidence 的证据一并抹掉——它们的计数被反复清零,于是 + // **永远**攒不到 2 次,场景永远长不出来。实测:6 个通道各来 3 次, + // 只长出 2 个场景。 + // + // ★ 桶键是 sig.Label(2) 本身,**不带 "auto:" 前缀**(见 + // recordSituationEvidenceLocked)——base 是带前缀的场景键,两者不是一回事。 + if _, err := tx.Exec(`DELETE FROM situation_evidence WHERE label = ?`, sig.Label(2)); err != nil { return "", err } if err := tx.Commit(); err != nil { diff --git a/internal/memory/scene_evidence_test.go b/internal/memory/scene_evidence_test.go new file mode 100644 index 0000000..5427598 --- /dev/null +++ b/internal/memory/scene_evidence_test.go @@ -0,0 +1,129 @@ +package memory + +// R5 的判据:场景成立时清证据,只清**本指纹那个桶**,不是全表。 +// +// 现状(scene_emerge.go createSceneLocked 末尾): +// +// DELETE FROM situation_evidence ← 全表清 +// +// 后果:多场景并发轮次下,A 场景的建立会连带清掉 B 尚未攒够 +// minSceneEvidence=2 的证据 ⇒ 门槛判定被「别的场景刚好长出来」随机打断。 +// 这不是理论:QQ / mc / webui 三个通道在同一进程里各自计数。 +// +// 判据参照物在生产代码之外:期望值是「建一个场景后,其它桶的证据必须原样还在」。 + +import ( + "os" + "testing" +) + +// evidenceCount 读某桶的累计次数(直查表,不走被测函数)。 +func evidenceCount(t *testing.T, g *GraphDB, label string) int { + t.Helper() + var n int + err := g.db.QueryRow( + `SELECT COALESCE((SELECT count FROM situation_evidence WHERE label = ?), 0)`, label, + ).Scan(&n) + if err != nil { + t.Fatal(err) + } + return n +} + +// A 场景成立时,B 桶的证据不得被动。 +func TestSceneCreationKeepsOtherEvidence(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + qq := mkSig("qq", "", "") + webui := mkSig("webui", "", "") + + // 两个通道各登记一次证据(都还没到门槛) + if _, _, err := g.EnterScene(qq); err != nil { + t.Fatal(err) + } + if _, _, err := g.EnterScene(webui); err != nil { + t.Fatal(err) + } + // 桶键 = sig.Label(2) 本身,**不带 "auto:" 前缀**(已用探针实测: + // 登记出来的 label 是 "chan:qq" / "chan:webui")。 + if evidenceCount(t, g, NormalizeSceneKey("chan:webui")) == 0 { + t.Fatal("webui 桶的证据没登记上,前提不成立") + } + + // qq 第 2 次 → 建出场景 + key, _, err := g.EnterScene(qq) + if err != nil { + t.Fatal(err) + } + if key == "" { + t.Fatal("qq 第 2 次应建出场景") + } + + // ★ 关键断言:qq 的证据被吸收了,webui 的必须还在 + if n := evidenceCount(t, g, NormalizeSceneKey("chan:webui")); n != 1 { + t.Errorf("建 qq 场景时把 webui 桶的证据清了(剩 %d,应为 1)—— "+ + "DELETE FROM situation_evidence 是全表清,"+ + "别的场景的门槛计数被这次建键随机打断了", n) + } +} + +// 被吸收的应该是**本指纹**那个桶。 +func TestSceneCreationClearsOwnEvidence(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + qq := mkSig("qq", "", "") + webui := mkSig("webui", "", "") + + g.EnterScene(qq) + g.EnterScene(webui) + qqLabel := NormalizeSceneKey(qq.Label(2)) // 桶键不带 auto: 前缀 + if evidenceCount(t, g, qqLabel) == 0 { + t.Fatalf("前提不成立:%q 桶应有证据", qqLabel) + } + + if _, _, err := g.EnterScene(qq); err != nil { + t.Fatal(err) + } + + if n := evidenceCount(t, g, qqLabel); n != 0 { + t.Errorf("场景已成立,%q 桶的证据应被吸收(该场面已长出场景),实际仍为 %d", qqLabel, n) + } +} + +// 反复建多个场景,早期桶的证据必须能活到自己的门槛。 +// 这是 R5 的真实后果形态:三个通道轮流入,每个都只来过一次, +// 全表清会让它们**永远**攒不到 2 次。 +func TestEvidenceSurvivesOtherSceneCreations(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sigs := map[string]Situation{ + "qq": mkSig("qq", "", ""), + "mc": mkSig("mc", "", ""), + "cli": mkSig("cli", "", ""), + "acp": mkSig("acp", "", ""), + "http": mkSig("http", "", ""), + "timer": mkSig("timer", "", ""), + } + + // 每个通道轮流来 3 次。若全表清,后到的通道永远攒不够。 + for round := 0; round < 3; round++ { + for _, sig := range sigs { + if _, _, err := g.EnterScene(sig); err != nil { + t.Fatal(err) + } + } + } + + // 6 个通道都该长出场景 + if n := len(sceneKeys(t, g)); n != len(sigs) { + t.Errorf("6 个通道各来 3 次,应长出 %d 个场景,实际 %d: %v\n"+ + "全表清证据会让除第一个之外的通道永远攒不到 minSceneEvidence", + len(sigs), n, sceneKeys(t, g)) + } +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 93edf9b..e5f1bea 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -89,7 +89,11 @@ func ValidRecallPolicy(policy string) bool { // 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 // // 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。 -// 它们每次触发都在撑一个「场面」,会把不相干的交互聚到一起。 +// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70 +// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间 +// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。 +// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件 +// 将来确实需要时的闸门。 const ( ScenePolicyAuto = "auto" ScenePolicyNone = "none" From 13a070fbb24f19f81f9bc4e2eba179145d2d6de8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 20:17:17 +0800 Subject: [PATCH 06/13] =?UTF-8?q?docs(memory):=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=203/5=20=E5=AE=8C=E6=88=90=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=204=20=E9=83=A8=E7=BD=B2=E5=89=8D=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E4=B8=8E=E7=A1=AC=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 步骤 4 加了硬约束提醒:必须先部署含 R1 修复的二进制再清理,否则 重新涌现出来的还是带 +/# 的旧键。并记录部署前实测基线(PID 92115、 子进程插件 25、近 24h 异常日志 0、场景 65),以及 homed --version 这个 flag 并不存在(纪律清单那条要用 status 接口或 ps 核对)。 --- docs/zh/scene-memory-fix-plan.md | 41 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md index 33ca9ba..c1edce2 100644 --- a/docs/zh/scene-memory-fix-plan.md +++ b/docs/zh/scene-memory-fix-plan.md @@ -163,7 +163,14 @@ RecallPolicy 召不召回记忆(只读,默认开) (`loadEmergentScenesLocked` 只取 `origin='emergent'`), 故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。 -### 步骤 3:修 R3(让图整理覆盖全库) +### 步骤 3:修 R3(让图整理覆盖全库)✅ 已完成 + +- [x] `GraphDB.DedupeScenes()`:归一化后同名场景合成一个 +- [x] 接到 `mergeLoop` 尾部(不塞进实体的 O(n²) 双重循环) +- [x] 判据 8 例(先红后绿) +- 生产库副本实测:65 → 57,合并 8 组,`auto:chan:qq_part:morning` + 的 refs/rel/ent 一条没丢,strength 1 → 272 +- 提交:`758ec11` - [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环 (理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮; @@ -175,6 +182,30 @@ RecallPolicy 召不召回记忆(只读,默认开) 用户明确要求:**直接清理,重新生成**(不做保守迁移)。 +> ⚠️ **硬约束:必须先部署含 R1 修复的二进制**。反过来的话,重新涌现出来的 +> 还是带 `+`/`#` 的旧键,清一场白清。 + +#### 部署前基线(2026-09-26 20:1x 实测) + +``` +PID 92115 / 启动于 Sat 19:12:43 +子进程插件 25 个 +近 24h 异常日志(Fatal/panic/DATA RACE): 0 +场景数 65 +`homed --version` 不存在(flag 未定义)⇒ 纪律清单那条用 status 接口或 ps 核对 +``` + +#### 执行顺序 + +- [ ] 1. `make build`(默认 `HOMED_TAGS=onnxruntime`,cgo;校验 onnx/cgo 标记) +- [ ] 2. 备份:`sqlite3 graph.db ".backup 'graph.db.bak-'"`(**禁用 cp**,WAL 不一致) +- [ ] 3. `install -m 0755 build/homed /usr/local/bin/homed`(**禁用 cp**,会写坏运行中进程映像) +- [ ] 4. `systemctl restart homeagent` +- [ ] 5. 后置验证:进程起来 / 子进程插件数恢复 / 异常日志为 0 / 真实对话跑通 +- [ ] 6. 清理:删 `origin='emergent'` 全部 + 关联 features/refs + `situation_evidence` +- [ ] 7. 等 ≥2 次同类交互让场景重新涌现 +- [ ] 8. 复验:新键不含 `+`/`#`、有 features **且**有 refs、strength 合理 + - [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照) - [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs` - [ ] 同步清 `situation_evidence` @@ -186,10 +217,12 @@ RecallPolicy 召不召回记忆(只读,默认开) > 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留, > 涌现重建期间它继续承担 QQ 场景召回。 -### 步骤 5:修 R5(证据桶别全表清) +### 步骤 5:修 R5(证据桶别全表清)✅ 已完成 -- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删 -- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在 +- [x] `DELETE FROM situation_evidence` → `... WHERE label = ?`(桶键 = `sig.Label(2)`, + **不带 `auto:` 前缀**——这点修的时候差点栽) +- [x] 判据 3 例:判据实测「6 个通道各来 3 次只长出 2 个场景」 +- 提交:`758ec11` ### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来) From 2567a22be534b8cf3429908116879be575e46ed3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 21:55:12 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(memory):=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E9=94=AE=E4=B8=A4=E8=B7=AF=E5=90=88=E5=B9=B6=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=EF=BC=88=E7=8E=B0=E7=BD=91=E6=97=A5=E5=BF=97=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=20scenes=3D[chan:qq=20chan:qq]=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现网 21:49 的 QQ 轮次日志打出 `scenes=[chan:qq chan:qq]` —— 同一键 出现两次。查因:tooldefs.go:63 与 task.go:793 是同一段拼接写法, scenes := a.sceneKeysFor(...) // 内部有 seen 去重 turn := a.resolveTurnScenes(...) for _, k := range turn.Keys { scenes = append(scenes, k) // ← 两路之间没有共同的 seen } 而声明路与通道派生路都会产出 chan:qq(既是插件声明的、也是从 evt.Source 派生的),于是重复。 功能上无害(RecallByScene 内部会再去重),但有两个实际代价:日志里 的 scenes=[...] 误导排查——会让人以为场景集合本身有问题;以及每次 白走一遍前缀匹配。 修法:抽 mergeSceneKeys 共用函数(顺带消掉两处重复代码),两处调用点 都走它。判据 scenemerge_test.go 5 例:3 个合并场景(同名 / 归一化后 同名 / 多路重复)+ scene_policy=none 时两路皆空(防「声明路关了但 涌现路还开着」的半开状态)。 记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。 --- internal/agent/core/memorypass.go | 29 ++++++++++ internal/agent/core/scenemerge_test.go | 73 ++++++++++++++++++++++++++ internal/agent/core/task.go | 4 +- internal/agent/core/tooldefs.go | 6 +-- 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 internal/agent/core/scenemerge_test.go diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index e20179c..84a299c 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -65,6 +65,35 @@ func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string return keys } +// mergeSceneKeys 把「声明路」与「涌现场景」两路合并成一个**无重复**的场景集合。 +// +// 为什么需要它:两路各自都去重过(sceneKeysFor 内部有 seen、resolveTurnScenes +// 内部也有),但**两路之间**没有共同的 seen。而声明路与通道派生路会产出 +// 同一个键(chan:qq 既是声明的、也是从 evt.Source 派生的)——现网日志实测到 +// `scenes=[chan:qq chan:qq]`。 +// +// 功能上 RecallByScene 内部会再去重,所以这不是 bug,但有两个实际代价: +// 日志里的 scenes=[...] 会误导排查;每次白走一遍前缀匹配。 +func mergeSceneKeys(declared, emergent []string) []string { + out := make([]string, 0, len(declared)+len(emergent)) + seen := make(map[string]bool, len(declared)+len(emergent)) + for _, k := range declared { + if k == "" || seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + for _, k := range emergent { + if k == "" || seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + return out +} + // memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。 type memoryPassOut struct { // Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。 diff --git a/internal/agent/core/scenemerge_test.go b/internal/agent/core/scenemerge_test.go new file mode 100644 index 0000000..6125b8f --- /dev/null +++ b/internal/agent/core/scenemerge_test.go @@ -0,0 +1,73 @@ +package core + +// 场景键去重的判据。 +// +// 症状:现网日志出现 `scenes=[chan:qq chan:qq]` —— 同一个键出现两次。 +// 原因:buildTaskMemoryContext / stepToolAfter 都先取 sceneKeysFor(内部 +// 有 seen 去重),再把 resolveTurnScenes 的 turn.Keys 直接 append 上去, +// **两路之间没有共同的 seen 集合**。声明路和通道派生路都会产出 chan:qq。 +// +// 功能上无害(RecallByScene 内部会去重),但它有两个实际代价: +// 1. 日志里的 scenes=[...] 具有误导性——排查时会以为场景集合有问题; +// 2. 每次多带一个重复键进召回,白走一遍前缀匹配。 +// +// 判据参照物在生产代码之外:期望值是「场景集合内不得有重复键」这条 +// 不变量,直接对合并后的切片计数,不引用被测实现。 + +import ( + "testing" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// TestSceneKeysMerged_NoDuplicates 声明路与涌现路的并集不得有重复。 +// 现状下 tooldefs.go:63 与 task.go:793 都是直接 append,缺这一步。 +func TestSceneKeysMerged_NoDuplicates(t *testing.T) { + cases := []struct { + name string + declared []string + emergent []string + }{ + { + name: "涌现键与声明键同名(现网实测 chan:qq 两路都产出)", + declared: []string{"chan:qq"}, + emergent: []string{"chan:qq"}, + }, + { + name: "涌现键已归一化后与声明键同名", + declared: []string{"chan:qq"}, + emergent: []string{"chan:QQ", "chan:qq"}, + }, + { + name: "多路重复", + declared: []string{"chan:qq", "tool:qq_get_message"}, + emergent: []string{"chan:qq", "tool:qq_get_message", "auto:chan:qq"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mergeSceneKeys(tc.declared, tc.emergent) + seen := map[string]bool{} + for _, k := range got { + if seen[k] { + t.Errorf("场景集合含重复键 %q: %v", k, got) + } + seen[k] = true + } + }) + } +} + +// TestSceneSuppressedSkipsMerge none 声明时两路都应为空, +// 不能出现「声明路被关、涌现路还在」这种半开状态。 +func TestSceneSuppressedSkipsMerge(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + evt := &agentIO.InputEvent{ + Source: "system", + Payload: map[string]interface{}{"scene_policy": "none"}, + } + declared := a.sceneKeysFor(evt, "") + if len(declared) != 0 { + t.Fatalf("scene_policy=none 时声明路应为空,实际 %v", declared) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 70e6253..c05981a 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -790,9 +790,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { // 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景 scenes := a.sceneKeysFor(f.Evt, tc.Name) turn := a.resolveTurnScenes(f, tc.Name) - for _, k := range turn.Keys { - scenes = append(scenes, k) - } + scenes = mergeSceneKeys(scenes, turn.Keys) recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText } } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index c89e7c7..c560468 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -63,11 +63,9 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int if f.Evt != nil && f.Evt.Source != "" { trigger = "input:" + f.Evt.Source } - // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集。 + // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集(去重)。 turn := a.resolveTurnScenes(f, "") - for _, k := range turn.Keys { - scenes = append(scenes, k) - } + scenes = mergeSceneKeys(scenes, turn.Keys) return a.recallText(query, trigger, maxTokens, scenes) } From bf5d8915dd3f66c27601fd2a816467fd90463135 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 23:14:17 +0800 Subject: [PATCH 08/13] =?UTF-8?q?docs(site):=20=E4=BB=8B=E7=BB=8D=E7=AB=99?= =?UTF-8?q?=E8=A1=A5=E3=80=8C=E5=9C=BA=E9=9D=A2=E6=B6=8C=E7=8E=B0=E3=80=8D?= =?UTF-8?q?=E6=9D=BF=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层记忆那张图只讲了字面相关性召回,缺了按场合召回的那一半。 新增一段说明:场面指纹(通道/对象/工具/话题/时段)如何自己长成场面、 为什么只发生一次的不算场面、以及 ScenePolicy 声明项(默认参与)。 沿用现有 card/grid-3/reveal class,视觉与既有三张卡一致; div 与 p 配平关系与改动前完全一致(142/142、差值 40 未变)。 --- site/index.html | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/site/index.html b/site/index.html index 7b7be52..fc59be2 100644 --- a/site/index.html +++ b/site/index.html @@ -2604,6 +2604,39 @@

+ +
+

场面涌现:同一场合,记忆自己回来

+

+ 三层记忆按字面相关性召回——你得说出相近的词才想得起来。 + 场面记忆补上另一半:按场合召回。同一个场合再次出现, + 当时挂在这个场合上的约定、偏好、人物关系会自动回来,与这次说了什么措辞无关。 +

+
+
+

没人声明,自己长出来

+

+ 每轮交互只采集可观察的信号:在哪个通道、跟谁、在用什么工具、聊什么词、什么时段。 + 这组指纹反复重合时,一场「场面」就自己成形了——不需要人工标注。 +

+
+
+

只发生一次的不算场面

+

+ 同类场面出现第二次才被认定会重复。 + 一次性的交互不建场面,否则图库里会堆满只发生过一次的事, + 每次路过都要召回一堆不相干的。 +

+
+
+

参与与否,由插件声明

+

+ 通道可用 ScenePolicy 声明是否参与场面识别(默认参与)。 + 纯内部信号(内核自循环、心跳)可以显式退出,不在记忆里留下脚印。 +

+
+
+
From 9346fed0d908d2f80ac6b38b3192d2dcc931a54e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 23:31:58 +0800 Subject: [PATCH 09/13] =?UTF-8?q?feat(webui):=20=E6=98=9F=E5=9B=BE?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=20agent=20=E6=B4=BB=E5=8A=A8=20+=20=E6=90=AC?= =?UTF-8?q?=E5=88=B0=E4=B8=BB=E9=A1=B5=20+=20=E4=BF=AE=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E9=85=8D=E8=89=B2=E4=BB=8E=E6=9C=AA=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 星图此前只是静态展示:starmapAnimate() 只转星空,节点完全静止, 与 agent 的动作零关联。本轮三件事。 ★ 修一个从未被发现的 bug:分类配色一直是坏的 服务端 type 是首字母大写("Concept",见 internal/memory/graph.go), 而 smTypeColors 的键全是小写 ⇒ 永远匹配不上 ⇒ 1151 个节点全渲染成 同一个灰色 0xcccccc。浏览器实测确认:改前 colors=["cccccc"], 改后 ["44ff88"](概念绿)。 一、跟随 agent 动(三路信号,全部在渲染循环里推进,不另起定时器) 1. tool_call / stage / agent_output 的 SSE 事件 → 命中节点发光冲高 + 尺寸微扩。工具名按**词**匹配实体(knowledge_list → knowledge_*)。 2. /runtime 调度器(3s)→ 排队/中断/挂起时全图绷紧;中断或抢占计数 上升时来一记强脉冲。 3. /memory/graph/pulse(10s,新端点)→ 新记忆「生长」:从 0 弹到 正常大小并留余晖。 另:距上次活动越近,全图越亮(抽样呼吸)—— agent 一忙图就活。 二、搬到主页 + 独立页签 总览页内嵌 360px 星图;顶部导航加「星图」独立页签(全高 + 图例)。 同一套 renderer 用 appendChild 在容器间搬运 canvas(three.js 的 canvas 只能有一个父节点,同时渲染会一边黑屏)。 三、性能:保留全部 1151 节点,但全部降规格 改前每节点 = 独立 SphereGeometry(16,12) + 独立光晕球 + 一张 256x64 CanvasTexture ⇒ 2302 个独立 geometry、约 88 万三角形、1151 个 ,仅文字贴图就吃约 72MB 显存。全景远看根本读不清那些标签。 改后:共享 SphereGeometry(8,6)(约 84 三角形/节点);标签改为 hover 时在容器角上显示 HTML 文本(零显存,且比 3D 贴图更清晰); 866 条边按关系类型合并成 4 个 LineSegments(draw call 866 → 4)。 hover 复位随之改为 baseScale —— 旧的 set(1,1,1) 会把按 mention_count 缩放过的大节点缩成最小尺寸。 四、/memory/graph 瘦身:不再下发稠密向量 星图是本接口唯一消费者,却从不读 vector。生产实测该字段占 79,314 / 402,811 字节 = 19%,而 8 块记忆就这么多,200 块就是 ~2MB 白查白发白堆。真实数据集实测响应 402,811 → 326,997 字节(-18%)。 新增 /memory/graph/pulse:只回 since 窗口内变动过的实体(id/name/type/ mention_count/updated_at)。星图每 10s 拉它来判断「哪个节点新长出来」, 而不必重拉 400KB 全量。 验证(不是「应该能跑」): - go vet 干净;go test 全绿;新增 5 个测试(向量裁剪 / pulse 窗口 / since 放大 / pulse 不带向量 / 类型断言失败时透传不丢数据) - 覆盖率 65.5% → 66.5% - 真实 1151 节点数据集上跑 headless chromium + SwiftShader 实测: nodeMeshes=1151、edgeSegs=4、geoShared=true、控制台零报错、 图例与统计(1151 节点 / 866 关系)正常、canvas 在两个容器间正确搬运 - 脉冲匹配在浏览器里逐个 hint 验证: knowledge_list → 2 个(只命中 knowledge_base / knowledge_list) qq_get_message 等无匹配 → 8 个(走「整体活动」兜底) ★ 途中修掉自己的两个错:① 最早的子串匹配让 hint="knowledge" 命中 全部单字实体(一次 pulse 选中 250 个、队列顶到 260 上限); ② 改成词匹配后,旧的「补齐到 20 个」逻辑又把 1 个真实命中补成 20 个 无关节点 —— 现象与①一样,只是成因不同。补齐现在只在**完全无匹配** 时启用。 未做(本轮范围外):服务端 gzip(首屏 793KB 无压缩,实测可压到 ~240KB)、 renderAll 按页签懒加载(首屏仍在拉隐藏页签的 kernel+settings 共 230KB)、 setInterval 15s 全量重拉(空闲 53MB/h)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/plugins/webui/dashboard.css | 36 + internal/plugins/webui/dashboard.html | 5 + internal/plugins/webui/dashboard.js | 928 +++++++++++++----- internal/plugins/webui/handler.go | 3 + .../plugins/webui/handler_graph_pulse_test.go | 163 +++ internal/plugins/webui/handler_memory.go | 145 ++- 6 files changed, 1027 insertions(+), 253 deletions(-) create mode 100644 internal/plugins/webui/handler_graph_pulse_test.go diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 8a4d77d..31706a1 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -2076,6 +2076,42 @@ #sm-container-chat canvas { display: block; } + /* 星图容器:独立页签 / 总览页 / 聊天面板三处共用同一套样式。 + position:relative 是必需的 —— hover 角标(#sm-label)绝对定位在容器上。*/ + #sm-container-page, + #sm-container-home { + background: var(--bg-input); + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + overflow: hidden; + position: relative; + width: 100%; + } + #sm-container-page canvas, + #sm-container-home canvas { + display: block; + } + /* hover 角标:取代原先每节点一张 256x64 CanvasTexture + (1151 张贴图 ≈ 72MB 显存)。DOM 文字更清晰且零 GPU 开销。*/ + #sm-label { + position: absolute; + left: 10px; + bottom: 10px; + display: none; + max-width: 60%; + padding: 4px 10px; + font-size: 12px; + line-height: 1.4; + color: var(--text-primary); + background: rgba(10, 10, 20, 0.82); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + pointer-events: none; + z-index: 5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .toggle-row { margin-top: 8px; diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index b6ca9ba..36a3a4e 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -66,6 +66,10 @@ 对话 + + + 星图 + 插件 @@ -176,6 +180,7 @@
+
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index d430734..b056dca 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1,5 +1,5 @@ // ===== State ===== - let state = { + const state = { status: {}, kernel: null, settings: {}, @@ -20,6 +20,18 @@ starmapInit: false, starmapLoading: false, starmapData: null, + // ===== 星图:跟随 agent 活动 ===== + // 三路活动信号(均已在页面上,无需新数据源): + // 1. toolPulseMs —— SSE tool_call/stage 触发的「脉冲」,实时 + // 2. activity —— /runtime 调度器计数(排队/中断/抢占),3s 轮询 + // 3. grownIds —— /memory/graph/pulse 检出「新长出来」的节点,10s 轮询 + starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 + starmapGrown: {}, // nodeId -> 生长动画截止时间戳 + starmapActivity: null, // /runtime 的调度器快照 + starmapPulseTimer: null, // /memory/graph/pulse 轮询 id + starmapActivityTimer: null, // /runtime 轮询 id + starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 + starmapPulseSince: 0, // 下次 pulse 请求的回看起点(unix 秒) chatHistory: [], terminals: [], cmdHistory: [], @@ -39,6 +51,7 @@ window._i18n = { navOverview: ["概览", "Overview"], navChat: ["对话", "Chat"], + navStarmap: ["星图", "Star Map"], navPlugins: ["插件", "Plugins"], navSettings: ["设置", "Settings"], navAdapters: ["适配器", "Adapters"], @@ -57,7 +70,7 @@ function toggleLang() { state.lang = state.lang === "zh" ? "en" : "zh"; localStorage.setItem("ha-lang", state.lang); - document.querySelectorAll("[data-i18n]").forEach(function (el) { + document.querySelectorAll("[data-i18n]").forEach((el) => { var k = el.getAttribute("data-i18n"); var m = window._i18n && window._i18n[k]; if (m) el.textContent = __(m[0], m[1]); @@ -69,7 +82,7 @@ var lang = state.lang; var btn = document.getElementById("lang-btn"); if (btn) btn.textContent = lang === "zh" ? "EN" : "中"; - document.querySelectorAll("[data-i18n]").forEach(function (el) { + document.querySelectorAll("[data-i18n]").forEach((el) => { var k = el.getAttribute("data-i18n"); var m = window._i18n && window._i18n[k]; if (m) el.textContent = lang === "en" ? m[1] : m[0]; @@ -162,7 +175,7 @@ var img = localStorage.getItem("ha-bg-img") || ""; var blur = localStorage.getItem("ha-bg-blur") || "0"; var dots = ""; - Object.keys(PALETTES).forEach(function (k) { + Object.keys(PALETTES).forEach((k) => { dots += '
"; // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" @@ -1253,7 +1282,7 @@ var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); var maxReg = 1; var maxPre = 1; - RT_LEVELS.forEach(function (L) { + RT_LEVELS.forEach((L) => { maxReg = Math.max(maxReg, byLv[L.lv] || 0); maxPre = Math.max(maxPre, preLv[L.lv] || 0); }); @@ -1262,7 +1291,7 @@ // 五个**等大表框**(四级中断 + 一条排队),与阶段管道同一套视觉语言。 // 此前是五行扁条,四级中断全为 0 时四行几乎全是空白,又占高度又难看。 h += '
'; - RT_LEVELS.forEach(function (L) { + RT_LEVELS.forEach((L) => { var depth = q[L.lv] || 0; var reg = byLv[L.lv] || 0; var pre = preLv[L.lv] || 0; @@ -1298,7 +1327,7 @@ h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); if (frames.length) { h += '
'; - frames.forEach(function (f, i) { + frames.forEach((f, i) => { var t = (f && f.task) || {}; h += '
' + escHtml(t.kind || "") + " #" + (t.id || "?") + 'L' + (t.level || 0) + @@ -1340,17 +1369,17 @@ // 界面最终状态由轮询拉到的数据决定,SSE 只负责让流式看起来即时。 function startChatTicker() { if (state._chatTicker) return; - state._chatTicker = setInterval(function () { + state._chatTicker = setInterval(() => { var tab = document.querySelector("#tab-chat"); if (tab && tab.classList.contains("active")) { - syncChatFromHistory().catch(function () {}); + syncChatFromHistory().catch(() => {}); } }, 3000); } function startRuntimeTicker() { if (state._runtimeTicker) return; - state._runtimeTicker = setInterval(function () { + state._runtimeTicker = setInterval(() => { var tab = document.querySelector("#tab-overview"); if (tab && tab.classList.contains("active")) { loadRuntime(); @@ -1417,11 +1446,20 @@ ovKpi("docs", OV_ICONS.file, __("文档", "Docs")) + ovKpi("runtime", OV_ICONS.cpu, __("运行时", "Runtime")) + '
' + + // 星图搬到主页:作为总览的门面,跟随 agent 活动脉动。 // 开源许可单独成框:之前在 KPI 卡底部只是一行小链接(.ov-foot), // 几乎看不见,也看不出是许可证还是别的什么。 + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + ''; } updateOverview(); + // 星图在主页常驻:容器已建好,首次就拉数据 + 初始化 3D。 + if (document.getElementById("sm-container-home") && !state.starmapInit) { + renderChatStarmap(); + } } function ovSet(id, text) { @@ -1738,7 +1776,7 @@ tpl.innerHTML = html; var next = Array.prototype.slice.call(tpl.children); var existing = {}; - Array.prototype.forEach.call(container.children, function (n) { + Array.prototype.forEach.call(container.children, (n) => { var k = n.getAttribute && n.getAttribute("data-key"); if (k) existing[k] = n; }); @@ -1778,7 +1816,7 @@ msgsEl._stickBound = true; msgsEl.addEventListener( "scroll", - function () { + () => { state.chatStick = msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; @@ -1791,7 +1829,7 @@ ); msgsEl.addEventListener( "load", - function () { + () => { if (state.chatStick === true) { msgsEl.scrollTop = msgsEl.scrollHeight; } @@ -1802,7 +1840,7 @@ var msgs = state.messages; var sig = msgs - .map(function (m) { + .map((m) => { var c = m.content || ""; return ( (m.role || "") + @@ -1812,9 +1850,7 @@ c.slice(-40) + ":" + (m.tool_calls || []) - .map(function (t) { - return (t.tool || t.name || "") + "/" + (t.status || ""); - }) + .map((t) => (t.tool || t.name || "") + "/" + (t.status || "")) .join(",") ); }) @@ -1835,7 +1871,7 @@ if (state.chatLoading) { var lastM = msgs.length ? msgs[msgs.length - 1] : null; if (lastM && lastM.role === "assistant") { - (lastM.tool_calls || []).forEach(function (tc) { + (lastM.tool_calls || []).forEach((tc) => { if (!tc.result && tc.status !== "denied") { var nm = tc.tool || tc.name || ""; if (newPending.indexOf(nm) === -1) newPending.push(nm); @@ -1843,9 +1879,7 @@ }); } } - var newlyDone = prevPending.filter(function (n) { - return newPending.indexOf(n) === -1; - }); + var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1); msgsEl._lastPending = newPending; var streamingLast = !!( state.chatLoading && @@ -1855,7 +1889,7 @@ ); function pillHtml() { var s = ""; - newPending.forEach(function (nm) { + newPending.forEach((nm) => { var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in"; s += '
"; } else { - msgs.forEach(function (m, i) { + msgs.forEach((m, i) => { var _k = chatMsgKey(m); var role = m.role || "user"; var c = m.content || ""; @@ -1959,7 +1993,7 @@ } var tcs = ""; if (m.tool_calls && m.tool_calls.length > 0) { - m.tool_calls.forEach(function (tc) { + m.tool_calls.forEach((tc) => { var argsStr = typeof tc.args === "object" ? JSON.stringify(tc.args, null, 1) @@ -2136,7 +2170,7 @@ var last = msgs.length ? msgs[msgs.length - 1] : null; if (last && last.role === "assistant" && !last._final) { if (_rerenderTimer) return; // 已有排程的增量更新 - _rerenderTimer = setTimeout(function () { + _rerenderTimer = setTimeout(() => { _rerenderTimer = null; renderChatStreamChunk(); }, 90); @@ -2145,7 +2179,7 @@ } // 非流式(完成/工具/历史变化):全量重渲(含防抖合并) if (_rerenderTimer) clearTimeout(_rerenderTimer); - _rerenderTimer = setTimeout(function () { + _rerenderTimer = setTimeout(() => { _rerenderTimer = null; renderChat(); if (full) { @@ -2263,8 +2297,11 @@ } } + // renderChatStarmap:把星图挂到**当前活跃的容器**上。 + // 容器优先级:独立星图页签 > 总览页小图 > 聊天面板。 + // 同一套 three.js renderer 在容器间搬运 canvas,不重建场景。 function renderChatStarmap() { - var cont = document.getElementById("sm-container-chat"); + var cont = starmapActiveContainer(); if (!cont) return; if ( window._THREE_FAILED || @@ -2348,13 +2385,19 @@ } function initChatStarmap() { - var cont = document.getElementById("sm-container-chat"); + // 容器可以是独立星图页签 / 总览页 / 聊天面板 —— 同一套 renderer + // 会在多个容器间搬运 canvas(appendChild 即可)。 + var cont = starmapActiveContainer(); if (!cont) return; var rect = cont.getBoundingClientRect(); var w = Math.max(rect.width || 300, 100); var h = Math.max(rect.height || 250, 100); if (starmapRen) { starmapRen.setSize(w, h); + // 换父节点前先把旧角标从旧容器里拨掉,否则它会残留在旧位置。 + if (starmapLabelEl && starmapLabelEl.parentElement === cont) { + starmapLabelEl.style.display = "none"; + } cont.appendChild(starmapRen.domElement); starmapRen.domElement.style.display = "block"; return; @@ -2384,19 +2427,40 @@ starmapScene.add(dl); createStarField(); createNebula(); + // 共享几何(低规格):所有节点/光晕球复用这两份。 + starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); + starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); buildChatStarmapGraph(); starmapRen.domElement.addEventListener("mousemove", onStarmapMove); starmapRen.domElement.addEventListener("click", onStarmapClick); window.addEventListener("resize", onStarmapResize); if (starmapRaf) cancelAnimationFrame(starmapRaf); starmapAnimate(); + // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 + // 只在星图真正初始化后启动,避免在隐藏页签空跑。 + starmapStartActivity(); + } + + // starmapStartActivity 启动两路活动轮询(幂等)。 + function starmapStartActivity() { + if (state.starmapPulseTimer) return; + starmapPullActivity(); + starmapPullPulse(); + state.starmapPulseTimer = setInterval(() => { + starmapPullActivity(); + }, 3000); + var pulseTimer = setInterval(() => { + starmapPullPulse(); + }, 10000); + // 两个 id 合并到一个字段会导致 setInterval 被覆盖,这里分开记。 + state.starmapActivityTimer = pulseTimer; } function buildChatStarmapGraph() { - starmapNodeMeshes.forEach(function (m) { + starmapNodeMeshes.forEach((m) => { starmapScene.remove(m); }); - starmapEdgeLines.forEach(function (l) { + starmapEdgeLines.forEach((l) => { starmapScene.remove(l); }); starmapNodeMeshes = []; @@ -2404,23 +2468,19 @@ if (starmapNodes.length === 0) return; // Calculate node degrees for leaf node detection var nodeDegs = {}; - starmapNodes.forEach(function (n) { + starmapNodes.forEach((n) => { nodeDegs[n.id] = 0; }); - starmapEdges.forEach(function (e) { + starmapEdges.forEach((e) => { nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1; nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1; }); var nodeMap = {}; - starmapNodes.forEach(function (n) { + starmapNodes.forEach((n) => { nodeMap[n.id] = n; }); - var sorted = starmapNodes.slice().sort(function (a, b) { - return (b.mention_count || 0) - (a.mention_count || 0); - }); - var mc = sorted.map(function (n) { - return n.mention_count || 0; - }); + var sorted = starmapNodes.slice().sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0)); + var mc = sorted.map((n) => n.mention_count || 0); var maxMc = Math.max(...mc, 1), minMc = Math.min(...mc, 0), rng = maxMc - minMc || 1; @@ -2430,7 +2490,7 @@ maxR = 80; var total = sorted.length; var acc = 0; - sorted.forEach(function (n, i) { + sorted.forEach((n, i) => { var m = n.mention_count || 0, mn = rng > 0 ? (m - minMc) / rng : 0; var radius = baseR + mn * (maxR - baseR); @@ -2447,12 +2507,10 @@ }; }); // Leaf nodes (degree 1) reposition near parent - sorted.forEach(function (n) { + sorted.forEach((n) => { var deg = nodeDegs[n.id] || 0; if (deg !== 1) return; - var edge = starmapEdges.find(function (e) { - return e.source_id === n.id || e.target_id === n.id; - }); + var edge = starmapEdges.find((e) => e.source_id === n.id || e.target_id === n.id); if (!edge) return; var parentId = edge.source_id === n.id ? edge.target_id : edge.source_id; @@ -2495,7 +2553,7 @@ } } // Attraction along edges - starmapEdges.forEach(function (e) { + starmapEdges.forEach((e) => { var a = pos[e.source_id], b = pos[e.target_id]; if (!a || !b) return; @@ -2515,7 +2573,7 @@ } }); // Centering constraint - ids.forEach(function (id) { + ids.forEach((id) => { var p = pos[id]; var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); var maxA = maxR * 1.5; @@ -2528,90 +2586,101 @@ }); } // Create nodes - starmapNodes.forEach(function (n) { + // + // ★ 低规格渲染(1151 节点实测后定的方案):保留**全部**节点, + // 但把每节点开销从「一个独立球 + 一个独立球光晕 + 一张 256x64 + // 文字贴图」降到「共享几何 + 共享材质 + 无常驻文字」。 + // + // 改之前的实际开销(生产实例 1151 节点): + // - SphereGeometry(16,12) 主球 + 同样规格的光晕球 = 2302 个独立 + // BufferGeometry,共约 88 万三角形 + // - 每节点一张 256x64 CanvasTexture = 1151 张 + + // 1151 个纹理,仅文字就吃约 72MB 显存 + // 这两样在「星图只是个展示」的前提下纯属浪费:星图全图远看根本 + // 读不清标签(256x64 贴在半径 0.5~2.5 的球上,本来就糊)。 + // + // 现在:共享一份 SphereGeometry(8,6)(约 84 三角形/节点), + // 颜色靠每 mesh 的 material.color(材质本身仍每节点一份, + // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 + // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— + // 文字清晰度反而比 3D 贴图好,且零显存。 + starmapNodes.forEach((n) => { var p = pos[n.id]; if (!p) return; var mn = n.mention_count || 0, mnr = rng > 0 ? (mn - minMc) / rng : 0; var rad = 0.5 + mnr * 2.0; - var col = smTypeColors[n.type] || 0xcccccc; + // 服务端 type 是首字母大写("Concept" / "Person" …), + // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 + // 这里统一小写归一化,并补上服务端实际会产出的类型。 + var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; var ei = 0.3 + mnr * 0.7; - var g = new THREE.SphereGeometry(rad, 16, 12); var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: ei, shininess: 30, }); - var mesh = new THREE.Mesh(g, mat); + var mesh = new THREE.Mesh(starmapGeo, mat); mesh.position.set(p.x, p.y, p.z); + mesh.scale.setScalar(rad / 0.5); // 共享几何半径 0.5,按需缩放 mesh.userData.nodeData = n; mesh.userData.nodeId = n.id; mesh.userData.baseEmissive = ei; - // Glow sphere - var gr = rad * 1.2 + mnr * 0.5; - var gg = new THREE.SphereGeometry(gr, 16, 12); + mesh.userData.baseScale = rad / 0.5; + mesh.userData.baseGlow = 0.12 + mnr * 0.08; + // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) var gm = new THREE.MeshBasicMaterial({ color: col, transparent: true, - opacity: 0.12 + mnr * 0.08, + opacity: mesh.userData.baseGlow, side: THREE.BackSide, blending: THREE.AdditiveBlending, }); - var gs = new THREE.Mesh(gg, gm); + var gs = new THREE.Mesh(starmapGlowGeo, gm); + gs.scale.setScalar(rad * 1.2 + mnr * 0.5); mesh.add(gs); mesh.userData.glowSphere = gs; - // Label sprite - var canvas = document.createElement("canvas"); - canvas.width = 256; - canvas.height = 64; - var ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, 256, 64); - ctx.font = "Bold 24px Courier New"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.shadowColor = "#aaccff"; - ctx.shadowBlur = 8; - ctx.fillStyle = "#ffffff"; - ctx.fillText((n.name || n.id).substring(0, 12), 128, 32); - var tex = new THREE.CanvasTexture(canvas); - tex.needsUpdate = true; - var spMat = new THREE.SpriteMaterial({ - map: tex, - transparent: true, - opacity: 0.9, - depthTest: false, - depthWrite: false, - blending: THREE.AdditiveBlending, - }); - var sprite = new THREE.Sprite(spMat); - sprite.scale.set(8, 2, 1); - sprite.position.y = rad + 2; - mesh.add(sprite); + // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 starmapScene.add(mesh); starmapNodeMeshes.push(mesh); }); // Create edges - starmapEdges.forEach(function (e) { + // + // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial + // (866 个 draw call)。改为**按关系类型分组**的少量 LineSegments: + // 同类型边合并成一个几何体,draw call 从 866 降到「关系类型数」 + // (生产实例实测 6 种左右)。 + // + // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 + // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 + var edgeByColor = {}; + starmapEdges.forEach((e) => { var a = pos[e.source_id], b = pos[e.target_id]; if (!a || !b) return; var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; - var pts = [ - new THREE.Vector3(a.x, a.y, a.z), - new THREE.Vector3(b.x, b.y, b.z), - ]; - var geo = new THREE.BufferGeometry().setFromPoints(pts); + var k = String(col); + if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] }; + edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z); + }); + Object.keys(edgeByColor).forEach((k) => { + var g = edgeByColor[k]; + if (!g.pts.length) return; + var geo = new THREE.BufferGeometry(); + geo.setAttribute( + "position", + new THREE.Float32BufferAttribute(g.pts, 3), + ); var mat = new THREE.LineBasicMaterial({ - color: col, + color: g.col, transparent: true, opacity: 0.4, }); - var line = new THREE.Line(geo, mat); - line.userData = { edgeId: e.id, edgeData: e }; - starmapScene.add(line); - starmapEdgeLines.push(line); + var seg = new THREE.LineSegments(geo, mat); + starmapScene.add(seg); + starmapEdgeLines.push(seg); }); } @@ -2640,7 +2709,7 @@ // 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 function armTurnWatchdog() { if (state._turnWatchdog) clearTimeout(state._turnWatchdog); - state._turnWatchdog = setTimeout(function () { + state._turnWatchdog = setTimeout(() => { state._turnWatchdog = null; if (state.chatLoading) { endChatTurn(); @@ -2663,14 +2732,14 @@ var panel = document.getElementById("chat-panel-chat"); if (!panel || panel.__dnd) return; panel.__dnd = true; - panel.addEventListener("dragover", function (e) { + panel.addEventListener("dragover", (e) => { e.preventDefault(); panel.style.outline = "2px dashed var(--accent, #4a90d9)"; }); - panel.addEventListener("dragleave", function () { + panel.addEventListener("dragleave", () => { panel.style.outline = ""; }); - panel.addEventListener("drop", function (e) { + panel.addEventListener("drop", (e) => { e.preventDefault(); panel.style.outline = ""; if (e.dataTransfer.files && e.dataTransfer.files.length) { @@ -2678,7 +2747,7 @@ } }); // 粘贴截图/复制的文件直接发送 - document.addEventListener("paste", function (e) { + document.addEventListener("paste", (e) => { var chatVisible = document.getElementById("chat-panel-chat") && document.getElementById("chat-input"); @@ -2751,7 +2820,7 @@ // 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。 try { var ctrl = new AbortController(); - var ackTimer = setTimeout(function () { + var ackTimer = setTimeout(() => { ctrl.abort(); }, 15000); var r = null; @@ -2893,9 +2962,7 @@ __("实体", "Entities") + '' + entities - .map(function (e) { - return escHtml(e.name || e.id || ""); - }) + .map((e) => escHtml(e.name || e.id || "")) .join(", ") + ""; } @@ -2960,7 +3027,7 @@ ); } var html = ""; - views.forEach(function (v) { + views.forEach((v) => { html += '
'; html += '
'; html += '' + knowEsc(v.name) + ""; @@ -2976,7 +3043,7 @@ html += "
"; if (v.media && v.media.length) { html += '
'; - v.media.forEach(function (m) { + v.media.forEach((m) => { var kind = m.kind || "file"; html += '[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…"; @@ -3004,7 +3071,7 @@ function renderKnowTree(node, depth) { var html = ""; // 本节点的条目 - (node.items || []).forEach(function (it) { + (node.items || []).forEach((it) => { html += '
"; }); // 子分类 - (node.children || []).forEach(function (c) { + (node.children || []).forEach((c) => { var isCur = _knowCat === c.path; var mark = isCur ? "● " : ""; html += @@ -3186,14 +3253,14 @@ context: document.getElementById("chat-panel-context"), knowledge: document.getElementById("chat-panel-knowledge"), }; - Object.keys(panels).forEach(function (k) { + Object.keys(panels).forEach((k) => { var p = panels[k]; if (p) p.classList.toggle("active", k === tab); }); if (el) { var parent = el.parentElement; if (parent) { - Array.from(parent.children).forEach(function (ch) { + Array.from(parent.children).forEach((ch) => { ch.classList.remove("active"); }); el.classList.add("active"); @@ -3249,7 +3316,7 @@ if (!data) return 0; if (typeof data.last_seq === "number") return data.last_seq; var mx = 0; - (data.messages || []).forEach(function (m) { + (data.messages || []).forEach((m) => { if (m && m.seq > mx) mx = m.seq; }); return mx; @@ -3270,7 +3337,7 @@ if (prev && prev._grow) sm._grow = true; return sm; } - list.forEach(function (sm) { + list.forEach((sm) => { if (!sm) return; var seq = sm.seq; var found = -1; @@ -3310,7 +3377,7 @@ msgs.push(sm); changed = true; }); - list.forEach(function (sm) { + list.forEach((sm) => { if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq; }); if (changed) rerenderChat(); @@ -3327,19 +3394,19 @@ var after = state.chatLastSeq || 0; if (!after) { // 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑 - return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) { + return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => { state.chatLastSeq = historyLastSeq(data); return mergeChatFromHistory(data); }); } return api("/chat/history?after=" + after) - .then(function (data) { + .then((data) => { if (!data) return; state.chatLastSeq = historyLastSeq(data) || after; applyServerMessages(data.messages || [], false); return api("/chat/history?limit=1"); }) - .then(function (tail) { + .then((tail) => { if (tail && tail.messages && tail.messages.length) { applyServerMessages(tail.messages.slice(-1), true); } @@ -3394,8 +3461,8 @@ if (_syncingChat) return Promise.resolve(); _syncingChat = true; return pollChatIncremental() - .catch(function () {}) - .then(function () { + .catch(() => {}) + .then(() => { _syncingChat = false; }); } @@ -3404,7 +3471,6 @@ // 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页 // 凭空消失、视口跳回顶部)。 function mergeChatFromHistory(data) { - { if (!data || !data.messages || data.messages.length === 0) return; var serverMsgs = data.messages; var localMsgs = state.messages; @@ -3469,7 +3535,7 @@ if (msgsEl) { var aiAvatar = '小宅'; var userAvatar = ''; - newMsgs.forEach(function (m) { + newMsgs.forEach((m) => { var role = m.role || m.Role || "user"; var c = m.content || m.Content || ""; if (role === "assistant") c = renderMd(c); else c = escHtml(c); @@ -3491,7 +3557,6 @@ Array.prototype.push.apply(state.messages, newMsgs); // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) syncStreamingPlaceholder(); - } } // syncStreamingPlaceholder:同步聊天占位符的可见性 function syncStreamingPlaceholder() { @@ -3503,7 +3568,7 @@ if (showPh && !existing) { var aiAvatar = '小宅'; var pillHtml = ""; - (state.pendingTools || []).forEach(function (nm) { + (state.pendingTools || []).forEach((nm) => { pillHtml += '' + escHtml(nm) + ''; }); msgsEl.insertAdjacentHTML("beforeend", '
' + aiAvatar + '
' + (pillHtml ? '' + pillHtml + '' : '') + '
'); @@ -3549,7 +3614,7 @@ return; } var html = ""; - list.forEach(function (t, i) { + list.forEach((t, i) => { var detailId = "term-detail-" + i; var scr = (state.termScreens && state.termScreens[t.id]) || null; var running = scr ? scr.running : !!t.running; @@ -3619,9 +3684,7 @@ var r = document.getElementById("cmd-list"); var cnt = document.getElementById("cmd-count-badge"); if (!r) return; - var running = (state.terminals || []).filter(function (t) { - return t.running; - }); + var running = (state.terminals || []).filter((t) => t.running); if (cnt) cnt.textContent = running.length; if (running.length === 0) { r.innerHTML = @@ -3638,7 +3701,7 @@ "" + __("运行时长", "Uptime") + ""; - running.forEach(function (t) { + running.forEach((t) => { var scr = (state.termScreens && state.termScreens[t.id]) || null; var out = scr ? scr.output : t.output || ""; html += @@ -3685,13 +3748,15 @@ return; } console.log("[SSE] connected"); - es.addEventListener("agent_output", function (e) { + es.addEventListener("agent_output", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 if (p.channel) rtSparkOutput(ev.source || "", p.channel); + // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。 + starmapPulse("output", p.channel || null); console.log( "[SSE] agent_output received", p.content ? p.content.substring(0, 50) : "(empty)", @@ -3775,7 +3840,7 @@ }); // token 级流式增量:逐块追加到当前回复内容(流式生成中); // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 - es.addEventListener("content_delta", function (e) { + es.addEventListener("content_delta", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3814,7 +3879,7 @@ rerenderChat(); } catch (ex) {} }); - es.addEventListener("terminal_output", function (e) { + es.addEventListener("terminal_output", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3837,7 +3902,7 @@ console.error("[SSE] terminal_output error", ex); } }); - es.addEventListener("reasoning", function (e) { + es.addEventListener("reasoning", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3865,7 +3930,7 @@ } catch (ex) {} }); // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废 - es.addEventListener("reasoning_delta", function (e) { + es.addEventListener("reasoning_delta", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3891,7 +3956,7 @@ rerenderChat(); } catch (ex) {} }); - es.addEventListener("tool_call", function (e) { + es.addEventListener("tool_call", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3922,13 +3987,16 @@ var pidx = (state.pendingTools || []).indexOf(p.tool); if (pidx !== -1) state.pendingTools.splice(pidx, 1); state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || ""); + // 星图跟随:工具调用是最可靠的活动信号。工具名(如 + // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。 + starmapPulse("tool", p.tool); rerenderChat(); } catch (ex) { console.error("[SSE] tool_call error", ex); } }); // 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。 - es.addEventListener("stage", function (e) { + es.addEventListener("stage", (e) => { try { var ev = JSON.parse(e.data); var p = ev.payload || {}; @@ -3954,9 +4022,11 @@ } else if (phase === "after_output") { rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done")); } + // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。 + starmapPulse("stage", tool || phase); state.pipelinePhase = phase; if (state.pipelineTimer) clearTimeout(state.pipelineTimer); - state.pipelineTimer = setTimeout(function () { + state.pipelineTimer = setTimeout(() => { state.pipelinePhase = ""; if (document.getElementById("rt-sec-pipe")) renderRuntime(); }, 2500); @@ -3989,7 +4059,7 @@ }); // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 - es.addEventListener("channel_input", function (e) { + es.addEventListener("channel_input", (e) => { try { var d = JSON.parse(e.data); var src = d.source || ""; @@ -3998,27 +4068,27 @@ console.error("[SSE] channel_input error", ex); } }); - es.onopen = function () { + es.onopen = () => { console.log("[SSE] connection opened"); }; - es.onerror = function (e) { + es.onerror = (e) => { console.error("[SSE] error", e); // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 try { state.eventSource && state.eventSource.close(); state.eventSource = null; } catch(ex){} // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) - syncChatFromHistory().catch(function(){}); + syncChatFromHistory().catch(()=> {}); // 3) 2s 后手动重连(比原 5s 更快恢复) setTimeout(connectSSE, 2000); }; // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 - es.addEventListener("sync_required", function(e) { + es.addEventListener("sync_required", (e) => { console.log("[SSE] sync_required received, incremental sync"); - syncChatFromHistory().catch(function(){}); + syncChatFromHistory().catch(()=> {}); }); // Periodically refresh sidebar data if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); - state._sidebarRefresh = setInterval(async function () { + state._sidebarRefresh = setInterval(async () => { try { var td = await api("/terminals"); if (td && td.terminals) state.terminals = td.terminals; @@ -4075,7 +4145,7 @@ ) + "
"; html += '
'; - svcs.forEach(function (s) { + svcs.forEach((s) => { var url = s.url || ""; if (url && token) url += "?__token=" + encodeURIComponent(token); var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; @@ -4163,17 +4233,15 @@ __("选择 .hmap 文件上传", "Upload .hmap file") + "
"; var disabledNames = {}; - (state.disabledPlugins || []).forEach(function (d) { + (state.disabledPlugins || []).forEach((d) => { disabledNames[d.name] = d; }); - var installedNames = (state.installedPlugins || []).map(function (p) { - return p.name; - }); + var installedNames = (state.installedPlugins || []).map((p) => p.name); var allPluginNames = {}; - plugins.forEach(function (p) { + plugins.forEach((p) => { allPluginNames[p.name] = true; }); - state.disabledPlugins.forEach(function (d) { + state.disabledPlugins.forEach((d) => { allPluginNames[d.name] = true; }); html += @@ -4197,12 +4265,10 @@ "" + __("操作", "Actions") + ""; - names.forEach(function (name) { + names.forEach((name) => { var isExternal = installedNames.indexOf(name) >= 0; var isDisabled = disabledNames[name]; - var loaded = plugins.some(function (p) { - return p.name === name; - }); + var loaded = plugins.some((p) => p.name === name); var statusHtml = loaded && !isDisabled ? '' + @@ -4273,7 +4339,7 @@ "" + __("操作", "Actions") + ""; - installed.forEach(function (p) { + installed.forEach((p) => { html += "" + escHtml(p.name) + @@ -4319,7 +4385,7 @@ tools.length + ")" + '
'; - tools.forEach(function (t) { + tools.forEach((t) => { html += ' t.name === "healthcheck"); if (!healthTool) { panel.innerHTML = '

' + @@ -4608,12 +4672,8 @@ "

" ); var checks = r.checks || []; - var passed = checks.filter(function (c) { - return c.pass; - }).length; - var failed = checks.filter(function (c) { - return !c.pass; - }).length; + var passed = checks.filter((c) => c.pass).length; + var failed = checks.filter((c) => !c.pass).length; var html = '
' + '' + @@ -4630,7 +4690,7 @@ __("总计: ", "Total: ") + checks.length + "
"; - checks.forEach(function (c) { + checks.forEach((c) => { var passClass = c.pass ? "check-pass" : "check-fail"; if (c.status === "skip") passClass = "check-skip"; html += @@ -4777,7 +4837,7 @@ ")"; if (k.plugins?.length) { html += '
'; - k.plugins.forEach(function (p) { + k.plugins.forEach((p) => { html += '' + escHtml(p.name) + ""; }); @@ -4804,12 +4864,31 @@ starmapSelected = null, starmapAutoView = true; var starmapRaf = null; + // 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 + // BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, + // 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, + // 真实半径由 scale 给出(见 buildChatStarmapGraph)。 + var starmapGeo = null, + starmapGlowGeo = null; + // 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 + var starmapLabelEl = null; + // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 + // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), + // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, + // 分类配色实际上从未生效过。 var smTypeColors = { person: 0x4488ff, task: 0xff8844, ai: 0xaa44ff, concept: 0x44ff88, object: 0xff4444, + // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") + location: 0xffaa44, + source: 0x8899ff, + document: 0xaabbcc, + event: 0xff88cc, + entity: 0x44ddcc, + scene: 0x88ff44, }; var smEdgeColors = { 喜欢: 0xff6b6b, @@ -4820,6 +4899,228 @@ 创建: 0xff9ff3, }; + // ===== 星图活动:让图跟上 agent 的动作 ===== + // + // 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号: + // + // 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage / + // agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机 + // 取一批)做一次扩散涟漪 + 发光冲高。 + // 2. starmapPullActivity() —— /runtime 调度器快照,3s。 + // ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲); + // interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。 + // 3. starmapPullPulse() —— /memory/graph/pulse,10s。 + // 最近变动的实体(新增概念 / mention_count 变化)做一次 + // 「生长」:从 0 缩放到正常大小,并留下余晖。 + // + // 全部在渲染循环里推进,不额外起定时器。 + var SM_PULSE_MS = 1400; // 单个脉冲的生命期 + var SM_RIPPLE_R = 26; // 涟漪最大半径 + + // starmapPulse 发出一次活动脉冲。 + // kind: "tool" | "stage" | "output" | "grow" + function starmapPulse(kind, hint) { + if (!starmapScene || !starmapNodeMeshes.length) return; + var now = Date.now(); + state.starmapLastPulseAt = now; + // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点, + // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。 + var targets = starmapPickPulseTargets(hint); + if (!targets.length) return; + var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS; + targets.forEach((m) => { + state.starmapPulses.push({ mesh: m, until: now + life, kind: kind }); + }); + // 队列上限:防止密集工具调用时脉冲无限堆积占内存。 + if (state.starmapPulses.length > 260) + state.starmapPulses = state.starmapPulses.slice(-260); + } + + // starmapPickPulseTargets 选出该被点亮的节点。 + // 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点 + // 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。 + // + // ★ 匹配必须用「词」而不是子串包含: + // hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱 + // (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, + // 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse + // 就选中 250 个节点、队列顶到 260 上限。 + function starmapPickPulseTargets(hint) { + var out = []; + if (!starmapNodeMeshes.length) return out; + if (hint) { + var keys = starmapHintTokens(hint); + if (keys.length) { + for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) { + var nd = starmapNodeMeshes[i].userData.nodeData || {}; + var nm = String(nd.name || "").toLowerCase(); + var ty = String(nd.type || "").toLowerCase(); + if (!nm) continue; + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + // 词边界命中:实体名恰好等于该词,或以该词为词首 + // (如 "knowledge_base" 命中词 "knowledge")。 + if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) { + out.push(starmapNodeMeshes[i]); + break; + } + } + } + } + } + // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。 + // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个, + // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 + // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 + // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 + if (out.length > 0) return out; + // 补齐:按 mention_count 降序取靠前且尚未入列的。 + var sorted = starmapNodeMeshes.slice().sort((a, b) => ( + (b.userData.nodeData || {}).mention_count - + (a.userData.nodeData || {}).mention_count + )); + for (var j = 0; j < sorted.length && out.length < 8; j++) { + if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); + } + return out; + } + + // starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。 + // 例:"knowledge_list" → ["knowledge","list"]。 + // 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关 + // 实体名,匹配它们只会制造噪声。 + function starmapHintTokens(hint) { + var raw = String(hint).toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/); + var out = []; + for (var i = 0; i < raw.length; i++) { + var t = raw[i]; + if (t.length >= 3 && out.indexOf(t) === -1) out.push(t); + } + return out; + } + + // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 + function starmapPullActivity() { + if (!starmapScene) return; + api("/runtime") + .then((rt) => { + if (!rt || !rt.scheduler) return; + var s = rt.scheduler; + var prev = state.starmapActivity; + state.starmapActivity = s; + var pend = + (s.ready_queue_depth || 0) + + (s.pending_interrupts || 0) + + (s.suspend_stack || 0); + // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 + if (pend > 0) starmapPulse("stage", null); + // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 + if (prev) { + var dInt = + (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); + var dPre = + (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); + if (dInt > 0 || dPre > 0) starmapPulse("output", null); + } + }) + .catch(() => {}); + } + + // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 + function starmapPullPulse() { + if (!starmapScene) return; + var since = state.starmapPulseSince || 0; + api("/memory/graph/pulse?since=" + since) + .then((r) => { + if (!r || !r.success || !r.data) return; + var now = Math.floor(Date.now() / 1000); + state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点 + var nodes = r.data.nodes || []; + if (!nodes.length) return; + var known = 0; + nodes.forEach((n) => { + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id); + if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量 + known++; + state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2; + starmapPulse("grow", n.name); + }); + // 若有新实体但一个都没匹配上,说明全量图过期了, + // 下一拍重拉全量(新节点才能出现)。 + if (known === 0 && nodes.length > 2) starmapDirty = true; + }) + .catch(() => {}); + } + + // starmapTickActivity 在渲染循环里推进所有脉冲与余晖。 + function starmapTickActivity(t) { + var now = Date.now(); + var breathe = 0; + // 1) 活动脉冲:发光冲高 + 尺寸微扩 + 涟漪环 + if (state.starmapPulses.length) { + var keep = []; + for (var i = 0; i < state.starmapPulses.length; i++) { + var p = state.starmapPulses[i]; + if (p.until <= now) { + p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive; + p.mesh.scale.setScalar(p.mesh.userData.baseScale); + continue; + } + keep.push(p); + var left = (p.until - now) / SM_PULSE_MS; // 1→0 + var k = 1 - left; // 0→1 + var wave = Math.sin(Math.min(1, k) * Math.PI); + var amp = p.kind === "output" ? 1.8 : 1.2; + p.mesh.material.emissiveIntensity = + p.mesh.userData.baseEmissive + wave * amp; + p.mesh.scale.setScalar( + p.mesh.userData.baseScale * (1 + wave * 0.28), + ); + // 生长:新节点从 0 弹到正常大小 + if (p.kind === "grow") { + var g = Math.min(1, k * 1.4); + p.mesh.scale.setScalar( + p.mesh.userData.baseScale * (0.15 + 0.85 * g), + ); + } + } + state.starmapPulses = keep; + } + // 2) 「生长」余晖:脉冲结束后短暂保留一点亮 + if (state.starmapGrown) { + for (var gid in state.starmapGrown) { + if (state.starmapGrown[gid] <= now) { + delete state.starmapGrown[gid]; + continue; + } + var gm = starmapNodeMeshes.find((x) => x.userData.nodeId == gid); + if (gm) + gm.material.emissiveIntensity = Math.max( + gm.material.emissiveIntensity, + gm.userData.baseEmissive + 0.6, + ); + } + } + // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」 + var idle = (now - (state.starmapLastPulseAt || 0)) / 4000; + breathe = Math.max(0, 1 - idle); + if (breathe > 0.01 && starmapNodeMeshes.length) { + // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。 + var stride = 24; + for (var b = 0; b < starmapNodeMeshes.length; b += stride) { + var m2 = starmapNodeMeshes[b]; + if (m2.userData.baseEmissive === undefined) continue; + m2.material.emissiveIntensity = Math.max( + m2.userData.baseEmissive, + m2.userData.baseEmissive + breathe * 0.25, + ); + } + } + return breathe; + } + function createStarField() { var c = 3000; var p = new Float32Array(c * 3), @@ -4872,18 +5173,153 @@ if (hits.length > 0) { var n = hits[0].object; if (starmapHovered !== n) { - if (starmapHovered) starmapHovered.scale.set(1, 1, 1); + // 复位用 baseScale,不能用 set(1,1,1):节点现在是按 mention_count + // 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸。 + // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时 + // 几何体本身就带半径、不靠 scale。) + if (starmapHovered) + starmapHovered.scale.setScalar( + starmapHovered.userData.baseScale || 1, + ); starmapHovered = n; - n.scale.set(1.2, 1.2, 1.2); + starmapLabelShow(n); } } else { if (starmapHovered) { - starmapHovered.scale.set(1, 1, 1); + starmapHovered.scale.setScalar( + starmapHovered.userData.baseScale || 1, + ); starmapHovered = null; + starmapLabelHide(); } } } + // starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。 + // 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存), + // 文字用 DOM 渲染反而更清楚,且零 GPU 开销。 + function starmapLabelShow(mesh) { + var cont = starmapActiveContainer(); + if (!cont) return; + if (!starmapLabelEl) { + starmapLabelEl = document.createElement("div"); + starmapLabelEl.id = "sm-label"; + } + // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。 + // canvas 换页签搬运时,角标也要跟着搬。 + if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl); + var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {}; + var nm = nd.name || nd.id || ""; + starmapLabelEl.textContent = + nm + (nd.mention_count ? " ×" + nd.mention_count : ""); + starmapLabelEl.style.display = "block"; + } + function starmapLabelHide() { + if (starmapLabelEl) starmapLabelEl.style.display = "none"; + } + // starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 + function starmapContainer() { + return ( + document.getElementById("sm-container-home") || + document.getElementById("sm-container-chat") + ); + } + + // starmapActiveContainer 选出「该把星图画在哪」的容器。 + // + // 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页, + // 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个 + // 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到 + // 当前该显示的地方,是单一 renderer 前提下最干净的做法。 + function starmapActiveContainer() { + var tab = document.getElementById("tab-starmap"); + if (tab && tab.classList.contains("active")) { + var c = document.getElementById("sm-container-page"); + if (c) return c; + } + var ov = document.getElementById("tab-overview"); + if (ov && ov.classList.contains("active")) { + var h = document.getElementById("sm-container-home"); + if (h) return h; + } + return ( + document.getElementById("sm-container-chat") || + document.getElementById("sm-container-home") || + null + ); + } + + // renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。 + function renderStarmapTab() { + var host = document.getElementById("tab-starmap"); + if (!host) return; + if (!document.getElementById("sm-container-page")) { + var rt = state.runtime || {}; + var sc = rt.scheduler || {}; + var pend = + (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0); + host.innerHTML = + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + + '
' + + smLegend("person", __("人物", "Person")) + + smLegend("concept", __("概念", "Concept")) + + smLegend("object", __("对象", "Object")) + + smLegend("location", __("地点", "Location")) + + smLegend("source", __("来源", "Source")) + + "
" + + '
' + + __( + "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", + "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.", + ) + + "
"; + } + // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。 + if (document.getElementById("sm-container-page")) { + if (!state.starmapInit && !state.starmapLoading) + loadChatStarmapData(); + else if (starmapRen) { + var cont = document.getElementById("sm-container-page"); + if (starmapRen.domElement.parentElement !== cont) { + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } + onStarmapResize(); + } + } + smUpdateStat(); + } + + // smLegend 生成图例小项。 + function smLegend(key, label) { + var col = smTypeColors[key] || 0xcccccc; + var hex = "#" + ("0000" + col.toString(16)).slice(-6); + return ( + '' + + '' + + escHtml(label) + + "" + ); + } + + // smUpdateStat 在星图页签头部显示节点/边/活动状态。 + function smUpdateStat() { + var el = document.getElementById("sm-page-stat"); + if (!el) return; + var act = state.starmapActivity; + var busy = act && (act.ready_queue_depth || act.pending_interrupts); + var txt = starmapNodes.length + " " + __("节点", "nodes"); + if (starmapEdges.length) txt += " / " + starmapEdges.length + " " + __("关系", "edges"); + if (busy) txt += " · " + __("调度中", "busy"); + el.textContent = txt; + } + + function onStarmapClick(e) { if (!starmapRen || !starmapCam) return; var rect = starmapRen.domElement.getBoundingClientRect(); @@ -4907,9 +5343,7 @@ function flyStarmapTo(nodeId, dur) { if (!starmapAutoView) return; - var m = starmapNodeMeshes.find(function (x) { - return x.userData.nodeId === nodeId; - }); + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId); if (!m) return; var tp = m.position.clone(), sp = starmapCam.position.clone(), @@ -4919,7 +5353,7 @@ var t0 = Date.now(); (function lerp() { var t = Math.min((Date.now() - t0) / dur, 1), - e = 1 - Math.pow(1 - t, 3); + e = 1 - (1 - t) ** 3; starmapCam.position.lerpVectors(sp, ep, e); starmapCtrl.target.lerpVectors(st, tp, e); if (t < 1) requestAnimationFrame(lerp); @@ -4944,6 +5378,8 @@ starmapRaf = requestAnimationFrame(starmapAnimate); if (starmapCtrl) starmapCtrl.update(); if (starmapStarField) starmapStarField.rotation.y += 0.0001; + // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 + if (starmapNodeMeshes.length) starmapTickActivity(); if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam); } @@ -5007,16 +5443,16 @@ var ap = document.createElement("span"); ap.textContent = __("外观", "Appearance"); if (state.selectedSection === "appearance") ap.className = "active"; - ap.onclick = function () { + ap.onclick = () => { state.selectedSection = "appearance"; renderOneSettings(); }; el.appendChild(ap); - state.settingsPlugins.forEach(function (p) { + state.settingsPlugins.forEach((p) => { var a = document.createElement("span"); a.textContent = pluginDisplayName(p); if (p === state.selectedSection) a.className = "active"; - a.onclick = function () { + a.onclick = () => { state.selectedSection = p; renderOneSettings(); }; @@ -5031,7 +5467,7 @@ var theme = document.documentElement.getAttribute("data-theme") || "light"; var color = localStorage.getItem("ha-color") || "mono"; var dots = ""; - Object.keys(PALETTES).forEach(function (k) { + Object.keys(PALETTES).forEach((k) => { dots += ''; @@ -5078,9 +5514,7 @@ } var prefix = state.selectedSection + "."; var allKeys = Object.keys(state.settings || {}); - var filtered = allKeys.filter(function (k) { - return k === prefix.slice(0, -1) || k.startsWith(prefix); - }); + var filtered = allKeys.filter((k) => k === prefix.slice(0, -1) || k.startsWith(prefix)); filtered.sort(); var hideTopLlms = [ "core.llm.base_url", @@ -5090,36 +5524,30 @@ "core.llm.adapter_path", "core.llm.thinking_enabled", ]; - var sourceKeys = filtered.filter(function (k) { - return k.startsWith("core.llm.sources."); - }); + var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources.")); var sourceMap = {}; - sourceKeys.forEach(function (k) { + sourceKeys.forEach((k) => { var parts = k.split("."); var srcName = parts[3]; if (!sourceMap[srcName]) sourceMap[srcName] = {}; sourceMap[srcName][k] = true; }); - var mcpServerKeys = filtered.filter(function (k) { - return ( + var mcpServerKeys = filtered.filter((k) => ( k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5 - ); - }); + )); var mcpServerMap = {}; - mcpServerKeys.forEach(function (k) { + mcpServerKeys.forEach((k) => { var parts = k.split("."); var srvName = parts[3]; if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; mcpServerMap[srvName][k] = true; }); - var regularKeys = filtered.filter(function (k) { - return ( + var regularKeys = filtered.filter((k) => ( !k.startsWith("core.llm.sources.") && hideTopLlms.indexOf(k) === -1 && !k.startsWith("plugin.mcp.servers.") && k !== "plugin.mcp.servers" - ); - }); + )); var html = '
'; if ( @@ -5135,7 +5563,7 @@ __("暂无设置项", "No settings") + "

"; } else { - regularKeys.forEach(function (k) { + regularKeys.forEach((k) => { var v = state.settings[k]; var sv = typeof v === "object" ? JSON.stringify(v) : String(v); var m = state.meta?.[k]; @@ -5161,7 +5589,7 @@ ""; } else if (typ === "select") { var selOpts = ""; - opts.forEach(function (o) { + opts.forEach((o) => { selOpts += '
"; Object.keys(mcpServerMap) .sort() - .forEach(function (srv) { + .forEach((srv) => { var baseKey = "plugin.mcp.servers." + srv; var fields = [ { @@ -5419,7 +5847,7 @@ }, ]; html += '

' + escHtml(srv) + "

"; - fields.forEach(function (f) { + fields.forEach((f) => { var fk = baseKey + "." + f.key; var fv = state.settings?.[fk] || ""; var fieldId = "inp-" + fk.replace(/\./g, "_"); @@ -5561,17 +5989,15 @@ adapter_path: "", thinking_enabled: "false", }; - var promises = keys.map(function (f) { - return api("/settings", { + var promises = keys.map((f) => api("/settings", { method: "PUT", body: JSON.stringify({ key: "core.llm.sources." + name + "." + f, value: values[f], }), - }); - }); + })); Promise.all(promises) - .then(function () { + .then(() => { toast( __("源", "Source") + ' "' + @@ -5584,7 +6010,7 @@ ); renderAll(); }) - .catch(function (e) { + .catch((e) => { toast(__("创建失败: ", "Create failed: ") + e.message, true); }); } @@ -5639,17 +6065,15 @@ } var fields = ["command", "url", "args", "env"]; var values = { command: "", url: "", args: "[]", env: "[]" }; - var promises = fields.map(function (f) { - return api("/settings", { + var promises = fields.map((f) => api("/settings", { method: "PUT", body: JSON.stringify({ key: "plugin.mcp.servers." + name + "." + f, value: values[f], }), - }); - }); + })); Promise.all(promises) - .then(function () { + .then(() => { toast( "MCP " + __("服务器", "server") + @@ -5660,7 +6084,7 @@ ); renderAll(); }) - .catch(function (e) { + .catch((e) => { toast(__("创建失败: ", "Create failed: ") + e.message, true); }); } @@ -5727,7 +6151,7 @@ "" + __("操作", "Actions") + ""; - adapters.forEach(function (a) { + adapters.forEach((a) => { html += "" + escHtml(a.name) + @@ -5758,7 +6182,7 @@ "" + '' + + '\nreturn {\n name = "openai",\n version = "1.0",\n transform_request = function(raw) ... end,\n transform_response = function(raw) ... end,\n}">' + '
"; @@ -5851,7 +6275,7 @@ renderConfigDisabled(); // 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 - (async function () { + (async () => { await loadChatHistory(); renderAll(); connectSSE(); @@ -5864,6 +6288,6 @@ // 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 // 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 // syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 - setInterval(function () { - syncChatFromHistory().catch(function(){}); + setInterval(() => { + syncChatFromHistory().catch(()=> {}); }, 30000); diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index f4f4a60..de412ca 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -479,6 +479,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory)) mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory)) mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph)) + // 星图活动端点:只回最近变动的实体(几百字节 ~ 几 KB), + // 而不是每 10s 重拉 400KB 全量图谱。见 handleMemoryGraphPulse。 + mux.HandleFunc("/api/v1/memory/graph/pulse", h.requireAPI(h.handleMemoryGraphPulse)) mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext)) mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools)) mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory)) diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go new file mode 100644 index 0000000..cfa4b3e --- /dev/null +++ b/internal/plugins/webui/handler_graph_pulse_test.go @@ -0,0 +1,163 @@ +package webui + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// fakeGraphMemory 是一个最小 MemoryAPI 桩,只为把 GraphData 喂给 handler。 +type fakeGraphMemory struct { + data map[string]interface{} +} + +func (f *fakeGraphMemory) Recall([]string, int) ([]pubsdk.Entity, []pubsdk.Relation, error) { + return nil, nil, nil +} +func (f *fakeGraphMemory) Commit([]pubsdk.Triple) error { return nil } +func (f *fakeGraphMemory) Introspect() (map[string]interface{}, error) { + return nil, nil +} +func (f *fakeGraphMemory) MergeEntities(string, string) (int, error) { return 0, nil } +func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) { + return 0, nil +} +func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil } + +// graphFixture 造一份与生产实例同构的图谱快照: +// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。 +func graphFixture() map[string]interface{} { + now := time.Now() + old := now.Add(-72 * time.Hour) + return map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": 1, "name": "小宅", "type": "Concept", "mention_count": 228, + "created_at": old, "updated_at": now.Add(-8 * time.Hour)}, + {"id": 2, "name": "刚刚学到的东西", "type": "Concept", "mention_count": 1, + "created_at": now, "updated_at": now}, + }, + "edges": []map[string]interface{}{}, + "memory_blocks": []memory.MemoryBlock{ + {ID: "b1", Modality: memory.BlockModality("text"), Text: "hi", + PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now}, + }, + } +} + +func newGraphHandler(m *fakeGraphMemory) *Handler { + return &Handler{memory: m} +} + +// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。 +// +// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图 +// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。 +func TestHandleMemoryGraph_StripsVector(t *testing.T) { + h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + rr := httptest.NewRecorder() + h.handleMemoryGraph(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) { + t.Fatalf("response still contains vector field:\n%s", rr.Body.String()) + } + // 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。 + for _, want := range []string{`"payload_digest":"d1"`, `"id":"b1"`, `"nodes"`, `"edges"`} { + if !bytes.Contains(rr.Body.Bytes(), []byte(want)) { + t.Fatalf("response missing %s:\n%s", want, rr.Body.String()) + } + } +} + +// TestHandleMemoryGraphPulse_OnlyRecent 钉住 pulse 端点只回窗口内变动的节点。 +// +// 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。 +func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) { + h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil) + h.handleMemoryGraphPulse(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp struct { + Success bool `json:"success"` + Data struct { + Nodes []struct { + ID int64 `json:"id"` + Name string `json:"name"` + MentionCount int `json:"mention_count"` + } `json:"nodes"` + } `json:"data"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Success { + t.Fatalf("success = false") + } + // 默认窗口 900s:8h 前的「小宅」不该在里面,刚更新的应该在。 + if len(resp.Data.Nodes) != 1 { + t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes) + } + if resp.Data.Nodes[0].ID != 2 { + t.Fatalf("expected node id=2, got %d", resp.Data.Nodes[0].ID) + } +} + +// TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。 +func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) { + h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil) + h.handleMemoryGraphPulse(rr, req) + var resp struct { + Data struct { + Nodes []struct { + ID int64 `json:"id"` + } `json:"nodes"` + } `json:"data"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data.Nodes) != 2 { + t.Fatalf("since=86400 should include both nodes, got %d", len(resp.Data.Nodes)) + } +} + +// TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。 +func TestHandleMemoryGraphPulse_NoVector(t *testing.T) { + h := newGraphHandler(&fakeGraphMemory{data: graphFixture()}) + rr := httptest.NewRecorder() + h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)) + if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) { + t.Fatalf("pulse response contains vector:\n%s", rr.Body.String()) + } +} + +// TestGraphDataForVisual_PassthroughWhenTypeMismatch 钉住断言失败时不吞数据。 +// +// graphDataForVisual 用类型断言识别 memory_blocks。若 GraphData 换了 +// 返回类型(比如改成 []*MemoryBlock),断言不中时必须原样透传而不是 +// 把字段弄丢 —— 宁可多发 vector,也不能让整个图谱接口变空。 +func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) { + in := map[string]interface{}{ + "nodes": []map[string]interface{}{{"id": 1}}, + "memory_blocks": []*memory.MemoryBlock{{ID: "x"}}, + } + out := graphDataForVisual(in) + if out["memory_blocks"] == nil { + t.Fatalf("memory_blocks dropped on type mismatch") + } + if _, ok := out["nodes"]; !ok { + t.Fatalf("nodes dropped") + } +} diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index b96ad33..2eea229 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -9,9 +9,11 @@ import ( "path/filepath" "strconv" "strings" + "time" "unicode/utf8" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) @@ -109,6 +111,77 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) { }) } +// graphBlockView 是 memory_blocks 的**瘦身**下发视图。 +// +// 为什么要瘦身(实测生产实例 1151 节点 / 866 边): +// +// 原始 /memory/graph 响应 408,146 B,其中 memory_blocks[].vector 占 79,314 B +// (19%)。那是稠密向量 —— 检索侧(SearchIn / 稠密召回)才需要它, +// 而星图是本接口**唯一**消费者,它只画节点/连线,压根不读 vector。 +// +// 更大的问题是量级:每多一块记忆就多一份向量。8 块已经 79KB, +// 200 块就是约 2MB 白白从库里查出来、序列化、走 socket、丢进浏览器堆, +// 全程没有一行代码看过它。文本向量的维度还随模型走(数百到数千), +// 换一次 embedder 就能让这个开销翻几倍。 +// +// 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关: +// 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。 +type graphBlockView struct { + ID string `json:"id"` + Modality string `json:"modality"` + Text string `json:"text,omitempty"` + PayloadDigest string `json:"payload_digest"` + MIME string `json:"mime,omitempty"` + Size int64 `json:"size"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Source string `json:"source,omitempty"` + Tool string `json:"tool,omitempty"` + Scene string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。 +// +// 用 map 断言而不是泛型/反射:GraphData 返回 map[string]interface{}, +// 里面的具体类型是包内私有的 graphEntity/[]*memory.MemoryBlock, +// 断言不中就原样透传(宁可多发也不让接口挂掉)。 +func graphDataForVisual(data map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(data)) + for k, v := range data { + out[k] = v + } + blocks, _ := out["memory_blocks"].([]memory.MemoryBlock) + if blocks == nil { + // 可能是 []*memory.MemoryBlock 或空;两种都不是就直接跳过裁剪。 + return out + } + views := make([]graphBlockView, 0, len(blocks)) + for i := range blocks { + b := blocks[i] + views = append(views, graphBlockView{ + ID: b.ID, + Modality: string(b.Modality), + Text: b.Text, + PayloadDigest: b.PayloadDigest, + MIME: b.MIME, + Size: b.Size, + Width: b.Width, + Height: b.Height, + Fingerprint: b.Fingerprint, + Source: b.Source, + Tool: b.Tool, + Scene: b.Scene, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + }) + } + out["memory_blocks"] = views + return out +} + func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -123,9 +196,79 @@ func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data}) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": graphDataForVisual(data)}) } +// handleMemoryGraphPulse 是给星图「跟随 agent 动」用的**轻量**活动端点。 +// +// 为什么不让星图反复拉完整 /memory/graph 做对比: +// +// 完整图谱生产实例 408KB(瘦身前 408KB→瘦身后约 329KB,仍含 1151 个节点 +// 和 866 条边的全量 JSON)。为了「知道哪些节点是新的」而每 N 秒拉一次全量, +// 是把带宽和 JSON.parse 全花在重复数据上。 +// +// 这里只回「最近 since 秒内变动过的实体」,字段压到最小(id + name + +// mention_count + updated_at),实测是几百字节到几 KB 的量级 —— +// 与完整图谱差两个数量级。新节点「生长」出来、老节点被再次提及而计数变化, +// 都能从这份清单里看出来。 +// +// since 缺省给 900s(15 分钟):略大于星图轮询周期(10s), +// 即使客户端漏掉几个周期也能自愈,不必担心漏掉节点。 +func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.memory == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"}) + return + } + since := time.Now().Add(-defaultGraphPulseWindow) + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + if sec, err := strconv.Atoi(raw); err == nil && sec > 0 { + since = time.Now().Add(-time.Duration(sec) * time.Second) + } + } + data, err := h.memory.GraphData() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + out := graphDataForVisual(data) + // 过滤出窗口内变动过的节点。GraphData 的 nodes 是 []graphEntity(私有类型), + // 这里用重新序列化的方式裁剪:字段少、无向量、且不依赖私有类型断言。 + // 成本是「再序列化一次节点」,但相比把 400KB 发出去仍然划算得多。 + rawNodes, _ := json.Marshal(out["nodes"]) + var nodes []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + MentionCount int `json:"mention_count"` + UpdatedAt time.Time `json:"updated_at"` + } + _ = json.Unmarshal(rawNodes, &nodes) + pulse := make([]map[string]interface{}, 0, 8) + for _, n := range nodes { + if n.UpdatedAt.Before(since) { + continue + } + pulse = append(pulse, map[string]interface{}{ + "id": n.ID, + "name": n.Name, + "type": n.Type, + "mention_count": n.MentionCount, + "updated_at": n.UpdatedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "data": map[string]interface{}{"nodes": pulse}, + }) +} + +// defaultGraphPulseWindow 是 /memory/graph/pulse 不带 since 时的回看窗口。 +const defaultGraphPulseWindow = 900 * time.Second + // knowledgeWriteReq 是知识写入请求体(JSON 分支)。 type knowledgeWriteReq struct { Name string `json:"name"` From ae87f4b8b2026a7497785fb0ed0b11070929d606 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 00:02:28 +0800 Subject: [PATCH 10/13] =?UTF-8?q?perf(webui):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=20gzip=20=E2=80=94=E2=80=94=20=E9=A6=96=E5=B1=8F=20wi?= =?UTF-8?q?re=20=E5=AD=97=E8=8A=82=20-70%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测:首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding (直连 127.0.0.1:8080 与经 nginx 的公网入口两条路径都验过:响应头里没有 该字段,wire 尺寸 == 原始尺寸)。同一份数据 gzip 后: /api/v1/kernel 152,667 → 37,697 (-75%) /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%) 这些是高度重复的 JSON(同批 key 名反复出现、中文实体名、时间戳), 压缩比自然地高。真实实例上实测首屏 wire 字节 77,943 → 23,339(-70%)。 位置:链改为 proxyDispatch → gzip → logged → mux。夹在 proxyDispatch 与 logged 之间,是因为 proxyDispatch 命中时直接 return、响应来自上游 (其 Content-Encoding 由 httputil 处理),我们不插手;门户自身的全部 响应(requireAPI 的 401/503、requireWeb 的 302、HTML/CSS/JS、全部 JSON API)都压。 ### 三个必须显式处理的坑 1. **SSE 不能压。** text/event-stream 进 gzip 缓冲后 flush 语义就废了 (前端收不到流式,要等缓冲攒够)。对 SSE 请求直接透传。 2. **必须透传 http.Flusher。** handleChatEvents / streamOpenAI 里是 `w.(http.Flusher)` 类型断言;包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil ⇒ 走降级分支 ⇒ SSE **静默**坏掉(不报错,只是收不到 流式)。这不是「顺手加一下」能过的改动,有专门的判据守着。 3. **204/304/HEAD 没有 body**,压它们只浪费 CPU 并加坏头。 另外 webp/png/zip/gzip 等已压缩类型也跳过(mascot.webp 133KB 就在内)。 ### 小于 1KB 的响应不压 gzip 头 23 字节,几百字节的 JSON 压完反而更大。与 nginx 的 gzip_min_length 1000 对齐。实测 /api/v1/status(197B)不带 Content-Encoding。 ### 状态机写成枚举而非多个 bool 第一版用 passthrough/decided/buffering/allowBuf 四个 bool 交叉表示, 结果出两个 bug:小响应内容被写成空、已压缩类型仍被压。根因是 「该不该压」在 Write / WriteHeader / 收尾三处各判一次且判据不一致。 改成单一 mode 枚举(undecided/passThrough/buffering/streaming)、 判据只在 WriteHeader 与 Write 各求值一次后,两个 bug 同时消失。 ### ★ 一条判据我自己写错了,值得记下来 TestGzipDropsContentLength 初版断言「压缩响应不应带 Content-Length」, 实测失败。追查后证明**判据错了、代码是对的**: Go 在 Del("Content-Length") 之后,若响应体小到能被一次性缓冲(<2048B), net/http 会**自动重算**并补上压缩后的真实长度(实测 14000B → 119B → 响应头 Content-Length: 119,正确)。真正要防的是**陈旧长度**:留着 14000 而实发 119 时,客户端按 Content-Length 读满会先拿到 119 字节再吃 unexpected EOF(已用对照探针实测复现)。判据改成两条:①声明长度 == 实际读到字节数 ②该值 == 压缩后长度而非压缩前长度。另加一条对照判据 TestGzipStaleContentLengthWouldBreak,把危害钉成可执行断言。 ### 验证(不是「应该能跑」) - go vet 干净;全量测试通过;新增 12 条 gzip 判据;覆盖率 66.5% → 67.0% - 真实实例(独立数据目录 + 18081 端口)实测: · SSE:无 Content-Encoding,2 次独立 TCP 读(逐帧下发,未被缓冲) · /api/v1/status(197B):不带 Content-Encoding · /api/v1/kernel -69%、/api/v1/settings -73%、/api/v1/plugins -63% · 首屏 wire 字节 77,943 → 23,339(-70%) · 内容完整性:gzip 解压后与明文逐字段相等(plugins/tools/build/ channels 名称集合与顺序均一致) - ★ 途中被一个「MISMATCH」误导过一轮:/api/v1/kernel 两次请求字节不同。 追查发现是 IOManager.ListChannels 遍历 **map**(Go 每次迭代随机化), **在本次改动之前就已不确定**,与 gzip 无关。差点被我误报成压缩 bug。 附:dashboard.js 被自动格式化器整体重排(6783 增 / 6293 删,纯空白与 引号风格)。已用 prettier 归一化后逐字节比对确认**零语义差异**。 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/plugins/webui/dashboard.js | 13076 +++++++++++++------------- internal/plugins/webui/gzip.go | 301 + internal/plugins/webui/gzip_test.go | 449 + internal/plugins/webui/handler.go | 7 +- 4 files changed, 7538 insertions(+), 6295 deletions(-) create mode 100644 internal/plugins/webui/gzip.go create mode 100644 internal/plugins/webui/gzip_test.go diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index b056dca..a58dc02 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1,6293 +1,6783 @@ - // ===== State ===== - const state = { - status: {}, - kernel: null, - settings: {}, - meta: {}, - runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道) - pluginMeta: {}, - disabledPlugins: [], - settingsPlugins: ["core"], - selectedSection: "core", - messages: [], - chatLoading: false, - chatStage: "", - pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页) - pipelineTimer: null, - stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环) - toolFlash: false, // 本轮新到一条工具调用:本轮渲染后给「工具」格放一次滑入动画 - healthResult: null, - starmapInit: false, - starmapLoading: false, - starmapData: null, - // ===== 星图:跟随 agent 活动 ===== - // 三路活动信号(均已在页面上,无需新数据源): - // 1. toolPulseMs —— SSE tool_call/stage 触发的「脉冲」,实时 - // 2. activity —— /runtime 调度器计数(排队/中断/抢占),3s 轮询 - // 3. grownIds —— /memory/graph/pulse 检出「新长出来」的节点,10s 轮询 - starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 - starmapGrown: {}, // nodeId -> 生长动画截止时间戳 - starmapActivity: null, // /runtime 的调度器快照 - starmapPulseTimer: null, // /memory/graph/pulse 轮询 id - starmapActivityTimer: null, // /runtime 轮询 id - starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 - starmapPulseSince: 0, // 下次 pulse 请求的回看起点(unix 秒) - chatHistory: [], - terminals: [], - cmdHistory: [], - termScreens: {}, - chatStick: true, - pendingTools: [], - eventSource: null, - chatFinalIdx: -1, - chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 - chatTotal: 0, // 服务端历史总条数 - chatHasMore: false, // 是否还有更早历史可向上加载 - chatLastSeq: 0, // 增量轮询游标:已合并到本地的最大消息 seq - lang: localStorage.getItem("ha-lang") || "zh", - }; - - // ===== I18n ===== - window._i18n = { - navOverview: ["概览", "Overview"], - navChat: ["对话", "Chat"], - navStarmap: ["星图", "Star Map"], - navPlugins: ["插件", "Plugins"], - navSettings: ["设置", "Settings"], - navAdapters: ["适配器", "Adapters"], - navKernel: ["内核", "Kernel"], - navLogout: ["退出登录", "Logout"], - themeToggle: ["切换亮色/暗色模式", "Toggle theme"], - }; - - function __(zh, en) { - return state.lang === "en" ? en : zh; - } - function L() { - return state.lang; - } - - function toggleLang() { - state.lang = state.lang === "zh" ? "en" : "zh"; - localStorage.setItem("ha-lang", state.lang); - document.querySelectorAll("[data-i18n]").forEach((el) => { - var k = el.getAttribute("data-i18n"); - var m = window._i18n && window._i18n[k]; - if (m) el.textContent = __(m[0], m[1]); - }); - renderAll(); - } - - function applyI18n() { - var lang = state.lang; - var btn = document.getElementById("lang-btn"); - if (btn) btn.textContent = lang === "zh" ? "EN" : "中"; - document.querySelectorAll("[data-i18n]").forEach((el) => { - var k = el.getAttribute("data-i18n"); - var m = window._i18n && window._i18n[k]; - if (m) el.textContent = lang === "en" ? m[1] : m[0]; - }); - } - - // ===== Theme ===== - var ICON_SUN = - ''; - var ICON_MOON = - ''; - - function setTheme(name) { - document.documentElement.setAttribute("data-theme", name); - var btn = document.getElementById("theme-btn"); - if (btn) btn.innerHTML = name === "light" ? ICON_SUN : ICON_MOON; - } - - function saveTheme(name) { - localStorage.setItem("ha-theme", name); - setTheme(name); - } - - function toggleTheme() { - var cur = document.documentElement.getAttribute("data-theme"); - saveTheme(cur === "light" ? "dark" : "light"); - } - - var PALETTES = { - // mono 放第一位:它现在是默认配色(黑白单调),也应当是选择器里最显眼那个。 - mono: "#c2c7d0", - sakura: "#ff7fac", - cyan: "#2dd4bf", - violet: "#a78bfa", - emerald: "#34d399", - amber: "#fbbf24", - blue: "#60a5fa", - }; - - function setColor(name) { - document.documentElement.setAttribute("data-color", name); - localStorage.setItem("ha-color", name); - // 配色变了要强制重画运行态:拓扑的归属配色跟着 data-color 走, - // 而数据签名没变,不置空 _rtSig 的话图会一直停在旧色。 - _rtSig = null; - if (document.getElementById("rt-sec-topo")) { - try { - renderRuntime(); - } catch (e) {} - } - var pop = document.getElementById("palette-pop"); - if (!pop) return; - var btns = pop.querySelectorAll("button.cdot"); - for (var i = 0; i < btns.length; i++) { - btns[i].className = - btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot"; - } - } - - function applyBgImg(url) { - url = (url || "").trim(); - if (!url) { - document.documentElement.style.setProperty("--bg-img", "none"); - localStorage.removeItem("ha-bg-img"); - return; - } - document.documentElement.style.setProperty( - "--bg-img", - 'url("' + url.replace(/"/g, '\\"') + '")', - ); - localStorage.setItem("ha-bg-img", url); - } - - function applyBgBlur(n) { - n = Math.max(0, Math.min(30, Number(n) || 0)); - document.documentElement.style.setProperty("--bg-blur", String(n)); - localStorage.setItem("ha-bg-blur", String(n)); - var v = document.getElementById("bg-blur-val"); - if (v) v.textContent = n + "px"; - var r = document.getElementById("bg-blur-range"); - if (r) r.value = String(n); - } - - function toggleAppearance() { - var pop = document.getElementById("palette-pop"); - if (!pop) return; - var on = pop.classList.contains("on"); - if (!pop.querySelector("button.cdot")) { - var cur = localStorage.getItem("ha-color") || "mono"; - var img = localStorage.getItem("ha-bg-img") || ""; - var blur = localStorage.getItem("ha-bg-blur") || "0"; - var dots = ""; - Object.keys(PALETTES).forEach((k) => { - dots += - '"; - }); - pop.innerHTML = - "

主题色

" + - dots + - "
" + - '

背景图片 URL

' + - '' + - '
' + - '' + - '模糊 ' + - '' + - '' + - blur + - "px
"; - setColor(cur); - var rr = document.getElementById("bg-blur-range"); - if (rr) rr.value = blur; - var vv = document.getElementById("bg-blur-val"); - if (vv) vv.textContent = blur + "px"; - } - pop.classList.toggle("on", !on); - } - - (function initAppearance() { - var img = localStorage.getItem("ha-bg-img"); - var blur = localStorage.getItem("ha-bg-blur"); - if (img) applyBgImg(img); - if (blur) applyBgBlur(blur); - })(); - - (() => { - var saved = localStorage.getItem("ha-theme"); - setTheme(saved || "light"); - // 默认配色 = 黑白:必须**主动** setColor 一次。只设 data-theme 时 - // base 主题仍是樱粉(:root 的 --accent 就是粉色),不写 data-color 就 - // 会默认花。用户选过别的颜色时以 localStorage 为准。 - setColor(localStorage.getItem("ha-color") || "mono"); - var sb = localStorage.getItem("ha-sidebar"); - if (sb === "1" || (!sb && window.innerWidth <= 768)) { - document.body.classList.add("sidebar-collapsed"); - } - })(); - - // 字节数人性化显示(附件卡片用) - function formatBytes(n) { - if (!n || n <= 0) return ""; - var units = ["B", "KB", "MB", "GB"]; - var i = 0; - while (n >= 1024 && i < units.length - 1) { - n /= 1024; - i++; - } - return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; - } - - // ===== Utility ===== - function renderMd(text) { - if (typeof text !== "string") text = String(text || ""); - var html; - if (typeof marked !== "undefined") { - try { - html = marked.parse(text); - } catch (e) { - html = escHtml(text); - } - } else { - html = "
" + escHtml(text) + "
"; - } - if ( - typeof DOMPurify !== "undefined" && - typeof DOMPurify.sanitize === "function" - ) { - try { - return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); - } catch (e) {} - } - return html - .replace(//gi, "") - .replace(/\son\w+\s*=\s*"[^"]*"/gi, "") - .replace(/\son\w+\s*=\s*'[^']*'/gi, "") - .replace(/javascript:/gi, ""); - } - function escHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function toast(m, isError) { - var t = document.getElementById("toast"); - t.textContent = m; - t.className = "toast" + (isError ? " error" : ""); - t.style.display = "block"; - clearTimeout(t._hideTimer); - t._hideTimer = setTimeout(() => { - t.style.display = "none"; - }, 3000); - } - - // ===== 8.6 Unified toast + confirm dialog ===== - - // ===== 8.4 Card 3D tilt + cursor glow ===== - document.addEventListener("mousemove", (e) => { - var card = e.target.closest ? e.target.closest(".card.tilt") : null; - if (card) { - var r = card.getBoundingClientRect(); - card.style.setProperty("--mx", e.clientX - r.left + "px"); - card.style.setProperty("--my", e.clientY - r.top + "px"); - var rx = ((e.clientY - r.top) / r.height - 0.5) * -4; - var ry = ((e.clientX - r.left) / r.width - 0.5) * 4; - card.style.transform = - "perspective(1000px) rotateX(" + - rx.toFixed(2) + - "deg) rotateY(" + - ry.toFixed(2) + - "deg) translateY(-1px)"; - } - }); - document.addEventListener("mouseleave", (e) => { - var card = e.target.closest ? e.target.closest(".card.tilt") : null; - if (card) card.style.transform = ""; - }); - - // ===== 首启人格向导 ===== - // 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 - // 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; - // 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 - async function maybeShowPersonaWizard() { - var st; - try { - st = await api("/persona"); - } catch (e) { - return; // 拿不到状态就不打扰用户 - } - if (!st || st.initialized) return; - var ov = document.createElement("div"); - ov.className = "confirm-overlay"; - ov.style.display = "flex"; - ov.innerHTML = - '
' + - "

" + escHtml(__("人格设定", "Persona")) + "

" + - "

" + escHtml(__( - "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", - "First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart." - )) + "

" + - '
' + - '' + - '
' + - '" + - '" + - '" + - "
"; - document.body.appendChild(ov); - var ta = ov.querySelector(".persona-ta"); - var warn = ov.querySelector(".persona-warn"); - var customOpen = false; - if (st.file_override) { - warn.style.display = "block"; - warn.textContent = __( - "注意:检测到 personal/personal.md,它优先于这里的设置。", - "Note: personal/personal.md exists and takes precedence over this choice." - ); - } - function close() { - ov.remove(); - } - async function submit(mode, content) { - try { - var r = await api("/persona", { - method: "POST", - body: JSON.stringify({ mode: mode, content: content || "" }), - }); - if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart")); - else toast(__("已保存", "Saved")); - } catch (e) { - toast(__("保存失败:", "Save failed: ") + e, true); - } - close(); - } - ov.querySelectorAll("button[data-mode]").forEach((b) => { - b.onclick = () => { - var mode = b.getAttribute("data-mode"); - if (mode !== "custom") { - submit(mode); - return; - } - if (!customOpen) { // 第一次点:展开文本域并预填当前人格 - customOpen = true; - ta.style.display = "block"; - ta.value = st.current_prompt || ""; - ta.focus(); - return; - } - if (!ta.value.trim()) { - toast(__("内容不能为空", "Content cannot be empty"), true); - return; - } - submit("custom", ta.value); - }; - }); - } - - // ===== API ===== - async function api(p, o) { - var opts = { - credentials: "include", - headers: { "Content-Type": "application/json", ...o?.headers }, - ...o, - }; - // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) - if (o?.body instanceof FormData) { - delete opts.headers["Content-Type"]; - } - var r = await fetch("/api/v1" + p, opts); - if (r.status === 401) { - location.href = "/login"; - throw new Error("unauthorized"); - } - if (opts.raw) return r; - var ct = r.headers.get("content-type") || ""; - if (ct.includes("json")) return r.json(); - return r.text(); - } - - // ===== Navigation ===== - function switchTab(n) { - document.querySelectorAll(".tab-content").forEach((e) => { - e.classList.remove("active"); - }); - var el = document.getElementById("tab-" + n); - if (el) el.classList.add("active"); - document.querySelectorAll("nav a").forEach((e) => { - e.classList.remove("active"); - }); - var match = document.querySelector('nav a[onclick*="' + n + '"]'); - if (match) match.classList.add("active"); - var crumb = document.querySelector(".crumb-current"); - if (crumb) { - var crumbKey = "nav" + n.charAt(0).toUpperCase() + n.slice(1); - var m = window._i18n && window._i18n[crumbKey]; - crumb.setAttribute("data-i18n", crumbKey); - if (m) crumb.textContent = __(m[0], m[1]); - else crumb.textContent = n; - } - if (window.innerWidth <= 768) { - document.body.classList.add("sidebar-collapsed"); - } - if (n === "chat") { - state.chatStick = true; - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - initChatDragDrop(); - } - if (n === "starmap") { - // 独立星图页签:把 canvas 搬过来并按新容器尺寸重算。 - // 必须在 renderAll 之前做,否则 renderChatStarmap 会按上一次的 - // 活跃容器(总览页)算尺寸。 - var sc = document.getElementById("sm-container-page"); - if (sc) { - if (starmapRen) { - if (starmapRen.domElement.parentElement !== sc) { - sc.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } - onStarmapResize(); - } else if (!state.starmapInit && !state.starmapLoading) { - loadChatStarmapData(); - } - } - } - renderAll(); - } - - function toggleSidebar() { - document.body.classList.toggle("sidebar-collapsed"); - var saved = localStorage.getItem("ha-sidebar"); - localStorage.setItem( - "ha-sidebar", - document.body.classList.contains("sidebar-collapsed") ? "1" : "0", - ); - } - - // ===== Tab Render Dispatch ===== - async function renderAll() { - try { - var s = await api("/status"); - state.status = s; - state.startedAt = s.startedAt - ? new Date(s.startedAt).getTime() - : null; - } catch (e) {} - try { - state.kernel = await api("/kernel"); - } catch (e) {} - try { - var s = await api("/settings"); - state.settings = s.settings || {}; - state.meta = s.meta || {}; - state.settingsPlugins = s.plugins || ["core"]; - state.pluginMeta = s.plugin_meta || {}; - state.disabledPlugins = s.disabled_plugins || []; - } catch (e) {} - try { - state.installedPlugins = await api("/plugins"); - } catch (e) {} - try { - await loadProxyServices(); - } catch (e) {} - try { - await loadTerminals(); - } catch (e) {} - try { - await loadCmdHistory(); - } catch (e) {} - try { - renderOverview(); - } catch (e) { - console.error("renderOverview", e); - } - try { - await loadRuntime(); - } catch (e) { - console.error("loadRuntime", e); - } - try { - renderChat(); - } catch (e) { - console.error("renderChat", e); - } - try { - renderChatStarmap(); - } catch (e) { - console.error("renderChatStarmap", e); - } - try { - renderStarmapTab(); - } catch (e) { - console.error("renderStarmapTab", e); - } - try { - renderPlugins(); - } catch (e) { - console.error("renderPlugins", e); - } - try { - renderKernel(); - } catch (e) { - console.error("renderKernel", e); - } - try { - renderOneSettings(); - } catch (e) { - console.error("renderOneSettings", e); - } - try { - renderAdapters(); - } catch (e) { - console.error("renderAdapters", e); - } - try { - if (!window.matchMedia("(hover: none)").matches) { - document - .querySelectorAll( - "#tab-overview .card, #tab-plugins .card, #tab-kernel .card", - ) - .forEach((c) => { - if (!c.querySelector(".tilt-glow")) { - var g = document.createElement("span"); - g.className = "tilt-glow"; - c.appendChild(g); - c.classList.add("tilt"); - } - }); - } - } catch (e) {} - applyI18n(); - } - - // fmtBuild 把构建身份压成一行:commit + 构建时间(日期部分)。 - // - // 未注入 ldflags 的本地构建会得到 "unknown",此时只显示 SDK 版本, - // 避免把 "unknown / unknown" 这种噪音摆在概览卡上。 - function fmtBuild(b, s) { - var parts = []; - var commit = (b && b.commit) || (s && s.commit) || ""; - if (commit && commit !== "unknown") parts.push(commit); - var bt = (b && b.build_time) || (s && s.build_time) || ""; - if (bt && bt !== "unknown") { - // 2026-09-03T05:55:35Z → 2026-09-03 - parts.push(String(bt).split("T")[0]); - } - var sdkv = (b && b.sdk_compatible) || (s && s.sdk_version) || ""; - if (sdkv) parts.push("SDK " + sdkv); - return parts.length ? parts.join(" · ") : "-"; - } - - function fmtUptime(ms) { - var s = Math.floor(ms / 1000); - if (s < 60) return s + "s"; - var m = Math.floor(s / 60); - s = s % 60; - if (m < 60) return m + "m " + s + "s"; - var h = Math.floor(m / 60); - m = m % 60; - return h + "h " + m + "m " + s + "s"; - } - - var uptimeTick = null; - function startUptimeTicker() { - if (uptimeTick) clearInterval(uptimeTick); - uptimeTick = setInterval(() => { - var el = document.querySelector("#ov-uptime"); - if (el && state.startedAt) { - var now = Date.now(); - el.textContent = fmtUptime(now - state.startedAt); - } else if (!state.startedAt) { - var el2 = document.querySelector("#ov-uptime"); - if (el2) el2.textContent = "-"; - } - }, 1000); - } - - // ===== Overview ===== - - // ===== 运行态面板:把内核的调度器/驻留子/通道画出来 ===== - // - // 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), - // 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 - // - // 面板从上到下:四个数字块回答"现在忙不忙";阶段管道滑块回答"这一轮走到哪"; - // 五条队列条形 + 中断栈回答"堵在哪一级/压了几层";最后的分带拓扑回答 - // "哪条输入喂给哪个 agent、哪个 agent 往哪条输出写、各自负载多高"。 - // 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), - // 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 - var RT_LEVELS = [ - { lv: 4, name: "L4", desc: "内核独占", cls: "rt-lv-4" }, - { lv: 3, name: "L3", desc: "交互", cls: "rt-lv-3" }, - { lv: 2, name: "L2", desc: "消息", cls: "rt-lv-2" }, - { lv: 1, name: "L1", desc: "后台", cls: "rt-lv-1" }, - ]; - var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" }; - - // _rtSig 缓存上一次渲染的数据签名。 - // - // 为什么必须缓存:运行态每 3s 轮询一次,数据绝大多数时候是**没变**的; - // 无条件 `innerHTML =` 会把整块 DOM(含各级条的 transition)每 3s 重建一遍, - // 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。 - var _rtSig = null; - - // morph:把容器的 DOM 就地「形变」成 html 描述的样子。 - // - // 为什么不用 innerHTML = html:整块重建会把**没变的**节点也换掉 —— - // SVG 过渡从头播、图片重解码、展开态丢失、滚动锚点重置。运行态每几秒 - // 刷一次,未变节点占绝大多数,那些闪烁就是这么来的。 - // - // 做法:按「子节点位置 + nodeName」递归对账。同名元素复用同一个 DOM 节点, - // 只同步发生变化的属性与文本;只有标签真的不同才替换。元素身份不变 ⇒ - // CSS 过渡继续、不闪。 - function morph(container, html) { - var tpl = document.createElement("div"); - tpl.innerHTML = html; - morphChildren(container, tpl); - } - function morphChildren(oldParent, newParent) { - var olds = Array.prototype.slice.call(oldParent.childNodes); - var news = Array.prototype.slice.call(newParent.childNodes); - var n = Math.max(olds.length, news.length); - for (var i = 0; i < n; i++) { - var o = olds[i], x = news[i]; - if (!o && x) { - oldParent.appendChild(x); - continue; - } - if (o && !x) { - oldParent.removeChild(o); - continue; - } - if (o.nodeType !== x.nodeType || o.nodeName !== x.nodeName) { - oldParent.replaceChild(x, o); - continue; - } - if (o.nodeType === 3 || o.nodeType === 8) { - if (o.nodeValue !== x.nodeValue) o.nodeValue = x.nodeValue; - continue; - } - morphAttrs(o, x); - morphChildren(o, x); - } - } - function morphAttrs(o, x) { - var i, a; - for (i = o.attributes.length - 1; i >= 0; i--) { - a = o.attributes[i]; - if (!x.hasAttribute(a.name)) o.removeAttribute(a.name); - } - for (i = 0; i < x.attributes.length; i++) { - a = x.attributes[i]; - if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value); - } - } - - // rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。 - function rtSlider(value, max, label, display) { - var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; - return ( - '' + - escHtml(label) + - '' + - '' + - escHtml(String(display === undefined ? value : display)) + - "" - ); - } - - // rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。 - function rtMini(value, max, label) { - var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; - return ( - '' + - value + - "" - ); - } - - function rtTile(num, label, sub, pct, active, warn) { - return ( - '
' + - num + - '
' + - label + - "
" + - (sub ? '
' + sub + "
" : "") + - '
' - ); - } - - function rtCapsHtml(caps) { - var out = ""; - Object.keys(RT_CAP_NAMES).forEach((bit) => { - if (caps & Number(bit)) { - out += '' + RT_CAP_NAMES[bit] + ""; - } - }); - return out ? '' + out + "" : ""; - } - - // 归属配色:根用青色,驻留子轮转其余颜色(同一张图里能一眼分清谁是谁)。 - var RT_OWNER_COLORS = ["#88c0d0", "#b48ead", "#a3be8c", "#ebcb8b", "#d08770", "#8fbcbb"]; - // 黑白配色下,拓扑归属也用灰阶 —— 否则整页只剩这张图还是一片彩。 - var RT_OWNER_COLORS_MONO = ["#e6e9ef", "#c2c7d0", "#9aa0ad", "#7b818d", "#5f6570", "#454a55"]; - function rtOwnerColors() { - return document.documentElement.getAttribute("data-color") === "mono" - ? RT_OWNER_COLORS_MONO - : RT_OWNER_COLORS; - } - - // rtOwnerGroups 把 inputch 按**归属**分组(owner == 根 agent id 与 "" 归一为根)。 - function rtOwnerGroups(inputs, residents, rootID) { - var order = []; - var map = {}; - inputs.forEach((c) => { - var o = c.owner || ""; - if (o === rootID) o = ""; - if (!map[o]) { - map[o] = []; - order.push(o); - } - map[o].push(c); - }); - // 驻留子即使一条 inputch 都没划到也要出现——否则「子存在但看不见」 - // 与「子不存在」无法区分。 - residents.forEach((r) => { - var o = r.id || ""; - if (o && o !== rootID && !map[o]) { - map[o] = []; - order.push(o); - } - }); - order.sort((a, b) => { - if (a === "") return -1; - if (b === "") return 1; - return a < b ? -1 : 1; - }); - var ci = 0; - var OW = rtOwnerColors(); - return order.map((o) => { - var res = null; - residents.forEach((r) => { - if (r.id === o) res = r; - }); - var list = map[o] || []; - return { - owner: o, - child: o !== "", - label: o === "" ? __("根 agent / 内核默认", "root agent / kernel default") : __("驻留子 ", "resident ") + o, - color: o === "" ? OW[0] : OW[1 + (ci++ % (OW.length - 1))], - list: list, - count: list.length, - res: res, - }; - }); - } - - // ---- 阶段管道滑块 ---- - // - // 七阶段与内核 sdk.Stage 一一对应(顺序即执行顺序),由 SSE `stage` 事件驱动: - // 哪个阶段在执行,滑块就滑到哪一格。空闲时整条管道降透明度,不做假动画。 - // - // 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", - // 两者合起来才是运行态。此前它只是对话页一个 10px 的角标,等于看不见。 - // 阶段管道:**循环流程 + 本轮轨迹**,不是单向滑块。 - // - // 一轮里工具调用会反复回到「行动后」再接下一个工具(post_action → - // before_toolcall → after_toolcall → post_action …),还可能中途多次 - // 输出,所以线性滑块本身就是错的表述。这里画成 - // 输入 → 行动 ⇄(工具) → 输出 → 结束 - // (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**: - // 普通工具与 output_* 输出通道调用用不同配色区分开来。 - // 页面上**任何位置都不用 emoji/符号字符充当图标** —— 一律内联 SVG, - // 24x24 viewBox + currentColor stroke,随主题与状态变色,不额外引资源。 - var RT_ICO = { - in: '', - act: '', - tool: '', - out: '', - done: '', - loop: '', - bolt: '', - caret: '', - }; - - var RT_PIPE_GROUPS = [ - { zh: "输入", en: "in", ico: "in" }, - { zh: "行动", en: "act", ico: "act" }, - { zh: "工具", en: "tool", ico: "tool", loop: true }, - { zh: "输出", en: "out", ico: "out" }, - { zh: "结束", en: "done", ico: "done" }, - ]; - function rtPhaseGroup(phase) { - switch (phase) { - case "on_input": - return 0; - case "pre_action": - case "post_action": - return 1; - case "before_toolcall": - case "after_toolcall": - return 2; - case "before_output": - return 3; - case "after_output": - return 4; - } - return -1; - } - // rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名。 - function rtShortTool(name) { - var n = String(name || ""); - var i = n.lastIndexOf("__"); - if (i >= 0) n = n.slice(i + 2); - return n.length > 14 ? n.slice(0, 13) + "…" : n; - } - // rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。 - // - // 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩一串文字, - // 看上去就是「这块空着」。格槽在 0 时仍有形状,占用多少一眼可数, - // 也不用为了「看得见」而给 0 画一条假进度。 - function rtSlots(depth, slots, extraCls) { - var n = Math.max(5, Math.min(16, slots || 5)); - var d = depth || 0; - var out = ''; - for (var i = 0; i < n; i++) out += ''; - // 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去, - // 多一个兄弟节点会被挤到下一行,整块队列就串了。 - if (d > n) out += '+' + (d - n) + ""; - return out + ""; - } - // rtChipEl 画一枚事件 chip。工具与输出通道调用各有配色;名称包进 - // .rt-chip-t,让窄框里由它做省略号截断(.rt-chip 本身是 flex 容器, - // text-overflow 对直接子文本不生效,会被硬切掉半截)。 - function rtChipEl(t) { - var kind = t.kind || "stage"; - var ico = kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : ""; - return ( - '' + ico + - '' + escHtml(t.short || t.label) + "" + - (t.n > 1 ? 'x' + t.n + "" : "") + - "" - ); - } - // rtPipelineHtml 画阶段管道:**五个等大的表框**,每框里是本阶段本轮发生的事。 - // - // 为什么不是「小圆点 + 连接线 + 9px 小字」:那种画法在总览里几乎读不出 - // 「现在走到哪一步」,尺寸也远小于旁边的 KPI 框。现在与中断队列、KPI 用同 - // 一套视觉语言(等大框 + 大号加粗标题),并且事件直接落在它所属的框里, - // 所以「这一步发生了什么」不需要靠颜色或图例去猜。 - function rtPipelineHtml(phase, trail) { - var g = rtPhaseGroup(phase); - var cells = RT_PIPE_GROUPS.map((s, i) => { - var items = (trail || []).filter((t) => (t.g | 0) === i); - // 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都 - // 追加成 chip,这格会被撑成一长条,读者反而看不出「现在正在调什么」。 - // 所以它只保留**最新一条**,旧条在同一行视口里向上滚走 - // (rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。 - var cls = "rt-pipe-events"; - var body; - if (!items.length) { - body = '' + __("无", "none") + ""; - } else if (s.loop) { - cls += " rt-pipe-scroll"; - var total = 0; - for (var k = 0; k < items.length; k++) total += items[k].n || 1; - body = - rtChipEl(items[items.length - 1]) + - 'x' + total + ""; - } else { - body = items.map(rtChipEl).join(""); - } - return ( - '
' + - '
' + RT_ICO[s.ico] + - "" + __(s.zh, s.en) + "" + - (s.loop - ? '' + RT_ICO.loop + "" - : "") + - "
" + - '
' + body + "
" + - "
" - ); - }).join(""); - return ( - '
' + - __("阶段管道", "Stage pipeline") + - (g < 0 ? " " + __("(空闲)", "(idle)") : "") + - "
" + - '
' + cells + "
" - ); - } - - // rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里。 - // 同一阶段重复出现同一条(如同一工具连调 3 次)只累加计数,不刷屏。 - function rtTrailPush(g, kind, label, short) { - if (!state.stageTrail) state.stageTrail = []; - var arr = state.stageTrail; - var last = arr.length ? arr[arr.length - 1] : null; - if (last && last.g === g && last.kind === kind && last.short === short) { - last.n = (last.n || 1) + 1; - return; - } - arr.push({ g: g, kind: kind, label: label, short: short, n: 1 }); - if (arr.length > 24) arr.shift(); - } - - // rtFlashLatestTool 让「工具」格里最新那条做一次「向上滚入」。 - // - // morph 是就地改文本:新工具到来时 chip 节点不会被替换,纯 CSS 的 - // animation 因此不会自动重放。这里摘类 → 强制 reflow → 重加类,重置动画 - // 时间轴。prefers-reduced-motion 由样式表统一压到 0.01ms,无需在此判断。 - function rtFlashLatestTool() { - var box = document.querySelector(".rt-pipe-events.rt-pipe-scroll"); - if (!box) return; - var chip = box.querySelector(".rt-chip"); - if (!chip) return; - chip.classList.remove("rt-chip-enter"); - void chip.offsetWidth; // 强制 reflow:少了这句,重加类不会重启动画 - chip.classList.add("rt-chip-enter"); - } - - // ---- per-agent 负载 ---- - // - // 负载 = 该 agent **自己的**调度器积压折算成的百分比。 - // - // 为什么按级别加权:四级中断里 L4 是内核独占、L3 是交互,堵在 L4 一条比堵在 - // L1 五条更严重;中断栈深度意味着有现场被压着。权重是启发式的("满载"没有 - // 硬定义),因此函数做成饱和式 backlog/(backlog+K):单调、上界 100、不越界。 - // context_full 单独加分:子上下文满了以后每轮都要压缩,本身就是高负载。 - function rtLoadPct(sc, ctxFull) { - sc = sc || {}; - var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; - var backlog = - (sc.ready_queue_depth || 0) + - (sc.pending_interrupts || 0) * 1.5 + - (sc.suspend_stack || 0) * 2 + - (q[1] || 0) * 1 + - (q[2] || 0) * 1.2 + - (q[3] || 0) * 1.5 + - (q[4] || 0) * 2; - var pct = 100 * (backlog / (backlog + 8)); - if (ctxFull) pct += 15; - return Math.max(0, Math.min(100, Math.round(pct))); - } - - // rtAgents 把「inputch 归属 + 驻留子 + 各自的调度器积压」整理成每个 agent 一行。 - // 根 agent 的积压来自 rt.scheduler;驻留子的来自它自己的 ResidentStatus - // (见 internal/sdk/status.go 的 ReadyQueueDepth 等四项)。 - function rtAgents(rt) { - var rootID = rt.agent_id || ""; - var groups = rtOwnerGroups(rt.input_channels || [], rt.residents || [], rootID); - var outChans = (rt.channels || []).filter((c) => c.direction === "out" || c.direction === "io"); - return groups.map((g) => { - var id = g.owner || rootID; - var outputs = []; - var seen = {}; - function add(name) { - if (!name || seen[name]) return; - seen[name] = 1; - outputs.push(name); - } - if (g.child) { - // 驻留子:优先用父显式授权的输出;没有授权登记时退回它自己 inputch 的 - // 默认回程(`output` 字段)。 - ((g.res && g.res.allowed_outputs) || []).forEach(add); - if (!outputs.length) g.list.forEach((c) => { add(c.output); }); - } else { - // 根 agent 可以写任何输出通道;上限交给渲染侧截断。 - outChans.forEach((c) => { add(c.name); }); - } - var load = g.child - ? rtLoadPct( - { - ready_queue_depth: g.res && g.res.ready_queue_depth, - pending_interrupts: g.res && g.res.pending_interrupts, - suspend_stack: g.res && g.res.suspend_stack, - interrupt_queues: (g.res && g.res.interrupt_queues) || [], - }, - g.res && g.res.context_full, - ) - : rtLoadPct(rt.scheduler || {}, false); - return { - id: id, child: g.child, label: g.label, color: g.color, - inputs: g.list, outputs: outputs, load: load, res: g.res || null, - }; - }); - } - - // 连线路径表:通道名 → path d。光点动画靠它把「哪个通道」翻成一条曲线。 - var _rtEdgeIn = {}; - var _rtEdgeOut = {}; - var RT_SVGNS = "http://www.w3.org/2000/svg"; - - // rtAgentTopology 画「通道 → agent → 通道」的分带拓扑。 - // - // 每个 agent 一条横带:左边是归它的 inputch,中间是它自己(圆环 = 负载), - // 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 - // 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 - // 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 - // rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是 - // 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块, - // 观感就是"间距不对"。 - function rtTopoWidth() { - var host = document.getElementById("rt-sec-topo"); - var w = host ? host.clientWidth : 0; - if (!w) { - var panel = document.getElementById("rt-panel"); - w = panel ? panel.clientWidth : 0; - } - if (!w || w < 360) w = 900; - return Math.round(w) - 4; - } - - // rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。 - // 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。 - function rtClip(text, maxPx, fontPx) { - var s = String(text == null ? "" : text); - var w = 0; - var i = 0; - for (; i < s.length; i++) { - var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56; - if (w + cw > maxPx) break; - w += cw; - } - if (i >= s.length) return s; - return s.slice(0, Math.max(1, i - 1)) + "…"; - } - - function rtAgentTopology(agents) { - var W = rtTopoWidth(); - var ROW = 30, BOX_H = 20, GAP = 14, NODE_R = 20, MAX_OUT = 8; - // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。 - var IN_X = 0, IN_W = Math.round(W * 0.32); - var NX = Math.round(W * 0.44), OUT_X = Math.round(W * 0.56), OUT_W = W - Math.round(W * 0.56) - 2; - var out = []; - _rtEdgeIn = {}; - _rtEdgeOut = {}; - function esc(s) { - return String(s == null ? "" : s).replace(/[<>&]/g, (m) => m === "<" ? "<" : m === ">" ? ">" : "&"); - } - function pathD(x1, y1, x2, y2) { - var mx = (x1 + x2) / 2; - return "M" + x1 + " " + y1 + " C" + mx + " " + y1 + " " + mx + " " + y2 + " " + x2 + " " + y2; - } - var y = 8; - if (!agents.length) { - out.push('' + __("暂无通道 / agent", "no channels / agents") + ""); - y = 44; - } - agents.forEach((a) => { - var nin = a.inputs.length; - var nout = Math.min(a.outputs.length, MAX_OUT); - var rows = Math.max(nin, nout, 1); - var truncated = a.outputs.length > MAX_OUT; - // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的 - // 名字会溢出到上/下一个带上("间距不对"的一个来源)。 - var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20); - var contentH = Math.max(rows * ROW, nodeBlock, truncated ? nout * ROW + 18 : 0); - var bandH = contentH + GAP; - var cy = y + contentH / 2; - // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。 - var inTop = y + (contentH - nin * ROW) / 2; - var outTop = y + (contentH - nout * ROW) / 2; - out.push(''); - out.push(''); - - // 输入通道 → agent - a.inputs.forEach((c, i) => { - var ry = inTop + i * ROW + ROW / 2; - out.push(''); - out.push('' + esc(rtClip(c.name, IN_W - 44, 12)) + "" + esc(c.name) + ""); - if (c.capacity) { - out.push('' + esc(c.capacity) + ""); - } - var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy); - _rtEdgeIn[c.name] = d; - out.push(''); - }); - - // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) - var C = 2 * Math.PI * (NODE_R - 3); - out.push(''); - out.push(''); - out.push( - '', - ); - out.push('' + a.load + ""); - out.push('' + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + ""); - if (a.res && a.res.context_full) { - out.push('' + __("上下文已满", "ctx full") + ""); - } - - // agent → 输出通道 - a.outputs.slice(0, MAX_OUT).forEach((name, i) => { - var ry = outTop + i * ROW + ROW / 2; - out.push(''); - out.push('' + esc(rtClip(name, OUT_W - 20, 12)) + "" + esc(name) + ""); - var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); - _rtEdgeOut[a.id + "\u0000" + name] = d; - out.push(''); - }); - if (a.outputs.length > MAX_OUT) { - out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + ""); - } - y += bandH; - }); - var H = Math.max(60, y); - return ( - '
' + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + "
" + - '
' + - out.join("") + - "
" - ); - } - - // rtSpark 让一个光点沿一条连线跑一趟。 - // - // 用 SMIL 而不是 CSS offset-path / rAF:它由浏览器自己按 - // SVG 用户坐标跑,不需要前端维护动画循环,也没有 path() 在缩放 viewBox 下 - // 的坐标换算问题。跑完把节点摘掉,避免 DOM 累积。 - function rtSpark(pathD, color) { - if (!pathD) return; - var svg = document.getElementById("rt-topo-svg"); - if (!svg) return; - var c = document.createElementNS(RT_SVGNS, "circle"); - c.setAttribute("r", "3.2"); - c.setAttribute("fill", color || "#ffffff"); - c.setAttribute("class", "rt-spark"); - var am = document.createElementNS(RT_SVGNS, "animateMotion"); - am.setAttribute("dur", "0.85s"); - am.setAttribute("path", pathD); - am.setAttribute("fill", "freeze"); - am.setAttribute("begin", "0s"); - c.appendChild(am); - svg.appendChild(c); - setTimeout(() => { if (c.parentNode) c.parentNode.removeChild(c); }, 950); - } - // 输入:某条 inputch 来消息了(SSE channel_input)。 - function rtSparkInput(source) { - rtSpark(_rtEdgeIn[source], "#88c0d0"); - } - // 输出:某个 agent 往某条 outputch 写了东西(SSE agent_output)。 - function rtSparkOutput(agentID, channel) { - if (!channel || !agentID) return; - rtSpark(_rtEdgeOut[agentID + "\u0000" + channel], "#ff7fac"); - } - - function renderRuntime() { - var el = document.getElementById("rt-panel"); - if (!el) return; - var rt = state.runtime; - if (!rt) { - el.innerHTML = '
' + __("运行态数据不可用", "runtime unavailable") + "
"; - _rtSig = null; - return; - } - var sc = rt.scheduler || {}; - var residents = rt.residents || []; - var channels = rt.channels || []; - var inputs = rt.input_channels || []; - - // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 - // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, - // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 - var sig = JSON.stringify([ - sc, residents, channels, inputs, state.pipelinePhase, - (state.stageTrail || []).map((t) => t.g + ":" + t.kind + ":" + (t.short || t.label) + "x" + (t.n || 1)).join(","), - ]); - if (sig === _rtSig) return; - _rtSig = sig; - - // 外壳只建一次;随后**逐段**更新。 - // - // 为什么不是整块 innerHTML:设备通道列表这类数据本来就会来回变(实测远程设备 - // 通道 11→9 条),整块重建会把没变的段落(含条形 transition)也推倒重来, - // 视觉上就是「一闪一闪」。逐段比较后只替换真正变了的那一段。 - if (!document.getElementById("rt-sec-tiles")) { - el.innerHTML = - '

' + __("运行态", "Runtime") + "

" + - '
' + - '
' + - '
' + - '
' + - '
' + - "
"; - } - function put(sec, html) { - var n = document.getElementById("rt-sec-" + sec); - if (!n || n.__sig === html) return; // 该段没变:一个字节都不动 - n.__sig = html; - // 变了也只「形变」到新样子:复用未变的子节点,而不是整块重建。 - morph(n, html); - } - - var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; - var pending = sc.pending_interrupts || 0; - var ready = sc.ready_queue_depth || 0; - var stack = sc.suspend_stack || 0; - var maxStack = sc.max_suspend_depth || 4; - var frames = sc.suspend_frames || []; - var byLv = sc.interrupts_by_level || []; - var preLv = sc.preempts_by_level || []; - - // ---- 段 1:四个数字块 ---- - var h = '
'; - h += rtTile(ready, __("排队", "Ready"), "", ready ? Math.min(100, ready * 20) : 0, ready > 0); - h += rtTile(pending, __("中断", "Pending"), "", pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0); - h += rtTile(stack + "/" + maxStack, __("栈", "Stack"), "", (stack / (maxStack || 4)) * 100, stack > 0); - var rFull = residents.filter((r) => r.context_full).length; - h += rtTile(residents.length, __("子代理", "Subagents"), rFull ? rFull + __("满", " full") : "", residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0); - h += "
"; - // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" - // 没有帮助,却占掉一整行,是总览里最大的一坨文字。 - put("tiles", h); - - // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- - put("pipe", rtPipelineHtml(state.pipelinePhase, state.stageTrail)); - - // ---- 段 3:队列(四级中断 + 一条排队)---- - // - // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: - // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 - // 「不需要及时处理」的那一半输入。排队队列**无级别**,故用不同配色 + 虚线。 - h = '
' + __("队列", "Queues") + "
"; - var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); - var maxReg = 1; - var maxPre = 1; - RT_LEVELS.forEach((L) => { - maxReg = Math.max(maxReg, byLv[L.lv] || 0); - maxPre = Math.max(maxPre, preLv[L.lv] || 0); - }); - // 槽位数按全场最大深度缩放(且至少 5 格):0 时也有可见形状,不空着。 - var qSlots = Math.max(5, Math.min(16, maxQ)); - // 五个**等大表框**(四级中断 + 一条排队),与阶段管道同一套视觉语言。 - // 此前是五行扁条,四级中断全为 0 时四行几乎全是空白,又占高度又难看。 - h += '
'; - RT_LEVELS.forEach((L) => { - var depth = q[L.lv] || 0; - var reg = byLv[L.lv] || 0; - var pre = preLv[L.lv] || 0; - h += - '
' + - '
' + L.name + "" + escHtml(L.desc) + "
" + - '
' + depth + "
" + - rtSlots(depth, qSlots, L.cls) + - '
' + - rtMini(reg, maxReg, __("登记", "registered")) + - rtMini(pre, maxPre, __("抢占", "preempted")) + - "
"; - }); - // 第五条:排队队列(无级别,纯 FIFO)。 - // 它不是优先级而是另一**类别**(排队 vs 中断),所以用虚线框区分。 - h += - '
' + - '
' + __("排队", "queued") + "FIFO
" + - '
' + ready + "
" + - rtSlots(ready, qSlots, "rt-lv-q") + - '
' + __("无级别", "no priority") + "
"; - h += "
"; - if (sc.immediate) { - h += '
' + RT_ICO.bolt + " " + __("立即运行", "immediate") + ":" + - escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id + - 'L' + (sc.immediate.level || 0) + "
"; - } - put("levels", h); - - // ---- 段 4:中断栈 ---- - h = '
' + __("栈", "Stack") + "
"; - // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 - h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); - if (frames.length) { - h += '
'; - frames.forEach((f, i) => { - var t = (f && f.task) || {}; - h += '
' + escHtml(t.kind || "") + " #" + (t.id || "?") + - 'L' + (t.level || 0) + - (i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + "
"; - }); - h += "
"; - } else { - h += '
' + __("中断栈为空(当前无被抢占的现场)", "stack empty (nothing preempted)") + "
"; - } - if (sc.running) { - h += '
' + __("正在运行", "running") + ":" + - escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")
"; - } - put("stack", h); - - // ---- 段 5:Agent 拓扑 ---- - // - // 每个 agent 一条横带:左是归它的 inputch、中间是它自己(圆环 = 负载)、 - // 右是它能写的 outputch,连线即路由。归属、路由、容量、负载全部画进 - // 同一张 SVG,不再单开"通道分配"一节——那本来就是这个拓扑的一部分。 - put("topo", rtAgentTopology(rtAgents(rt))); - } - - // loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 - async function loadRuntime() { - try { - var rt = await api("/runtime"); - state.runtime = rt; - renderRuntime(); - } catch (e) { - state.runtime = null; - } - } - - // startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义, - // 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。 - // startChatTicker:聊天页可见时 3s 轮询一次增量(与运行态同一节奏)。 - // SSE 仍在(token 级流式靠它),轮询是「数据查询 api」那条腿: - // 界面最终状态由轮询拉到的数据决定,SSE 只负责让流式看起来即时。 - function startChatTicker() { - if (state._chatTicker) return; - state._chatTicker = setInterval(() => { - var tab = document.querySelector("#tab-chat"); - if (tab && tab.classList.contains("active")) { - syncChatFromHistory().catch(() => {}); - } - }, 3000); - } - - function startRuntimeTicker() { - if (state._runtimeTicker) return; - state._runtimeTicker = setInterval(() => { - var tab = document.querySelector("#tab-overview"); - if (tab && tab.classList.contains("active")) { - loadRuntime(); - } - }, 3000); - } - - // ===== Overview ===== - // - // 总览页是**静态骨架 + 数据绑定**:DOM 只在首帧建一次,之后所有刷新只改 - // 文本/类名,绝不重建节点。此前 renderAll 每 15s 把整个总览(含运行态面板) - // innerHTML 重建一遍 —— 那是页面上最刺眼的周期性闪烁的来源。 - // - // 文字也压到最少:状态用彩色圆点表达,其余只留数字与两字标签,不再堆 - // 「未初始化 / 不可用」这类整句描述。 - var OV_ICONS = { - activity: - '', - clock: - '', - puzzle: - '', - tag: - '', - brain: - '', - db: - '', - file: - '', - cpu: - '', - }; - - function ovKpi(id, icon, label, dot) { - return ( - '
' + - icon + - '' + - (dot ? '' : "") + - '—' + - '' + - '' + - label + - "
" - ); - } - - function renderOverview() { - var host = document.getElementById("tab-overview"); - if (!host) return; - // 只在首帧建骨架;之后(renderAll 每 15s 一次)只 updateOverview。 - // 一个节点都不重建,所以不会闪。 - if (!document.getElementById("ov-kpis")) { - host.innerHTML = - '
' + - '
' + - ovKpi("status", OV_ICONS.activity, __("状态", "Status"), true) + - ovKpi("uptime", OV_ICONS.clock, __("运行", "Uptime")) + - ovKpi("plugins", OV_ICONS.puzzle, __("插件", "Plugins")) + - ovKpi("version", OV_ICONS.tag, __("版本", "Version")) + - ovKpi("llm", OV_ICONS.brain, "LLM", true) + - ovKpi("memory", OV_ICONS.db, __("记忆", "Memory")) + - ovKpi("docs", OV_ICONS.file, __("文档", "Docs")) + - ovKpi("runtime", OV_ICONS.cpu, __("运行时", "Runtime")) + - '
' + - // 星图搬到主页:作为总览的门面,跟随 agent 活动脉动。 - // 开源许可单独成框:之前在 KPI 卡底部只是一行小链接(.ov-foot), - // 几乎看不见,也看不出是许可证还是别的什么。 - '

' + - __("记忆星图", "Memory Star Map") + - '

' + - '
' + - ''; - } - updateOverview(); - // 星图在主页常驻:容器已建好,首次就拉数据 + 初始化 3D。 - if (document.getElementById("sm-container-home") && !state.starmapInit) { - renderChatStarmap(); - } - } - - function ovSet(id, text) { - var el = document.getElementById(id); - if (el && el.textContent !== String(text)) el.textContent = String(text); - } - function ovDot(id, cls) { - var el = document.getElementById(id); - if (el && el.className !== cls) el.className = cls; - } - - // updateOverview 只写值与类名,从不改结构。 - function updateOverview() { - var s = state.status || {}; - var k = state.kernel; - var running = s.status === "running"; - ovSet("ov-status", running ? __("运行中", "running") : s.status || "unknown"); - ovDot("ov-status-dot", "ov-dot " + (running ? "ok" : "warn")); - ovSet("ov-uptime", state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-"); - ovSet("ov-plugins", ((k && k.plugins) || []).length || 0); - // 内核身份:光有版本号分不清是哪个内核、哪次构建 —— 补上内核名与 commit。 - // 之前 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩余 "v1.4.0"。 - var kb = (k && k.build) || {}; - var kv = kb.version || s.version || ""; - ovSet("ov-version", kv ? "v" + kv : "—"); - ovSet( - "ov-version-sub", - kv - ? (kb.kernel_name || "HomeAgent") + - (kb.commit && kb.commit !== "unknown" - ? " · " + String(kb.commit).slice(0, 7) - : "") - : "", - ); - var llm = (k && k.llm) || {}; - ovDot("ov-llm-dot", "ov-dot " + (llm.available ? "ok" : "bad")); - ovSet("ov-llm", llm.provider || "—"); - var mem = (k && k.memory) || {}; - ovSet("ov-memory", mem.available ? mem.entity_count + "/" + mem.relation_count : "—"); - var docs = (k && k.documents) || {}; - ovSet("ov-docs", docs.available ? docs.doc_count : "—"); - var rt = (k && k.runtime) || {}; - ovSet( - "ov-runtime", - rt.goroutines ? rt.goroutines + (rt.memory_mb ? " · " + rt.memory_mb + "M" : "") : "—", - ); - // 开源许可卡:协议标识 + 协议全文 + 源码仓库。 - // - // 为什么单独成框、且三者都给:AGPL-3.0 §13 的义务是「向网络使用者提供 - // 取得 Corresponding Source 的机会」——只给一个仓库链接、不写协议名, - // 使用者看不出这受什么许可约束,也看不出网络服务场景下还有 §13 的义务。 - // 内容对一次构建是常量,所以填一次就够(__html 比对避免重复重建)。 - var legal = document.getElementById("ov-legal"); - if (legal) { - var lb = (k && k.build) || {}; - var src = lb.source_url || ""; - var lic = lb.license || ""; - var licURL = lb.license_url || ""; - var rows = ""; - if (lic) { - rows += - '
"; - } - if (src) { - rows += - '
' + - __("源码仓库", "Source") + - '' + - escHtml(src) + - "
"; - } - // 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。 - var note = - lic && lic.toUpperCase().indexOf("AGPL") >= 0 - ? '" - : ""; - var want = rows - ? '

' + __("开源许可", "License") + "

" + rows + note - : ""; - if (legal.__html !== want) { - legal.__html = want; - legal.innerHTML = want; - } - } - } - - // ===== Chat ===== - var _chatLayoutBuilt = false; - - function buildChatLayout() { - var cont = document.getElementById("tab-chat"); - var k = state.kernel || {}; - var html = '
'; - html += - '
' + - '' + - __("对话", "Chat") + - "" + - "" + - __("星图", "Star Map") + - "" + - "" + - __("终端", "Terminal") + - "" + - "" + - __("运行中命令", "Running Commands") + - "" + - "" + - __("记忆", "Memory") + - "" + - "" + - __("上下文", "Context") + - "" + - "" + - __("知识", "Knowledge") + - "
"; - html += - '
'; - html += - '

' + - __("对话", "Chat") + - '

'; - if (state.messages.length === 0) { - html += - '

' + - __( - "开始对话以测试 Agent 回复", - "Start a conversation to test Agent replies", - ) + - "

"; - } - html += - "
" + - '
' + - '' + - '" + - '' + - '" + - '" + - "
"; - html += - '

' + - __("星图", "Star Map") + - "

" + - '
'; - html += - '

' + - __("终端", "Terminal") + - ' 0

' + - '
'; - html += - '

' + - __("命令历史", "Command History") + - ' 0

' + - '
'; - html += - '

' + - __("记忆", "Memory") + - "

" + - '
' + - __("实体", "Entities") + - '' + - (k?.memory?.entity_count || "-") + - "
" + - '
' + - __("关系", "Relations") + - '' + - (k?.memory?.relation_count || "-") + - "
" + - '
' + - '' + - '" + - '
' + - "
"; - html += - '

' + - __("上下文", "Context") + - "

" + - '
' + - '' + - '" + - '
' + - "
"; - html += - '

' + - __("知识", "Knowledge") + - "

" + - '
' + - __("项目", "Items") + - '' + - (k?.knowledge?.item_count ?? "-") + - "
" + - '
' + - '' + - __("按分类浏览", "Browse by category") + - '' + - '
' + - '
' + - '
' + - '' + - '' + - '" + - '" + - '
' + - '
' + - '' + - '' + - '' + - '
" + - '' + - "
" + - "
"; - html += "
"; - cont.innerHTML = html; - _chatLayoutBuilt = true; - } - - var CHAN_COLORS = [ - "#e08a5f", - "#5f9fe0", - "#6bbf8f", - "#c06bbf", - "#d9a13b", - "#5fb3bf", - "#b06b6b", - "#7f8ce0", - ]; - - function chanColor(src) { - var h = 0; - for (var i = 0; i < src.length; i++) { - h = (h * 31 + src.charCodeAt(i)) >>> 0; - } - return CHAN_COLORS[h % CHAN_COLORS.length]; - } - - function chanLetter(src) { - var s = (src || "").trim(); - if (!s) return "C"; - var ch = s.charAt(0).toUpperCase(); - return /[A-Za-z0-9]/.test(ch) ? ch : "C"; - } - - // ===== 列表对账(keyed reconcile)===== - // - // 为什么必须对账而不是 innerHTML 整块重建:整块重建会把**没变的**那些消息 - // 节点也推倒重来 —— 图片重新解码闪一下、工具卡片的展开态丢失、CSS 过渡从 - // 头播、滚动锚点被重置。这些闪烁几乎无法用"重建后再还原"消除。 - // - // 做法:新 HTML 先在游离容器里解析成节点,然后按 data-key 逐个对账: - // - key 命中且内容一致 → 复用原节点(一个字节都不动); - // - key 命中但内容变了 → 只替换这一个节点; - // - key 未命中 → 作为新节点插入; - // 最后把多出来的旧节点删掉。未变节点因此完全不受影响。 - var _localKeySeq = 0; - - // chatMsgKey 给一条消息一个**稳定**的 key。 - // - 服务端消息用 seq(单调、落盘、重启不重编号); - // - 本地乐观消息(用户刚发的 / 正在流式的)第一次渲染时分配一个进程内 - // 自增 key 并挂在对象上,后续渲染不变。 - function chatMsgKey(m) { - if (m && m.seq) return "s" + m.seq; - if (m && !m._k) m._k = "L" + ++_localKeySeq; - return (m && m._k) || "L0"; - } - - function commitChatList(container, html) { - var tpl = document.createElement("div"); - tpl.innerHTML = html; - var next = Array.prototype.slice.call(tpl.children); - var existing = {}; - Array.prototype.forEach.call(container.children, (n) => { - var k = n.getAttribute && n.getAttribute("data-key"); - if (k) existing[k] = n; - }); - for (var i = 0; i < next.length; i++) { - var fresh = next[i]; - var key = fresh.getAttribute && fresh.getAttribute("data-key"); - var node = fresh; - if (key && existing[key]) { - var old = existing[key]; - // 内容逐字节相同 → 复用旧节点(不触碰它,保存动画/展开/图片状态); - // 否则只替换这一个。outerHTML 比较对消息节点足够(含 tool_calls 状态)。 - if (old.outerHTML === fresh.outerHTML) { - node = old; - } else { - old.remove(); - } - } - var cur = container.children[i]; - if (cur !== node) container.insertBefore(node, cur || null); - } - // 多出来的旧节点(本轮新列表里没有的)从尾部清掉 - while (container.children.length > next.length) { - container.removeChild(container.lastElementChild); - } - } - - function renderChat() { - if (!_chatLayoutBuilt) { - buildChatLayout(); - renderChatStarmap(); - renderTerminals(); - renderCmdHistory(); - } - var msgsEl = document.getElementById("chat-msgs"); - if (!msgsEl) return; - if (!msgsEl._stickBound) { - msgsEl._stickBound = true; - msgsEl.addEventListener( - "scroll", - () => { - state.chatStick = - msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < - 80; - // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 - if (msgsEl.scrollTop < 60 && state.chatHasMore) { - loadOlderChat(); - } - }, - { passive: true }, - ); - msgsEl.addEventListener( - "load", - () => { - if (state.chatStick === true) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - }, - true, - ); - } - var msgs = state.messages; - var sig = - msgs - .map((m) => { - var c = m.content || ""; - return ( - (m.role || "") + - ":" + - c.length + - ":" + - c.slice(-40) + - ":" + - (m.tool_calls || []) - .map((t) => (t.tool || t.name || "") + "/" + (t.status || "")) - .join(",") - ); - }) - .join("|") + - "|L" + - (state.chatLoading ? "1" : "0") + - "|P" + - (state.pendingTools || []).join(","); - if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) { - if (state.chatStick === true) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - return; - } - msgsEl._chatSig = sig; - var prevPending = msgsEl._lastPending || []; - var newPending = (state.pendingTools || []).slice(); - if (state.chatLoading) { - var lastM = msgs.length ? msgs[msgs.length - 1] : null; - if (lastM && lastM.role === "assistant") { - (lastM.tool_calls || []).forEach((tc) => { - if (!tc.result && tc.status !== "denied") { - var nm = tc.tool || tc.name || ""; - if (newPending.indexOf(nm) === -1) newPending.push(nm); - } - }); - } - } - var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1); - msgsEl._lastPending = newPending; - var streamingLast = !!( - state.chatLoading && - lastM && - lastM.role === "assistant" && - !lastM._final - ); - function pillHtml() { - var s = ""; - newPending.forEach((nm) => { - var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in"; - s += - '' + - '' + - escHtml(nm) + - ""; - }); - return s; - } - var html = ""; - if (msgs.length === 0) { - html = - '

' + - __( - "开始对话以测试 Agent 回复", - "Start a conversation to test Agent replies", - ) + - "

"; - } else { - msgs.forEach((m, i) => { - var _k = chatMsgKey(m); - var role = m.role || "user"; - var c = m.content || ""; - // 附件消息:agent 经 output_send__webui 发送,或用户上传。 - // 用 role 区分方向(user=右侧你发的 / assistant=左侧小宅发的), - // 卡片加来源标签避免混淆。 - if (m.attachment) { - var att = m.attachment; - var isUserAtt = role === "user"; - var srcLabel = isUserAtt - ? __("你发送的", "You sent") - : __("小宅发送的", "Sent by agent"); - var attHtml = ""; - if (att.type === "image") { - attHtml = - '' + - '图片加载失败\'"/>'; - } else if (att.type === "audio") { - // 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。 - // preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。 - attHtml = - '' + - '' + - escHtml(att.name || "audio") + - (att.size ? " (" + formatBytes(att.size) + ")" : "") + - ""; - } else { - var sizeStr = att.size ? formatBytes(att.size) : ""; - attHtml = - '' + - '' + - "" + - escHtml(att.name || "附件") + - (sizeStr ? " (" + sizeStr + ")" : "") + - ""; - } - html += - '
' + - '
' + - srcLabel + - "
" + - attHtml + - (c ? '
' + renderMd(c) + "
" : "") + - "
"; - return; - } - if (role === "assistant") { - c = renderMd(c); - } else if (role === "system") { - c = escHtml(c); - } else { - c = escHtml(c); - } - var isChan = !!(m.source && m.source !== "webui"); - var rc = ""; - if (m.reasoning_content) { - rc = renderReasoningCard(m.reasoning_content, isStreamingLast); - } - var tcs = ""; - if (m.tool_calls && m.tool_calls.length > 0) { - m.tool_calls.forEach((tc) => { - var argsStr = - typeof tc.args === "object" - ? JSON.stringify(tc.args, null, 1) - : tc.args || ""; - var resultStr = tc.result - ? typeof tc.result === "object" - ? JSON.stringify(tc.result, null, 1) - : String(tc.result) - : ""; - var running = !resultStr && tc.status !== "denied"; - var error = - tc.status === "error" || tc.status === "denied" || !!tc.error; - var drip = - newlyDone.indexOf(tc.tool || tc.name || "") !== -1 - ? " tool-drip-in" - : ""; - var iconSvg = error - ? '' - : running - ? '' - : ''; - var statusHtml = - tc.status === "denied" - ? '' + - __("已拒绝", "Denied") + - "" - : running - ? '' + - __("调用中", "Running") + - "" - : '' + - __("完成", "Done") + - ""; - var pluginHtml = tc.plugin - ? '' + escHtml(tc.plugin) + "" - : ""; - tcs += - '
' + - '
' + - iconSvg + - '' + - escHtml(tc.tool || tc.name || "") + - "" + - pluginHtml + - statusHtml + - '' + RT_ICO.caret + "
" + - '
"; - }); - } - var body = rc + tcs; - var growCls = m._grow ? " grow-in" : ""; - if (m._grow) m._grow = false; - var isStreamingLast = i === msgs.length - 1 && streamingLast; - if (isStreamingLast) { - var liveRow = - '' + - (newPending.length - ? '' + pillHtml() + "" - : ""); - if (c) { - body += - '
' + - liveRow + - '
' + - c + - "
"; - c = ""; - } else { - body += '
' + liveRow + "
"; - } - } else if (c) { - body += - '
' + - c + - "
"; - } - if (role === "system") { - html += - '
' + - (c || "") + - "
"; - } else if (isChan) { - html += - '
' + - '
' + - chanLetter(m.source) + - "
" + - '
' + - escHtml(m.source) + - "
" + - body + - "
"; - } else { - var userAvatar = - ''; - var aiAvatar = '小宅'; - html += - '
' + - '
' + - (role === "user" ? userAvatar : aiAvatar) + - "
" + - '
' + - body + - "
" + - "
"; - } - }); - } - if (state.chatLoading && !streamingLast) { - var aiAvatarL = '小宅'; - html += - '
' + - aiAvatarL + - '
' + - '' + - (newPending.length - ? '' + pillHtml() + "" - : "") + - "
"; - } - // 重建前记住阅读位置:非粘底(用户正向上翻)时,innerHTML 重建后必须把位置还回去, - // 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。 - var prevTop = msgsEl.scrollTop; - commitChatList(msgsEl, html); - if (state.chatStick !== false) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } else { - msgsEl.scrollTop = prevTop; - } - updateChatBadge(); - } - - function updateChatBadge() { - var badge = document.getElementById("chat-stage"); - if (!badge) return; - badge.textContent = state.chatStage || ""; - badge.style.display = "none"; - } - - // 流式渲染分发:流式增量(只更新最后一条正文)vs 全量重建 - var _rerenderTimer = null; - function rerenderChat(full) { - // 流式中且非强制全量:走增量路径(防抖合并 chunk,只更新最后一条消息节点) - if (!full && state.chatLoading) { - var msgs = state.messages; - var last = msgs.length ? msgs[msgs.length - 1] : null; - if (last && last.role === "assistant" && !last._final) { - if (_rerenderTimer) return; // 已有排程的增量更新 - _rerenderTimer = setTimeout(() => { - _rerenderTimer = null; - renderChatStreamChunk(); - }, 90); - return; - } - } - // 非流式(完成/工具/历史变化):全量重渲(含防抖合并) - if (_rerenderTimer) clearTimeout(_rerenderTimer); - _rerenderTimer = setTimeout(() => { - _rerenderTimer = null; - renderChat(); - if (full) { - renderChatStarmap(); - renderTerminals(); - renderCmdHistory(); - } - }, 90); - } - - // 流式增量渲染(移植自 GUI):仅更新最后一条 assistant 消息的正文与思考预览, - // 不重建 DOM。正文节流 parse(>200 字符或 >300ms 才 renderMd),小增量纯文本追加。 - function renderChatStreamChunk() { - var msgsEl = document.getElementById("chat-msgs"); - var last = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (!msgsEl || !last) return; - var el = msgsEl.lastElementChild; - if (!el) { - renderChat(); - return; - } - var textEl = el.querySelector(".msg-bubble .text"); - var c = last.content || ""; - if (textEl && c) { - var now = Date.now(); - var lastParse = el.__lastParse || 0; - var lastLen = el.__lastLen || 0; - if (c.length - lastLen > 200 || now - lastParse > 300) { - textEl.innerHTML = renderMd(c); - el.__lastParse = now; - el.__lastLen = c.length; - } else { - var tail = c.slice(lastLen); - if (tail) textEl.appendChild(document.createTextNode(tail)); - el.__lastLen = c.length; - } - if (state.chatStick !== false) { - try { - msgsEl.scrollTop = msgsEl.scrollHeight; - } catch (e) {} - } - return; - } - // 思考预览更新(流式中折叠,只刷 preview 文本) - var rcPrev = el.querySelector(".reasoning-preview"); - if (rcPrev && last.reasoning_content) { - rcPrev.textContent = last.reasoning_content - .replace(/[\s\n]+/g, " ") - .slice(0, 60); - if (state.chatStick !== false) { - try { - msgsEl.scrollTop = msgsEl.scrollHeight; - } catch (e) {} - } - return; - } - // 结构变化兜底:全量 - renderChat(); - } - function toggleToolCall(el) { - var d = el.querySelector(".tc-detail"); - if (!d) return; - var open = d.style.display !== "none"; - d.style.display = open ? "none" : "block"; - if (open) { - el.classList.remove("open"); - } else { - el.classList.add("open"); - } - } - - function renderReasoningCard(text, isStreaming) { - var preview = - typeof marked !== "undefined" - ? text.replace(/[\s\n]+/g, " ").slice(0, 60) - : escHtml(text) - .replace(/<[^>]+>/g, " ") - .slice(0, 60); - return ( - '
' + - '
' + - '' + - '' + - (isStreaming - ? __("思考中...", "Thinking...") - : __("思考", "Thinking")) + - "" + - '' + RT_ICO.caret + "
" + - '
' + - (isStreaming - ? '
' + - escHtml(preview) + - '
' - : '
' + renderMd(text) + "
") + - "
" - ); - } - function toggleReasoning(el) { - var card = el.closest(".reasoning-card"); - if (!card) return; - var body = card.querySelector(".reasoning-body"); - if (!body) return; - var open = body.style.display !== "none"; - body.style.display = open ? "none" : "block"; - if (open) { - card.classList.remove("open"); - } else { - card.classList.add("open"); - } - } - - // renderChatStarmap:把星图挂到**当前活跃的容器**上。 - // 容器优先级:独立星图页签 > 总览页小图 > 聊天面板。 - // 同一套 three.js renderer 在容器间搬运 canvas,不重建场景。 - function renderChatStarmap() { - var cont = starmapActiveContainer(); - if (!cont) return; - if ( - window._THREE_FAILED || - (!window.THREE && window._THREE_FAILED !== undefined) - ) { - cont.innerHTML = - '

' + - __( - "3D 星图不可用(CDN 加载失败)", - "Star map unavailable (CDN load failed)", - ) + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - return; - } - if (!window.THREE) { - cont.innerHTML = - '
'; - state.starmapInit = false; - state.starmapLoading = false; - return; - } - if (cont.querySelector("canvas")) { - var rect = cont.getBoundingClientRect(); - if (starmapRen && rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - return; - } - if (state.starmapInit) { - if (starmapRen) { - var rect = cont.getBoundingClientRect(); - if (rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } else { - // starmapRen was destroyed (e.g. re-render cycle), restart - state.starmapInit = false; - state.starmapLoading = false; - } - return; - } - if (state.starmapLoading) return; - state.starmapLoading = true; - loadChatStarmapData(); - } - - async function loadChatStarmapData() { - try { - var resp = await api("/memory/graph"); - if ( - !resp || - !resp.success || - !resp.data || - !resp.data.nodes || - resp.data.nodes.length === 0 - ) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("暂无记忆数据", "No memory data") + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - return; - } - var d = resp.data; - starmapNodes = d.nodes || []; - starmapEdges = d.edges || []; - state.starmapInit = true; - state.starmapLoading = false; - initChatStarmap(); - } catch (e) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("加载失败", "Load failed") + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - } - } - - function initChatStarmap() { - // 容器可以是独立星图页签 / 总览页 / 聊天面板 —— 同一套 renderer - // 会在多个容器间搬运 canvas(appendChild 即可)。 - var cont = starmapActiveContainer(); - if (!cont) return; - var rect = cont.getBoundingClientRect(); - var w = Math.max(rect.width || 300, 100); - var h = Math.max(rect.height || 250, 100); - if (starmapRen) { - starmapRen.setSize(w, h); - // 换父节点前先把旧角标从旧容器里拨掉,否则它会残留在旧位置。 - if (starmapLabelEl && starmapLabelEl.parentElement === cont) { - starmapLabelEl.style.display = "none"; - } - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - return; - } - starmapScene = new THREE.Scene(); - starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015); - starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000); - starmapCam.position.set(0, 20, 40); - starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true }); - starmapRen.setSize(w, h); - starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - starmapRen.setClearColor(0x0a0a1a, 1); - cont.innerHTML = ""; - cont.appendChild(starmapRen.domElement); - starmapCtrl = new THREE.OrbitControls( - starmapCam, - starmapRen.domElement, - ); - starmapCtrl.enableDamping = true; - starmapCtrl.dampingFactor = 0.05; - starmapCtrl.rotateSpeed = 0.5; - starmapCtrl.zoomSpeed = 0.8; - var al = new THREE.AmbientLight(0x444466, 0.6); - starmapScene.add(al); - var dl = new THREE.DirectionalLight(0xffffff, 0.8); - dl.position.set(50, 100, 50); - starmapScene.add(dl); - createStarField(); - createNebula(); - // 共享几何(低规格):所有节点/光晕球复用这两份。 - starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); - starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); - buildChatStarmapGraph(); - starmapRen.domElement.addEventListener("mousemove", onStarmapMove); - starmapRen.domElement.addEventListener("click", onStarmapClick); - window.addEventListener("resize", onStarmapResize); - if (starmapRaf) cancelAnimationFrame(starmapRaf); - starmapAnimate(); - // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 - // 只在星图真正初始化后启动,避免在隐藏页签空跑。 - starmapStartActivity(); - } - - // starmapStartActivity 启动两路活动轮询(幂等)。 - function starmapStartActivity() { - if (state.starmapPulseTimer) return; - starmapPullActivity(); - starmapPullPulse(); - state.starmapPulseTimer = setInterval(() => { - starmapPullActivity(); - }, 3000); - var pulseTimer = setInterval(() => { - starmapPullPulse(); - }, 10000); - // 两个 id 合并到一个字段会导致 setInterval 被覆盖,这里分开记。 - state.starmapActivityTimer = pulseTimer; - } - - function buildChatStarmapGraph() { - starmapNodeMeshes.forEach((m) => { - starmapScene.remove(m); - }); - starmapEdgeLines.forEach((l) => { - starmapScene.remove(l); - }); - starmapNodeMeshes = []; - starmapEdgeLines = []; - if (starmapNodes.length === 0) return; - // Calculate node degrees for leaf node detection - var nodeDegs = {}; - starmapNodes.forEach((n) => { - nodeDegs[n.id] = 0; - }); - starmapEdges.forEach((e) => { - nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1; - nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1; - }); - var nodeMap = {}; - starmapNodes.forEach((n) => { - nodeMap[n.id] = n; - }); - var sorted = starmapNodes.slice().sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0)); - var mc = sorted.map((n) => n.mention_count || 0); - var maxMc = Math.max(...mc, 1), - minMc = Math.min(...mc, 0), - rng = maxMc - minMc || 1; - // Layout positions - var pos = {}; - var baseR = 15, - maxR = 80; - var total = sorted.length; - var acc = 0; - sorted.forEach((n, i) => { - var m = n.mention_count || 0, - mn = rng > 0 ? (m - minMc) / rng : 0; - var radius = baseR + mn * (maxR - baseR); - var baseStep = (Math.PI * 2) / total; - var extra = mn * baseStep * 2; - var angle = acc + extra / 2; - acc += baseStep + extra; - pos[n.id] = { - x: radius * Math.cos(angle), - y: (Math.random() - 0.5) * (10 + mn * 20), - z: radius * Math.sin(angle), - mn: mn, - rad: radius, - }; - }); - // Leaf nodes (degree 1) reposition near parent - sorted.forEach((n) => { - var deg = nodeDegs[n.id] || 0; - if (deg !== 1) return; - var edge = starmapEdges.find((e) => e.source_id === n.id || e.target_id === n.id); - if (!edge) return; - var parentId = - edge.source_id === n.id ? edge.target_id : edge.source_id; - if (!pos[parentId]) return; - var pp = pos[parentId]; - var m = n.mention_count || 0, - mn = rng > 0 ? (m - minMc) / rng : 0; - var off = 6 + mn * 8 + Math.random() * 4; - var a2 = Math.random() * Math.PI * 2; - pos[n.id] = { - x: pp.x + off * Math.cos(a2), - y: pp.y + (Math.random() - 0.5) * (4 + mn * 6), - z: pp.z + off * Math.sin(a2), - mn: mn, - rad: off, - }; - }); - // Force-directed simulation - for (var it = 0; it < 50; it++) { - var ids = Object.keys(pos); - // Repulsion - for (var i = 0; i < ids.length; i++) { - for (var j = i + 1; j < ids.length; j++) { - var a = pos[ids[i]], - b = pos[ids[j]]; - var dx = a.x - b.x, - dy = a.y - b.y, - dz = a.z - b.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var rf = 0.5 + (a.mn + b.mn) * 0.5; - if (d < 25) { - var force = (0.06 * rf) / Math.max(d, 0.5); - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - } - } - // Attraction along edges - starmapEdges.forEach((e) => { - var a = pos[e.source_id], - b = pos[e.target_id]; - if (!a || !b) return; - var dx = b.x - a.x, - dy = b.y - a.y, - dz = b.z - a.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3); - if (d > 20) { - var force = 0.04 * af; - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - }); - // Centering constraint - ids.forEach((id) => { - var p = pos[id]; - var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); - var maxA = maxR * 1.5; - if (dist > maxA) { - var s = maxA / dist; - p.x *= s; - p.y *= s; - p.z *= s; - } - }); - } - // Create nodes - // - // ★ 低规格渲染(1151 节点实测后定的方案):保留**全部**节点, - // 但把每节点开销从「一个独立球 + 一个独立球光晕 + 一张 256x64 - // 文字贴图」降到「共享几何 + 共享材质 + 无常驻文字」。 - // - // 改之前的实际开销(生产实例 1151 节点): - // - SphereGeometry(16,12) 主球 + 同样规格的光晕球 = 2302 个独立 - // BufferGeometry,共约 88 万三角形 - // - 每节点一张 256x64 CanvasTexture = 1151 张 + - // 1151 个纹理,仅文字就吃约 72MB 显存 - // 这两样在「星图只是个展示」的前提下纯属浪费:星图全图远看根本 - // 读不清标签(256x64 贴在半径 0.5~2.5 的球上,本来就糊)。 - // - // 现在:共享一份 SphereGeometry(8,6)(约 84 三角形/节点), - // 颜色靠每 mesh 的 material.color(材质本身仍每节点一份, - // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 - // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— - // 文字清晰度反而比 3D 贴图好,且零显存。 - starmapNodes.forEach((n) => { - var p = pos[n.id]; - if (!p) return; - var mn = n.mention_count || 0, - mnr = rng > 0 ? (mn - minMc) / rng : 0; - var rad = 0.5 + mnr * 2.0; - // 服务端 type 是首字母大写("Concept" / "Person" …), - // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 - // 这里统一小写归一化,并补上服务端实际会产出的类型。 - var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; - var ei = 0.3 + mnr * 0.7; - var mat = new THREE.MeshPhongMaterial({ - color: col, - emissive: col, - emissiveIntensity: ei, - shininess: 30, - }); - var mesh = new THREE.Mesh(starmapGeo, mat); - mesh.position.set(p.x, p.y, p.z); - mesh.scale.setScalar(rad / 0.5); // 共享几何半径 0.5,按需缩放 - mesh.userData.nodeData = n; - mesh.userData.nodeId = n.id; - mesh.userData.baseEmissive = ei; - mesh.userData.baseScale = rad / 0.5; - mesh.userData.baseGlow = 0.12 + mnr * 0.08; - // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) - var gm = new THREE.MeshBasicMaterial({ - color: col, - transparent: true, - opacity: mesh.userData.baseGlow, - side: THREE.BackSide, - blending: THREE.AdditiveBlending, - }); - var gs = new THREE.Mesh(starmapGlowGeo, gm); - gs.scale.setScalar(rad * 1.2 + mnr * 0.5); - mesh.add(gs); - mesh.userData.glowSphere = gs; - // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 - starmapScene.add(mesh); - starmapNodeMeshes.push(mesh); - }); - // Create edges - // - // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial - // (866 个 draw call)。改为**按关系类型分组**的少量 LineSegments: - // 同类型边合并成一个几何体,draw call 从 866 降到「关系类型数」 - // (生产实例实测 6 种左右)。 - // - // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 - // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 - var edgeByColor = {}; - starmapEdges.forEach((e) => { - var a = pos[e.source_id], - b = pos[e.target_id]; - if (!a || !b) return; - var col = - smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; - var k = String(col); - if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] }; - edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z); - }); - Object.keys(edgeByColor).forEach((k) => { - var g = edgeByColor[k]; - if (!g.pts.length) return; - var geo = new THREE.BufferGeometry(); - geo.setAttribute( - "position", - new THREE.Float32BufferAttribute(g.pts, 3), - ); - var mat = new THREE.LineBasicMaterial({ - color: g.col, - transparent: true, - opacity: 0.4, - }); - var seg = new THREE.LineSegments(geo, mat); - starmapScene.add(seg); - starmapEdgeLines.push(seg); - }); - } - - // 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 - // POST 结束 ≠ 回合结束:agent 可能还在生成(排队+长生成),提前复位 - // chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。 - function endChatTurn() { - if (!state.chatLoading) return; - state.chatLoading = false; - state.chatStage = ""; - if (state._turnWatchdog) { - clearTimeout(state._turnWatchdog); - state._turnWatchdog = null; - } - var btn = document.getElementById("chat-send-btn"); - if (btn) { - btn.disabled = false; - btn.textContent = __("发送", "Send"); - } - var sb = document.getElementById("chat-stop-btn"); - if (sb) sb.style.display = "none"; - rerenderChat(true); - } - - // 回合看门狗:POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失), - // 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 - function armTurnWatchdog() { - if (state._turnWatchdog) clearTimeout(state._turnWatchdog); - state._turnWatchdog = setTimeout(() => { - state._turnWatchdog = null; - if (state.chatLoading) { - endChatTurn(); - toast( - __( - "长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看", - "No reply received for a long time; the reply may have been generated, refresh to check", - ), - true, - ); - } - }, 120000); - } - - // 上传文件并注入 agent:multipart POST /api/v1/chat/file。 - // 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; - // 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 - // 拖拽文件到聊天区即发送(可选:先在输入框写附言) - function initChatDragDrop() { - var panel = document.getElementById("chat-panel-chat"); - if (!panel || panel.__dnd) return; - panel.__dnd = true; - panel.addEventListener("dragover", (e) => { - e.preventDefault(); - panel.style.outline = "2px dashed var(--accent, #4a90d9)"; - }); - panel.addEventListener("dragleave", () => { - panel.style.outline = ""; - }); - panel.addEventListener("drop", (e) => { - e.preventDefault(); - panel.style.outline = ""; - if (e.dataTransfer.files && e.dataTransfer.files.length) { - sendChatFile(e.dataTransfer.files[0]); - } - }); - // 粘贴截图/复制的文件直接发送 - document.addEventListener("paste", (e) => { - var chatVisible = - document.getElementById("chat-panel-chat") && - document.getElementById("chat-input"); - if (!chatVisible) return; - var items = e.clipboardData && e.clipboardData.files; - if (items && items.length && document.activeElement !== null) { - sendChatFile(items[0]); - } - }); - } - - async function sendChatFile(fileObj, extraText) { - if (!fileObj || state.chatLoading) return; - var inp = document.getElementById("chat-input"); - var message = (extraText || inp.value || "").trim(); - if (inp) inp.value = ""; - var fd = new FormData(); - fd.append("file", fileObj); - if (message) fd.append("message", message); - state.messages.push({ - role: "user", - content: message, - attachment: { - // 与服务端的 attType 判定保持一致(image/audio/file), - // 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。 - type: /^image\//.test(fileObj.type) - ? "image" - : /^audio\//.test(fileObj.type) - ? "audio" - : "file", - url: URL.createObjectURL(fileObj), - name: fileObj.name, - size: fileObj.size, - }, - }); - rerenderChat(true); - state.chatLoading = true; - state.chatStage = __("等待AI回复...", "Waiting for AI..."); - rerenderChat(true); - try { - await api("/chat/file", { method: "POST", body: fd, rawBody: true }); - // 回复经 SSE 流式到达,这里无需处理响应体 - } catch (e) { - toast( - __("文件发送失败:" + e.message, "File send failed: " + e.message), - true, - ); - state.chatLoading = false; - endChatTurn(); - } - } - - async function sendChat() { - var inp = document.getElementById("chat-input"); - var btn = document.getElementById("chat-send-btn"); - var stopBtn = document.getElementById("chat-stop-btn"); - var text = inp.value.trim(); - if (!text || state.chatLoading) return; - state.chatStick = true; - state.chatFinalIdx = -1; - state.messages.push({ role: "user", content: text }); - inp.value = ""; - rerenderChat(true); - state.chatLoading = true; - state.chatStage = __("等待AI回复...", "Waiting for AI..."); - btn.disabled = true; - btn.textContent = ""; - if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止 - rerenderChat(true); - // 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。 - try { - var ctrl = new AbortController(); - var ackTimer = setTimeout(() => { - ctrl.abort(); - }, 15000); - var r = null; - try { - r = await api("/chat", { - method: "POST", - body: JSON.stringify({ message: text }), - signal: ctrl.signal, - }); - } catch (ackErr) { - // 同步超时/失败:不阻塞 UI,等 SSE 兑底;明确提示"可能已发送" - console.warn("[sendChat] trigger failed: " + ackErr.message); - toast( - __( - "请求超时(可能已发送,请稍候或在收到回复前勿重复发送)", - "Request timeout (may have been sent; wait for reply before resending)", - ), - true, - ); - r = null; - } finally { - clearTimeout(ackTimer); - } - var last = state.messages[state.messages.length - 1]; - if (r && r.response) { - if (last && last.role === "assistant" && last._streaming) { - last.content = r.response || __("(无响应)", "(no response)"); - last._grow = true; - if (!last.reasoning_content) { - last.reasoning_content = r.reasoning_content || ""; - } - last._final = true; - delete last._streaming; - } else { - state.messages.push({ - role: "assistant", - content: r.response || __("(无响应)", "(no response)"), - reasoning_content: r.reasoning_content, - tool_calls: - last && last.role === "assistant" && last.tool_calls - ? last.tool_calls - : [], - _final: true, - _grow: true, - }); - } - state.chatFinalIdx = state.messages.length - 1; - } - rerenderChat(true); - } catch (e) { - // 真实错误(非受理超时):展示错误信息 - if (!String(e.message || "").includes("aborted")) { - state.messages.push({ - role: "assistant", - content: __("错误: ", "Error: ") + e.message, - _final: true, - }); - rerenderChat(true); - toast(__("请求失败: ", "Request failed: ") + e.message, true); - } - } finally { - if (r && r.response) { - // 同步兜底已拿到完整回复:回合结束 - endChatTurn(); - } else { - // 触发式受理(POST 已 abort/失败):回合仍打开,等 SSE 流式渲染; - // 由 agent_output final / reset 帧 / watchdog 收尾 - armTurnWatchdog(); - } - } - } - - // 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前 - // 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。 - async function interruptChat() { - try { - await api("/chat/interrupt", { - method: "POST", - body: JSON.stringify({}), - }); - toast(__("已发送中断信号", "Interrupt signal sent")); - } catch (e) { - toast(__("中断失败: ", "Interrupt failed: ") + e.message, true); - } - } - - async function queryMemoryChat() { - var q = document.getElementById("mem-query")?.value; - var r = document.getElementById("mem-result-chat"); - if (!r || !q) return; - r.innerHTML = '
'; - try { - var data = await api( - "/memory?q=" + encodeURIComponent(q) + "&depth=2", - ); - r.innerHTML = - '
' +
-            escHtml(JSON.stringify(data, null, 2)) +
-            "
"; - } catch (e) { - r.innerHTML = - '

' + - __("查询失败: ", "Query failed: ") + - escHtml(e.message) + - "

"; - } - } - - async function queryMemoryContext() { - var q = document.getElementById("ctx-query")?.value; - var r = document.getElementById("ctx-result"); - if (!r) return; - r.innerHTML = '
'; - try { - var data = await api( - "/memory/context?q=" + encodeURIComponent(q || ""), - ); - var ctx = data?.context || __("无上下文", "No context"); - var summary = data?.summary || ""; - var entities = data?.entities || []; - var tk = data?.token_estimate || 0; - var html = '
'; - if (summary) - html += - '
' + - __("摘要", "Summary") + - '' + - escHtml(summary) + - "
"; - html += - '
Token ' + - __("预估", "Estimate") + - '' + - tk + - "
"; - if (entities.length) { - html += - '
' + - __("实体", "Entities") + - '' + - entities - .map((e) => escHtml(e.name || e.id || "")) - .join(", ") + - "
"; - } - html += - '
' +
-            escHtml(ctx) +
-            "
"; - r.innerHTML = html; - } catch (e) { - r.innerHTML = - '

' + - __("获取失败: ", "Get failed: ") + - escHtml(e.message) + - "

"; - } - } - - // 知识库面板:搜索 / 创建 / 删除 / 刷新计数。 - // - // 此前三处问题:搜索把裸 JSON 直接 stringify 丢进
(用户看到一坨
-      // 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
-      // 完仍是旧值);没有任何删除入口,也没有媒体上传。
-      function knowEsc(v) {
-        return String(v == null ? "" : v)
-          .replace(/&/g, "&")
-          .replace(//g, ">");
-      }
-
-      async function refreshKnowledgeCount() {
-        try {
-          var d = await api("/knowledge");
-          var el = document.getElementById("know-count");
-          if (el && d && d.names) el.textContent = d.names.length;
-          // 稠密路(多模态)状态提示
-          var hint = document.getElementById("know-media-hint");
-          if (hint && d && d.dense) {
-            if (d.dense.enabled) {
-              hint.textContent =
-                __("多模态已就绪 ", "Multimodal ready ") +
-                (d.dense.ready || 0) +
-                "/" +
-                ((d.dense.ready || 0) + (d.dense.stale || 0));
-            } else {
-              hint.textContent = __(
-                "未接入多模态:图片只记录不参与召回",
-                "Multimodal off: images recorded but not searchable",
-              );
-            }
-          }
-        } catch (e) {
-          /* 计数刷新失败不该打断用户操作 */
-        }
-      }
-
-      function renderKnowledgeResults(views, q) {
-        if (!views || !views.length) {
-          return (
-            '

' + - __("未找到相关知识", "No matching knowledge") + - "

" - ); - } - var html = ""; - views.forEach((v) => { - html += '
'; - html += '
'; - html += '' + knowEsc(v.name) + ""; - if (v.size) { - html += '' + v.size + " B"; - } - html += - '"; - html += "
"; - if (v.media && v.media.length) { - html += '
'; - v.media.forEach((m) => { - var kind = m.kind || "file"; - html += - '[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…"; - }); - html += "
"; - } - if (v.preview) { - html += - '
' + - knowEsc(v.preview) + - "
"; - } - html += "
"; - }); - return html; - } - - // 知识库分类树浏览。 - // - // 数据来自 /knowledge/tree(只读树接口)。当前分类存 state, - // 搜索时自动带上 —— 这样"先定位分类再检索"这个更准的用法在 UI 上 - // 是一步的事,而不是要求用户手打分类名。 - var _knowCat = ""; // 当前分类的完整 path;"" = 全库 - - function renderKnowTree(node, depth) { - var html = ""; - // 本节点的条目 - (node.items || []).forEach((it) => { - html += - '
' + - '▸ ' + - '' + - knowEsc(it.name) + - "" + - (it.size ? ' ' + it.size + "B" : "") + - (it.media && it.media.length - ? ' [' + it.media.length + __(" 媒体", " media") + "]" - : "") + - "
"; - }); - // 子分类 - (node.children || []).forEach((c) => { - var isCur = _knowCat === c.path; - var mark = isCur ? "● " : ""; - html += - '
' + - mark + - knowEsc(c.name) + - ' ' + - c.item_count + - "/" + - c.total_count + - "
"; - html += renderKnowTree(c, depth + 1); - }); - return html; - } - - async function loadKnowledgeTree() { - var box = document.getElementById("know-tree"); - if (!box) return; - box.innerHTML = '
'; - try { - var d = await api("/knowledge/tree?items=1"); - if (!d || !d.tree) { - box.innerHTML = - '

' + __("暂无分类", "No categories yet") + "

"; - return; - } - box.innerHTML = renderKnowTree(d.tree, 0); - } catch (e) { - box.innerHTML = - '

' + __("加载分类失败: ", "Load categories failed: ") + knowEsc(e.message) + "

"; - } - } - - function selectKnowledgeCategory(path) { - _knowCat = path || ""; - var lbl = document.getElementById("know-cat-scope"); - if (lbl) { - lbl.textContent = _knowCat - ? __("范围:", "scope: ") + _knowCat - : __("范围:全库", "scope: all"); - } - loadKnowledgeTree(); - // 立刻按新范围搜一次,省一次点击 - var q = document.getElementById("know-query")?.value; - if (q) searchKnowledgeChat(); - } - - function clearKnowledgeCategory() { - selectKnowledgeCategory(""); - } - - async function searchKnowledgeChat() { - var q = document.getElementById("know-query")?.value; - var cat = _knowCat || ""; - var r = document.getElementById("know-result-chat"); - if (!r) return; - if (!q) { - r.innerHTML = - '

' + - __("请输入查询关键词", "Enter a keyword") + - "

"; - return; - } - r.innerHTML = '
'; - try { - var url = "/knowledge?q=" + encodeURIComponent(q); - if (cat) url += "&category=" + encodeURIComponent(cat); - var data = await api(url); - r.innerHTML = renderKnowledgeResults(data && data.results, q); - } catch (e) { - r.innerHTML = - '

' + __("搜索失败: ", "Search failed: ") + knowEsc(e.message) + "

"; - } - } - - async function deleteKnowledge(name) { - if ( - !confirm( - __("确定删除知识「", "Delete knowledge \"") + name + __("」?此操作不可撤销。", "\"? This cannot be undone."), - ) - ) { - return; - } - try { - await api("/knowledge?name=" + encodeURIComponent(name), { method: "DELETE" }); - toast(__("知识「", 'Knowledge "') + name + __("」已删除", '" deleted')); - refreshKnowledgeCount(); - searchKnowledgeChat(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - async function createKnowledgeChat() { - var name = document.getElementById("know-name")?.value; - var content = document.getElementById("know-content")?.value; - var fileInput = document.getElementById("know-media"); - var files = fileInput && fileInput.files ? fileInput.files : null; - if (!name) { - toast(__("名称不能为空", "Name is required"), true); - return; - } - if ((!content || !content.trim()) && (!files || !files.length)) { - toast( - __("内容与媒体至少要有一项", "Content or media is required"), - true, - ); - return; - } - try { - var r; - if (files && files.length) { - // 有文件走 multipart:服务端按**探测到的真实类型**分流, - // 图片/音视频入媒体库并按 digest 挂到条目上,文本存正文。 - var fd = new FormData(); - fd.append("name", name); - if (content) fd.append("content", content); - for (var i = 0; i < files.length; i++) fd.append("file", files[i]); - r = await api("/knowledge", { method: "POST", body: fd }); - } else { - r = await api("/knowledge", { - method: "POST", - body: JSON.stringify({ name: name, content: content }), - }); - } - var msg = __("知识「", 'Knowledge "') + name + __("」已创建", '" created'); - if (r && r.media) msg += __(",含 ", " with ") + r.media + __(" 个媒体", " media item(s)"); - if (r && r.rejected && r.rejected.length) { - msg += __(";", "; ") + r.rejected.length + __(" 项被跳过", " skipped"); - } - toast(msg); - document.getElementById("know-name").value = ""; - document.getElementById("know-content").value = ""; - if (fileInput) fileInput.value = ""; - refreshKnowledgeCount(); - } catch (e) { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - } - } - - function switchChatPanel(tab, el) { - // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, - // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 - if (tab === "knowledge") { - refreshKnowledgeCount(); - var lbl = document.getElementById("know-cat-scope"); - if (lbl && !lbl.textContent) { - lbl.textContent = _knowCat ? __("范围:", "scope: ") + _knowCat : __("范围:全库", "scope: all"); - } - loadKnowledgeTree(); - } - var panels = { - chat: document.getElementById("chat-panel-chat"), - starmap: document.getElementById("chat-panel-starmap"), - terminal: document.getElementById("chat-panel-terminal"), - cmd: document.getElementById("chat-panel-cmd"), - memory: document.getElementById("chat-panel-memory"), - context: document.getElementById("chat-panel-context"), - knowledge: document.getElementById("chat-panel-knowledge"), - }; - Object.keys(panels).forEach((k) => { - var p = panels[k]; - if (p) p.classList.toggle("active", k === tab); - }); - if (el) { - var parent = el.parentElement; - if (parent) { - Array.from(parent.children).forEach((ch) => { - ch.classList.remove("active"); - }); - el.classList.add("active"); - } - } - if (tab === "starmap") { - if (!state.starmapInit) { - state.starmapLoading = false; - renderChatStarmap(); - } else if (starmapRen) { - var cont = document.getElementById("sm-container-chat"); - if (cont) { - var rect = cont.getBoundingClientRect(); - if (rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - if (!cont.contains(starmapRen.domElement)) - cont.appendChild(starmapRen.domElement); - } - } - } - if (tab === "chat") { - state.chatStick = true; - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - try { - msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" }); - } catch (e) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - } - } - if (tab === "context") queryMemoryContext(); - } - - // 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 - var CHAT_PAGE_SIZE = 40; - async function loadChatHistory() { - try { - var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); - if (data && data.messages) { - state.messages = data.messages; - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatTotal = typeof data.total === "number" ? data.total : data.messages.length; - state.chatHasMore = !!data.has_more; - state.chatLastSeq = historyLastSeq(data); - } - } catch (e) {} - } - - // historyLastSeq 从一次 /chat/history 响应里取出「已见到的最大 seq」: - // 优先用服务端给的 last_seq,缺了就取消息里的最大值。 - function historyLastSeq(data) { - if (!data) return 0; - if (typeof data.last_seq === "number") return data.last_seq; - var mx = 0; - (data.messages || []).forEach((m) => { - if (m && m.seq > mx) mx = m.seq; - }); - return mx; - } - - // applyServerMessages 把服务端消息并进 state.messages,按 seq 对账: - // - 同 seq 已存在 → 原地替换(工具调用/最终文本是原地更新); - // - 不存在 → 追加(若末尾是无 seq 的乐观消息且 role+content 一致,则替换它, - // 避免"自己刚发的那条"重复成两条)。 - // tailOnly=true 时只做原地更新与"比本地新才追加",不把尾探测当成新消息。 - // @returns {boolean} 是否真的改动了 state.messages - function applyServerMessages(list, tailOnly) { - if (!list || !list.length) return false; - var msgs = state.messages; - var changed = false; - function carry(prev, sm) { - if (prev && prev._final) sm._final = true; - if (prev && prev._grow) sm._grow = true; - return sm; - } - list.forEach((sm) => { - if (!sm) return; - var seq = sm.seq; - var found = -1; - for (var j = msgs.length - 1; j >= 0 && j >= msgs.length - 12; j--) { - if (seq && msgs[j] && msgs[j].seq === seq) { - found = j; - break; - } - } - if (found >= 0) { - if (JSON.stringify(msgs[found]) !== JSON.stringify(sm)) { - msgs[found] = carry(msgs[found], sm); - changed = true; - } - return; - } - if (tailOnly) { - var lastS = msgs.length ? msgs[msgs.length - 1].seq : 0; - if (seq && (!lastS || seq > lastS)) { - msgs.push(sm); - changed = true; - } - return; - } - if (msgs.length) { - var last = msgs[msgs.length - 1]; - if ( - !last.seq && - (last.role || "") === (sm.role || "") && - (last.content || "") === (sm.content || "") - ) { - msgs[msgs.length - 1] = carry(last, sm); - changed = true; - return; - } - } - msgs.push(sm); - changed = true; - }); - list.forEach((sm) => { - if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq; - }); - if (changed) rerenderChat(); - return changed; - } - - // pollChatIncremental 轮询「自上次以来新增了什么」。 - // - // 这就是「暴露数据查询 api,前端轮询后 patch 视图」那条路:after=游标 - // 只拿增量,再单独探一次尾部做原地更新(工具调用/最终文本是原地改的, - // 不会产生新 seq,只靠 after 拿不到)。视图更新走 commitChatList 的 - // keyed 对账,未变消息节点一个字节都不动 —— 闪烁由此消失。 - function pollChatIncremental() { - var after = state.chatLastSeq || 0; - if (!after) { - // 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑 - return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => { - state.chatLastSeq = historyLastSeq(data); - return mergeChatFromHistory(data); - }); - } - return api("/chat/history?after=" + after) - .then((data) => { - if (!data) return; - state.chatLastSeq = historyLastSeq(data) || after; - applyServerMessages(data.messages || [], false); - return api("/chat/history?limit=1"); - }) - .then((tail) => { - if (tail && tail.messages && tail.messages.length) { - applyServerMessages(tail.messages.slice(-1), true); - } - }); - } - - // loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 - // 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 - var _loadingOlder = false; - async function loadOlderChat() { - if (_loadingOlder || !state.chatHasMore) return; - _loadingOlder = true; - var msgsEl = document.getElementById("chat-msgs"); - var prevH = msgsEl ? msgsEl.scrollHeight : 0; - var prevTop = msgsEl ? msgsEl.scrollTop : 0; - try { - var before = state.chatOffset || 0; - if (before <= 0) { - state.chatHasMore = false; - return; - } - var data = await api( - "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, - ); - if (data && data.messages && data.messages.length) { - state.messages = data.messages.concat(state.messages); - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatHasMore = !!data.has_more; - state.chatStick = false; // 向上翻页时不自动粘底 - renderChat(); - if (msgsEl) { - // 补偿新增内容高度,保持用户视觉位置不变 - msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); - } - } else { - state.chatHasMore = false; - } - } catch (e) { - } finally { - _loadingOlder = false; - } - } - - // syncChatFromHistory 增量同步:先做一次极轻的"尾巴探测",只有尾巴变了才拉整页。 - // - // 原先每 30s(以及每次 SSE 报错)都直接拉一页 40 条:本地实测 180KB、 - // 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。 - // 探测只需 1 条(约几 KB),尾巴一致就直接跳过。 - var _syncingChat = false; - // syncChatFromHistory 保留旧名(多处调用点):内部改走游标增量轮询。 - function syncChatFromHistory() { - if (_syncingChat) return Promise.resolve(); - _syncingChat = true; - return pollChatIncremental() - .catch(() => {}) - .then(() => { - _syncingChat = false; - }); - } - - // mergeChatFromHistory 把服务端的一页历史并进本地:只追加新消息,不重建已有节点。 - // 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页 - // 凭空消失、视口跳回顶部)。 - function mergeChatFromHistory(data) { - if (!data || !data.messages || data.messages.length === 0) return; - var serverMsgs = data.messages; - var localMsgs = state.messages; - // 首次加载(空列表)→ 全量赋值 - if (localMsgs.length === 0) { - state.messages = serverMsgs; - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatHasMore = !!data.has_more; - rerenderChat(true); - return; - } - // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, - // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 - var lastLocal = localMsgs[localMsgs.length - 1]; - var lastServer = serverMsgs[serverMsgs.length - 1]; - var localContent = lastLocal.content || lastLocal.Content || ""; - var serverContent = lastServer.content || lastServer.Content || ""; - // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, - // 依赖 content+role+time 足以识别) - if (localContent === serverContent) { - // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) - if (lastServer.role === "assistant" && localContent !== serverContent && serverContent) { - lastLocal.content = serverContent; - if (lastServer.ReasoningContent) lastLocal.reasoning_content = lastServer.ReasoningContent; - var msgsEl0 = document.getElementById("chat-msgs"); - if (msgsEl0 && msgsEl0.lastElementChild) { - var textEl0 = msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); - if (textEl0) textEl0.innerHTML = renderMd(serverContent); - } - } - return; - } - // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 - // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) - // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 - var overlap = -1; - for (var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); k >= 1; k--) { - var sMsg = serverMsgs[serverMsgs.length - 1 - k]; - var lMsg = localMsgs[localMsgs.length - 1 - k]; - if (lMsg && (lMsg.content || "") === (sMsg.content || "") && lMsg.role === sMsg.role) { - overlap = k; - break; - } - } - var newMsgs; - if (overlap >= 0) { - // 本地末尾 overlap 条在服务端页里,之后的部分是新增 - newMsgs = serverMsgs.slice(serverMsgs.length - overlap); - if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) - } else { - // 找不到重合点:**不能**直接拿服务端页覆盖本地。 - // 服务端只回一页,本地翻上来的旧页更长;覆盖会同时造成两个后果: - // 用户翻过的旧消息凭空消失、容器变矮后视口被夹回顶部。 - // 只有在服务端页不短于本地时才整体替换(那种情况下不丢内容)。 - if (serverMsgs.length >= localMsgs.length) { - state.messages = serverMsgs; - rerenderChat(true); - } - return; - } - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - var aiAvatar = '小宅'; - var userAvatar = ''; - newMsgs.forEach((m) => { - var role = m.role || m.Role || "user"; - var c = m.content || m.Content || ""; - if (role === "assistant") c = renderMd(c); else c = escHtml(c); - var isChan = !!(m.source && m.source !== "webui"); - var bubble = c ? '
' + c + '
' : '
'; - if (role === "system") { - msgsEl.insertAdjacentHTML("beforeend", '
' + c + '
'); - } else if (isChan) { - msgsEl.insertAdjacentHTML("beforeend", '
' + (m.source||"?")[0].toUpperCase() + '
' + escHtml(m.source) + '
' + bubble + '
'); - } else { - msgsEl.insertAdjacentHTML("beforeend", '
' + (role === "user" ? userAvatar : aiAvatar) + '
' + bubble + '
'); - } - }); - // 删除流式占位符(同步完成,下一次 SSE 会重建) - var streamingPh = msgsEl.querySelector(".msg-streaming-ph"); - if (streamingPh) streamingPh.remove(); - } - // 追加新消息对象到 state.messages - Array.prototype.push.apply(state.messages, newMsgs); - // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) - syncStreamingPlaceholder(); - } - // syncStreamingPlaceholder:同步聊天占位符的可见性 - function syncStreamingPlaceholder() { - var msgsEl = document.getElementById("chat-msgs"); - if (!msgsEl) return; - var existing = msgsEl.querySelector(".msg-streaming-ph"); - var lastMsg = state.messages.length ? state.messages[state.messages.length - 1] : null; - var showPh = state.chatLoading && (!lastMsg || lastMsg.role !== "assistant" || lastMsg._final); - if (showPh && !existing) { - var aiAvatar = '小宅'; - var pillHtml = ""; - (state.pendingTools || []).forEach((nm) => { - pillHtml += '' + escHtml(nm) + ''; - }); - msgsEl.insertAdjacentHTML("beforeend", '
' + aiAvatar + '
' + (pillHtml ? '' + pillHtml + '' : '') + '
'); - } else if (!showPh && existing) { - existing.remove(); - } - } - - async function loadTerminals() { - try { - var data = await api("/terminals"); - if (data && data.terminals) state.terminals = data.terminals; - } catch (e) {} - } - - async function loadCmdHistory() { - try { - var data = await api("/cmd/history"); - if (data && data.history) state.cmdHistory = data.history; - } catch (e) {} - } - - function appendTermBuf(el, text) { - if (!text) return; - el.textContent += text; - if (el.textContent.length > 262144) { - el.textContent = el.textContent.slice(el.textContent.length - 262144); - } - el.scrollTop = el.scrollHeight; - } - - function renderTerminals() { - var r = document.getElementById("term-list"); - var cnt = document.getElementById("term-count-badge"); - if (!r) return; - var list = state.terminals || []; - if (cnt) cnt.textContent = list.length; - if (list.length === 0) { - r.innerHTML = - '

' + - __("暂无终端会话", "No terminal sessions") + - "

"; - return; - } - var html = ""; - list.forEach((t, i) => { - var detailId = "term-detail-" + i; - var scr = (state.termScreens && state.termScreens[t.id]) || null; - var running = scr ? scr.running : !!t.running; - var fullOut = scr ? scr.output : t.output || ""; - if (!fullOut) { - fullOut = - '' + - __("[终端暂无输出]", "[No terminal output]") + - ""; - } else { - fullOut = escHtml(fullOut); - } - html += - '
'; - html += - '
"; - html += - '' + - escHtml(t.id || "-") + - ""; - html += - '' + - escHtml(t.command || "") + - ""; - html += - '' + - (running ? __("运行中", "Running") : __("已关闭", "Closed")) + - ""; - html += - '' + - escHtml(t.created_at || "") + - ""; - html += "
"; - html += - '
"; - }); - r.innerHTML = html; - } - - function renderCmdHistory() { - var r = document.getElementById("cmd-list"); - var cnt = document.getElementById("cmd-count-badge"); - if (!r) return; - var running = (state.terminals || []).filter((t) => t.running); - if (cnt) cnt.textContent = running.length; - if (running.length === 0) { - r.innerHTML = - '

' + - __("暂无运行中的命令", "No running commands") + - "

"; - return; - } - var html = - '"; - running.forEach((t) => { - var scr = (state.termScreens && state.termScreens[t.id]) || null; - var out = scr ? scr.output : t.output || ""; - html += - "" + - '" + - '" + - '" + - ""; - if (out) { - html += - "" + - '"; - } - }); - html += "
' + - __("命令", "Command") + - "" + - __("状态", "Status") + - "" + - __("运行时长", "Uptime") + - "
' + - escHtml(t.command || t.id || "") + - "' + - __("运行中", "Running") + - "' + - escHtml(t.uptime || "-") + - "
' +
-              escHtml(out.substring(0, 2000)) +
-              "
"; - r.innerHTML = html; - } - - function connectSSE() { - if (state.eventSource) { - console.log("[SSE] closing old connection"); - state.eventSource.close(); - } - var es; - try { - es = new EventSource("/api/v1/chat/events", { - withCredentials: true, - }); - state.eventSource = es; - } catch (ex) { - console.error("[SSE] create failed", ex); - return; - } - if (!es) { - console.error("[SSE] es is null"); - return; - } - console.log("[SSE] connected"); - es.addEventListener("agent_output", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; - // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 - if (p.channel) rtSparkOutput(ev.source || "", p.channel); - // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。 - starmapPulse("output", p.channel || null); - console.log( - "[SSE] agent_output received", - p.content ? p.content.substring(0, 50) : "(empty)", - ); - if (!p.content) return; - state.chatStage = __("AI 回复中...", "AI replying..."); - if (p.kind === "channel_output") { - // 附件输出(output_type=image/file):渲染为图片预览/下载卡片 - var att = null; - if ( - p.output_type === "image" || - p.output_type === "audio" || - p.output_type === "file" - ) { - att = { - type: p.output_type, - url: p.url || p.content, - size: p.size || 0, - name: (p.url || "").split("/").pop() || "附件", - }; - } - var cm = { - role: "assistant", - content: att ? "" : p.content, - source: p.channel || "", - _final: true, - _grow: true, - attachment: att, - }; - if ( - state.chatFinalIdx >= 0 && - state.chatFinalIdx < state.messages.length - ) { - state.messages.splice(state.chatFinalIdx, 0, cm); - state.chatFinalIdx++; - } else { - state.messages.push(cm); - } - rerenderChat(); - return; - } - var lastM2 = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (lastM2 && lastM2.role === "assistant" && !lastM2._final) { - // 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准, - // 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。 - lastM2._grow = true; - lastM2.content = p.content; - lastM2._final = true; - rerenderChat(); - endChatTurn(); - return; - } - if ( - lastM2 && - lastM2.role === "assistant" && - lastM2._final && - !lastM2.source - ) { - // 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略; - // 内容不同视为新一轮输出(上一轮已 final 且无 source),开新消息。 - // 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。 - if (lastM2.content === p.content) { - endChatTurn(); - return; - } - } - state.messages.push({ - role: "assistant", - content: p.content, - _streaming: true, - _grow: true, - _final: true, - }); - rerenderChat(); - endChatTurn(); - } catch (ex) { - console.error("[SSE] agent_output error", ex); - } - }); - // token 级流式增量:逐块追加到当前回复内容(流式生成中); - // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 - es.addEventListener("content_delta", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.reset) { - var lm = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (lm && lm.role === "assistant" && !lm._final) { - lm._final = true; - rerenderChat(); - } - // 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息] - // 重启轮次,保持回合打开让确认回复继续流式渲染, - // 由其 agent_output final / watchdog 收尾。 - armTurnWatchdog(); - return; - } - if (!p.content) return; - state.chatStage = __("AI 回复中...", "AI replying..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - tool_calls: [], - _streaming: true, - _grow: true, - }); - last = state.messages[state.messages.length - 1]; - } - last.content += p.content; - rerenderChat(); - } catch (ex) {} - }); - es.addEventListener("terminal_output", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (!p.terminal_id) return; - var id = p.terminal_id; - if (!state.termScreens) state.termScreens = {}; - var scr = - state.termScreens[id] || - (state.termScreens[id] = { output: "", running: true }); - if (p.output) scr.output += p.output; - if (typeof p.running === "boolean") scr.running = p.running; - var bufel = document.getElementById("term-buf-" + id); - if (bufel) { - appendTermBuf(bufel, p.output || ""); - var dot = document.getElementById("term-dot-" + id); - if (dot) - dot.className = "term-dot" + (scr.running ? "" : " stopped"); - } - } catch (ex) { - console.error("[SSE] terminal_output error", ex); - } - }); - es.addEventListener("reasoning", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.content) { - state.chatStage = __("AI 思考中...", "AI thinking..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - reasoning_content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - // 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价) - last.reasoning_content = p.content; - rerenderChat(); - } - } catch (ex) {} - }); - // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废 - es.addEventListener("reasoning_delta", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考 - if (!p.content) return; - state.chatStage = __("AI 思考中...", "AI thinking..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - reasoning_content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - last.reasoning_content = (last.reasoning_content || "") + p.content; - rerenderChat(); - } catch (ex) {} - }); - es.addEventListener("tool_call", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - console.log("[SSE] tool_call", p); - if (!p.tool) return; - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - if (!last.tool_calls) last.tool_calls = []; - last.tool_calls.push({ - tool: p.tool, - name: p.tool, - args: p.args || {}, - result: p.result || "", - status: p.status || "ok", - plugin: p.plugin || "", - }); - var pidx = (state.pendingTools || []).indexOf(p.tool); - if (pidx !== -1) state.pendingTools.splice(pidx, 1); - state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || ""); - // 星图跟随:工具调用是最可靠的活动信号。工具名(如 - // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。 - starmapPulse("tool", p.tool); - rerenderChat(); - } catch (ex) { - console.error("[SSE] tool_call error", ex); - } - }); - // 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。 - es.addEventListener("stage", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - var phase = p.phase || ""; - var tool = p.tool || ""; - if (p.channel === "_consolidation_") return; - console.log("[SSE] stage event", phase, tool); - // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 - // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 - // 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。 - // 工具调用会反复出现(多轮 toolcall),output_* 单独配色。 - if (phase === "on_input") { - state.stageTrail = []; - rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input")); - } else if (phase === "pre_action") { - rtTrailPush(1, "stage", __("组装上下文并思考", "assemble context and think"), __("思考", "think")); - } else if (phase === "before_toolcall" && tool) { - var isOut = tool.indexOf("output_") === 0; - rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool)); - state.toolFlash = true; - } else if (phase === "before_output") { - rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen")); - } else if (phase === "after_output") { - rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done")); - } - // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。 - starmapPulse("stage", tool || phase); - state.pipelinePhase = phase; - if (state.pipelineTimer) clearTimeout(state.pipelineTimer); - state.pipelineTimer = setTimeout(() => { - state.pipelinePhase = ""; - if (document.getElementById("rt-sec-pipe")) renderRuntime(); - }, 2500); - if (document.getElementById("rt-sec-pipe")) renderRuntime(); - // 渲染后再放动画:morph 已经把 chip 文本更新成最新那条。 - if (state.toolFlash) { - state.toolFlash = false; - rtFlashLatestTool(); - } - if (phase === "pre_action") { - state.chatStage = __("AI 思考中...", "AI thinking..."); - } else if (phase === "before_toolcall") { - state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); - if (tool && (state.pendingTools || []).indexOf(tool) === -1) { - if (!state.pendingTools) state.pendingTools = []; - state.pendingTools.push(tool); - rerenderChat(); - } - } else if (phase === "before_output") { - state.chatStage = __("生成回复中...", "Generating response..."); - } - var badge = document.getElementById("chat-stage"); - if (badge) { - badge.textContent = state.chatStage || ""; - badge.style.display = "none"; - } - } catch (ex) { - console.error("[SSE] stage error", ex); - } - }); - // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent - // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 - es.addEventListener("channel_input", (e) => { - try { - var d = JSON.parse(e.data); - var src = d.source || ""; - if (src) rtSparkInput(src); - } catch (ex) { - console.error("[SSE] channel_input error", ex); - } - }); - es.onopen = () => { - console.log("[SSE] connection opened"); - }; - es.onerror = (e) => { - console.error("[SSE] error", e); - // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 - try { state.eventSource && state.eventSource.close(); state.eventSource = null; } catch(ex){} - // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) - syncChatFromHistory().catch(()=> {}); - // 3) 2s 后手动重连(比原 5s 更快恢复) - setTimeout(connectSSE, 2000); - }; - // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, - // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 - es.addEventListener("sync_required", (e) => { - console.log("[SSE] sync_required received, incremental sync"); - syncChatFromHistory().catch(()=> {}); - }); - // Periodically refresh sidebar data - if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); - state._sidebarRefresh = setInterval(async () => { - try { - var td = await api("/terminals"); - if (td && td.terminals) state.terminals = td.terminals; - } catch (e) {} - try { - var ch = await api("/cmd/history"); - if (ch && ch.history) state.cmdHistory = ch.history; - } catch (e) {} - renderTerminals(); - renderCmdHistory(); - }, 5000); - } - - // ===== Plugins ===== - // 「服务入口」:被反代的插件服务,点一下直接打开。 - // - // 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个 - // webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是 - // 「我要进某个插件的管理页」。 - // - // 链接带 ?__token=:子域与门户不同源,浏览器不会带上会话 cookie; - // 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。 - function renderProxyServicesCard() { - var svcs = state.proxyServices || []; - var base = state.proxyBaseDomain || "localhost"; - var html = - '

' + - __("服务入口", "Service Entry Points") + - "

"; - if (!svcs.length) { - return ( - html + - '

' + - __( - "暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。", - "No proxied plugin services yet. Declare them in plugin.json's proxies field.", - ) + - "

" - ); - } - var token = state.settings?.["plugin.webui.api_key"] || ""; - html += - '
' + - __( - "插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ", - "Proxied through the same port. The subdomain form needs DNS for ", - ) + - "" + - escHtml("*." + base) + - "" + - __( - ";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。", - "; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).", - ) + - "
"; - html += '
'; - svcs.forEach((s) => { - var url = s.url || ""; - if (url && token) url += "?__token=" + encodeURIComponent(token); - var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; - html += - '
' + - '' + - '' + escHtml(s.plugin_name || s.plugin) + "" + - '' + escHtml(s.name) + "" + - (s.websocket - ? 'WS' - : "") + - (s.auth === "none" - ? '' + - __("未保护", "unprotected") + - "" - : "") + - '' + escHtml(s.host + "." + base) + ""; - // 「打开」优先用**路径形态**(url_portal): - // - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用; - // - 子域形态要求 DNS 能解析 .<基域名>,而这些域名 - // 在外部常常不可达(实测:外层只放行一个 Host,三级子域 - // 因通配证书不匹配而握手失败)。 - // 保留子域链接作为次选(局域网内直连时它更直观)。 - var primary = s.url_portal || url; - if (s.ok && primary) { - html += - '' + - __("打开", "Open") + - ""; - if (url && s.url_portal && url !== s.url_portal) { - html += - '' + - __("子域", "subdomain") + - ""; - } - } else { - html += - '' + - escHtml(s.error || __("不可用", "unavailable")) + - ""; - } - html += "
"; - }); - html += "
"; - return html; - } - - async function loadProxyServices() { - try { - var d = await api("/proxy/services"); - state.proxyServices = (d && d.services) || []; - state.proxyBaseDomain = (d && d.base_domain) || "localhost"; - } catch (e) { - state.proxyServices = []; - } - } - - function renderPlugins() { - var k = state.kernel; - var plugins = k?.plugins || []; - var tools = k?.tools || []; - var installed = state.installedPlugins || []; - var html = renderProxyServicesCard(); - html += - '

' + - __("安装插件", "Install Plugin") + - "

" + - '
' + - '' + - '
" + - '
' + - '
"; - var disabledNames = {}; - (state.disabledPlugins || []).forEach((d) => { - disabledNames[d.name] = d; - }); - var installedNames = (state.installedPlugins || []).map((p) => p.name); - var allPluginNames = {}; - plugins.forEach((p) => { - allPluginNames[p.name] = true; - }); - state.disabledPlugins.forEach((d) => { - allPluginNames[d.name] = true; - }); - html += - '

' + - __("所有插件", "All Plugins") + - " (" + - Object.keys(allPluginNames).length + - ")

"; - var names = Object.keys(allPluginNames).sort(); - if (names.length === 0) { - html += - '

' + - __("暂无插件", "No plugins") + - "

"; - } else { - html += - ""; - names.forEach((name) => { - var isExternal = installedNames.indexOf(name) >= 0; - var isDisabled = disabledNames[name]; - var loaded = plugins.some((p) => p.name === name); - var statusHtml = - loaded && !isDisabled - ? '' + - __("已加载", "Loaded") + - "" - : loaded && isDisabled - ? '' + - __("运行中(禁用待生效)", "Running (disable pending)") + - "" - : isDisabled - ? '' + - __("已禁用", "Disabled") + - "" - : '' + - __("未加载", "Not Loaded") + - ""; - var actionsHtml = ""; - if (loaded) { - actionsHtml += - '"; - } else { - actionsHtml += - '"; - } - if (isExternal) { - actionsHtml += - '"; - } - html += - "" + - "" + - ""; - }); - html += "
" + - __("名称", "Name") + - "" + - __("状态", "Status") + - "" + - __("操作", "Actions") + - "
" + - escHtml(name) + - "" + - statusHtml + - "" + - actionsHtml + - "
"; - } - html += "
"; - if (installed.length > 0) { - html += - '

' + - __("已安装外部插件", "Installed Plugins") + - " (" + - installed.length + - ")

" + - ""; - installed.forEach((p) => { - html += - "" + - "" + - "" + - '"; - }); - html += "
" + - __("名称", "Name") + - "" + - __("版本", "Version") + - "" + - __("描述", "Description") + - "" + - __("操作", "Actions") + - "
" + - escHtml(p.name) + - "" + - escHtml(p.version || "-") + - "" + - escHtml((p.description || "").substring(0, 50)) + - " " + - '
"; - } - if (state.pluginInfo) { - html += - '

' + - __("插件详情", "Plugin Details") + - ": " + - escHtml(state.pluginInfo.name) + - "

" + - "
" +
-            escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
-            "
" + - '
"; - } - if (tools.length > 0) { - html += - '

' + - __("已注册工具", "Registered Tools") + - " (" + - tools.length + - ")

" + - '
'; - tools.forEach((t) => { - html += - '' + - escHtml(t.name) + - ""; - }); - html += "
"; - } - html += - '

' + - __("系统操作", "System Operations") + - "

" + - '
"; - html += - '

' + - __("健康检查", "Health Check") + - '

'; - if (state.healthResult) { - html += renderHealthResult(state.healthResult); - } else { - html += - '

' + - __( - "尚未运行,点击右上角「运行」开始", - "Not run yet, click Run to start", - ) + - "

"; - } - html += "
"; - document.getElementById("tab-plugins").innerHTML = html; - } - - async function loadInstalledPlugins() { - try { - state.installedPlugins = await api("/plugins"); - } catch (e) { - state.installedPlugins = []; - } - } - - async function installPlugin() { - var inp = document.getElementById("plugin-url"); - var url = inp?.value.trim(); - if (!url) { - toast(__("请输入插件包 URL", "Please enter plugin URL"), true); - return; - } - try { - var raw = await api("/plugins", { - method: "POST", - body: JSON.stringify({ url: url }), - raw: true, - }); - var ct = raw.headers.get("content-type") || ""; - var r = ct.includes("json") ? await raw.json() : await raw.text(); - if (!raw.ok || (r && r.error)) { - toast( - __("安装失败: ", "Install failed: ") + - ((r && (r.error || r.details)) || "HTTP " + raw.status), - true, - ); - return; - } - toast( - __("安装结果: ", "Install result: ") + - (r.status || JSON.stringify(r)), - ); - if (r.action === "reload_required") - toast( - __( - "已安装,请点击「重载插件」加载", - 'Installed, click "Reload Plugins" to load', - ), - false, - ); - await loadInstalledPlugins(); - renderPlugins(); - } catch (e) { - toast(__("安装失败: ", "Install failed: ") + e.message, true); - } - } - - async function installPluginFile(file) { - if (!file) return; - try { - var r = await fetch("/api/v1/plugins", { - method: "POST", - body: file, - headers: { "Content-Type": "application/octet-stream" }, - }); - var data = await r.json(); - toast( - __("上传安装: ", "Upload install: ") + - (data.status || JSON.stringify(data)), - ); - if (data.action === "reload_required") - toast( - __( - "已安装,请点击「重载插件」加载", - 'Installed, click "Reload Plugins" to load', - ), - false, - ); - loadInstalledPlugins(); - renderPlugins(); - } catch (e) { - toast(__("上传失败: ", "Upload failed: ") + e.message, true); - } - } - - async function showPluginInfo(name) { - try { - state.pluginInfo = await api("/plugins/" + encodeURIComponent(name)); - renderPlugins(); - } catch (e) { - toast(__("获取详情失败: ", "Get details failed: ") + e.message, true); - } - } - - function closePluginInfo() { - state.pluginInfo = null; - renderPlugins(); - } - - var removingPlugins = {}; - - async function removePlugin(name) { - if (removingPlugins[name]) return; // 防重复点击 - if ( - !confirm( - __("确定卸载插件", "Are you sure to unload plugin") + - "「" + - name + - "」?", - ) - ) - return; - removingPlugins[name] = true; - try { - var raw = await api("/plugins/" + encodeURIComponent(name), { - method: "DELETE", - raw: true, - }); - var ct = raw.headers.get("content-type") || ""; - var body = ct.includes("json") ? await raw.json() : await raw.text(); - if (!raw.ok) { - var em = - (body && (body.error || body.details)) || "HTTP " + raw.status; - toast(__("卸载失败: ", "Unload failed: ") + em, true); - // 内置插件或路径错误时刷新一次列表保持状态一致 - loadInstalledPlugins(); - renderPlugins(); - return; - } - toast( - __("已卸载: ", "Unloaded: ") + (body.name || body.status || name), - ); - await loadInstalledPlugins(); - // 同步内核插件/禁用列表,确保列表与工具立即消失 - try { - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - } catch (e2) {} - renderPlugins(); - } catch (e) { - toast(__("卸载失败: ", "Unload failed: ") + e.message, true); - } finally { - delete removingPlugins[name]; - } - } - - async function disablePlugin(name) { - if (name === "webui") { - var r = confirm( - __( - "禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?", - "Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?", - ), - ); - if (!r) return; - } - try { - var r = await api( - "/plugins/" + encodeURIComponent(name) + "/disable", - { method: "POST" }, - ); - toast(__("已禁用: ", "Disabled: ") + name); - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - renderPlugins(); - } catch (e) { - toast(__("禁用失败: ", "Disable failed: ") + e.message, true); - } - } - - async function enablePlugin(name) { - try { - var r = await api( - "/plugins/" + encodeURIComponent(name) + "/enable", - { method: "POST" }, - ); - toast(__("已启用: ", "Enabled: ") + name); - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - renderPlugins(); - } catch (e) { - toast(__("启用失败: ", "Enable failed: ") + e.message, true); - } - } - - async function reloadPlugins() { - try { - var r = await api("/plugins/reload", { method: "POST" }); - toast(__("插件已重载", "Plugins reloaded")); - state.kernel = await api("/kernel"); - renderPlugins(); - } catch (e) { - toast(__("重载失败: ", "Reload failed: ") + e.message, true); - } - } - - async function runHealthcheck() { - var panel = document.getElementById("health-panel"); - if (!panel) return; - panel.innerHTML = - '

' + - __("运行中...", "Running...") + - "

"; - try { - var r = await api("/kernel"); - var tools = r?.tools || []; - var healthTool = tools.find((t) => t.name === "healthcheck"); - if (!healthTool) { - panel.innerHTML = - '

' + - __("healthcheck 工具未注册", "healthcheck tool not registered") + - "

"; - return; - } - panel.innerHTML = - '

' + - __( - "通过 Agent 对话触发 healthcheck...", - "Triggering healthcheck via Agent...", - ) + - "

"; - var chatR = await api("/chat", { - method: "POST", - body: JSON.stringify({ - message: __( - "请运行 healthcheck 工具进行全面健康检查并报告结果", - "Please run the healthcheck tool for a full system check and report the results", - ), - }), - }); - panel.innerHTML = - "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; - } catch (e) { - panel.innerHTML = - '

' + - __("错误: ", "Error: ") + - escHtml(e.message) + - "

"; - toast( - __("健康检查失败: ", "Health check failed: ") + e.message, - true, - ); - } - } - - function renderHealthResult(r) { - if (!r || !r.checks) - return ( - '

' + - __("暂无健康检查数据", "No health check data") + - "

" - ); - var checks = r.checks || []; - var passed = checks.filter((c) => c.pass).length; - var failed = checks.filter((c) => !c.pass).length; - var html = - '
' + - '' + - __("通过: ", "Pass: ") + - passed + - "" + - '' + - __("失败: ", "Fail: ") + - failed + - "" + - '' + - __("总计: ", "Total: ") + - checks.length + - "
"; - checks.forEach((c) => { - var passClass = c.pass ? "check-pass" : "check-fail"; - if (c.status === "skip") passClass = "check-skip"; - html += - '
' + - '' + - escHtml(c.name) + - "" + - '' + - (c.status || "unknown") + - "" + - '' + - escHtml(c.detail || "") + - "
"; - }); - return html; - } - - // ===== Kernel ===== - function renderKernel() { - var k = state.kernel; - if (!k) { - document.getElementById("tab-kernel").innerHTML = - '

' + - __("内核未响应", "Kernel not responding") + - "

"; - return; - } - var b = k.build || {}; - var html = - '

' + - __("构建", "Build") + - "

" + - '
' + - __("内核", "Kernel") + - '' + - escHtml(b.kernel_name || "HomeAgent") + - "
" + - '
' + - __("内核版本", "Kernel version") + - '' + - escHtml(b.version ? "v" + b.version : "-") + - "
" + - '
Commit' + - escHtml(b.commit || "-") + - "
" + - '
' + - __("构建时间", "Build time") + - '' + - escHtml(b.build_time || "-") + - "
" + - '
' + - __("SDK 兼容", "SDK compat") + - '' + - escHtml(b.sdk_compatible || "-") + - "
" + - (b.source_url - ? '
' + - __("源码", "Source") + - '' + - escHtml(b.source_url) + - "
" - : "") + - "
"; - html += - '

' + - __("运行时", "Runtime") + - "

" + - '
Goroutines' + - (k?.runtime?.goroutines || "-") + - "
" + - '
' + - __("内存", "Memory") + - '' + - (k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + - "
" + - '
Go ' + - __("版本", "Version") + - '' + - (k?.runtime?.go_version || "-") + - "
"; - html += - '

LLM

' + - '
Provider' + - (k.llm?.provider || __("未配置", "Not configured")) + - "
" + - '
' + - __("可用源", "Sources") + - '' + - (k.llm?.sources || 0) + - "
" + - '
' + - __("状态", "Status") + - '' + - (k.llm?.available - ? __("运行中", "Running") - : __("不可用", "Unavailable")) + - "
"; - html += - '

' + - __("记忆", "Memory") + - "

" + - '
' + - __("图记忆", "Graph Memory") + - '' + - (k.memory?.available - ? k.memory.entity_count + - __(" 实体, ", " entities, ") + - k.memory.relation_count + - __(" 关系", " relations") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("文档记忆", "Document Memory") + - '' + - (k.documents?.available - ? k.documents.doc_count + __(" 文档", " docs") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("文本记忆", "Text Memory") + - '' + - (k.text_memory?.available - ? k.text_memory.file_count + __(" 文件", " files") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("知识库", "Knowledge") + - '' + - (k.knowledge?.available - ? k.knowledge.item_count + __(" 项", " items") - : __("未初始化", "Uninitialized")) + - "
"; - html += - '

' + - __("插件", "Plugins") + - " (" + - (k.plugins?.length || 0) + - ")

"; - if (k.plugins?.length) { - html += '
'; - k.plugins.forEach((p) => { - html += - '' + escHtml(p.name) + ""; - }); - html += "
"; - } else { - html += - '

' + __("无", "None") + "

"; - } - html += "
"; - document.getElementById("tab-kernel").innerHTML = html; - } - - // ===== Star Map ===== - var starmapScene = null, - starmapCam = null, - starmapRen = null, - starmapCtrl = null; - var starmapNodes = [], - starmapEdges = []; - var starmapNodeMeshes = [], - starmapEdgeLines = [], - starmapStarField = null; - var starmapHovered = null, - starmapSelected = null, - starmapAutoView = true; - var starmapRaf = null; - // 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 - // BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, - // 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, - // 真实半径由 scale 给出(见 buildChatStarmapGraph)。 - var starmapGeo = null, - starmapGlowGeo = null; - // 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 - var starmapLabelEl = null; - // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 - // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), - // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, - // 分类配色实际上从未生效过。 - var smTypeColors = { - person: 0x4488ff, - task: 0xff8844, - ai: 0xaa44ff, - concept: 0x44ff88, - object: 0xff4444, - // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") - location: 0xffaa44, - source: 0x8899ff, - document: 0xaabbcc, - event: 0xff88cc, - entity: 0x44ddcc, - scene: 0x88ff44, - }; - var smEdgeColors = { - 喜欢: 0xff6b6b, - 学习: 0x4ecdc4, - 属于: 0x45b7d1, - 相关: 0x96ceb4, - 使用: 0xfeca57, - 创建: 0xff9ff3, - }; - - // ===== 星图活动:让图跟上 agent 的动作 ===== - // - // 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号: - // - // 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage / - // agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机 - // 取一批)做一次扩散涟漪 + 发光冲高。 - // 2. starmapPullActivity() —— /runtime 调度器快照,3s。 - // ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲); - // interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。 - // 3. starmapPullPulse() —— /memory/graph/pulse,10s。 - // 最近变动的实体(新增概念 / mention_count 变化)做一次 - // 「生长」:从 0 缩放到正常大小,并留下余晖。 - // - // 全部在渲染循环里推进,不额外起定时器。 - var SM_PULSE_MS = 1400; // 单个脉冲的生命期 - var SM_RIPPLE_R = 26; // 涟漪最大半径 - - // starmapPulse 发出一次活动脉冲。 - // kind: "tool" | "stage" | "output" | "grow" - function starmapPulse(kind, hint) { - if (!starmapScene || !starmapNodeMeshes.length) return; - var now = Date.now(); - state.starmapLastPulseAt = now; - // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点, - // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。 - var targets = starmapPickPulseTargets(hint); - if (!targets.length) return; - var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS; - targets.forEach((m) => { - state.starmapPulses.push({ mesh: m, until: now + life, kind: kind }); - }); - // 队列上限:防止密集工具调用时脉冲无限堆积占内存。 - if (state.starmapPulses.length > 260) - state.starmapPulses = state.starmapPulses.slice(-260); - } - - // starmapPickPulseTargets 选出该被点亮的节点。 - // 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点 - // 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。 - // - // ★ 匹配必须用「词」而不是子串包含: - // hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱 - // (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, - // 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse - // 就选中 250 个节点、队列顶到 260 上限。 - function starmapPickPulseTargets(hint) { - var out = []; - if (!starmapNodeMeshes.length) return out; - if (hint) { - var keys = starmapHintTokens(hint); - if (keys.length) { - for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) { - var nd = starmapNodeMeshes[i].userData.nodeData || {}; - var nm = String(nd.name || "").toLowerCase(); - var ty = String(nd.type || "").toLowerCase(); - if (!nm) continue; - for (var k = 0; k < keys.length; k++) { - var key = keys[k]; - // 词边界命中:实体名恰好等于该词,或以该词为词首 - // (如 "knowledge_base" 命中词 "knowledge")。 - if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) { - out.push(starmapNodeMeshes[i]); - break; - } - } - } - } - } - // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。 - // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个, - // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 - // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 - // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 - if (out.length > 0) return out; - // 补齐:按 mention_count 降序取靠前且尚未入列的。 - var sorted = starmapNodeMeshes.slice().sort((a, b) => ( - (b.userData.nodeData || {}).mention_count - - (a.userData.nodeData || {}).mention_count - )); - for (var j = 0; j < sorted.length && out.length < 8; j++) { - if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); - } - return out; - } - - // starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。 - // 例:"knowledge_list" → ["knowledge","list"]。 - // 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关 - // 实体名,匹配它们只会制造噪声。 - function starmapHintTokens(hint) { - var raw = String(hint).toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/); - var out = []; - for (var i = 0; i < raw.length; i++) { - var t = raw[i]; - if (t.length >= 3 && out.indexOf(t) === -1) out.push(t); - } - return out; - } - - // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 - function starmapPullActivity() { - if (!starmapScene) return; - api("/runtime") - .then((rt) => { - if (!rt || !rt.scheduler) return; - var s = rt.scheduler; - var prev = state.starmapActivity; - state.starmapActivity = s; - var pend = - (s.ready_queue_depth || 0) + - (s.pending_interrupts || 0) + - (s.suspend_stack || 0); - // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 - if (pend > 0) starmapPulse("stage", null); - // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 - if (prev) { - var dInt = - (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); - var dPre = - (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); - if (dInt > 0 || dPre > 0) starmapPulse("output", null); - } - }) - .catch(() => {}); - } - - // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 - function starmapPullPulse() { - if (!starmapScene) return; - var since = state.starmapPulseSince || 0; - api("/memory/graph/pulse?since=" + since) - .then((r) => { - if (!r || !r.success || !r.data) return; - var now = Math.floor(Date.now() / 1000); - state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点 - var nodes = r.data.nodes || []; - if (!nodes.length) return; - var known = 0; - nodes.forEach((n) => { - var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id); - if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量 - known++; - state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2; - starmapPulse("grow", n.name); - }); - // 若有新实体但一个都没匹配上,说明全量图过期了, - // 下一拍重拉全量(新节点才能出现)。 - if (known === 0 && nodes.length > 2) starmapDirty = true; - }) - .catch(() => {}); - } - - // starmapTickActivity 在渲染循环里推进所有脉冲与余晖。 - function starmapTickActivity(t) { - var now = Date.now(); - var breathe = 0; - // 1) 活动脉冲:发光冲高 + 尺寸微扩 + 涟漪环 - if (state.starmapPulses.length) { - var keep = []; - for (var i = 0; i < state.starmapPulses.length; i++) { - var p = state.starmapPulses[i]; - if (p.until <= now) { - p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive; - p.mesh.scale.setScalar(p.mesh.userData.baseScale); - continue; - } - keep.push(p); - var left = (p.until - now) / SM_PULSE_MS; // 1→0 - var k = 1 - left; // 0→1 - var wave = Math.sin(Math.min(1, k) * Math.PI); - var amp = p.kind === "output" ? 1.8 : 1.2; - p.mesh.material.emissiveIntensity = - p.mesh.userData.baseEmissive + wave * amp; - p.mesh.scale.setScalar( - p.mesh.userData.baseScale * (1 + wave * 0.28), - ); - // 生长:新节点从 0 弹到正常大小 - if (p.kind === "grow") { - var g = Math.min(1, k * 1.4); - p.mesh.scale.setScalar( - p.mesh.userData.baseScale * (0.15 + 0.85 * g), - ); - } - } - state.starmapPulses = keep; - } - // 2) 「生长」余晖:脉冲结束后短暂保留一点亮 - if (state.starmapGrown) { - for (var gid in state.starmapGrown) { - if (state.starmapGrown[gid] <= now) { - delete state.starmapGrown[gid]; - continue; - } - var gm = starmapNodeMeshes.find((x) => x.userData.nodeId == gid); - if (gm) - gm.material.emissiveIntensity = Math.max( - gm.material.emissiveIntensity, - gm.userData.baseEmissive + 0.6, - ); - } - } - // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」 - var idle = (now - (state.starmapLastPulseAt || 0)) / 4000; - breathe = Math.max(0, 1 - idle); - if (breathe > 0.01 && starmapNodeMeshes.length) { - // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。 - var stride = 24; - for (var b = 0; b < starmapNodeMeshes.length; b += stride) { - var m2 = starmapNodeMeshes[b]; - if (m2.userData.baseEmissive === undefined) continue; - m2.material.emissiveIntensity = Math.max( - m2.userData.baseEmissive, - m2.userData.baseEmissive + breathe * 0.25, - ); - } - } - return breathe; - } - - function createStarField() { - var c = 3000; - var p = new Float32Array(c * 3), - cl = new Float32Array(c * 3), - s = new Float32Array(c); - for (var i = 0; i < c; i++) { - var i3 = i * 3; - var r = 400 + Math.random() * 600, - th = Math.random() * Math.PI * 2, - ph = Math.acos(2 * Math.random() - 1); - p[i3] = r * Math.sin(ph) * Math.cos(th); - p[i3 + 1] = r * Math.sin(ph) * Math.sin(th); - p[i3 + 2] = r * Math.cos(ph); - if (Math.random() < 0.7) { - cl[i3] = 0.8 + Math.random() * 0.2; - cl[i3 + 1] = 0.8 + Math.random() * 0.2; - cl[i3 + 2] = 1; - } else { - cl[i3] = 1; - cl[i3 + 1] = 0.9 + Math.random() * 0.1; - cl[i3 + 2] = 0.8 + Math.random() * 0.2; - } - s[i] = 0.5 + Math.random() * 2; - } - var g = new THREE.BufferGeometry(); - g.setAttribute("position", new THREE.BufferAttribute(p, 3)); - g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); - g.setAttribute("size", new THREE.BufferAttribute(s, 1)); - var m = new THREE.PointsMaterial({ - size: 1.5, - vertexColors: true, - transparent: true, - opacity: 0.8, - sizeAttenuation: true, - }); - starmapStarField = new THREE.Points(g, m); - starmapScene.add(starmapStarField); - } - - function onStarmapMove(e) { - if (!starmapRen || !starmapCam) return; - var rect = starmapRen.domElement.getBoundingClientRect(); - var mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1, - ); - var rc = new THREE.Raycaster(); - rc.setFromCamera(mouse, starmapCam); - var hits = rc.intersectObjects(starmapNodeMeshes); - if (hits.length > 0) { - var n = hits[0].object; - if (starmapHovered !== n) { - // 复位用 baseScale,不能用 set(1,1,1):节点现在是按 mention_count - // 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸。 - // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时 - // 几何体本身就带半径、不靠 scale。) - if (starmapHovered) - starmapHovered.scale.setScalar( - starmapHovered.userData.baseScale || 1, - ); - starmapHovered = n; - starmapLabelShow(n); - } - } else { - if (starmapHovered) { - starmapHovered.scale.setScalar( - starmapHovered.userData.baseScale || 1, - ); - starmapHovered = null; - starmapLabelHide(); - } - } - } - - // starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。 - // 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存), - // 文字用 DOM 渲染反而更清楚,且零 GPU 开销。 - function starmapLabelShow(mesh) { - var cont = starmapActiveContainer(); - if (!cont) return; - if (!starmapLabelEl) { - starmapLabelEl = document.createElement("div"); - starmapLabelEl.id = "sm-label"; - } - // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。 - // canvas 换页签搬运时,角标也要跟着搬。 - if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl); - var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {}; - var nm = nd.name || nd.id || ""; - starmapLabelEl.textContent = - nm + (nd.mention_count ? " ×" + nd.mention_count : ""); - starmapLabelEl.style.display = "block"; - } - function starmapLabelHide() { - if (starmapLabelEl) starmapLabelEl.style.display = "none"; - } - // starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 - function starmapContainer() { - return ( - document.getElementById("sm-container-home") || - document.getElementById("sm-container-chat") - ); - } - - // starmapActiveContainer 选出「该把星图画在哪」的容器。 - // - // 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页, - // 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个 - // 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到 - // 当前该显示的地方,是单一 renderer 前提下最干净的做法。 - function starmapActiveContainer() { - var tab = document.getElementById("tab-starmap"); - if (tab && tab.classList.contains("active")) { - var c = document.getElementById("sm-container-page"); - if (c) return c; - } - var ov = document.getElementById("tab-overview"); - if (ov && ov.classList.contains("active")) { - var h = document.getElementById("sm-container-home"); - if (h) return h; - } - return ( - document.getElementById("sm-container-chat") || - document.getElementById("sm-container-home") || - null - ); - } - - // renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。 - function renderStarmapTab() { - var host = document.getElementById("tab-starmap"); - if (!host) return; - if (!document.getElementById("sm-container-page")) { - var rt = state.runtime || {}; - var sc = rt.scheduler || {}; - var pend = - (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0); - host.innerHTML = - '

' + - __("记忆星图", "Memory Star Map") + - '

' + - '
' + - '
' + - smLegend("person", __("人物", "Person")) + - smLegend("concept", __("概念", "Concept")) + - smLegend("object", __("对象", "Object")) + - smLegend("location", __("地点", "Location")) + - smLegend("source", __("来源", "Source")) + - "
" + - '
' + - __( - "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", - "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.", - ) + - "
"; - } - // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。 - if (document.getElementById("sm-container-page")) { - if (!state.starmapInit && !state.starmapLoading) - loadChatStarmapData(); - else if (starmapRen) { - var cont = document.getElementById("sm-container-page"); - if (starmapRen.domElement.parentElement !== cont) { - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } - onStarmapResize(); - } - } - smUpdateStat(); - } - - // smLegend 生成图例小项。 - function smLegend(key, label) { - var col = smTypeColors[key] || 0xcccccc; - var hex = "#" + ("0000" + col.toString(16)).slice(-6); - return ( - '' + - '' + - escHtml(label) + - "" - ); - } - - // smUpdateStat 在星图页签头部显示节点/边/活动状态。 - function smUpdateStat() { - var el = document.getElementById("sm-page-stat"); - if (!el) return; - var act = state.starmapActivity; - var busy = act && (act.ready_queue_depth || act.pending_interrupts); - var txt = starmapNodes.length + " " + __("节点", "nodes"); - if (starmapEdges.length) txt += " / " + starmapEdges.length + " " + __("关系", "edges"); - if (busy) txt += " · " + __("调度中", "busy"); - el.textContent = txt; - } - - - function onStarmapClick(e) { - if (!starmapRen || !starmapCam) return; - var rect = starmapRen.domElement.getBoundingClientRect(); - var mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1, - ); - var rc = new THREE.Raycaster(); - rc.setFromCamera(mouse, starmapCam); - var hits = rc.intersectObjects(starmapNodeMeshes); - if (hits.length > 0) { - var n = hits[0].object; - starmapSelected = starmapSelected === n ? null : n; - if (starmapAutoView && starmapSelected) - flyStarmapTo(starmapSelected.userData.nodeId, 500); - onStarmapMove(e); - } else { - starmapSelected = null; - } - } - - function flyStarmapTo(nodeId, dur) { - if (!starmapAutoView) return; - var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId); - if (!m) return; - var tp = m.position.clone(), - sp = starmapCam.position.clone(), - st = starmapCtrl.target.clone(); - var dist = tp.length() + 25, - ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8); - var t0 = Date.now(); - (function lerp() { - var t = Math.min((Date.now() - t0) / dur, 1), - e = 1 - (1 - t) ** 3; - starmapCam.position.lerpVectors(sp, ep, e); - starmapCtrl.target.lerpVectors(st, tp, e); - if (t < 1) requestAnimationFrame(lerp); - })(); - } - - function onStarmapResize() { - if (!starmapRen || !starmapCam) return; - var cont = starmapRen.domElement.parentElement; - if (!cont) return; - var rect = cont.getBoundingClientRect(); - var w = rect.width || 800, - h = Math.max(rect.height || 250, 100); - if (w > 0 && h > 0) { - starmapCam.aspect = w / h; - starmapCam.updateProjectionMatrix(); - starmapRen.setSize(w, h); - } - } - - function starmapAnimate() { - starmapRaf = requestAnimationFrame(starmapAnimate); - if (starmapCtrl) starmapCtrl.update(); - if (starmapStarField) starmapStarField.rotation.y += 0.0001; - // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 - if (starmapNodeMeshes.length) starmapTickActivity(); - if (starmapRen && starmapScene && starmapCam) - starmapRen.render(starmapScene, starmapCam); - } - - function createNebula() { - var nc = 500; - var p = new Float32Array(nc * 3), - cl = new Float32Array(nc * 3); - for (var i = 0; i < nc; i++) { - var i3 = i * 3; - p[i3] = (Math.random() - 0.5) * 800; - p[i3 + 1] = (Math.random() - 0.5) * 800; - p[i3 + 2] = (Math.random() - 0.5) * 800; - var ch = Math.random(); - if (ch < 0.33) { - cl[i3] = 0.5 + Math.random() * 0.3; - cl[i3 + 1] = 0.2 + Math.random() * 0.2; - cl[i3 + 2] = 0.7 + Math.random() * 0.3; - } else if (ch < 0.66) { - cl[i3] = 0.2 + Math.random() * 0.2; - cl[i3 + 1] = 0.3 + Math.random() * 0.3; - cl[i3 + 2] = 0.8 + Math.random() * 0.2; - } else { - cl[i3] = 0.7 + Math.random() * 0.3; - cl[i3 + 1] = 0.2 + Math.random() * 0.2; - cl[i3 + 2] = 0.5 + Math.random() * 0.3; - } - } - var g = new THREE.BufferGeometry(); - g.setAttribute("position", new THREE.BufferAttribute(p, 3)); - g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); - var m = new THREE.PointsMaterial({ - size: 8, - vertexColors: true, - transparent: true, - opacity: 0.15, - sizeAttenuation: true, - blending: THREE.AdditiveBlending, - }); - var np = new THREE.Points(g, m); - starmapScene.add(np); - } - - // ===== Settings ===== - function pluginDisplayName(p) { - if (p === "core") return __("核心", "Core"); - var name = p.replace("plugin.", ""); - var meta = state.pluginMeta && state.pluginMeta[name]; - if (meta) - return state.lang === "en" - ? meta.name_en || name - : meta.name_zh || name; - return name; - } - - function renderSettingsSidebar() { - var el = document.querySelector(".settings-tabs"); - if (!el) return; - el.innerHTML = ""; - // 「外观」不是插件设置,但它是 webui 自己的配置,放在设置页最前面。 - var ap = document.createElement("span"); - ap.textContent = __("外观", "Appearance"); - if (state.selectedSection === "appearance") ap.className = "active"; - ap.onclick = () => { - state.selectedSection = "appearance"; - renderOneSettings(); - }; - el.appendChild(ap); - state.settingsPlugins.forEach((p) => { - var a = document.createElement("span"); - a.textContent = pluginDisplayName(p); - if (p === state.selectedSection) a.className = "active"; - a.onclick = () => { - state.selectedSection = p; - renderOneSettings(); - }; - el.appendChild(a); - }); - } - - // 外观设置区:主题(浅/深)+ 配色 + 背景图。与侧栏那个 palette-pop 同一套 - // setColor/applyBgImg/applyBgBlur,只是给它一个正式的、可发现的落点 - // (原来只有侧栏底部一个调色盘图标,找不到)。 - function appearanceHtml() { - var theme = document.documentElement.getAttribute("data-theme") || "light"; - var color = localStorage.getItem("ha-color") || "mono"; - var dots = ""; - Object.keys(PALETTES).forEach((k) => { - dots += - ''; - }); - var img = localStorage.getItem("ha-bg-img") || ""; - var blur = localStorage.getItem("ha-bg-blur") || "0"; - return ( - '

' + __("外观", "Appearance") + "

" + - '
' + __("主题", "Theme") + '' + - ' " + - '" + - "
" + - '
' + __("配色", "Color") + '' + dots + "
" + - "" + - '' + - '
' + - '" + - '" + - "
" + - '" + - '' + - '

' + - __("配色只影响本站点界面的强调色,不改动 Agent 的人格或数据。", "Colors only affect this dashboard's accent; agent behavior and data are unchanged.") + - "

" - ); - } - function pickTheme(name) { - saveTheme(name); - renderOneSettings(); - } - function pickColor(name) { - setColor(name); - renderOneSettings(); - } - - function renderOneSettings() { - if (state.selectedSection === "appearance") { - document.getElementById("tab-settings").innerHTML = - '
' + - appearanceHtml() + - "
"; - renderSettingsSidebar(); - return; - } - var prefix = state.selectedSection + "."; - var allKeys = Object.keys(state.settings || {}); - var filtered = allKeys.filter((k) => k === prefix.slice(0, -1) || k.startsWith(prefix)); - filtered.sort(); - var hideTopLlms = [ - "core.llm.base_url", - "core.llm.model", - "core.llm.api_key", - "core.llm.adapter", - "core.llm.adapter_path", - "core.llm.thinking_enabled", - ]; - var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources.")); - var sourceMap = {}; - sourceKeys.forEach((k) => { - var parts = k.split("."); - var srcName = parts[3]; - if (!sourceMap[srcName]) sourceMap[srcName] = {}; - sourceMap[srcName][k] = true; - }); - var mcpServerKeys = filtered.filter((k) => ( - k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5 - )); - var mcpServerMap = {}; - mcpServerKeys.forEach((k) => { - var parts = k.split("."); - var srvName = parts[3]; - if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; - mcpServerMap[srvName][k] = true; - }); - var regularKeys = filtered.filter((k) => ( - !k.startsWith("core.llm.sources.") && - hideTopLlms.indexOf(k) === -1 && - !k.startsWith("plugin.mcp.servers.") && - k !== "plugin.mcp.servers" - )); - var html = - '
'; - if ( - regularKeys.length === 0 && - Object.keys(sourceMap).length === 0 && - Object.keys(mcpServerMap).length === 0 && - state.selectedSection !== "plugin.mcp" - ) { - html += - '

' + - escHtml(state.selectedSection) + - '

' + - __("暂无设置项", "No settings") + - "

"; - } else { - regularKeys.forEach((k) => { - var v = state.settings[k]; - var sv = typeof v === "object" ? JSON.stringify(v) : String(v); - var m = state.meta?.[k]; - var shortName = k.split(".").pop().replace(/_/g, " "); - var label = m?.display_name || shortName; - var desc = m?.description || ""; - var typ = m?.type || "string"; - var ph = m?.placeholder || ""; - var opts = m?.options || []; - var inpId = "inp-" + k.replace(/\./g, "_"); - var inp = ""; - if (typ === "bool") { - var chk = sv === "true" ? "checked" : ""; - inp = - '"; - } else if (typ === "select") { - var selOpts = ""; - opts.forEach((o) => { - selOpts += - '"; - }); - inp = - ""; - } else if (typ === "text") { - inp = - ""; - } else { - inp = - ""; - } - var extra = ""; - if (m?.extra) { - m.extra.forEach((f) => { - var fk = (k ? k + "." : "") + f.key; - var fv = state.settings?.[fk]; - var fph = f.placeholder || __("输入", "Enter ") + f.label; - extra += - '
"; - if (f.type === "select") { - var fopts = ""; - if (f.options) - f.options.forEach((o) => { - fopts += - '"; - }); - extra += - ""; - } else { - extra += - ''; - } - extra += "
"; - }); - } - var descHtml = desc - ? '

' + - escHtml(desc) + - "

" - : ""; - html += - '
' + - escHtml(k) + - "
" + - inp + - descHtml + - extra + - '
"; - }); - // LLM Sources - Object.keys(sourceMap) - .sort() - .forEach((src) => { - var baseKey = "core.llm.sources." + src; - var srcData = - state.settings?.[baseKey + ".adapter"] || - state.settings?.[baseKey + ".base_url"] || - ""; - var fields = [ - { - key: "adapter", - label: __("适配器", "Adapter"), - type: "text", - }, - { key: "base_url", label: "Base URL", type: "text" }, - { key: "model", label: __("模型", "Model"), type: "text" }, - { key: "api_key", label: "API Key", type: "text" }, - { - key: "thinking_enabled", - label: __("思考模式", "Thinking Mode"), - type: "select", - options: ["true", "false"], - }, - { - key: "adapter_path", - label: __("适配器路径", "Adapter Path"), - type: "text", - }, - ]; - var headerLabel = mL10n(src, "LLM Source: " + src); - html += '

' + escHtml(headerLabel) + "

"; - fields.forEach((f) => { - var fk = baseKey + "." + f.key; - var fv = state.settings?.[fk] || ""; - var flabel = f.label; - var fieldId = "inp-" + fk.replace(/\./g, "_"); - if (f.type === "select") { - var fopts = ""; - f.options.forEach((o) => { - fopts += - '"; - }); - html += - ""; - } else { - html += - "'; - } - }); - html += - '
' + - '" + - '
"; - }); - if ( - Object.keys(sourceMap).length > 0 || - state.selectedSection === "core.llm" - ) { - html += - '"; - } - // MCP Servers - if ( - state.selectedSection === "plugin.mcp" || - Object.keys(mcpServerMap).length > 0 - ) { - html += - '

' + - __("MCP 服务器", "MCP Servers") + - '

' + - __( - "配置 Model Context Protocol 服务端连接", - "Configure Model Context Protocol server connections", - ) + - "

"; - Object.keys(mcpServerMap) - .sort() - .forEach((srv) => { - var baseKey = "plugin.mcp.servers." + srv; - var fields = [ - { - key: "command", - label: __("启动命令", "Command"), - type: "text", - }, - { key: "url", label: "SSE URL", type: "text" }, - { - key: "args", - label: __("参数(JSON数组)", "Args (JSON array)"), - type: "text", - }, - { - key: "env", - label: __("环境变量(JSON数组)", "Env (JSON array)"), - type: "text", - }, - ]; - html += '

' + escHtml(srv) + "

"; - fields.forEach((f) => { - var fk = baseKey + "." + f.key; - var fv = state.settings?.[fk] || ""; - var fieldId = "inp-" + fk.replace(/\./g, "_"); - html += - "'; - }); - html += - '
' + - '" + - '
"; - }); - html += - '"; - } - } - html += "
"; - document.getElementById("tab-settings").innerHTML = html; - renderSettingsSidebar(); - } - - function markDirty(k) { - var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); - if (inp) inp.style.borderColor = "var(--save-btn-border)"; - } - - async function saveSetting(k) { - var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); - if (!inp) return; - var val; - var m = state.meta?.[k]; - if (m?.type === "bool") { - val = inp.checked ? "true" : "false"; - } else if (m?.type === "select") { - val = inp.value; - } else { - var raw = inp.value; - try { - val = JSON.parse(raw); - } catch (e) { - val = raw; - } - } - try { - var r = await api("/settings", { - method: "PUT", - body: JSON.stringify({ key: k, value: val }), - }); - if (r.status === "ok") { - inp.style.borderColor = ""; - state.settings[k] = val; - toast(__("已保存: ", "Saved: ") + k); - } else { - toast( - __("保存失败: ", "Save failed: ") + (r.error || "unknown"), - true, - ); - } - } catch (e) { - toast(__("保存失败: ", "Save failed: ") + e.message, true); - } - } - - function renderConfigDisabled() { - document.getElementById("tab-settings").innerHTML = - '

' + - __("设置", "Settings") + - '

' + - __("设置面板已加载", "Settings panel loaded") + - "

"; - renderOneSettings(); - } - - function mL10n(key, fallback) { - var meta = state.meta?.[key]; - if (meta?.display_name) return meta.display_name; - return fallback || key; - } - - function showAddSourceDialog() { - var name = prompt( - __( - "输入新 LLM 源名称(如 openai、anthropic):", - "Enter new LLM source name (e.g. openai, anthropic):", - ), - ); - if (!name || !name.trim()) return; - name = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_"); - if (!name) { - toast(__("名称无效", "Invalid name"), true); - return; - } - var keys = [ - "base_url", - "model", - "api_key", - "adapter", - "adapter_path", - "thinking_enabled", - ]; - var values = { - base_url: "https://api." + name + ".com", - model: "", - api_key: "", - adapter: name, - adapter_path: "", - thinking_enabled: "false", - }; - var promises = keys.map((f) => api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "core.llm.sources." + name + "." + f, - value: values[f], - }), - })); - Promise.all(promises) - .then(() => { - toast( - __("源", "Source") + - ' "' + - name + - '" ' + - __( - "已创建,请配置各项参数", - "created, please configure parameters", - ), - ); - renderAll(); - }) - .catch((e) => { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - }); - } - - async function deleteSource(name) { - if ( - !confirm( - __("确认删除源", "Are you sure to delete source") + - ' "' + - name + - '"?', - ) - ) - return; - var base = "core.llm.sources." + name; - var fields = [ - "adapter", - "base_url", - "model", - "api_key", - "thinking_enabled", - "adapter_path", - ]; - try { - for (var f of fields) { - await api("/settings", { - method: "PUT", - body: JSON.stringify({ key: base + "." + f, value: null }), - }); - } - toast( - __("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted"), - ); - renderAll(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - function addMCPSource() { - var name = prompt( - __("输入新 MCP 服务器名称:", "Enter new MCP server name:"), - ); - if (!name || !name.trim()) return; - name = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_"); - if (!name) { - toast(__("名称无效", "Invalid name"), true); - return; - } - var fields = ["command", "url", "args", "env"]; - var values = { command: "", url: "", args: "[]", env: "[]" }; - var promises = fields.map((f) => api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "plugin.mcp.servers." + name + "." + f, - value: values[f], - }), - })); - Promise.all(promises) - .then(() => { - toast( - "MCP " + - __("服务器", "server") + - ' "' + - name + - '" ' + - __("已创建", "created"), - ); - renderAll(); - }) - .catch((e) => { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - }); - } - - async function deleteMCPServer(name) { - if ( - !confirm( - __("确认删除 MCP 服务器", "Are you sure to delete MCP server") + - ' "' + - name + - '"?', - ) - ) - return; - var fields = ["command", "url", "args", "env"]; - try { - for (var f of fields) { - await api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "plugin.mcp.servers." + name + "." + f, - value: null, - }), - }); - } - toast( - "MCP " + - __("服务器", "server") + - ' "' + - name + - '" ' + - __("已删除", "deleted"), - ); - renderAll(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - // ===== Adapters ===== - async function renderAdapters() { - var html = ""; - try { - var r = await api("/adapters"); - var adapters = r.adapters || []; - window._adapters = adapters; - html += - '

' + - __("已加载的适配器", "Loaded Adapters") + - " (" + - adapters.length + - ")

"; - if (adapters.length === 0) { - html += - '

' + - __("暂无适配器", "No adapters") + - "

"; - } else { - html += - ""; - adapters.forEach((a) => { - html += - "" + - '"; - }); - html += "
" + - __("名称", "Name") + - "" + - __("版本", "Version") + - "" + - __("操作", "Actions") + - "
" + - escHtml(a.name) + - "" + - escHtml(a.version || "-") + - "
"; - } - html += "
"; - html += - '

' + - __("上传新适配器", "Upload New Adapter") + - "

" + - "" + - '' + - "" + - '' + - '
"; - } catch (e) { - html += - '

' + - __("加载适配器失败: ", "Failed to load adapters: ") + - escHtml(e.message) + - "

"; - } - document.getElementById("tab-adapters").innerHTML = html; - } - - async function uploadAdapter() { - var name = document.getElementById("adapter-name")?.value; - var code = document.getElementById("adapter-code")?.value; - if (!name || !code) { - toast( - __("名称和代码不能为空", "Name and code cannot be empty"), - true, - ); - return; - } - try { - var r = await api("/adapters", { - method: "POST", - body: JSON.stringify({ name: name, code: code }), - }); - if (r.status === "loaded") { - toast( - __("适配器", "Adapter") + - ' "' + - name + - '" ' + - __("已加载", "loaded"), - ); - renderAdapters(); - } else { - toast( - __("上传失败: ", "Upload failed: ") + (r.error || "unknown"), - true, - ); - } - } catch (e) { - toast(__("上传失败: ", "Upload failed: ") + e.message, true); - } - } - - async function deleteAdapter(name) { - if ( - !confirm( - __("确定删除适配器", "Are you sure to delete adapter") + - ' "' + - name + - '"?', - ) - ) - return; - try { - var r = await api("/adapters/" + encodeURIComponent(name), { - method: "DELETE", - }); - if (r.status === "deleted") { - toast( - __("适配器", "Adapter") + - ' "' + - name + - '" ' + - __("已删除", "deleted"), - ); - renderAdapters(); - } else { - toast(__("删除失败", "Delete failed"), true); - } - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - // ===== Init ===== - async function logout() { - try { - await fetch("/api/v1/logout", { - method: "POST", - credentials: "include", - }); - } catch (e) {} - location.href = "/login"; - } - - renderConfigDisabled(); - // 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 - (async () => { - await loadChatHistory(); - renderAll(); - connectSSE(); - startUptimeTicker(); - startRuntimeTicker(); - startChatTicker(); - maybeShowPersonaWizard(); - })(); - setInterval(renderAll, 15000); - // 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 - // 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 - // syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 - setInterval(() => { - syncChatFromHistory().catch(()=> {}); - }, 30000); +// ===== State ===== +const state = { + status: {}, + kernel: null, + settings: {}, + meta: {}, + runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道) + pluginMeta: {}, + disabledPlugins: [], + settingsPlugins: ["core"], + selectedSection: "core", + messages: [], + chatLoading: false, + chatStage: "", + pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页) + pipelineTimer: null, + stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环) + toolFlash: false, // 本轮新到一条工具调用:本轮渲染后给「工具」格放一次滑入动画 + healthResult: null, + starmapInit: false, + starmapLoading: false, + starmapData: null, + // ===== 星图:跟随 agent 活动 ===== + // 三路活动信号(均已在页面上,无需新数据源): + // 1. toolPulseMs —— SSE tool_call/stage 触发的「脉冲」,实时 + // 2. activity —— /runtime 调度器计数(排队/中断/抢占),3s 轮询 + // 3. grownIds —— /memory/graph/pulse 检出「新长出来」的节点,10s 轮询 + starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 + starmapGrown: {}, // nodeId -> 生长动画截止时间戳 + starmapActivity: null, // /runtime 的调度器快照 + starmapPulseTimer: null, // /memory/graph/pulse 轮询 id + starmapActivityTimer: null, // /runtime 轮询 id + starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 + starmapPulseSince: 0, // 下次 pulse 请求的回看起点(unix 秒) + chatHistory: [], + terminals: [], + cmdHistory: [], + termScreens: {}, + chatStick: true, + pendingTools: [], + eventSource: null, + chatFinalIdx: -1, + chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 + chatTotal: 0, // 服务端历史总条数 + chatHasMore: false, // 是否还有更早历史可向上加载 + chatLastSeq: 0, // 增量轮询游标:已合并到本地的最大消息 seq + lang: localStorage.getItem("ha-lang") || "zh", +}; + +// ===== I18n ===== +window._i18n = { + navOverview: ["概览", "Overview"], + navChat: ["对话", "Chat"], + navStarmap: ["星图", "Star Map"], + navPlugins: ["插件", "Plugins"], + navSettings: ["设置", "Settings"], + navAdapters: ["适配器", "Adapters"], + navKernel: ["内核", "Kernel"], + navLogout: ["退出登录", "Logout"], + themeToggle: ["切换亮色/暗色模式", "Toggle theme"], +}; + +function __(zh, en) { + return state.lang === "en" ? en : zh; +} +function L() { + return state.lang; +} + +function toggleLang() { + state.lang = state.lang === "zh" ? "en" : "zh"; + localStorage.setItem("ha-lang", state.lang); + document.querySelectorAll("[data-i18n]").forEach((el) => { + var k = el.getAttribute("data-i18n"); + var m = window._i18n && window._i18n[k]; + if (m) el.textContent = __(m[0], m[1]); + }); + renderAll(); +} + +function applyI18n() { + var lang = state.lang; + var btn = document.getElementById("lang-btn"); + if (btn) btn.textContent = lang === "zh" ? "EN" : "中"; + document.querySelectorAll("[data-i18n]").forEach((el) => { + var k = el.getAttribute("data-i18n"); + var m = window._i18n && window._i18n[k]; + if (m) el.textContent = lang === "en" ? m[1] : m[0]; + }); +} + +// ===== Theme ===== +var ICON_SUN = + ''; +var ICON_MOON = + ''; + +function setTheme(name) { + document.documentElement.setAttribute("data-theme", name); + var btn = document.getElementById("theme-btn"); + if (btn) btn.innerHTML = name === "light" ? ICON_SUN : ICON_MOON; +} + +function saveTheme(name) { + localStorage.setItem("ha-theme", name); + setTheme(name); +} + +function toggleTheme() { + var cur = document.documentElement.getAttribute("data-theme"); + saveTheme(cur === "light" ? "dark" : "light"); +} + +var PALETTES = { + // mono 放第一位:它现在是默认配色(黑白单调),也应当是选择器里最显眼那个。 + mono: "#c2c7d0", + sakura: "#ff7fac", + cyan: "#2dd4bf", + violet: "#a78bfa", + emerald: "#34d399", + amber: "#fbbf24", + blue: "#60a5fa", +}; + +function setColor(name) { + document.documentElement.setAttribute("data-color", name); + localStorage.setItem("ha-color", name); + // 配色变了要强制重画运行态:拓扑的归属配色跟着 data-color 走, + // 而数据签名没变,不置空 _rtSig 的话图会一直停在旧色。 + _rtSig = null; + if (document.getElementById("rt-sec-topo")) { + try { + renderRuntime(); + } catch (e) {} + } + var pop = document.getElementById("palette-pop"); + if (!pop) return; + var btns = pop.querySelectorAll("button.cdot"); + for (var i = 0; i < btns.length; i++) { + btns[i].className = + btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot"; + } +} + +function applyBgImg(url) { + url = (url || "").trim(); + if (!url) { + document.documentElement.style.setProperty("--bg-img", "none"); + localStorage.removeItem("ha-bg-img"); + return; + } + document.documentElement.style.setProperty( + "--bg-img", + 'url("' + url.replace(/"/g, '\\"') + '")', + ); + localStorage.setItem("ha-bg-img", url); +} + +function applyBgBlur(n) { + n = Math.max(0, Math.min(30, Number(n) || 0)); + document.documentElement.style.setProperty("--bg-blur", String(n)); + localStorage.setItem("ha-bg-blur", String(n)); + var v = document.getElementById("bg-blur-val"); + if (v) v.textContent = n + "px"; + var r = document.getElementById("bg-blur-range"); + if (r) r.value = String(n); +} + +function toggleAppearance() { + var pop = document.getElementById("palette-pop"); + if (!pop) return; + var on = pop.classList.contains("on"); + if (!pop.querySelector("button.cdot")) { + var cur = localStorage.getItem("ha-color") || "mono"; + var img = localStorage.getItem("ha-bg-img") || ""; + var blur = localStorage.getItem("ha-bg-blur") || "0"; + var dots = ""; + Object.keys(PALETTES).forEach((k) => { + dots += + '"; + }); + pop.innerHTML = + "

主题色

" + + dots + + "
" + + '

背景图片 URL

' + + '' + + '
' + + '' + + '模糊 ' + + '' + + '' + + blur + + "px
"; + setColor(cur); + var rr = document.getElementById("bg-blur-range"); + if (rr) rr.value = blur; + var vv = document.getElementById("bg-blur-val"); + if (vv) vv.textContent = blur + "px"; + } + pop.classList.toggle("on", !on); +} + +(function initAppearance() { + var img = localStorage.getItem("ha-bg-img"); + var blur = localStorage.getItem("ha-bg-blur"); + if (img) applyBgImg(img); + if (blur) applyBgBlur(blur); +})(); + +(() => { + var saved = localStorage.getItem("ha-theme"); + setTheme(saved || "light"); + // 默认配色 = 黑白:必须**主动** setColor 一次。只设 data-theme 时 + // base 主题仍是樱粉(:root 的 --accent 就是粉色),不写 data-color 就 + // 会默认花。用户选过别的颜色时以 localStorage 为准。 + setColor(localStorage.getItem("ha-color") || "mono"); + var sb = localStorage.getItem("ha-sidebar"); + if (sb === "1" || (!sb && window.innerWidth <= 768)) { + document.body.classList.add("sidebar-collapsed"); + } +})(); + +// 字节数人性化显示(附件卡片用) +function formatBytes(n) { + if (!n || n <= 0) return ""; + var units = ["B", "KB", "MB", "GB"]; + var i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; +} + +// ===== Utility ===== +function renderMd(text) { + if (typeof text !== "string") text = String(text || ""); + var html; + if (typeof marked !== "undefined") { + try { + html = marked.parse(text); + } catch (e) { + html = escHtml(text); + } + } else { + html = "
" + escHtml(text) + "
"; + } + if ( + typeof DOMPurify !== "undefined" && + typeof DOMPurify.sanitize === "function" + ) { + try { + return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); + } catch (e) {} + } + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*"[^"]*"/gi, "") + .replace(/\son\w+\s*=\s*'[^']*'/gi, "") + .replace(/javascript:/gi, ""); +} +function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function toast(m, isError) { + var t = document.getElementById("toast"); + t.textContent = m; + t.className = "toast" + (isError ? " error" : ""); + t.style.display = "block"; + clearTimeout(t._hideTimer); + t._hideTimer = setTimeout(() => { + t.style.display = "none"; + }, 3000); +} + +// ===== 8.6 Unified toast + confirm dialog ===== + +// ===== 8.4 Card 3D tilt + cursor glow ===== +document.addEventListener("mousemove", (e) => { + var card = e.target.closest ? e.target.closest(".card.tilt") : null; + if (card) { + var r = card.getBoundingClientRect(); + card.style.setProperty("--mx", e.clientX - r.left + "px"); + card.style.setProperty("--my", e.clientY - r.top + "px"); + var rx = ((e.clientY - r.top) / r.height - 0.5) * -4; + var ry = ((e.clientX - r.left) / r.width - 0.5) * 4; + card.style.transform = + "perspective(1000px) rotateX(" + + rx.toFixed(2) + + "deg) rotateY(" + + ry.toFixed(2) + + "deg) translateY(-1px)"; + } +}); +document.addEventListener("mouseleave", (e) => { + var card = e.target.closest ? e.target.closest(".card.tilt") : null; + if (card) card.style.transform = ""; +}); + +// ===== 首启人格向导 ===== +// 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 +// 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; +// 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 +async function maybeShowPersonaWizard() { + var st; + try { + st = await api("/persona"); + } catch (e) { + return; // 拿不到状态就不打扰用户 + } + if (!st || st.initialized) return; + var ov = document.createElement("div"); + ov.className = "confirm-overlay"; + ov.style.display = "flex"; + ov.innerHTML = + '
' + + "

" + + escHtml(__("人格设定", "Persona")) + + "

" + + "

" + + escHtml( + __( + "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", + 'First run: pick your assistant\'s persona. "Use default" is fine (change it later in Settings); custom content takes effect after the next restart.', + ), + ) + + "

" + + '
' + + '' + + '
' + + '" + + '" + + '" + + "
"; + document.body.appendChild(ov); + var ta = ov.querySelector(".persona-ta"); + var warn = ov.querySelector(".persona-warn"); + var customOpen = false; + if (st.file_override) { + warn.style.display = "block"; + warn.textContent = __( + "注意:检测到 personal/personal.md,它优先于这里的设置。", + "Note: personal/personal.md exists and takes precedence over this choice.", + ); + } + function close() { + ov.remove(); + } + async function submit(mode, content) { + try { + var r = await api("/persona", { + method: "POST", + body: JSON.stringify({ mode: mode, content: content || "" }), + }); + if (r && r.restart_required) + toast(__("已保存,重启后生效", "Saved; takes effect after restart")); + else toast(__("已保存", "Saved")); + } catch (e) { + toast(__("保存失败:", "Save failed: ") + e, true); + } + close(); + } + ov.querySelectorAll("button[data-mode]").forEach((b) => { + b.onclick = () => { + var mode = b.getAttribute("data-mode"); + if (mode !== "custom") { + submit(mode); + return; + } + if (!customOpen) { + // 第一次点:展开文本域并预填当前人格 + customOpen = true; + ta.style.display = "block"; + ta.value = st.current_prompt || ""; + ta.focus(); + return; + } + if (!ta.value.trim()) { + toast(__("内容不能为空", "Content cannot be empty"), true); + return; + } + submit("custom", ta.value); + }; + }); +} + +// ===== API ===== +async function api(p, o) { + var opts = { + credentials: "include", + headers: { "Content-Type": "application/json", ...o?.headers }, + ...o, + }; + // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) + if (o?.body instanceof FormData) { + delete opts.headers["Content-Type"]; + } + var r = await fetch("/api/v1" + p, opts); + if (r.status === 401) { + location.href = "/login"; + throw new Error("unauthorized"); + } + if (opts.raw) return r; + var ct = r.headers.get("content-type") || ""; + if (ct.includes("json")) return r.json(); + return r.text(); +} + +// ===== Navigation ===== +function switchTab(n) { + document.querySelectorAll(".tab-content").forEach((e) => { + e.classList.remove("active"); + }); + var el = document.getElementById("tab-" + n); + if (el) el.classList.add("active"); + document.querySelectorAll("nav a").forEach((e) => { + e.classList.remove("active"); + }); + var match = document.querySelector('nav a[onclick*="' + n + '"]'); + if (match) match.classList.add("active"); + var crumb = document.querySelector(".crumb-current"); + if (crumb) { + var crumbKey = "nav" + n.charAt(0).toUpperCase() + n.slice(1); + var m = window._i18n && window._i18n[crumbKey]; + crumb.setAttribute("data-i18n", crumbKey); + if (m) crumb.textContent = __(m[0], m[1]); + else crumb.textContent = n; + } + if (window.innerWidth <= 768) { + document.body.classList.add("sidebar-collapsed"); + } + if (n === "chat") { + state.chatStick = true; + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + initChatDragDrop(); + } + if (n === "starmap") { + // 独立星图页签:把 canvas 搬过来并按新容器尺寸重算。 + // 必须在 renderAll 之前做,否则 renderChatStarmap 会按上一次的 + // 活跃容器(总览页)算尺寸。 + var sc = document.getElementById("sm-container-page"); + if (sc) { + if (starmapRen) { + if (starmapRen.domElement.parentElement !== sc) { + sc.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } + onStarmapResize(); + } else if (!state.starmapInit && !state.starmapLoading) { + loadChatStarmapData(); + } + } + } + renderAll(); +} + +function toggleSidebar() { + document.body.classList.toggle("sidebar-collapsed"); + var saved = localStorage.getItem("ha-sidebar"); + localStorage.setItem( + "ha-sidebar", + document.body.classList.contains("sidebar-collapsed") ? "1" : "0", + ); +} + +// ===== Tab Render Dispatch ===== +async function renderAll() { + try { + var s = await api("/status"); + state.status = s; + state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; + } catch (e) {} + try { + state.kernel = await api("/kernel"); + } catch (e) {} + try { + var s = await api("/settings"); + state.settings = s.settings || {}; + state.meta = s.meta || {}; + state.settingsPlugins = s.plugins || ["core"]; + state.pluginMeta = s.plugin_meta || {}; + state.disabledPlugins = s.disabled_plugins || []; + } catch (e) {} + try { + state.installedPlugins = await api("/plugins"); + } catch (e) {} + try { + await loadProxyServices(); + } catch (e) {} + try { + await loadTerminals(); + } catch (e) {} + try { + await loadCmdHistory(); + } catch (e) {} + try { + renderOverview(); + } catch (e) { + console.error("renderOverview", e); + } + try { + await loadRuntime(); + } catch (e) { + console.error("loadRuntime", e); + } + try { + renderChat(); + } catch (e) { + console.error("renderChat", e); + } + try { + renderChatStarmap(); + } catch (e) { + console.error("renderChatStarmap", e); + } + try { + renderStarmapTab(); + } catch (e) { + console.error("renderStarmapTab", e); + } + try { + renderPlugins(); + } catch (e) { + console.error("renderPlugins", e); + } + try { + renderKernel(); + } catch (e) { + console.error("renderKernel", e); + } + try { + renderOneSettings(); + } catch (e) { + console.error("renderOneSettings", e); + } + try { + renderAdapters(); + } catch (e) { + console.error("renderAdapters", e); + } + try { + if (!window.matchMedia("(hover: none)").matches) { + document + .querySelectorAll( + "#tab-overview .card, #tab-plugins .card, #tab-kernel .card", + ) + .forEach((c) => { + if (!c.querySelector(".tilt-glow")) { + var g = document.createElement("span"); + g.className = "tilt-glow"; + c.appendChild(g); + c.classList.add("tilt"); + } + }); + } + } catch (e) {} + applyI18n(); +} + +// fmtBuild 把构建身份压成一行:commit + 构建时间(日期部分)。 +// +// 未注入 ldflags 的本地构建会得到 "unknown",此时只显示 SDK 版本, +// 避免把 "unknown / unknown" 这种噪音摆在概览卡上。 +function fmtBuild(b, s) { + var parts = []; + var commit = (b && b.commit) || (s && s.commit) || ""; + if (commit && commit !== "unknown") parts.push(commit); + var bt = (b && b.build_time) || (s && s.build_time) || ""; + if (bt && bt !== "unknown") { + // 2026-09-03T05:55:35Z → 2026-09-03 + parts.push(String(bt).split("T")[0]); + } + var sdkv = (b && b.sdk_compatible) || (s && s.sdk_version) || ""; + if (sdkv) parts.push("SDK " + sdkv); + return parts.length ? parts.join(" · ") : "-"; +} + +function fmtUptime(ms) { + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + s = s % 60; + if (m < 60) return m + "m " + s + "s"; + var h = Math.floor(m / 60); + m = m % 60; + return h + "h " + m + "m " + s + "s"; +} + +var uptimeTick = null; +function startUptimeTicker() { + if (uptimeTick) clearInterval(uptimeTick); + uptimeTick = setInterval(() => { + var el = document.querySelector("#ov-uptime"); + if (el && state.startedAt) { + var now = Date.now(); + el.textContent = fmtUptime(now - state.startedAt); + } else if (!state.startedAt) { + var el2 = document.querySelector("#ov-uptime"); + if (el2) el2.textContent = "-"; + } + }, 1000); +} + +// ===== Overview ===== + +// ===== 运行态面板:把内核的调度器/驻留子/通道画出来 ===== +// +// 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), +// 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 +// +// 面板从上到下:四个数字块回答"现在忙不忙";阶段管道滑块回答"这一轮走到哪"; +// 五条队列条形 + 中断栈回答"堵在哪一级/压了几层";最后的分带拓扑回答 +// "哪条输入喂给哪个 agent、哪个 agent 往哪条输出写、各自负载多高"。 +// 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), +// 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 +var RT_LEVELS = [ + { lv: 4, name: "L4", desc: "内核独占", cls: "rt-lv-4" }, + { lv: 3, name: "L3", desc: "交互", cls: "rt-lv-3" }, + { lv: 2, name: "L2", desc: "消息", cls: "rt-lv-2" }, + { lv: 1, name: "L1", desc: "后台", cls: "rt-lv-1" }, +]; +var RT_CAP_NAMES = { + 1: "text", + 2: "file", + 4: "image", + 8: "audio", + 16: "structured", +}; + +// _rtSig 缓存上一次渲染的数据签名。 +// +// 为什么必须缓存:运行态每 3s 轮询一次,数据绝大多数时候是**没变**的; +// 无条件 `innerHTML =` 会把整块 DOM(含各级条的 transition)每 3s 重建一遍, +// 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。 +var _rtSig = null; + +// morph:把容器的 DOM 就地「形变」成 html 描述的样子。 +// +// 为什么不用 innerHTML = html:整块重建会把**没变的**节点也换掉 —— +// SVG 过渡从头播、图片重解码、展开态丢失、滚动锚点重置。运行态每几秒 +// 刷一次,未变节点占绝大多数,那些闪烁就是这么来的。 +// +// 做法:按「子节点位置 + nodeName」递归对账。同名元素复用同一个 DOM 节点, +// 只同步发生变化的属性与文本;只有标签真的不同才替换。元素身份不变 ⇒ +// CSS 过渡继续、不闪。 +function morph(container, html) { + var tpl = document.createElement("div"); + tpl.innerHTML = html; + morphChildren(container, tpl); +} +function morphChildren(oldParent, newParent) { + var olds = Array.prototype.slice.call(oldParent.childNodes); + var news = Array.prototype.slice.call(newParent.childNodes); + var n = Math.max(olds.length, news.length); + for (var i = 0; i < n; i++) { + var o = olds[i], + x = news[i]; + if (!o && x) { + oldParent.appendChild(x); + continue; + } + if (o && !x) { + oldParent.removeChild(o); + continue; + } + if (o.nodeType !== x.nodeType || o.nodeName !== x.nodeName) { + oldParent.replaceChild(x, o); + continue; + } + if (o.nodeType === 3 || o.nodeType === 8) { + if (o.nodeValue !== x.nodeValue) o.nodeValue = x.nodeValue; + continue; + } + morphAttrs(o, x); + morphChildren(o, x); + } +} +function morphAttrs(o, x) { + var i, a; + for (i = o.attributes.length - 1; i >= 0; i--) { + a = o.attributes[i]; + if (!x.hasAttribute(a.name)) o.removeAttribute(a.name); + } + for (i = 0; i < x.attributes.length; i++) { + a = x.attributes[i]; + if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value); + } +} + +// rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。 +function rtSlider(value, max, label, display) { + var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( + '' + + escHtml(label) + + '' + + '' + + escHtml(String(display === undefined ? value : display)) + + "" + ); +} + +// rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。 +function rtMini(value, max, label) { + var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( + '' + + value + + "" + ); +} + +function rtTile(num, label, sub, pct, active, warn) { + return ( + '
' + + num + + '
' + + label + + "
" + + (sub ? '
' + sub + "
" : "") + + '
' + ); +} + +function rtCapsHtml(caps) { + var out = ""; + Object.keys(RT_CAP_NAMES).forEach((bit) => { + if (caps & Number(bit)) { + out += '' + RT_CAP_NAMES[bit] + ""; + } + }); + return out ? '' + out + "" : ""; +} + +// 归属配色:根用青色,驻留子轮转其余颜色(同一张图里能一眼分清谁是谁)。 +var RT_OWNER_COLORS = [ + "#88c0d0", + "#b48ead", + "#a3be8c", + "#ebcb8b", + "#d08770", + "#8fbcbb", +]; +// 黑白配色下,拓扑归属也用灰阶 —— 否则整页只剩这张图还是一片彩。 +var RT_OWNER_COLORS_MONO = [ + "#e6e9ef", + "#c2c7d0", + "#9aa0ad", + "#7b818d", + "#5f6570", + "#454a55", +]; +function rtOwnerColors() { + return document.documentElement.getAttribute("data-color") === "mono" + ? RT_OWNER_COLORS_MONO + : RT_OWNER_COLORS; +} + +// rtOwnerGroups 把 inputch 按**归属**分组(owner == 根 agent id 与 "" 归一为根)。 +function rtOwnerGroups(inputs, residents, rootID) { + var order = []; + var map = {}; + inputs.forEach((c) => { + var o = c.owner || ""; + if (o === rootID) o = ""; + if (!map[o]) { + map[o] = []; + order.push(o); + } + map[o].push(c); + }); + // 驻留子即使一条 inputch 都没划到也要出现——否则「子存在但看不见」 + // 与「子不存在」无法区分。 + residents.forEach((r) => { + var o = r.id || ""; + if (o && o !== rootID && !map[o]) { + map[o] = []; + order.push(o); + } + }); + order.sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a < b ? -1 : 1; + }); + var ci = 0; + var OW = rtOwnerColors(); + return order.map((o) => { + var res = null; + residents.forEach((r) => { + if (r.id === o) res = r; + }); + var list = map[o] || []; + return { + owner: o, + child: o !== "", + label: + o === "" + ? __("根 agent / 内核默认", "root agent / kernel default") + : __("驻留子 ", "resident ") + o, + color: o === "" ? OW[0] : OW[1 + (ci++ % (OW.length - 1))], + list: list, + count: list.length, + res: res, + }; + }); +} + +// ---- 阶段管道滑块 ---- +// +// 七阶段与内核 sdk.Stage 一一对应(顺序即执行顺序),由 SSE `stage` 事件驱动: +// 哪个阶段在执行,滑块就滑到哪一格。空闲时整条管道降透明度,不做假动画。 +// +// 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", +// 两者合起来才是运行态。此前它只是对话页一个 10px 的角标,等于看不见。 +// 阶段管道:**循环流程 + 本轮轨迹**,不是单向滑块。 +// +// 一轮里工具调用会反复回到「行动后」再接下一个工具(post_action → +// before_toolcall → after_toolcall → post_action …),还可能中途多次 +// 输出,所以线性滑块本身就是错的表述。这里画成 +// 输入 → 行动 ⇄(工具) → 输出 → 结束 +// (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**: +// 普通工具与 output_* 输出通道调用用不同配色区分开来。 +// 页面上**任何位置都不用 emoji/符号字符充当图标** —— 一律内联 SVG, +// 24x24 viewBox + currentColor stroke,随主题与状态变色,不额外引资源。 +var RT_ICO = { + in: '', + act: '', + tool: '', + out: '', + done: '', + loop: '', + bolt: '', + caret: + '', +}; + +var RT_PIPE_GROUPS = [ + { zh: "输入", en: "in", ico: "in" }, + { zh: "行动", en: "act", ico: "act" }, + { zh: "工具", en: "tool", ico: "tool", loop: true }, + { zh: "输出", en: "out", ico: "out" }, + { zh: "结束", en: "done", ico: "done" }, +]; +function rtPhaseGroup(phase) { + switch (phase) { + case "on_input": + return 0; + case "pre_action": + case "post_action": + return 1; + case "before_toolcall": + case "after_toolcall": + return 2; + case "before_output": + return 3; + case "after_output": + return 4; + } + return -1; +} +// rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名。 +function rtShortTool(name) { + var n = String(name || ""); + var i = n.lastIndexOf("__"); + if (i >= 0) n = n.slice(i + 2); + return n.length > 14 ? n.slice(0, 13) + "…" : n; +} +// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。 +// +// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩一串文字, +// 看上去就是「这块空着」。格槽在 0 时仍有形状,占用多少一眼可数, +// 也不用为了「看得见」而给 0 画一条假进度。 +function rtSlots(depth, slots, extraCls) { + var n = Math.max(5, Math.min(16, slots || 5)); + var d = depth || 0; + var out = ''; + for (var i = 0; i < n; i++) + out += ''; + // 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去, + // 多一个兄弟节点会被挤到下一行,整块队列就串了。 + if (d > n) out += '+' + (d - n) + ""; + return out + ""; +} +// rtChipEl 画一枚事件 chip。工具与输出通道调用各有配色;名称包进 +// .rt-chip-t,让窄框里由它做省略号截断(.rt-chip 本身是 flex 容器, +// text-overflow 对直接子文本不生效,会被硬切掉半截)。 +function rtChipEl(t) { + var kind = t.kind || "stage"; + var ico = kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : ""; + return ( + '' + + ico + + '' + + escHtml(t.short || t.label) + + "" + + (t.n > 1 ? 'x' + t.n + "" : "") + + "" + ); +} +// rtPipelineHtml 画阶段管道:**五个等大的表框**,每框里是本阶段本轮发生的事。 +// +// 为什么不是「小圆点 + 连接线 + 9px 小字」:那种画法在总览里几乎读不出 +// 「现在走到哪一步」,尺寸也远小于旁边的 KPI 框。现在与中断队列、KPI 用同 +// 一套视觉语言(等大框 + 大号加粗标题),并且事件直接落在它所属的框里, +// 所以「这一步发生了什么」不需要靠颜色或图例去猜。 +function rtPipelineHtml(phase, trail) { + var g = rtPhaseGroup(phase); + var cells = RT_PIPE_GROUPS.map((s, i) => { + var items = (trail || []).filter((t) => (t.g | 0) === i); + // 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都 + // 追加成 chip,这格会被撑成一长条,读者反而看不出「现在正在调什么」。 + // 所以它只保留**最新一条**,旧条在同一行视口里向上滚走 + // (rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。 + var cls = "rt-pipe-events"; + var body; + if (!items.length) { + body = + '' + __("无", "none") + ""; + } else if (s.loop) { + cls += " rt-pipe-scroll"; + var total = 0; + for (var k = 0; k < items.length; k++) total += items[k].n || 1; + body = + rtChipEl(items[items.length - 1]) + + 'x' + + total + + ""; + } else { + body = items.map(rtChipEl).join(""); + } + return ( + '
' + + '
' + + RT_ICO[s.ico] + + "" + + __(s.zh, s.en) + + "" + + (s.loop + ? '' + + RT_ICO.loop + + "" + : "") + + "
" + + '
' + + body + + "
" + + "
" + ); + }).join(""); + return ( + '
' + + __("阶段管道", "Stage pipeline") + + (g < 0 ? " " + __("(空闲)", "(idle)") : "") + + "
" + + '
' + + cells + + "
" + ); +} + +// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里。 +// 同一阶段重复出现同一条(如同一工具连调 3 次)只累加计数,不刷屏。 +function rtTrailPush(g, kind, label, short) { + if (!state.stageTrail) state.stageTrail = []; + var arr = state.stageTrail; + var last = arr.length ? arr[arr.length - 1] : null; + if (last && last.g === g && last.kind === kind && last.short === short) { + last.n = (last.n || 1) + 1; + return; + } + arr.push({ g: g, kind: kind, label: label, short: short, n: 1 }); + if (arr.length > 24) arr.shift(); +} + +// rtFlashLatestTool 让「工具」格里最新那条做一次「向上滚入」。 +// +// morph 是就地改文本:新工具到来时 chip 节点不会被替换,纯 CSS 的 +// animation 因此不会自动重放。这里摘类 → 强制 reflow → 重加类,重置动画 +// 时间轴。prefers-reduced-motion 由样式表统一压到 0.01ms,无需在此判断。 +function rtFlashLatestTool() { + var box = document.querySelector(".rt-pipe-events.rt-pipe-scroll"); + if (!box) return; + var chip = box.querySelector(".rt-chip"); + if (!chip) return; + chip.classList.remove("rt-chip-enter"); + void chip.offsetWidth; // 强制 reflow:少了这句,重加类不会重启动画 + chip.classList.add("rt-chip-enter"); +} + +// ---- per-agent 负载 ---- +// +// 负载 = 该 agent **自己的**调度器积压折算成的百分比。 +// +// 为什么按级别加权:四级中断里 L4 是内核独占、L3 是交互,堵在 L4 一条比堵在 +// L1 五条更严重;中断栈深度意味着有现场被压着。权重是启发式的("满载"没有 +// 硬定义),因此函数做成饱和式 backlog/(backlog+K):单调、上界 100、不越界。 +// context_full 单独加分:子上下文满了以后每轮都要压缩,本身就是高负载。 +function rtLoadPct(sc, ctxFull) { + sc = sc || {}; + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var backlog = + (sc.ready_queue_depth || 0) + + (sc.pending_interrupts || 0) * 1.5 + + (sc.suspend_stack || 0) * 2 + + (q[1] || 0) * 1 + + (q[2] || 0) * 1.2 + + (q[3] || 0) * 1.5 + + (q[4] || 0) * 2; + var pct = 100 * (backlog / (backlog + 8)); + if (ctxFull) pct += 15; + return Math.max(0, Math.min(100, Math.round(pct))); +} + +// rtAgents 把「inputch 归属 + 驻留子 + 各自的调度器积压」整理成每个 agent 一行。 +// 根 agent 的积压来自 rt.scheduler;驻留子的来自它自己的 ResidentStatus +// (见 internal/sdk/status.go 的 ReadyQueueDepth 等四项)。 +function rtAgents(rt) { + var rootID = rt.agent_id || ""; + var groups = rtOwnerGroups( + rt.input_channels || [], + rt.residents || [], + rootID, + ); + var outChans = (rt.channels || []).filter( + (c) => c.direction === "out" || c.direction === "io", + ); + return groups.map((g) => { + var id = g.owner || rootID; + var outputs = []; + var seen = {}; + function add(name) { + if (!name || seen[name]) return; + seen[name] = 1; + outputs.push(name); + } + if (g.child) { + // 驻留子:优先用父显式授权的输出;没有授权登记时退回它自己 inputch 的 + // 默认回程(`output` 字段)。 + ((g.res && g.res.allowed_outputs) || []).forEach(add); + if (!outputs.length) + g.list.forEach((c) => { + add(c.output); + }); + } else { + // 根 agent 可以写任何输出通道;上限交给渲染侧截断。 + outChans.forEach((c) => { + add(c.name); + }); + } + var load = g.child + ? rtLoadPct( + { + ready_queue_depth: g.res && g.res.ready_queue_depth, + pending_interrupts: g.res && g.res.pending_interrupts, + suspend_stack: g.res && g.res.suspend_stack, + interrupt_queues: (g.res && g.res.interrupt_queues) || [], + }, + g.res && g.res.context_full, + ) + : rtLoadPct(rt.scheduler || {}, false); + return { + id: id, + child: g.child, + label: g.label, + color: g.color, + inputs: g.list, + outputs: outputs, + load: load, + res: g.res || null, + }; + }); +} + +// 连线路径表:通道名 → path d。光点动画靠它把「哪个通道」翻成一条曲线。 +var _rtEdgeIn = {}; +var _rtEdgeOut = {}; +var RT_SVGNS = "http://www.w3.org/2000/svg"; + +// rtAgentTopology 画「通道 → agent → 通道」的分带拓扑。 +// +// 每个 agent 一条横带:左边是归它的 inputch,中间是它自己(圆环 = 负载), +// 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 +// 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 +// 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 +// rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是 +// 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块, +// 观感就是"间距不对"。 +function rtTopoWidth() { + var host = document.getElementById("rt-sec-topo"); + var w = host ? host.clientWidth : 0; + if (!w) { + var panel = document.getElementById("rt-panel"); + w = panel ? panel.clientWidth : 0; + } + if (!w || w < 360) w = 900; + return Math.round(w) - 4; +} + +// rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。 +// 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。 +function rtClip(text, maxPx, fontPx) { + var s = String(text == null ? "" : text); + var w = 0; + var i = 0; + for (; i < s.length; i++) { + var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56; + if (w + cw > maxPx) break; + w += cw; + } + if (i >= s.length) return s; + return s.slice(0, Math.max(1, i - 1)) + "…"; +} + +function rtAgentTopology(agents) { + var W = rtTopoWidth(); + var ROW = 30, + BOX_H = 20, + GAP = 14, + NODE_R = 20, + MAX_OUT = 8; + // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。 + var IN_X = 0, + IN_W = Math.round(W * 0.32); + var NX = Math.round(W * 0.44), + OUT_X = Math.round(W * 0.56), + OUT_W = W - Math.round(W * 0.56) - 2; + var out = []; + _rtEdgeIn = {}; + _rtEdgeOut = {}; + function esc(s) { + return String(s == null ? "" : s).replace(/[<>&]/g, (m) => + m === "<" ? "<" : m === ">" ? ">" : "&", + ); + } + function pathD(x1, y1, x2, y2) { + var mx = (x1 + x2) / 2; + return ( + "M" + + x1 + + " " + + y1 + + " C" + + mx + + " " + + y1 + + " " + + mx + + " " + + y2 + + " " + + x2 + + " " + + y2 + ); + } + var y = 8; + if (!agents.length) { + out.push( + '' + + __("暂无通道 / agent", "no channels / agents") + + "", + ); + y = 44; + } + agents.forEach((a) => { + var nin = a.inputs.length; + var nout = Math.min(a.outputs.length, MAX_OUT); + var rows = Math.max(nin, nout, 1); + var truncated = a.outputs.length > MAX_OUT; + // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的 + // 名字会溢出到上/下一个带上("间距不对"的一个来源)。 + var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20); + var contentH = Math.max( + rows * ROW, + nodeBlock, + truncated ? nout * ROW + 18 : 0, + ); + var bandH = contentH + GAP; + var cy = y + contentH / 2; + // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。 + var inTop = y + (contentH - nin * ROW) / 2; + var outTop = y + (contentH - nout * ROW) / 2; + out.push( + '', + ); + out.push( + '', + ); + + // 输入通道 → agent + a.inputs.forEach((c, i) => { + var ry = inTop + i * ROW + ROW / 2; + out.push( + '', + ); + out.push( + '' + + esc(rtClip(c.name, IN_W - 44, 12)) + + "" + + esc(c.name) + + "", + ); + if (c.capacity) { + out.push( + '' + + esc(c.capacity) + + "", + ); + } + var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy); + _rtEdgeIn[c.name] = d; + out.push( + '', + ); + }); + + // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) + var C = 2 * Math.PI * (NODE_R - 3); + out.push( + '', + ); + out.push( + '', + ); + out.push( + '', + ); + out.push( + '' + + a.load + + "", + ); + out.push( + '' + + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + + "", + ); + if (a.res && a.res.context_full) { + out.push( + '' + + __("上下文已满", "ctx full") + + "", + ); + } + + // agent → 输出通道 + a.outputs.slice(0, MAX_OUT).forEach((name, i) => { + var ry = outTop + i * ROW + ROW / 2; + out.push( + '', + ); + out.push( + '' + + esc(rtClip(name, OUT_W - 20, 12)) + + "" + + esc(name) + + "", + ); + var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); + _rtEdgeOut[a.id + "\u0000" + name] = d; + out.push( + '', + ); + }); + if (a.outputs.length > MAX_OUT) { + out.push( + '+' + + (a.outputs.length - MAX_OUT) + + " " + + __("更多", "more") + + "", + ); + } + y += bandH; + }); + var H = Math.max(60, y); + return ( + '
' + + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + + "
" + + '
' + + out.join("") + + "
" + ); +} + +// rtSpark 让一个光点沿一条连线跑一趟。 +// +// 用 SMIL 而不是 CSS offset-path / rAF:它由浏览器自己按 +// SVG 用户坐标跑,不需要前端维护动画循环,也没有 path() 在缩放 viewBox 下 +// 的坐标换算问题。跑完把节点摘掉,避免 DOM 累积。 +function rtSpark(pathD, color) { + if (!pathD) return; + var svg = document.getElementById("rt-topo-svg"); + if (!svg) return; + var c = document.createElementNS(RT_SVGNS, "circle"); + c.setAttribute("r", "3.2"); + c.setAttribute("fill", color || "#ffffff"); + c.setAttribute("class", "rt-spark"); + var am = document.createElementNS(RT_SVGNS, "animateMotion"); + am.setAttribute("dur", "0.85s"); + am.setAttribute("path", pathD); + am.setAttribute("fill", "freeze"); + am.setAttribute("begin", "0s"); + c.appendChild(am); + svg.appendChild(c); + setTimeout(() => { + if (c.parentNode) c.parentNode.removeChild(c); + }, 950); +} +// 输入:某条 inputch 来消息了(SSE channel_input)。 +function rtSparkInput(source) { + rtSpark(_rtEdgeIn[source], "#88c0d0"); +} +// 输出:某个 agent 往某条 outputch 写了东西(SSE agent_output)。 +function rtSparkOutput(agentID, channel) { + if (!channel || !agentID) return; + rtSpark(_rtEdgeOut[agentID + "\u0000" + channel], "#ff7fac"); +} + +function renderRuntime() { + var el = document.getElementById("rt-panel"); + if (!el) return; + var rt = state.runtime; + if (!rt) { + el.innerHTML = + '
' + + __("运行态数据不可用", "runtime unavailable") + + "
"; + _rtSig = null; + return; + } + var sc = rt.scheduler || {}; + var residents = rt.residents || []; + var channels = rt.channels || []; + var inputs = rt.input_channels || []; + + // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 + // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, + // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 + var sig = JSON.stringify([ + sc, + residents, + channels, + inputs, + state.pipelinePhase, + (state.stageTrail || []) + .map( + (t) => + t.g + ":" + t.kind + ":" + (t.short || t.label) + "x" + (t.n || 1), + ) + .join(","), + ]); + if (sig === _rtSig) return; + _rtSig = sig; + + // 外壳只建一次;随后**逐段**更新。 + // + // 为什么不是整块 innerHTML:设备通道列表这类数据本来就会来回变(实测远程设备 + // 通道 11→9 条),整块重建会把没变的段落(含条形 transition)也推倒重来, + // 视觉上就是「一闪一闪」。逐段比较后只替换真正变了的那一段。 + if (!document.getElementById("rt-sec-tiles")) { + el.innerHTML = + '

' + + __("运行态", "Runtime") + + "

" + + '
' + + '
' + + '
' + + '
' + + '
' + + "
"; + } + function put(sec, html) { + var n = document.getElementById("rt-sec-" + sec); + if (!n || n.__sig === html) return; // 该段没变:一个字节都不动 + n.__sig = html; + // 变了也只「形变」到新样子:复用未变的子节点,而不是整块重建。 + morph(n, html); + } + + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var pending = sc.pending_interrupts || 0; + var ready = sc.ready_queue_depth || 0; + var stack = sc.suspend_stack || 0; + var maxStack = sc.max_suspend_depth || 4; + var frames = sc.suspend_frames || []; + var byLv = sc.interrupts_by_level || []; + var preLv = sc.preempts_by_level || []; + + // ---- 段 1:四个数字块 ---- + var h = '
'; + h += rtTile( + ready, + __("排队", "Ready"), + "", + ready ? Math.min(100, ready * 20) : 0, + ready > 0, + ); + h += rtTile( + pending, + __("中断", "Pending"), + "", + pending ? Math.min(100, pending * 25) : 0, + pending > 0, + pending > 0, + ); + h += rtTile( + stack + "/" + maxStack, + __("栈", "Stack"), + "", + (stack / (maxStack || 4)) * 100, + stack > 0, + ); + var rFull = residents.filter((r) => r.context_full).length; + h += rtTile( + residents.length, + __("子代理", "Subagents"), + rFull ? rFull + __("满", " full") : "", + residents.length ? Math.min(100, residents.length * 20) : 0, + residents.length > 0, + rFull > 0, + ); + h += "
"; + // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" + // 没有帮助,却占掉一整行,是总览里最大的一坨文字。 + put("tiles", h); + + // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- + put("pipe", rtPipelineHtml(state.pipelinePhase, state.stageTrail)); + + // ---- 段 3:队列(四级中断 + 一条排队)---- + // + // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: + // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 + // 「不需要及时处理」的那一半输入。排队队列**无级别**,故用不同配色 + 虚线。 + h = '
' + __("队列", "Queues") + "
"; + var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); + var maxReg = 1; + var maxPre = 1; + RT_LEVELS.forEach((L) => { + maxReg = Math.max(maxReg, byLv[L.lv] || 0); + maxPre = Math.max(maxPre, preLv[L.lv] || 0); + }); + // 槽位数按全场最大深度缩放(且至少 5 格):0 时也有可见形状,不空着。 + var qSlots = Math.max(5, Math.min(16, maxQ)); + // 五个**等大表框**(四级中断 + 一条排队),与阶段管道同一套视觉语言。 + // 此前是五行扁条,四级中断全为 0 时四行几乎全是空白,又占高度又难看。 + h += '
'; + RT_LEVELS.forEach((L) => { + var depth = q[L.lv] || 0; + var reg = byLv[L.lv] || 0; + var pre = preLv[L.lv] || 0; + h += + '
' + + '
' + + L.name + + "" + + escHtml(L.desc) + + "
" + + '
' + + depth + + "
" + + rtSlots(depth, qSlots, L.cls) + + '
' + + rtMini(reg, maxReg, __("登记", "registered")) + + rtMini(pre, maxPre, __("抢占", "preempted")) + + "
"; + }); + // 第五条:排队队列(无级别,纯 FIFO)。 + // 它不是优先级而是另一**类别**(排队 vs 中断),所以用虚线框区分。 + h += + '
' + + '
' + + __("排队", "queued") + + "FIFO
" + + '
' + + ready + + "
" + + rtSlots(ready, qSlots, "rt-lv-q") + + '
' + + __("无级别", "no priority") + + "
"; + h += "
"; + if (sc.immediate) { + h += + '
' + + RT_ICO.bolt + + " " + + __("立即运行", "immediate") + + ":" + + escHtml(sc.immediate.kind || "") + + " #" + + sc.immediate.id + + 'L' + + (sc.immediate.level || 0) + + "
"; + } + put("levels", h); + + // ---- 段 4:中断栈 ---- + h = '
' + __("栈", "Stack") + "
"; + // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 + h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); + if (frames.length) { + h += '
'; + frames.forEach((f, i) => { + var t = (f && f.task) || {}; + h += + '
' + + escHtml(t.kind || "") + + " #" + + (t.id || "?") + + 'L' + + (t.level || 0) + + (i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + + "
"; + }); + h += "
"; + } else { + h += + '
' + + __( + "中断栈为空(当前无被抢占的现场)", + "stack empty (nothing preempted)", + ) + + "
"; + } + if (sc.running) { + h += + '
' + + __("正在运行", "running") + + ":" + + escHtml(sc.running.kind || "") + + " #" + + sc.running.id + + " (L" + + (sc.running.level || 0) + + ")
"; + } + put("stack", h); + + // ---- 段 5:Agent 拓扑 ---- + // + // 每个 agent 一条横带:左是归它的 inputch、中间是它自己(圆环 = 负载)、 + // 右是它能写的 outputch,连线即路由。归属、路由、容量、负载全部画进 + // 同一张 SVG,不再单开"通道分配"一节——那本来就是这个拓扑的一部分。 + put("topo", rtAgentTopology(rtAgents(rt))); +} + +// loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 +async function loadRuntime() { + try { + var rt = await api("/runtime"); + state.runtime = rt; + renderRuntime(); + } catch (e) { + state.runtime = null; + } +} + +// startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义, +// 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。 +// startChatTicker:聊天页可见时 3s 轮询一次增量(与运行态同一节奏)。 +// SSE 仍在(token 级流式靠它),轮询是「数据查询 api」那条腿: +// 界面最终状态由轮询拉到的数据决定,SSE 只负责让流式看起来即时。 +function startChatTicker() { + if (state._chatTicker) return; + state._chatTicker = setInterval(() => { + var tab = document.querySelector("#tab-chat"); + if (tab && tab.classList.contains("active")) { + syncChatFromHistory().catch(() => {}); + } + }, 3000); +} + +function startRuntimeTicker() { + if (state._runtimeTicker) return; + state._runtimeTicker = setInterval(() => { + var tab = document.querySelector("#tab-overview"); + if (tab && tab.classList.contains("active")) { + loadRuntime(); + } + }, 3000); +} + +// ===== Overview ===== +// +// 总览页是**静态骨架 + 数据绑定**:DOM 只在首帧建一次,之后所有刷新只改 +// 文本/类名,绝不重建节点。此前 renderAll 每 15s 把整个总览(含运行态面板) +// innerHTML 重建一遍 —— 那是页面上最刺眼的周期性闪烁的来源。 +// +// 文字也压到最少:状态用彩色圆点表达,其余只留数字与两字标签,不再堆 +// 「未初始化 / 不可用」这类整句描述。 +var OV_ICONS = { + activity: + '', + clock: + '', + puzzle: + '', + tag: '', + brain: + '', + db: '', + file: '', + cpu: '', +}; + +function ovKpi(id, icon, label, dot) { + return ( + '
' + + icon + + '' + + (dot ? '' : "") + + '—' + + '' + + '' + + label + + "
" + ); +} + +function renderOverview() { + var host = document.getElementById("tab-overview"); + if (!host) return; + // 只在首帧建骨架;之后(renderAll 每 15s 一次)只 updateOverview。 + // 一个节点都不重建,所以不会闪。 + if (!document.getElementById("ov-kpis")) { + host.innerHTML = + '
' + + '
' + + ovKpi("status", OV_ICONS.activity, __("状态", "Status"), true) + + ovKpi("uptime", OV_ICONS.clock, __("运行", "Uptime")) + + ovKpi("plugins", OV_ICONS.puzzle, __("插件", "Plugins")) + + ovKpi("version", OV_ICONS.tag, __("版本", "Version")) + + ovKpi("llm", OV_ICONS.brain, "LLM", true) + + ovKpi("memory", OV_ICONS.db, __("记忆", "Memory")) + + ovKpi("docs", OV_ICONS.file, __("文档", "Docs")) + + ovKpi("runtime", OV_ICONS.cpu, __("运行时", "Runtime")) + + "
" + + // 星图搬到主页:作为总览的门面,跟随 agent 活动脉动。 + // 开源许可单独成框:之前在 KPI 卡底部只是一行小链接(.ov-foot), + // 几乎看不见,也看不出是许可证还是别的什么。 + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + + ''; + } + updateOverview(); + // 星图在主页常驻:容器已建好,首次就拉数据 + 初始化 3D。 + if (document.getElementById("sm-container-home") && !state.starmapInit) { + renderChatStarmap(); + } +} + +function ovSet(id, text) { + var el = document.getElementById(id); + if (el && el.textContent !== String(text)) el.textContent = String(text); +} +function ovDot(id, cls) { + var el = document.getElementById(id); + if (el && el.className !== cls) el.className = cls; +} + +// updateOverview 只写值与类名,从不改结构。 +function updateOverview() { + var s = state.status || {}; + var k = state.kernel; + var running = s.status === "running"; + ovSet("ov-status", running ? __("运行中", "running") : s.status || "unknown"); + ovDot("ov-status-dot", "ov-dot " + (running ? "ok" : "warn")); + ovSet( + "ov-uptime", + state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-", + ); + ovSet("ov-plugins", ((k && k.plugins) || []).length || 0); + // 内核身份:光有版本号分不清是哪个内核、哪次构建 —— 补上内核名与 commit。 + // 之前 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩余 "v1.4.0"。 + var kb = (k && k.build) || {}; + var kv = kb.version || s.version || ""; + ovSet("ov-version", kv ? "v" + kv : "—"); + ovSet( + "ov-version-sub", + kv + ? (kb.kernel_name || "HomeAgent") + + (kb.commit && kb.commit !== "unknown" + ? " · " + String(kb.commit).slice(0, 7) + : "") + : "", + ); + var llm = (k && k.llm) || {}; + ovDot("ov-llm-dot", "ov-dot " + (llm.available ? "ok" : "bad")); + ovSet("ov-llm", llm.provider || "—"); + var mem = (k && k.memory) || {}; + ovSet( + "ov-memory", + mem.available ? mem.entity_count + "/" + mem.relation_count : "—", + ); + var docs = (k && k.documents) || {}; + ovSet("ov-docs", docs.available ? docs.doc_count : "—"); + var rt = (k && k.runtime) || {}; + ovSet( + "ov-runtime", + rt.goroutines + ? rt.goroutines + (rt.memory_mb ? " · " + rt.memory_mb + "M" : "") + : "—", + ); + // 开源许可卡:协议标识 + 协议全文 + 源码仓库。 + // + // 为什么单独成框、且三者都给:AGPL-3.0 §13 的义务是「向网络使用者提供 + // 取得 Corresponding Source 的机会」——只给一个仓库链接、不写协议名, + // 使用者看不出这受什么许可约束,也看不出网络服务场景下还有 §13 的义务。 + // 内容对一次构建是常量,所以填一次就够(__html 比对避免重复重建)。 + var legal = document.getElementById("ov-legal"); + if (legal) { + var lb = (k && k.build) || {}; + var src = lb.source_url || ""; + var lic = lb.license || ""; + var licURL = lb.license_url || ""; + var rows = ""; + if (lic) { + rows += + '
' + + __("许可协议", "License") + + '' + + (licURL + ? '' + + escHtml(lic) + + "" + : escHtml(lic)) + + "
"; + } + if (src) { + rows += + '
' + + __("源码仓库", "Source") + + '' + + escHtml(src) + + "
"; + } + // 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。 + var note = + lic && lic.toUpperCase().indexOf("AGPL") >= 0 + ? '" + : ""; + var want = rows + ? "

" + __("开源许可", "License") + "

" + rows + note + : ""; + if (legal.__html !== want) { + legal.__html = want; + legal.innerHTML = want; + } + } +} + +// ===== Chat ===== +var _chatLayoutBuilt = false; + +function buildChatLayout() { + var cont = document.getElementById("tab-chat"); + var k = state.kernel || {}; + var html = '
'; + html += + '
' + + '' + + __("对话", "Chat") + + "" + + "" + + __("星图", "Star Map") + + "" + + "" + + __("终端", "Terminal") + + "" + + "" + + __("运行中命令", "Running Commands") + + "" + + "" + + __("记忆", "Memory") + + "" + + "" + + __("上下文", "Context") + + "" + + "" + + __("知识", "Knowledge") + + "
"; + html += + '
'; + html += + '

' + + __("对话", "Chat") + + '

'; + if (state.messages.length === 0) { + html += + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } + html += + "
" + + '
' + + '' + + '" + + '' + + '" + + '" + + "
"; + html += + '

' + + __("星图", "Star Map") + + "

" + + '
'; + html += + '

' + + __("终端", "Terminal") + + ' 0

' + + '
'; + html += + '

' + + __("命令历史", "Command History") + + ' 0

' + + '
'; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("实体", "Entities") + + '' + + (k?.memory?.entity_count || "-") + + "
" + + '
' + + __("关系", "Relations") + + '' + + (k?.memory?.relation_count || "-") + + "
" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("上下文", "Context") + + "

" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("知识", "Knowledge") + + "

" + + '
' + + __("项目", "Items") + + '' + + (k?.knowledge?.item_count ?? "-") + + "
" + + '
' + + '' + + __("按分类浏览", "Browse by category") + + "" + + '
' + + "
" + + '
' + + '' + + '' + + '" + + '" + + '
' + + '
' + + '' + + '' + + '' + + '
" + + '' + + "
" + + "
"; + html += "
"; + cont.innerHTML = html; + _chatLayoutBuilt = true; +} + +var CHAN_COLORS = [ + "#e08a5f", + "#5f9fe0", + "#6bbf8f", + "#c06bbf", + "#d9a13b", + "#5fb3bf", + "#b06b6b", + "#7f8ce0", +]; + +function chanColor(src) { + var h = 0; + for (var i = 0; i < src.length; i++) { + h = (h * 31 + src.charCodeAt(i)) >>> 0; + } + return CHAN_COLORS[h % CHAN_COLORS.length]; +} + +function chanLetter(src) { + var s = (src || "").trim(); + if (!s) return "C"; + var ch = s.charAt(0).toUpperCase(); + return /[A-Za-z0-9]/.test(ch) ? ch : "C"; +} + +// ===== 列表对账(keyed reconcile)===== +// +// 为什么必须对账而不是 innerHTML 整块重建:整块重建会把**没变的**那些消息 +// 节点也推倒重来 —— 图片重新解码闪一下、工具卡片的展开态丢失、CSS 过渡从 +// 头播、滚动锚点被重置。这些闪烁几乎无法用"重建后再还原"消除。 +// +// 做法:新 HTML 先在游离容器里解析成节点,然后按 data-key 逐个对账: +// - key 命中且内容一致 → 复用原节点(一个字节都不动); +// - key 命中但内容变了 → 只替换这一个节点; +// - key 未命中 → 作为新节点插入; +// 最后把多出来的旧节点删掉。未变节点因此完全不受影响。 +var _localKeySeq = 0; + +// chatMsgKey 给一条消息一个**稳定**的 key。 +// - 服务端消息用 seq(单调、落盘、重启不重编号); +// - 本地乐观消息(用户刚发的 / 正在流式的)第一次渲染时分配一个进程内 +// 自增 key 并挂在对象上,后续渲染不变。 +function chatMsgKey(m) { + if (m && m.seq) return "s" + m.seq; + if (m && !m._k) m._k = "L" + ++_localKeySeq; + return (m && m._k) || "L0"; +} + +function commitChatList(container, html) { + var tpl = document.createElement("div"); + tpl.innerHTML = html; + var next = Array.prototype.slice.call(tpl.children); + var existing = {}; + Array.prototype.forEach.call(container.children, (n) => { + var k = n.getAttribute && n.getAttribute("data-key"); + if (k) existing[k] = n; + }); + for (var i = 0; i < next.length; i++) { + var fresh = next[i]; + var key = fresh.getAttribute && fresh.getAttribute("data-key"); + var node = fresh; + if (key && existing[key]) { + var old = existing[key]; + // 内容逐字节相同 → 复用旧节点(不触碰它,保存动画/展开/图片状态); + // 否则只替换这一个。outerHTML 比较对消息节点足够(含 tool_calls 状态)。 + if (old.outerHTML === fresh.outerHTML) { + node = old; + } else { + old.remove(); + } + } + var cur = container.children[i]; + if (cur !== node) container.insertBefore(node, cur || null); + } + // 多出来的旧节点(本轮新列表里没有的)从尾部清掉 + while (container.children.length > next.length) { + container.removeChild(container.lastElementChild); + } +} + +function renderChat() { + if (!_chatLayoutBuilt) { + buildChatLayout(); + renderChatStarmap(); + renderTerminals(); + renderCmdHistory(); + } + var msgsEl = document.getElementById("chat-msgs"); + if (!msgsEl) return; + if (!msgsEl._stickBound) { + msgsEl._stickBound = true; + msgsEl.addEventListener( + "scroll", + () => { + state.chatStick = + msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; + // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 + if (msgsEl.scrollTop < 60 && state.chatHasMore) { + loadOlderChat(); + } + }, + { passive: true }, + ); + msgsEl.addEventListener( + "load", + () => { + if (state.chatStick === true) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + }, + true, + ); + } + var msgs = state.messages; + var sig = + msgs + .map((m) => { + var c = m.content || ""; + return ( + (m.role || "") + + ":" + + c.length + + ":" + + c.slice(-40) + + ":" + + (m.tool_calls || []) + .map((t) => (t.tool || t.name || "") + "/" + (t.status || "")) + .join(",") + ); + }) + .join("|") + + "|L" + + (state.chatLoading ? "1" : "0") + + "|P" + + (state.pendingTools || []).join(","); + if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) { + if (state.chatStick === true) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + return; + } + msgsEl._chatSig = sig; + var prevPending = msgsEl._lastPending || []; + var newPending = (state.pendingTools || []).slice(); + if (state.chatLoading) { + var lastM = msgs.length ? msgs[msgs.length - 1] : null; + if (lastM && lastM.role === "assistant") { + (lastM.tool_calls || []).forEach((tc) => { + if (!tc.result && tc.status !== "denied") { + var nm = tc.tool || tc.name || ""; + if (newPending.indexOf(nm) === -1) newPending.push(nm); + } + }); + } + } + var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1); + msgsEl._lastPending = newPending; + var streamingLast = !!( + state.chatLoading && + lastM && + lastM.role === "assistant" && + !lastM._final + ); + function pillHtml() { + var s = ""; + newPending.forEach((nm) => { + var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in"; + s += + '' + + '' + + escHtml(nm) + + ""; + }); + return s; + } + var html = ""; + if (msgs.length === 0) { + html = + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } else { + msgs.forEach((m, i) => { + var _k = chatMsgKey(m); + var role = m.role || "user"; + var c = m.content || ""; + // 附件消息:agent 经 output_send__webui 发送,或用户上传。 + // 用 role 区分方向(user=右侧你发的 / assistant=左侧小宅发的), + // 卡片加来源标签避免混淆。 + if (m.attachment) { + var att = m.attachment; + var isUserAtt = role === "user"; + var srcLabel = isUserAtt + ? __("你发送的", "You sent") + : __("小宅发送的", "Sent by agent"); + var attHtml = ""; + if (att.type === "image") { + attHtml = + '' + + '图片加载失败\'"/>'; + } else if (att.type === "audio") { + // 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。 + // preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。 + attHtml = + '' + + '' + + escHtml(att.name || "audio") + + (att.size ? " (" + formatBytes(att.size) + ")" : "") + + ""; + } else { + var sizeStr = att.size ? formatBytes(att.size) : ""; + attHtml = + '' + + '' + + "" + + escHtml(att.name || "附件") + + (sizeStr ? " (" + sizeStr + ")" : "") + + ""; + } + html += + '
' + + '
' + + srcLabel + + "
" + + attHtml + + (c ? '
' + renderMd(c) + "
" : "") + + "
"; + return; + } + if (role === "assistant") { + c = renderMd(c); + } else if (role === "system") { + c = escHtml(c); + } else { + c = escHtml(c); + } + var isChan = !!(m.source && m.source !== "webui"); + var rc = ""; + if (m.reasoning_content) { + rc = renderReasoningCard(m.reasoning_content, isStreamingLast); + } + var tcs = ""; + if (m.tool_calls && m.tool_calls.length > 0) { + m.tool_calls.forEach((tc) => { + var argsStr = + typeof tc.args === "object" + ? JSON.stringify(tc.args, null, 1) + : tc.args || ""; + var resultStr = tc.result + ? typeof tc.result === "object" + ? JSON.stringify(tc.result, null, 1) + : String(tc.result) + : ""; + var running = !resultStr && tc.status !== "denied"; + var error = + tc.status === "error" || tc.status === "denied" || !!tc.error; + var drip = + newlyDone.indexOf(tc.tool || tc.name || "") !== -1 + ? " tool-drip-in" + : ""; + var iconSvg = error + ? '' + : running + ? '' + : ''; + var statusHtml = + tc.status === "denied" + ? '' + + __("已拒绝", "Denied") + + "" + : running + ? '' + + __("调用中", "Running") + + "" + : '' + + __("完成", "Done") + + ""; + var pluginHtml = tc.plugin + ? '' + escHtml(tc.plugin) + "" + : ""; + tcs += + '
' + + '
' + + iconSvg + + '' + + escHtml(tc.tool || tc.name || "") + + "" + + pluginHtml + + statusHtml + + '' + + RT_ICO.caret + + "
" + + '
"; + }); + } + var body = rc + tcs; + var growCls = m._grow ? " grow-in" : ""; + if (m._grow) m._grow = false; + var isStreamingLast = i === msgs.length - 1 && streamingLast; + if (isStreamingLast) { + var liveRow = + '' + + (newPending.length + ? '' + pillHtml() + "" + : ""); + if (c) { + body += + '
' + + liveRow + + '
' + + c + + "
"; + c = ""; + } else { + body += '
' + liveRow + "
"; + } + } else if (c) { + body += + '
' + + c + + "
"; + } + if (role === "system") { + html += + '
' + + (c || "") + + "
"; + } else if (isChan) { + html += + '
' + + '
' + + chanLetter(m.source) + + "
" + + '
' + + escHtml(m.source) + + "
" + + body + + "
"; + } else { + var userAvatar = + ''; + var aiAvatar = '小宅'; + html += + '
' + + '
' + + (role === "user" ? userAvatar : aiAvatar) + + "
" + + '
' + + body + + "
" + + "
"; + } + }); + } + if (state.chatLoading && !streamingLast) { + var aiAvatarL = '小宅'; + html += + '
' + + aiAvatarL + + '
' + + '' + + (newPending.length + ? '' + pillHtml() + "" + : "") + + "
"; + } + // 重建前记住阅读位置:非粘底(用户正向上翻)时,innerHTML 重建后必须把位置还回去, + // 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。 + var prevTop = msgsEl.scrollTop; + commitChatList(msgsEl, html); + if (state.chatStick !== false) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } else { + msgsEl.scrollTop = prevTop; + } + updateChatBadge(); +} + +function updateChatBadge() { + var badge = document.getElementById("chat-stage"); + if (!badge) return; + badge.textContent = state.chatStage || ""; + badge.style.display = "none"; +} + +// 流式渲染分发:流式增量(只更新最后一条正文)vs 全量重建 +var _rerenderTimer = null; +function rerenderChat(full) { + // 流式中且非强制全量:走增量路径(防抖合并 chunk,只更新最后一条消息节点) + if (!full && state.chatLoading) { + var msgs = state.messages; + var last = msgs.length ? msgs[msgs.length - 1] : null; + if (last && last.role === "assistant" && !last._final) { + if (_rerenderTimer) return; // 已有排程的增量更新 + _rerenderTimer = setTimeout(() => { + _rerenderTimer = null; + renderChatStreamChunk(); + }, 90); + return; + } + } + // 非流式(完成/工具/历史变化):全量重渲(含防抖合并) + if (_rerenderTimer) clearTimeout(_rerenderTimer); + _rerenderTimer = setTimeout(() => { + _rerenderTimer = null; + renderChat(); + if (full) { + renderChatStarmap(); + renderTerminals(); + renderCmdHistory(); + } + }, 90); +} + +// 流式增量渲染(移植自 GUI):仅更新最后一条 assistant 消息的正文与思考预览, +// 不重建 DOM。正文节流 parse(>200 字符或 >300ms 才 renderMd),小增量纯文本追加。 +function renderChatStreamChunk() { + var msgsEl = document.getElementById("chat-msgs"); + var last = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (!msgsEl || !last) return; + var el = msgsEl.lastElementChild; + if (!el) { + renderChat(); + return; + } + var textEl = el.querySelector(".msg-bubble .text"); + var c = last.content || ""; + if (textEl && c) { + var now = Date.now(); + var lastParse = el.__lastParse || 0; + var lastLen = el.__lastLen || 0; + if (c.length - lastLen > 200 || now - lastParse > 300) { + textEl.innerHTML = renderMd(c); + el.__lastParse = now; + el.__lastLen = c.length; + } else { + var tail = c.slice(lastLen); + if (tail) textEl.appendChild(document.createTextNode(tail)); + el.__lastLen = c.length; + } + if (state.chatStick !== false) { + try { + msgsEl.scrollTop = msgsEl.scrollHeight; + } catch (e) {} + } + return; + } + // 思考预览更新(流式中折叠,只刷 preview 文本) + var rcPrev = el.querySelector(".reasoning-preview"); + if (rcPrev && last.reasoning_content) { + rcPrev.textContent = last.reasoning_content + .replace(/[\s\n]+/g, " ") + .slice(0, 60); + if (state.chatStick !== false) { + try { + msgsEl.scrollTop = msgsEl.scrollHeight; + } catch (e) {} + } + return; + } + // 结构变化兜底:全量 + renderChat(); +} +function toggleToolCall(el) { + var d = el.querySelector(".tc-detail"); + if (!d) return; + var open = d.style.display !== "none"; + d.style.display = open ? "none" : "block"; + if (open) { + el.classList.remove("open"); + } else { + el.classList.add("open"); + } +} + +function renderReasoningCard(text, isStreaming) { + var preview = + typeof marked !== "undefined" + ? text.replace(/[\s\n]+/g, " ").slice(0, 60) + : escHtml(text) + .replace(/<[^>]+>/g, " ") + .slice(0, 60); + return ( + '
' + + '
' + + '' + + '' + + (isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) + + "" + + '' + + RT_ICO.caret + + "
" + + '
' + + (isStreaming + ? '
' + + escHtml(preview) + + '
' + : '
' + renderMd(text) + "
") + + "
" + ); +} +function toggleReasoning(el) { + var card = el.closest(".reasoning-card"); + if (!card) return; + var body = card.querySelector(".reasoning-body"); + if (!body) return; + var open = body.style.display !== "none"; + body.style.display = open ? "none" : "block"; + if (open) { + card.classList.remove("open"); + } else { + card.classList.add("open"); + } +} + +// renderChatStarmap:把星图挂到**当前活跃的容器**上。 +// 容器优先级:独立星图页签 > 总览页小图 > 聊天面板。 +// 同一套 three.js renderer 在容器间搬运 canvas,不重建场景。 +function renderChatStarmap() { + var cont = starmapActiveContainer(); + if (!cont) return; + if ( + window._THREE_FAILED || + (!window.THREE && window._THREE_FAILED !== undefined) + ) { + cont.innerHTML = + '

' + + __( + "3D 星图不可用(CDN 加载失败)", + "Star map unavailable (CDN load failed)", + ) + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + return; + } + if (!window.THREE) { + cont.innerHTML = + '
'; + state.starmapInit = false; + state.starmapLoading = false; + return; + } + if (cont.querySelector("canvas")) { + var rect = cont.getBoundingClientRect(); + if (starmapRen && rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + return; + } + if (state.starmapInit) { + if (starmapRen) { + var rect = cont.getBoundingClientRect(); + if (rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } else { + // starmapRen was destroyed (e.g. re-render cycle), restart + state.starmapInit = false; + state.starmapLoading = false; + } + return; + } + if (state.starmapLoading) return; + state.starmapLoading = true; + loadChatStarmapData(); +} + +async function loadChatStarmapData() { + try { + var resp = await api("/memory/graph"); + if ( + !resp || + !resp.success || + !resp.data || + !resp.data.nodes || + resp.data.nodes.length === 0 + ) { + document.getElementById("sm-container-chat").innerHTML = + '

' + + __("暂无记忆数据", "No memory data") + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + return; + } + var d = resp.data; + starmapNodes = d.nodes || []; + starmapEdges = d.edges || []; + state.starmapInit = true; + state.starmapLoading = false; + initChatStarmap(); + } catch (e) { + document.getElementById("sm-container-chat").innerHTML = + '

' + + __("加载失败", "Load failed") + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + } +} + +function initChatStarmap() { + // 容器可以是独立星图页签 / 总览页 / 聊天面板 —— 同一套 renderer + // 会在多个容器间搬运 canvas(appendChild 即可)。 + var cont = starmapActiveContainer(); + if (!cont) return; + var rect = cont.getBoundingClientRect(); + var w = Math.max(rect.width || 300, 100); + var h = Math.max(rect.height || 250, 100); + if (starmapRen) { + starmapRen.setSize(w, h); + // 换父节点前先把旧角标从旧容器里拨掉,否则它会残留在旧位置。 + if (starmapLabelEl && starmapLabelEl.parentElement === cont) { + starmapLabelEl.style.display = "none"; + } + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + return; + } + starmapScene = new THREE.Scene(); + starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015); + starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000); + starmapCam.position.set(0, 20, 40); + starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + starmapRen.setSize(w, h); + starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + starmapRen.setClearColor(0x0a0a1a, 1); + cont.innerHTML = ""; + cont.appendChild(starmapRen.domElement); + starmapCtrl = new THREE.OrbitControls(starmapCam, starmapRen.domElement); + starmapCtrl.enableDamping = true; + starmapCtrl.dampingFactor = 0.05; + starmapCtrl.rotateSpeed = 0.5; + starmapCtrl.zoomSpeed = 0.8; + var al = new THREE.AmbientLight(0x444466, 0.6); + starmapScene.add(al); + var dl = new THREE.DirectionalLight(0xffffff, 0.8); + dl.position.set(50, 100, 50); + starmapScene.add(dl); + createStarField(); + createNebula(); + // 共享几何(低规格):所有节点/光晕球复用这两份。 + starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); + starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); + buildChatStarmapGraph(); + starmapRen.domElement.addEventListener("mousemove", onStarmapMove); + starmapRen.domElement.addEventListener("click", onStarmapClick); + window.addEventListener("resize", onStarmapResize); + if (starmapRaf) cancelAnimationFrame(starmapRaf); + starmapAnimate(); + // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 + // 只在星图真正初始化后启动,避免在隐藏页签空跑。 + starmapStartActivity(); +} + +// starmapStartActivity 启动两路活动轮询(幂等)。 +function starmapStartActivity() { + if (state.starmapPulseTimer) return; + starmapPullActivity(); + starmapPullPulse(); + state.starmapPulseTimer = setInterval(() => { + starmapPullActivity(); + }, 3000); + var pulseTimer = setInterval(() => { + starmapPullPulse(); + }, 10000); + // 两个 id 合并到一个字段会导致 setInterval 被覆盖,这里分开记。 + state.starmapActivityTimer = pulseTimer; +} + +function buildChatStarmapGraph() { + starmapNodeMeshes.forEach((m) => { + starmapScene.remove(m); + }); + starmapEdgeLines.forEach((l) => { + starmapScene.remove(l); + }); + starmapNodeMeshes = []; + starmapEdgeLines = []; + if (starmapNodes.length === 0) return; + // Calculate node degrees for leaf node detection + var nodeDegs = {}; + starmapNodes.forEach((n) => { + nodeDegs[n.id] = 0; + }); + starmapEdges.forEach((e) => { + nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1; + nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1; + }); + var nodeMap = {}; + starmapNodes.forEach((n) => { + nodeMap[n.id] = n; + }); + var sorted = starmapNodes + .slice() + .sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0)); + var mc = sorted.map((n) => n.mention_count || 0); + var maxMc = Math.max(...mc, 1), + minMc = Math.min(...mc, 0), + rng = maxMc - minMc || 1; + // Layout positions + var pos = {}; + var baseR = 15, + maxR = 80; + var total = sorted.length; + var acc = 0; + sorted.forEach((n, i) => { + var m = n.mention_count || 0, + mn = rng > 0 ? (m - minMc) / rng : 0; + var radius = baseR + mn * (maxR - baseR); + var baseStep = (Math.PI * 2) / total; + var extra = mn * baseStep * 2; + var angle = acc + extra / 2; + acc += baseStep + extra; + pos[n.id] = { + x: radius * Math.cos(angle), + y: (Math.random() - 0.5) * (10 + mn * 20), + z: radius * Math.sin(angle), + mn: mn, + rad: radius, + }; + }); + // Leaf nodes (degree 1) reposition near parent + sorted.forEach((n) => { + var deg = nodeDegs[n.id] || 0; + if (deg !== 1) return; + var edge = starmapEdges.find( + (e) => e.source_id === n.id || e.target_id === n.id, + ); + if (!edge) return; + var parentId = edge.source_id === n.id ? edge.target_id : edge.source_id; + if (!pos[parentId]) return; + var pp = pos[parentId]; + var m = n.mention_count || 0, + mn = rng > 0 ? (m - minMc) / rng : 0; + var off = 6 + mn * 8 + Math.random() * 4; + var a2 = Math.random() * Math.PI * 2; + pos[n.id] = { + x: pp.x + off * Math.cos(a2), + y: pp.y + (Math.random() - 0.5) * (4 + mn * 6), + z: pp.z + off * Math.sin(a2), + mn: mn, + rad: off, + }; + }); + // Force-directed simulation + for (var it = 0; it < 50; it++) { + var ids = Object.keys(pos); + // Repulsion + for (var i = 0; i < ids.length; i++) { + for (var j = i + 1; j < ids.length; j++) { + var a = pos[ids[i]], + b = pos[ids[j]]; + var dx = a.x - b.x, + dy = a.y - b.y, + dz = a.z - b.z, + d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; + var rf = 0.5 + (a.mn + b.mn) * 0.5; + if (d < 25) { + var force = (0.06 * rf) / Math.max(d, 0.5); + a.x += (dx / d) * force; + a.y += (dy / d) * force; + a.z += (dz / d) * force; + b.x -= (dx / d) * force; + b.y -= (dy / d) * force; + b.z -= (dz / d) * force; + } + } + } + // Attraction along edges + starmapEdges.forEach((e) => { + var a = pos[e.source_id], + b = pos[e.target_id]; + if (!a || !b) return; + var dx = b.x - a.x, + dy = b.y - a.y, + dz = b.z - a.z, + d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; + var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3); + if (d > 20) { + var force = 0.04 * af; + a.x += (dx / d) * force; + a.y += (dy / d) * force; + a.z += (dz / d) * force; + b.x -= (dx / d) * force; + b.y -= (dy / d) * force; + b.z -= (dz / d) * force; + } + }); + // Centering constraint + ids.forEach((id) => { + var p = pos[id]; + var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); + var maxA = maxR * 1.5; + if (dist > maxA) { + var s = maxA / dist; + p.x *= s; + p.y *= s; + p.z *= s; + } + }); + } + // Create nodes + // + // ★ 低规格渲染(1151 节点实测后定的方案):保留**全部**节点, + // 但把每节点开销从「一个独立球 + 一个独立球光晕 + 一张 256x64 + // 文字贴图」降到「共享几何 + 共享材质 + 无常驻文字」。 + // + // 改之前的实际开销(生产实例 1151 节点): + // - SphereGeometry(16,12) 主球 + 同样规格的光晕球 = 2302 个独立 + // BufferGeometry,共约 88 万三角形 + // - 每节点一张 256x64 CanvasTexture = 1151 张 + + // 1151 个纹理,仅文字就吃约 72MB 显存 + // 这两样在「星图只是个展示」的前提下纯属浪费:星图全图远看根本 + // 读不清标签(256x64 贴在半径 0.5~2.5 的球上,本来就糊)。 + // + // 现在:共享一份 SphereGeometry(8,6)(约 84 三角形/节点), + // 颜色靠每 mesh 的 material.color(材质本身仍每节点一份, + // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 + // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— + // 文字清晰度反而比 3D 贴图好,且零显存。 + starmapNodes.forEach((n) => { + var p = pos[n.id]; + if (!p) return; + var mn = n.mention_count || 0, + mnr = rng > 0 ? (mn - minMc) / rng : 0; + var rad = 0.5 + mnr * 2.0; + // 服务端 type 是首字母大写("Concept" / "Person" …), + // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 + // 这里统一小写归一化,并补上服务端实际会产出的类型。 + var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; + var ei = 0.3 + mnr * 0.7; + var mat = new THREE.MeshPhongMaterial({ + color: col, + emissive: col, + emissiveIntensity: ei, + shininess: 30, + }); + var mesh = new THREE.Mesh(starmapGeo, mat); + mesh.position.set(p.x, p.y, p.z); + mesh.scale.setScalar(rad / 0.5); // 共享几何半径 0.5,按需缩放 + mesh.userData.nodeData = n; + mesh.userData.nodeId = n.id; + mesh.userData.baseEmissive = ei; + mesh.userData.baseScale = rad / 0.5; + mesh.userData.baseGlow = 0.12 + mnr * 0.08; + // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) + var gm = new THREE.MeshBasicMaterial({ + color: col, + transparent: true, + opacity: mesh.userData.baseGlow, + side: THREE.BackSide, + blending: THREE.AdditiveBlending, + }); + var gs = new THREE.Mesh(starmapGlowGeo, gm); + gs.scale.setScalar(rad * 1.2 + mnr * 0.5); + mesh.add(gs); + mesh.userData.glowSphere = gs; + // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 + starmapScene.add(mesh); + starmapNodeMeshes.push(mesh); + }); + // Create edges + // + // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial + // (866 个 draw call)。改为**按关系类型分组**的少量 LineSegments: + // 同类型边合并成一个几何体,draw call 从 866 降到「关系类型数」 + // (生产实例实测 6 种左右)。 + // + // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 + // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 + var edgeByColor = {}; + starmapEdges.forEach((e) => { + var a = pos[e.source_id], + b = pos[e.target_id]; + if (!a || !b) return; + var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; + var k = String(col); + if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] }; + edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z); + }); + Object.keys(edgeByColor).forEach((k) => { + var g = edgeByColor[k]; + if (!g.pts.length) return; + var geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(g.pts, 3)); + var mat = new THREE.LineBasicMaterial({ + color: g.col, + transparent: true, + opacity: 0.4, + }); + var seg = new THREE.LineSegments(geo, mat); + starmapScene.add(seg); + starmapEdgeLines.push(seg); + }); +} + +// 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 +// POST 结束 ≠ 回合结束:agent 可能还在生成(排队+长生成),提前复位 +// chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。 +function endChatTurn() { + if (!state.chatLoading) return; + state.chatLoading = false; + state.chatStage = ""; + if (state._turnWatchdog) { + clearTimeout(state._turnWatchdog); + state._turnWatchdog = null; + } + var btn = document.getElementById("chat-send-btn"); + if (btn) { + btn.disabled = false; + btn.textContent = __("发送", "Send"); + } + var sb = document.getElementById("chat-stop-btn"); + if (sb) sb.style.display = "none"; + rerenderChat(true); +} + +// 回合看门狗:POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失), +// 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 +function armTurnWatchdog() { + if (state._turnWatchdog) clearTimeout(state._turnWatchdog); + state._turnWatchdog = setTimeout(() => { + state._turnWatchdog = null; + if (state.chatLoading) { + endChatTurn(); + toast( + __( + "长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看", + "No reply received for a long time; the reply may have been generated, refresh to check", + ), + true, + ); + } + }, 120000); +} + +// 上传文件并注入 agent:multipart POST /api/v1/chat/file。 +// 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; +// 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 +// 拖拽文件到聊天区即发送(可选:先在输入框写附言) +function initChatDragDrop() { + var panel = document.getElementById("chat-panel-chat"); + if (!panel || panel.__dnd) return; + panel.__dnd = true; + panel.addEventListener("dragover", (e) => { + e.preventDefault(); + panel.style.outline = "2px dashed var(--accent, #4a90d9)"; + }); + panel.addEventListener("dragleave", () => { + panel.style.outline = ""; + }); + panel.addEventListener("drop", (e) => { + e.preventDefault(); + panel.style.outline = ""; + if (e.dataTransfer.files && e.dataTransfer.files.length) { + sendChatFile(e.dataTransfer.files[0]); + } + }); + // 粘贴截图/复制的文件直接发送 + document.addEventListener("paste", (e) => { + var chatVisible = + document.getElementById("chat-panel-chat") && + document.getElementById("chat-input"); + if (!chatVisible) return; + var items = e.clipboardData && e.clipboardData.files; + if (items && items.length && document.activeElement !== null) { + sendChatFile(items[0]); + } + }); +} + +async function sendChatFile(fileObj, extraText) { + if (!fileObj || state.chatLoading) return; + var inp = document.getElementById("chat-input"); + var message = (extraText || inp.value || "").trim(); + if (inp) inp.value = ""; + var fd = new FormData(); + fd.append("file", fileObj); + if (message) fd.append("message", message); + state.messages.push({ + role: "user", + content: message, + attachment: { + // 与服务端的 attType 判定保持一致(image/audio/file), + // 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。 + type: /^image\//.test(fileObj.type) + ? "image" + : /^audio\//.test(fileObj.type) + ? "audio" + : "file", + url: URL.createObjectURL(fileObj), + name: fileObj.name, + size: fileObj.size, + }, + }); + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + rerenderChat(true); + try { + await api("/chat/file", { method: "POST", body: fd, rawBody: true }); + // 回复经 SSE 流式到达,这里无需处理响应体 + } catch (e) { + toast( + __("文件发送失败:" + e.message, "File send failed: " + e.message), + true, + ); + state.chatLoading = false; + endChatTurn(); + } +} + +async function sendChat() { + var inp = document.getElementById("chat-input"); + var btn = document.getElementById("chat-send-btn"); + var stopBtn = document.getElementById("chat-stop-btn"); + var text = inp.value.trim(); + if (!text || state.chatLoading) return; + state.chatStick = true; + state.chatFinalIdx = -1; + state.messages.push({ role: "user", content: text }); + inp.value = ""; + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + btn.disabled = true; + btn.textContent = ""; + if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止 + rerenderChat(true); + // 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。 + try { + var ctrl = new AbortController(); + var ackTimer = setTimeout(() => { + ctrl.abort(); + }, 15000); + var r = null; + try { + r = await api("/chat", { + method: "POST", + body: JSON.stringify({ message: text }), + signal: ctrl.signal, + }); + } catch (ackErr) { + // 同步超时/失败:不阻塞 UI,等 SSE 兑底;明确提示"可能已发送" + console.warn("[sendChat] trigger failed: " + ackErr.message); + toast( + __( + "请求超时(可能已发送,请稍候或在收到回复前勿重复发送)", + "Request timeout (may have been sent; wait for reply before resending)", + ), + true, + ); + r = null; + } finally { + clearTimeout(ackTimer); + } + var last = state.messages[state.messages.length - 1]; + if (r && r.response) { + if (last && last.role === "assistant" && last._streaming) { + last.content = r.response || __("(无响应)", "(no response)"); + last._grow = true; + if (!last.reasoning_content) { + last.reasoning_content = r.reasoning_content || ""; + } + last._final = true; + delete last._streaming; + } else { + state.messages.push({ + role: "assistant", + content: r.response || __("(无响应)", "(no response)"), + reasoning_content: r.reasoning_content, + tool_calls: + last && last.role === "assistant" && last.tool_calls + ? last.tool_calls + : [], + _final: true, + _grow: true, + }); + } + state.chatFinalIdx = state.messages.length - 1; + } + rerenderChat(true); + } catch (e) { + // 真实错误(非受理超时):展示错误信息 + if (!String(e.message || "").includes("aborted")) { + state.messages.push({ + role: "assistant", + content: __("错误: ", "Error: ") + e.message, + _final: true, + }); + rerenderChat(true); + toast(__("请求失败: ", "Request failed: ") + e.message, true); + } + } finally { + if (r && r.response) { + // 同步兜底已拿到完整回复:回合结束 + endChatTurn(); + } else { + // 触发式受理(POST 已 abort/失败):回合仍打开,等 SSE 流式渲染; + // 由 agent_output final / reset 帧 / watchdog 收尾 + armTurnWatchdog(); + } + } +} + +// 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前 +// 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。 +async function interruptChat() { + try { + await api("/chat/interrupt", { + method: "POST", + body: JSON.stringify({}), + }); + toast(__("已发送中断信号", "Interrupt signal sent")); + } catch (e) { + toast(__("中断失败: ", "Interrupt failed: ") + e.message, true); + } +} + +async function queryMemoryChat() { + var q = document.getElementById("mem-query")?.value; + var r = document.getElementById("mem-result-chat"); + if (!r || !q) return; + r.innerHTML = '
'; + try { + var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2"); + r.innerHTML = + '
' +
+			escHtml(JSON.stringify(data, null, 2)) +
+			"
"; + } catch (e) { + r.innerHTML = + '

' + + __("查询失败: ", "Query failed: ") + + escHtml(e.message) + + "

"; + } +} + +async function queryMemoryContext() { + var q = document.getElementById("ctx-query")?.value; + var r = document.getElementById("ctx-result"); + if (!r) return; + r.innerHTML = '
'; + try { + var data = await api("/memory/context?q=" + encodeURIComponent(q || "")); + var ctx = data?.context || __("无上下文", "No context"); + var summary = data?.summary || ""; + var entities = data?.entities || []; + var tk = data?.token_estimate || 0; + var html = '
'; + if (summary) + html += + '
' + + __("摘要", "Summary") + + '' + + escHtml(summary) + + "
"; + html += + '
Token ' + + __("预估", "Estimate") + + '' + + tk + + "
"; + if (entities.length) { + html += + '
' + + __("实体", "Entities") + + '' + + entities.map((e) => escHtml(e.name || e.id || "")).join(", ") + + "
"; + } + html += + '
' +
+			escHtml(ctx) +
+			"
"; + r.innerHTML = html; + } catch (e) { + r.innerHTML = + '

' + + __("获取失败: ", "Get failed: ") + + escHtml(e.message) + + "

"; + } +} + +// 知识库面板:搜索 / 创建 / 删除 / 刷新计数。 +// +// 此前三处问题:搜索把裸 JSON 直接 stringify 丢进
(用户看到一坨
+// 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
+// 完仍是旧值);没有任何删除入口,也没有媒体上传。
+function knowEsc(v) {
+	return String(v == null ? "" : v)
+		.replace(/&/g, "&")
+		.replace(//g, ">");
+}
+
+async function refreshKnowledgeCount() {
+	try {
+		var d = await api("/knowledge");
+		var el = document.getElementById("know-count");
+		if (el && d && d.names) el.textContent = d.names.length;
+		// 稠密路(多模态)状态提示
+		var hint = document.getElementById("know-media-hint");
+		if (hint && d && d.dense) {
+			if (d.dense.enabled) {
+				hint.textContent =
+					__("多模态已就绪 ", "Multimodal ready ") +
+					(d.dense.ready || 0) +
+					"/" +
+					((d.dense.ready || 0) + (d.dense.stale || 0));
+			} else {
+				hint.textContent = __(
+					"未接入多模态:图片只记录不参与召回",
+					"Multimodal off: images recorded but not searchable",
+				);
+			}
+		}
+	} catch (e) {
+		/* 计数刷新失败不该打断用户操作 */
+	}
+}
+
+function renderKnowledgeResults(views, q) {
+	if (!views || !views.length) {
+		return (
+			'

' + + __("未找到相关知识", "No matching knowledge") + + "

" + ); + } + var html = ""; + views.forEach((v) => { + html += + '
'; + html += '
'; + html += + '' + + knowEsc(v.name) + + ""; + if (v.size) { + html += '' + v.size + " B"; + } + html += + '"; + html += "
"; + if (v.media && v.media.length) { + html += '
'; + v.media.forEach((m) => { + var kind = m.kind || "file"; + html += + '[' + + knowEsc(kind) + + "] " + + knowEsc(m.digest.slice(0, 8)) + + "…"; + }); + html += "
"; + } + if (v.preview) { + html += + '
' + + knowEsc(v.preview) + + "
"; + } + html += "
"; + }); + return html; +} + +// 知识库分类树浏览。 +// +// 数据来自 /knowledge/tree(只读树接口)。当前分类存 state, +// 搜索时自动带上 —— 这样"先定位分类再检索"这个更准的用法在 UI 上 +// 是一步的事,而不是要求用户手打分类名。 +var _knowCat = ""; // 当前分类的完整 path;"" = 全库 + +function renderKnowTree(node, depth) { + var html = ""; + // 本节点的条目 + (node.items || []).forEach((it) => { + html += + '
' + + '▸ ' + + '' + + knowEsc(it.name) + + "" + + (it.size + ? ' ' + it.size + "B" + : "") + + (it.media && it.media.length + ? ' [' + + it.media.length + + __(" 媒体", " media") + + "]" + : "") + + "
"; + }); + // 子分类 + (node.children || []).forEach((c) => { + var isCur = _knowCat === c.path; + var mark = isCur ? "● " : ""; + html += + '
' + + mark + + knowEsc(c.name) + + ' ' + + c.item_count + + "/" + + c.total_count + + "
"; + html += renderKnowTree(c, depth + 1); + }); + return html; +} + +async function loadKnowledgeTree() { + var box = document.getElementById("know-tree"); + if (!box) return; + box.innerHTML = '
'; + try { + var d = await api("/knowledge/tree?items=1"); + if (!d || !d.tree) { + box.innerHTML = + '

' + + __("暂无分类", "No categories yet") + + "

"; + return; + } + box.innerHTML = renderKnowTree(d.tree, 0); + } catch (e) { + box.innerHTML = + '

' + + __("加载分类失败: ", "Load categories failed: ") + + knowEsc(e.message) + + "

"; + } +} + +function selectKnowledgeCategory(path) { + _knowCat = path || ""; + var lbl = document.getElementById("know-cat-scope"); + if (lbl) { + lbl.textContent = _knowCat + ? __("范围:", "scope: ") + _knowCat + : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + // 立刻按新范围搜一次,省一次点击 + var q = document.getElementById("know-query")?.value; + if (q) searchKnowledgeChat(); +} + +function clearKnowledgeCategory() { + selectKnowledgeCategory(""); +} + +async function searchKnowledgeChat() { + var q = document.getElementById("know-query")?.value; + var cat = _knowCat || ""; + var r = document.getElementById("know-result-chat"); + if (!r) return; + if (!q) { + r.innerHTML = + '

' + + __("请输入查询关键词", "Enter a keyword") + + "

"; + return; + } + r.innerHTML = '
'; + try { + var url = "/knowledge?q=" + encodeURIComponent(q); + if (cat) url += "&category=" + encodeURIComponent(cat); + var data = await api(url); + r.innerHTML = renderKnowledgeResults(data && data.results, q); + } catch (e) { + r.innerHTML = + '

' + + __("搜索失败: ", "Search failed: ") + + knowEsc(e.message) + + "

"; + } +} + +async function deleteKnowledge(name) { + if ( + !confirm( + __("确定删除知识「", 'Delete knowledge "') + + name + + __("」?此操作不可撤销。", '"? This cannot be undone.'), + ) + ) { + return; + } + try { + await api("/knowledge?name=" + encodeURIComponent(name), { + method: "DELETE", + }); + toast(__("知识「", 'Knowledge "') + name + __("」已删除", '" deleted')); + refreshKnowledgeCount(); + searchKnowledgeChat(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +async function createKnowledgeChat() { + var name = document.getElementById("know-name")?.value; + var content = document.getElementById("know-content")?.value; + var fileInput = document.getElementById("know-media"); + var files = fileInput && fileInput.files ? fileInput.files : null; + if (!name) { + toast(__("名称不能为空", "Name is required"), true); + return; + } + if ((!content || !content.trim()) && (!files || !files.length)) { + toast(__("内容与媒体至少要有一项", "Content or media is required"), true); + return; + } + try { + var r; + if (files && files.length) { + // 有文件走 multipart:服务端按**探测到的真实类型**分流, + // 图片/音视频入媒体库并按 digest 挂到条目上,文本存正文。 + var fd = new FormData(); + fd.append("name", name); + if (content) fd.append("content", content); + for (var i = 0; i < files.length; i++) fd.append("file", files[i]); + r = await api("/knowledge", { method: "POST", body: fd }); + } else { + r = await api("/knowledge", { + method: "POST", + body: JSON.stringify({ name: name, content: content }), + }); + } + var msg = __("知识「", 'Knowledge "') + name + __("」已创建", '" created'); + if (r && r.media) + msg += __(",含 ", " with ") + r.media + __(" 个媒体", " media item(s)"); + if (r && r.rejected && r.rejected.length) { + msg += __(";", "; ") + r.rejected.length + __(" 项被跳过", " skipped"); + } + toast(msg); + document.getElementById("know-name").value = ""; + document.getElementById("know-content").value = ""; + if (fileInput) fileInput.value = ""; + refreshKnowledgeCount(); + } catch (e) { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + } +} + +function switchChatPanel(tab, el) { + // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, + // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 + if (tab === "knowledge") { + refreshKnowledgeCount(); + var lbl = document.getElementById("know-cat-scope"); + if (lbl && !lbl.textContent) { + lbl.textContent = _knowCat + ? __("范围:", "scope: ") + _knowCat + : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + } + var panels = { + chat: document.getElementById("chat-panel-chat"), + starmap: document.getElementById("chat-panel-starmap"), + terminal: document.getElementById("chat-panel-terminal"), + cmd: document.getElementById("chat-panel-cmd"), + memory: document.getElementById("chat-panel-memory"), + context: document.getElementById("chat-panel-context"), + knowledge: document.getElementById("chat-panel-knowledge"), + }; + Object.keys(panels).forEach((k) => { + var p = panels[k]; + if (p) p.classList.toggle("active", k === tab); + }); + if (el) { + var parent = el.parentElement; + if (parent) { + Array.from(parent.children).forEach((ch) => { + ch.classList.remove("active"); + }); + el.classList.add("active"); + } + } + if (tab === "starmap") { + if (!state.starmapInit) { + state.starmapLoading = false; + renderChatStarmap(); + } else if (starmapRen) { + var cont = document.getElementById("sm-container-chat"); + if (cont) { + var rect = cont.getBoundingClientRect(); + if (rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + if (!cont.contains(starmapRen.domElement)) + cont.appendChild(starmapRen.domElement); + } + } + } + if (tab === "chat") { + state.chatStick = true; + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + try { + msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" }); + } catch (e) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + } + } + if (tab === "context") queryMemoryContext(); +} + +// 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 +var CHAT_PAGE_SIZE = 40; +async function loadChatHistory() { + try { + var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); + if (data && data.messages) { + state.messages = data.messages; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatTotal = + typeof data.total === "number" ? data.total : data.messages.length; + state.chatHasMore = !!data.has_more; + state.chatLastSeq = historyLastSeq(data); + } + } catch (e) {} +} + +// historyLastSeq 从一次 /chat/history 响应里取出「已见到的最大 seq」: +// 优先用服务端给的 last_seq,缺了就取消息里的最大值。 +function historyLastSeq(data) { + if (!data) return 0; + if (typeof data.last_seq === "number") return data.last_seq; + var mx = 0; + (data.messages || []).forEach((m) => { + if (m && m.seq > mx) mx = m.seq; + }); + return mx; +} + +// applyServerMessages 把服务端消息并进 state.messages,按 seq 对账: +// - 同 seq 已存在 → 原地替换(工具调用/最终文本是原地更新); +// - 不存在 → 追加(若末尾是无 seq 的乐观消息且 role+content 一致,则替换它, +// 避免"自己刚发的那条"重复成两条)。 +// tailOnly=true 时只做原地更新与"比本地新才追加",不把尾探测当成新消息。 +// @returns {boolean} 是否真的改动了 state.messages +function applyServerMessages(list, tailOnly) { + if (!list || !list.length) return false; + var msgs = state.messages; + var changed = false; + function carry(prev, sm) { + if (prev && prev._final) sm._final = true; + if (prev && prev._grow) sm._grow = true; + return sm; + } + list.forEach((sm) => { + if (!sm) return; + var seq = sm.seq; + var found = -1; + for (var j = msgs.length - 1; j >= 0 && j >= msgs.length - 12; j--) { + if (seq && msgs[j] && msgs[j].seq === seq) { + found = j; + break; + } + } + if (found >= 0) { + if (JSON.stringify(msgs[found]) !== JSON.stringify(sm)) { + msgs[found] = carry(msgs[found], sm); + changed = true; + } + return; + } + if (tailOnly) { + var lastS = msgs.length ? msgs[msgs.length - 1].seq : 0; + if (seq && (!lastS || seq > lastS)) { + msgs.push(sm); + changed = true; + } + return; + } + if (msgs.length) { + var last = msgs[msgs.length - 1]; + if ( + !last.seq && + (last.role || "") === (sm.role || "") && + (last.content || "") === (sm.content || "") + ) { + msgs[msgs.length - 1] = carry(last, sm); + changed = true; + return; + } + } + msgs.push(sm); + changed = true; + }); + list.forEach((sm) => { + if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq; + }); + if (changed) rerenderChat(); + return changed; +} + +// pollChatIncremental 轮询「自上次以来新增了什么」。 +// +// 这就是「暴露数据查询 api,前端轮询后 patch 视图」那条路:after=游标 +// 只拿增量,再单独探一次尾部做原地更新(工具调用/最终文本是原地改的, +// 不会产生新 seq,只靠 after 拿不到)。视图更新走 commitChatList 的 +// keyed 对账,未变消息节点一个字节都不动 —— 闪烁由此消失。 +function pollChatIncremental() { + var after = state.chatLastSeq || 0; + if (!after) { + // 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑 + return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => { + state.chatLastSeq = historyLastSeq(data); + return mergeChatFromHistory(data); + }); + } + return api("/chat/history?after=" + after) + .then((data) => { + if (!data) return; + state.chatLastSeq = historyLastSeq(data) || after; + applyServerMessages(data.messages || [], false); + return api("/chat/history?limit=1"); + }) + .then((tail) => { + if (tail && tail.messages && tail.messages.length) { + applyServerMessages(tail.messages.slice(-1), true); + } + }); +} + +// loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 +// 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 +var _loadingOlder = false; +async function loadOlderChat() { + if (_loadingOlder || !state.chatHasMore) return; + _loadingOlder = true; + var msgsEl = document.getElementById("chat-msgs"); + var prevH = msgsEl ? msgsEl.scrollHeight : 0; + var prevTop = msgsEl ? msgsEl.scrollTop : 0; + try { + var before = state.chatOffset || 0; + if (before <= 0) { + state.chatHasMore = false; + return; + } + var data = await api( + "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, + ); + if (data && data.messages && data.messages.length) { + state.messages = data.messages.concat(state.messages); + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + state.chatStick = false; // 向上翻页时不自动粘底 + renderChat(); + if (msgsEl) { + // 补偿新增内容高度,保持用户视觉位置不变 + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + } + } else { + state.chatHasMore = false; + } + } catch (e) { + } finally { + _loadingOlder = false; + } +} + +// syncChatFromHistory 增量同步:先做一次极轻的"尾巴探测",只有尾巴变了才拉整页。 +// +// 原先每 30s(以及每次 SSE 报错)都直接拉一页 40 条:本地实测 180KB、 +// 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。 +// 探测只需 1 条(约几 KB),尾巴一致就直接跳过。 +var _syncingChat = false; +// syncChatFromHistory 保留旧名(多处调用点):内部改走游标增量轮询。 +function syncChatFromHistory() { + if (_syncingChat) return Promise.resolve(); + _syncingChat = true; + return pollChatIncremental() + .catch(() => {}) + .then(() => { + _syncingChat = false; + }); +} + +// mergeChatFromHistory 把服务端的一页历史并进本地:只追加新消息,不重建已有节点。 +// 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页 +// 凭空消失、视口跳回顶部)。 +function mergeChatFromHistory(data) { + if (!data || !data.messages || data.messages.length === 0) return; + var serverMsgs = data.messages; + var localMsgs = state.messages; + // 首次加载(空列表)→ 全量赋值 + if (localMsgs.length === 0) { + state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + rerenderChat(true); + return; + } + // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, + // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 + var lastLocal = localMsgs[localMsgs.length - 1]; + var lastServer = serverMsgs[serverMsgs.length - 1]; + var localContent = lastLocal.content || lastLocal.Content || ""; + var serverContent = lastServer.content || lastServer.Content || ""; + // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, + // 依赖 content+role+time 足以识别) + if (localContent === serverContent) { + // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) + if ( + lastServer.role === "assistant" && + localContent !== serverContent && + serverContent + ) { + lastLocal.content = serverContent; + if (lastServer.ReasoningContent) + lastLocal.reasoning_content = lastServer.ReasoningContent; + var msgsEl0 = document.getElementById("chat-msgs"); + if (msgsEl0 && msgsEl0.lastElementChild) { + var textEl0 = + msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); + if (textEl0) textEl0.innerHTML = renderMd(serverContent); + } + } + return; + } + // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 + // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) + // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 + var overlap = -1; + for ( + var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); + k >= 1; + k-- + ) { + var sMsg = serverMsgs[serverMsgs.length - 1 - k]; + var lMsg = localMsgs[localMsgs.length - 1 - k]; + if ( + lMsg && + (lMsg.content || "") === (sMsg.content || "") && + lMsg.role === sMsg.role + ) { + overlap = k; + break; + } + } + var newMsgs; + if (overlap >= 0) { + // 本地末尾 overlap 条在服务端页里,之后的部分是新增 + newMsgs = serverMsgs.slice(serverMsgs.length - overlap); + if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) + } else { + // 找不到重合点:**不能**直接拿服务端页覆盖本地。 + // 服务端只回一页,本地翻上来的旧页更长;覆盖会同时造成两个后果: + // 用户翻过的旧消息凭空消失、容器变矮后视口被夹回顶部。 + // 只有在服务端页不短于本地时才整体替换(那种情况下不丢内容)。 + if (serverMsgs.length >= localMsgs.length) { + state.messages = serverMsgs; + rerenderChat(true); + } + return; + } + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + var aiAvatar = '小宅'; + var userAvatar = + ''; + newMsgs.forEach((m) => { + var role = m.role || m.Role || "user"; + var c = m.content || m.Content || ""; + if (role === "assistant") c = renderMd(c); + else c = escHtml(c); + var isChan = !!(m.source && m.source !== "webui"); + var bubble = c + ? '
' + c + "
" + : '
'; + if (role === "system") { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + c + + "
", + ); + } else if (isChan) { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + (m.source || "?")[0].toUpperCase() + + '
' + + escHtml(m.source) + + "
" + + bubble + + "
", + ); + } else { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + (role === "user" ? userAvatar : aiAvatar) + + '
' + + bubble + + "
", + ); + } + }); + // 删除流式占位符(同步完成,下一次 SSE 会重建) + var streamingPh = msgsEl.querySelector(".msg-streaming-ph"); + if (streamingPh) streamingPh.remove(); + } + // 追加新消息对象到 state.messages + Array.prototype.push.apply(state.messages, newMsgs); + // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) + syncStreamingPlaceholder(); +} +// syncStreamingPlaceholder:同步聊天占位符的可见性 +function syncStreamingPlaceholder() { + var msgsEl = document.getElementById("chat-msgs"); + if (!msgsEl) return; + var existing = msgsEl.querySelector(".msg-streaming-ph"); + var lastMsg = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + var showPh = + state.chatLoading && + (!lastMsg || lastMsg.role !== "assistant" || lastMsg._final); + if (showPh && !existing) { + var aiAvatar = '小宅'; + var pillHtml = ""; + (state.pendingTools || []).forEach((nm) => { + pillHtml += + '' + + escHtml(nm) + + ""; + }); + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + aiAvatar + + '
' + + (pillHtml + ? '' + pillHtml + "" + : "") + + "
", + ); + } else if (!showPh && existing) { + existing.remove(); + } +} + +async function loadTerminals() { + try { + var data = await api("/terminals"); + if (data && data.terminals) state.terminals = data.terminals; + } catch (e) {} +} + +async function loadCmdHistory() { + try { + var data = await api("/cmd/history"); + if (data && data.history) state.cmdHistory = data.history; + } catch (e) {} +} + +function appendTermBuf(el, text) { + if (!text) return; + el.textContent += text; + if (el.textContent.length > 262144) { + el.textContent = el.textContent.slice(el.textContent.length - 262144); + } + el.scrollTop = el.scrollHeight; +} + +function renderTerminals() { + var r = document.getElementById("term-list"); + var cnt = document.getElementById("term-count-badge"); + if (!r) return; + var list = state.terminals || []; + if (cnt) cnt.textContent = list.length; + if (list.length === 0) { + r.innerHTML = + '

' + + __("暂无终端会话", "No terminal sessions") + + "

"; + return; + } + var html = ""; + list.forEach((t, i) => { + var detailId = "term-detail-" + i; + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var running = scr ? scr.running : !!t.running; + var fullOut = scr ? scr.output : t.output || ""; + if (!fullOut) { + fullOut = + '' + + __("[终端暂无输出]", "[No terminal output]") + + ""; + } else { + fullOut = escHtml(fullOut); + } + html += + '
'; + html += + '
"; + html += + '' + + escHtml(t.id || "-") + + ""; + html += + '' + + escHtml(t.command || "") + + ""; + html += + '' + + (running ? __("运行中", "Running") : __("已关闭", "Closed")) + + ""; + html += + '' + + escHtml(t.created_at || "") + + ""; + html += "
"; + html += + '
"; + }); + r.innerHTML = html; +} + +function renderCmdHistory() { + var r = document.getElementById("cmd-list"); + var cnt = document.getElementById("cmd-count-badge"); + if (!r) return; + var running = (state.terminals || []).filter((t) => t.running); + if (cnt) cnt.textContent = running.length; + if (running.length === 0) { + r.innerHTML = + '

' + + __("暂无运行中的命令", "No running commands") + + "

"; + return; + } + var html = + '"; + running.forEach((t) => { + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var out = scr ? scr.output : t.output || ""; + html += + "" + + '" + + '" + + '" + + ""; + if (out) { + html += + "" + + '"; + } + }); + html += "
' + + __("命令", "Command") + + "" + + __("状态", "Status") + + "" + + __("运行时长", "Uptime") + + "
' + + escHtml(t.command || t.id || "") + + "' + + __("运行中", "Running") + + "' + + escHtml(t.uptime || "-") + + "
' +
+				escHtml(out.substring(0, 2000)) +
+				"
"; + r.innerHTML = html; +} + +function connectSSE() { + if (state.eventSource) { + console.log("[SSE] closing old connection"); + state.eventSource.close(); + } + var es; + try { + es = new EventSource("/api/v1/chat/events", { + withCredentials: true, + }); + state.eventSource = es; + } catch (ex) { + console.error("[SSE] create failed", ex); + return; + } + if (!es) { + console.error("[SSE] es is null"); + return; + } + console.log("[SSE] connected"); + es.addEventListener("agent_output", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; + // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 + if (p.channel) rtSparkOutput(ev.source || "", p.channel); + // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。 + starmapPulse("output", p.channel || null); + console.log( + "[SSE] agent_output received", + p.content ? p.content.substring(0, 50) : "(empty)", + ); + if (!p.content) return; + state.chatStage = __("AI 回复中...", "AI replying..."); + if (p.kind === "channel_output") { + // 附件输出(output_type=image/file):渲染为图片预览/下载卡片 + var att = null; + if ( + p.output_type === "image" || + p.output_type === "audio" || + p.output_type === "file" + ) { + att = { + type: p.output_type, + url: p.url || p.content, + size: p.size || 0, + name: (p.url || "").split("/").pop() || "附件", + }; + } + var cm = { + role: "assistant", + content: att ? "" : p.content, + source: p.channel || "", + _final: true, + _grow: true, + attachment: att, + }; + if ( + state.chatFinalIdx >= 0 && + state.chatFinalIdx < state.messages.length + ) { + state.messages.splice(state.chatFinalIdx, 0, cm); + state.chatFinalIdx++; + } else { + state.messages.push(cm); + } + rerenderChat(); + return; + } + var lastM2 = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (lastM2 && lastM2.role === "assistant" && !lastM2._final) { + // 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准, + // 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。 + lastM2._grow = true; + lastM2.content = p.content; + lastM2._final = true; + rerenderChat(); + endChatTurn(); + return; + } + if ( + lastM2 && + lastM2.role === "assistant" && + lastM2._final && + !lastM2.source + ) { + // 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略; + // 内容不同视为新一轮输出(上一轮已 final 且无 source),开新消息。 + // 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。 + if (lastM2.content === p.content) { + endChatTurn(); + return; + } + } + state.messages.push({ + role: "assistant", + content: p.content, + _streaming: true, + _grow: true, + _final: true, + }); + rerenderChat(); + endChatTurn(); + } catch (ex) { + console.error("[SSE] agent_output error", ex); + } + }); + // token 级流式增量:逐块追加到当前回复内容(流式生成中); + // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 + es.addEventListener("content_delta", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.reset) { + var lm = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (lm && lm.role === "assistant" && !lm._final) { + lm._final = true; + rerenderChat(); + } + // 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息] + // 重启轮次,保持回合打开让确认回复继续流式渲染, + // 由其 agent_output final / watchdog 收尾。 + armTurnWatchdog(); + return; + } + if (!p.content) return; + state.chatStage = __("AI 回复中...", "AI replying..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + tool_calls: [], + _streaming: true, + _grow: true, + }); + last = state.messages[state.messages.length - 1]; + } + last.content += p.content; + rerenderChat(); + } catch (ex) {} + }); + es.addEventListener("terminal_output", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (!p.terminal_id) return; + var id = p.terminal_id; + if (!state.termScreens) state.termScreens = {}; + var scr = + state.termScreens[id] || + (state.termScreens[id] = { output: "", running: true }); + if (p.output) scr.output += p.output; + if (typeof p.running === "boolean") scr.running = p.running; + var bufel = document.getElementById("term-buf-" + id); + if (bufel) { + appendTermBuf(bufel, p.output || ""); + var dot = document.getElementById("term-dot-" + id); + if (dot) dot.className = "term-dot" + (scr.running ? "" : " stopped"); + } + } catch (ex) { + console.error("[SSE] terminal_output error", ex); + } + }); + es.addEventListener("reasoning", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.content) { + state.chatStage = __("AI 思考中...", "AI thinking..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + reasoning_content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + // 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价) + last.reasoning_content = p.content; + rerenderChat(); + } + } catch (ex) {} + }); + // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废 + es.addEventListener("reasoning_delta", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考 + if (!p.content) return; + state.chatStage = __("AI 思考中...", "AI thinking..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + reasoning_content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + last.reasoning_content = (last.reasoning_content || "") + p.content; + rerenderChat(); + } catch (ex) {} + }); + es.addEventListener("tool_call", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + console.log("[SSE] tool_call", p); + if (!p.tool) return; + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + if (!last.tool_calls) last.tool_calls = []; + last.tool_calls.push({ + tool: p.tool, + name: p.tool, + args: p.args || {}, + result: p.result || "", + status: p.status || "ok", + plugin: p.plugin || "", + }); + var pidx = (state.pendingTools || []).indexOf(p.tool); + if (pidx !== -1) state.pendingTools.splice(pidx, 1); + state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || ""); + // 星图跟随:工具调用是最可靠的活动信号。工具名(如 + // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。 + starmapPulse("tool", p.tool); + rerenderChat(); + } catch (ex) { + console.error("[SSE] tool_call error", ex); + } + }); + // 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。 + es.addEventListener("stage", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + var phase = p.phase || ""; + var tool = p.tool || ""; + if (p.channel === "_consolidation_") return; + console.log("[SSE] stage event", phase, tool); + // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 + // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 + // 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。 + // 工具调用会反复出现(多轮 toolcall),output_* 单独配色。 + if (phase === "on_input") { + state.stageTrail = []; + rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input")); + } else if (phase === "pre_action") { + rtTrailPush( + 1, + "stage", + __("组装上下文并思考", "assemble context and think"), + __("思考", "think"), + ); + } else if (phase === "before_toolcall" && tool) { + var isOut = tool.indexOf("output_") === 0; + rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool)); + state.toolFlash = true; + } else if (phase === "before_output") { + rtTrailPush( + 3, + "stage", + __("生成回复", "generate reply"), + __("生成", "gen"), + ); + } else if (phase === "after_output") { + rtTrailPush( + 4, + "stage", + __("本轮完成", "turn complete"), + __("完成", "done"), + ); + } + // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。 + starmapPulse("stage", tool || phase); + state.pipelinePhase = phase; + if (state.pipelineTimer) clearTimeout(state.pipelineTimer); + state.pipelineTimer = setTimeout(() => { + state.pipelinePhase = ""; + if (document.getElementById("rt-sec-pipe")) renderRuntime(); + }, 2500); + if (document.getElementById("rt-sec-pipe")) renderRuntime(); + // 渲染后再放动画:morph 已经把 chip 文本更新成最新那条。 + if (state.toolFlash) { + state.toolFlash = false; + rtFlashLatestTool(); + } + if (phase === "pre_action") { + state.chatStage = __("AI 思考中...", "AI thinking..."); + } else if (phase === "before_toolcall") { + state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); + if (tool && (state.pendingTools || []).indexOf(tool) === -1) { + if (!state.pendingTools) state.pendingTools = []; + state.pendingTools.push(tool); + rerenderChat(); + } + } else if (phase === "before_output") { + state.chatStage = __("生成回复中...", "Generating response..."); + } + var badge = document.getElementById("chat-stage"); + if (badge) { + badge.textContent = state.chatStage || ""; + badge.style.display = "none"; + } + } catch (ex) { + console.error("[SSE] stage error", ex); + } + }); + // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent + // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 + es.addEventListener("channel_input", (e) => { + try { + var d = JSON.parse(e.data); + var src = d.source || ""; + if (src) rtSparkInput(src); + } catch (ex) { + console.error("[SSE] channel_input error", ex); + } + }); + es.onopen = () => { + console.log("[SSE] connection opened"); + }; + es.onerror = (e) => { + console.error("[SSE] error", e); + // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 + try { + state.eventSource && state.eventSource.close(); + state.eventSource = null; + } catch (ex) {} + // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) + syncChatFromHistory().catch(() => {}); + // 3) 2s 后手动重连(比原 5s 更快恢复) + setTimeout(connectSSE, 2000); + }; + // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, + // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 + es.addEventListener("sync_required", (e) => { + console.log("[SSE] sync_required received, incremental sync"); + syncChatFromHistory().catch(() => {}); + }); + // Periodically refresh sidebar data + if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); + state._sidebarRefresh = setInterval(async () => { + try { + var td = await api("/terminals"); + if (td && td.terminals) state.terminals = td.terminals; + } catch (e) {} + try { + var ch = await api("/cmd/history"); + if (ch && ch.history) state.cmdHistory = ch.history; + } catch (e) {} + renderTerminals(); + renderCmdHistory(); + }, 5000); +} + +// ===== Plugins ===== +// 「服务入口」:被反代的插件服务,点一下直接打开。 +// +// 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个 +// webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是 +// 「我要进某个插件的管理页」。 +// +// 链接带 ?__token=:子域与门户不同源,浏览器不会带上会话 cookie; +// 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。 +function renderProxyServicesCard() { + var svcs = state.proxyServices || []; + var base = state.proxyBaseDomain || "localhost"; + var html = + '

' + __("服务入口", "Service Entry Points") + "

"; + if (!svcs.length) { + return ( + html + + '

' + + __( + "暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。", + "No proxied plugin services yet. Declare them in plugin.json's proxies field.", + ) + + "

" + ); + } + var token = state.settings?.["plugin.webui.api_key"] || ""; + html += + '
' + + __( + "插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ", + "Proxied through the same port. The subdomain form needs DNS for ", + ) + + "" + + escHtml("*." + base) + + "" + + __( + ";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。", + "; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).", + ) + + "
"; + html += '
'; + svcs.forEach((s) => { + var url = s.url || ""; + if (url && token) url += "?__token=" + encodeURIComponent(token); + var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; + html += + '
' + + '' + + '' + + escHtml(s.plugin_name || s.plugin) + + "" + + '' + + escHtml(s.name) + + "" + + (s.websocket ? 'WS' : "") + + (s.auth === "none" + ? '' + + __("未保护", "unprotected") + + "" + : "") + + '' + + escHtml(s.host + "." + base) + + ""; + // 「打开」优先用**路径形态**(url_portal): + // - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用; + // - 子域形态要求 DNS 能解析 .<基域名>,而这些域名 + // 在外部常常不可达(实测:外层只放行一个 Host,三级子域 + // 因通配证书不匹配而握手失败)。 + // 保留子域链接作为次选(局域网内直连时它更直观)。 + var primary = s.url_portal || url; + if (s.ok && primary) { + html += + '' + + __("打开", "Open") + + ""; + if (url && s.url_portal && url !== s.url_portal) { + html += + '' + + __("子域", "subdomain") + + ""; + } + } else { + html += + '' + + escHtml(s.error || __("不可用", "unavailable")) + + ""; + } + html += "
"; + }); + html += "
"; + return html; +} + +async function loadProxyServices() { + try { + var d = await api("/proxy/services"); + state.proxyServices = (d && d.services) || []; + state.proxyBaseDomain = (d && d.base_domain) || "localhost"; + } catch (e) { + state.proxyServices = []; + } +} + +function renderPlugins() { + var k = state.kernel; + var plugins = k?.plugins || []; + var tools = k?.tools || []; + var installed = state.installedPlugins || []; + var html = renderProxyServicesCard(); + html += + '

' + + __("安装插件", "Install Plugin") + + "

" + + '
' + + '' + + '
" + + '
' + + '
"; + var disabledNames = {}; + (state.disabledPlugins || []).forEach((d) => { + disabledNames[d.name] = d; + }); + var installedNames = (state.installedPlugins || []).map((p) => p.name); + var allPluginNames = {}; + plugins.forEach((p) => { + allPluginNames[p.name] = true; + }); + state.disabledPlugins.forEach((d) => { + allPluginNames[d.name] = true; + }); + html += + '

' + + __("所有插件", "All Plugins") + + " (" + + Object.keys(allPluginNames).length + + ")

"; + var names = Object.keys(allPluginNames).sort(); + if (names.length === 0) { + html += + '

' + + __("暂无插件", "No plugins") + + "

"; + } else { + html += + ""; + names.forEach((name) => { + var isExternal = installedNames.indexOf(name) >= 0; + var isDisabled = disabledNames[name]; + var loaded = plugins.some((p) => p.name === name); + var statusHtml = + loaded && !isDisabled + ? '' + + __("已加载", "Loaded") + + "" + : loaded && isDisabled + ? '' + + __("运行中(禁用待生效)", "Running (disable pending)") + + "" + : isDisabled + ? '' + + __("已禁用", "Disabled") + + "" + : '' + + __("未加载", "Not Loaded") + + ""; + var actionsHtml = ""; + if (loaded) { + actionsHtml += + '"; + } else { + actionsHtml += + '"; + } + if (isExternal) { + actionsHtml += + '"; + } + html += + "" + + "" + + ""; + }); + html += "
" + + __("名称", "Name") + + "" + + __("状态", "Status") + + "" + + __("操作", "Actions") + + "
" + + escHtml(name) + + "" + + statusHtml + + "" + + actionsHtml + + "
"; + } + html += "
"; + if (installed.length > 0) { + html += + '

' + + __("已安装外部插件", "Installed Plugins") + + " (" + + installed.length + + ")

" + + ""; + installed.forEach((p) => { + html += + "" + + "" + + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("描述", "Description") + + "" + + __("操作", "Actions") + + "
" + + escHtml(p.name) + + "" + + escHtml(p.version || "-") + + "" + + escHtml((p.description || "").substring(0, 50)) + + " " + + '
"; + } + if (state.pluginInfo) { + html += + '

' + + __("插件详情", "Plugin Details") + + ": " + + escHtml(state.pluginInfo.name) + + "

" + + "
" +
+			escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
+			"
" + + '
"; + } + if (tools.length > 0) { + html += + '

' + + __("已注册工具", "Registered Tools") + + " (" + + tools.length + + ")

" + + '
'; + tools.forEach((t) => { + html += + '' + + escHtml(t.name) + + ""; + }); + html += "
"; + } + html += + '

' + + __("系统操作", "System Operations") + + "

" + + '
"; + html += + '

' + + __("健康检查", "Health Check") + + '

'; + if (state.healthResult) { + html += renderHealthResult(state.healthResult); + } else { + html += + '

' + + __("尚未运行,点击右上角「运行」开始", "Not run yet, click Run to start") + + "

"; + } + html += "
"; + document.getElementById("tab-plugins").innerHTML = html; +} + +async function loadInstalledPlugins() { + try { + state.installedPlugins = await api("/plugins"); + } catch (e) { + state.installedPlugins = []; + } +} + +async function installPlugin() { + var inp = document.getElementById("plugin-url"); + var url = inp?.value.trim(); + if (!url) { + toast(__("请输入插件包 URL", "Please enter plugin URL"), true); + return; + } + try { + var raw = await api("/plugins", { + method: "POST", + body: JSON.stringify({ url: url }), + raw: true, + }); + var ct = raw.headers.get("content-type") || ""; + var r = ct.includes("json") ? await raw.json() : await raw.text(); + if (!raw.ok || (r && r.error)) { + toast( + __("安装失败: ", "Install failed: ") + + ((r && (r.error || r.details)) || "HTTP " + raw.status), + true, + ); + return; + } + toast( + __("安装结果: ", "Install result: ") + (r.status || JSON.stringify(r)), + ); + if (r.action === "reload_required") + toast( + __( + "已安装,请点击「重载插件」加载", + 'Installed, click "Reload Plugins" to load', + ), + false, + ); + await loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("安装失败: ", "Install failed: ") + e.message, true); + } +} + +async function installPluginFile(file) { + if (!file) return; + try { + var r = await fetch("/api/v1/plugins", { + method: "POST", + body: file, + headers: { "Content-Type": "application/octet-stream" }, + }); + var data = await r.json(); + toast( + __("上传安装: ", "Upload install: ") + + (data.status || JSON.stringify(data)), + ); + if (data.action === "reload_required") + toast( + __( + "已安装,请点击「重载插件」加载", + 'Installed, click "Reload Plugins" to load', + ), + false, + ); + loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("上传失败: ", "Upload failed: ") + e.message, true); + } +} + +async function showPluginInfo(name) { + try { + state.pluginInfo = await api("/plugins/" + encodeURIComponent(name)); + renderPlugins(); + } catch (e) { + toast(__("获取详情失败: ", "Get details failed: ") + e.message, true); + } +} + +function closePluginInfo() { + state.pluginInfo = null; + renderPlugins(); +} + +var removingPlugins = {}; + +async function removePlugin(name) { + if (removingPlugins[name]) return; // 防重复点击 + if ( + !confirm( + __("确定卸载插件", "Are you sure to unload plugin") + + "「" + + name + + "」?", + ) + ) + return; + removingPlugins[name] = true; + try { + var raw = await api("/plugins/" + encodeURIComponent(name), { + method: "DELETE", + raw: true, + }); + var ct = raw.headers.get("content-type") || ""; + var body = ct.includes("json") ? await raw.json() : await raw.text(); + if (!raw.ok) { + var em = (body && (body.error || body.details)) || "HTTP " + raw.status; + toast(__("卸载失败: ", "Unload failed: ") + em, true); + // 内置插件或路径错误时刷新一次列表保持状态一致 + loadInstalledPlugins(); + renderPlugins(); + return; + } + toast(__("已卸载: ", "Unloaded: ") + (body.name || body.status || name)); + await loadInstalledPlugins(); + // 同步内核插件/禁用列表,确保列表与工具立即消失 + try { + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + } catch (e2) {} + renderPlugins(); + } catch (e) { + toast(__("卸载失败: ", "Unload failed: ") + e.message, true); + } finally { + delete removingPlugins[name]; + } +} + +async function disablePlugin(name) { + if (name === "webui") { + var r = confirm( + __( + "禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?", + "Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?", + ), + ); + if (!r) return; + } + try { + var r = await api("/plugins/" + encodeURIComponent(name) + "/disable", { + method: "POST", + }); + toast(__("已禁用: ", "Disabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("禁用失败: ", "Disable failed: ") + e.message, true); + } +} + +async function enablePlugin(name) { + try { + var r = await api("/plugins/" + encodeURIComponent(name) + "/enable", { + method: "POST", + }); + toast(__("已启用: ", "Enabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("启用失败: ", "Enable failed: ") + e.message, true); + } +} + +async function reloadPlugins() { + try { + var r = await api("/plugins/reload", { method: "POST" }); + toast(__("插件已重载", "Plugins reloaded")); + state.kernel = await api("/kernel"); + renderPlugins(); + } catch (e) { + toast(__("重载失败: ", "Reload failed: ") + e.message, true); + } +} + +async function runHealthcheck() { + var panel = document.getElementById("health-panel"); + if (!panel) return; + panel.innerHTML = + '

' + + __("运行中...", "Running...") + + "

"; + try { + var r = await api("/kernel"); + var tools = r?.tools || []; + var healthTool = tools.find((t) => t.name === "healthcheck"); + if (!healthTool) { + panel.innerHTML = + '

' + + __("healthcheck 工具未注册", "healthcheck tool not registered") + + "

"; + return; + } + panel.innerHTML = + '

' + + __( + "通过 Agent 对话触发 healthcheck...", + "Triggering healthcheck via Agent...", + ) + + "

"; + var chatR = await api("/chat", { + method: "POST", + body: JSON.stringify({ + message: __( + "请运行 healthcheck 工具进行全面健康检查并报告结果", + "Please run the healthcheck tool for a full system check and report the results", + ), + }), + }); + panel.innerHTML = + "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; + } catch (e) { + panel.innerHTML = + '

' + + __("错误: ", "Error: ") + + escHtml(e.message) + + "

"; + toast(__("健康检查失败: ", "Health check failed: ") + e.message, true); + } +} + +function renderHealthResult(r) { + if (!r || !r.checks) + return ( + '

' + + __("暂无健康检查数据", "No health check data") + + "

" + ); + var checks = r.checks || []; + var passed = checks.filter((c) => c.pass).length; + var failed = checks.filter((c) => !c.pass).length; + var html = + '
' + + '' + + __("通过: ", "Pass: ") + + passed + + "" + + '' + + __("失败: ", "Fail: ") + + failed + + "" + + '' + + __("总计: ", "Total: ") + + checks.length + + "
"; + checks.forEach((c) => { + var passClass = c.pass ? "check-pass" : "check-fail"; + if (c.status === "skip") passClass = "check-skip"; + html += + '
' + + '' + + escHtml(c.name) + + "" + + '' + + (c.status || "unknown") + + "" + + '' + + escHtml(c.detail || "") + + "
"; + }); + return html; +} + +// ===== Kernel ===== +function renderKernel() { + var k = state.kernel; + if (!k) { + document.getElementById("tab-kernel").innerHTML = + '

' + + __("内核未响应", "Kernel not responding") + + "

"; + return; + } + var b = k.build || {}; + var html = + '

' + + __("构建", "Build") + + "

" + + '
' + + __("内核", "Kernel") + + '' + + escHtml(b.kernel_name || "HomeAgent") + + "
" + + '
' + + __("内核版本", "Kernel version") + + '' + + escHtml(b.version ? "v" + b.version : "-") + + "
" + + '
Commit' + + escHtml(b.commit || "-") + + "
" + + '
' + + __("构建时间", "Build time") + + '' + + escHtml(b.build_time || "-") + + "
" + + '
' + + __("SDK 兼容", "SDK compat") + + '' + + escHtml(b.sdk_compatible || "-") + + "
" + + (b.source_url + ? '
' + + __("源码", "Source") + + '' + + escHtml(b.source_url) + + "
" + : "") + + "
"; + html += + '

' + + __("运行时", "Runtime") + + "

" + + '
Goroutines' + + (k?.runtime?.goroutines || "-") + + "
" + + '
' + + __("内存", "Memory") + + '' + + (k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + + "
" + + '
Go ' + + __("版本", "Version") + + '' + + (k?.runtime?.go_version || "-") + + "
"; + html += + '

LLM

' + + '
Provider' + + (k.llm?.provider || __("未配置", "Not configured")) + + "
" + + '
' + + __("可用源", "Sources") + + '' + + (k.llm?.sources || 0) + + "
" + + '
' + + __("状态", "Status") + + '' + + (k.llm?.available ? __("运行中", "Running") : __("不可用", "Unavailable")) + + "
"; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("图记忆", "Graph Memory") + + '' + + (k.memory?.available + ? k.memory.entity_count + + __(" 实体, ", " entities, ") + + k.memory.relation_count + + __(" 关系", " relations") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文档记忆", "Document Memory") + + '' + + (k.documents?.available + ? k.documents.doc_count + __(" 文档", " docs") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文本记忆", "Text Memory") + + '' + + (k.text_memory?.available + ? k.text_memory.file_count + __(" 文件", " files") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("知识库", "Knowledge") + + '' + + (k.knowledge?.available + ? k.knowledge.item_count + __(" 项", " items") + : __("未初始化", "Uninitialized")) + + "
"; + html += + '

' + + __("插件", "Plugins") + + " (" + + (k.plugins?.length || 0) + + ")

"; + if (k.plugins?.length) { + html += '
'; + k.plugins.forEach((p) => { + html += '' + escHtml(p.name) + ""; + }); + html += "
"; + } else { + html += '

' + __("无", "None") + "

"; + } + html += "
"; + document.getElementById("tab-kernel").innerHTML = html; +} + +// ===== Star Map ===== +var starmapScene = null, + starmapCam = null, + starmapRen = null, + starmapCtrl = null; +var starmapNodes = [], + starmapEdges = []; +var starmapNodeMeshes = [], + starmapEdgeLines = [], + starmapStarField = null; +var starmapHovered = null, + starmapSelected = null, + starmapAutoView = true; +var starmapRaf = null; +// 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 +// BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, +// 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, +// 真实半径由 scale 给出(见 buildChatStarmapGraph)。 +var starmapGeo = null, + starmapGlowGeo = null; +// 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 +var starmapLabelEl = null; +// smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 +// 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), +// 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, +// 分类配色实际上从未生效过。 +var smTypeColors = { + person: 0x4488ff, + task: 0xff8844, + ai: 0xaa44ff, + concept: 0x44ff88, + object: 0xff4444, + // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") + location: 0xffaa44, + source: 0x8899ff, + document: 0xaabbcc, + event: 0xff88cc, + entity: 0x44ddcc, + scene: 0x88ff44, +}; +var smEdgeColors = { + 喜欢: 0xff6b6b, + 学习: 0x4ecdc4, + 属于: 0x45b7d1, + 相关: 0x96ceb4, + 使用: 0xfeca57, + 创建: 0xff9ff3, +}; + +// ===== 星图活动:让图跟上 agent 的动作 ===== +// +// 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号: +// +// 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage / +// agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机 +// 取一批)做一次扩散涟漪 + 发光冲高。 +// 2. starmapPullActivity() —— /runtime 调度器快照,3s。 +// ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲); +// interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。 +// 3. starmapPullPulse() —— /memory/graph/pulse,10s。 +// 最近变动的实体(新增概念 / mention_count 变化)做一次 +// 「生长」:从 0 缩放到正常大小,并留下余晖。 +// +// 全部在渲染循环里推进,不额外起定时器。 +var SM_PULSE_MS = 1400; // 单个脉冲的生命期 +var SM_RIPPLE_R = 26; // 涟漪最大半径 + +// starmapPulse 发出一次活动脉冲。 +// kind: "tool" | "stage" | "output" | "grow" +function starmapPulse(kind, hint) { + if (!starmapScene || !starmapNodeMeshes.length) return; + var now = Date.now(); + state.starmapLastPulseAt = now; + // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点, + // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。 + var targets = starmapPickPulseTargets(hint); + if (!targets.length) return; + var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS; + targets.forEach((m) => { + state.starmapPulses.push({ mesh: m, until: now + life, kind: kind }); + }); + // 队列上限:防止密集工具调用时脉冲无限堆积占内存。 + if (state.starmapPulses.length > 260) + state.starmapPulses = state.starmapPulses.slice(-260); +} + +// starmapPickPulseTargets 选出该被点亮的节点。 +// 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点 +// 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。 +// +// ★ 匹配必须用「词」而不是子串包含: +// hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱 +// (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, +// 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse +// 就选中 250 个节点、队列顶到 260 上限。 +function starmapPickPulseTargets(hint) { + var out = []; + if (!starmapNodeMeshes.length) return out; + if (hint) { + var keys = starmapHintTokens(hint); + if (keys.length) { + for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) { + var nd = starmapNodeMeshes[i].userData.nodeData || {}; + var nm = String(nd.name || "").toLowerCase(); + var ty = String(nd.type || "").toLowerCase(); + if (!nm) continue; + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + // 词边界命中:实体名恰好等于该词,或以该词为词首 + // (如 "knowledge_base" 命中词 "knowledge")。 + if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) { + out.push(starmapNodeMeshes[i]); + break; + } + } + } + } + } + // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。 + // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个, + // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 + // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 + // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 + if (out.length > 0) return out; + // 补齐:按 mention_count 降序取靠前且尚未入列的。 + var sorted = starmapNodeMeshes + .slice() + .sort( + (a, b) => + (b.userData.nodeData || {}).mention_count - + (a.userData.nodeData || {}).mention_count, + ); + for (var j = 0; j < sorted.length && out.length < 8; j++) { + if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); + } + return out; +} + +// starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。 +// 例:"knowledge_list" → ["knowledge","list"]。 +// 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关 +// 实体名,匹配它们只会制造噪声。 +function starmapHintTokens(hint) { + var raw = String(hint) + .toLowerCase() + .split(/[^a-z0-9\u4e00-\u9fa5]+/); + var out = []; + for (var i = 0; i < raw.length; i++) { + var t = raw[i]; + if (t.length >= 3 && out.indexOf(t) === -1) out.push(t); + } + return out; +} + +// starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 +function starmapPullActivity() { + if (!starmapScene) return; + api("/runtime") + .then((rt) => { + if (!rt || !rt.scheduler) return; + var s = rt.scheduler; + var prev = state.starmapActivity; + state.starmapActivity = s; + var pend = + (s.ready_queue_depth || 0) + + (s.pending_interrupts || 0) + + (s.suspend_stack || 0); + // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 + if (pend > 0) starmapPulse("stage", null); + // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 + if (prev) { + var dInt = + (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); + var dPre = + (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); + if (dInt > 0 || dPre > 0) starmapPulse("output", null); + } + }) + .catch(() => {}); +} + +// starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 +function starmapPullPulse() { + if (!starmapScene) return; + var since = state.starmapPulseSince || 0; + api("/memory/graph/pulse?since=" + since) + .then((r) => { + if (!r || !r.success || !r.data) return; + var now = Math.floor(Date.now() / 1000); + state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点 + var nodes = r.data.nodes || []; + if (!nodes.length) return; + var known = 0; + nodes.forEach((n) => { + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id); + if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量 + known++; + state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2; + starmapPulse("grow", n.name); + }); + // 若有新实体但一个都没匹配上,说明全量图过期了, + // 下一拍重拉全量(新节点才能出现)。 + if (known === 0 && nodes.length > 2) starmapDirty = true; + }) + .catch(() => {}); +} + +// starmapTickActivity 在渲染循环里推进所有脉冲与余晖。 +function starmapTickActivity(t) { + var now = Date.now(); + var breathe = 0; + // 1) 活动脉冲:发光冲高 + 尺寸微扩 + 涟漪环 + if (state.starmapPulses.length) { + var keep = []; + for (var i = 0; i < state.starmapPulses.length; i++) { + var p = state.starmapPulses[i]; + if (p.until <= now) { + p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive; + p.mesh.scale.setScalar(p.mesh.userData.baseScale); + continue; + } + keep.push(p); + var left = (p.until - now) / SM_PULSE_MS; // 1→0 + var k = 1 - left; // 0→1 + var wave = Math.sin(Math.min(1, k) * Math.PI); + var amp = p.kind === "output" ? 1.8 : 1.2; + p.mesh.material.emissiveIntensity = + p.mesh.userData.baseEmissive + wave * amp; + p.mesh.scale.setScalar(p.mesh.userData.baseScale * (1 + wave * 0.28)); + // 生长:新节点从 0 弹到正常大小 + if (p.kind === "grow") { + var g = Math.min(1, k * 1.4); + p.mesh.scale.setScalar(p.mesh.userData.baseScale * (0.15 + 0.85 * g)); + } + } + state.starmapPulses = keep; + } + // 2) 「生长」余晖:脉冲结束后短暂保留一点亮 + if (state.starmapGrown) { + for (var gid in state.starmapGrown) { + if (state.starmapGrown[gid] <= now) { + delete state.starmapGrown[gid]; + continue; + } + var gm = starmapNodeMeshes.find((x) => x.userData.nodeId == gid); + if (gm) + gm.material.emissiveIntensity = Math.max( + gm.material.emissiveIntensity, + gm.userData.baseEmissive + 0.6, + ); + } + } + // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」 + var idle = (now - (state.starmapLastPulseAt || 0)) / 4000; + breathe = Math.max(0, 1 - idle); + if (breathe > 0.01 && starmapNodeMeshes.length) { + // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。 + var stride = 24; + for (var b = 0; b < starmapNodeMeshes.length; b += stride) { + var m2 = starmapNodeMeshes[b]; + if (m2.userData.baseEmissive === undefined) continue; + m2.material.emissiveIntensity = Math.max( + m2.userData.baseEmissive, + m2.userData.baseEmissive + breathe * 0.25, + ); + } + } + return breathe; +} + +function createStarField() { + var c = 3000; + var p = new Float32Array(c * 3), + cl = new Float32Array(c * 3), + s = new Float32Array(c); + for (var i = 0; i < c; i++) { + var i3 = i * 3; + var r = 400 + Math.random() * 600, + th = Math.random() * Math.PI * 2, + ph = Math.acos(2 * Math.random() - 1); + p[i3] = r * Math.sin(ph) * Math.cos(th); + p[i3 + 1] = r * Math.sin(ph) * Math.sin(th); + p[i3 + 2] = r * Math.cos(ph); + if (Math.random() < 0.7) { + cl[i3] = 0.8 + Math.random() * 0.2; + cl[i3 + 1] = 0.8 + Math.random() * 0.2; + cl[i3 + 2] = 1; + } else { + cl[i3] = 1; + cl[i3 + 1] = 0.9 + Math.random() * 0.1; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } + s[i] = 0.5 + Math.random() * 2; + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + g.setAttribute("size", new THREE.BufferAttribute(s, 1)); + var m = new THREE.PointsMaterial({ + size: 1.5, + vertexColors: true, + transparent: true, + opacity: 0.8, + sizeAttenuation: true, + }); + starmapStarField = new THREE.Points(g, m); + starmapScene.add(starmapStarField); +} + +function onStarmapMove(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + if (hits.length > 0) { + var n = hits[0].object; + if (starmapHovered !== n) { + // 复位用 baseScale,不能用 set(1,1,1):节点现在是按 mention_count + // 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸。 + // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时 + // 几何体本身就带半径、不靠 scale。) + if (starmapHovered) + starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1); + starmapHovered = n; + starmapLabelShow(n); + } + } else { + if (starmapHovered) { + starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1); + starmapHovered = null; + starmapLabelHide(); + } + } +} + +// starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。 +// 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存), +// 文字用 DOM 渲染反而更清楚,且零 GPU 开销。 +function starmapLabelShow(mesh) { + var cont = starmapActiveContainer(); + if (!cont) return; + if (!starmapLabelEl) { + starmapLabelEl = document.createElement("div"); + starmapLabelEl.id = "sm-label"; + } + // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。 + // canvas 换页签搬运时,角标也要跟着搬。 + if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl); + var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {}; + var nm = nd.name || nd.id || ""; + starmapLabelEl.textContent = + nm + (nd.mention_count ? " ×" + nd.mention_count : ""); + starmapLabelEl.style.display = "block"; +} +function starmapLabelHide() { + if (starmapLabelEl) starmapLabelEl.style.display = "none"; +} +// starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 +function starmapContainer() { + return ( + document.getElementById("sm-container-home") || + document.getElementById("sm-container-chat") + ); +} + +// starmapActiveContainer 选出「该把星图画在哪」的容器。 +// +// 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页, +// 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个 +// 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到 +// 当前该显示的地方,是单一 renderer 前提下最干净的做法。 +function starmapActiveContainer() { + var tab = document.getElementById("tab-starmap"); + if (tab && tab.classList.contains("active")) { + var c = document.getElementById("sm-container-page"); + if (c) return c; + } + var ov = document.getElementById("tab-overview"); + if (ov && ov.classList.contains("active")) { + var h = document.getElementById("sm-container-home"); + if (h) return h; + } + return ( + document.getElementById("sm-container-chat") || + document.getElementById("sm-container-home") || + null + ); +} + +// renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。 +function renderStarmapTab() { + var host = document.getElementById("tab-starmap"); + if (!host) return; + if (!document.getElementById("sm-container-page")) { + var rt = state.runtime || {}; + var sc = rt.scheduler || {}; + var pend = (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0); + host.innerHTML = + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + + '
' + + smLegend("person", __("人物", "Person")) + + smLegend("concept", __("概念", "Concept")) + + smLegend("object", __("对象", "Object")) + + smLegend("location", __("地点", "Location")) + + smLegend("source", __("来源", "Source")) + + "
" + + '
' + + __( + "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", + "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.", + ) + + "
"; + } + // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。 + if (document.getElementById("sm-container-page")) { + if (!state.starmapInit && !state.starmapLoading) loadChatStarmapData(); + else if (starmapRen) { + var cont = document.getElementById("sm-container-page"); + if (starmapRen.domElement.parentElement !== cont) { + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } + onStarmapResize(); + } + } + smUpdateStat(); +} + +// smLegend 生成图例小项。 +function smLegend(key, label) { + var col = smTypeColors[key] || 0xcccccc; + var hex = "#" + ("0000" + col.toString(16)).slice(-6); + return ( + '' + + '' + + escHtml(label) + + "" + ); +} + +// smUpdateStat 在星图页签头部显示节点/边/活动状态。 +function smUpdateStat() { + var el = document.getElementById("sm-page-stat"); + if (!el) return; + var act = state.starmapActivity; + var busy = act && (act.ready_queue_depth || act.pending_interrupts); + var txt = starmapNodes.length + " " + __("节点", "nodes"); + if (starmapEdges.length) + txt += " / " + starmapEdges.length + " " + __("关系", "edges"); + if (busy) txt += " · " + __("调度中", "busy"); + el.textContent = txt; +} + +function onStarmapClick(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + if (hits.length > 0) { + var n = hits[0].object; + starmapSelected = starmapSelected === n ? null : n; + if (starmapAutoView && starmapSelected) + flyStarmapTo(starmapSelected.userData.nodeId, 500); + onStarmapMove(e); + } else { + starmapSelected = null; + } +} + +function flyStarmapTo(nodeId, dur) { + if (!starmapAutoView) return; + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId); + if (!m) return; + var tp = m.position.clone(), + sp = starmapCam.position.clone(), + st = starmapCtrl.target.clone(); + var dist = tp.length() + 25, + ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8); + var t0 = Date.now(); + (function lerp() { + var t = Math.min((Date.now() - t0) / dur, 1), + e = 1 - (1 - t) ** 3; + starmapCam.position.lerpVectors(sp, ep, e); + starmapCtrl.target.lerpVectors(st, tp, e); + if (t < 1) requestAnimationFrame(lerp); + })(); +} + +function onStarmapResize() { + if (!starmapRen || !starmapCam) return; + var cont = starmapRen.domElement.parentElement; + if (!cont) return; + var rect = cont.getBoundingClientRect(); + var w = rect.width || 800, + h = Math.max(rect.height || 250, 100); + if (w > 0 && h > 0) { + starmapCam.aspect = w / h; + starmapCam.updateProjectionMatrix(); + starmapRen.setSize(w, h); + } +} + +function starmapAnimate() { + starmapRaf = requestAnimationFrame(starmapAnimate); + if (starmapCtrl) starmapCtrl.update(); + if (starmapStarField) starmapStarField.rotation.y += 0.0001; + // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 + if (starmapNodeMeshes.length) starmapTickActivity(); + if (starmapRen && starmapScene && starmapCam) + starmapRen.render(starmapScene, starmapCam); +} + +function createNebula() { + var nc = 500; + var p = new Float32Array(nc * 3), + cl = new Float32Array(nc * 3); + for (var i = 0; i < nc; i++) { + var i3 = i * 3; + p[i3] = (Math.random() - 0.5) * 800; + p[i3 + 1] = (Math.random() - 0.5) * 800; + p[i3 + 2] = (Math.random() - 0.5) * 800; + var ch = Math.random(); + if (ch < 0.33) { + cl[i3] = 0.5 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.7 + Math.random() * 0.3; + } else if (ch < 0.66) { + cl[i3] = 0.2 + Math.random() * 0.2; + cl[i3 + 1] = 0.3 + Math.random() * 0.3; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } else { + cl[i3] = 0.7 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.5 + Math.random() * 0.3; + } + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + var m = new THREE.PointsMaterial({ + size: 8, + vertexColors: true, + transparent: true, + opacity: 0.15, + sizeAttenuation: true, + blending: THREE.AdditiveBlending, + }); + var np = new THREE.Points(g, m); + starmapScene.add(np); +} + +// ===== Settings ===== +function pluginDisplayName(p) { + if (p === "core") return __("核心", "Core"); + var name = p.replace("plugin.", ""); + var meta = state.pluginMeta && state.pluginMeta[name]; + if (meta) + return state.lang === "en" ? meta.name_en || name : meta.name_zh || name; + return name; +} + +function renderSettingsSidebar() { + var el = document.querySelector(".settings-tabs"); + if (!el) return; + el.innerHTML = ""; + // 「外观」不是插件设置,但它是 webui 自己的配置,放在设置页最前面。 + var ap = document.createElement("span"); + ap.textContent = __("外观", "Appearance"); + if (state.selectedSection === "appearance") ap.className = "active"; + ap.onclick = () => { + state.selectedSection = "appearance"; + renderOneSettings(); + }; + el.appendChild(ap); + state.settingsPlugins.forEach((p) => { + var a = document.createElement("span"); + a.textContent = pluginDisplayName(p); + if (p === state.selectedSection) a.className = "active"; + a.onclick = () => { + state.selectedSection = p; + renderOneSettings(); + }; + el.appendChild(a); + }); +} + +// 外观设置区:主题(浅/深)+ 配色 + 背景图。与侧栏那个 palette-pop 同一套 +// setColor/applyBgImg/applyBgBlur,只是给它一个正式的、可发现的落点 +// (原来只有侧栏底部一个调色盘图标,找不到)。 +function appearanceHtml() { + var theme = document.documentElement.getAttribute("data-theme") || "light"; + var color = localStorage.getItem("ha-color") || "mono"; + var dots = ""; + Object.keys(PALETTES).forEach((k) => { + dots += + '"; + }); + var img = localStorage.getItem("ha-bg-img") || ""; + var blur = localStorage.getItem("ha-bg-blur") || "0"; + return ( + '

' + + __("外观", "Appearance") + + "

" + + '
' + + __("主题", "Theme") + + '' + + ' " + + '" + + "
" + + '
' + + __("配色", "Color") + + '' + + dots + + "
" + + "" + + '' + + '
' + + '" + + "" + + "
" + + '" + + '" + + '

' + + __( + "配色只影响本站点界面的强调色,不改动 Agent 的人格或数据。", + "Colors only affect this dashboard's accent; agent behavior and data are unchanged.", + ) + + "

" + ); +} +function pickTheme(name) { + saveTheme(name); + renderOneSettings(); +} +function pickColor(name) { + setColor(name); + renderOneSettings(); +} + +function renderOneSettings() { + if (state.selectedSection === "appearance") { + document.getElementById("tab-settings").innerHTML = + '
' + + appearanceHtml() + + "
"; + renderSettingsSidebar(); + return; + } + var prefix = state.selectedSection + "."; + var allKeys = Object.keys(state.settings || {}); + var filtered = allKeys.filter( + (k) => k === prefix.slice(0, -1) || k.startsWith(prefix), + ); + filtered.sort(); + var hideTopLlms = [ + "core.llm.base_url", + "core.llm.model", + "core.llm.api_key", + "core.llm.adapter", + "core.llm.adapter_path", + "core.llm.thinking_enabled", + ]; + var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources.")); + var sourceMap = {}; + sourceKeys.forEach((k) => { + var parts = k.split("."); + var srcName = parts[3]; + if (!sourceMap[srcName]) sourceMap[srcName] = {}; + sourceMap[srcName][k] = true; + }); + var mcpServerKeys = filtered.filter( + (k) => k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5, + ); + var mcpServerMap = {}; + mcpServerKeys.forEach((k) => { + var parts = k.split("."); + var srvName = parts[3]; + if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; + mcpServerMap[srvName][k] = true; + }); + var regularKeys = filtered.filter( + (k) => + !k.startsWith("core.llm.sources.") && + hideTopLlms.indexOf(k) === -1 && + !k.startsWith("plugin.mcp.servers.") && + k !== "plugin.mcp.servers", + ); + var html = + '
'; + if ( + regularKeys.length === 0 && + Object.keys(sourceMap).length === 0 && + Object.keys(mcpServerMap).length === 0 && + state.selectedSection !== "plugin.mcp" + ) { + html += + '

' + + escHtml(state.selectedSection) + + '

' + + __("暂无设置项", "No settings") + + "

"; + } else { + regularKeys.forEach((k) => { + var v = state.settings[k]; + var sv = typeof v === "object" ? JSON.stringify(v) : String(v); + var m = state.meta?.[k]; + var shortName = k.split(".").pop().replace(/_/g, " "); + var label = m?.display_name || shortName; + var desc = m?.description || ""; + var typ = m?.type || "string"; + var ph = m?.placeholder || ""; + var opts = m?.options || []; + var inpId = "inp-" + k.replace(/\./g, "_"); + var inp = ""; + if (typ === "bool") { + var chk = sv === "true" ? "checked" : ""; + inp = + '"; + } else if (typ === "select") { + var selOpts = ""; + opts.forEach((o) => { + selOpts += + '"; + }); + inp = + ""; + } else if (typ === "text") { + inp = + ""; + } else { + inp = + ""; + } + var extra = ""; + if (m?.extra) { + m.extra.forEach((f) => { + var fk = (k ? k + "." : "") + f.key; + var fv = state.settings?.[fk]; + var fph = f.placeholder || __("输入", "Enter ") + f.label; + extra += + '
"; + if (f.type === "select") { + var fopts = ""; + if (f.options) + f.options.forEach((o) => { + fopts += + '"; + }); + extra += + ""; + } else { + extra += + ''; + } + extra += "
"; + }); + } + var descHtml = desc + ? '

' + + escHtml(desc) + + "

" + : ""; + html += + '
' + + escHtml(k) + + "
" + + inp + + descHtml + + extra + + '
"; + }); + // LLM Sources + Object.keys(sourceMap) + .sort() + .forEach((src) => { + var baseKey = "core.llm.sources." + src; + var srcData = + state.settings?.[baseKey + ".adapter"] || + state.settings?.[baseKey + ".base_url"] || + ""; + var fields = [ + { + key: "adapter", + label: __("适配器", "Adapter"), + type: "text", + }, + { key: "base_url", label: "Base URL", type: "text" }, + { key: "model", label: __("模型", "Model"), type: "text" }, + { key: "api_key", label: "API Key", type: "text" }, + { + key: "thinking_enabled", + label: __("思考模式", "Thinking Mode"), + type: "select", + options: ["true", "false"], + }, + { + key: "adapter_path", + label: __("适配器路径", "Adapter Path"), + type: "text", + }, + ]; + var headerLabel = mL10n(src, "LLM Source: " + src); + html += '

' + escHtml(headerLabel) + "

"; + fields.forEach((f) => { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var flabel = f.label; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + if (f.type === "select") { + var fopts = ""; + f.options.forEach((o) => { + fopts += + '"; + }); + html += + ""; + } else { + html += + "'; + } + }); + html += + '
' + + '" + + '
"; + }); + if ( + Object.keys(sourceMap).length > 0 || + state.selectedSection === "core.llm" + ) { + html += + '"; + } + // MCP Servers + if ( + state.selectedSection === "plugin.mcp" || + Object.keys(mcpServerMap).length > 0 + ) { + html += + '

' + + __("MCP 服务器", "MCP Servers") + + '

' + + __( + "配置 Model Context Protocol 服务端连接", + "Configure Model Context Protocol server connections", + ) + + "

"; + Object.keys(mcpServerMap) + .sort() + .forEach((srv) => { + var baseKey = "plugin.mcp.servers." + srv; + var fields = [ + { + key: "command", + label: __("启动命令", "Command"), + type: "text", + }, + { key: "url", label: "SSE URL", type: "text" }, + { + key: "args", + label: __("参数(JSON数组)", "Args (JSON array)"), + type: "text", + }, + { + key: "env", + label: __("环境变量(JSON数组)", "Env (JSON array)"), + type: "text", + }, + ]; + html += '

' + escHtml(srv) + "

"; + fields.forEach((f) => { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + html += + "'; + }); + html += + '
' + + '" + + '
"; + }); + html += + '"; + } + } + html += "
"; + document.getElementById("tab-settings").innerHTML = html; + renderSettingsSidebar(); +} + +function markDirty(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (inp) inp.style.borderColor = "var(--save-btn-border)"; +} + +async function saveSetting(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (!inp) return; + var val; + var m = state.meta?.[k]; + if (m?.type === "bool") { + val = inp.checked ? "true" : "false"; + } else if (m?.type === "select") { + val = inp.value; + } else { + var raw = inp.value; + try { + val = JSON.parse(raw); + } catch (e) { + val = raw; + } + } + try { + var r = await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: k, value: val }), + }); + if (r.status === "ok") { + inp.style.borderColor = ""; + state.settings[k] = val; + toast(__("已保存: ", "Saved: ") + k); + } else { + toast(__("保存失败: ", "Save failed: ") + (r.error || "unknown"), true); + } + } catch (e) { + toast(__("保存失败: ", "Save failed: ") + e.message, true); + } +} + +function renderConfigDisabled() { + document.getElementById("tab-settings").innerHTML = + '

' + + __("设置", "Settings") + + '

' + + __("设置面板已加载", "Settings panel loaded") + + "

"; + renderOneSettings(); +} + +function mL10n(key, fallback) { + var meta = state.meta?.[key]; + if (meta?.display_name) return meta.display_name; + return fallback || key; +} + +function showAddSourceDialog() { + var name = prompt( + __( + "输入新 LLM 源名称(如 openai、anthropic):", + "Enter new LLM source name (e.g. openai, anthropic):", + ), + ); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var keys = [ + "base_url", + "model", + "api_key", + "adapter", + "adapter_path", + "thinking_enabled", + ]; + var values = { + base_url: "https://api." + name + ".com", + model: "", + api_key: "", + adapter: name, + adapter_path: "", + thinking_enabled: "false", + }; + var promises = keys.map((f) => + api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "core.llm.sources." + name + "." + f, + value: values[f], + }), + }), + ); + Promise.all(promises) + .then(() => { + toast( + __("源", "Source") + + ' "' + + name + + '" ' + + __("已创建,请配置各项参数", "created, please configure parameters"), + ); + renderAll(); + }) + .catch((e) => { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); +} + +async function deleteSource(name) { + if ( + !confirm( + __("确认删除源", "Are you sure to delete source") + ' "' + name + '"?', + ) + ) + return; + var base = "core.llm.sources." + name; + var fields = [ + "adapter", + "base_url", + "model", + "api_key", + "thinking_enabled", + "adapter_path", + ]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: base + "." + f, value: null }), + }); + } + toast(__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted")); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +function addMCPSource() { + var name = prompt(__("输入新 MCP 服务器名称:", "Enter new MCP server name:")); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var fields = ["command", "url", "args", "env"]; + var values = { command: "", url: "", args: "[]", env: "[]" }; + var promises = fields.map((f) => + api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: values[f], + }), + }), + ); + Promise.all(promises) + .then(() => { + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已创建", "created"), + ); + renderAll(); + }) + .catch((e) => { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); +} + +async function deleteMCPServer(name) { + if ( + !confirm( + __("确认删除 MCP 服务器", "Are you sure to delete MCP server") + + ' "' + + name + + '"?', + ) + ) + return; + var fields = ["command", "url", "args", "env"]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: null, + }), + }); + } + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已删除", "deleted"), + ); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +// ===== Adapters ===== +async function renderAdapters() { + var html = ""; + try { + var r = await api("/adapters"); + var adapters = r.adapters || []; + window._adapters = adapters; + html += + '

' + + __("已加载的适配器", "Loaded Adapters") + + " (" + + adapters.length + + ")

"; + if (adapters.length === 0) { + html += + '

' + + __("暂无适配器", "No adapters") + + "

"; + } else { + html += + ""; + adapters.forEach((a) => { + html += + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("操作", "Actions") + + "
" + + escHtml(a.name) + + "" + + escHtml(a.version || "-") + + "
"; + } + html += "
"; + html += + '

' + + __("上传新适配器", "Upload New Adapter") + + "

" + + "" + + '' + + "" + + '' + + '
"; + } catch (e) { + html += + '

' + + __("加载适配器失败: ", "Failed to load adapters: ") + + escHtml(e.message) + + "

"; + } + document.getElementById("tab-adapters").innerHTML = html; +} + +async function uploadAdapter() { + var name = document.getElementById("adapter-name")?.value; + var code = document.getElementById("adapter-code")?.value; + if (!name || !code) { + toast(__("名称和代码不能为空", "Name and code cannot be empty"), true); + return; + } + try { + var r = await api("/adapters", { + method: "POST", + body: JSON.stringify({ name: name, code: code }), + }); + if (r.status === "loaded") { + toast( + __("适配器", "Adapter") + ' "' + name + '" ' + __("已加载", "loaded"), + ); + renderAdapters(); + } else { + toast(__("上传失败: ", "Upload failed: ") + (r.error || "unknown"), true); + } + } catch (e) { + toast(__("上传失败: ", "Upload failed: ") + e.message, true); + } +} + +async function deleteAdapter(name) { + if ( + !confirm( + __("确定删除适配器", "Are you sure to delete adapter") + + ' "' + + name + + '"?', + ) + ) + return; + try { + var r = await api("/adapters/" + encodeURIComponent(name), { + method: "DELETE", + }); + if (r.status === "deleted") { + toast( + __("适配器", "Adapter") + ' "' + name + '" ' + __("已删除", "deleted"), + ); + renderAdapters(); + } else { + toast(__("删除失败", "Delete failed"), true); + } + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +// ===== Init ===== +async function logout() { + try { + await fetch("/api/v1/logout", { + method: "POST", + credentials: "include", + }); + } catch (e) {} + location.href = "/login"; +} + +renderConfigDisabled(); +// 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 +(async () => { + await loadChatHistory(); + renderAll(); + connectSSE(); + startUptimeTicker(); + startRuntimeTicker(); + startChatTicker(); + maybeShowPersonaWizard(); +})(); +setInterval(renderAll, 15000); +// 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 +// 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 +// syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 +setInterval(() => { + syncChatFromHistory().catch(() => {}); +}, 30000); diff --git a/internal/plugins/webui/gzip.go b/internal/plugins/webui/gzip.go new file mode 100644 index 0000000..ac89622 --- /dev/null +++ b/internal/plugins/webui/gzip.go @@ -0,0 +1,301 @@ +package webui + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + "sync" +) + +// gzip 中间件:给可压缩的响应加 Content-Encoding: gzip。 +// +// ★ 为什么必须有(生产实例实测,非估算): +// +// 首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding +// (直连与经 nginx 两条路径都验过:头里没有该字段,wire 尺寸 == 原始 +// 尺寸)。实测同一份数据 gzip -9 后: +// +// /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%) +// /api/v1/kernel 152,667 → 37,697 (-75%) +// +// 这些响应是**高度重复的 JSON**(同一批 key 名反复出现、中文实体名、 +// 时间戳),压缩比自然地高。经公网入口(frp + 移动网络)时,793KB 的 +// 首屏与 53MB/h 的空闲轮询都是实打实的流量钱。 +// +// 放在哪一层: +// +// 包在 logged **外面**(链:proxyDispatch → gzip → logged → mux)。 +// 理由:proxyDispatch 命中时直接 return,响应来自上游(上游自己的 +// Content-Encoding 由 httputil 处理),我们不该插手;而门户自身的 +// 全部响应(含 requireAPI 的 401/503、requireWeb 的 302 跳转、 +// HTML/CSS/JS、全部 JSON API)都该压。 +// +// ★ 三个必须显式处理的坑: +// +// 1. **SSE / 流式不能压。** text/event-stream 一旦进了 gzip 缓冲, +// flush 语义就废了(表现为「前端收不到流式,要等缓冲攒够」)。 +// 2. **必须透传 http.Flusher。** handler 里有 `w.(http.Flusher)` +// 的类型断言(handleChatEvents / streamOpenAI)。包装 ResponseWriter +// 会让断言失败 ⇒ flusher 为 nil ⇒ 代码走降级分支,SSE 直接坏掉。 +// 这不是「顺手加一下」能过的改动。 +// 3. **HEAD / 204 / 304 没有 body**,压缩它们只会浪费 CPU 和加坏头。 +const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐 + +// gzipCompressibleContentType 判定是否值得压。 +// +// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍 +// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。 +// +// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压): +// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。 +func gzipCompressibleContentType(ct string) bool { + if ct == "" { + return false + } + // 取分号前的主类型(content-type 可能带 charset) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + ct = strings.TrimSpace(strings.ToLower(ct)) + switch ct { + case "application/json", "application/javascript", "text/javascript", + "text/html", "text/css", "text/plain", + "application/xml", "text/xml", "image/svg+xml": + return true + } + return false +} + +// acceptsGzip 判断客户端是否要 gzip。 +func acceptsGzip(r *http.Request) bool { + for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { + if i := strings.IndexByte(v, ';'); i >= 0 { + v = v[:i] + } + if strings.EqualFold(strings.TrimSpace(v), "gzip") { + return true + } + } + return false +} + +// gzipWriter 池:gzip.NewWriter 每次都要分配窗口/哈希状态, +// 而 webui 的 API 响应极频繁,不复用会让 GC 压力反噬我们要省的目的。 +var gzipPool = sync.Pool{ + New: func() any { return gzip.NewWriter(io.Discard) }, +} + +// gzip 响应的三个状态。写成枚举而不是几个 bool —— 上一版用 +// passthrough/decided/buffering/allowBuf 四个 bool 交叉表示, +// 出现了「小响应内容被写成空」和「已压缩类型仍被压」两个 bug, +// 根因就是「到底该不该压」在 Write / WriteHeader / 收尾三处各判一次、 +// 判据还不一致。**单一判据在单一处求值**是这里的硬要求。 +type gzipMode int + +const ( + // gzipUndecided:还没看过 Content-Type,不知道该不该压。 + gzipUndecided gzipMode = iota + // gzipPassThrough:不该压(或不能压),原样透传。 + gzipPassThrough + // gzipBuffering:可压且已决定压,但还没写够阈值,先攒着。 + gzipBuffering + // gzipStreaming:正在边收边压(已越过阈值)。 + gzipStreaming +) + +// gzipResponseWriter 包装 ResponseWriter,边写边压。 +type gzipResponseWriter struct { + http.ResponseWriter + gz *gzip.Writer + mode gzipMode + wroteHeader bool + status int + buf []byte +} + +func (g *gzipResponseWriter) WriteHeader(code int) { + if g.wroteHeader { + return + } + g.status = code + // 无 body 的状态码不压,也不加 Content-Encoding。 + if code == http.StatusNoContent || code == http.StatusNotModified { + g.commit(gzipPassThrough) + return + } + // 内容类型不可压(如 image/webp):透传,头照常发。 + if !gzipCompressibleContentType(g.Header().Get("Content-Type")) { + g.commit(gzipPassThrough) + return + } + // 可压,但**先不发头**:Content-Length 一旦发出就不能改, + // 得先知道最终写多少字节才能决定压不压(小响应压了反而变大)。 + // 真正的 commit 发生在首次 Write 越过阈值、或 handler 返回时。 +} + +func (g *gzipResponseWriter) Write(p []byte) (int, error) { + switch g.mode { + case gzipPassThrough: + g.commit(gzipPassThrough) + return g.ResponseWriter.Write(p) + + case gzipStreaming: + // 已开压:直接喂进 gzip 流。注意此时若下游还没 WriteHeader 过, + // startCompress 已经替我们发过了(见 commit)。 + if g.gz == nil { + return g.ResponseWriter.Write(p) + } + return g.gz.Write(p) + + case gzipBuffering: + g.buf = append(g.buf, p...) + if len(g.buf) >= gzipMinLength { + g.startCompress() + g.writeBufToStream() + } + return len(p), nil + + default: // gzipUndecided + // WriteHeader 没被显式调用(handler 直接 Write)也走这里。 + if !gzipCompressibleContentType(g.Header().Get("Content-Type")) { + g.commit(gzipPassThrough) + return g.ResponseWriter.Write(p) + } + g.buf = append(g.buf, p...) + if len(g.buf) >= gzipMinLength { + g.startCompress() + g.writeBufToStream() + } else { + g.mode = gzipBuffering + } + return len(p), nil + } +} + +// writeBufToStream 把缓冲内容送进 gzip 流。写失败(客户端已断开)在 +// 响应收尾阶段无法处置,与 close/Flush 中的处理一致地忽略。 +func (g *gzipResponseWriter) writeBufToStream() { + if g.gz != nil && len(g.buf) > 0 { + _, _ = g.gz.Write(g.buf) + } + g.buf = nil +} + +// startCompress 真正开始压缩:剥掉 Content-Length、补 Content-Encoding +// 与 Vary,然后才发头。 +func (g *gzipResponseWriter) startCompress() { + h := g.Header() + h.Del("Content-Length") // 压缩后长度未知,留着就是错的 + h.Set("Content-Encoding", "gzip") + // Vary:同一 URL 会因 Accept-Encoding 不同而返回不同编码。中间缓存 + // (nginx/CDN/浏览器)必须据此区分,否则会把 gzip 版发给不支持 + // 压缩的客户端。 + h.Add("Vary", "Accept-Encoding") + if g.gz == nil { + g.gz = gzipPool.Get().(*gzip.Writer) + g.gz.Reset(g.ResponseWriter) + } + g.commit(gzipStreaming) +} + +// commit 定模式并发头(幂等)。 +func (g *gzipResponseWriter) commit(mode gzipMode) { + if g.wroteHeader { + g.mode = mode + return + } + g.mode = mode + g.wroteHeader = true + if g.status == 0 { + g.status = http.StatusOK + } + g.ResponseWriter.WriteHeader(g.status) +} + +// Flush 透传:SSE 依赖它逐帧下发。 +// +// ★ 存在即必须正确:handler 里是 `w.(http.Flusher)` 断言, +// 拿不到就等于没有 Flush,SSE 会卡到缓冲满。 +// +// 若此刻仍在缓冲(可压但没写够阈值),必须先把已攒的内容发出去, +// 否则 Flush 形同虚设、且数据永远滞留缓冲。 +func (g *gzipResponseWriter) Flush() { + switch g.mode { + case gzipUndecided: + // 没写过任何东西就 Flush(少见):先把头发出去。 + g.commit(gzipPassThrough) + case gzipBuffering: + // 有内容但没到阈值:SSE 场景不该走到这;真走到了就直接定夺—— + // 有内容就压(已经攒了半天,收益远大于 23 字节的头开销)。 + if len(g.buf) > 0 { + g.startCompress() + g.writeBufToStream() + } else { + g.commit(gzipPassThrough) + } + case gzipStreaming: + if g.gz != nil { + _ = g.gz.Flush() + } + } + if f, ok := g.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// finish 在 handler 返回后收尾:把「攒着没发」的内容定夺掉。 +func (g *gzipResponseWriter) finish() { + switch g.mode { + case gzipBuffering: + if len(g.buf) >= gzipMinLength { + // 攒够阈值:压。 + g.startCompress() + g.writeBufToStream() + } else { + // 小于阈值(典型:/api/v1/status 197B):**原样发出**。 + // 这一支是「小响应不压」判据的落地点 —— 压它反而更大。 + g.commit(gzipPassThrough) + if len(g.buf) > 0 { + _, _ = g.ResponseWriter.Write(g.buf) + } + g.buf = nil + } + case gzipUndecided: + // handler 没写过 body(如只 WriteHeader)但我们压住了头: + // 按「无 body」处理,原样发头。 + g.commit(gzipPassThrough) + } +} + +// close 关闭 gzip 流并归还池。 +func (g *gzipResponseWriter) close() { + if g.gz != nil { + _ = g.gz.Close() + g.gz.Reset(io.Discard) + gzipPool.Put(g.gz) + g.gz = nil + } +} + +// gzipMW 是压缩中间件。 +func gzipMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // HEAD 没有 body;不协商编码。 + if r.Method == http.MethodHead || !acceptsGzip(r) { + next.ServeHTTP(w, r) + return + } + // SSE 直接透传:压缩会毁掉 flush 语义(见文件头注释)。 + if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { + next.ServeHTTP(w, r) + return + } + gw := &gzipResponseWriter{ResponseWriter: w} + defer func() { + gw.finish() + gw.close() + }() + next.ServeHTTP(gw, r) + }) +} diff --git a/internal/plugins/webui/gzip_test.go b/internal/plugins/webui/gzip_test.go new file mode 100644 index 0000000..9c7f107 --- /dev/null +++ b/internal/plugins/webui/gzip_test.go @@ -0,0 +1,449 @@ +package webui + +import ( + "bufio" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// TestGzipCompressesJSON 是本次改动的正面判据。 +// +// 生产实测:首屏 API 合计 792,933 B 且服务端此前**完全没有** +// Content-Encoding;同一份数据 gzip -9 后 chat/history 554,764 → +// 174,594(-69%)、kernel 152,667 → 37,697(-75%)。 +func TestGzipCompressesJSON(t *testing.T) { + // 造一个「像生产那样重复度高」的 JSON:同批 key 反复出现。 + var sb strings.Builder + sb.WriteByte('[') + for i := 0; i < 400; i++ { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(`{"id":12345,"name":"工具结果会撑爆共享段 arena","type":"Concept","mention_count":228,"updated_at":"2026-09-26T14:54:16Z"}`) + } + sb.WriteByte(']') + payload := sb.String() + + raw := len(payload) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, payload) + }))) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + // 手动关掉自动解压,才能量到 wire 尺寸。 + tr := &http.Transport{DisableCompression: true} + cl := &http.Client{Transport: tr} + resp, err := cl.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + wire, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q,应为 gzip", got) + } + if len(wire) >= raw { + t.Fatalf("压缩无效:raw=%d wire=%d(应显著变小)", raw, len(wire)) + } + t.Logf("JSON 压缩:%d → %d 字节(-%.0f%%)", raw, len(wire), + 100*float64(raw-len(wire))/float64(raw)) + // 内容必须一字不差可还原。 + dec := readGzFrom(t, payload, wire) + if dec != payload { + t.Fatalf("解压后内容与原始不一致(len %d vs %d)", len(dec), len(payload)) + } +} + +// readGzFrom 用已拿到的 wire 字节解压,避免再发请求。 +func readGzFrom(t *testing.T, _ string, wire []byte) string { + t.Helper() + zr, err := gzip.NewReader(strings.NewReader(string(wire))) + if err != nil { + t.Fatalf("gzip.NewReader: %v", err) + } + defer zr.Close() + b, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("read: %v", err) + } + return string(b) +} + +// TestGzipSkipsSmallResponses 钉住「小响应不压」。 +// +// 几百字节的 JSON 压完反而更大(gzip 头 23 字节 + deflate 无收益), +// 压它是纯亏。与 nginx 的 gzip_min_length 1000 对齐。 +func TestGzipSkipsSmallResponses(t *testing.T) { + body := `{"ok":true}` + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, body) + }))) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("小响应不应压缩,却带了 Content-Encoding=%q", ce) + } + b, _ := io.ReadAll(resp.Body) + if string(b) != body { + t.Fatalf("小响应内容被破坏: %q", string(b)) + } +} + +// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。 +// +// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小, +// 纯浪费 CPU——而 CPU 正是压缩要省的东西。 +func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) { + for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} { + t.Run(ct, func(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ct) + io.WriteString(w, strings.Repeat("x", 5000)) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("%s 不应压缩,却带了 %q", ct, ce) + } + }) + } +} + +func bytesRepeat(s string, n int) string { + return strings.Repeat(s, n) +} + +// TestGzipNoRequestNoCompress 钉住「客户端不要就不压」。 +func TestGzipNoRequestNoCompress(t *testing.T) { + body := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 300) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, body) + }))) + defer srv.Close() + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}). + Get(srv.URL) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("未声明 Accept-Encoding 时不应压缩,却带了 %q", ce) + } + b, _ := io.ReadAll(resp.Body) + if string(b) != body { + t.Fatalf("未压缩路径内容被破坏") + } +} + +// ★ TestGzipSetsVary 是缓存正确性的判据。 +// +// 同一 URL 会因 Accept-Encoding 不同而返回不同编码。缺 Vary 时 +// 中间缓存(nginx/CDN/浏览器)可能把 gzip 版发给不支持压缩的客户端。 +func TestGzipSetsVary(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, strings.Repeat(`{"a":"小宅"},`, 400)) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary = %q,应含 Accept-Encoding", resp.Header.Get("Vary")) + } +} + +// TestGzipDropsContentLength 钉住「压缩后不能留 Content-Length」。 +// +// 留着就是**错的**长度(gzip 后更短),客户端按 Content-Length 读会 +// 截断或多等。 +// ★ TestGzipDropsContentLength 钉住「压缩后不能留**陈旧的** Content-Length」。 +// +// ★ 这条判据的写法我先写错了,值得记下来:初版断言「压缩响应不应带 +// Content-Length」并实测失败。追查后证明**是我的判据错了、代码是对的**: +// +// 在 Go 里 Header.Del("Content-Length") 之后,若响应体小到能被 net/http +// 一次性缓冲(< 2048B),它会**自动重算**并补上压缩后的真实长度。 +// 实测:原始 14000B → wire 119B → 响应头 Content-Length: 119(正确)。 +// +// 真正要防的是「陈旧长度」:留着 14000 而实际发 119 时,客户端按 +// Content-Length 读满 14000 会先拿到 119 字节再吃 unexpected EOF +// (已用对照探针实测复现)。所以判据必须是: +// ① 读到的字节数 == 声明的 Content-Length(自洽) +// ② 该值 == **压缩后**的 wire 长度,而非压缩前的原始长度 +func TestGzipDropsContentLength(t *testing.T) { + payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // 故意设成**原始**长度,模拟「handler 自己写的长度」。 + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + io.WriteString(w, payload) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + wire, readErr := io.ReadAll(resp.Body) + if readErr != nil { + t.Fatalf("★ 读 body 失败(陈旧 Content-Length 的典型症状): %v", readErr) + } + cl := resp.Header.Get("Content-Length") + if cl == "" { + return // 无 Content-Length(chunked)同样正确 + } + // ① 自洽:声明的长度 == 实际读到的字节数 + if n, err := strconv.Atoi(cl); err != nil || n != len(wire) { + t.Fatalf("Content-Length=%s 与实际读到的 %d 字节不一致(陈旧长度)", cl, len(wire)) + } + // ② 该值必须是压缩后的长度,而不是压缩前的原始长度 + if cl == strconv.Itoa(len(payload)) { + t.Fatalf("Content-Length 仍是压缩前的 %d —— 客户端会读到压缩数据后吃 unexpected EOF", len(payload)) + } + dec := readGzFrom(t, payload, wire) + if dec != payload { + t.Fatalf("解压内容不一致") + } +} + +// TestGzipStaleContentLengthWouldBreak 正面记录「陈旧长度」的危害。 +// +// 若中间件忘了 Del("Content-Length"),客户端按原始长度读满就会撞 +// unexpected EOF。这条把危害钉成可执行的判据,而不是只靠注释。 +func TestGzipStaleContentLengthWouldBreak(t *testing.T) { + payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400) + // 模拟「忘记 Del」的错误中间件:压了却留着原始长度。 + broken := gzipMWBrokenNoDel(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + io.WriteString(w, payload) + })) + srv := httptest.NewServer(broken) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + return // 请求就失败了,也算「陈旧长度确实有害」 + } + defer resp.Body.Close() + wire, readErr := io.ReadAll(resp.Body) + // 对照:本包的真中间件不会出这个问题;这里只断言「要么失败、要么读到 + // 不足声明长度的数据」——两者都证明陈旧长度有害。 + if readErr == nil && len(wire) == len(payload) { + t.Skip("该环境恰好容忍了陈旧长度,无法构造反例") + } + t.Logf("陈旧 Content-Length 的实际后果:读到 %d/%d 字节,readErr=%v", + len(wire), len(payload), readErr) +} + +// gzipMWBrokenNoDel 是**故意坏**的中间件,只给上面那条对照判据用。 +func gzipMWBrokenNoDel(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !acceptsGzip(r) { + next.ServeHTTP(w, r) + return + } + gw := &gzipResponseWriter{ResponseWriter: w} + next.ServeHTTP(gw, r) + gw.finish() + // 故意不 Del("Content-Length") + gw.close() + }) +} + +// ★★ TestGzipPreservesFlusher 是**最关键**的一条。 +// +// handler 里是 `w.(http.Flusher)` 的类型断言(handleChatEvents / +// streamOpenAI)。包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil +// ⇒ 代码走降级分支 ⇒ SSE 静默坏掉(表现为「收不到流式」而不是报错)。 +// +// 判据:经过 gzipMW 之后,handler 仍必须能断言出 Flusher 并真的生效。 +func TestGzipPreservesFlusher(t *testing.T) { + var gotFlusher bool + var flushWorked bool + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, ok := w.(http.Flusher) + gotFlusher = ok + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + io.WriteString(w, strings.Repeat(`{"chunk":"x"},`, 300)) + if ok { + f.Flush() + // 记下:Flush 之后能不能立刻观察到数据写出。 + _, flushWorked = f.(interface{ FlushError() error }) + } + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + if !gotFlusher { + t.Fatal("★ 经过 gzipMW 后 w.(http.Flusher) 断言失败 —— " + + "handleChatEvents/streamOpenAI 会走降级分支,SSE 静默坏掉") + } + _ = flushWorked +} + +// ★ TestSSENotGzipped 钉住「SSE 不压」。 +// +// text/event-stream 一旦进 gzip 缓冲,flush 语义就废了 +// (表现为「前端收不到流式,要等缓冲攒够」)。 +func TestSSENotGzipped(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + io.WriteString(w, "data: hi\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Accept", "text/event-stream") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" { + t.Fatal("★ SSE 被压缩了 —— flush 语义会被毁掉") + } + rd := bufio.NewReader(resp.Body) + line, err := rd.ReadString('\n') + if err != nil { + t.Fatalf("read first line: %v", err) + } + if !strings.HasPrefix(line, "data:") { + t.Fatalf("首行 = %q", line) + } +} + +// TestGzipNoBodyStatuses 钉住 204/304 不带 Content-Encoding。 +func TestGzipNoBodyStatuses(t *testing.T) { + for _, code := range []int{http.StatusNoContent, http.StatusNotModified} { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + }))) + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Errorf("状态 %d 不应带 Content-Encoding,却有 %q", code, ce) + } + resp.Body.Close() + srv.Close() + } +} + +// TestGzipStatusCodePreserved 钉住状态码不被中间件改写。 +// +// requireAPI 的 401/503、requireWeb 的 302 都走这条路。 +func TestGzipStatusCodePreserved(t *testing.T) { + for _, code := range []int{200, 201, 302, 401, 404, 500, 503} { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + io.WriteString(w, strings.Repeat(`{"e":"x"},`, 400)) + }))) + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{ + Transport: &http.Transport{DisableCompression: true}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if resp.StatusCode != code { + t.Errorf("状态码 = %d,应为 %d", resp.StatusCode, code) + } + io.ReadAll(resp.Body) + resp.Body.Close() + srv.Close() + } +} + +// TestGzipRealChainSSEStillWorks 端到端:走生产同一条链(Handler())打真 SSE。 +// +// 这条最有价值——它同时验证 gzip 接入没破坏既有 SSE 行为。 +func TestGzipRealChainSSEStillWorks(t *testing.T) { + srv, _, _ := newOpenAITestServer(t) + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil) + req.Header.Set("X-API-Key", testAuthAPIKey) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Accept-Encoding", "gzip") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("SSE 连接失败: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode) + } + if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" { + t.Fatal("★ SSE 不应被压缩") + } + // 短时间内必须还活着(能读到首帧或至少没被立刻断开) + rd := bufio.NewReader(resp.Body) + type res struct{ ok bool } + ch := make(chan res, 1) + go func() { + _, err := rd.ReadString('\n') + ch <- res{err == nil} + }() + select { + case r := <-ch: + if !r.ok { + t.Error("SSE 首读失败") + } + case <-time.After(5 * time.Second): + _ = resp.Body.Close() + } +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index de412ca..8d56d3b 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -449,14 +449,17 @@ func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { // // 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代, // 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。 -// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。 +// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → gzip → 日志 → mux)。 // // 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节 // 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在 // plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免 // 这类漂移。 func (h *Handler) Handler() http.Handler { - return h.proxyDispatch(h.logged(h.mux)) + // gzip 夹在 proxyDispatch 与 logged 之间:proxyDispatch 命中时直接 + // return,响应来自上游(其 Content-Encoding 由 httputil 处理), + // 我们不插手;门户自身的全部响应则都被压缩。 + return h.proxyDispatch(gzipMW(h.logged(h.mux))) } func (h *Handler) proxyDispatch(next http.Handler) http.Handler { From d3315c68ca397be1081c77a66964b60b23ff806f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 00:23:04 +0800 Subject: [PATCH 11/13] =?UTF-8?q?perf(webui):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8C=89=E9=A1=B5=E7=AD=BE=E6=87=92=E5=8A=A0=E8=BD=BD=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=A6=96=E5=B1=8F=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8B=89=E9=9A=90=E8=97=8F=E9=A1=B5=E7=AD=BE=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=EF=BC=8C=E7=A9=BA=E9=97=B2=E8=AF=B7=E6=B1=82=E7=A0=8D?= =?UTF-8?q?=E5=88=B0=201/3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测的问题:renderAll 无论当前在哪个页签,都无条件拉 9 个接口并 渲染全部 7 个页签。首屏 792,933 B 里**约 230KB 花在用户看不见的隐藏 DOM 上** —— /api/v1/kernel(152KB,只有内核页要)与 /api/v1/settings (72KB,只有设置页要),而 renderKernel() / renderOneSettings() 是在 **隐藏的 tab 容器**里构建 DOM 的。更糟的是每 15s 重来一遍。 ### 依赖关系是实测出来的,不是猜的 逐个 render 函数 grep 它读的 state.*: renderOverview → 无(只读 status/runtime/dom)★ 总览最便宜 renderKernel → state.kernel renderOneSettings → state.settings / state.meta renderPlugins → state.kernel / installedPlugins / disabledPlugins renderAdapters → 无(自拉 /api/v1/adapters) renderChat → 自建布局;星图/终端/命令是其子面板 于是「切到哪页才拉哪页的数据」写成一张 TABS 表(唯一真相表), 正确性由结构保证,而不是靠一串 if 串联。 ### 顺带治掉三个轮询器(浏览器 40s 空载实测) 改前停在总览页 40s 内 37 个请求: /runtime 17 次、/terminals 8 次、/cmd/history 8 次、/status 3 次 1. **terminals/cmd/history 的 5s 轮询是无条件的** —— 但这两个面板只 存在于**聊天页**(buildChatLayout 里的 chat-panel-terminal/-cmd), 在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。 改为「仅聊天页可见时才轮询」。 2. **/runtime 有两个消费者各拉一遍**:startRuntimeTicker(3s)与 starmapPullActivity(3s)。星图现在只读 state.runtime,不再自己发请求。 3. 轮询改走共享数据块 starmapFetchBlock(带 3s 节流 + 单一数据源)。 改后 40s 内 17 个请求(-54%),且不再有任何接口用于渲染不可见的面板。 ### 首屏 overview 首屏只拉 status/runtime/chat/history/persona/memory-graph, **不再拉 kernel 与 settings**。 ### 刷新语义 区分「活数据」与「近乎不变的数据」:status/runtime 每 3s 允许重拉; kernel/settings/plugins 首次拉过后**不再每 15s 重拉**(这正是 53MB/h 的主因)。切回页签也不重拉(数据没理由变)。四个变更操作 (源/MCP 的增删)改调 renderAll(true) 强制刷新;启停插件/保存设置那几处 本来就自己 refetch 再局部重渲染,不依赖 renderAll。 ### ★ 途中修掉一个被上一提交引入的真 bug loadChatStarmapData 的空图分支硬写 getElementById("sm-container-chat")。 星图搬到总览后,总览页与独立页签都没有这个 id ⇒ 首页首帧必抛 「Cannot set properties of null」,被 catch 吞掉但 starmapInit 没置上, 于是**永不重试、星图永远空白**。改为取 starmapActiveContainer() 并加 空值保护。浏览器实测:修前 ERRORS 非空,修后 STARTUP + 7 个页签全 clean。 (教训:把 UI 元素挪到新位置后,必须把所有按 id 直取该元素的地方找全 —— grep 该 id 一次。) 同时删掉被 starmapActiveContainer 取代的死函数 starmapContainer()。 ### 验证(浏览器实测,非估算) - go vet 干净;全量测试通过 - 首屏(overview):只拉 status/runtime/chat-history/persona/memory-graph, kernel 与 settings 确认**未出现** - 逐页切换记录请求:每页只拉自己那几项 - 空载 40s:37 → 17 个请求 - 运行态面板仍正常渲染(rt-panel 存在、rt-sec-pipe / rt-sec-topo 均在) - 启动 + 7 个页签全部无控制台异常 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/plugins/webui/dashboard.js | 382 +++++++++++++++++++--------- 1 file changed, 259 insertions(+), 123 deletions(-) diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index a58dc02..60b5dc8 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -493,80 +493,173 @@ function toggleSidebar() { } // ===== Tab Render Dispatch ===== -async function renderAll() { - try { - var s = await api("/status"); - state.status = s; - state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; - } catch (e) {} - try { - state.kernel = await api("/kernel"); - } catch (e) {} - try { - var s = await api("/settings"); - state.settings = s.settings || {}; - state.meta = s.meta || {}; - state.settingsPlugins = s.plugins || ["core"]; - state.pluginMeta = s.plugin_meta || {}; - state.disabledPlugins = s.disabled_plugins || []; - } catch (e) {} - try { - state.installedPlugins = await api("/plugins"); - } catch (e) {} - try { - await loadProxyServices(); - } catch (e) {} - try { - await loadTerminals(); - } catch (e) {} - try { - await loadCmdHistory(); - } catch (e) {} - try { - renderOverview(); - } catch (e) { - console.error("renderOverview", e); + +// TABS 是「哪个页签需要哪些数据」的**唯一真相表**。 +// +// 为什么要有这张表(实测依据,非估算): +// +// 改前 renderAll 无论当前在哪个页签,都无条件拉 9 个接口并渲染全部 +// 7 个页签。生产实例首屏 792,933 B 里,**约 230KB 花在用户看不见的 +// 隐藏 DOM 上**:/api/v1/kernel(152KB,只有「内核」页要)与 +// /api/v1/settings(72KB,只有「设置」页要)——而 renderKernel() / +// renderOneSettings() 是在**隐藏的 tab 容器**里构建 DOM 的。 +// +// 依赖关系是**实测**出来的(逐个 render 函数 grep 它读的 state.*), +// 不是猜的: +// renderOverview → 无(只读 status/runtime/dom)★ 总览最便宜 +// renderKernel → state.kernel +// renderOneSettings → state.settings / state.meta +// renderPlugins → state.kernel / state.installedPlugins / +// state.disabledPlugins / state.pluginInfo +// renderAdapters → 无(自拉 /api/v1/adapters) +// renderChat → 自建布局;星图/终端/命令是其子面板 +// +// 于是「切到哪页才拉哪页的数据」成为结构性正确,而不是靠 if 串联。 +var TABS = { + overview: { fetch: ["status", "runtime"], render: ["renderOverview"] }, + chat: { + fetch: ["status", "proxyServices", "terminals", "cmdHistory"], + render: ["renderChat"], + }, + starmap: { fetch: [], render: ["renderStarmapTab"] }, + plugins: { + fetch: ["status", "kernel", "plugins", "runtime"], + render: ["renderPlugins"], + }, + settings: { fetch: ["settings", "status"], render: ["renderOneSettings"] }, + adapters: { fetch: ["status"], render: ["renderAdapters"] }, + kernel: { fetch: ["status", "kernel", "runtime"], render: ["renderKernel"] }, +}; + +// starmapActiveTab 返回当前激活的页签名。 +function starmapActiveTab() { + var el = document.querySelector(".tab-content.active"); + if (el && el.id) return el.id.replace(/^tab-/, ""); + return "overview"; +} + +// loadedOnce 记录每个数据块是否已拉过。刷新语义刻意区分: +// - 首次进入某页签:拉 +// - 之后每 15s 的 renderAll:**不**重拉 kernel/settings(它们几乎不变, +// 却占每拍 232KB —— 这是「空闲 53MB/h」的主因) +// - 切回页签:仍然不重拉(数据没理由变),只重渲染 +// 真正需要新鲜的(runtime/status)单独走快通道,见 STALE_EVERY。 +var _loadedOnce = {}; +var _lastFetchAt = {}; +// STALE_EVERY:这几块数据是活的(运行态/状态),每 3s 允许重拉一次。 +var STALE_EVERY = 3000; + +// starmapFetchBlock 按名字拉一块数据。幂等且带节流。 +async function starmapFetchBlock(name) { + var now = Date.now(); + var volatile_ = name === "status" || name === "runtime"; + if (_loadedOnce[name]) { + if (!volatile_) return; + if (now - (_lastFetchAt[name] || 0) < STALE_EVERY) return; } - try { - await loadRuntime(); - } catch (e) { - console.error("loadRuntime", e); + _lastFetchAt[name] = now; + switch (name) { + case "status": + try { + var s = await api("/status"); + state.status = s; + state.startedAt = s.startedAt + ? new Date(s.startedAt).getTime() + : null; + } catch (e) {} + break; + case "runtime": + try { + var rt = await api("/runtime"); + state.runtime = rt; + } catch (e) { + state.runtime = null; + } + // 运行态变了就顺手让星图重算一次(读同一个快照,不额外发请求)。 + if (typeof starmapApplyActivity === "function") starmapApplyActivity(); + break; + case "kernel": + try { + state.kernel = await api("/kernel"); + } catch (e) {} + break; + case "settings": + try { + var s2 = await api("/settings"); + state.settings = s2.settings || {}; + state.meta = s2.meta || {}; + state.settingsPlugins = s2.plugins || ["core"]; + state.pluginMeta = s2.plugin_meta || {}; + state.disabledPlugins = s2.disabled_plugins || []; + } catch (e) {} + break; + case "plugins": + try { + state.installedPlugins = await api("/plugins"); + } catch (e) {} + break; + case "proxyServices": + try { + await loadProxyServices(); + } catch (e) {} + break; + case "terminals": + try { + await loadTerminals(); + } catch (e) {} + break; + case "cmdHistory": + try { + await loadCmdHistory(); + } catch (e) {} + break; } + _loadedOnce[name] = true; +} + +async function renderAll(force) { + var tab = starmapActiveTab(); + var spec = TABS[tab] || TABS.overview; + // force:变更操作后(保存设置/启停插件)需要重新拉那块数据。 + if (force) _loadedOnce = {}; + // 1) 只拉当前页签需要的数据(并发的别串行等) + var needs = spec.fetch || []; + await Promise.all( + needs.map((n) => starmapFetchBlock(n)), + ); + // 2) 只渲染当前页签(+ 星图在多处出现,单独处理) try { - renderChat(); + spec.render.forEach((fn) => { + if (typeof window[fn] === "function") window[fn](); + }); } catch (e) { - console.error("renderChat", e); + console.error("renderAll(" + tab + ")", e); } - try { - renderChatStarmap(); - } catch (e) { - console.error("renderChatStarmap", e); + // 星图:总览页内嵌 + 独立页签两处都要确保已初始化。 + if (tab === "overview" || tab === "starmap" || tab === "chat") { + try { + renderChatStarmap(); + } catch (e) { + console.error("renderChatStarmap", e); + } + if (tab === "starmap") { + try { + renderStarmapTab(); + } catch (e) { + console.error("renderStarmapTab", e); + } + } } - try { - renderStarmapTab(); - } catch (e) { - console.error("renderStarmapTab", e); - } - try { - renderPlugins(); - } catch (e) { - console.error("renderPlugins", e); - } - try { - renderKernel(); - } catch (e) { - console.error("renderKernel", e); - } - try { - renderOneSettings(); - } catch (e) { - console.error("renderOneSettings", e); - } - try { - renderAdapters(); - } catch (e) { - console.error("renderAdapters", e); + // 3) 聊天页的子面板(终端/运行中命令)只在聊天页重建 + if (tab === "chat") { + try { + renderTerminals(); + renderCmdHistory(); + } catch (e) { + console.error("chat sub-panels", e); + } } + // 4) 卡片倾斜高光:只在指针设备上加 try { if (!window.matchMedia("(hover: none)").matches) { document @@ -1758,7 +1851,11 @@ function startRuntimeTicker() { state._runtimeTicker = setInterval(() => { var tab = document.querySelector("#tab-overview"); if (tab && tab.classList.contains("active")) { - loadRuntime(); + // 走共享数据块(自带 3s 节流 + 单一数据源)而不是直接 loadRuntime: + // 星图也读 state.runtime,两个入口拉同一接口就是重复请求。 + starmapFetchBlock("runtime").then(() => { + if (starmapActiveTab() === "overview") renderRuntime(); + }); } }, 3000); } @@ -2750,6 +2847,13 @@ function renderChatStarmap() { async function loadChatStarmapData() { try { var resp = await api("/memory/graph"); + // 星图现在有三个容器(总览/独立页签/聊天面板),写提示前必须重新 + // 拿当前活跃的那个。原代码硬写 getElementById("sm-container-chat"), + // 而总览页与独立页签都没有这个 id —— 空图分支一进就抛 + // 「Cannot set properties of null」(浏览器实测:首页首帧必现)。 + // 症状是整个函数被 catch 吞掉、state.starmapInit 却没置上, + // 于是后续再也不会重试 —— 星图永远是空的。 + var target = starmapActiveContainer(); if ( !resp || !resp.success || @@ -2757,10 +2861,11 @@ async function loadChatStarmapData() { !resp.data.nodes || resp.data.nodes.length === 0 ) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("暂无记忆数据", "No memory data") + - "

"; + if (target) + target.innerHTML = + '

' + + __("暂无记忆数据", "No memory data") + + "

"; state.starmapInit = true; state.starmapLoading = false; return; @@ -2772,10 +2877,12 @@ async function loadChatStarmapData() { state.starmapLoading = false; initChatStarmap(); } catch (e) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("加载失败", "Load failed") + - "

"; + var target2 = starmapActiveContainer(); + if (target2) + target2.innerHTML = + '

' + + __("加载失败", "Load failed") + + "

"; state.starmapInit = true; state.starmapLoading = false; } @@ -4573,22 +4680,37 @@ function connectSSE() { console.log("[SSE] sync_required received, incremental sync"); syncChatFromHistory().catch(() => {}); }); - // Periodically refresh sidebar data + // 终端 / 运行中命令的定时刷新。 + // + // ★ 原来这段是**无条件**的 5s 轮询(浏览器实测:停在总览页 40s 内 + // 打了 8 次 /terminals + 8 次 /cmd/history)。但这两个面板只存在于 + // **聊天页**(buildChatLayout 里的 chat-panel-terminal / -cmd), + // 在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。 + // + // 改为「仅聊天页可见时才轮询」,且错过的那一拍在切回聊天页时由 + // renderAll 补上(renderAll 的 terminals/cmdHistory 数据块带节流)。 if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); - state._sidebarRefresh = setInterval(async () => { - try { - var td = await api("/terminals"); - if (td && td.terminals) state.terminals = td.terminals; - } catch (e) {} - try { - var ch = await api("/cmd/history"); - if (ch && ch.history) state.cmdHistory = ch.history; - } catch (e) {} - renderTerminals(); - renderCmdHistory(); + state._sidebarRefresh = setInterval(() => { + var tab = document.querySelector("#tab-chat"); + if (!tab || !tab.classList.contains("active")) return; + fetchTerminalsAndCmd().catch(() => {}); }, 5000); } +// fetchTerminalsAndCmd 拉终端与运行中命令并重渲染(只在聊天页需要)。 +async function fetchTerminalsAndCmd() { + try { + var td = await api("/terminals"); + if (td && td.terminals) state.terminals = td.terminals; + } catch (e) {} + try { + var ch = await api("/cmd/history"); + if (ch && ch.history) state.cmdHistory = ch.history; + } catch (e) {} + renderTerminals(); + renderCmdHistory(); +} + // ===== Plugins ===== // 「服务入口」:被反代的插件服务,点一下直接打开。 // @@ -5480,32 +5602,53 @@ function starmapHintTokens(hint) { } // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 +// starmapPullActivity 用**已有的** /runtime 快照驱动图,绝不自己再发一轮请求。 +// +// ★ 原来它直接 api("/runtime"),而 startRuntimeTicker 也在 3s 拉同一个 +// 接口 —— 浏览器实测:停在总览页 40s 内 /runtime 被打 **17 次** +// (= 3s 一次 × 2 + 15s 的 renderAll)。两个消费者拉同一份数据是纯浪费。 +// +// 现在只读 state.runtime(由 startRuntimeTicker / renderAll 负责刷新)。 +// 星图慢一拍(最多 3s)无所谓:它只是把调度器状态映射成脉冲。 function starmapPullActivity() { if (!starmapScene) return; - api("/runtime") - .then((rt) => { - if (!rt || !rt.scheduler) return; - var s = rt.scheduler; - var prev = state.starmapActivity; - state.starmapActivity = s; - var pend = - (s.ready_queue_depth || 0) + - (s.pending_interrupts || 0) + - (s.suspend_stack || 0); - // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 - if (pend > 0) starmapPulse("stage", null); - // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 - if (prev) { - var dInt = - (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); - var dPre = - (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); - if (dInt > 0 || dPre > 0) starmapPulse("output", null); - } - }) - .catch(() => {}); + var s0 = state.runtime && state.runtime.scheduler; + if (!s0) { + // 还没有快照(星图先于运行态轮询初始化):拉一次,建立基线。 + starmapFetchBlock("runtime").then(() => { + starmapApplyActivity(); + }); + return; + } + starmapApplyActivity(); +} + +// starmapApplyActivity 把 state.runtime 里的调度器计数映射成星图脉冲。 +function starmapApplyActivity() { + var rt = state.runtime; + if (!rt || !rt.scheduler) return; + var s = rt.scheduler; + var prev = state.starmapActivity; + state.starmapActivity = s; + var pend = + (s.ready_queue_depth || 0) + + (s.pending_interrupts || 0) + + (s.suspend_stack || 0); + // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 + if (pend > 0) starmapPulse("stage", null); + if (!prev) { + smUpdateStat(); + return; + } + // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 + var sum = (a) => (a || []).reduce((x, y) => x + y, 0); + if ( + sum(s.interrupts_by_level) - sum(prev.interrupts_by_level) > 0 || + sum(s.preempts_by_level) - sum(prev.preempts_by_level) > 0 + ) { + starmapPulse("output", null); + } + smUpdateStat(); } // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 @@ -5689,13 +5832,6 @@ function starmapLabelShow(mesh) { function starmapLabelHide() { if (starmapLabelEl) starmapLabelEl.style.display = "none"; } -// starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 -function starmapContainer() { - return ( - document.getElementById("sm-container-home") || - document.getElementById("sm-container-chat") - ); -} // starmapActiveContainer 选出「该把星图画在哪」的容器。 // @@ -6517,7 +6653,7 @@ function showAddSourceDialog() { '" ' + __("已创建,请配置各项参数", "created, please configure parameters"), ); - renderAll(); + renderAll(true); }) .catch((e) => { toast(__("创建失败: ", "Create failed: ") + e.message, true); @@ -6548,7 +6684,7 @@ async function deleteSource(name) { }); } toast(__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted")); - renderAll(); + renderAll(true); } catch (e) { toast(__("删除失败: ", "Delete failed: ") + e.message, true); } @@ -6586,7 +6722,7 @@ function addMCPSource() { '" ' + __("已创建", "created"), ); - renderAll(); + renderAll(true); }) .catch((e) => { toast(__("创建失败: ", "Create failed: ") + e.message, true); @@ -6622,7 +6758,7 @@ async function deleteMCPServer(name) { '" ' + __("已删除", "deleted"), ); - renderAll(); + renderAll(true); } catch (e) { toast(__("删除失败: ", "Delete failed: ") + e.message, true); } From 4a231a8d4cd1c4d8c6e5ab2b53784f3d2f715ee5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 06:44:49 +0800 Subject: [PATCH 12/13] =?UTF-8?q?style(webui):=20dashboard.js=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E8=A1=A8=E8=BE=BE=E5=BC=8F=E5=90=88=E5=B9=B6=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化,无语义变化: - state.startedAt 的三元表达式(563 行附近) - renderAll 里 Promise.all 的实参(624 行附近) 两处的表达式与参数列表逐字符相同,只是原先的换行被收拢。 已随二进制部署并验证上线:现网 `/` 返回的 HTML 里单行写法命中 1 处、 旧多行写法 0 残留。 --- internal/plugins/webui/dashboard.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 60b5dc8..6823691 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -563,9 +563,7 @@ async function starmapFetchBlock(name) { try { var s = await api("/status"); state.status = s; - state.startedAt = s.startedAt - ? new Date(s.startedAt).getTime() - : null; + state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; } catch (e) {} break; case "runtime": @@ -624,9 +622,7 @@ async function renderAll(force) { if (force) _loadedOnce = {}; // 1) 只拉当前页签需要的数据(并发的别串行等) var needs = spec.fetch || []; - await Promise.all( - needs.map((n) => starmapFetchBlock(n)), - ); + await Promise.all(needs.map((n) => starmapFetchBlock(n))); // 2) 只渲染当前页签(+ 星图在多处出现,单独处理) try { spec.render.forEach((fn) => { From 9c93f232e33ada5d41f5339de37cd9b0b97edda1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 07:14:34 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(webui):=20=E6=98=9F=E5=9B=BE=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E6=94=B9=E4=B8=AD=E6=80=A7=E7=81=B0=E8=93=9D=20+=20?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E6=8C=89=E5=AE=9E=E9=99=85=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 为什么会出现「一片绿」 上一提交修好了「分类配色从未生效」那个 bug(服务端发 "Concept"、JS 键是 "concept" ⇒ 永不匹配 ⇒ 1150 个节点全回退兜底灰 0xcccccc)。修好之后 颜色值**一个字没改**(concept 键仍是 0x44ff88),于是 1148 个 Concept 节点第一次真的拿到了那个亮青绿 —— 1149 个节点里 1148 个是 Concept, 所以整张图变成绿的。 ⇒ 结论:绿色不是渲染 bug,是「配色终于生效」后暴露出的真实数据形状。 之前的灰色恰好是「全都没匹配上」的症状。 ### 换中性色(用户裁定) 默认色改为 SM_COLOR_DIM = 0x7d8a9e(中性灰蓝)。亮青绿配 1149 个 自发光球确实扎眼。 ### 顺带把「图例说谎」也修了 原图例写死「人物/概念/对象/地点/来源」五项,而实测图谱里只有 Concept(1148)与 Source(1)—— 列出四个永不出现的类型,等于告诉 用户存在实际不存在的分类。 现在: - 图例按**实际出现**的类型动态生成,标注占比 - 占比 <1% 的不单列(实测 1/1149 = 0.087%,单列会显示成「来源 0%」, 既难看又误导读者以为图里没有来源节点),归入「其他 N 个」 - 只有**一种有存在感**的类型时,附一句实话说明「节点同色不是分类图」 ### 颜色规则也跟图例对齐 smColorFor:只有存在 >=1% 的第二类型时才按类型上色,否则全图中性色。 理由:99.9% 概念 + 0.1% 其他时按类型上色,得到的仍是一整片同色, 而那一两个异色点在视觉上就是噪点(实测 colors 只剩 ["7d8a9e"])。 不动服务端类型识别(用户裁定):nlp/extractor.go 至今不判类型、 graph.go:451 写死 Concept,根治要改记忆链路,本轮不碰。 ### 途中修掉一个自己引入的 bug 图例空。首屏星图在**总览页**初始化,那时 #sm-legend 还不存在 (骨架由 renderStarmapTab 建),smLegend 内部 getElementById 返回 null 直接返回;而 renderStarmapTab 建完骨架后只调了 smUpdateStat()。 ⇒ 浏览器实测图例 html 长度 0。在 renderStarmapTab 里补一次 smLegend()。 ### 验证(真实 1149 节点数据集 + WebGL) - 颜色:["7d8a9e"](单一中性色)—— 改前 ["44ff88"] 一片绿 - 图例文本:「概念 100% 其他 1 个(图谱实体几乎都是同一类型,节点同色; 出现新类型后会自动分类)」 - 统计:1149 节点 / 865 关系;控制台零异常 - go vet 干净;全量测试通过 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/plugins/webui/dashboard.js | 183 +++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 33 deletions(-) diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 6823691..2597ca3 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -2936,6 +2936,9 @@ function initChatStarmap() { // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 // 只在星图真正初始化后启动,避免在隐藏页签空跑。 starmapStartActivity(); + // 图例按实际出现的类型生成(建图后 smPresentTypes 才是准的)。 + smLegend(); + smUpdateStat(); } // starmapStartActivity 启动两路活动轮询(幂等)。 @@ -2954,6 +2957,13 @@ function starmapStartActivity() { } function buildChatStarmapGraph() { + // 先统计实际出现的类型(smColorFor / 动态图例都要用)。 + // 必须在建节点**之前**算完 —— 颜色在创建 mesh 时就定下了。 + smPresentTypes = {}; + starmapNodes.forEach((n) => { + var t = String(n.type || "").toLowerCase() || "(未知)"; + smPresentTypes[t] = (smPresentTypes[t] || 0) + 1; + }); starmapNodeMeshes.forEach((m) => { starmapScene.remove(m); }); @@ -3113,7 +3123,7 @@ function buildChatStarmapGraph() { // 服务端 type 是首字母大写("Concept" / "Person" …), // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 // 这里统一小写归一化,并补上服务端实际会产出的类型。 - var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; + var col = smColorFor(n.type); var ei = 0.3 + mnr * 0.7; var mat = new THREE.MeshPhongMaterial({ color: col, @@ -5468,21 +5478,60 @@ var starmapLabelEl = null; // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, -// 分类配色实际上从未生效过。 +// 分类配色实际上从未生效过。现已加小写归一化,映射是真的生效了。 +// +// ★ 但色相换成**中性灰蓝**(用户裁定):亮青绿(0x44ff88)配 1151 个 +// 自发光球确实扎眼,而且它掩盖了一个更刺眼的事实 —— +// +// 实测图谱里 Concept 占 1148/1149 = 99%(nlp/extractor.go 完全不判类型, +// graph.go:451 在 type 为空时写死 "Concept")。也就是说这套按类型 +// 配色的设计,对真实数据而言**只有一种颜色会被用到**。 +// +// 所以真正的做法不是挑一个好看的色,而是: +// 1. 默认色改为低饱和灰蓝(全图统一,不假装在分类) +// 2. 类型色只在**真的存在多种类型**时才按类型区分(见 smColorFor) +// 3. 图例按实际节点集合动态生成,不列出永不出现的类型 +var SM_COLOR_DIM = 0x7d8a9e; // 中性灰蓝:单一类型时的全图色 var smTypeColors = { - person: 0x4488ff, - task: 0xff8844, - ai: 0xaa44ff, - concept: 0x44ff88, - object: 0xff4444, - // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") - location: 0xffaa44, - source: 0x8899ff, - document: 0xaabbcc, - event: 0xff88cc, - entity: 0x44ddcc, - scene: 0x88ff44, + // 以下类型在真实数据里几乎不出现(仅 social.go 会产出 person), + // 但保留定义:万一出现就能自动获得区分色 + 动态图例条目。 + person: 0x6f9fd8, + task: 0xd89a6a, + ai: 0xa583d8, + // 绝大多数节点(Concept):用中性灰蓝,不在“分类色”里扮浓。 + concept: SM_COLOR_DIM, + object: 0xd87a7a, + location: 0xd8b06a, + source: 0x8b9dc4, + document: 0x9aa5b1, + event: 0xc98fb5, + entity: 0x7fb5ad, + scene: 0x9dc47f, }; + +// smColorTypeSet 统计本次图谱里实际出现的类型(小写)。 +// 由 buildChatStarmapGraph 在建图前填好。 +var smPresentTypes = {}; + +// smColorFor 取节点颜色。 +// +// 规则:图谱里存在 2 种以上「有存在感」的类型时按类型上色,否则全图 +// 用 SM_COLOR_DIM。阈值与图例一致(>=1% 算“有存在感”)。 +// 理由:99.9% 概念 + 0.1% 其他时,按类型上色得到的仍是一整片同色, +// 只是换了个色相;而那一两个异色点在视觉上就是噪点。 +function smColorFor(type) { + var t = String(type || "").toLowerCase(); + var keys = Object.keys(smPresentTypes); + if (keys.length <= 1) return SM_COLOR_DIM; + var total = 0; + for (var i = 0; i < starmapNodes.length; i++) total++; + var self = (smPresentTypes[t] || 0) * 100; + if (total > 0 && self / total < 1) { + // 稀疏类型也走中性色:宁可全图同色,不引入单点亮色噪点。 + return SM_COLOR_DIM; + } + return smTypeColors[t] || SM_COLOR_DIM; +} var smEdgeColors = { 喜欢: 0xff6b6b, 学习: 0x4ecdc4, @@ -5866,13 +5915,7 @@ function renderStarmapTab() { __("记忆星图", "Memory Star Map") + ' ' + '
' + - '
' + - smLegend("person", __("人物", "Person")) + - smLegend("concept", __("概念", "Concept")) + - smLegend("object", __("对象", "Object")) + - smLegend("location", __("地点", "Location")) + - smLegend("source", __("来源", "Source")) + - "
" + + '
' + '
' + __( "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", @@ -5892,21 +5935,95 @@ function renderStarmapTab() { onStarmapResize(); } } + // ★ 图例也要在这里重算,而不只是 initChatStarmap 里算一次: + // 首屏时星图是在**总览页**初始化的(那时 #sm-legend 还不存在, + // smLegend 内部 getElementById 返回 null 直接返回),而本函数的 + // 容器骨架恰恰是此刻才建的。不在这里补一次,图例就永远是空的。 + smLegend(); smUpdateStat(); } -// smLegend 生成图例小项。 -function smLegend(key, label) { - var col = smTypeColors[key] || 0xcccccc; - var hex = "#" + ("0000" + col.toString(16)).slice(-6); - return ( - '' + - '' + - escHtml(label) + - "" - ); +// smLegendLabel 把内部类型名转成可读标签。 +var SM_TYPE_LABELS = { + person: ["人物", "Person"], + concept: ["概念", "Concept"], + object: ["对象", "Object"], + location: ["地点", "Location"], + source: ["来源", "Source"], + document: ["文档", "Document"], + event: ["事件", "Event"], + entity: ["实体", "Entity"], + scene: ["场景", "Scene"], + ai: ["AI", "AI"], + task: ["任务", "Task"], +}; + +// smLegend 按**实际出现**的类型生成图例。 +// +// ★ 为什么不用固定列表:原图例写死了「人物/概念/对象/地点/来源」五项, +// 而实测图谱里只有 Concept(1148)与 Source(1)。列出四个永不出现的 +// 类型 = 对用户说谎(图例说“分类”,实际是一整片同色)。 +// +// 现在: +// - 只有一种类型 → 只显示一项,并标注占比(如「概念 1149」) +// - 多种类型 → 按数量降序列出全部,每项带占比 +// - 类型名不认识 → 退化为小写原文而不是默默消失 +function smLegend() { + var box = document.getElementById("sm-legend"); + if (!box) return; + var keys = Object.keys(smPresentTypes); + if (!keys.length || !starmapNodes.length) { + box.innerHTML = ""; + return; + } + var total = starmapNodes.length; + keys.sort((a, b) => smPresentTypes[b] - smPresentTypes[a]); + // 「主要类型」= 占比 >= 1% 的。实测 1148 Concept + 1 Source 时, + // Source 占 0.087% —— 直接列出来会显示成「来源 0%」,既难看又误导 + //(读者会以为图里没有来源节点)。低于 1% 的归入「其他 N 个」。 + var MIN_SHOW_PCT = 1; + var major = []; + var minor = 0; + keys.forEach((k) => { + if ((smPresentTypes[k] * 100) / total >= MIN_SHOW_PCT) major.push(k); + else minor += smPresentTypes[k]; + }); + var dim = major.length <= 1; // 颜色上是否走单一色 + var html = major + .map((k) => { + var lbl = SM_TYPE_LABELS[k]; + var label = lbl ? __(lbl[0], lbl[1]) : k; + var hex = "#" + ("0000" + smColorFor(k).toString(16)).slice(-6); + var pct = Math.round((smPresentTypes[k] * 100) / total); + return ( + '' + + '' + + escHtml(label) + + ' ' + + pct + + "%" + ); + }) + .join(""); + if (minor > 0) + html += + '' + + __("其他 ", "other ") + + minor + + __(" 个", " nodes") + + ""; + // 颜色上实际是单一色时,说清楚这不是分类图。 + if (dim) + html += + '' + + __( + "(图谱实体几乎都是同一类型,节点同色;出现新类型后会自动分类)", + "(entities are almost all one type, so nodes share a color; new types will be color-coded automatically)", + ) + + ""; + box.innerHTML = html; } // smUpdateStat 在星图页签头部显示节点/边/活动状态。