285 Commits
v1.0.3 ... main

Author SHA1 Message Date
11144c62b5 chore(sdk): 同步 SDK 仓 SetAutoRestart 文档修正
SDK 仓 5af2a86 把 `SetAutoRestart` 的「崩溃后自动重载」改准为
「崩溃后自动重启」,并补上真实约束(线性退避 1s→2s→3s、
5 分钟窗口内第 4 次崩溃即停止、与「重载」是两回事)。

主仓以 vendored 方式跟踪该文件(third_party/homeagent-sdk/sdk/plugin.go,
主仓只跟踪其 40 个文件、不含 README),故此处同步同一改动,
保持两处一致。
2026-09-21 10:35:12 +08:00
d05161ac8d fix(plugin): 退避注释里无实测支撑的「感知不到工具缺席」
`scheduleProcRestart` 的注释写:

    线性退避:1 次→1s,2 次→2s,3 次→3s。崩溃循环时不至于打满 CPU,
    又足够快到用户感知不到工具缺席。

前半句是事实(退避确实只为防崩溃循环打满 CPU),后半句是主观断言:
首次重启就要等 1s,这 1s 内该插件的工具是缺席的、调用会直接报错。
「用户感知不到」既无实测支撑,也会让读代码的人误以为是无感恢复。

这正是另一处文档(README「崩溃到恢复 <1s」)同源的问题 ——
实测退避为 1s/2s/3s,故 <1s 从未成立(`procRestartBackoff = time.Second`
由 02cc74c 引入,且该提交是 v1.0.0 的祖先)。

改为写明真实代价与插件侧的正确做法(在 OnStart 里自建重连与状态重建),
与 SDK 仓 README 刚补的说明保持一致。
2026-09-21 10:34:41 +08:00
7587bd82d8 site: 正名「驻留子 agent」+ 删净页面小字注解
用户两点意见:
1. 那个东西不叫「分诊助手」,叫**驻留子 agent**
2. 页面上加的小字注解全是废话,全删

## ① 正名:分诊助手 → 驻留子 agent

「分诊」只是 offload.go 里对**职责**的描述(triage),
`resident.go` 顶注给的正式名是「驻留子」,`docs/zh/resident-subagent-design.md`
也把它列为独立概念(根 agent / 驻留子 / 工作式轻量子三类)。

7 处全部改正,含图内 SVG 文字、`aria-label`、架构流程图、FAQ 正文:

| 位置 | 原 | 现 |
|---|---|---|
| 图 5 框标题 | 分诊助手 | 驻留子 agent |
| 图 5 框内第二行 | 临时驻留子 | (删,与标题重复) |
| 图 5 的 aria-label | 转投给分诊助手 | 转投给驻留子 agent |
| 正文 | 转给一个临时驻留的**分诊助手** | 转给一个**驻留子 agent** |
| 架构图转投分支 | → 转投临时分诊助手 | → 转投驻留子 agent |
| FAQ | 这正是「分诊助手」的用途 | 这正是驻留子 agent 的用途 |
| CSS 注释 | 分诊助手接到消息 | 驻留子 agent 接到消息 |

## ② 删掉 11 处注解小字

原则:**复述图上已画出来的、或标题已说过的** → 删;
**承载数据**(插件版本 / 源码路径 / SVG 图内必要标签)→ 留。

- `.duty-note` ×6:架构图里 6 个阶段下面那行灰字
  (「构建消息与工具表 · 插件可在此短路」等)—— 阶段名本身已经说清,
  而这 6 行会把循环框右半边撑空
- `.flow-note`:重复解释 `on_input` / `before_output` 跑在什么时候
- `.dv-caps` ×3:图 4 的「任何中断都能插它前面」「被打断的现场压入中断栈」、
  图 5 的「不必干等」—— 图上插队动线和回执 chip 已经把它们画出来了
- `.dv-note` ×2:图 2「实测:statically linked,无动态依赖」、
  图 3「实测整段写入+读回 3.5µs」—— 数字重复
- `.dv5-tnote`:「默认关闭,需部署方打开」—— FAQ 里有完整说明(含理由)
- `.duty-ev` ×4 + `.sm` ×4:证据行/注释行,数字并回正文
  (「实测整段写入 + 读回 3.5µs。」等)

## ③ 删小字的连带修正

- **11 处死 CSS 全部清掉**(`.duty-ev` / `.flow-note` / `.dv-caps` /
  `.dv-note` / `.dv5-tnote` / `.sm`),单文件不留无人引用的规则
- 循环框删掉 6 行灰字后**右半边空了一大块** → 改为 `fit-content` 收窄居中

## 自己踩到并改掉的坑

- 循环框收得太窄(16rem),回边徽标「有工具调用则回到 ①」**压住**
  `after_toolcall` → 加 `min-width` 并加大底部 padding
- 想让循环框占满宽度,试过**阶段项排两列** → 截图发现编号被 grid
  按行填充排成 `1/3/5 · 2/4/6`,线性流程顺序全乱。已回退单列,
  并把这条教训写进 CSS 注释(免得后人再试一次)
- 图 5 触发条件框删掉小字后留白偏大 → 高度 56→42

## 验证

- 三档宽度(1440/768/390):无横向溢出、回边徽标与阶段项**零重叠**、
  文字未被截断
- 深浅双主题 / JS 禁用 / reduce 回归全绿;控制台零错误
- 死 CSS 计数全部归零;「分诊」全站残留 0
- CSS 括号平衡,文件 141,847 字节(原 143,960,净减 2.1KB)
2026-09-21 10:23:06 +08:00
7b7d405463 site: 重做第 3/5 张示意图 + 删冗长文案 + 修中文排版
用户反馈:3/5 页面图片不够精致、动画单调;hero 与架构节两句文案啰嗦;
图 1 那句注释要删。

## ① 删掉两处冗余文案

- 图 1 的 `<p class="dv-note">箭头只进出插件 —— 内核一列都没有</p>`:
  图上已经画出来了(光球只打插件、内核周围无连线),注解是重复
- 架构节副标题的「插件可改写或短路」:与组件表里 `Stage` 那行完全重复
  (那行还更具体,带 1/4/2 的阶段分布)

## ② hero 副标题重写

原文「内核只管编排、记忆与调度,消息 / 文件 / 网络 / 设备一律交给插件。
插件崩了不牵连内核 —— 换掉一个二进制就热重载。」三重堆叠、句子太长。

改短后又发现**两处与源码不符**,一并改准:
- 「热重载」不是崩溃后的行为。崩溃走的是退避重启
  (`scheduleProcRestart`:1s/2s/3s,`AutoRestartEnabled` 默认 true),
  `ReloadOne` 是换二进制那条路径。混成一句等于说错。
- 改为「崩了自己重启,波及不到内核」。

## ③ 图 3(共享内存):从稀疏线框重画

原图 19 个元素、层次扁平。重画后 53 个元素、三层递进:

- 两个进程做成带玻璃高光的卡片,各标自己的**虚拟地址**(0x7f2a… / 0x55c1…)
- 中间一句桥接:「不同虚拟地址 · 同一物理页」——这才是零拷贝的关键
- 物理页外框呼吸描边 + 极淡填充
- 段内标出 `header`(魔数·版本)与 `arena`,并加**字节刻度**,
  把「一片区域」讲成「有结构的区域」

## ④ 图 5(长任务转投):填掉大片空白

原图下三分之一完全空着,且「两种出路」只写在文案里、图上没有任何体现。

- 主 agent 加**忙碌进度条**(一直爬不到头,呼应「占住很久」)
- 积压消息由 2 条改 3 条、宽度递减成「一摞」,并逐条被取走
- 右上空白填入**触发条件**,给的是源码实测默认值
  (`defaultOffloadBusyAfter = 5min`、`defaultOffloadMinPending = 3`),
  并注明「默认关闭,需部署方打开」
- 新增回执区:`① 简单 → 直接办完,发回原通道` /
  `② 需主 agent → 回「忙碌中,请稍候」`,并用光点示意回执送达用户

## ⑤ 排版:中文之间夹空格

改文案时实测发现正文里有「实时 渲染」这类中文间空格 —— 源码换行被浏览器
渲染成一个空格。找出真实渲染有空格的位置并修掉(改用 innerText 判定,
textContent 会把 `<em>` 等块边界误判为空格,实测误报了 2 处)。

## 自己踩到并修掉的几个坑

- 「各自的虚拟地址」两句标签被竖向虚线穿过(压字)→ 改为一句居中桥接
- 图 3 标题靠左时被 `x=72` 的写入虚线穿过 → 改居中
- 玻璃高光整块铺满像蒙了层白纱 → 收到上半部、透明度 .3
- 巡行光点框看起来像页内多了一层框 → 去掉,改为外框呼吸
- 内层标题与外层桥接都在说「同一物理页」→ 内层改为描述段布局

## 验证

- 深浅双主题逐图截图核对(图 3、图 5 各两套)
- 动画:图 3 从 5 → 11 个动画元素、图 5 从 8 → 10;元素总数 19→53、17→45
- reduce 下新增的 CSS 动画(physBreathe、dv5-pile、忙碌条)全部停;
  SMIL 仍靠 pauseAnimations,8/8 SVG 已暂停
- 回归:深浅主题 / JS 禁用 / reduce / 390·768·1440 三档,零横向溢出、无控制台错误
- 结构:CSS 括号平衡、HTML 无未闭合标签
2026-09-20 23:37:16 +08:00
5d395116d1 site: 修正 FAQ 三处与源码不符的描述
用户指出「常见问题部分描述不太符合事实」。逐条对着源码核了六条,
查出三处不准(第 4 条是实质性误导),另修掉一个由此暴露的滚动缺陷。

## ① 第 3 条:「L4 是内核保留的」漏了一半

源码 `interruptLevel(evt, privileged)` 有**两条**放行 L4 的路径:
`privileged=true`(内核)与 `isKernelLevelSource`(编译期内置插件)。
`scheduler.go:74` 的注释也写明「只给内核与编译期内置插件」。

原文只说「内核保留」,会把内置插件的能力说没。已补上。
(「外部插件声明 L4 会被夹到 L3」这句本身没错,`clampPluginLevel` 确实如此。)

## ② 第 4 条:自动转投默认是关闭的 —— 原文读起来像默认行为

`DefaultOffloadOptions()` 返回 `Enabled: false`,源码注明理由:
「默认关闭、由部署方显式打开,与『显式才是特权』同一条理由」。

原文说「内核拉起临时驻留子接手」,通篇没提这个前提,读者会以为开箱即用。
已补上「默认关闭」并给出默认阈值(实测 `defaultOffloadBusyAfter = 5min`、
`defaultOffloadMinPending = 3`,原文只说「忙超阈值」「积压够多」,没给数)。

## ③ 第 5 条:「媒体跟着记忆块走」不完整

`payloadHeld()` 的存在说明有**共享**一说:同一份字节可能被多个块持有,
此时**不删**。两个删除点(`medialoop.forgetPayloads` 与 `sdk/memory_impl.go`)
都各自做了 `stillHeld` / `payloadHeld` 检查,注释写明
「同一张图可能被多个块引用」。「跟着块走」会让人以为按块计数即可。
已改为「如果同一份字节仍被别的块共享,则不删」。

## 核对无误的四条(未改)

- 第 1 条:`现场保存/恢复`、`中断栈` 都是 `scheduler.go` 的原词;四级中断、
  上下文预算、蒸馏与召回均在
- 第 2 条:三通道准确(`proc/shm.go` 顶注:`stdio JSON-RPC` 控制面 /
  `shm + 偏移` 数据面 / `eventfd` 通知面「事件环 post-and-forget」);
  「摘除工具、阶段与通道」对应 `UnregisterPluginTools` /
  `UnregisterPluginStages` / `releasePluginChannels`;`onProcCrash` 注释即
  「摘注册面、喂健康计数、排一次重启」
- 第 6 条:`go:embed adapters/*.lua` 真嵌入;9 个名字与 `internal/lua/adapters/`
  下文件逐一对应(`server.lua` 是网关脚本,不计入)

## ④ 顺带修掉:FAQ 全部展开后滚不到底

改动后我照例量了末尾能否到底,发现**全部展开时卡住、页脚不可见**:
展开后 faq 712 + footer 367 = 1079 > 一屏 900,`mandatory` 又把滚动锁住。

这里走了两次弯路,都记进注释了:
- 先想只让 faq 退出吸附 → **死锁**:它下方没有吸附点,而 mandatory 只允许
  停在吸附点,实测卡在 plugins 的吸附位(y=9455)不再前进
- 阈值先误用「视口高」→ 展开后 faq 高 712 < 892,判不出超限,类根本加不上

最终改为「展开超过『视口高 − 页脚高』时整页切到自由滚动」,
因为 faq 是最后一个吸附节,它一旦装不下,末尾就必须整体自由。

## 验证

- 折叠态:到底=true,页脚可见,faq 标题不被导航遮挡(h2 顶 210 > 67)
- 展开 1 条 / 展开全部 6 条:两种状态都到底=true、页脚可见
- 一滑一页仍生效(8/8 次停靠不同节,72px 偏移为导航高度)
- 深浅双主题 / JS 禁用 / reduce / 390·768·1440 三档:全绿,零横向溢出,无控制台错误
2026-09-20 22:49:56 +08:00
fc8f153e34 site: 修首屏滚不到底 + 交错行出入方向 + 让示意图真动起来
用户反馈三点:① 拉不到最下面 ② 文字与图片没有进出动画,单调
③ 图片仍是「框框住文本」,且希望有「外界光球打到插件上」。

## ① 滚不到底:真 bug,且是两个独立根因叠加

实测(滑到底后读 scrollY):停在 10283,上限 10669,**差 386px**,
页脚完全不可见。两个原因:

- **末尾两节合计超过一屏**。faq 高 635 + footer 367 = 1002px > 一屏 828px。
  `scroll-snap-type: y mandatory` 只允许停在吸附点,于是浏览器被迫二选一:
  要么 faq 标题对齐(则页脚滚不到),要么页脚可见(则标题被导航压住)。
  实测两种坏法都出现过:h2 顶 = -57 / -38(导航底 67)。
  修法:FAQ 六条改双列(省下约 190px),收尾段取消整屏高度,
  合计压到 762px,两个要求即可同时满足。
- **只在 footer 单独设吸附点会形成死锁**。试过给 architecture 设
  `scroll-snap-align: none`(它高 1180px),结果前一个吸附点把它自己吸回来、
  后面又无吸附点接住,实测卡死在 y=5791,怎么滑都不动。
  教训写进注释:高节退出吸附不是解法,「装得进一屏」才是。

## ② 交错行:方向与布局相反(结构性错误)

原文给每行写死 `slide-l`(文)与 `slide-r`(图)。但翻转行里图在**左边**,
却仍从右侧飞入 —— 图穿过文字进场,方向与布局相反。5 行里 3 行错。

修法不是逐行改正,而是**从布局推导方向**:`.alt:not(.flip)` 文左图右、
`.alt.flip` 图左文右,方向跟着 `.flip` 走。这样不可能再写错。
退出时不加 `.in`,transform 回到同侧 ——「从左进就从左出」是自动的。

## ③ 示意图:从静态线框变成有语义的动画

现状实测很糟:5 张图共 116 个元素,**只有 4 个在动**(各 1 条虚线),
图 2 完全静止 —— 用户说「框框住文本」是准确的。

按每张图的语义给它自己的动作(13 种 keyframes,47 个动画元素):

- **图 1 隔离**:外部光球从画面外飞入,四种颜色命中四个插件,
  激起涟漪 + 插件闪一下;内核一列都没有。这正是「外面的一切都落在插件上」。
- **图 2 零依赖**:四项依赖被**逐条划掉**。用 `pathLength=1` + `stroke-dashoffset`
  让线自己「划」过去,不是淡入。
- **图 3 共享内存**:写入/读回两个包沿链路跑,arena 上有光带自左向右扫过。
- **图 4 优先级**:金色包沿四条曲线**插队**到 L1–L4 之前;
  四级强度条依次点亮;队列条逐条被取走(先到先处理)。
- **图 5 长任务转投**:积压消息被接走飞向分诊助手,收到时闪一下。

配套修掉三处我自己引入的缺陷:
- `.dv-pkdot` 原用 `pkPulse` 动 `r`,与行进包叠加会闪烁 → 改只动透明度
- `.dv-ripple:nth-of-type(2n)` 按 `<circle>` 计数,误配到数据包圆点,
  四个涟漪全被染成金色 → 改显式逐色
- 涟漪原画在插件框**内部**,像框里画了个圆 → 移到框下层,从背后漾开

## ④ 顺带发现并修掉的真问题

- **CSS 的 `prefers-reduced-motion` 管不到 SMIL**:实测 reduce 下 6 个
  数据包照跑。加 JS 调 `svg.pauseAnimations()`,实测 8/8 SVG 已暂停。
- **窄屏横向溢出 26px**:源于我这次加的入场位移(`translateX(42px)`)
  在未入场时探出视口。修法是 `overflow-x: clip`(不用 hidden,避免新建滚动容器)
  并窄屏收到 ±20px。实测 390/768/1440 三档溢出均为 0。

## 验证

- 滚动:到底=true,页脚可见,faq 标题不被遮挡(h2 顶=210 > 导航底 67)
- 方向:5 行全部「文在左侧就从左进」,翻转行正确反向
- 入场:逐节停留 13 节全部入场,0 未揭示;
  滑到底后 23 个 opacity=0 的元素**全在视口上方**(退场生效),无一是「场内却不可见」
- 回归:深浅双主题、JS 禁用、reduce、390/768/1440 三档 全绿,无控制台错误
- 结构:HTML 解析无未闭合,CSS 括号平衡
2026-09-20 22:37:06 +08:00
3374e7dbd9 docs: 更正三处无实测支撑的性能断言
用户指出现有文档里的性能数字可疑。逐个实测后发现三类问题,都不加改原文地
标注更正(历史条目保留原文,仓内已有此惯例)。

## ① 「崩溃到恢复 <1s」——从未成立

写于 v1.0.0 发版说明。但**当时的退避代码就已是 1s**(查 v1.0.0 tag 的
`procRestartBackoff = time.Second`),首次重启就要等 1s。

实测(新增临时测试测量 scheduleProcRestart 延迟):

    第 1 次崩溃 → 1s      第 2 次 → 2.001s      第 3 次 → 3.002s

顺带纠正我自己刚在站点写错的阈值:并非「崩 3 次停下」。实测第 **4** 次
才停(`procMaxRestarts=3`,判定为 `n > 3`),前 3 次都会重启。

## ② 「RPC 往返 p50 24.1µs」——量级对、数字不符

实测 `BenchmarkToolInvoke`:inline/small **30.4µs**、frame/small 51.5µs、
inline/large 767µs、frame/large 398µs。原文与实测同为几十微秒量级,
但具体值对不上,且未注明测的是哪种 payload。

## ③ 「CLIP 实测常驻 1.15GB」——采样点不对(6 处)

实测加载 chineseclip 两塔,RSS 会**自己降下来**:

    加载前      0.00 GB
    两塔加载后  1.59 GB   ← 峰值
    GC + 静置     0.89 GB   ← 稳态(内核回收未用页)

1.15GB 落在两者之间,既不代表峰值也不代表稳态。线上稳态实测 0.39~0.58GB
(更长时间静置后更低)。同源问题:qwen3vl 的「常驻 9.4GB」实为**峰值**,
其视觉塔本就是按需加载(源码注释:每张图约 1.6GB,故按需)。

6 处全部改为「稳态 X(峰值 Y)」双值,消除口径歧义:README 中英、
docs/zh/multimodal-space.md、config/registry.go(2 处 + 1 处注释)、
providers/chineseclip/tokenizer.go。

## 验证

- `go build`(含 `-tags onnxruntime` 与不带)与 `go vet` 均通过
- 全仓 `grep 1.15GB` 已清零
- 测量用的临时测试文件已删除,无残留
2026-09-20 19:56:49 +08:00
ca6f4c510c feat(site): priority 图补上「排队输入」这一类(用户指出的漏项)
## 漏项

文案写「输入走两条路」,但图上只有 L1–L4 —— **排队的完全没出现**。
源码 `scheduler.go` 开篇就写明是**两类别**:

- TaskInterrupt(InjectInterrupt*):带 L1..L4,可抢占
- TaskQueued(InjectText*/InjectInputSync*):**无级别**,可被任何中断打断

「级别只属于中断」是这套调度模型的关键一句,图里不表达就等于漏了一半。

## 改法

图改成左右两列,标题直接写清差异:

    中断 · 带级别            排队 · 无级别
    ├ L4 内核独占            ├ 队列条(先到先处理)
    ├ L3 需及时处理          └ 任何中断都能插它前面
    ├ L2 消息类                      │
    └ L1 完全可等                     │
        └───────────┬──────────────┘
                正在跑的任务

- 排队列用**虚线框 + 素色条**(不发光),与左侧的实线发光级别条刻意区分 ——
  「无级别」这件事本身就该在视觉上体现
- 两路汇入「正在跑的任务」,并标注「同一时刻只一个」

## 顺带修的两处

- **浅色下排队条看不见**:原先复用 `gCard`,而浅色下 gCard 是白的,
  白底白条等于没画。改用独立的 `--dv-queue-item` 令牌
- **横向虚线穿过队列条**:原想表达「处理方向」,结果画在了条内部。
  改为右侧竖线 + 向下箭头

## 验证

- 双主题:控制台错误 0、横向溢出 0、图元零越界
- 一滑一页仍生效;JS 禁用 24/24 可见;reduce 下吸附停用
- 390/768/1440 溢出均 0
2026-09-20 10:39:53 +08:00
88923ed663 feat(site): 示意图升级为玻璃面板 + 修 priority 图两处错误
## 根源(用户指「图太平,没有高级 UI 效果」)

问题不在画得不够花,而在**手法本身就平**:纯 SVG rect + 平面文字浮在平坦
背景上,本质上就是工程草图。换基础:

- **玻璃面板**:每张图裹一层带内上高光、内下厚度、外分层投影的玻璃底 +
  `backdrop-filter: blur(8px)`,图形才像「浮在界面上」而非浮在空背景上
- **顶部柔光 + 细颗粒**:径向渐变给受光面,`feTurbulence` 噪点抿掉矢量图的塑料感
- **渐变节点 + 投影**:所有节点从纯色填充改为竖向渐变面 + `feDropShadow`
- **悬停提亮**:`fLift` 滤镜(更远的阴影 + 青色泛光)
- **流动虚线**:连接线 `dashFlow` 动画,看起来「有东西在跑」
- 全部走主题令牌,深浅各自调参(浅色受光方向相反)

## 修 priority 图两处真错误

**① 文案与源码语义错位**(用户报的)
原文写「可以等的 / 不能等的,不能等的再排 L1–L4」——
但源码 `LevelBackground = L1` 的注释是「**完全可等**」。我把 L1 归进了「不能等的」。
实际是:**排队无级别(谁都能插它),中断才带 L1–L4**。已改。

**② L4 文字溢出框外 57px**
`dv-ts` 是 `text-anchor: middle`,但我按左对齐给了 x=46,长的那行
(「L4 内核独占 · 立即打断」)以 46 为中心向两边展开 → 左溢 57px。
新增 `.dv-tsl`(`text-anchor: start`)专供左对齐行。实测四行现均整齐落在框内。

## 顺带修

- 「抢占」标签贴边 99.8%,玻璃面板内边距会裁掉它 → 整条线内移
- 抢占方向原为「从 L4 顶部绕出去悬在半空」,既没连上目标也读反了 →
  改为「从级别条右侧指向正在跑的任务」
- 图 4 内容仅占 viewBox 70%×71%(其余图 88–93%),显空 →
  重画为「强度条背景 + 级别徽标 + 场景标注 + 抢占回边」,现 81%×90%

## 验证

- 双主题:5 面板、控制台错误 0、横向溢出 0
- **全部图元与文字零越界**(含 viewBox 内边界检测)
- 一滑一页仍生效;JS 禁用 24/24 可见;reduce 下 snap 与流动动画均停
- 390/768/1440 溢出均 0
2026-09-20 10:33:20 +08:00
631963fdc7 feat(site): 总起页改为「真正的 AgentOS 长这样」+ 修页脚状态栏不可点
## 总起页:从对比改为展示(用户要求)

前几版是对照表(「别人说 X → 我们要做到 Y」)。用户指出重点不是对比,
而是**把自己作为「真正的 AgentOS 样例」展示出来**。改为四张并列卡片,
每条给「机制 + 可测数字」,不做比较。

标题「真正的 AgentOS,长这样」;四题:隔离 / 调度 / 通信 / 资源 ——
操作系统躲不开的四道题,逐题给答案。

## 文案改了第三轮(用户指「读着太难受」)

按「短句、有节奏、不堆从句」重写四段正文。举一例:

  旧:插件不是进程内的一个库,是内核 spawn 的独立进程。崩了就把它的
      工具、钩子、通道一并摘掉,其余照跑。
  新:插件不在内核里,是另一个进程。崩了就把它注册的东西一并摘掉,其余照跑。

## 修页脚「状态」栏不可点(用户报的 bug)

三行原为 `<span class="muted">` 死文本,点不动。改为链接:

- 最新发布:v1.3.x 线   → /releases
- main 在研:1.4.0      → blob/main/internal/meta/meta.go(版本号的实际来源)
- 许可:AGPL-3.0-only   → blob/main/LICENSE

## 一处自查纠正

我一度在卡片里写「插件崩溃 → 恢复 <1s」,那是照抄 README 的旧说法。
查源码 `dynamic_proc.go` 发现退避实为 **1s / 2s / 3s**(`procRestartBackoff=1s`,
5 分钟内崩 3 次 `procMaxRestarts=3` 即停手等人),**<1s 不成立**。
改为如实写明退避序列与停手机上阈值。README 那句待另开一轮核实。

## 验证

- 双主题:4 卡片、控制台错误 0、横向溢出 0
- 一滑一页仍生效(6 次滑动偏差恒为 72px = scroll-padding-top)
- JS 禁用 24/24 可见;reduce 下 snap 自动关闭
- 390/768/1440 溢出均 0
- 页脚 9 个 gitcode 目标逐个对照 origin/main 的树:**全部存在**
  (不只看 HTTP 200 —— gitcode 对错误路径也返回 200,此前踩过)
2026-09-20 10:11:02 +08:00
4078f3ac0a feat(site): 重写首屏文案 + 交错图文布局 + 一滑一页
## 口号与文笔(用户指「不够响亮、部分文笔不好」)

Hero 改为「是…更是…」句式:

  是记得住的管家 / 更是从不让你干等的搭档

## 五个设计决定:从卡片改为交错图文

原来 4 张卡片平铺。改为 5 行交错(文/图左右互换),每行配一张内联 SVG
示意图,纯 CSS + 主题令牌,深浅自适应,零外部依赖。

**换掉一条、新增一条**(用户指出「插件跑在独立进程」不算特色 —— MCP、LSP
都这么做,不是差异点):

| | 内容 | 依据 |
|---|---|---|
| ② | 部署,从未如此便捷 | 实测插件 `statically linked`、`not a dynamic executable` |
| ③ | 数据如水,随流,随改,随走 | 共享内存 + 相对偏移零拷贝;实测整段写入读回 3.5µs |

标题按用户给的句式写(②③ 原文照用),正文不给形容词、给可核验的做法与数字。

## 一滑一页

`scroll-snap-type: y mandatory` + 每节 `min-height: 100svh`。
为此把 features 的 5 条决定各拆成独立 section(原 2.74 屏塞 5 行,
mandatory 下会锁死底部),architecture 的流程图也单独成节。
现 13 节,实测连续 8 次滑动精确停在第 1..8 节,间距 828px = 一屏。

## 两处实测纠正(都是我先判断错、再被数据推翻)

1. **`proximity` 做不到「一滑一页」**:实测滑 500px 落点就是 500,离最近
   节边界 395px,不触发吸附 —— 只是「有时粘一下」。改用 mandatory。
2. **我误报 architecture「底部锁死」**:按 `h > innerHeight` 判定,忽略了
   溢出行仍可滚动。用真实 wheel 实测 13 节末元素全部可达(含该节 828 < 900)。
   所以没有锁死,压缩 vertical rhythm 是顺带的,不是修复。

## 验证

- 深浅双主题:13 节 / 5 交错行 / 5 示意图、控制台错误 0、横向溢出 0
- 一滑一页:8 次滑动停在 8 个不同节,落点间距精确 828px
- 72px 落点偏移经查是 `scroll-padding-top`(导航高 67px),确保标题不被遮挡 —— 有意为之
- JS 禁用 24/24 可见;reduce 下 snap 自动关闭(`prefers-reduced-motion`)
- 390/768 无 snap(窄屏强制一屏反而难受);1024/1440 启用
- 真人式滚动(wheel 与 400px 步进两种)未揭示元素均为 0

注:本轮前期用了几个 Python 补丁脚本改 HTML,用户指出「不好」。后续改为
直接编辑以产出可审阅的 diff,脚本已删除。
2026-09-20 09:46:18 +08:00
0c9a3900b8 fix(release): .hmap 纳入发布产物白名单 + 路径解析
## 白名单(真问题)

`upload_assets.py` 的 ARTIFACT_SUFFIXES 只有 .tar.gz/.zip/.deb/.rpm/.pkg/_win64.exe,
**没有 .hmap** —— 即使插件包已经构建好放在 dist/ 下,上传时也会被静默跳过。
这正是「release 里一个插件包都没有」的直接原因之一。

补 `.hmap` 与 `SHA256SUMS.plugins`(插件包的汇总校验和,与内核包的 SHA256SUMS 分开,
避免混用)。实测 is_artifact() 现能正确识别两者、仍跳过 README.md。

## 测试路径解析

`HMAP_BUNDLE_DIR=dist/plugins` 这种相对仓根的写法原先会失败:测试的 cwd 是包目录
(internal/plugins/pluginmgr),相对路径解析到包内,报 "no such file or directory",
看起来像产物不存在。改为相对路径按仓根解析(向上找含 go.mod 的目录)。

实测三种调用都正确:相对路径、绝对路径、不设时 skip。
2026-09-20 09:07:33 +08:00
a35f2126a1 test(pluginmgr): 校验发布用插件包能被内核真实安装
配套 SDK 仓新增的 scripts/build_plugin_bundles.sh:**能构建出来 ≠ 内核装得上**,
这个测试用内核自己的 extractPackage 把产物真解一遍,验证三种包形态都落成规范入口。

覆盖的三种形态(都由真实产物验证过):
- 多平台 bundle:`plugin.bin.<os>.<arch>` → 按当前平台挑出并**重命名为 plugin.bin**
- 单平台包(qq 的 plg.json 是 bundle:false):只有 `plugin.bin`
- Lua 包(luademo):入口是 `main.lua`,不编译 Go

不设 HMAP_BUNDLE_DIR 时 skip(不作为常规 CI 的必跑项,避免依赖 hmapdev 工具链):

    HMAP_BUNDLE_DIR=/path/to/plugins go test ./internal/plugins/pluginmgr/ \
      -run TestBuildPluginBundlesInstallable -v

实测 21 个真实产物全部通过(含 Lua 与单平台两种非 bundle 形态)。
2026-09-20 09:02:42 +08:00
9b26db45bc fix(site): 逐条对照源码修正描述(含两处真错误)
上一版有几处表述与源码不符。这轮把页面上每条可核验的说法都对着代码重新查一遍,
改掉 15 处,其中两处是**事实错误**而非措辞问题。

## 事实错误

**① L4 的归属说反了(FAQ)**
原文让读者「用更高级别的中断(如 L4:内核与内核级插件)」插队,暗示插件能用 L4。
源码 `scheduler.go` 的 `clampPluginLevel` 把 **>L3 一律夹到 L3**,注释也写明
「L4 由内核独占(panic、内核事件 selfip)」。照原文写插件会静默拿到 L3。
改为:插件可声明 L1–L3,L4 是内核保留的「立即打断」。

**② 驻留子的父侧动作列错**
组件表写「父可查看/收发/压缩/回收」。"收"不存在 —— 源码的动作集是
`list | create | send | inspect | compress | reclaim | destroy`,
子持有状态面由**父 pull**(resident.go 开篇注释:父持登记表,子持 inputch 处理表,
父 pull 不打断子)。改成「查看/发送/压缩/回收/销毁,子是父拉取而非推送」。

## 措辞不准确(12 处)

- **Context 层**「最近若干条受保护」→ 源码 `pCount := 10` **写死十条**;
  「预训练词向量 → 余弦相似度,TF-IDF 回退」→ 实为优先稠密向量余弦、
  未配置时退到稀疏词向量(TF-IDF / fastText);「自动下沉」→ 归档进 Document 层
- **PluginSDK「四通道」**→ 不是四个"通道",是三面接口(工具/钩子/事件)+ 输出通道声明
- **管道「7 个阶段钩子」**→ 会被读成都在管道内。实际分布是进管道前 1(on_input)、
  轮次中 4、收尾 2(before/after_output),两处都标明
- **sanitizer** 只写了"清工具调用残留",漏了它更常做的是洗坏 UTF-8/U+FFFD/ANSI
  (而这类字节会被模型复读),且不注册工具只挂钩子
- **rss**「推送通知」→ 实际是按间隔轮询 + 中断注入;补上"订阅时记历史条目,
  所以订一个源不会把旧文章全推一遍"
- **memo** 补上可核验的机制:每 5 分钟检查未完成待办
- **ocr / bili** 补外部依赖(tesseract + chi_sim / yt-dlp)—— 不写清楚装完才发现缺
- **mc**「两阶段激活」原样照抄没解释;实为「想连着(意图)」与「确实连着(连接)」
  两个状态分开,所以 bridge 被 kill -9 后能自动重登恢复会话
- **qq** 一句话太单薄,补 20 工具 + 权限模型要点(身份绑帧、取交集、前缀拒绝)
- **a2a / music / weather** 分别补:两个方向与端点、只读无副作用、NoMemory 取舍

## 顺带修掉两个我上一轮引入的 HTML 缺陷

用行替换时失手:Context 卡丢了一个 `</p>`、mc 卡多了一个 `</span>`。
这次写了栈式配对检查才发现(简单的计数对比看不出来)。

## 验证

- 栈式标签配对:p/span/div/button/code/section/h2/h3/details/ul/ol/a/li **全部平衡**
  (修复前 p 差 1、span 差 -1)
- 事实终检 10/10:内置插件 16(all.go 导入数)、LLM 适配器 9 且**逐个名字对上**、
  Go 行 109241→109k、go.mod 1.25.0、L4 归属、resident 动作、Stage 分布、备案号
- 浏览器回归:深浅错误 0、JS 禁用 47/47 可见、reduce 动效停、390/768/1440 溢出 0、
  滚到底未揭示元素 0
- mc 工具数:本写「12 个动作工具」,实测 `tp+"act"` 去重后 activate/deactivate/status
  之外是 **11** 个,已改
2026-09-20 08:48:15 +08:00
47052cb115 fix(site): 更正媒体机制描述 + 页脚补备案号
## 描述性错误(用户指出)
1. **「引用计数 GC」已不存在**。「有引用绝不删」「媒体靠引用计数 GC」
   两处都在讲一个已废弃的账本 —— 实测源码里已无 media_refs/ref_count,
   `internal/memory/media/media.go` 明确写「这不是 GC,也不看引用计数」。
   现行规则是「删除持有它的记忆块即删内容」,与文本块同一套
   (medialoop.go 的 payloadHeld 只在确认无块共享时才删字节)。
2. **「描述才是持久语义」整张卡已过时**。旧实现靠视觉模型生成的描述当索引;
   现已弃用 —— `mediaref.go` 写明标签「不再包含任何生成的描述文本」,
   图片改按统一空间向量检索,`graphmedia.go` 还带一个把旧描述式实体
   迁移成原生记忆块的迁移函数。卡片改为「图片靠自己的向量被检索」。

## 备案号
页脚补 豫ICP备2024074105号-1 与 豫公网安备41070202001579号,
链接到 beian.miit.gov.cn / beian.mps.gov.cn。取值来源是现网
门户配置(/root/portal/dashy/conf.yml),未凭记忆编造。

## 验证
深浅双主题下渲染正确、两条链接 href 实测无误、无 JS 错误。
2026-09-20 00:00:23 +08:00
09298886a2 feat(site): 「一条消息进来之后」改为真正的流程图
原来是 ASCII <pre> 图。它有三个问题:

1. **画不出循环**。真实执行序是 7 步状态机,其中工具循环要回到开头
   再来一轮 —— ASCII 只能表达上下关系,这一点只能靠文字暗示。
2. **7 个钩子排成一行是错的**。on_input 在进管道前跑,
   before/after_output 在**全部轮次结束后**才跑一次;把它们与管道内的
   钩子并列,读起来像一条直线。
3. 漏掉了 post_action 之后才发生的工具调用,以及"上下文裁剪 + 相关记忆召回"
   这一步(在 after_toolcall 里)。

## 现在的结构
五层节点 + 分支 + 循环体:
外部输入 → 输入调度器(三条分支:入队列 / 抢占 / 转投)
→ 处理管道 →〔① pre_action ② LLM ③ post_action ④ before_toolcall
⑤ 执行工具 ⑥ after_toolcall〕↻ 循环 → 三层记忆 → 输出通道

事实全部对照源码核过(不是照抄旧图):
- 7 个 Stage 常量取自 SDK `third_party/homeagent-sdk/sdk/plugin.go`
- 顺序取自内核 `internal/agent/core/task.go` 的 Step 状态机
  (StepPrepare→StepLLM→StepToolBegin→StepToolExec→StepToolAfter→StepTurnEnd)
- 脚本末尾补一句说明 on_input / before_output / after_output 的时机

## 视觉
节点用色与三层记忆的三色一致(蓝=Context/青=管道/金=Graph,紫=转投);
连接线上的光点错峰下行,序号依次点亮。全部是内联 SVG-free 的纯 CSS,
无外部依赖。

## 关键取舍
- **删掉了贯穿全图的中轴线**:节点背景是半透明令牌,轴线会直接透出来,
  实测在「处理管道」里穿过整个编号列表,看着像画错了。连接线本身就是主轴。
- **循环回边改为内嵌徽标**:先做成从框底绕出的弧线,但它会压到下一条
  连接线 —— 同样像画错。
- **给连接线补了静态箭头**:动画关掉时(reduce)方向也要看得出来。

## 验证(独立 headless 实跑)
深浅双主题控制台错误 0、页面溢出 0;图内溢出 0(390/620/900);
**JS 禁用下 14 个节点全部可见、7 个钩子名齐全**(流程图是内容不是装饰);
reduce 下光点/图标动画确为 none 而静态箭头仍在(宽 7px/2px);
**7 个钩子名与 SDK 常量逐一比对通过**,防止文案漂移;
滚动到底未揭示元素 0。
2026-09-19 22:38:06 +08:00
db8534e315 feat(site): 文案精简 + 插件可点击 + 版面精致化
## 文案(净减约 25%,信息量不变)
删的是解释性赘语与重复限定,不是信息:
- 「内核不直接读写任何外部世界…于是「内核有多可信」与…」→「内核不碰任何外部世界…可以分开评估」
- 「大多数框架先写功能再补边界。HomeAgent 反过来:先把边界和调度定死,再往上加能力。」
  →「先定边界与调度,再加能力。」
- FAQ 六条逐条收紧;副标题从句子改回短语
- AI 声明与许可段去重复(两段都在讲同一件事)

## 插件徽章从装饰变为可交互(这是用户报的「无法点击」)
每个徽章现在是真按钮:点开显示该插件的**版本 + 用途**(取自各 plugin.json,
共 20 个),可多开、可收起,末尾「展开全部 20 个」一次全开。
键盘可达(Enter/Space),选中态用 aria-pressed 表达。
初版是纯 <span>,带 hover 效果却不可点 —— 看起来能点但点了没反应。

## 修正一处事实错误
统计卡原写「**36 外部插件**」。实测 36 是**加载总数**(16 内置 + 20 外部);
外部插件实为 20 个。同时:
- 「110k Go 代码行」→ 109k(实测 109,241)
- 「10 LLM 协议适配器」→ 9(server.lua 是 zen 网关脚本,不是厂商适配器)
每张卡补一行小字说明口径,避免再被误读。

## 版面
section 统一 4.5rem 节奏、卡片内边距与标题间距收敛、组件表代码列定宽对齐、
统计卡加口径小字、三层记忆卡收紧。插件选中态从实心青底(20 个齐亮像一堵墙)
改为淡青底 + 主色描边,并给 color-mix 加了 rgba 回退。

## 验证(独立 headless 实跑,全部通过)
深浅双主题 console 错误 0;JS 禁用 47/47 可见且插件卡默认全隐(0 张);
reduce 下粒子停、全可见;主题切换刷新保持、首绘无闪白;
390/768/1440 横向溢出均 0;移动端点插件正常展开;
插件交互逐项验过:单击展开 → 再点收起 → 多开 3 张 → 全展开 20 张 → 收起。
★ 一度报「19 个元素未揭示」,查证是我测试脚本没滚动所致 ——
逐步滚到底后实测 0 个未揭示,非真回归。

另把取数命令写进 site/README.md,并注明「36 = 加载总数」这个易错点。
2026-09-19 22:24:03 +08:00
fab27a1194 feat(site): 深浅双主题 + 动效层,并按用户要求移除立绘
## 深浅双主题
跟随系统偏好,导航栏按钮可手动切换(存 localStorage)。首绘前在 <head> 里
定好 data-theme,无闪白(实测 reload 首绘即正确背景色)。
语义色全部令牌化,:root[data-theme="light"] 只覆盖取值;品牌三色两主题共用
(对应三层记忆,换主题不该换语义)。

## 动效层(1 → 11 个 keyframes)
极光漂移 + 细网格背景、粒子网络(近邻连线,密度按面积自适应、上限 72)、
三色滚动进度条、标题渐变流动、分块上错落入场、卡片聚光 + 3D 微倾、
三层记忆色条自上而下灌注、架构图流光带 + 节点脉冲、数字滚动到位、
分节标题下划线展开。

三条硬约束(都吃过亏):
1. **内容默认可读** —— 初始隐藏只在 .js-fx 下生效,而 .js-fx 仅当 JS 真跑起来才加。
   实测 JS 禁用时 30/30 元素可见(旧版把 opacity:0 写默认样式里 → 26/30 永久不可见)。
2. **尊重 prefers-reduced-motion** —— 不启粒子、不画进度条、元素直接可见。
3. **装饰不得产生滚动条** —— canvas 改用 documentElement.clientWidth
   (window.innerWidth 含滚动条,实测多出 15px 撑出横向滚动),body 加 overflow-x: clip 兜底。

## 移除立绘(用户要求)
删掉 HTML/CSS/JS/资源/令牌全部痕迹,Hero 改单栏。README 记下为什么最终不放图:
原图是不透明 WebP(mode=RGB 实测),白底与角色白裙子同色,flood-fill 会渗进轮廓
让约 42% 身体透明 —— 这类素材要么出透明图,要么就别放。

## 顺带修掉 3 个真 bug(都是主题化后暴露/复核出来的)
1. **代码块换行全丢** —— .code 缺 white-space: pre,实测整段命令挤成一行。
   (这个 bug 在我这次改动之前就存在)
2. **浅色下导航看不清** —— header 背景硬编码 rgba(11,16,32,.78),改用 --nav-bg。
3. **浅色下立绘处有灰块** —— .mascot::after 硬编码深色,改用 --mascot-fade
   (该规则已随立绘一并删除)。
另把 .badge / .btn-ghost:hover / 按钮光泽里 3 处 rgba(255,255,255,…) 令牌化为
--hover / --sheen,否则浅色下是白压白。

## 验证(共享 Chromium 实跑)
深/浅首屏 + 记忆段 + 架构段截图逐张看过;console 错误 0;坏图 0;
JS 禁用 30/30 可见;reduce 全可见且粒子/进度条已停;主题切换 → 刷新后保持;
390/768/1440 三档横向溢出均为 0;CSS 花括号平衡、8 个 keyframes 无孤儿。
2026-09-19 21:49:59 +08:00
923d5d595f feat(site): 新增产品官网落地页(单文件 · 零构建 · 零外部依赖)
用户要求写一个官网介绍页面。做成纯静态单文件,与仓库 WebUI 的既有做法一致
(原生 HTML/CSS/JS,无打包步骤)。

## 内容(每一条都对着源码/运行实例核实过)
- Hero:一句话定位(常驻型个人 Agent 框架)+ 看板娘立绘
- 四个设计决定:内核零 IO / 插件独立进程 / 输入有级别 / 忙时有人顶班
- 三层记忆:Context → Document → Graph,含媒体一等节点、描述即语义记忆、统一多模态空间
- 架构:一条消息进来之后的完整路径图 + 核心组件表
- 数字(**实测值,非估算**):36 外部插件 / 340 工具 / 110k Go 行 / 10 LLM 适配器
- 上手命令、插件徽章墙、6 条 FAQ、页脚(文档/深入/项目/状态)

## 两个设计决定,都有理由
1. **配色取自品牌指南**:蓝/青/金正好对应三层记忆,故三层记忆那节直接用三色做色条。
2. **立绘按「有意的圆角卡面」呈现,不抠图**:原图是白底 + 蓝紫渐变外框,
   而白底与角色的白裙子同色 —— 连通域分析显示 flood-fill 会让 42% 的身体变透明
   (围裙、发丝高光被吃掉)。改为圆角 + 发光边框 + 底部渐隐,方形图与深色页自然衔接。

## 修掉一个真实的可访问性缺陷
初版把 `opacity:0` 写在**默认样式**里、由 IntersectionObserver 加 `.in` 揭示。
实测:30 个 .reveal 元素里 26 个停在不可见 —— **JS 被禁用或报错时整页永久空白**。
改为渐进增强:内容默认可见,仅当 JS 真跑起来才加 `.js-reveal` 接管动画。
复测两种场景均 30/30 可见(正常滚动 + 禁用 JS)。

## 验证(用共享 Chromium 实跑,不只是看代码)
- 控制台错误 0;两张图均加载(logo 400x400、立绘 1024x1024)
- 移动端 390px:无横向溢出,导航折叠,立绘置顶
- FAQ 手风琴展开正常(open=true)
- a11y:图片 alt 齐全、单一 h1、lang=zh-CN、无空文本链接
- 标签配对全 OK;34.6 KB;**无任何外部依赖**(无 CDN/字体/JS 库)
2026-09-19 21:15:00 +08:00
71faf8d9ad docs(readme): 按源码修正 README 的过时事实(中英同步)
上一轮只补了 v1.3.x 变更日志,没系统核对全文。本次逐条对照源码,修掉 5 处硬错误:

1. **消息时序图漏掉输入调度器**(最严重):还画着 `IO->>EV: inputCh` 直连
   eventLoop,而当前输入必须先进调度器。补 participant 与调度阶段
   (两类别+四级中断、同级不排队/更高级抢占、转投分诊助手)。
2. **图里的 `drainInterrupts` 已不存在**:实测该函数在源码中查无此项,
   改为「安全点:中断求值/让位」(真实机制见 scheduler.go)。
3. **内置插件数 11 → 18**:漏列 ai_image / data / localuse / multimodal /
   remotedevice / skillmgr(实测 `ls internal/plugins/` = 18)。
4. **Lua 适配器 8 → 10**:漏列 ollama / server(实测 = 10)。
5. **`agent/api/` 描述错误**:它只有 provider.go,不含 Lua 适配器
   (适配器在 internal/lua/adapters/);改为如实的「provider.go 调 vm」。

另修一处**自相矛盾**:构建章节写「依赖 Linux/Windows」,而下载章节说
homed 已放弃 Windows 原生(`package-windows.sh` 明确「不往 Windows 装 homed」,
只建 waiter.exe + 引导 WSL2)。改为「依赖 Linux」并说明 Windows/macOS 的真实边界。

并给「设计要点」补上两个当前核心机制(此前只有域分离与三层记忆):
输入调度(两类别+四级中断)与驻留子/分诊助手。

验证:全仓文档断链 0;6 个 mermaid 图块配对全 OK;上述数字逐条实测复核。
2026-09-19 20:59:42 +08:00
c0274b71d5 docs: 删除迁移期临时文档,现行内容搬进正式文档
用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。

## 删除(38 个文件)
- docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「现网正在发生的问题」
  (output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**,
  留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖
  (且后者更细,含子进程生命周期管理)。
- docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。
- docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移",
  迁移早已完成;实测无任何构建/测试依赖它。

## 现行内容先搬走(不能随临时文档一起丢)
- plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八
  (只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线
  否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。
- git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起
  已到期,改为标注失效并指向 §八。

## 引用清理
8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、
4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、
proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。

## 验证
- `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿
- 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内)
2026-09-19 19:22:48 +08:00
7213edd181 docs: 全面按当前源码更新文档 + 删除已过时文档
## 删除(内容已落地/已被替换,保留只会误导)
- demo.md ................... failback 与 recoverydiag 均已实现,0 引用
- docs/defect-qq-output-send-loop.md .. 已修复(本身也标了「已修复」),0 引用
- docs/embedding-comparison.md ....... 一次性选型报告,仅被 agent 产物引用
- docs/zh/plan.md ............ 描述的旧 nav 布局已重写、死配置已清,全部完成
- docs/zh/plugin-migration-plan.md ... 迁移已上生产,纯过程稿(Part 0~6 全完成)

## 更新(按当前源码核对)
- assets/docs/{zh,en}/ARCHITECTURE.md(README 指向的用户文档,最重要):
  把只讲 cancel/intercept 的旧「中断机制」章节重写为「输入调度器与中断机制」——
  补上两类别 + 四级中断(L1~L4,默认 L1、外部插件 L4 夹到 L3)+ 抢占/挂起/中断栈
  + 饥饿防护(PreemptCount 提升,封顶 L4)+ 抢占冷却(2s)+ 停止语义(cancelBudget)
  + 驻留子/分诊助手/残余任务;新增「上下文预算」章节(窗口 ≠ 工作面,600K 封顶,
  预算是上限非填充目标)。中英章节数现已对齐(各 13 节)。
- assets/docs/{zh,en}/PLUGIN_DEV.md:插件示例表补 6 个缺失项
  (acp/deepsearch/plugindev/recoverydiag/vanblog/vikunja);qq 工具数 17 → 20(实测)。
- README.md / README_EN.md:补 v1.3.x 线(此前只到 v1.2.0,而 1.3.x 已发布 12 个 patch)——
  驻留式子 agent、输出通道寻址、输入调度器、轻量内核 profile、积压及时反馈。
- plan.md:开头两个「⚠️ 紧急/正在持续污染」是过期告警(实测  残留 = 0),
  改为「已解决」并加文档定位说明;§13 仍是活跃路线图故保留。
- docs/zh/plugin-interface-matrix.md + 两处源码注释:清理指向已删文档的断链。

全仓 md 断链检查:仅剩 1 处,位于 third_party 的 oh_modules(第三方依赖,非本项目)。
2026-09-19 19:14:55 +08:00
8acd3ce1a8 fix(offload): 内核说明不能被再转投(自我循环)+ offload_owned 未接线
★ 线上实测两个缺陷:

1. **自我循环**:转投会在队列留一条 [系统] 说明(source=kernel),
   而转投条件把这条说明也算进「积压够了」⇒ 每次转投都产生下一轮要转投的东西。
   实测 5 秒内连续触发两次,分诊助手不断收到「N 条积压已转投」这类噪音。
   修法:takeQueuedInputs 排除 isKernelNotice(source=kernel)。

2. **offload_owned 永远为 false**:我加了 ResidentInfo 字段、加了状态面映射,
   却漏了在 rc.info() 里赋值 ⇒ 线上转投子明明存在,读出来是 null。
   这类「加了字段但没接线」不会报错,只会让父的判断悄悄失效
   (父据此决定回收策略,读到 false 就会把临时助手当成正式子)。

测试 +3:说明不转投 / 循环必须终止 / offload_owned 会被上报。
前两条已实测「禁用守卫会失败、恢复后通过」,是真回归测试。
2026-09-19 17:47:44 +08:00
943eef01cf feat(resident): 分诊助手定位 + 残余任务由父显式决定
用户澄清(重要定性):这不是「内核替父决定」,而是**及时反馈** ——
主 agent 忙时不该让用户干等十几分钟。子 agent 是**分诊助手**:
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」、不勉强作答。

三处补齐:

1. 分诊助手的职责提示词(之前完全没给 ⇒ 子不知道自己为什么存在):
   两条路(直接办 / 报忙碌)、拿不准时报忙碌、必须 output_send 到原通道。
2. 驻留子继承父的 SystemPrompt(之前没传 ⇒ 子只用一句兜底文案,
   拿不到「异步通道必须显式 output_send,否则回复被静默丢弃」这条铁律。
   webui 这类同步通道能回是因为走 ResponseCh,掩盖了这个缺陷)。
3. 残余任务由父显式决定(用户要求):reclaim/destroy 时子手头未处理的消息
   不再由内核悄悄处置 —— 内核只负责列清楚,父用 residual=keep/drop 决定。
   之前 pendingEvents 只收带 ResponseCh 的,异步(qq)残余任务完全不在内,
   被销毁时静默消失、用户零反馈且日志无痕。

配套:
- scheduler.takeAllPendingEvents:取走全部未执行事件(不筛通道)
- ApplyResidual(keep|drop):keep 转回父队列(保留 ResponseCh),
  drop 逐条记日志 + 给同步调用方补终态(否则 cli/a2a 永久挂起)
- 状态面暴露 offload_owned,让父分清「我建的子」与「内核临时拉的助手」
- 工具 schema 加 residual 参数并说明 drop 的代价

这也是用户观察到的「机制很自然」的落点:分诊助手就在同一张登记表里,
父能 inspect/send/compress/reclaim/destroy,控制面 6 动作按 id 生效不区分来源。

测试 +7(残余 keep 转回且保留 ResponseCh / drop 通知同步调用方 /
空残余如实报告 / 分诊提示词 / 继承 SystemPrompt),
其中 drop 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
2026-09-19 17:43:16 +08:00
01909bb914 fix(offload): 转投必须保留 ResponseCh,否则同步调用方永久挂起
线上实测第二个 bug:转投生效、子也正常处理(日志各 ~3s),但 webui 的 HTTP
请求一直挂着不返回,最终 504。

根因:第一版用 InjectInputTo 转发,它会**重建** InputEvent ⇒ ResponseCh 被丢掉。
而 cli / a2a / webui 这类**同步**调用方正阻塞等这个 channel。
仓库反复警告过同一件事(Agent.Stop 的注释:「带 ResponseCh 的同步注入方
(cli / clawhubadapter 均无超时)会永久挂起」)。

修法:改走既有的跨 agent 投递原语 DeliverRouted —— 它推**原事件**,保留
ResponseCh/RequestID,只往 payload 里补转投标注。

回归测试 TestForwardKeepsResponseCh 断言**最强的那条性质**:真的等同步回执回来。
(不用「读子的 InputChan」来断言:SpawnResident 会启动子自己的调度循环,
它会与测试抢同一个 channel,那样写出来的测试是 flaky 的 —— 我第一版就是这样,
实测挂死过一次。)
已实测该测试对着错误实现会失败(10s 超时)、修后通过。
2026-09-19 17:16:24 +08:00
fe1d2672d8 fix(offload): 积压可能全堵在 io 输入 channel,不在就绪队列
线上实测发现上一版**永不触发**:主 agent 跑着 6×45s 的长任务、我连发 4 条消息,
scheduler 始终显示 queue=0、residents=0,转投一次都没发生。

根因:schedulerLoop 是**同步执行**任务的,所以「正忙」期间它根本回不到循环顶部
去调 pumpInbox —— 后到的输入全堆在 io.inputCh(容量 256)里,压根没进 sched.queue。
而 takeQueuedInputs 只看 s.queue ⇒ 恒取不到东西。

★ 仓库里早记过同一个坑:armStop 的注释写着「pending 是还没被 pumpInbox 搬进队列
的那一段……只数 s.queue 会得到 0(实测),配额随之失效」。我重犯了它。

修法:转投前先 drainInboxToQueue() 把 channel 里的输入搬进队列。
与 pumpInbox 的区别是**不要求 hasRoom** —— pumpInbox 满时会停下保留背压,
而转投场景恰恰是「队列空、输入堵在 channel」(调度器回不到 pumpInbox)。
队列上限仍由 enqueue 把关,放不下的给同步调用方 skipped 终态(不丢、不阻塞)。

回归测试 TestOffloadSeesInputsStuckInChannel 精确复现该现场状态:
已实测它对着修复前的逻辑**会失败**(期望 3 实际 0),修后通过 —— 是真回归测试。
2026-09-19 17:05:38 +08:00
69446a2649 feat(scheduler): 主 agent 忙时把积压任务自动转投给驻留子
问题(2026-09-19 线上实测):主 agent 被长任务占住时(现场:12 分 8 秒、69 次
工具调用),后来到达的消息全部以 level insufficient 排进中断队列干等 —— 同级
中断不能抢占同级运行任务(canPreempt),只能等前一个跑完。而内核本有驻留子
(独立 agent + 独立调度器)可并行干活。

行为(用户 2026-09-19 明确要求):
- 触发:运行任务持续 > offload_busy_after(5m) 且积压 >= offload_min_pending(3)
- 拉起/复用「转投专用」驻留子,把积压的纯排队输入转投过去
- 在原队列位置留下说明「[系统] N 条积压任务已转投给驻留子 agent X 处理…」

通道配置(按用户口径,与人工创建的子刻意不同):
- 不配 inputch(内核的干活 agent,不接收插件用户输入)
- 持有全部输出通道(结果要能发回 qq/webui 等正确通道)

三个设计要点(都是实测撞出来的,写进代码注释与设计文档 §7.1):
1. 检查必须在**独立 goroutine**:schedulerLoop 同步执行任务,放它里面在
   「正忙」期间根本回不到循环顶部 ⇒ 永不触发(我第一版就写错了,测试才发现)。
2. 只转投 TaskQueued 纯排队输入:中断任务带级别语义、self 任务与父的记忆面绑定。
3. 转投失败/关闭时必须把任务**放回队列前端**:吞一条输入比多处理一条更糟。

这是设计 §7「决策在父的模型手里」的**刻意例外**(父正忙、物理上无法决策,
而积压任务本来就是空的),已在文档中显式记录,且默认关闭、由部署方显式打开。

测试 11 条:只取排队输入 / 不足量不取 / 放回不丢任务 / 说明自解释 / 默认关闭 /
空闲不触发 / 端到端转投 / 上限不增殖 / 独立 goroutine 确实会触发。
2026-09-19 16:59:47 +08:00
e273924511 fix(llm): 参数无法解析时给出真因,不再静默丢弃整条调用
★ 上次修复误判了成因。真实根因(本次运行日志 34/34 同形):
    {"command": "…完好的长命令…", "timeout": 20s}
  command 一字节没错,只是 timeout 值少了引号 —— cmd_run 的 schema 把 timeout
  声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
  finish_reason=length 出现 0 次 ⇒ 上次那条"截断"分支从不生效。

旧行为把**整个参数**丢掉,模型只看到 "command is required",看不出坏在 timeout,
只能原样重试。实测本次运行 cmd_run 失败率 35%(34 败 / 71 成),
12 分钟的任务里更是 48% 时间耗在这上面 —— 每次失败都付一次完整 LLM 往返。

三处改动:
1. repairToolArgsJSON:解析失败时先试窄修复 —— 只给"值位置上未加引号的带单位
   数字"补引号,且修完必须真能解析成功才接受。不碰合法 JSON、不动正文里的 20s、
   不会把真截断"修好"。
2. 修复仍失败时不再静默降级成空 map,改为带 __arg_error 交给模型,并按成因
   分流文案:截断→拆小参数;JSON 写坏→提醒带单位的值要加引号。
3. 统一键名 __arg_error(原 __truncated_error 只覆盖截断,语义过窄)。

同一缺陷面不止 cmd:agentcli/healthcheck/timer 都有 string 类型却以
"5m, 1h" 作示例的参数,此修复一并覆盖。

回归测试:真实日志样本修复、保守性(不碰合法/正文/截断)、
端到端(修复后 timeout 仍能被 time.ParseDuration 接受)。
2026-09-19 16:49:16 +08:00
53e7106985 fix(llm): 参数解析失败不再丢弃完好字段(改错值格式,不是截断)
★ 上次修复误判了成因。真实根因(日志 11/11 同形):
    {"command": "…完好的长命令…", "timeout": 20s}
  command 一字节没错,只是 timeout 值少了引号 —— 而 cmd_run 的 schema 把
  timeout 声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
  实测 finish_reason=length 出现 0 次,所以上次那条"截断"分支从不生效。

旧行为把**整个参数**丢掉:模型只看到 "command is required",看不出是 timeout
写坏了,只能原样重试 —— 12 分钟的任务里 30 次失败 / 32 次成功(48% 浪费),
每次失败都付一次完整 LLM 往返。

改法:parseToolArgsJSON 失败时先试 repairToolArgsJSON,只做一件很窄的事 ——
给"值位置上未加引号的带单位数字"补引号,且修完必须真能解析成功才接受。
因此不会改坏合法 JSON、不会动字符串正文里的 20s、不会把真截断"修好"。

真实日志样本 + 保守性 + 反伪造三组回归测试已钉死。
2026-09-19 16:47:13 +08:00
6ddef5e49f feat(llm): 声明真实上下文窗口 + 工作区间与窗口分离
问题:core.llm.model=AUTO,而 ModelContextWindow("auto") 匹配不到任何分支、
掉进 default 32768 —— 该源真实窗口是 1M(实测 990,034 token 的 prompt 通过),
内核却按小 30 倍的窗口算全部预算。

三处改动:
1. ModelContextWindow 补 deepseek-v4/v3 → 1M;推断不出时打日志(静默降级是
   这次问题的成因,不能再默默退回一个小值)。
2. sourceFieldDefs 补 context_window 声明:它早已被 readInt 读进 LLMSource
   并透传到 provider,但没进这张表 ⇒ WebUI 里看不见也改不了。
3. ComputeTokenBudget 新增 maxTargetTokens=600000:窗口 1M 不等于按 838K
   (80%)干活。标称窗口≠有效窗口,600K 是该源最优工作区间,所以把
   「窗口上限」(会不会被上游拒)与「工作区间」(预算分配)分开。

配套 core.llm.max_tokens 4096→32768(实测长输出样本达 18,272 token,
16384 仍会截断;上限是 cap 不是目标,短问答零成本)。

测试:tokenbudget_test.go 钉死封顶生效且小窗口不受影响;
context_window_test.go 钉死推断值与显式声明优先级。
2026-09-19 14:24:31 +08:00
95292aff8e test(llm): 钉死截断必须短路工具分派
补一条端到端断言:截断的 tool call 绝不能拿着空 map 走到 files_write
(那会回 'path is required',模型据此原样重试)。断言 executeToolCallInner
的短路 + 指引可执行。
2026-09-19 13:53:56 +08:00
2722d76095 fix(llm): max_tokens 截断不再静默降级成空参数
长参数工具调用(整段脚本/大 JSON)被 core.llm.max_tokens 从中间切断时,
上游回 finish_reason=length,而旧实现把这个信号整个丢掉:残缺 JSON 解析失败
后静默降级成空 map,工具只看到参数为空并报 'path is required'。模型因此完全
看不出真因,原样重试四遍、次次撞同一堵墙(2026-09-19 实测 4 次 files_write 失败)。

注:files_read 并未失败——是写挂之后模型反复重写把读卷进同一轮,看起来像两者都报错。

改法:
- finish_reason=length 时不再静默降级,改为塞入 __truncated_error 指引,
  告诉模型「参数被截断 + 请拆成多次调用/追加写 + 勿原样重试」;
- executeToolCallInner 见到该标记即短路,不拿空参数去调工具;
- 非截断的残缺 JSON 保持旧行为(避免把「厂商不回 finish_reason」误判成截断)。

回归测试 2 条钉死这两面。
2026-09-19 13:49:21 +08:00
01113664b4 fix(obs): 抢占日志改在判决点打(修自伤)+ scheduler 事件接进 SSE
## 修我上一版的自伤

上一版把 preempt 日志打在 `executeNewTask`,但那时 `nextRef` 已经把
`s.running` 换成了抢占者自己,于是输出成了

    preempt start: task#2 ... -> victim task#2 (cli)

victim 打印的是入侵者本人。判据必须落在 `registerInterrupt`——那一刻
running 还是真正的受害者。改为在抢占判决点打:

    [agent] preempt: task#2 class=interrupt level=3 from cli (L3) preempts task#1 class=queued level=0 (qq)

## 补上「入队而非抢占」的日志

中断到了却没生效,此前完全不可解释。现在两种成因分开写:

    [agent] interrupt queued: ... vs ... (qq) — running in critical section; queue=N
    [agent] interrupt queued: ... vs ... (qq) — preempt cooldown; queue=N
    [agent] interrupt queued: ... vs ... (qq) — level insufficient; queue=N

没有这条,`interrupt from X` 打过之后任务为什么没让位就只能猜。

## scheduler 事件接进 SSE

`EventScheduler` 此前既不在 `handler_chat.go` 的 subTypes、也没有任何订阅者
(全仓 grep 零命中)——内核里 suspend/resume 只 publishEvent,于是事件发出来
就掉地上,对内对外都不可见。加进 subTypes 后前端/客户端能看到抢占链。

## 验证

- preempt_logging_test.go 增一条:victim 与入侵者必须是不同来源(qq vs cli),
  且排队输入对排队任务 `canPreempt` 必为假。
- 实测输出含 `cli (L3) preempts task#1 class=queued level=0 (qq)`。
- 全量 `go test ./internal/... ./cmd/...` 与 `go vet ./internal/...` 全绿。
2026-09-19 11:57:50 +08:00
b1ec278136 feat(obs): 抢占日志说出「受害者是谁」——suspend/resume 此前完全不落日志
排查「我的任务怎么被莫名打断了」时撞上的观测缺口。

## 缺口

`executeNewTask` 里挂起、`resumeTask` 里恢复,两处都**只发事件、不写日志**:

    a.sched.suspend(t, f)
    a.publishEvent(events.EventScheduler, map[string]any{"action": "suspend", ...})

于是生产日志里只有两行:`interrupt from X` 与 `LLM request cancelled by
preemption` —— **看不到受害者是谁、被谁挤下去、后来有没有恢复**。后果是实测过的:
按时间先后猜凶手,把时间上相邻的输入误认成抢占者。

## 改动

- `sourceOf(task, frame)`:取可辨识来源(`evt.Source` 优先,回退 OutputChannel,
  自循环任务给 `self:<channel>`)。取 Source 而**不是** OutputChannel:
  前者回答「谁送来的」(qq / homeagent-mail-bridge / timer / child/xxx),
  后者只回答投递到哪个通道;多数场景同名,但因果链上要的是前者。
- `describeTask(task)`:`task#N class=queued|interrupt level=L`。
- `suspendDepth()`:日志专用,走锁而不是让日志点直接摸 `suspendStack`。
- 三个日志点:抢占开始(含 victim)、挂起(含来源与栈深)、恢复。

输出形状:

    [agent] preempt start: task#2 class=interrupt level=4 from cli -> victim task#1 class=queued level=0 (qq)
    [agent] suspend: task#1 class=queued level=0 (qq) yields to an interrupt; suspendStack=0
    [agent] resume: task#1 class=queued level=0 (qq) resumes after the interrupt finished

## 验证

- 新增 preempt_logging_test.go:抢占后栈深 0→1、sourceOf 取到 qq、self/nil 不 panic。
- 实测日志(TestPreempt_HigherPreemptsAndResumes)三条齐全,能一眼看出
  是 `cli` 的 L4 挤掉了 `qq` 的排队任务、随后 qq 恢复。
- `go test ./internal/... ./cmd/...` 全绿。
2026-09-19 11:31:04 +08:00
d202f2ceec feat(ohos): screensue 改用 Web 组件渲染 HTML(RichText 撑不住)
用户实测截图:推送内容把整段 HTML 源码当字符串显示(含 <style>、@keyframes、
内联 <svg>、radial-gradient)。RichText 只认极小标签子集,这些一律不渲染。
用户明确要求「引入 webview」。

## 修法

- 新增 `common/ScreensueHtml.ets`(从 BridgeCaps 抽出:后者加进 HTML 逻辑后
  超 520 行,越了工程「单文件 ≤400 行」的约定;且「screensue 怎么解析/渲染」与
  「设备能力怎么实现」本是两件事)。
- `ScreensuePage.ets`:HTML 走 **Web**,纯文本仍走 Text。
- 加载用 `loadData(base64)`:encoding 非 base64 时按 URL 规则转义,几 KB 的
  完整文档会撞长度/转义问题。自写 `base64Utf8`(UTF-8 手编字节,含代理对合成)
  —— 直接把 UTF-16 码元交给 Base64Helper 会让中文变乱码。
- 非完整文档补一层 shell(meta viewport + 主题前景色),完整文档原样加载。

## 顺带修掉一个真实缺陷(实测发现)

`looksLikeHtml` 旧判据要求「首个非空字符就是 '<'」。而 agent 传参常把整段文档
连引号一起给(`'<html>…'`)——截图里那个孤立的 `'` 就是这么来的,判据因此
**判否并退回纯文本**,所以看到的是源码。改为扫第一个「像标签开头」的 '<'
(跳过引号/前导文字),且只在其后紧跟字母或 '/' 时才算,避免误判 "a < b"。
Node 复刻同一算法验证了 7 个样例(含截图实况、<3 表情、比较符)。

## 安全(我因为引入 WebView 而必须自己把关)

内容来自 agent(第三方)。显式关闭:
`javaScriptAccess(false)` / `fileAccess(false)` / `domStorageAccess(false)` /
`onlineImageAccess(false)` / `zoomAccess(false)`。
★ **javaScriptAccess 的默认值是 true** —— 不显式关掉等于让远端内容在客户端执行脚本。
已实测取证:推入带 `<script>` 与 `<img onerror>` 的页面,屏上稳定显示 `JS-OFF`,
两条执行路径都没跑起来。

## 另一个实测发现的缺陷

`onControllerAttached` 只在挂载时触发一次,而 ScreensuePage 在 `if (visible)`
里常驻 —— 连续两条 screensue 只改 @Prop、组件不重建,Web 一直显示**上一条**
内容(实测:倒计时变成 33s 但画面还是旧 HTML)。改 `@Prop @Watch('onDataChanged')`
显式重载,并记录 attached 状态避免过早 loadData(会抛 17100001)。

## 验证(模拟器真机链路)

把工程内连接指向本机服务、开启「允许 agent 控制本机」授权,经
`device_ctl_cmdrun` → 设备桥 → screensue 推入用户截图里那段原样 HTML:
- 渲染成功:radial-gradient 背景、内联 SVG 兔子、CSS 发光文字、两行文案
  (修复前同一段内容显示为满屏标签源码)
- 连推第二条 → 画面正确刷新为 SECOND PUSH
- JS 探测 → JS-OFF(脚本被拦)

模拟器只能装 unsigned 包(signed 报 READ_PASTEBOARD 授权失败,与既有记录一致)。
2026-09-18 12:01:33 +08:00
895948b24e fix(stop): 配额须计入停在输入 channel 的待处理消息
实测:停止后排队消息仍逐条跑完。原因是 armStop 只数 sched.queue,
而用户按下停止时调度器正忙于当前任务,其余消息大多还没被 pumpInbox
搬进队列、仍停在 inputCh ⇒ queued=0、配额归零。

- IOManager.PendingInputs():暴露 channel 中待处理条数。
- armStop(pending int):queued = len(s.queue) + pending。
- 补 TestStop_ArmCountsPendingChannelInputs 锁死该口径。
2026-09-18 11:39:04 +08:00
698ff7ddd5 fix(stop): 修自伤——interceptLoop 里 takeStop() 被 || 短路提前消费
上一版把 stop 标记在 interceptLoop 里消费掉了一部分:
`if n := armStop(); n > 0 || takeStop() { ... }`,queued=0 时短路到
takeStop(),标记先被吃掉,stepLLM 永远看不到 → 取消后照样重跑一轮。
实测:日志正确打出 stop requested ... queued=0,但生成仍跑到自然结束(5000+ 字全文落库)。

改为只 arm 不 take(takeStop 只由 stepLLM 消费),并补一条**经真实
interceptLoop** 的用例:它必须 a.Start()(第一版测试只调 New(),
interceptLoop 根本没跑,假绿)。已验证该用例在注入此 bug 时失败、修复后通过。
2026-09-18 11:32:20 +08:00
ccc2ac2d4d fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。

## 症状(实测取证)

1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
   `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。

## 根因

停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。

另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
  上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
  这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
  但用户按停止是「不要了」,重跑就是停止没生效。

## 修法(按用户明确的设计)

停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。

- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
  `consumeCancel`。配额取快照值(停止后新到的输入不受影响),
  重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
  **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
  带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
  Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
  否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。

## 鸿蒙端

- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
  避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
  纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
  Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
  注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
  ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。

## 验证

- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
  配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
  READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00
831bd2290b fix(ohos): 聊天改用 seq/after 增量轮询,修「不滚动/己方消息不显示/新消息不加载」
jianf 报的三个现象其实同一个根因:WebUI 前端在 commit 9711177 已改为
「暴露数据查询 API + 前端轮询 patch 视图」,聊天记录走 /chat/history?after=<seq>
增量游标 + seq 对账;鸿蒙端一直只做**首屏全量加载**,没跟上这套口径。

1) 页面不加载新的聊天信息(根因)
   鸿蒙只在 aboutToAppear 拉一次 /chat/history?limit=40,之后除了 SSE 就没有
   任何拉取。SSE 只在「本端发起的那一轮」推事件,其他端/其他渠道(QQ、
   WebUI 浏览器)发来的消息永远不会出现在鸿蒙页面上。线上实测:鸿蒙
   IP(61.54.104.206, auth=api-key)最后一次请求停在 9/17 22:34,此后
   只有浏览器 session 在轮询。

2) App 发出的消息不显示
   原来靠 mergeHistoryWithLocal 按「正文内容」去重:本地乐观 user 消息
   与服务端回显内容一致就被判重复丢弃;同一句话发两次同样误删。
   改为按 seq 对账(新增 reconcileServerMsgs,对齐 WebUI applyServerMessages):
   服务端带 seq → 有则原地更新、无则认领本地无 seq 的同类乐观消息;
   旧后端无 seq 才退化为内容比对。

3) 加载完不滚动、停在最新消息处
   @Watch 只在值**变化**时触发,不触发初始值。ChatPage.aboutToAppear 里
   loadHistory() 是异步的,若它在 ChatStream 构造之前就完成,
   requestScroll 递增的 chatScrollRev 就成了「挂载前已发生的变化」——
   onScrollReq 永不被调,于是停在顶部/中间。
   修:ChatStream.aboutToAppear 见已有消息就自己滚一次;scrollToBottom
   的重试从 50/260ms 扩到 50..800ms(长历史布局慢,两次不够),
   并用世代号作废旧一轮定时器,避免与新滚动打架。

配套:
- Model.ChatMessage 加 seq;ChatHistory 解析 seq 与 last_seq(游标缺失时
  回退本页最大 seq,保证不倒退)。
- 新增 pollIncremental():after 增量 + limit=1 尾部探测(工具卡/最终文本是
  原地改写已有 seq,不产生新 seq,只靠 after 拿不到),3s 节奏与 WebUI 一致。
- startPolling 挂在 connect() 而非 loadHistory 末尾:首屏失败也能自愈。
- disconnect() 停轮询。

验证:hvigor assembleHap BUILD SUCCESSFUL;make check-client-versions 一致;
go build/vet/test 全量零失败。
2026-09-18 10:49:30 +08:00
1f5af1dccf feat(cli,ohos): 补齐两处能力缺口——CLI /memory context|tools、鸿蒙 camerasue 录像回传
核对「CLI 与 WebUI 插件能力对齐」时发现两个此前遗漏的缺口,一并补齐。

1) CLI /memory 缺 context 与 tools(internal/plugins/cli/plugin.go)
   WebUI 有 GET /api/v1/memory/context 与 /api/v1/memory/tools,CLI 只有
   query/graph/text。IndexerAPI 本就对内部插件开放(BuildContext/
   FormatContext/GetToolDefinitions/BuildToolPrompt),不是 SDK 缺口。
   补上后与 WebUI 同源:context 打印「实际会注入什么上下文」,
   tools 打印工具定义 + 工具提示词。
   waiter 同步接上两条远端路由与 help 文案。

2) 鸿蒙 camerasue 录像(BridgeCaps/BridgeRouter/DeviceBridge.ets)
   此前 `camerasue <N秒>` 直接返回「暂不支持录像回传」。查 SDK 后发现
   cameraPicker 本身就有 PickerMediaType.VIDEO 与 PickerProfile.videoDuration
   —— 录像完全可行,只是回传通道没接。
   现改为:VIDEO 模式取回 mp4,经 DeviceBridge.sendDataChunked 按
   cmd_data_start/分块/cmd_data_end 回传(与 GUI/CLI 录像路径一致),
   网关聚合后落盘成文件、agent 拿路径;照片仍走小体积 base64 内联。
   上限 64MB、时长 1~300s,超限明确报错而不是把 WS/上下文撑爆。

   依赖方向处理:BridgeCaps 需要「往本请求回传字节」,但 DeviceBridge 为取
   CapResult 已 import BridgeCaps,反向 import 会成环。改为 BridgeRouter
   注入 DataChunkSender 回调(它同时持有 deviceBridge 与 reqId),
   BridgeCaps 不碰 socket。新增 CapResult.chunked 标记「结果已由能力分块
   发完」,DeviceBridge 据此不再回 cmd_result,避免网关把已完成请求与后续
   分块错配。

验证:hvigor assembleHap BUILD SUCCESSFUL(ArkTS 编译通过,改动文件零告警);
make check-client-versions 一致;go vet 干净;全量 go test ./internal/... ./cmd/... 零失败。
2026-09-18 10:04:53 +08:00
9de3b365a6 fix(agentcli): 修 4 个真实缺陷——停机死锁、超时泄漏、僵尸堆积、孙进程逃逸
jianf 提示 agentcli 可能有问题,系统性审了一遍(含 -race 与线上实证),
确认并修复 4 个互相叠加的真实缺陷,每个都配了「去掉修复即失败」的回归测试。

1) 停机/热重载死锁(plugin_stop_test.go)
   Stop() 先 p.wg.Wait() 再 Close 终端,而 readLoop 自己也记在 p.wg 上、
   只监 t.stopCh 不监 p.stopCh。只要有一个终端开着,wg.Wait() 就永不返回。
   后果:插件卸载/热重载(StopAndUnload/ReloadOne)与停机全挂死,且
   registry 持锁时是整个内核一起挂。
   修:先关活跃终端(move 出 map 后在锁外 Close),再 wg.Wait();
   readLoop 顶部加 p.stopCh 探测;Stop() 用 sync.Once 保证幂等。

2) 终端超时后资源全泄漏(plugin_lifecycle_test.go)
   readLoop 的 IsExpired 分支只 delete(sessions) 后 return,既不 Kill 也不
   Close。终端已被移出 sessions,cleanupLoop 也再看不到它,进程/PTY fd/
   reader 协程无人回收。实测:timeout=1s 的 sleep 300 超时后进程仍在跑。
   修:readLoop 加 defer releaseResources(),保证「只要退出就释放」。

3) 子进程从不回收 → <defunct> 僵尸堆积(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 只 Start 从不 Wait。线上实测 homed 名下已有一个
   [sh] <defunct> 僵尸子进程。
   修:linuxPty 加 Wait()(sync.Once 保证只 Wait 一次),
   releaseResources 通过可选接口 Wait() error 调用(Windows ConPTY 不实现则跳过)。

4) Kill 只杀直接子进程,孙进程逃逸(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 用 Setsid,sh 是新进程组领头,真正的命令(sleep/vim)是
   其孙进程且同组。只 Kill(sh) 会留下孤儿继续跑。实测:`sleep 300; echo done`
   只杀 leader 后 sleep 仍在(被 init 收养)。
   修:改为 syscall.Kill(-pid, SIGKILL) 杀整个进程组,失败再回落单进程 Kill。

测试设计要点:回归用例必须让「sh 保留为父进程 + 孙进程显式 trap "" HUP」,
否则单个 sleep 会被 sh exec 掉、关 PTY 的 SIGHUP 又会顺手带走孙进程,
两个缺陷都测不出来(这两种情况都实际踩过并修正了用例)。

全量 go test ./internal/... ./cmd/... 通过,agentcli 单包 -race 通过。
2026-09-17 20:24:03 +08:00
ec13eb391a fix(agentcli): 终端退出前补推残留输出,短命令输出不再丢失
线上验证「内核开、两个插件接」时发现的真实缺陷:`echo`、`ls` 这类在首个
200ms ticker 之前就结束的短命令,readLoop 走到 `!terminalRunning(t)` 分支
直接 return,残留在 stream 里的输出从未 flush。

症状:输出只留在 session.buf 里——agent 用 terminal_read 能看到,但
terminal_output 事件永远发不出去,于是内核权威视图(以及 WebUI/CLI 的
/terminals)的 output 恒为空。实测 term_2(echo HELLO_KERNEL_REGISTRY)
在 /terminals 里 output="" 而 agent 同期 terminal_read 拿到了正文。

修法:
- 抽出 flushTermStream(s, t),ticker 与所有退出路径共用同一条推送路径
  (避免以后再出现「某条退出路径忘了 flush」)。
- readLoop 顶部加 defer:defer flushTermStream 后于 defer emitTermState
  声明 → LIFO 下先 flush 再报停止,保证「最后一段输出」先于 running=false
  到达订阅者。

测试:plugin_flush_test.go 新增 TestReadLoopFlushesOutputOnExit——走
EventBus 捕获事件,推入输出后立即让进程退出(远早于 ticker),断言输出
已补推且末态 running=false。已验证去掉修复即 FAIL、加回即 PASS。

注:handleRead(clear=true) 会主动 Reset stream(避免与读取结果重复),
属既有设计;本修复针对的是「未被读取就退出」的路径。
2026-09-17 20:00:47 +08:00
e70d2171ee refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核
按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
2026-09-17 18:56:15 +08:00
3cce605722 feat(cli): /terminals、/cmd/history、/terminal 对齐 WebUI(事件面 + ToolAPI,无需新接口)
去看了一遍源码,纠正上轮判断:终端与命令历史也不是 WebUI 插件私有。
- 终端会话:agentcli 插件持有,发 EventTerminalOutput;WebUI 只是订阅该事件
  自己攒视图。命令历史:WebUI 订阅 EventToolCall 的 cmd_run 攒的。
- 于是 CLI 插件订阅同样两个事件即可同口径:/terminals、/cmd/history。
- 开/写/读/关终端:SDK 的 ToolAPI.ExecuteTool 已允许跨插件调用工具,
  CLI 直接调 agentcli 的 terminal_create/write/read/close,新增 /terminal 子命令。

waiter 侧同步:/terminals、/cmd/history 两条路(local/remote)都接;/terminal
仅在 local 可用(远端 WebUI 无对应 REST 端点,明确提示而不是当聊天发出去)。
2026-09-15 15:31:32 +08:00
0227fc2d4d feat(cli): /persona 与 /agents 对齐 WebUI(复用已开放的 SDK 面)
- /persona:读写 core.agent.personal_prompt / core.internal.persona_initialized;
  插件 SDK 的 Settings() 满足 internal/config.PersonaKV,与 WebUI 同一实现。
  GET 等价返回 initialized/current_prompt/file_override;/persona set <mode> [内容] 写。
- /agents:改用 supervisor.ListAgents()(WebUI /agents 同源),此前只回一个
  agent_id、驻留子信息全丢。
- waiter 侧 /persona 在 local/remote 两条路都接上,/help 补齐。
2026-09-15 15:25:20 +08:00
91c25fa536 feat(cli): /runtime 接上(runtime 本就经 KernelStatus 对内部插件开放)
纠正上一轮的判断:runtime 不是“SDK 未暴露”。s.Status().GetKernelStatus()
里的 Scheduler / Residents / Channels / InputChannels 就是 WebUI /runtime
的数据源,内部插件同样拿得到——CLI 只是漏接了这条命令。

- CLI 插件新增 /runtime,输出与 GET /api/v1/runtime 同口径。
- waiter 侧 /runtime 在 local(发 CLI 插件)与 remote(走 REST)两条路都接上,/help 补齐。
2026-09-15 14:29:29 +08:00
bcaa8f3f31 feat(cli): /plugin install 真正可用(直连 pluginmgr 回环端点,与 WebUI 同实现)
原来 CLI 的 /plugin install 只打印“请去 WebUI”。插件安装逻辑在 pluginmgr
插件里(回环 HTTP,默认 127.0.0.1:9876,无鉴权),WebUI 也是转发到它;
CLI 插件改为直连同一端点,能力对齐。
2026-09-15 12:17:34 +08:00
5333a33e20 feat(cli): CLI 插件能力对齐 WebUI(memory/knowledge/config/tracker/adapters/network)
原来 CLI 插件只覆盖 WebUI 的一小部分:/memory 只有 query、/knowledge 只有
list、没有 config/tracker/adapters/network,结构化输出还各拼一套文本格式。

按 WebUI 的 REST 面对齐:
- /memory query|graph|text [n]     (对应 /memory、/memory/graph、/memory/text)
- /knowledge | delete <name> | stats(对应 GET/DELETE /knowledge)
- /config                          (对应 GET /config)
- /tracker | rollback              (对应 GET /tracker、POST /tracker/rollback)
- /adapters | remove <name>        (对应 GET/DELETE /adapters)
- /network                         (对应 GET /network)
- 统一 writeJSONContent:结构化数据一律缩进 JSON,与 WebUI 同口径。

waiter 侧同步:把上述命令在 local(发 CLI 插件)与 remote(走 REST)两条路
都接上,/help 补齐;两条路语义一致。
2026-09-15 12:14:25 +08:00
95b1950bb3 fix(ohos): 历史刷新不再整表替换,避免刚发出的消息凭空消失
sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争:若历史快照
里还没有这条 user 消息,整表替换会让它消失(“客户端侧发出的消息不显示”)。
改为合并:历史为权威,但保留本地两类消息追加在末尾——
  - user 且 source 为空(乐观消息)且内容未出现在历史里;
  - assistant 且 !isFinal(仍在流式输出)。
2026-09-15 12:09:58 +08:00
307a6faee7 fix(ohos): 设置页插件/工具数不显示 + 聊天自动滚底时机
设置页计数(实测:后端返回 plugins=35/tools=260,卡片却一直显示 '-'):
- 根因是 ArkUI 的 @Builder 按值传参是“快照”语义——父组件因
  @StorageProp 变化重渲染时不会用新值重跑 builder,数值永远停在
  首次渲染的 0。把 KPI 小卡从 @Builder 方法改为独立 @Component
  (@Prop 单向下发),父组件重渲染时子组件拿到新值并重绘。
- 已上模拟器验证:设置页显示 插件 35 / 工具 260。

聊天自动滚动:
- scrollToBottom 原来只在 50ms 后滚一次;长历史/长思考卡的布局在
  消息数组更新后的若干帧才稳定,一次滚动会落在“当时”的底部,
  最后一条被输入区挡住。改为 50ms 与 260ms 各滚一次,480ms 后
  恢复正常滚动态。
2026-09-15 11:56:18 +08:00
baddaf387e merge: 回流场景式关联召回 + 记忆整备 + 客户端修复(feature/recall-policy)
- 场景式关联召回:声明/涌现双通道、场面指纹聚类、场景前缀/相似度召回
- 记忆整备:doc→graph 闸门、噪音/孤立清理、关系去重、原句回显、memoryPass 收敛
- 本轮修复:衰减真半衰期、索引同步基线、Ensence 死参/死分支、冗余索引
- 客户端:鸿蒙未连接连接入口 + camerasue;waiter 斜杠命令本地语义修复
- 版本:GUI/鸿蒙/waiter 与内核统一 1.4.0(make check-client-versions)
- 客户端版本同步文档 §四
2026-09-15 11:13:04 +08:00
15497ee0a2 feat(ohos+waiter): 补 camerasue 能力;修 waiter 本地斜杠命令被当聊天文本
ohos camerasue:
- LOCAL_DEVICE_CAPS 增 camerasue;BridgeRouter 增 camerasue 路由。
- 实现走系统相机选择器 cameraPicker(三方应用无法无界面直驱摄像头),
  结果落应用沙箱(saveUri=filesDir),不写系统媒体库、不需 READ_IMAGEVIDEO。
- 录像(camerasue <N秒>)明确返回“暂不支持录像回传”,而不是回一个超长
  base64 撑爆上下文;二进制分块回传待接线。

waiter 本地/远端命令语义统一:
- 修 bug:/settings、/settings set、/plugin install/remove/info、
  /memory query、/knowledge delete 本地分支发的是 cmd[1:](丢掉前导 /),
  于是 CLI 插件不认、被当成聊天文本丢给 LLM。改为原样发(保留 /)。
- 补 /stop、/interrupt:/help 一直写着但 handleBuiltin 没实现,会落到
  “当普通消息发给 Agent”。远端走 POST /chat/interrupt,本地交给 CLI 插件。
- 补 /plugin disable|enable:远端插件管理 REST 动作,本地 CLI 插件。
- /help 文案改为“local 与 remote 行为一致”并列出新命令。
2026-09-15 11:12:27 +08:00
c151d391ee docs(release): §四 补客户端版本必须与内核同步 + make 门禁 2026-09-15 11:04:31 +08:00
065732f42e chore(version): 客户端版本与内核对齐(唯一事实源 internal/meta.Version)
此前三份版本号互不相干:内核 1.4.0、GUI 1.0.0、鸿蒙 1.1.1。手工各改各的
必然漂移,所以把「对齐」做成机械动作而不是约定:

- deploy/scripts/sync-client-versions.sh:从 internal/meta.Version 读版本,
  同步 cmd/gui/package.json 与鸿蒙 AppScope/app.json5(versionName +
  versionCode=X*1e6+Y*1e3+Z);--check 给 CI/Makefile 做漂移门禁。
- Makefile 新增 sync-client-versions / check-client-versions;build-cli 也注入
  LDFLAGS,waiter 与 homed 同版本。
- 鸿蒙:新增 common/AppVersion.ets,从 bundleManager 读安装包 versionName,
  BridgeProtocol/BridgeCaps 里两处硬编码 '1.1.1' 改为读它——版本只剩
  app.json5 一份,杜绝第二真相。
- waiter:`-version` 打印版本,启动横幅与设备桥 hello 的 version 字段
  直接引用 internal/meta,与内核天然同源。

对齐后:GUI 1.4.0 / 鸿蒙 1.4.0(1004000) / waiter 1.4.0 / 内核 1.4.0。
2026-09-15 11:03:26 +08:00
a9ad97240b fix(ohos): 未连接后端时给出不可错过的连接入口
问题:全新安装(未配置后端)时,聊天页只有空列表+输入框,用户找不到
任何连后端的入口;设置页的连接入口在列表里也容易被略过。

改动:
- 聊天空态按连接状态分流:未连接显示「尚未连接后端服务」+「去设置连接」
  按钮;已连接显示「开始新的对话」。
- 跨页信号(AppStorage:K_HAS_CONN / K_REQUESTED_TAB / K_SETTINGS_SUB):
  按钮 → Index 切到设置 Tab → SettingsPage 直接打开「后端连接」二级页。
- 设置页在未连接时主动把连接表单推到面前:窄屏 onNavigationModeChange(Stack)
  直接 push,宽屏右栏默认页从「运行状态」改为「后端连接」。
- 连接增删改切后广播 K_HAS_CONN,聊天空态即时切换文案与入口。

已在手机(窄屏)与折叠展开(宽屏)模拟器验证:空态按钮可达、点击后
落到带「+ 添加」的连接表单;冷启动点设置 Tab 亦自动打开连接页。
2026-09-15 10:52:48 +08:00
acc94723fd fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
2026-09-15 10:20:40 +08:00
49695c38f3 feat(memory): 场景双通道——主动声明与被动涌现并存,且互不吞噬
按「声明式的也要支持,相当于主动被动两条路」落实。此前两者只是恰好并存,
没有边界,实测会互相吃掉(下面的坑就是)。

- Triple.Scenes []string(多值):一轮写下的记忆**两条路都挂**。
  只挂一条会丢东西——只挂声明则细粒度唤起丢失,只挂涌现则首次交互
  (场景还没长出来)没有兜底。单值 Scene 保留兼容。
- TurnScene:Primary 用于写(优先涌现场景,首次退到声明场景兜底),
  Keys 是两条路的并集,用于召回(声明+涌动的场景一起进 RecallByScene)。
- EnterSceneWithHint:主动路 EnsureScene(声明即建场景,不等第二次),
  被动路 EnterScene(指纹聚类)。写侧由 executeToolCall 把本轮场景集合
  传给 memory_commit,模型不需要知道"场景"这回事。

踩到并修掉的坑(两条路互相吞噬):
  最初让声明场景也吸收**整轮指纹**,于是 chan:qq 的相似度永远是 1.0,
  把后续所有同类轮次全部吃掉 → 被动路再也长不出更细的场面,
  实测 turn2.Emergent=true 但 Primary 仍是 chan:qq、没有 auto: 场景。
  修法:给场景加 origin(declared/emergent):
  - 被动聚类只认 origin='emergent' 的场景(声明场景不进相似度空间);
  - 声明场景的特征**只从键自身解析**(chan:qq/peer:group_1 → {chan:qq, peer:group_1}),
    白名单 kind(chan/peer/peer_group/tool/topic/part),不猜——
    「老大2026-09-04_12:27_qq私聊图片」里的 12:27 也是 kind:value 形态,
    放进特征空间就是往相似度里灌垃圾(有测试钉住)。
  - 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),机制各归各。
- memgc -scene-stats 增加 [declared|emergent] 与 strength/features 两栏,
  可直接观察两条路各自在长什么。

新增/改写用例:
- TestDeclaredAndEmergentBothLearn:首次交互兜底到声明场景 → 第 2 轮长出
  细粒度涌现场景且**优先用于写入** → 声明场景不进相似度空间(防止压死被动路)
  但仍走声明键取回 → 两条路都进召回集合 → 声明键特征解析与白名单。
- TestEffectiveScenes:多值+单值合并去重保序。

go build/vet 干净,go test -count=1 ./... 全绿。
2026-09-15 09:37:29 +08:00
bb7e7979ae fix(memory): SceneStats 的 strength/features 不再说谎
- 旧行经 ALTER 加列后 strength 为 NULL,直接 SELECT 会显示成 0(实际是 1 次);
  改为 COALESCE(strength,1),并补上 features 计数(场景长出了几个特征)。
- 两栏一起看才能判断「场景是不是真在涌现」,而不是被一次性写出来的。
2026-09-15 09:29:46 +08:00
d4f9a12db0 feat(memory): 场景从「声明」改为「涌现」——场面指纹自己长成场景
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。
那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事,
后续类似场面自动唤起对应记忆」的要求重做。

机制(全部取自运行时可观察量,无需模型配合、无需人工标注):

- **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx /
  tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0),
  工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。
- **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。
  加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让
  一次偶然的话题重合把两个不同场面并成一个。
- **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景
  (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」,
  给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。
- **强化**:场景每次重现 strength+1、并入新特征。
- **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低
  ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。
- **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删;
  已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。

三个必须讲清的边界:
1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度,
   「工具调得多」会被误读成「这个场面更常出现」。
2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚),
   涌现是「这轮看起来像哪个场面」。两者都进召回。
3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景,
   模型不需要知道场景这回事。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例(核心证据):
- TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 →
  同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→
  强度随重现增长、特征多条。全程没有任何人声明过场景键。
- TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被
  自动唤起(含原句),无关场面不唤起。
- TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉,
  仍在重现的场景不受影响。
- TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
2026-09-15 09:27:39 +08:00
b7e47a1b1f feat(rel): 文档层接入场景(document 节点挂场景)
场景贯穿流水线的 doc 层补完:
- 新增 TagSceneDocument,linkBlocksToDocument 里建文档节点时一并挂场景;
- RecallByScene 返回该场景下的文档 id(可枚举「这个场面有哪些文档」);
- 文档场景由**来源派生**(chan:<source>),不存冗余 Doc.Scene 字段——
  存一份会随来源改名而说谎,是同一事实的第二份真相。

go build/vet 干净,go test -count=1 ./internal/memory/ ./internal/agent/core/ 全绿。
2026-09-15 09:16:22 +08:00
9d45cd0277 fix(rel): 场景贯穿流水线到块层 + 编辑不再丢置信度/场景 + 构建默认带 onnxruntime
三件事,前两件是上一轮热部署暴露/遗留的真缺陷。

1) 热部署差点静默降级(已修)
   `make build` 之前**不带任何 tags**,而发行构建(deploy/packaging/build.sh)
   默认 HOMED_TAGS=onnxruntime,package-linux.sh 还会直接拒收非 onnxruntime 二进制。
   实测差异:33MB vs 84MB;启动日志里
   「multimodal space active: provider=chineseclip dim=512」整行消失、
   少加载一个插件(chinese-clip/qwen3vl provider 降级)、
   静态词向量退回 fallback。即「随手 make build」与「发行构建」不是同一个东西,
   而部署时无从察觉。
   修:Makefile 的 build 默认 HOMED_TAGS ?= onnxruntime(与打包脚本一致),
   构建后自动校验二进制里有没有 onnxruntime,缺了就打 WARN。
   生产已按此重新构建部署(v1.4.0+hotfix.d98bf51,已核实 provider 行回归)。

2) memory_edit 每跑一次就静默降级一次(新)
   memory_edit 是「按包含匹配 Purge + 写新三元组」,中间那一步把旧关系的
   置信度、原句、**场景引用**全丢了:置信度被重置成默认 1.0,场景钉死的记忆
   被打散成无场景。而关系复审心跳(reviewLoop)走的正是这条路——每轮复审都
   在无声地削记忆质量。
   修:编辑前用 FindRelations 精确取回旧关系,把置信度/原句/场景带到新三元组;
   新增 ScenesOfRelation。Purge(hard/soft)与 PurgeNoise/PurgeOrphans 之后
   统一清理悬空 scene_refs,SceneStats 不再说谎。

3) 场景贯穿流水线到块层(按「rel 应贯穿整条流水线」的设计)
   此前场景只到 relation/entity:块(L0/L3 一等记忆块)没有场景,于是
   「那场 QQ 对话里发过来的那张图」在场面重现时永远取不回来。
   - MemoryBlock.Scene + memory_blocks.scene 列(幂等 ALTER 迁移)。
   - scene_refs 增加 ref_text 承载字符串主键(块/文档 id 不是数值)。
     **不能只 ALTER ADD COLUMN**:唯一约束要从 (scene_id,kind,ref_id) 变成
     含 ref_text 的四元组,而 ALTER 改不了约束——旧约束会让「同场景第 2 个块」
     直接冲突(只在多块场景暴露)。改为按列探测后整表重建并搬运旧数据。
   - PutMemoryBlocks 同事务挂 scene_refs(kind='block');无场景重写不覆盖已有场景
     (否则一次无场景重写就静默抹掉挂载)。
   - RecallByScene 返回块;FormatContext 增「场景素材」段(模态 + 文本/短 digest),
     上限 3 条。
   - 生产者接线:attachBlocksToSentence 让块继承承载它的三元组的场景;
     linkBlocksToDocument 让文档的块继承文档来源场景(QQ 归档的图挂 chan:qq)。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景块(取回/同场景多块/无场景重写不抹场景/悬空引用清理)、
**旧表结构迁移**(降级成旧 scene_refs 后重开,旧数据保留且多块可写)、
场景素材注入、FindRelations+ScenesOfRelation 编辑搬运闭环。

生产:已重建(-tags onnxruntime)并原子替换 /usr/local/bin/homed + 重启,
35 插件全加载、panic/fatal=0、chineseclip 空间 active。
2026-09-15 09:03:36 +08:00
d98bf512e1 feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回
背景(实测):带条件的记忆召不回来。生产库里明明有
「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」,
但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去;
输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。

根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做)
约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形
命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object),
即使命中名字也拿不到「纯文本不用Markdown」这句正文。

改动:把「触发条件」升成一等索引维度。

- schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight),
  kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理,
  悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。
- 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、
  tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层),
  空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。
- 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」
  挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。
- 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底
  防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。
- 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在
  最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字;
  场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。
- 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message),
  并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本),
  真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。
- 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认);
  docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。
  不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。
- 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE——
  LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆
  (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。
- 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供
  PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、
前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、
清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、
agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。

生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写):
把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比:
- 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式
  --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。
- 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。
- 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
2026-09-15 08:13:52 +08:00
b37141f3f5 fix(memory): doc→graph 补回常用词闸门 + 存量噪音/孤立节点清理
问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) /
context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2——
占着热实体位、挤满召回预算,却不带任何结构。

根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统)
把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact
(去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph
蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩
validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母),
完全不挡「像名字的常用词」。

改动:
- 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 /
  context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用,
  PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。
  判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦——
  它们挡不挡是领域决策,见函数注释。
- docToTriples 接上闸门(用户点名的「文档常用词」路)。
  对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph,
  且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路
  既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。
- cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/
  其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/
  别的/其他/其余/各自/本身/方位词/部分/方面。
  「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径,
  加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句
  的相似度大小关系反了),属真回归,不留。
- graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、
PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、
PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与
模板锚点不被误杀。

存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份
到 graph.db.bak-20260915-073210):
- 噪音实体 29 个 + 其关系 59 条
- 零关系孤立实体 25 个
- 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留
  (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。
- 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。
2026-09-15 07:47:16 +08:00
94995eaa64 fix(memory): 修图记忆召回的两处能力缺失(关系重复 + 原句不回显)
针对 memory_recall / 自动注入这条图记忆召回链路的实测复核:

- Recall(depth>1) 跨层不去重:每层都用已累积的 entityIDs 查邻接,
  上一层刚产出的关系会在下一层被反复查回并再次 append。实测
  小明→小红 在 depth=2 出现两次,memory_recall 的 10 条关系预算被
  同一句话刷屏、真正的新关系(小红→小刚)被截断。改为按 relation ID
  跨层去重(实体本就已去重)。
- memory_recall 从不回显 sentence_text:工具 schema 明写「填了才能日后
  从图谱回到原文」、Recall 也已 JOIN 出句子,但输出只给实体名与关系类型,
  该字段形同虚设。抽出 formatRecallRelations,对非空原句截断 60 字附在
  关系行后;超过 10 条仍截断并提示。

测试:TestRecallWithDepth 增补去重与精确条数断言;
新增 TestFormatRecallRelations_SurfacesSentence / _Truncates。
go build/vet 干净,go test -race ./internal/memory/ ./internal/agent/core/... 全绿。
2026-09-15 06:48:36 +08:00
6afe361804 fix(memory): 记忆层启动接线/并发/落盘一致性整备
按设计方案整顿记忆系统,收敛一批"单测照不出、只在长跑生产里暴露"的缺陷:

- 启动接线:initMemoryStack 残留 `defer distiller.Stop()`,规则蒸馏
  10min 心跳启动即死。改为由调用点 cleanup 停机,并补 Stopped() 探针 +
  TestInitMemoryStackKeepsDistillerRunning / TestStartKeepsLoopRunningUntilStop。
- L0 相关性上下文:SetDenseSpace 注入稠密空间时回填已有事件的稠密向量,
  否则旧事件走稀疏余弦、新事件走稠密余弦,同一次 Prune 里两种尺度混排。
- 文档检索:QueryScored 访问计数从读锁内写移出(-race 竞争),更新后置脏,
  优雅关停可落盘、FindColdDocs 冷度判据跨重启不再失真。
- 蒸馏管线:只 flush 未落盘记录(persisted 标记)、蒸馏成功后从 raw 文件
  删除对应行、原子写文件,修重启重复蒸馏导致 mention_count 膨胀。
- 图库:全量 Recall 加实体上限(内部整备路径,防大图整表进内存);
  ClearSentenceID 补写锁;Commit/upsertEntity 计数语义注释澄清。
- 索引器:recalled 去重集加 FIFO 上限,防长跑进程自动注入越来越沉默。
- 文档/媒体注释修正;README 记忆层流程对齐跨模态召回。

验证:go build ./...、go vet、go test -race
./internal/memory/... ./internal/agent/core/... ./cmd/homed/... 全绿。
2026-09-15 06:32:36 +08:00
3a780384d0 refactor(memory): 裁剪与召回收敛到唯一入口 memoryPass
把「踢出去(prune)」与「取进来(recall)」从两处各写一遍,收敛为
memoryPass(query, trigger, prune, recall) 单一入口,统一:

- 同一份清洗后的 query(避免噪声带偏相关性打分);
- 同一次 token 预算与召回截断;
- 同一条带 trigger 的审计日志(谁、据什么触发了哪种操作)。

落地:
- 新增 memorypass.go:memoryPass + pruneByQuery(原 pruneOnInput 的执行体);
- pruneOnInput 只解析声明,执行委托 memoryPass;
- stepToolAfter 的裁剪/召回改为一次 memoryPass 调用(去掉重复的 topK 逻辑);
- 抽出 recallText,输入侧 buildTaskMemoryContext 与工具侧 recallTextFor 共用;
- 输入侧召回 query 改用 CleanInput(清洗文本),与裁剪侧同一语义;
- QQ qq_get_history 补齐声明 ContextPolicy=prune + RecallPolicy=auto
  (内容类工具:真实聊天正文既当轮用完即裁,又据正文召回)。

测试:新增 memorypass_test.go,锁死 no-op / 两轴同时生效 / 正交不互相触发 /
输入侧用清洗 query。go build/vet 干净,internal/... 全绿,qq 插件模块测试通过。
2026-09-14 23:32:41 +08:00
b792a94b84 feat(memory): 新增可声明的召回轴 RecallPolicy(与 prune 正交)
问题:召回(把 L2/L3 相关记忆注入本轮)此前不可声明、也不受任何 SDK 字段
控制——它只在任务开始时对 f.Input 无条件跑一次。于是 qq_get_message 取回
真实正文后只触发 Prune(裁剪),从不触发召回;而中断通知的 meta 文本反而
会去召回(词不对题,命中一堆泛实体)。

改动:
- SDK 新增 RecallPolicy(none|auto) 轴,落在 InjectOptions / ChannelDef /
  ToolDef 三个声明面,与 ContextPolicy 正交(裁剪 vs 召回)。默认值与
  prune 刻意相反:输入/注入默认 auto(保持既有「每条输入都召回」),
  工具默认 none(工具输出多为噪声,按需声明)。
- 内核:recallDeclared 按 注入点 > 通道 > 默认auto 解析;输入侧用它决定
  是否注入记忆索引;工具侧 ContextPolicy/RecallPolicy 共用同一份清洗后
  query,一次相关性过程分别 prune / recall;召回以 system 消息挂到消息
  末尾(同任务内替换而非累加)。
- 管线:proc RPC(inject/register + 校验)、lua 键、io payload 全量透传。
- QQ 插件:中断与 qq 通道声明 RecallPolicy=none(meta 不是内容);
  qq_get_message 声明 RecallPolicy=auto(取回正文后据正文召回)。

测试:新增 recallpolicy_test.go(core)与 proc 校验用例;
go build ./... 通过,go test ./internal/... 全通过,SDK 模块与 qq 插件测试通过。
2026-09-14 23:14:38 +08:00
29d763e210 merge: 桌面版/鸿蒙端同步阶段管道工具格滚动展示最新一条调用(feature/stage-pipe-tool-cell-clients) 2026-09-14 20:09:57 +08:00
37d6796230 feat(gui+ohos): 同步阶段管道工具格「只滚动展示最新一条调用」
WebUI 已改为工具格只露最新一条。桌面版与鸿蒙端是同一套运行态面板的
镜像,一并跟上,避免三端口径不一致:

- cmd/gui:renderRuntimePanel 工具格只渲染最新条目 + 本轮累计次数;
  overview 是整块 innerHTML 重建,故动画类由 state.toolFlash 单次驱动,
  避免任何重渲染都闪一下。
- cmd/ohos:RuntimePanel 工具格改为 latestEvent() + totalCount(),
  不再 ForEach 追加。已用 hvigor 实测 BUILD SUCCESSFUL。
2026-09-14 20:09:57 +08:00
0493960985 merge: webui 阶段管道工具格改为滚动展示最新一条调用(feature/webui-tool-cell-scroll) 2026-09-14 20:07:33 +08:00
bc32fbbc98 feat(webui): 阶段管道的工具格改为滚动展示最新一条调用
「工具」是循环格:一轮里可能调几十次工具/输出通道。此前每一次都追加成
chip,这一格被撑成一长条,反而看不出「现在在调什么」。改为固定一行的
滚动视口——只留最新一条,右侧给出本轮累计次数;新调用到来时旧条向上
滚出、新条滑入(morph 就地改文本不会重放 CSS 动画,故摘类 + 强制 reflow
+ 重加类)。并给 chip 名称加 .rt-chip-t 承接省略号,窄框不再硬切半截。

配套 TestStagePipelineToolCellShowsLatestOnly 钉住该口径。
2026-09-14 20:07:29 +08:00
f3fa0e8f5b feat(ohos): 运行态面板 —— 阶段管道 + 中断队列(落在设置页二级明细顶部)
接上一条腿:桌面版已同步,这次补鸿蒙端缺的运行态面板。
落点按之前的建议放在「设置 → 运行状态」二级明细页最前:明细卡回答「内核有哪些
东西、多少」,运行态回答「现在在干什么」,后者是进这个页面最先想看的。

## 新增

- `common/StageTrail.ets`:阶段轨迹单例(SSE 驱动)。由 ChatSse 的 stage 分支
  喂入,面板读取。**不并进 StatusStore**:轨迹来自 SSE 流,与 /status、/kernel
  的轮询是两条独立数据源,生命周期与失败模式都不同(SSE 断连不该让状态卡变空,
  状态轮询失败也不该清掉轨迹)。七阶段归并成五格,同阶段同一条累加计数,
  2.5s 无新事件自动回空闲。
- `components/RuntimePanel.ets`:等大表框面板。
  - 四个数字块(排队/中断/栈/子代理)
  - 阶段管道:每格 = 阶段名 + 本阶段本轮事件;当前阶段整框点亮
  - 中断队列:5 格(L4/L3/L2/L1 + 排队),级别色贯穿框头/槽位/描边;
    有积压整框描边点亮;排队队列虚线框区分(另一类别,不是另一优先级)
  - 格槽固定可见:深度为 0 时也有形状,不会剩一片空白

## 改动

- `StatusStore`:新增 `/runtime` 采集与 `RuntimeSnapshot`/`RuntimeQueue`
  (明细与运行态分开取、分开存);失败保留上一次快照,旧后端无此端点时
  面板显示「运行态数据不可用」。
- `ChatSse`:stage 分支先喂轨迹,再管聊天侧角标。
- `SettingsPage`:`stageTrail.init()`。
- `StatusCards`:二级明细顶部渲染 `RuntimePanel()`。

## 关于宽度

模拟器(API 24,1256x2760 ≈ 360vp 宽)上 5 框一行会让「内核独占」这类标签被
挤成省略号,所以按项目已有的 `isWideScreen` 分两支:宽屏 5 框一行,手机 3+2
(补一个占位格保证框宽对齐)。两档都是等大框。

## 验证

- `hvigorw assembleHap` → **BUILD SUCCESSFUL**;`clean` 后全量重建,
  本次新增/改动的 6 个文件 **零 ArkTS 告警**。
- 未上机实测:本机签名 profile 无法授予 `ohos.permission.READ_PASTEBOARD`
  (module.json5 里已有的一项,非本次改动),`hdc install -r` 报
  `error: install failed due to grant request permissions failed`。
  没有为此卸载设备上的应用(会丢用户已存的连接配置),也没有改权限列表
  (属产品决定)。要上机的话,我可以临时去掉那一条权限打个一次性包验证。
2026-09-14 19:05:46 +08:00
2163a7a092 feat(gui+ohos): 同步 WebUI 总览改版 —— 桌面版补运行态面板,两端补内核身份与开源许可
WebUI 那边这几轮改完,桌面 app(Electron)与鸿蒙 app(ArkTS)要跟上。
先摸了底:**两个 app 此前都没有运行态面板**(阶段管道 / 中断队列),
所以这不是"移植",是新做;emoji 图标两端本来就没有,无需处理。

## 桌面 app(cmd/gui/renderer)

1) 运行态面板(新)—— 与 WebUI 同一套设计语言:**等大表框**
   - 数据源 /api/v1/runtime(此前只拉 /status 与 /kernel)。
   - 四个数字块沿用本 app 的 statCard(排队/中断/栈/子代理)。
   - 阶段管道:5 个等大框,框内是本阶段本轮发生的事件 chip;
     当前阶段整框点亮。图标一律内联 SVG(含「工具会循环」标记)。
   - 中断队列:5 个等大框(L4/L3/L2/L1 + 排队)一行排开,
     级别名 16px/800、深度 26px/800、可见格槽(0 时也有形状);
     有积压整框描边点亮;排队队列虚线框区分(另一类别,不是另一优先级)。
   - stage SSE 事件接上轨迹(rtTrailPush,同阶段同一条累加 xN),
     2.5s 无新事件回空闲。
   - 列宽 repeat(auto-fit, minmax(100px,1fr)):窄容器也保证 5 框一行,
     不出「4 个 + 1 个」的孤行。

2) 版本身份(修一个真缺陷)
   旧实现是 `s.version || "0.1.0"`:拿不到数据时**向用户展示一个不存在的
   版本号** 0.1.0。改为取 /kernel 的 build(-ldflags 注入的真实版本/commit),
   并在版本号下补一行「内核名 · commit」。

3) 开源许可卡(新)
   协议标识 + 协议全文 + 源码仓库 + §13 说明;网络条款按标识是否含 AGPL
   决定是否渲染,不硬写协议名。

## 鸿蒙 app(cmd/ohos/HomeAgent)

4) StatusStore 解析 /kernel 的 build:补 内核版本 / Commit / SDK 兼容 /
   构建时间 到「内核」分组;K_VERSION 统一成 v<版本>,新增 K_BUILD 广播
   「内核名 · commit」给摘要卡(版本号本身没有内核身份)。

5) 新增「开源许可」分组:许可协议 / 协议全文 / 源码仓库 / 网络条款说明。

## 验证

- 桌面:共享浏览器加载 renderer(桩掉 preload 桥)后喂真实形状数据渲染 ——
  版本卡 `v1.4.0+hotfix.f89e57a` + `HomeAgent · f89e57a`;管道
  `输入=输入 | 行动=思考 | 工具=qq_get_message x3 qq* | 输出=生成 | 结束=完成`;
  队列 `L4:0 | L3:3 on=3[active] | L2:2 on=2[active] | L1:0 | 排队:2 on=2[active]`;
  许可卡两个链接均为 target=_blank + rel=noopener noreferrer;无 emoji。
  node --check 通过。
- 鸿蒙:/opt/huawei/command-line-tools/bin/hvigorw assembleHap **BUILD SUCCESSFUL**。

## 未做(下一条腿)

鸿蒙端的运行态面板(阶段管道 + 中断队列)**还没有**。它比桌面端贵:
需要新组件(5 框管道 + 5 框队列)、把 /runtime 快照接入 StatusStore,
以及把 SSE 的 stage 事件从 ChatSse 的 sink 引到状态侧——后者是接口改动。
「状态」Tab 此前已被有意删除(并入设置页 + 二级明细),面板落点也要定
(建议放二级明细页顶部)。桌面的实现可直接作参照。
2026-09-14 18:53:25 +08:00
0faf9fb4e8 style(webui): 队列/管道列宽下限收到 100px,消掉「4 个 + 1 个」孤行
118px 时容器 540px(小窗口侧栏展开的宽度)只放得下 4 列,第 5 个框
落到第二行且后面四个位置全空 —— 正是「看着空」的那种观感。
收到 100px 后 540px 也能一行放下 5 个;配套给 .rt-qmeta 加 wrap,
窄框里「登记/抢占」两枚迷你条换行而不是撑破框。

五档实测(1400/1100/900/700/480):1400/1100/900/700 都是 5 框一行
(200/140/100/120px),480 为 3+2;均无横向溢出,框内元素无越界。
2026-09-14 18:45:36 +08:00
f89e57a732 feat(webui): 阶段管道与中断队列统一为等大表框,字体加大加粗
问题:阶段管道是「小圆点 + 一条连接线 + 9px 小字」,中断队列是五行
「名字 | 进度条 | 元数据」的扁条 —— 两块都远小于旁边的 KPI 框,中断队列四级
全为 0 时四行几乎全是空白,既占高度又难看。

改法:两块统一成同一套视觉语言 —— **等大表框**(与 KPI 同一种骨架)。

阶段管道(.rt-pipe-row / .rt-pipe-cell)
- 5 个等大框,框内 = 图标 + 阶段名 + 本阶段本轮发生的事件 chip。
- 阶段名 9px/500 → 13.5px/700;图标 12px → 15px。
- 当前阶段整框点亮(accent 描边 + 淡底 + 内阴影),不再靠一个小圆点表意。
- 删掉圆点、连接线、滑块把手那套已死的 CSS(.rt-pipe-track/.rt-pipe-knob 等)。

中断队列(.rt-queues / .rt-qcell)
- 五行扁条 → 5 个等大框(L4/L3/L2/L1 + 排队),一行排开。
- 框头级别名 16px/800、深度数字 26px/800(原来深度只是行末一个小数字)。
- 级别色同时用在框头、点亮格槽、有积压时的整框描边 —— 一处配色贯穿。
- 排队队列无级别,用虚线框与四级中断区分(另一**类别**,不是另一优先级)。
- 保留可见格槽:0 时也有形状,不会变回一片空白。

列宽自适应:两块共用 repeat(auto-fit, minmax(118px, 1fr)),
118px 而不是 150px 是为了让 640–740px 容器(窄屏侧栏收起后的宽度)也能
5 个框排一行,不出现「4 个 + 1 个」的孤行。chip 补 min-width:0 以免撑破窄框。

顺带清掉一条无用的旧 .rt-chip 规则(与新规则重名且只被阶段事件用到)。

实测(现网 CDP,1400/1100/900/700/480 五档):
- 1400/1100/700px:5 框一行(200px / 140px / 120px);900/480px:换行且框仍等大
- 五档均无横向溢出
- 字号:阶段名 13.5px/700,级别 16px/800,深度 26px/800
- 注入一轮轨迹:输入=输入|行动=思考|工具=qq_get_message x3 qq*|输出=生成|结束=完成
- 注入 L3=3/排队=2:L3 描边 rgba(255,166,87,.55)、框头与点亮槽同为琥珀色;
  排队绿框;空的 L4 保持默认描边(首次读到的默认色是 0.25s 过渡中途,非 bug)
- chip 未溢出所在框;总览/侧栏/顶栏渲染文本无 emoji
2026-09-14 18:38:08 +08:00
37924b295b feat(webui): 总览底部源码区改为独立的「开源许可」框(协议 + 全文 + 源码)
此前总览底部只在 KPI 卡里挂了一行小链接(.ov-foot),既看不出受什么许可
约束,也看不出 AGPL 网络服务场景下的义务。现在单独成一张卡:

  开销许可
    许可协议     AGPL-3.0-only   → GNU 官方全文
    源码仓库     <source_url>    → 仓库
    网络服务条款(§13):把修改后的版本作为网络服务对外提供时,
                        必须向使用者提供取得对应源码的途径。

内核侧(License 是新事实,不能只靠前端写死):
- internal/meta:新增 License(SPDX 标识)与 LicenseURL,都可 -ldflags 覆盖。
  LicenseURL 默认指向 GNU 官方 AGPL-3.0 全文页 —— 与仓库托管方、分支名、
  文件路径都无关,换仓库/换分支不会失效。
- internal/sdk/status.go 的 BuildStatus:新增 License / LicenseURL 两个
  json 字段(additive,旧消费方忽略未知字段即可)。
  注意 internal/sdk 不受公开接口冻结约束(docs/git-branching.md §六),
  本次未触碰 third_party/homeagent-sdk/sdk/。
- internal/agent/core/status.go:从 meta 填充。

前端:
- 骨架里 .ov-foot 换成独立的 <div class="card" id="ov-legal">(放在 KPI 卡之后)。
- 网络条款那一段按许可标识是否含 AGPL 决定是否渲染,不硬写协议名。
- 内容对一次构建是常量,沿用 __html 比对,填一次后不再重建(不引入闪烁)。

验证(现网 1400x920,CDP 实测):
- /api/v1/kernel 的 build 现在带 license="AGPL-3.0-only"、
  license_url="https://www.gnu.org/licenses/agpl-3.0.html"
- 卡片为真框:class=card、border 1px、radius 14px;总览结构 = rt-panel | card | ov-legal
- 两个链接均为真 <a>,target=_blank + rel=noopener noreferrer
- updateOverview() 再跑一次,卡片子节点身份不变(不重建、不闪)
- 回归:KPI 版本副行、阶段管道 5 节点/5 列/6 SVG、队列 5 行×5 格 均正常
- 无横向溢出;总览/侧栏/顶栏渲染文本无 emoji
- go vet 干净;agent/core、plugins/webui、plugins 全量测试通过
2026-09-14 17:48:54 +08:00
bead5746c3 feat(webui): 总览显示内核身份、图标全 SVG 化、队列改格槽、阶段管道下方按阶段列事件
四个问题一起改(都出在总览/内核页的展示层,不动内核逻辑):

1) 内核版本不再"看不见"
   - 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩一个 "v1.4.0",分不清
     是哪个内核、哪次构建。现在 KPI 值给版本号,下面补一行副行
     「HomeAgent · <commit>」(.ov-sub)。
   - 内核页此前**完全没有构建信息**,现在补一张「构建」卡:内核名/内核版本/
     Commit/构建时间/SDK 兼容/源码链接(AGPL §13 的入口页)。

2) 任何位置都不再用 emoji/符号字符充当图标
   - 新增 RT_ICO(纯内联 SVG,24x24 / currentColor),替换:阶段节点的循环
     标记(原 ↻)、轨迹 chip 的工具/输出标记(原 ⚙/⇥)、"立即运行"(原 )、
     工具卡与思考卡的下拉箭头(原 ▾)。
   - CSS 注释里的同类字符一并去掉。

3) 队列不再"空着只有文字"
   - 原来画的是宽度百分比进度条:深度为 0 时宽度就是 0,五行只剩文字。
     改成 rtSlots 的「车位」式格槽(至少 5 格、最多 16 格,按全场最大深度
     缩放),0 时仍有可见形状,占用多少一眼可数;超出格数时给 +N。
   - 修掉一个真实的 DOM 结构错误:第五条「排队」队列被写在 .rt-levels 闭合
     **之后**,且后面多一个 </div>,多出来的闭合标签会提前关掉祖先节点、
     把整块布局撞歪。现在它回到容器内。

4) 每一步管道的事件显示在管道下方对应阶段列里
   - 原来是一条拍平的 chip 序列,看不出"这件事发生在哪个阶段"。
     现在 .rt-pipe-cols 与上面的阶段节点共用 5 等分栅格,事件按 g(阶段组)
     分列落位;实测列中心与节点中心偏差 ≤ 2px。
   - 轨迹覆盖全部阶段(输入/思考/工具/输出/完成),同阶段重复的同一条
     累加 ×N 而不是刷屏(rtTrailPush)。空列显示一个弱化的「无」。

验证(现网 1400x920,CDP 实测):
- 版本 KPI = v1.4.0+hotfix.0fd4fb1 / HomeAgent · 0fd4fb1;内核页构建卡齐全
- 注入一轮轨迹:col0=输入 col1=思考x2 col2=qq_get_message x3/qq/cmd_run
  col3=生成x2 col4=完成,active 节点=工具,×N 计数正常,3 个 chip SVG
- 队列 L3=3/5、排队=2/5 点亮,L3 取到琥珀色 rgb(255,166,87)
- 页面无横向溢出;总览/侧栏/顶栏/内核页渲染文本无 emoji
- go vet 干净,internal/plugins/webui 测试通过
2026-09-14 17:40:54 +08:00
21db84e8dc fix(webui): 拓扑 +N 提示改用输出带顶部锚点,修提示串行
无输出通道的 agent 那条带上 outTop 在渲染时才确定,而提示行仍在用
循环变量 y(已累加到别的带),所以「+N 更多」会跑到隔壁带上。
改用该带自己的 outTop,并把基线从 +13 收到 +11(紧贴最后一行)。
2026-09-14 17:40:46 +08:00
0fd4fb1f7a fix(webui): 拓扑按实测容器宽布局 + 字号/截断,修间距失衡与文字难读
三处实机问题:
1) viewBox 固定 640 而容器 ~920,浏览器按 'meet' 把内容顶到左上、右侧空出一大片
   —— 观感就是「间距不对」。改为 viewBox 宽 = 实测容器宽、width 用像素值,
   缩放恒为 1(已用 getScreenCTM().a 验证)。
2) 文字全是 9-11px + 低对比度硬编码色(#8b90a5)→ 难读。字号提到 10.5-12.5px,
   fill/font-size 改走 .tp-* 类,颜色交给 --text-primary/--text-muted 主题变量。
3) 列短的一侧原来顶在带上半、节点居中,连线又长又歪;长通道名还会溢出到邻居身上。
   现在两列在带内各自居中、节点块高度参与带高计算(单行带不再把节点名压到下一条带),
   长名按估算宽度截断加 …,完整名放 <title> 悬停可见。
另:rtSpark 用的 _rtEdgeIn/_rtEdgeOut 键与取值方式未变,光点动画照旧。
2026-09-14 16:51:41 +08:00
5ea87a498a chore(vendor): 同步 qq 插件权限身份绑帧修复(sdk cfa72df) 2026-09-14 16:45:17 +08:00
9eebd96ab7 fix(core): 插件拒绝工具时把 ctx.Response 的理由透给模型
before_toolcall 的 ctx.Response 是插件写的**拒绝理由**,但工具结果被写死成
「工具 X 已被插件拒绝」,理由从不到达模型——模型于是不知道能不能重试,
会反复重试被拒的调用。抽出 denialResultText 并在有理由时原样透出。
2026-09-14 16:45:04 +08:00
c252915083 feat(webui): 阶段管道改「循环 + 本轮轨迹」,区分工具/输出调用;再砍总览文字
jianf:阶段管道像无记忆的单向滑块,但一轮里会多次 toolcall、也可能多次输出;
且没区分 output_* 调用与普通工具调用;总览仍有一大坨文字。

- 阶段管道不再是单向滑块:#
  画成 输入 → 行动 ⇄(工具↻) → 输出 → 结束 的循环结构,当前阶段高亮;
  下面用一排 chip 记**本轮真实发生过的序列**(on_input 重置、before_toolcall 追加、
  after_output 收尾,最多 24 条)。工具调用会反复出现,循环因此可见。
- 区分调用类型:普通工具 chip 前缀 ⚙(青),output_* 输出通道调用前缀 ⇥(accent 色),
  两者配色与图标都不同。
- 文字再收缩:删掉「累计:入队/执行/抢占/挂起/背压」整行;队列标签由
  「L4 内核独占…」压成 L4/L3/L2/L1/排队(原描述进 title);各段标题压成
  「队列」「栈」「拓扑」;KPI 块标签压成 排队/中断/栈/子代理。

顺带(同类问题):CLI /stop 是人在终端当场下的指令,优先级由默认 L1 提到 L3。
2026-09-14 16:31:37 +08:00
36b577bff8 fix(webui): 总览改静态骨架 + 图标 KPI,彻底去掉整页重建的闪烁
jianf:仍严重闪烁;应彻底摒弃增量重建,用动态图标 + api 数据展示;主页文字太多。

- renderOverview 从「每次 innerHTML 重建整页(含运行态面板)」改成**首帧建一次
  静态骨架**,之后 renderAll(每 15s 一次)只 updateOverview —— 只写 textContent
  与类名,一个节点都不重建。实测连续两次 renderAll 后 #ov-kpis / #ov-status /
  #rt-panel 仍是同一批 DOM 节点,这是"不再闪"的直接判据。
- 主页文字大幅收缩:删掉「系统概览 / LLM 状态 / 记忆状态 / 运行时」四张 kv 文字卡,
  改成一排 8 个图标 KPI(状态/运行/插件/版本/LLM/记忆/文档/运行时),状态用彩色
  圆点表达,其余只留数字 + 两字标签。
- 运行态面板不再被 renderOverview 清空(去掉 _rtSig=null 与重建),保持连续更新。
2026-09-14 16:16:14 +08:00
7768168dd2 chore(sdk-vendor): 同步 qq 插件的消息合并改动(对应 SDK 仓 a01fe21)
本仓 vendored 了 SDK 的部分文件(third_party/homeagent-sdk,经 go.mod replace
引用),其中 example/qq/plugin.go 被跟踪。SDK 仓的 qq 消息合并提交同步过来,
保持 vendored 副本与 SDK 仓一致。
2026-09-14 16:11:22 +08:00
97111778a9 feat(webui): 数据查询 API + 前端 keyed 对账,去掉「局部重建」的闪烁
jianf:局部重建的闪烁几乎消不掉,应暴露数据查询 api,前端轮询后增量更新视图,
聊天记录也用这套。

后端(数据查询 api):
- ChatMsg 增加 seq(服务端单调递增、随记录落盘);老记录加载时补 1..n,重启不重编号。
- /api/v1/chat/history 增加 after=<seq> 增量通道:只回 seq 更大的消息,返回 last_seq
  作下次游标;一批超 limit 时回**最旧**的一批(回最新会把被挤掉的旧消息永久漏掉)。
  普通响应也带 last_seq,客户端首次全量后据此初始化游标。
- 测试 TestChatHistoryIncrementalAfterCursor 钉住「不重不漏 + 截断停在返回的最后一条」。

前端:
- 新增通用 morph():按「子节点位置 + nodeName」递归对账 DOM,同名节点复用、只同步
  变化的属性与文本。运行态面板的 put() 由 innerHTML 重建改为 morph —— SVG 圆环、
  队列条、数字块这些未变节点不再被替换,CSS 过渡与动画不再从头播。
- 聊天列表改用 keyed commitChatList():按 data-key(服务端 seq / 本地临时 key)对账,
  未变消息节点一个字节都不动,只替换真正变化的那条。
- syncChatFromHistory 改走游标:pollChatIncremental() 用 after 拿增量 + tail=1 探尾部
  原地更新(工具调用/最终文本是原地改的,不产生新 seq);聊天页可见时 3s 轮询。
2026-09-14 15:56:27 +08:00
f722498dba feat(webui): 默认配色改黑白 + 设置页新增「外观」区
jianf:默认配色太花,且配色要能在设置页调。

- 新增 mono(黑白灰)配色并设为默认:未选过配色的 localStorage 一律
  data-color=mono。黑白下连拓扑归属配色也走灰阶,不至于只剩一张彩图。
- accent 的所有硬编码 rgba(255,127,172,x) 收敛成语义变量 --accent-rgb,
  各配色块(sakura/cyan/violet/emerald/amber/blue)各自声明自己的 rgb,
  于是换配色时阴影/描边/阴影辉光一起换,不再残留粉色。
- 设置页新增「外观」区(侧栏最前):主题(浅/深)+ 7 个配色圆点 +
  背景图 URL/模糊。原先只有侧栏底部一个调色盘图标,找不到。
- 切配色时强制重画运行态(置空 _rtSig),否则拓扑会停在旧色。
2026-09-14 15:44:17 +08:00
27a7a3b439 chore(docs): 收编 QQ output_send 循环缺陷记录,标记已由 max_tool_turns 修复
仓库根目录的 problem.md(未跟踪)是一份 QQ `output_send` 回声/无限循环的
定位记录,状态写着「待修复」,但核心早已有轮次上限(core.agent.max_tool_turns,
默认 10,task.go 到达即强制收尾,测试 TestMaxToolTurns_CapsRunawayLoop)。
把它移进 docs/ 并更新状态,避免一份过期结论长期挂在根目录;同时删掉根目录的
临时基准脚本 tmp_fusion.py。
2026-09-14 15:35:31 +08:00
e4d69fa140 feat(webui): 总览改版 —— 阶段管道滑块 / per-agent 负载环 / 通道→agent 拓扑与光点
总览页此前是一堆数字与文字块,看不出「这一轮走到哪、谁忙、消息从哪进哪出」。
本次把运行态面板改成以图形为主:

- 阶段管道:七阶段滑块,由 SSE stage 事件驱动,当前阶段高亮、滑块滑过去;
  一轮结束(after_output 或 2.5s 无事件)自动回到空闲,不做假动画。
- 队列与中断栈:沿用五条进度条(L1–L4 + 排队),中断栈补一条深度进度条。
- Agent 拓扑:改成「每 agent 一条横带」——左 inputch、中 agent 节点(圆环 = 负载)、
  右 outputch,连线即路由;删掉旧的「归属框 + 单个内核盒」画法(看得出哪个子接了哪条输入)。
- 光点动画:channel_input(新增轻量 SSE 事件)沿 inputch→agent 连线跑;
  agent_output 沿 agent→outputch 连线跑。用 SMIL animateMotion,不需要 rAF 循环。
- 负载:由该 agent **自己的**调度器积压(排队 / 四级中断 / 中断栈)按级别加权折算,
  环形图展示。为此把驻留子的调度器积压透出到状态面(SDK 纯追加字段)。

后端:sdk.ResidentStatus / core.ResidentInfo 增加子 agent 调度器积压四项;
WebUI SSE 增加 channel_input 轻量事件(只带通道名与 agent id,不带正文)。

顺带收口对话区视觉(页签改分段控件、消息间距/气泡区分、输入区分隔线)。
2026-09-14 15:33:23 +08:00
0fda210e8b feat(webui): 视觉重做第一轮 —— 侧栏图标化、顶栏标题化、卡片/行/按钮收口
反馈是「丑死了」,没有具体项,所以按「哪儿在制造廉价感」逐条改:

1. **侧栏只有文字**:6 个导航项各加 24×24 stroke 图标(currentColor,随选中/hover 变色),
   10px 间距、13.5px/500 字重、圆角 10px 的药丸命中区;品牌字改 sakura→frost 渐变。
   → 空荡荡的 16rem 栏终于有了骨架。

2. **选中态把文字整体右推 + 发光文字**:原来用 `border-left: 3px` 画选中条,
   hover 时整行抖 3px;还加了 text-shadow 光晕。改成 `box-shadow: inset 2px 0 0`
   (不占布局)+ 取消光晕 + 选中加粗。这类「一像素级不稳」是廉价感的主要来源。

3. **顶栏只有一行灰字面包屑**:把当前页做成 15px/650 的标题色,面包屑碎片
   压到 12.5px 且降透明度;顶栏 48→56px。页面总算有「入口」。

4. **卡片 hover 整页上下浮**:`.card:hover` 去掉 `translateY(-1px)`(十几张卡一起
   浮,视线扫过像在抖),只提亮阴影与描边;padding 20→22、卡片间距 16→18。

5. **卡片标题没有章节信号**:`h2` 前加 3×14px 的 sakura→frost 渐变短竖。

6. **kv-row 是文字墙**:flex + 固定 180px 键列 → grid `minmax(110px,180px) 1fr`,
   行高 8px、负外边距 hover 高亮、末行去分隔线、数值 `tabular-nums`(端口/计数上下对齐)。

7. **满屏药丸按钮**:`.btn` 圆角从 999px 收到 10px(与卡片同一套圆角),
   padding/font 微调;`.btn-sm` 11→11.5px 提升可读性。

8. **内容区靠左铺满**:`.container` 居中 + `max-width: 1240px`(超宽屏摊满整个
   屏幕是「后台模板」的典型观感)。

i18n 有个坑:切语言那段是 `el.textContent = …`,所以 `data-i18n` 必须从 `<a>` 挪到
内层 `<span>`,否则切一次语言图标就被抹掉。实测 ZH→EN→ZH 图标都在。

验证:go build/测试绿(webui + sdk + agent + plugin);真机 1440×900 六页截图对比
(侧栏图标、渐变品牌、标题竖条、grid 行、居中内容区均生效)。
2026-09-14 14:29:17 +08:00
cdb2ea2207 polish(webui): 拓扑图只在容量非默认时写数字(去掉十几行「默认」文字) 2026-09-14 11:24:34 +08:00
e8d7bb4c06 feat(webui): 通道归属合并进拓扑图,整张图改 SVG(少文字、多图形)
上一版把「通道分配(按归属)」单开一段,等于把同一件事拆成两张表——
而通道属于谁是**拓扑的一部分**(左边这些输入口分别被谁接管),拆开反而
看不出关系。按用户要求合并,并整体改成图形化:

- 整张拓扑用 SVG:左侧按归属画出输入通道容器(根=青色虚线框,驻留子=彩色
  实线框并标轮次/上下文满),→ 汇集母线 → 内核 → 输出母线 → 右侧输出通道。
  **连线即路由**。
- 信息全部改用图形编码:归属=容器/配色、容量=节点内细条(默认容量不画填充)、
  输出能力=五个彩色圆点(text/file/image/audio/structured)。
- 文字降到最少:去掉四个数字块的副标题、排队队列那行只留 "FIFO"、
  通道行不再写"回程由来源决定"这类说明。
- 段名改为「通道拓扑(连线即路由;左框 = 归属)」。

验证:node --check 通过;go build ./... 干净;webui 测试全绿。
2026-09-14 11:22:20 +08:00
75f377fd4d fix(remotedevice): 心跳 pong 忘了 Flush —— 修「设备通道每 60 秒掉线重连」
真因(实测定位):服务端 writePong 只调 writeFrameHeader,**不 Flush**。
pong 只有两个字节,且设备空闲时没有任何别的写会顺带把 bufio 缓冲刷出去 ——
于是 pong 永远留在服务端缓冲里。

链路:客户端每 30s 发一个 ping(pingLoop)→ 服务端算出 pong 却没发出 →
客户端的读循环设的是「2 倍 ping 间隔」读超时(默认 60s)→ 每 60 秒准点
i/o timeout → 桥断开 → 3s 后重连 → 服务端 markOffline 注销 outputch,
重连后再注册。

生产日志就是这个指纹(online :20 → offline 下一分钟 :20 → 重连 :23,
连续数小时无一次例外);面板上表现为设备通道/工具凭空消失又出现,
/devices 列表跟着闪。

改法:writePong 复用 writeFrame(它 Flush)。另把客户端读循环退出时的
静默 return 改成带错误与 opcode 的日志 —— 此前断线真因在设备侧完全不可见,
只能靠对端日志倒推,正是这次排查一开始卡住的地方。

回归用例 TestWSPingGetsPongWhileIdle:只发一个 ping,随后什么都不发,
要求 2s 内必须收到 pong。**反向验证过**:把修复改回 writeFrameHeader,
用例即以 `read tcp ...: i/o timeout` 失败(与生产症状一致)。
2026-09-14 11:15:21 +08:00
8756f8d77f fix(webui): 通道归属把「根 agent id」与驻留子分开(根不再被标成「驻留子 main」)
实测(创建一个驻留子 uitest 并把 timer 划给它)暴露的归类错误:
inputch 的 owner 在登记表里可以是**根 agent 自己的 id**(如 "main")——
child/<id> 这条就是 owner="main"。前端只按「owner 非空」判为驻留子,
于是根自己那条被标成「驻留子 main」,而同一条通道在 residents 里根本不存在。

改法:
- /api/v1/runtime 补 agent_id(根 agent 的 id);
- 前端把 owner == 根 id 与 owner == "" 归一成同一组「根 agent / 内核默认」,
  只有既非空又非根 id 的才是子容器。
2026-09-14 11:08:27 +08:00
d502fc1bf5 fix(webui): 运行态面板逐段更新(真修「一闪一闪」)+ 补第五条排队队列
1) 上一版只做了整体签名缓存,实测仍会重建:设备通道列表本身就在来回变
   (远程设备通道 11→9 条),签名一变就整块 innerHTML,没变的段落(含条
   transition)也跟着推倒重来——视觉上仍是闪。改法:外壳只建一次,之后
   **逐段**(tiles/levels/stack/owners/topo)比较 HTML,只替换真正变了的那段。

2) 设计是「四条中断队列(L1–L4)+ 一条排队队列」= 五个队列,面板只画了四条:
   排队输入这条线在运行态里凭空消失。补第五行「排队(无级别)」,用中性色 +
   虚线分隔(它不是优先级,而是另一**类别**),并把它计入条形归一化基准。
   段标题从「中断队列(按级别)」改为「队列(四级中断 + 排队)」。

3) 顺带把累计计数(入队/执行/抢占/挂起恢复/拒绝/背压)显式列在数字块下方——
   背压是新指标,之前只能看接口看不到面板。

验证:node --check 通过;go build ./... 干净;webui/core/sdk 测试全绿。
2026-09-14 11:03:37 +08:00
43536ba326 fix(webui): 运行态面板不再闪、通道分配带归属(含驻留子)、改图形化
三个用户可见问题,逐个说明根因与改法。

1) 首页「一闪一闪」——运行态每 3s 轮询一次,renderRuntime 无条件重建
   #rt-panel 的 innerHTML:数据没变也把整块 DOM(含各级条的 transition)
   推倒重来。改法:缓存数据签名(**不含 uptime**——它每秒都变,带上等于没缓存),
   签名相同直接 return,一个字节都不动。另:renderOverview 会整块重建
   #rt-panel(面板本身是空的),所以那里必须让签名失效,否则空面板填不上。

2) 通道分配只显示内核/根 agent,看不见驻留子——根因是状态面只暴露了设备能力
   (KernelStatus.Channels,来自 iom.ListChannels),而「这条输入归谁」是
   ChannelRegistry 的属性(InputChannel.Owner/Capacity/Output),从未出过内核。
   而登记表本来就是根 agent 与驻留子**共用同一份**,所以数据一直都在,只是没画。
   改法:KernelStatus 新增 InputChannels(+ sdk.InputChannelInfo),
   /api/v1/runtime 带出 input_channels;前端把它按 owner 分进「归属容器」,
   驻留子即使一条 inputch 都没划到也照样出现在图里(否则"子存在但看不见"
   与"子不存在"无法区分),并显示其 allowed_outputs / 轮次 / 上下文满标记。

3) 「这些信息明明可以图形化」——四级中断的登记/抢占由纯文本改成并排迷你条;
   通道分配用归属容器 + 容量滑块(轨道/填充/把手/读数),并把回程通道、
   注册插件做成胶囊标签。设备能力拓扑(原有)保留。

验证:go build/vet 干净;go test ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;node --check dashboard.js 语法通过。
TestRuntimeEndpoint 扩展为同时钉住 input_channels 的归属与「驻留子划走的那条」。
2026-09-14 10:55:45 +08:00
c321388a21 fix(scheduler): 安全点重新求值中断队列 + 抢占/背压计数修正 + 停机补终态
对照 docs/zh/input-scheduler-design.md 原文修四处(前两处是真缺陷,后两处是
观测面与设计承诺不一致),均配回归用例:

1. §4.3/§5.2「临界区结束后的第一个安全点重新求值」此前**没有实现**:
   全仓唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
   自然结束。可达症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却窗内 →
   入队 → 再也不会被求值。修:runTaskSteps 的安全点先 rearmPending()——
   判据与 registerInterrupt 完全同一套(canPreempt + 冷却 + 临界区闸门)。

2. PreemptsByLevel 的语义是「进入 immediate 槽的次数」,但计数发生在
   setImmediateLocked 之前:immediate 是单槽,同一安全点前到达的两条同级中断里
   被降级的那条也被计成抢占。修:setImmediateLocked 只在真占住槽时返回 true,
   计数随之为真;同时把「降级入队」的责任收归调用方,消除同一任务被入队两次的
   隐患(实测该隐患会让中断任务执行两次、Executed 虚高)。

3. 状态面 Preempted 此前拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
   修:Preempted = Σ PreemptsByLevel[1..4]。

4. §4.4/Q4「满时阻塞发送方 + 计数并打日志」只做了阻塞:pumpInbox 满时直接返回,
   一个字都不计。修:新增 Stats.Backpressure(+DTO 字段) 与只报一次的状态翻转日志;
   同时显式处理 enqueue 返回值(静默丢弃会让同步调用方永久挂起)。

另:Stop() 停机前排空待办——给从未运行与已挂起的、带 ResponseCh 的任务补
skipped 终态,否则 cli/clawhubadapter 这类无超时同步注入方永久挂起(§7 I5、§11.3 X4)。
emitResponse 的 ResponseCh 写入改为非阻塞 + 告警,避免一行写错就卡死调度器 goroutine。

验证:go build/vet 干净;go test -count=1 ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;go test -race ./internal/agent/core/ ./internal/sdk/ 干净。
新增 scheduler_rearm_test.go 六个用例(冷却期满重新求值/同级降级不计数/Preempted 求和/
停机补终态/背压计数与翻转/pumpInbox 满计数)。
2026-09-14 10:39:25 +08:00
1ce3a917a5 feat(status+webui): 运行态图形化 —— 排队/四级中断队列/中断栈/驻留子/通道拓扑
需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 agent 数量;这些要向**内部 SDK 暴露接口**,供 WebUI 等应用
展示;通道划分也要能画出来。

## 一、内核状态面(internal/sdk,内部 SDK,不受公开 SDK 冻结约束)

* SchedulerStatus 补:
  - interrupt_queues[5]:**四级中断队列各自的深度**(下标即级别 1..4,下标 0 恒 0,
    这样 level 能直接当数组下标用)。此前只有 pending_interrupts 总数,
    看不出"堵在 L1 还是 L4"——四级是抢占优先级,堵在哪级是完全不同的运行状态。
  - immediate:刚抢占成功、下一个安全点立即运行的那个中断(此前完全不可见)。
  - suspend_frames:中断栈的帧(栈底→栈顶,只给任务标识),depth 之外还能看出
    "谁被谁打断"。
  - interrupts_by_level / preempts_by_level:各级累计登记数与抢占成功数。
* KernelStatus 补 residents(驻留子运行时视图:状态/轮次/上下文已满/输入通道/允许输出)。
  刻意**不带**每个驻留子的 inputch 登记明细——状态面会被反复轮询,明细会让
  每次 /status 背上几十 KB;只给表大小,要明细走专门接口。
* ChannelInfo 补 direction(in/out/io)、description、tools、output_caps、caps_text。
  此前 collectKernelStatus 只透传 Name/Type,把描述/工具/能力**全丢了**,
  前端只能画出一排光秃秃的名字。

## 二、WebUI

* 新增只读 `/api/v1/runtime`:只回运行态三件事(scheduler/residents/channels),
  实测 **1.0KB**(/kernel 是 30KB 级)——所以能 3 秒轮询做"实时"感,
  而不必反复拉全量状态。
* 首页新增「运行态」面板(纯 CSS + 内联 SVG,前端仍无构建链):
  - 四个数字块:排队任务 / 待处理中断 / 中断栈(深度/上限) / 驻留子 Agent,带占比条;
  - 四级中断队列条形图:每级"深度 · 登记/抢占",四级语义**照抄内核**
    (L4 内核独占 / L3 交互 / L2 消息 / L1 后台),不自己起名字;
  - 中断栈层叠图(栈顶在上)+ 立即运行项;
  - 通道拓扑:输入通道 → 内核 → 输出通道,双向通道两侧都出现,能力以胶囊标签显示。
* 3 秒轮询只在总览页可见时才发请求;切回总览时 renderAll 会立刻补一次。

## 验证

* 新增 TestSchedulerStatusExposesLevelsAndStack(四级队列/立即项/栈帧/各级计数映射,
  并断言"未使用的级别必须为 0"与"下标 0 恒 0")、TestChannelInfoCarriesTopology、
  TestRuntimeEndpoint(形状 + 不携带 tools/plugins + 无状态源时 503)。
* go build / vet / agent+core+sdk+plugin+webui 全量测试绿。
* 真实浏览器实测(CDP 驱动,注入运行态样本走真实渲染路径):
  数字块 [3, 5, 2/4, 2];四级条 L4=1/L3=2/L2=1/L1=1 与数据一致;
  栈帧按"栈底→栈顶"渲染且标出栈顶;通道左右分列、io 通道两侧都出现。
2026-09-14 09:05:14 +08:00
c0e9dc1818 fix(webui): 聊天记录不再"每次都发完整记录",并修掉视口跳顶
两个都是你指出的症状,都定位到了具体代码路径。

① 「每次都发完整聊天记录」
   a) API 缺省值错了:/api/v1/chat/history 的 limit 缺省是 0 = **不限制**,
      于是任何不带 limit 的调用每次都拿到整段记录。实测(126 条):
      不带 limit 635,297 字节;现在默认只回一页 180,668 字节。
      显式 limit=0 仍可整取(逃生口)。WebUI/GUI 本来都带 limit,不受影响。
   b) 前端 30s 轮询(以及每次 SSE 报错)都直接拉一页 40 条:
      浏览器实测单次 180,813 字节。现在先做"尾巴探测"(limit=1,362 字节),
      尾巴一致就直接跳过;不一致才拉整页。

② 「聊天记录会跳到顶部」——两条会导致视口丢失的路径都堵上
   a) syncChatFromHistory 在"找不到重合点"时直接 `state.messages = serverMsgs`:
      服务端只回一页,而本地可能已经向上翻了好几页;一覆盖,容器立刻变矮,
      视口被夹回顶部,用户翻过的旧消息也凭空消失。现在只在服务端页**不短于**本地时
      才整体替换。
   b) renderChat 全量重建 innerHTML 后,仅在粘底时滚到底;非粘底(用户正在向上读)
      时位置没人管。改为重建前记住 scrollTop、非粘底时原样还回去。

浏览器实测(CDP 驱动真实页面,126 条历史):
  * 15s/30s 定时器跑满 40s:聊天区滚动位置 **0 px 变化**,未跳顶;
  * 期间 chat/history 请求:limit=1 × 2(各 362 字节)+ 首屏 limit=40 一次;
  * 控制台无报错;新增消息后轮询仍能正确并进来(尾巴探测→拉整页→合并)。

测试:新增 TestChatHistoryDefaultIsPaged(默认一页 / has_more / limit=0 整取 / 显式分页)。
顺带修测试串味:迁移用例往 os.TempDir() 写共享历史文件,会让其它用例的
NewHandler 加载到脏历史(表现为条数多 1);现在各用例用自己的临时文件。
2026-09-14 08:06:16 +08:00
764939ed90 refactor(webui): dashboard.html 6637 行拆成「外壳 + 样式 + 脚本」
前端刻意没有构建链(纯 CSS + Vanilla JS,go:embed 进二进制),所以拆法是:
外壳 dashboard.html 留 {{DASHBOARD_CSS}} / {{DASHBOARD_JS}} 两个占位符,
init() 启动时把两份资产原样填回去 —— **发出的 HTML 与拆分前逐字节一致**,
但 6637 行的单文件变成三份,便于编辑与评审。

  dashboard.html   168 行   外壳(head/body 结构 + 两个占位符)
  dashboard.css   2099 行   样式
  dashboard.js    4371 行   脚本

逐字节校验(三重):
  * 组装结果 sha256 == git HEAD 里拆分前的 dashboard.html;
  * 真实实例 GET /(带 API key)返回体 sha256 同上:0ef14b49…c053;
  * 新增 TestDashboardAssetsSplit:占位符必须存在、样式/脚本不得再内联回外壳、
    组装结果不得残留占位符且必须含样式与脚本特征串。

按行号切片时踩过一次坑并已修正:`</style>`/`</script>` 两个闭合标签被切掉
(正好少 27 字节)——正是因为当时少了逐字节校验,现在把它固化成断言。
2026-09-14 07:17:23 +08:00
4cbfdc970c perf(webui)+feat(config): 聊天记录写盘节流 + 配置库空闲页回收
两条都是我上一封里点出、你说继续的问题。

① 聊天记录:每条消息都整段重写 → 节流合并写
   原来 persistChatLocked 每次变更就整段重写记录文件,而一轮对话会触发多次
   (用户消息、每个工具事件、收尾消息)。200 条上限下文件可达数 MB,单轮就能
   放大出几十 MB 写。文件里还留着一个 chatSaveThrottle=3s 常量——声明了但从未
   被使用(疑似上次 revert 的遗留),等于节流从来没生效。
   现在:persistChatLocked 只置脏 + 唤醒写盘协程;chatPersistLoop 去抖
   chatSaveThrottle(3s)、并以 chatSaveMaxDelay(10s) 兜底(持续输出也不会无限拖延);
   写盘前把快照拷出来,**不持 chatMu 做文件 IO**;写失败重新标脏下轮重试。
   插件 Stop 里调 Handler.Close():停协程 + 强制落最后一次(幂等),否则丢最后一轮。

   实测(临时实例,连发 3 条消息):3s 窗口内记录文件**尚未创建**(节流生效);
   SIGTERM 后文件出现且 6 条(3 用户 + 3 助手,无 LLM key 故为错误回复)全在
   ——关停落盘没丢。

② config.db:SQLite 的 DELETE 不缩文件 → 空闲页够多时 VACUUM
   新增 ConfigRegistry.MaybeCompact(minFreeBytes, minRatio):空闲页 >= 1MB 且
   占页数 >= 25% 才做一次 VACUUM,避免每次启动都重写整库。库里是 WAL 模式,
   VACUUM 之后必须再 wal_checkpoint(TRUNCATE),否则主库文件看着没变小。
   调用点放在插件加载**之后**(大值的搬走/删除发生在插件 Start 里,之前调没意义)。

   实测(一个刚被搬走 5MB 聊天记录的实例):
     freelist 1288 页 × 4096B;启动日志「配置库已压缩: 5394432 -> 118784 字节」
     config.db 5,394,432 → 118,784 字节;记录文件 5,279,491 字节完好未动。

测试:TestChatPersistenceIsThrottled(节流窗口内不写盘 + Close 必落盘 + Close 幂等)、
TestMaybeCompactReclaimsFreePages(删大值后文件确实变小 + 数据完好 + 阈值不达标时不白做功)。
2026-09-14 07:14:55 +08:00
642e1c39b1 refactor(webui): handler.go 2993 行按资源拆成 11 个同包文件
拆法:按「资源面」搬家,每个顶层声明(func/type/var/const)整体搬到目标文件,
声明体一字未改,各文件按实际用到的包重新生成 import。文件头加一行说明本文件负责哪一面。

  handler.go              骨架:嵌入前端资源、Handler/构造、路由表、鉴权会话日志中间件、静态页
  handler_chat.go         对话面:消息模型与内存历史、SSE 事件订阅、对话/历史接口
  handler_upload.go       上传面:handleChatFile / handleUploads / 中断对话
  handler_memory.go       记忆面:图/文档/文本记忆、知识库、LLM 源、变更追踪
  handler_agents.go       内核与代理面:状态、kernel、人格、代理/快照/回滚
  handler_settings.go     设置与插件面:配置读写、插件列表详情(含 pluginmgr 反代)
  handler_terminal.go     终端面:终端会话、终端接口、命令历史
  handler_sse.go          SSE 环形缓冲(断线重连补发)
  handler_openai.go       OpenAI 兼容面:/v1/chat/completions
  handler_device.go       设备网关反代(HTTP + WS 升级透传)
  handler_files.go        /files/ 与 /uploads/ 下载

零漂移校验:拿重构前的 handler.go 与新 11 个文件逐行比对(忽略空行、package/import 头),
**丢失行 0**;新增行恰好是 11 个文件头注释(14 行)。

顺带修掉 import 里两处假使用:handler_openai 的 sdk 只作为 Handler 字段名出现(h.sdk.),
handler_settings 的 fmt 只出现在注释里 —— 都从 import 里去掉。

验证:go build ./... / go vet / webui+config+sdk 测试全绿;
起真实实例(沿用已有 data 目录)后 /status /settings /chat/history /plugins /terminals
/kernel /memory /config /login 全部 200,设置在注入 5MB 历史的情况下仍是 33,921 字节。

最大文件从 2993 → 706 行(handler_chat.go)。
2026-09-14 07:03:47 +08:00
c4998bb102 feat(webui): 聊天记录改为独立文件存储(位置可配)+ 存量自动迁移
起因:聊天记录原先作为插件配置项 plugin.webui.chathistory 存在 config.db 里,
带来三个后果(都在生产实例上实测过):
  1. 整段记录 5,176,016 字节会被 GET /api/v1/settings 当普通配置项整块返回;
  2. 每来一条消息就把整段记录重新 marshal 后写回 config 表,而那次写要拿
     config registry 的全局写锁 —— 消息频繁时所有配置读写都被拖着排队;
  3. 位置不可配(想放独立挂载盘只能改整个 data_dir)。

改动:
* 新增 internal/plugins/webui/history.go:
  - historyStore:默认 <data>/webui_chat_history.json,写盘用同目录 tmp+rename
    原子替换,崩溃不会留半截 JSON;读失败/JSON 损坏按空历史处理并告警
    (聊天记录不是关键数据,不该让它拖垮 WebUI)。
  - resolveHistoryFile:插件设置 history_file > 默认路径;相对路径按 data 目录
    解析(可指向独立挂载盘),data 目录未知时落到系统临时目录而不是进程 CWD。
  - LoadWithMigration:文件为准;文件为空而老配置项有内容时,把记录搬到文件、
    搬成功才删配置项(删不掉就保留并告警,不丢数据);文件已有数据时顺手清掉
    上次没删干净的遗留键。
* 新增插件设置项 history_file(设置页可见可改):留空 = 默认路径。
* handler.go:chatHistory 的读/写改走 historyStore,不再碰 settings;
  顺带把「写失败静默忽略」改成告警。

存量迁移实测(拿仍持有 5,271,690 字节老记录的实例跑新二进制):
  日志:聊天记录已迁移到独立文件 .../webui_chat_history.json(1300 条),并从插件配置表移除
  迁移后:config_webui 里 chathistory 行数 = 0;记录文件 5,279,491 字节
          GET /api/v1/settings = 33,921 字节(迁移前 8,244,108)
          GET /api/v1/chat/history 正常(从文件读回 1300 条里最新的 3 条)

新增测试:TestResolveHistoryFile、TestHistoryStoreMigratesFromConfig(含二次加载
不重复迁移 + 损坏文件不 panic)、TestHistoryStoreSaveIsAtomicAndRoundTrips。
2026-09-14 07:00:07 +08:00
0beb389223 fix(webui): 设置接口不再吐内部数据;--webui 覆盖生效;端口占用不再静默成功
三处实测确认的缺陷:

① 设置接口整块吐出聊天记录
   plugin.webui.chathistory 是 webui 自己持久化的整段聊天记录(生产实例
   实测 5,176,016 字节),躺在插件配置表里被设置接口当普通配置项整块返回,
   前端还会把它渲染成一个巨大的文本框。
   修复:GET 跳过该键(按插件+键精确判定),PUT 直接 400,避免误改。

② CLI --webui 与 webui.listen_addr 一直是死配置
   内核原本在插件加载前写 settings["addr"],但那时 config_<name> 表还没建
   (表只在插件注册 def 时创建),PluginSettings.Set 的 INSERT 失败,而错误被
   "_ =" 忽略了;随后插件 Start 里 RegisterDef 才建表并写入默认 :8080。
   实测:传 "-webui 127.0.0.1:18099" 仍然监听 :8080。
   修复:覆盖值改由插件自己接收(webui.SetListenOverride,loadPlugins 前调用),
   优先级 CLI > webui.listen_addr(非默认值才算显式配置)> settings["addr"]。
   实测修复后:"-webui 127.0.0.1:18099" 正确监听 18099,与生产的 :8080 并存。

③ 端口被占时 webui 静默死亡
   Start 在后台 goroutine 里 ListenAndServe,先打印 "listening on" 再尝试绑定,
   失败只留一行日志,Start 永远返回 nil → 插件仍被当成加载成功。
   修复:net.Listen 同步做,失败即返回 error(交给加载器/守护),
   成功后才起 Serve,并打印真实绑定地址。
   A/B 实测(两个实例都撞生产的 :8080):
     修复前:"listening on :8080" + "server error: address already in use" + LOADED: webui
     修复后:"[plugin] start webui: webui: 监听 :8080 失败: ...",不再有 LOADED: webui

效果实测(同一实例,先注入 5,271,690 字节 chathistory):
  GET /api/v1/settings   8,244,108 → 28,652 字节(约 1/288)
  meta 条数              5,208 → 105,幻影键 0 条
  设置页仍正常:?prefix=plugin.webui 返回 8 条 def;普通键 PUT 落库;
  校验:GET/PUT 内部键被拒;-webui 覆盖真实生效。

新增测试:TestSettingsNoCrossPluginLeak(跨插件泄漏/幻影键/chathistory 读写)、
TestListenOverrideAndBindFailure(覆盖生效 + 端口占用必须报错)、
TestResolveListenAddrPrecedence(优先级)。
2026-09-14 06:54:16 +08:00
9e6627f0c3 fix(config): 插件 def 查询不再越界 —— ListDefs 作用域 + 新增 ListCoreDefs
两个方向相反的越界,合起来把 WebUI 设置接口的 meta 撑成 5208 条(96% 重复):

1) PluginSettings.ListDefs(prefix) 把 prefix 直接透传给全局 ListDefs,
   等于「返回全仓所有 def」——调用方以为在问某个插件,实际拿到全部。
   修复:限定到 plugin.<name>. 命名空间,并把 Key 剥回插件内局部键
   (调用方看到的键必须与 Set/Get/ListPlugin 的局部键一致)。

2) DefsCore(prefix) → reg.ListDefs(prefix) 会连插件 def 一起返回,
   于是 meta 里出现 plugin.<name>.<key> 的「核心侧副本」。
   修复:新增 ConfigRegistry.ListCoreDefs,显式排除 plugin.* 命名空间。

生产实例实测(旧代码):GET /api/v1/settings 的 meta = 5208 条,
其中 core.agent.* 等每个 def 都被复制 28 份(每个插件命名空间一份),
并派生出 plugin.<a>.plugin.<b>.<key> 这类幻影键。

⚠️ 幻影键不只是脏数据:设置接口的 PUT 走 SplitN(key, ".", 3),
对 plugin.<a>.plugin.<b>.<key> 会解出 (a, "plugin.<b>.<key>"),
即按 UI 上的幻影条目保存会**写进错误插件的配置表**。

新增 TestPluginDefsAreNamespaced 钉住两条作用域。
2026-09-14 06:53:58 +08:00
3edab0fe68 refactor(homed): main() 696 行按启动阶段拆成 25 个阶段函数
main() 原本是一整条 696 行的启动脚本:日志、目录、记忆、配置、Lua、守护、
追踪、内核 API、文本记忆、LLM 源、文档/知识、人格、插件、Agent、ONNX、
IPC、心跳、关停全挤在一个函数里,变量跨 500 行互相引用。

现在 main() 只剩「顺序编排 + 就地交接」(**149 行**,低于 funlen 阈值 150):
  opt           := parseFlags()
  logDir        := setupLogging(opt.dataDir)
  agentWorkDir  := ensureDataDirs(opt.dataDir)
  mem, closeMem := initMemoryStack(opt.dataDir)
  ...
共 25 个阶段调用,实现体在同包 bootstrap.go(一一对应)。

零漂移保证:
  * 阶段体逐字取自原 main,只做机械替换(`*dataDir`→参数、`memIdx`→`mem.indexer`);
  * 原 main 的每个 defer 都换成一个在**同一位置**注册的 cleanup,
    LIFO 释放顺序不变;多资源阶段内部再按原注册顺序取反;
  * 语句级比对:原 main 的 525 条可执行语句全部有对应,无遗漏。
    仅 3 处为**有意**的结构改写(其余为同义替换):
      1. initLuaVM / initTextMemory:「启动成功才 defer Stop」改为
         「失败返回 no-op cleanup,成功返回 Stop」——调用点语义不变;
      2. startIPCServer:同上(用 started 标志保证失败时不 Stop);
      3. resolveBaseAPIKey:把三级兜底 API key 解析提成一个纯函数。
  * defaultPrompt 提为包级 const defaultSystemPrompt(不含版本号字面量)。

验证(A/B 实测,不是只跑编译):
  * go build ./... / go vet ./cmd/homed/ / go test ./cmd/... ./internal/agent/... ./internal/plugin/... 全绿
  * 重构前后二进制各起一次(-data 临时目录,SIGTERM 收尾),日志集合**完全一致**:
    63 个注册工具、同名插件全部 loaded、kernel ready、插件逆序关停、'stopped'
    —— 差异仅为并发加载插件的打印顺序。

全仓非测试 Go 函数现状:≥300 行 **0 个**,≥200 行 9 个,≥150 行 16 个。
2026-09-14 06:30:45 +08:00
129a3aeb38 refactor(proc): coreHandler.Handle 550 行按 method 组拆成 14 个分部函数
原 Handle 是一个 550 行的巨型 switch(C ABI 51 个 case 的整块平移),
按协议面拆进同包 5 个新文件、14 个小函数:

  corehandler_register.go  handleRegister        注册面(tool/stage/output/api/input)
  corehandler_inject.go    handleInject          IO 注入 + SetToolBlocks
  corehandler_memory.go    handleGraphMemory     图记忆
                           handleDocMemory       文档记忆
                           handleKnowledge       知识库
                           handleTextMemory      文本记忆
  corehandler_settings.go  handleSettings        设置(14 个 method 共用一条实现)
                           handleLLM             LLM 源
                           handleSocial          社交图只读
                           handleLifecycle       生命周期开关
  corehandler_runtime.go   handlePluginMgr       插件管理
                           handleStageLocks      段锁仲裁
                           handleEvents          事件订阅
                           handleArena           共享槽池

Handle 保留 capability 强制检查,只做「method → 分部函数」一跳。

零漂移保证:case 标签由脚本从原文提取(不手抄常量名),case 体逐字搬迁,
逐函数比对确认 59 个标签 / 510 行 case 体与原文件完全一致(仅行首缩进经 gofmt 重排)。

验证:go build ./... / go vet / go test ./internal/plugin/... / -race 全绿。
2026-09-13 23:29:13 +08:00
dcae21b24c refactor(lua): replaceSDKReal 633 行按 SDK 子表拆分
抽 luaReg 上下文(L/t/plg/s + subTable/pushVal/pushList/pushErr/pushNil
五个助手方法),把单函数拆成 registerRegistrars / registerInjectors /
registerDataAPIs / registerSettings / registerEventsAndMgr 五个方法。

被移动的代码体**逐字保留**:各方法头部把 L/t/s/plg 与五个助手注入为局部
别名,所以内部一行未改,行为零漂移。luaSyncUnavailable 提为包级函数
(原来它在被拆到另一个方法的作用域里会 undefined)。

实测:replaceSDKReal 从 633 行消失,最大子方法 265 行;非测试函数
>=300 行的数量 3→2。go build/vet/test -race 全绿。
2026-09-13 22:44:39 +08:00
62e1e02036 docs(ohos): README 两处事实修正(产物路径前置条件、≤400 行约定的边界)
1. **安装小节的产物路径**:`entry/build/` 是纯构建产物、不入库,干净 clone 或清理过
   工作区时该文件不存在。补明"必须先跑完第 2 步",并说明目录名随
   `-p product=<名字>` 变化、同目录还有 unsigned 版(`hdc install` 要用 signed)。
   本次实际构建校验过路径:`entry/build/default/outputs/default/entry-default-signed.hap`。
2. **≤400 行约定补边界**:不到 400 行的文件不要为了拆分而拆分 ——
   `@Component` 的 `build()` 只允许一个根节点,多节点 `@Builder` 改组件会多出一层
   Column 包裹,布局等价是推理出来的、不是看出来的,每拆一次都要付一次
   "未上机验证"的账。并记下 `pages/Index.ets`(396) 属于"不越线就不动"的一类。

无代码改动,纯文档。
2026-09-13 22:42:37 +08:00
8a58cde3ee docs(ohos): 本地构建前置条件 + 运行时验证清单 + 工程结构表刷新
1. **oh_modules 前置条件**:该目录被 .gitignore 忽略但必须存在 —— hvigor 不会
   自动 ohpm install,移走后构建直接报 arkts-no-untyped-obj-literals(依赖类型
   声明缺失)且不重建目录。写明"clone 后先 ohpm install,别当垃圾清掉",
   同时说明 entry/build 与 .hvigor 是纯产物、可随时删(冷构建 ~8s)。
2. **hvigorw 绝对路径**:仓库里的 ./hvigorw 是符号链接,启动脚本按
   $(dirname $0) 定位,会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw;
   统一改用 <command-line-tools>/bin/hvigorw(本机 /opt/huawei/command-line-tools/bin/hvigorw)。
3. **运行时验证清单**:把"构建通过 ≠ UI 行为不变"这件事写进仓库而不是留在邮件里
   (流式对话/思考卡与工具卡/附件上传/设置与设备与插件的二级页/宽屏分栏),
   并注明无提权环境起不来模拟器时需在真机补验证。
4. 工程结构表按前几轮拆分后的实际情况刷新(common/components/pages 新增文件与职责),
   并记下"单文件 ≤400 行、页面只做壳"的约定。

无代码改动,纯文档。
2026-09-13 22:35:29 +08:00
e733d05a5e refactor(ohos): DevicePage 560→307 行(入口列表/四个面板/模型拆分)
- common/DeviceModel.ets(73):device_id 兜底(桥 id 优先 → 持久化 → 生成并落盘,
  顺序与原 aboutToAppear 一致)、/device/online 响应解析、四个二级页路由 id
- components/DeviceRootEntries.ets(92):一级入口(本机/通道两组 NavRow 列表);
  宽屏高亮自己读 AppStorage 的 isWideScreen,页面只给 activeSub 与 onOpen
- components/DevicePanes.ets(316):DeviceLocalPane(基本信息 + 授权开关)、
  DeviceCapsPane(能力清单)、DeviceGatewayPane(网关信息 + 刷新)、
  DeviceListPane(在线设备列表)、DeviceKvRow;每个面板自带 SubPageLayer

页面保留导航栈、openSub/closeSub、授权开关、toast、refreshDevices 与
SubDestination 分发。所有文案、图标、颜色、过渡与失败分支逐字保留;
面板内容多了一层无 padding 的 Column 根节点(@Component 的 build() 只允许
一个根),宽度 100% + Start 对齐,与 SubPageLayer 内容槽的布局一致。

验证:hvigorw assembleHap BUILD SUCCESSFUL。
2026-09-13 22:34:20 +08:00
8a9fc05451 refactor(ohos): DeviceBridge 409→296 行(协议类型与帧构造移到 BridgeProtocol)
- common/BridgeProtocol.ets(168):hello/bind/cmd_result/cmd_data_start/
  cmd_data_end/event/status 七个消息结构、CHUNK_SIZE、BridgeCmdHandler 回调类型,
  以及 bridgeHelloFrame / bridgeBindFrame / bridgeResultFrame /
  bridgeDataStartFrame / bridgeDataEndFrame / bridgeEventFrame /
  bridgeStatusFrame / bridgeChunkSlices 八个纯构造/切片函数
- common/DeviceBridge.ets(296):只剩 socket 生命周期、重连代际、绑定状态机
  与命令分发

**DeviceBridgeClient 的对外方法名与签名一字未改**(isConnected / getDeviceId /
setCmdHandler / setStateListener / connect / updateAuthorized / disconnect /
sendResult / sendDataChunked / sendEvent / sendStatus / send),
`send*` 仍是"拼帧 + 发出去"两步,拼帧那一步搬走;分块发送的
"start → chunks(失败即 break)→ end"顺序与 `bytes.slice` 语义保持不变。
外部只有 deviceBridge 单例被 import(BridgeRouter / DeviceBridgeSession /
DevicePage),无其它符号依赖。

验证:hvigorw assembleHap BUILD SUCCESSFUL;diff 中 DeviceBridge.ets 无
状态机/回调/重连逻辑改动。
2026-09-13 22:28:53 +08:00
31bbde9cdd refactor(ohos): Attachment 463→344 行(纯函数与字节解码移出 components)
- common/AttachmentMeta.ets(106):parseAttachment / attachmentFromChannelOutput /
  fileNameOf / formatBytes / oneDecimal / extLabel / sanitize,纯函数无平台依赖
- common/AttachmentImage.ets(37):loadPixelMap(沙箱 file:// 与远端 /files 两条路径)

components/Attachment.ets 只留 UI:AttachmentCard 与 AttachmentDetailContent。
**两者的导出名、@Prop 形状与 import 路径均未变**(ChatPage 仍从
'../components/Attachment' 取 AttachmentDetailContent),组件内部的布局、
过渡、提示文案与失败分支逐字保留,仅函数体搬走。引用方 5 处 import 路径同步更新。

验证:hvigorw assembleHap BUILD SUCCESSFUL;diff 中 Attachment.ets 只有
"-删除纯函数 + import 改写",无属性/布局改动。
2026-09-13 22:27:34 +08:00
e0514c3692 refactor(ohos): ChatPage 1962→207 行(状态机/SSE/气泡/输入区拆分)
- common/ChatStore.ets(388):消息数组、分页游标、新消息入场标记、
  SSE 连接与重连、防抖刷新(50ms)统一成一个单例状态机
- common/ChatSse.ets(192):SSE 事件 → 状态的翻译层,逐分支照搬
  channel_output/agent_output/reasoning/delta/tool_call/stage/agent_error/
  sync_required;通过 ChatStreamSink 接口写入,避免与 ChatStore 形成循环依赖
- common/ChatSession.ets(176):POST /chat 与 POST /chat/file 的发送、
  超时兜底、POST 响应与 SSE 的合并判定
- common/ChatFormat.ets(223):mime 推断、ForEach 键 structSig、工具卡
  状态/颜色、渠道判定与头像配色
- common/ChatHistory.ets(96):历史载荷与 tool_calls 解析
- components/ChatBubble.ets(247):气泡(头像/渠道名/思考卡/工具卡/附件卡/正文)
- components/ChatToolCard.ets(253):思考卡 + 工具卡
- components/ChatStream.ets(210):消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
- components/ChatComposer.ets(357):输入行、选图/选文件、沙箱落盘、发送
- components/ChatAttachBar.ets(139):加号菜单 + 待发送附件条

响应式语义刻意保持不变:数组不进 AppStorage,改用自增版本号 K_CHAT_REV
通知订阅组件重取快照(ChatStream 把它镜像进 @State messages,ForEach 每次
拿到的仍是新数组引用,与拆分前 this.messages = this.messages.slice() 等价);
structSig 仍不含 content,正文靠 MarkdownView 的 @Prop 流式更新;
markNew 仍不切片、入场动画交给紧随其后的 refresh();
animateTo 只能存在于组件里,所以折叠翻转的动作留在 ChatStream 内。

验证:hvigorw assembleHap BUILD SUCCESSFUL;SSE 各分支、ensureToolCall、
markNew、sendChat/sendWithAttachment 的守卫顺序均与原实现逐条比对过,
守卫从"页面方法内"移到输入区组件时保持了原有先后(连接判定先于清空输入)。
2026-09-13 22:19:05 +08:00
f79e0f82dd refactor(ohos): SettingsPage 1463→389 行(模型/条目卡/四个面板拆分)
- common/SettingsModel.ets(313):设置载荷解析、分类归并、分页切片、
  路由常量,纯逻辑无 UI
- components/SettingsEntryCard.ets(215):单条设置卡(值编辑/保存)
- components/SettingsRootEntries.ets(100):一级入口行 + 状态汇总卡
- components/SettingsHome.ets(95):一级页壳(顶栏/浮层/提示条)
- components/ConnectionsPane.ets(347):后端连接 CRUD(自带二级页壳)
- components/AppearancePane.ets(265):主题/背景/透明度(自带二级页壳)
- components/BackendSettingsPane.ets(172):分类与条目两个二级面板

页面保留 @State 集合、加载/保存编排与 SubDestination 分发;数组仍走
@Link 直传(未引入 AppStorage 数组)。相册选择、重启前台桥等既有行为
与提示文案逐字保留。

验证:hvigorw assembleHap BUILD SUCCESSFUL。
2026-09-13 22:18:56 +08:00
131c1ff0e9 refactor(ohos): PluginsPage 995→374 行(接口/状态/列表/详情/浮层拆分)
拆分方向按职责切,页面只留导航与数据编排:
- common/PluginApi.ets(174):fetchPluginRows / fetchPluginDetail
- common/PluginStatus.ets(78):状态判定、文案、颜色、副标题
- components/PluginListView.ets(166):列表卡 + 列表
- components/PluginDetailPane.ets(291):详情面板
- components/PluginsOverlays.ets(65):安装表单
- components/ToastBar.ets(48):提示条,PluginsToast 与 SettingsPage 的
  toast 合并成这一个组件(此前两处各写一份)

UI 结构与文案逐字保留(含 92%/layoutWeight 等既有布局修法)。

验证:hvigorw assembleHap BUILD SUCCESSFUL。
2026-09-13 22:18:49 +08:00
414e6a1627 refactor(ohos): StaticMarkdown 604→332 行(Markdown 解析抽到 common/MarkdownParser)
把"文本 → 块/片段"的纯解析逻辑(MdBlock/MdSpan、parseBlocks、parseInline、
表格/分隔线/有序列表判定)整体搬到 common/MarkdownParser.ets(282 行),
StaticMarkdown.ets 只留 StaticMarkdownView 组件(332 行)。

解析规则逐字保留,未改任何排版行为;MdBlock/MdSpan 原本只被
StaticMarkdown.ets 使用(唯一引用方是 components/MarkdownView.ets,
它只用 StaticMarkdownView)。

验证:hvigorw assembleHap BUILD SUCCESSFUL。
2026-09-13 22:18:43 +08:00
431bb5a0c9 refactor(tooldefs): buildToolDefs 614→273 行(抽 toolDef 助手,schema 形状不变)
原实现每条工具都是 4 层嵌套的 map[string]interface{} 字面量(约 20 行/条),
40+ 条堆成 614 行的巨型函数。新增 toolDef(name, desc, props, required...)
助手消除外层样板;所有 name/description/properties/required 文本**逐字保留**
(用 go/ast 定位原样搬迁,非重新键入),schema 形状与 JSON 输出不变。

验证:go build ./... / go vet / go test ./internal/agent/core/ 全绿;
AST 实测 614→273 行,非测试函数 ≥300 行数由 4 降到 3。
2026-09-13 22:17:45 +08:00
6878f0126d fix(lua): 同步注入在 Lua 中明确报不可用(避免自锁)+ 文档/mock 同步
sdk.inject_input_sync / *_opts / inject_input_media_sync* 在 Lua 里必然自锁:
Lua 代码只在 Start/工具/阶段/输出/事件回调中执行,这些路径都持有 plg.mu,
而同步注入要等本轮回复(回复路径上的回调又需要同一把锁)。原实现会挂死
直到超时;现改为立即返回明确错误,并在中英文 PLUGIN_DEV 里标注不可用 +
指向 Go 插件/异步注入。mock sdk.lua(SDK 仓为事实源)同步为同样的错误语义。
新增 TestLuaSyncInjectUnavailable 钉住不挂死。
2026-09-13 21:59:23 +08:00
52127a3323 fix(resident): CreateResident 注册入站 inputch 后加 defer 回滚
注册点与 residents 登记之间当前无可失败步骤,但缺回滚路径就是 child/<id>
残留那只 bug 的另一条入口。加 registered 标志 + defer:未走到成功返回就注销。
2026-09-13 21:59:10 +08:00
11d9038927 fix(io): ChannelRegistry 补 UnbindOutputTarget,Unregister 清理 outputTargets
outputTargets 只增不减:Unregister 一个 inputch 后,指向它的输出目标登记仍
留在表里,ResolveOutputTarget 会继续把消息路由到已不存在的 agent/inputch。
与刚修的驻留 inputch 残留同属「注册未注销」类。现补 UnbindOutputTarget
(幂等),并让 Unregister 顺手清掉显式绑定与同名回退两种目标登记。
2026-09-13 21:59:10 +08:00
a15e7c2dc1 chore(lint): 加 .golangci.yml(funlen/gocyclo/lll/dupl,warn-only)+ make lint-full
main 上曾有 4 个 >=300 行函数、12 个 >=200 行函数;没有复杂度 linter 是它们
长期存活的直接原因。先以 warn-only(issues.exit-code:0)立阈值、只出清单,
历史债下降后再收成硬门禁。测试与 testdata/third_party 排除长度类规则。
2026-09-13 21:59:10 +08:00
4518c3eb03 fix(lua): events.subscribe 改用内部 Subscribe + 订阅生命周期(修死锁/use-after-close)
上一版 Lua 对齐引入的 sdk.events.subscribe 有两个真问题,本提交修掉:

1) 用了公共 SDK 的 Events(),但本内核从未注入 event subscriber
   (SetEventSubscriber 全仓无调用点),拿到永远是 nil ⇒ subscribe 只会
   返回 "events unavailable"。改用内部 SDK 的 s.Subscribe——内置插件走的就是
   这条路径(cli/webui/skillmgr 全用它)。

2) 自死锁:subscribe 会在 Lua 的 plugin.start(sdk) 回调里被调用,而
   luaPlugin.Start 正持有 p.mu;原实现在 subscribe 里再 lock p.mu 追加 subs,
   不可重入 ⇒ 测试实测 30s 超时。改用独立的 subsMu。

3) use-after-close:Stop 会 Close LState,但事件订阅此前无人取消,残留回调
   再触发就会碰已关的 L。现在:Stop 先(不持 p.mu,避免与 Bus.Publish
   锁序反转)取 subsMu 取消全部订阅,再置 closed 并关 L;事件回调持 p.mu 后
   先查 closed,已进入等锁的旧回调会直接返回。

4) plugin_mgr 访问补 nil 保护(部分单测构造的 SDK 不含 pluginMgr)。

回归:TestLuaEventsSubscribeAndStopCleanup——订阅后 Publish 命中、Stop 后
再 Publish 不 panic。全套 Lua 测试在 -race 下通过。
2026-09-13 20:33:13 +08:00
44cb7243c5 fix(resident): 销毁驻留子时注销其入站 inputch(child/<id>)—— 修登记表脏数据累积
根因:residentInboundChannel 在 create 时把 child/<id> 登记进共享登记表
(Plugin=resident, Owner=父),但 teardownResident 只把划入的 inputch
(如 timer)归还为未分配,从未注销这条入站登记。于是每次 create/destroy
都在登记表里留下一条脏记录,且随次数单调累积。

实测(HomeAgent 侧,HΔ-Kernel v1.3.10 / 1b49365):
resident_agents destroy 之后,input_channels by_agent 仍列出 child/<id>,
归属 main;而 HomeAgent 没有任何工具能单独注销 inputch,只能重启 homed 清。

修法:teardownResident 里用纯函数 inboundChannelName 算出名字并 Unregister。
不能复用 residentInboundChannel——它有重新登记的副作用。
该路径同时覆盖 destroy / reclaim / StopResidents(父退出)。

测试:TestResident_LifecycleAndNoOrphans 增加两条断言——销毁后与父退出后
child/<id> 都必须从登记表消失。
2026-09-13 20:19:37 +08:00
162f33f81e feat(lua): Lua 插件桥全量对齐 SDK 1.3.0(媒体/注入标志位/优先级/事件/通道注销)
内核 Lua 桥(internal/plugin/lua_plugin.go)此前停在 v0.8.0 时代能力面,
1.1/1.2/1.3 新增能力只在 Go 侧存在,而 PLUGIN_DEV.md 宣称『能力完全对齐』。
本补丁把 Lua 侧补齐到与公开 SDK 1.3.0 对齐:

- 1.1 媒体:memory.commit 支持 sentence_text/media_digests;
  doc.insert_with_media + attachments;text_memory.append attachments;
  set_tool_blocks / inject_input_media(_sync) / inject_interrupt_media。
- 1.2 注入语义:inject_input_sync(_opts)、六个 *_opts 变体
  (no_memory/context_policy/cleaner_name/priority);
  ToolDef/ChannelDef 解析 context_policy。
- 1.3 优先级与动态通道:priority 常量透传;unregister_output_channel。
- StageContext 暴露 reasoning_content/context_msgs/token_usage/memory/extra/errors。
- 新增 sdk.events.subscribe 与 sdk.plugin_mgr.*。
- sdk.lua mock 同步(单一事实源在 SDK 仓 sdk/lua/sdk.lua,内核副本由
  third_party/homeagent-sdk/scripts/sync-lua-sdk.sh 同步)。

契约测试(lua_surface_test.go):
- 守住内核内嵌 mock 与 SDK 仓事实源一致;
- 守住 mock 承诺的每个函数都有运行时 RawSetString 绑定;
- 覆盖 opts/media/attachments 解析与 context_policy 透传。

文档:中英 PLUGIN_DEV.md 的 Lua API 表补齐并改为『对齐至 SDK 1.3.0』。
2026-09-13 19:56:32 +08:00
180b96e21a docs: 记下 gitcode 附件的"同名只写一次"硬约束(校验和首次没传全就永远补不回来)
实测证据:同一个名字(ZZprobe.txt)传两次不同内容,下载端始终返回第一次那份;
资产列表里该名字只有一项。删除接口走不通 —— release JSON 不含 `id`,附件列表接口 404,
`DELETE .../releases/<tag>/attach_files/<name>` 返回 400「参数类型错误」(要数字 id)。

后果(1.3.1–1.3.10 全都踩了):首次上传 `SHA256SUMS` 时只有 linux/amd64 四个产物,
之后补 arm64/darwin/win 时"合并后重传"**全部无效** —— 线上那份至今仍是 4 项、带 `./` 前缀,
arm64/darwin/win 的产物没有校验依据。

⇒ 纪律:**打包全部平台后才第一次上传校验和**;分批上传时先传产物、最后传校验和,
校验和只传一次。补救只能换名(`SHA256SUMS.complete`)或重建 release(需重传全部产物)。
2026-09-13 18:50:06 +08:00
3fda1db9c0 fix(packaging): package-windows.sh 支持 NSI 覆盖 + payload 预检
上一步失败:`File "..\..\build\linux-payload\*.*" -> no files found`。
原因:NSIS 的 `File` 路径**相对 .nsi 所在目录**解析,而我用了主仓的 installer.nsi
(它去找主仓的 build/linux-payload),payload 却 stage 在 tag 的 worktree 里。

改法:`NSI` 可覆盖 —— 在 tag 的 worktree 里构建时用**该 tag 里的** installer.nsi
(与产物同源,也正是可复现发布该有的样子);另加 payload 空载荷预检(空载荷=装不上,
必须直接失败而不是打出一个没内容的安装器)。
2026-09-13 18:29:13 +08:00
8910c8c454 docs: 补两条流水线纪律(脚本必须 set -e;tag worktree 里的驱动脚本)
今天连踩两次,都是"脚本本身"的问题而不是打包逻辑的问题:

1. **没 `set -e`**:`package-windows.sh` 在 tag worktree 里找不到(新脚本只在 main),
   bash 报 No such file or directory 之后**流程照旧往下走**,把只含 4 项的校验和
   传上去覆盖了原本覆盖 10 项的那份 ⇒ 只能把产物下回来重建。
2. **驱动脚本不在 tag 里**:发布件在 tag 的干净 worktree 里构建,而刚补的脚本还没进 tag。
   ⇒ 让脚本支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,用"主仓脚本 + 产物目录
   指向 worktree"来解;文档写清这条约束。

同步进发布技能的同名小节(这两条和"分批上传要全量重算校验和"是同一类:**校验和的完整性
比产物本身更容易被流程吃掉**)。
2026-09-13 18:23:22 +08:00
c83eca5e88 fix(packaging): package-windows.sh 支持 DIST_LINUX/BUILD_DIR/DIST_RELEASE 覆盖
我在 v1.3.10 的 tag worktree 里调这个脚本,而它提交在 main(tag 里当然没有)⇒
`No such file or directory`;又因为脚本没加 `set -e`,它继续往下跑,把只含 4 项
(linux amd64)的校验和传上去,覆盖掉了原本覆盖 10 项的那份。

目录可覆盖后就能「用主仓脚本、产物目录指向 worktree」,两个坑一起消掉。
2026-09-13 18:22:33 +08:00
594496a225 feat(packaging): 补 Windows(WSL) 安装器的驱动脚本,并按变体定向 payload
用户要求:**Windows 的 homed 安装包应当是往 WSL 里安装**。口径本身早已落地
(installer.nsi 注释 + install-via-wsl.ps1 + build.sh 的 WSL 分支),但缺两样东西:

1. **没有驱动脚本**:`build.sh` 里没有 `makensis`,仓库里也没有任何脚本调用它 ——
   build/ 下那几个历史 .exe 是手工打的。新增 `deploy/packaging/package-windows.sh
   <server|client|full> [arch]`:按变体准备 payload、必要时编 waiter.exe、调 makensis、
   把产物落到 dist/release。
2. **payload 不分变体**:`build.sh` 的 `stage_linux_payload` 把 dist/linux 下所有 deb+tar
   全塞进 payload ⇒ 现在 server/full 的 deb 各带 ~719MB 模型,任何变体的安装器都会
   膨胀到 ~2.4GB。而 WSL 侧脚本只取 payload 里的**第一个** `.deb`
   (install-via-wsl.ps1:141)⇒ 按变体只放对应的那一个包。

同时:client/full 需要 Windows GUI payload(HAS_GUI=1),本机无 electron-builder 时
**明确失败并给出命令**,不产出"装完没有界面"的半残包。

docs/git-branching.md §七.5 补上这条口径与三条命令。
2026-09-13 18:13:04 +08:00
56ba1332f3 docs: 补「分批上传时后一轮必须全量重算 SHA256SUMS」
实测踩到:v1.3.10 先传 amd64 的 9 个资产(含只覆盖 amd64 的 SHA256SUMS),
后补 arm64 时按 arm64 那 4 个文件重算 ⇒ 同名附件覆盖 ⇒ amd64 的校验和消失。
校验和是附件的唯一完整性依据,丢了等于没有校验。已同步到技能的同名小节。
2026-09-13 17:35:02 +08:00
1d4f2beeea fix(prompt): 去掉"每轮只能发一次 output_send"的凭空限制;type 缺省即 text
用户现场指出:**qq 插件的输出通道判据太严了**(那条判据在插件侧,已单独修:
`output_send__qq` 不再受"当前会话身份"限制)。同时内核提示词里还有一条**同类的凭空限制**:

  「每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。
    仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条」

可设计上输出是 agent 的**主动调用**:收到一次输入后,可以往**任意(已授权的)通道**
发**任意多次**(分段播报、先回执后结论、同时通知多个通道都合法)。这句话会让模型
自己收起合理的多次输出 —— 而且它不是任何机制的要求,只是当初为压 output-loop 写的
措辞(真正的防环机制是"回执只回 ok、不回传富结果",那条保留)。

改法:
- 提示词改为明确授权:**输出次数与目标通道由你自己决定**,没有「一轮只能发一次」的限制;
  只保留两条真话:单条长度上限(超长拆完整段落)、别反复重发**完全相同**的内容。
- `output_send__*` 的 `type` 参数改为**可选**(缺省 text):判据该拦的是"不知道发什么",
  不是"没写众所周知的默认值"——此前缺 type 会直接失败并让模型重试一次。

判据 3 条(新增 `output_rules_test.go`):提示词不得含输出次数限制且必须显式授权 /
省略 type 时按 text 发送成功且 schema 的 required 只有 payload / 空 payload 仍被拦。

(cherry picked from commit 17ea7fd5f0)
2026-09-13 16:04:16 +08:00
5160d8d4d1 chore(sdk-mirror): qq 插件 1.4.1(输出工具不再受当前会话身份限制)
镜像 SDK 仓的插件修复:被子的中断唤醒的一轮里,父带齐 meta 调 output_send__qq 也被
「可信 QQ 会话身份不完整」拒掉;输出改为先放行(目标由 meta 决定),读取类工具仍限当前会话。
2026-09-13 16:03:28 +08:00
e2500035d5 fix(resident): 子的「轮次」一直显示 0 —— info() 根本没填 Rounds
现象(用户线上联调实录 + 我复验):父侧 `resident_agents` 列出 `输入ch=[timer] 轮次=0
处理表=2` —— **处理表已有两条记录,轮次却是 0**,自相矛盾,容易被读成"子没干活"。

根因:`residentChild.info()` 构造 `ResidentInfo` 时**从来没有填过 Rounds 字段**
(结构体里有这个字段,于是永远输出零值),不是计数漏加。

改法:`Rounds = 已执行轮次数`(调度器执行计数,单调不减)。新增 `Agent.roundsExecuted()`
并写明为什么**不能**用 inputch 处理表条数当轮次:那张表记的是"当前上下文窗口内"的轮次,
压缩会清空(设计 §8.3)—— 用它会让父看到轮次倒退。

判据:inputch 路由测试里补一条断言 —— 子处理完输入后 `info().Rounds > 0`。

(cherry picked from commit cd88b2dfe5)
2026-09-13 15:41:07 +08:00
886ba78b11 fix(scheduler): inputch 划给子后输入只流向子 —— 补上「进内核之前」的输入路由
用户指出的语义(设计稿 §4.1 早已写明):
**inputch 是可分配资源**,「路由发生在**进内核之前**」—— 划给某个 agent 后,
该通道的输入**只流向那个 agent**;outputch 不同,授权是**非独占**的,
父依旧可以通过它发送内容。

而代码里 inputch 划拨只做了**登记**,没有做**路由**:
- 插件注入输入的 io 是**根 agent 的**(`cmd/homed` 里 `pluginReg.SetIOManager(iom)`);
- 唯一消费输入的是「该 io 自己的调度器」(`scheduler.go` 读 `a.io.InputChan()`);
- `ChannelRegistry.Assign` 只把 Owner 写进登记表,**没有任何转发动作**。

⇒ 现场表现(用户线上联调):子挂 `inputch=[timer]`,**timer 的输入却打在父身上**
(日志 `[agent] interrupt from timer/timer`),子侧 `轮次=0` 永远不动。
登记表里的 Owner 于是沦为标签。

改法(按 §4.1 把路由放回"进内核之前"):
- `IOManager` 增加 `InputRouter`(`SetInputRouter`),并把**五处直接入队**收口到
  `deliverInput`:`InjectInput` / `InjectInputSync` / `InjectInputTo` /
  `InjectInputSyncTo` / `InjectInterrupt`(排队与中断两条路都过路由)。
- 内核注入路由器 `Agent.routeInputByOwner`:查 inputch 的 Owner —— 归自己/未分配 ⇒
  本内核处理;归自己的某个驻留子 ⇒ `DeliverRouted` 交给它(**不再进父的队列**);
  归一个不存在的 agent ⇒ **不吞输入**,父兜底 + 留痕(吞掉输入比多处理一条更糟)。
- `DeliverRouted` 是"已路由"的投递口,不再二次路由(避免成环)。
- 同步输入的 `ResponseCh` 随事件一起走 ⇒ 回答由持有者写回同一回程(§4.3)。

判据(新增 6 条):
- io 层:被接管时排队/中断都**不入本内核队列**(且中断确实经过路由)/ 放行与未设
  路由器时与历史行为一致 / `DeliverRouted` 不再触发路由
- 内核层:划给子的 inputch 输入进**子**(子 Executed>0)且**父 Enqueued 不变** /
  归属到不存在的 agent 时父兜底(不吞)/ 未分配的 inputch 仍归父

(cherry picked from commit 4707b05498)
2026-09-13 15:35:59 +08:00
d0997f6279 docs(resident): 补写「子的 io 通道视图 = 对父的实时回退」(N3 落地口径)
输出通道在 io 层就是 Device,由插件登记在父的 IOManager 上;驻留子只共享了 inputch
登记表 ⇒ 子侧 childIO 空壳(现场:子调 output_send__cli 被判「通道不存在或不可用」)。
文档写清:继承方式(SetParentIO)、四个受影响的方法、为什么是实时回退而非快照、
以及回退只解决看得见、授权仍在白名单之后。附线上实测结果(result: ok)。
2026-09-13 15:16:58 +08:00
91c4bc01f1 fix(resident): 驻留子继承父的输出通道 —— 修「子侧 childIO 空壳、子不会发消息」
现场(用户在线上跑驻留子联调,日志实录):
  父 agent 侧「通道装载完整」,子 `demo-resident` 侧 `childIO` 是**空壳**:
  子的 `output_list_channels` 为空、`output_send__<通道>` 一律被判
  「通道 [X] 不存在或不可用」,连 `output_send__*` 工具都不生成 ⇒ 子不会发消息。

根因:**输出通道在 io 层就是 Device**,而它们由插件登记在**父**的 `IOManager` 上。
`SpawnResident` 给子建的是全新 `IOManager`(它确实该有自己的输入入口与 outputCh),
却只共享了 inputch 登记表,**没有继承设备/输出通道视图**:
  - `executeOutputSendTool` → `a.io.GetChannelCapabilities(ch)` 查的是 `devices[ch]` ⇒ 0
  - 投递路径 `a.io.GetDevice(ch).Execute("output", …)` ⇒ nil
  - 工具面 `tooldefs.go` 从 `a.io.ListChannels()` 生成 `output_send__*` ⇒ 空

改法:给 `IOManager` 增加**上级回退**(`SetParentIO`)——驻留子创建时把自己的 io 挂到
父的 io 上,`GetDevice` / `GetChannelCapabilities` / `ListChannels` / `ExecuteTool`
在自己没有时回退到上级。

为什么是**实时回退**而不是创建时复制快照:设备随资源生灭(远程设备上线/掉线以分钟计,
现场日志 60 秒一个来回),复制出来的表转瞬即过期;而回退永远与父一致。
**授权不受影响**:回退只解决"看得见",能不能用仍由各自的 `AllowedOutputs` 白名单把关
(`executeOutputSendTool` 的授权闸 + 工具生成时的过滤都在白名单之后);
自己的登记优先,子可以覆盖/屏蔽同名通道。

判据(新增 5 条):
- io 层:无上级时行为与以前完全一致 / 挂上级后看得见 / **实时**(父新登记立刻可见、
  注销立刻不可见)/ 同名自己的优先且不重复列出 / `ExecuteTool` 同样回退
- 内核层:子看得见父通道 + 真能发出(父通道收到 1 次 output)/ 白名单外被拒且未送达 /
  子工具面只生成授权通道(含 `_help`)/ 父后登记的通道立刻可见 / 默认即完整授权

(cherry picked from commit 8537577123)
2026-09-13 15:09:44 +08:00
fb2db2c304 docs: 两仓文档对齐当前版本状态 + 补发版产物清单;同步 SDK meta 路牌
用户指出:SDK 版本又带 patch 位、agent 自称 1.0.3、两个仓库文档都没更新。

docs/git-branching.md:
1. **§三 状态表**重写(停在 2026-09-12:main 还写 1.3.0、release/v1.2.x 还被当成"本条发布线、
   尚无 tag"、SDK main 还写 1.2.0、完全没有 release/v1.3.x)。现按事实更新,并写明
   `v1.3.0` 是已撤回的坏 tag。
2. **§2.3** 增补:发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走;
   只用 `-ldflags -X` 打版本而不改源码会让二进制与源码对不上账(1.3.1–1.3.4 就是这么打的)。
3. **反例表**补两行:人格文本在**播种时**固化版本(生产实例自称 v1.0.3)、
   发布线路牌不随 patch 推进 / 给 SDK 误发 patch tag。并澄清"插值"必须在**渲染时**,
   把算好的结果固化进配置库与写死没有区别。
4. **§七.5 新增"发版产物清单(可复现)"**:核心仓(tar.gz + 3 个 deb + SHA256SUMS,
   校验和必须在全部产物生成后统一算)、SDK 仓(5 平台 hmapdev + SHA256SUMS)、
   上传脚本的用法与两个坑(release 条目必须先存在;`hmapdev_*` 无扩展名不会被自动识别)。
   起因就是本次"推了 tag 却没建 release、没打包"——推 tag ≠ 完成发版。

third_party/homeagent-sdk/meta/meta.go:镜像 SDK 仓 main 的路牌(1.3.0 → 1.4.0)与
版本语义注释更新(1.3.0 已定版 ⇒ 该号归发布线,main 推进)。
2026-09-13 14:41:24 +08:00
ba3ab5a7d9 chore(meta): main 的版本路牌推到 1.4.0(1.3.0 已归 release/v1.3.x 所有)
规范 §2.1 / §七.4:切出 release/v1.3.x 后,1.3.0 就归发布线所有,main 立即推进到
下一个未发布中版本。此前停在 1.3.0 属遗漏 —— 会让 main 构建出来的二进制自称已发布版本。
2026-09-13 14:34:46 +08:00
a4ebcb6e96 fix(config): 播种时不再把版本号写进人格文本 + 存量实例一次性去版本化
用户发现:agent 自报版本 **1.0.3**,内核早已 1.3.x。

根因(两层):
1. `SeedDefaults` 当年用 `fmt.Sprintf("…HΔ-Kernel v%s…", meta.Version)` **在播种时**
   就把版本写进了 `core.agent.system_prompt` —— 装完即冻住,之后每次升级都不会
   去改配置里的文本,于是实例终生自称装机那天的版本。默认模板用 meta.Version
   插值本是"不写死"的做法,但**播种 = 把插值结果固化**,等于写死。
2. `core.agent.personal_prompt`(新人格机制)本身是对的(DefaultPersonaPrompt
   无版本字面量,由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住),
   但旧键仍在系统提示词里说话,模型就照抄旧键的版本。

改动:
- **不再播种** `core.agent.system_prompt`:留空 → 组装时取 cmd/homed 的内置底座
  提示词;人格由 personal_prompt 承载。全新安装不再预置会腐坏的文本。
- 新增 `migrateSeededSystemPrompt()`(在 SeedDefaults 最前,故不被播种标记早退):
  只对"当年那段播种模板"(前缀 + `HΔ-Kernel v<数字>` 字面量双判据)做
  `v<数字>` → `v{{kernel_version}}`;用户自己写的人格卡一律不碰。
  一次性标记 `core.internal.system_prompt_deversion_v1` 守住幂等 ——
  幂等语句不等于语义幂等,重复执行会把用户后来手写的版本号也改掉。
- 与 v1.3.5 的占位符展开配套:占位符在组装系统提示词时按真实构建展开。

回归判据 4 条(`prompt_migration_test.go`):存量卡被去版本化且正文不动 /
迁移只跑一次 / 用户自写卡不动 / 全新安装不播种该键。
2026-09-13 14:34:44 +08:00
f168862eaa fix(prompt): 系统提示词支持版本占位符 —— 人格卡不再写死版本号
现象(用户发现):agent 向用户自报版本是 **1.0.3**,而内核早已 1.3.x。
根因:**人格卡是配置项**,线上 `core.agent.system_prompt` 里写死了
「HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手」——那是当年装机的文本,
之后每次发版都不会去改它,模型于是照抄给用户。默认模板用 `meta.Version`
拼接(`registry.go` 的 `fmt.Sprintf`)所以一直是对的,**只要被自定义过就会漂**。

修法:在 `buildSystemPrompt` 组装处展开占位符,让这类文本跟随真实构建:

  {{kernel_version}} → meta.Version(如 1.3.5)
  {{kernel_commit}}  → 构建 commit
  {{sdk_version}}    → 所兼容 SDK 版本(如 1.3.0)

- 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串;
- 不含 `{{` 时原样返回(提示词在热路径上);
- 覆盖所有路径:主 agent 与驻留子都经 `buildSystemPrompt`,
  `persona_set` 写入的文本同样在读取时展开(存的是模板,不是渲染结果);
- 配置项描述里写明可用占位符,引导用户别再写死版本。

回归判据 `TestExpandPromptVars`:展开正确 / 内置占位符不残留 /
线上真实人格卡文本能被纠正 / 未知占位符不被吞 / 无占位符不改写。
2026-09-13 14:34:44 +08:00
44cc824b28 chore(sdk-mirror): a2a 1.3.0→1.3.1、acp 1.2.0→1.2.1 的 plg.json 跟上 SDK 仓
SDK 仓 11303e3 升了两个示例插件的版本(补 RegisterInputChannel 后按插件自身语义升 patch),
外层仓镜像里这两个 plg.json 忘了同步 —— 镜像与源头不一致会让"装的是哪个版本"对不上账。
2026-09-13 14:30:56 +08:00
937359b4df feat(pluginmgr): plugin_install 支持本机 path(配合 plugindev_build 的产物)
背景:Agent 现在能自己构建插件了(plugindev 插件封装了 hmapdev),但安装只支持
http(s) URL —— 本地刚构建出来的 `dist/*.hmap` 装不上,链路断在最后一步。
pluginmgr 的 HTTP API 本来就接受 `{path}`(installFromPath),只是工具面没暴露。

改动:`plugin_install` 增加可选 `path`(本机 .hmap 路径),与 `url` 二选一,
同时给出时以 `path` 为准;`path` 必须存在且不是目录。描述里写明
「配合 plugindev_build 的产物用这个」。

于是 Agent 的完整闭环成立:
  plugindev_init → plugindev_build → plugin_install(path) → plgreload
2026-09-13 14:17:11 +08:00
55dc6545f5 perf(memory): 静态词向量改用 float32 存储(省 ~0.65GB 常驻)
生产实测:`[static_embedder] loaded 200000 words`(zh) + `378151 words`(en) = 57.8 万词 × 300 维,
`map[string][]float64` 光向量本体就 **1.29GB**(外加 map 开销 ~0.1-0.2GB),占 homed
4.14GB RSS 的约三分之一。

源数据(fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益:
- `words map[string][]float32` / `unkVec []float32`;
- 加载时按 `ParseFloat(..., 32)` 解析(与源精度一致);
- 相似度累加仍在 float64(`sum []float64`,读时提升),计算精度不受影响。

⇒ 向量本体 1.29GB → 0.65GB,**省 0.65GB**。(与配置侧 `#topN` 可叠加:
生产把两份 vec 各限 5 万词后,向量降到 ~0.22GB。)

防复发:`TestStaticEmbedder_VectorMemIsFloat32` 用**编译期类型断言**
(`var typed []float32 = vec`)+ 字节数断言(词数×维数×4)钉住 —— 改回 float64 会直接编译失败。

验证:`go test ./internal/memory/ ./internal/agent/core/ ./internal/nlp/` 全绿。
2026-09-13 14:09:35 +08:00
fddefc78a1 fix(plugin): "只声明出站通道"的告警改为插件加载完成后判定(此前按注册顺序误报 qq)
## 现象

生产日志(v1.3.1 启动)出现:
`[plugin] qq 只声明了输出通道 "qq",已按双向通道兜底登记 inputch;若要明确意图请显式 RegisterInputChannel`
用户据此问"qq 插件你没更新?"

## 查证:qq 没漏,是我的判据错了

- SDK 示例 `example/qq/plugin.go`:`RegisterOutputChannel("qq")` 在 368 行、
  `RegisterInputChannel("qq", {NoMemory:true, Cleaner: inputCleaner})` 在 399 行 —— **先出站后入站**;
- 生产 `plugins/qq/plugin.bin`:版本 1.4.0,且二进制里含 `inputCleaner` 痕迹 ⇒ 确实调用了入站声明;
- 我的兜底告警在 **RegisterOutputChannel 的那一刻**判"有没有入站声明" ⇒ 对"先出站后入站"
  这种完全合法的写法必然误报(a2a/acp/weather 同理)。

## 修法

告警判据从"注册时刻"改为"**插件 Start 结束后最终声明了什么**":

- `regOutput` 只保留兜底登记(功能不变),不再告警;
- 新增 `warnOutputOnlyChannels(plugin)`,在插件加载/重载成功后统一判定:
  遍历该插件**最终**声明过的出站通道,只有始终没有对应入站声明的才告警,
  且措辞改为"内核已兜底登记 inputch,若这是有意为之可忽略"。
- 判据与顺序解耦后,告警才代表真实缺口(例:weather 的 `weather_out` 与
  `weather_in` 名字不同,出站名从未被声明为入站 —— 那条告警就是真的)。

## 验证

- 新增 `TestWarnOutputOnlyChannels`:①先出站后入站(qq 写法)**不告警**;
  ②只声明出站(weather 写法)**告警且只报那一个通道**。
- `go test ./internal/plugin/ ./internal/plugins/...` 全绿。
2026-09-13 13:40:24 +08:00
3f431063e2 chore(sdk-mirror): 同步 SDK 仓 v1.3.1 的文档 —— 通道名会进 LLM 函数名(命名约束)
镜像文件:`third_party/homeagent-sdk/sdk/plugin.go`(`RegisterOutputChannel` 的命名约束)。
SDK 仓对应提交/tag:v1.3.1。

说明:内核 v1.3.1 的 tag 已指向功能修复提交 d17c186,本镜像提交在其之后 ——
文档镜像不参与二进制构建,故不影响已部署产物;功能与文档的对应关系见两仓 tag 说明。
2026-09-13 13:10:38 +08:00
18d7ad3a36 fix(remotedevice): 设备通道名改用 - 分隔并派生合规名(v1.3.0 部署后 agent 完全不应答的根因)
## 事故

v1.3.0 部署到生产后,**整个 agent 不应答**:任何对话都返回
`all 3 providers failed, last error: api error 403: model "claude-opus-5" is not allowed for this key`。
回滚到 1.2.2 立即恢复(部署前 403=0/成功对话=10,部署后 403=5/成功对话=0)。

## 根因(网关日志给出的原文)

```
tier 3 gozen/deepseek-v4.1-flash: api error 400: [invalid_request_error]
  Invalid 'tools[299].function.name': string does not match pattern '^[a-zA...
```

设备的每设备输出通道名叫 `device/<id>`,内核按 `output_send__<通道名>` 生成工具 ⇒
`output_send__device/<id>` 里的 `/` 违反上游函数名规范 `^[a-zA-Z0-9_-]{1,64}$`。
上游不是"拒掉这一个工具",而是**整条请求 400** ⇒ 网关 auto tier 全链条失败
(400/429/503 混在一起)⇒ 内核只能报"所有 provider 都失败"。
两台真实设备(waiter-fnnas / waiter-mainnas)一上线就登记了这种通道,于是必然触发。

## 修法(改插件,不改内核)

初版我在内核里加了"通道名净化 + 反向解析"层。用户否掉了这个方向,理由对:
**通道名是插件自己的声明,不合契约就该改插件**,不该让内核替插件擦屁股。
内核侧改动已全部回退(HEAD 干净)。

插件侧两处:
1. 分隔符 `device/<id>` → `device-<id>`(源码与来源标签统一,不留两套名字)。
2. 设备 id 是**外部输入**(设备自己声明),可能含空格/非 ASCII/超长 ⇒
   `deviceChannelName()` 把它派生为**合规且唯一**的通道名:
   保留 `[A-Za-z0-9_-]`、其它折成 `-`、主体截断到 32 字符(预算 64 = 13+7+32+7+…)、
   发生截断或撞名时追加 id 的 6 位短哈希。同一 id 恒定同名;真名仍用于路由与日志。

核心契约写进了插件注释与 SDK 文档(见 SDK 仓同批提交):名字若来自外部输入,
**在插件侧派生合规名**,内核不会替你净化。

## 验证

- 新增 `TestDeviceChannelNameIsLLMFunctionNameSafe`:恶意 id(空格/符号/非 ASCII/超长/
  会折成同名的两个 id)都必须派生出**合法且互不重复**的通道名与工具名。
  反向验证:把分隔符改回 `/` 即 FAIL。
- 生产两台设备派生结果:`device-waiter-fnnas`、`device-waiter-mainnas`
  ⇒工具名 `output_send__device-waiter-fnnas`(37 字符,合规)。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(remotedevice + core)无 DATA RACE。
2026-09-13 13:10:38 +08:00
201a02839e chore(meta): SDKCompatibleVersion 升到 1.3.0(内核已实现 SDK 1.3.0 的全部新增面)
SDK 1.3.0 相对 1.2.1 的公开面新增(`git diff v1.2.1..main -- sdk/` 逐条核对):
- `InjectOptions.Priority` + `PriorityL1/PriorityL2/PriorityL3/PriorityL4`
- `UnregisterOutputChannel` + `OutputChannelUnregistrar` + `SetOutputChannelUnregistrar`

内核侧两样都已实现,故按既有先例(d1959cb 随"实现 SDK 1.2.0 新增方法"同步声明)在此声明:
- Priority:`applyInjectOpts` 落 payload → 调度器 `interruptLevel` 按四级中断阶梯调度,
  L4 归内核自身与内核级插件;
- UnregisterOutputChannel:插件登记表的 `regOutputUnreg` + io 设备表注销
  (远程设备 `device/<id>` 掉线即注销,不留死通道)。

不调新面的存量插件照旧可用(新增方法由插件调用、内核实现),无需重编。
2026-09-13 12:46:31 +08:00
f0be8cbaa5 feat(remotedevice): 设备能力 outputch 化 —— 每设备一个 device/<id> 通道 + 设备指令类工具补授权闸
用户指出 remotedevice 的能力应当 outputch 化。查证后发现比"应当"更严重:
设备方向**根本没有出站实现**。

## 查到的三个缺口

1. `devicectlDevice` 一直声明 `OutputCapabilities() = CapStructured`(对外宣称可作输出目标),
   但 `Execute` 的 switch 里**没有 "output" 分支** ⇒ `output_send__devicectl` 必然拿到
   `unknown device tool output`,回模型"通过 [devicectl] 通道发送失败"。
2. 全插件没有 `RegisterOutputChannel`,也没有任何 EmitOutput/output_send 路径:
   设备方向只有「工具(请求-响应)」与「设备→agent 注入」,**agent → 设备是断的**
   (唯一的下行通道是个 HTTP 端点 `/api/v1/device/push`,不在 agent 的工具/通道模型里)。
3. 寻址是聚合的:所有设备共用一个名字 `devicectl`,没有 `device/<id>`;且设备 caps 只在
   插件内部软检查(`SupportsTool`),**绕过**了内核的 `AllowedOutputs` 授权闸 ——
   驻留子只要拿到 `device_ctl_cmdrun` 就能指挥**任意**设备。

## 按方向切分(不是一刀切)

**出站/消息类 → 每设备一个输出通道 `device/<id>`**(与入站同名):
- 上线注册、掉线注销(caps 由设备声明的 caps 映射:文本恒有;有屏→图/文件;
  speaker→音频;可跑命令(cmd/cmdrun/cmdresult)或未声明已知能力→全能力,与
  `deviceSupportsTool` 的旧设备兼容规则一致)。断连不注销会留下死通道骗模型。
- 于是自动获得:内核按 caps 在**发送前**拦(送图给纯文本音箱直接拒);
  `output_list_channels` 能列出设备;`AllowedOutputs` 可按设备收窄给驻留子。
- 上下线钩子用 `Registry.SetPresenceHandler`(**同步回调**)而不是既有的 `ChangeChan`
  (那是 select+default,缓冲满会丢事件;丢一次就留下死通道或漏注册)。
- `devicectl` 保留为聚合通道,并把它"声明了却不实现"的 output 补实:按
  `meta.device_id`(或 meta 就是设备 id / args.device_id)路由;缺省时返回**可执行**的
  报错(列出在线设备),而不是含糊失败。

**RPC 类保留为工具**(`devicedetect`/`screensee`/`computeruse`/`clipboard*`/
`device_ctl_status|cmdrun|cmdresult`):它们的返回值(图像/命令输出/状态)必须进模型
上下文,做成通道会丢掉这个语义。

**并给设备指令类工具补上同一道授权闸**(core/toolcall.go):`device_id` 指向的设备
必须是本 agent 被授权的 `device/<id>`。根 agent 默认完整授权 ⇒ 无行为变化;
驻留子收窄后,"拿到工具就能指挥任意设备"的缺口被堵上(新增 3 条 core 测试钉住)。

**设备端参考实现**(`internal/devicebridge/client/bridge.go` + waiter):新增 `op=push`
分发与 `OnPush` 回调(文本/结构化;二进制走既有 `cmd_speech_*` → `DataHandler`),
waiter 把它打到终端。

## 设计口径(用户当场纠偏,已写进代码注释与 harness README)

**主动转发只有 webui 与 cli 两个交互界面**(webui 订阅 EventAgentOutput 渲染气泡、
cli 用同步回程写回终端)。其它通道一律要求 agent **显式** `output_send__<通道>`。
我第一版给 remotedevice 加了 `EventAgentOutput` 订阅来自动回投设备——那是凭空造了
第三个转发者,违背"输出是 agent 的主动调用",已撤回(该测试一并删除)。

## 验收

- 单测:caps 映射词表;设备上线→注册通道(含同名 inputch)/掉线→注销;push 真落到
  WS 设备;聚合通道 `devicectl` 的寻址(无 device_id 报可执行错误、按 meta 投递、
  指定不存在设备报错);core 授权闸 3 例。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugins/plugin/devicebridge/sdk)干净。
- **真二进制端到端**(私有 netns + mock LLM + 真 WS 设备客户端 scripts/kernel-stress/devclient.py):
  设备上线 → 通道表出现 `device/pydev-1`(caps=[text file image audio structured],由
  `caps:["cmd"]` 映射)→ agent 经 `output_send__device/pydev-1` 主动发送 → 设备收到
  `{"op":"push","payload":"内核推给你的消息","type":"text"}` → 设备掉线 → 通道从表中消失。
2026-09-13 12:23:28 +08:00
9f80289a89 test(kernel-stress): 把"真二进制压力测试"harness 固化进仓库(私有 netns + mock LLM + 多连接驱动)
本轮压测是从 /var/tmp 里现写脚本跑的,能复现才能长期用 —— 固化到这。

为什么要有它(与 go test 的分工):单测都在进程内、参数显式给,抓不到集成面问题;
这一轮它抓到了三个只有真二进制才暴露的问题(DataDir 漏接线、插件通道未登记为
inputch、create 后子不开工),以及两处生成器缺陷(旧 SDK 导致生成工程编译失败;
模板工程从不示范通道登记)。

- `launch.sh`:在**私有 netns** 里同时起 mock LLM 与内核实例。
  必须隔离的原因:生产实例占着 *:8080/*:9890/*:9876 且插件设了 SO_REUSEADDR,
  同机再起一个实例会在 127.0.0.1 上与之并存绑定(首次实测抢到 9890 约 1 分钟);
  netns 里只有 lo,结构上不可能碰到生产端口。unix socket 是文件系统对象,
  所以驱动脚本在 netns 外照样能连。
- `mockllm.py`:OpenAI 兼容 mock(可控延迟 + SSE 分块 + 按 `!resident`/`!notify`
  标记回工具调用),让无外网也能跑、且**流式段可被中断**(压抢占路径的前提)。
  注意必须实现 `do_HEAD`:内核探活用 HEAD,501 会被判不可达 → degraded → rollback 循环。
- `stress.py`:多并发连接轰炸(单连接是串行的,造不出队列压力)+ 中断线程 + 峰值采样。
- `kcli.py`:CLI socket 客户端(`/auth` → `/kernel` / 广播)。
- README:前置配置(含探活端点、rollback 关掉)、用法、指标读法、已知坑。

实测结果(详见 6e60351 提交信息):密集 274 任务 executed=274/rejected=0;
稀疏中断下 suspended=resumed=preempted=27;驻留子全链路 + 优雅退出不留孤儿。
2026-09-13 11:38:31 +08:00
ae9b06976c chore(sdk-mirror): 同步 SDK 仓 4cb3a0b —— 通道方向契约 + 示例插件显式登记 inputch
镜像文件(外层仓按策略只跟这些;`example/` 被 .gitignore 忽略,已跟踪文件用 -f 提交):
- `sdk/plugin.go`:RegisterInputChannel/RegisterOutputChannel 的方向契约文档
  (入站 vs 出站分开登记;用哪个通道名注入就要登记哪个)。
- `example/{a2a,acp,browser,memo,calendar,rss}/plugin.go`:补 RegisterInputChannel。

未镜像(外层 .gitignore 忽略,按策略不入镜像):
`README.md`(README*)、`tools/hmapdev/{templates.go,cmd_sdk.go,cmd_build.go}`(tools/)
—— 即"模板工程"与"`sdk install --from`"两项改动只存在于 SDK 仓,详见该仓提交 4cb3a0b。

注:这 6 个示例此前未 gofmt(`example/*` 共有 9 个未格式化文件),
本次改动文件被 gofmt 顺带规范,diff 里含格式噪音(calendar 49 行、a2a 33 行)。
2026-09-13 11:38:10 +08:00
6e6035141a fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。

## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来

现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)

修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。

## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败

现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)

修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
  `clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
  才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
  实测:改完内建插件后,启动日志里兜底告警 **0 次**。

## ③ create 之后子不开工 ⇒ rounds 恒为 0

现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。

## 测试

- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
  随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
  断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。

## 真实二进制压力测试结果(修复后)

私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
  峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
  `interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
2026-09-13 11:38:03 +08:00
aba1770701 fix(lightkernel): 传统上下文落到实处 —— 子不做策略性裁剪(并修掉裁剪估算的字节/rune 单位混用)
用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走
`formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。
即"动态上下文"(父专属能力)漏进了轻量内核。

## 三处闸门(按 isLightKernel() 判)

1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`,
   子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`;
   漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。
2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。
3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。

## "不裁"的准确含义

不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束;
且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒
**丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。

## 顺带修掉的既有 bug(单位混用)

`formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 ——
`len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍:
实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、
把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。

## 测试

- 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider
  断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段,
  消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上);
  填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。
- 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。

全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。
2026-09-13 10:36:04 +08:00
cba33d2a6f test(resident): 压力规模可用环境变量放大(RESIDENT_STRESS_N / RESIDENT_STRESS_ROUNDS)
默认仍是 8 子 × 12 轮(CI 友好);要跑加重压力就放大:
  RESIDENT_STRESS_N=64 RESIDENT_STRESS_ROUNDS=40 go test -race -count=2 ./internal/agent/core/ -run TestResident_E2EAndStress

实测已跑:
- 24 子 × 25 轮(-race):通过
- 64 子 × 40 轮(-race -count=2,即 5120 轮次):通过
2026-09-13 10:25:49 +08:00
2ebbdadcd6 feat(resident): N3–N7 驻留式子 agent 全量落地(生命周期/双向投递/处理表/contextfull/e2e+压力)
设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。

## N3 生命周期(resident.go)

- `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核;
  划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/<id>` 入站 inputch;
  建独立 `IOManager`(共享通道登记表);启动子。
- `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。
- `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。
- `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。

## N4 跨 agent 投递

- 子→父:`notify_parent` → 投进父的 `child/<id>` inputch,优先级 **L3**。
- 父→子:`SendToResident` → 投进子的 inputch,优先级 **L4**;
  `isKernelLevelSource` 泛化为"该 agent 的上级"(`AgentConfig.KernelSource`)⇒
  只有父能在子的阶梯上产生 L4(子内部一律 ≤L3)。
- 子的 contextfull → 父侧 `raiseKernelInterrupt`(内核级事件,带子标识,父侧 L4)。

## N5 inputch 处理表

- 子持有;`inputch_note` 主动写**优先**,轮末 `autoRecordInputch` 兜底 ⇒ 每轮必有记录。
- 压缩时清表(表记的是被压掉那段窗口的逐轮处理)。

## N6 contextfull(判据修正)

初版判据是"拼好的 `f.Msgs` 估算 > 90% 窗口",**结构上永不成立**:
`buildMessages` 拿到的 `budget.ContextTokens` 由 `targetUsage = 0.8 × 窗口` 推出,
时间线**在拼进消息之前就被预算裁过**,`f.Msgs` 封顶在 ~80% 窗口。
(初版测试用一个比系统提示词还小的窗口才勉强越线 —— 那等于什么都没测。)
现判据 = **未裁剪的积累上下文**(`a.context.Recent(0)`)超过窗口 90%:
它超过就说明下一轮必须丢事件,这正是"上下文满"。

三处置:`CompressResident`(保留语义:`TrimKeepRecent` 保留最近 N 条 + 清表)/
`ReclaimResident`(取消语义:`ExportTriples` 读 temp → 父选出要保留的 → `Commit` 进 main → 取消该子)/
`DestroyResident`(立刻销毁并移除)。

## 工具面

`resident_agents`(父,单工具多动作:list/create/send/inspect/compress/reclaim/destroy)、
`notify_parent` + `inputch_note`(子)。声明条件式:父才有前者,子才有后两者。

## 验收

`resident_test.go` 五项:生命周期与不留孤儿、双向投递(含"子的主动消息不得以 L4 出现")、
处理表(自动写 vs 主动写优先)、contextfull + 三处置、
**压力 8 子 × 12 轮(父→子 L4 与普通输入各半)+ 双向汇报 + 父退出清理**。
全仓 go test ./... 37 包 ok / 0 FAIL;`-race`(agent/memory/plugin)干净;
压力 `-race -count=3` 通过。
2026-09-13 10:20:06 +08:00
48cfa8fb6c feat(lightkernel): N2c —— 轻量内核 profile(窄接口 GraphMemory + nil 即禁用整理面)
按用户指出的关键点(a.memory 多数使用点属"主 agent 整理记忆"与"记忆整理流水线",
子不该有那些路径)实现,方案见设计 §16.0。

## 窄接口:只有 Recall + Commit

新增 `GraphMemory` 接口(memoryface.go)—— 按调用方实测分类后,真正"根与子都要"的只有这两个:
- `Recall`:上下文检索 / memory_recall
- `Commit`:自动写入路径的图部分 / memory_commit

新增 `Agent.graph`(共同面)与 `Agent.graphMem()` 访问器:
- `graph` 显式为 nil 时**回落**到 `memory` ⇒ 既有"只用 Agent 字面量设 memory"的测试无需改动
  (原本会出现"必须同时设两个字段"的脚坑,实测踩到后去掉了)
- 轻量内核:`graph = *memory.LightMemory`,`memory = nil`

## nil 即禁用:整理面自动消失,不需要受限包装

子的 `a.memory == nil` ⇒ 既有的 22 处 `if a.memory != nil` 关卡自动禁掉全部整理面:
- 记忆整理流水线(distill.go 的 archive/review/merge 循环)
- 记忆块 + 媒体桥(graphmedia.go / medialoop.go)
- 记忆整理工具(memory_merge / memory_delete_entity / memory_block_merge /
  memory_purge / memory_edit / memory_introspect)—— 它们本就在 `if a.memory != nil` 块内

唯一拆开的一处是自动写入 `commitTriplesWithMedia`:
图部分走 `graphMem().Commit`(父落 main、子落 temp),块/媒体部分仍由 `a.memory != nil` 守卫。
`executeMemoryTool` 的读路径改走 `graphMem().Recall`;整理类 case 加 `requireFull()` 闸门,
被直调时明确报"本 agent 是轻量内核:记忆整理不可用",不静默降级。

## 验收(3 项新测试)

- `TestLightProfile_MemoryFaceWiring`:整理面为 nil、写入只落 temp(主库无子痕迹)、
  读是并集(主库实体 + temp 实体都看得到)
- `TestLightProfile_OrganizeToolsAbsentAndRefused`:整理类工具**不进工具表**;
  即便被直调也明确报"轻量内核不支持"
- `TestFullProfile_KeepsOrganizeFace`:对照,根 agent 仍保留整理面与整理工具

全仓 go test ./... 37 包 ok / 0 FAIL;-race(agent/memory)干净;gofmt 干净。
2026-09-13 10:02:02 +08:00
93aa942e1e test(plugins): deepsearch E2E 不再带走共享搜索后端(附回归判据)
背景:E2E 临时目录里拉起的插件实例在 teardown 时执行了 `docker compose stop -t 2`,
把线上正在用的 SearXNG 关掉,表现为「搜索后端起不来」。

- integration_test.go:把 ConfigRegistry 暴露给测试环境(其余不变)
- deepsearch_e2e_test.go:
  - forbidStoppingSharedBackend:测试实例一律 stop_searxng_on_exit=false
    (配置表按内核约定先 RegisterDef 再 Set)
  - 新增 TestRealPlugin_DeepSearchKeepsSharedBackendOnStop:停掉插件实例后,
    6 秒内 healthz 必须始终 200 —— 判据落在网络层,直接钉住「跑测试不能断线上搜索」

验证:三条 E2E 全过,且跑完 healthz 仍 200、容器 StartedAt 未变(未重启)。
2026-09-13 09:55:33 +08:00
c5b1242980 docs(resident-subagent): 更正 N2c 方案 —— 窄接口(只剩 Recall/Commit)+ nil 即禁用
用户指出:a.memory 的多数使用点属于**主 agent 整理记忆**与**记忆整理流水线**,子根本不该有那些
代码路径。据此把上一版"约 20 方法的接口 + 受限包装"改成**实测分类 + 窄接口**。

按调用方实测分类(42 处):
- A 记忆整理流水线(distill.go 10 处:archive/review/merge 循环)→ root-only
- B 记忆块 + 媒体桥(graphmedia.go 18 + medialoop.go 4)→ root-only
- C 记忆整理工具(executeMemoryTool 8 处:merge/delete/block_merge/purge/edit/stats)→ root-only
- D 共同面:**只有 Recall + Commit**(自动写入路径 + memory_recall 工具)
- E 22 处 if a.memory != nil 既有关卡 + 状态/工具表判空

更正后的方案:
- `GraphMemory` 接口**只含 Recall + Commit**
- 根:a.graph = a.memory = 同一个 *GraphDB
- 子:a.graph = *LightMemory,**a.memory = nil** ⇒ 既有 22 处 nil 关卡自动禁掉全部 root-only 路径
  (executeMemoryTool 开头本来就是 `if a.memory == nil { return "图记忆系统不可用" }`)
- 唯一要拆的:自动写入 commitTriplesWithMedia(图部分走 a.graph.Commit;块/媒体部分用 a.memory != nil 守卫)
- 整理类工具**不进子的工具表**(而不是进去再报不可用)

⇒ 不必写"18 个方法都返回错误"的受限包装 —— 子压根没有那些路径。
2026-09-13 09:42:43 +08:00
f0562915db feat(memory): N2a 第二块砖 + N2d 数据面 —— 轻量图记忆装配(temp 可写/主库只读/并集)与回收合入
按用户确认的形态:**独立存储实例**(不给共享记忆层加 space 列)。

## LightMemory:子的图记忆装配(设计 §5.6)

    temp 实例(独立存储,读写)   ← 子的一切图记忆写入落这里,与子同生共死
    主库受限句柄(只读)          ← 子只能读(query_only 结构性拒绝写入)
    子的查询 = 两实例各查一次 + **应用层合并**(并集)

- 写入**只落 temp**(`Commit` 不接受 main 方向)
- 并集合并规则:实体按**名字**去重(同名保留 mention_count 较大者)、
  关系按 (源名, 关系, 目标名) 去重;结果排序确定(便于断言与展示稳定)
- 单侧查询失败不影响另一侧(只有两侧都失败才报错)
- `AllowWrite=false`(用户给的备选简化):**没有 temp 实例**,子对图记忆完全只读,
  写入被拒;读主库照常

## ExportTriples + 回收合入(设计 §9,N2d 数据面)

- `GraphDB.ExportTriples(limit)`:导出**活跃**三元组,把实体名一并带出
  ⇒ 合入侧直接复用 `Commit`(按实体名 upsert + 关系唯一约束)
- 回收主流程:子写 temp → 父导出 → **父选哪几条** → 写进 main。
  未选中的**不进**主库;重复收割**幂等**(不产生重复实体)

## 验收(7 项新测试)

`light_memory_test.go`(5):
- 写只落 temp、查询是并集、主库无子的痕迹
- **两个子的 temp 互不可见**(只有 main 共享)
- 写禁用时完全只读(无 temp 实例、写入被拒、读照常)
- 并集去重(同名实体只出现一次)
- 合并确定性(去重 + 排序 + mention_count 取大)

`reclaim_test.go`(2):
- 导出只含活跃关系且带实体名、limit 生效
- 回收主流程(选中的进主库、未选中的不进、重复收割幂等)

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:38:54 +08:00
818ce2698f feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"
按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。

## 结构性保证

新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 ——
连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让
任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。

为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal,
而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。

⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移
(库由父建好,受限句柄不会凭空造出一个空主库)。

## 设计文档

新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配:

    子的轻量内核
    ├─ temp 图记忆实例(独立存储,读写)  ← 与子同生共死
    └─ 主图记忆的受限句柄(只读)
    子的查询 = 两个实例各查一次 + 应用层合并(并集)
    回收时由父读 temp、选记录、写进主图记忆

并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时
子对图记忆完全只读,没有 temp 实例、没有合入。

## 验收

`internal/memory/graph_readonly_test.go`(2 项):
- 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹**
- 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:32:29 +08:00
36e7556a02 docs(resident-subagent): 子的记忆面收窄为「传统上下文 + 图记忆」
用户澄清:**doc 记忆**与 **context 动态上下文**是内核独立设计的记忆能力(父专属),
子 agent 的记忆面只有 **图记忆**。据此更正设计:

- §5 开头加适用范围界定:两级空间(读 temp∪main / 写 temp)**只针对图记忆**
- 新增 §5.5「子的记忆面」对照表:
    | 能力 | 根 | 驻留子 |
    | 图记忆(含向量检索) |  main 读写 |  作用域化 |
    | doc 记忆 / context 动态上下文 / 蒸馏 / 归档 / consolidation / 文本 / 知识库 / 媒体 / 社交 |  |  父专属 |
- 修正「轻量内核」的表述:不是"记忆变轻",而是**记忆面裁到只剩图记忆 + 图记忆被作用域化**
- §16.1 记忆面清单加「子可用?」列;**v1 只给图记忆(含其向量检索)加 space 维度**
  —— 其余面子根本够不到,加 space 是白工
- 里程碑:N2a = 图记忆 space 维度;N2b = 图记忆的向量检索接入同一过滤
- 决策表补 R3b

纯文档更正;全仓 go test ./... 37 包 ok。
2026-09-13 09:24:09 +08:00
069552e921 docs(resident-subagent): 把 N2 拆成 N2a–N2d(记忆作用域逐面铺开)+ 记忆面清单
N2(轻量内核 + 记忆作用域)是目前最大的一块:记忆子系统有 7 个面
(图记忆/向量索引/文档/知识库/文本/媒体/社交),每个面都要加 space 维度。

拆成可独立验收的四步:
- N2a 作用域对象 + **图记忆** space 维度(先做这一条纵切)
- N2b 其余记忆面加 space(逐面验收)
- N2c Agent 级作用域接线(根 = {main,[main]};驻留子 = {sub/<id>,[sub/<id>,main]})
- N2d 晋升与丢弃(回收时父把选中的 temp promote 进 main;销毁/回收丢弃 temp)

文档新增 §16.1 记忆面清单(面 → 载体 → 表),说明为何先做图记忆这条纵切。
2026-09-13 09:15:58 +08:00
f7c3a4e81d feat(channel): N1b —— 输出通道授权集合(三处过滤一致)+ 输出通道→目标 agent 的 inputch 解析
设计:docs/zh/resident-subagent-design.md §4.5(里程碑 N1b)。

## 输出通道授权集合(默认完整授权,父可收窄)

`AgentConfig.AllowedOutputs`(nil/空 = 完整授权)。三处过滤点必须一致,
否则会出现「列表里看不到、按名字还能调」的裂缝:

1. **工具表**:不为未授权的通道生成 output_send__X(模型看不到就不会调)
2. **列表工具**:output_list_channels 只列授权的
3. **调用点**:凭名字直调未授权的输出门必须被拒(纵深防御)

## 输出通道 → 目标 agent 的 inputch 解析

`ChannelRegistry.BindOutputTarget / ResolveOutputTarget`:
把输出通道解析成「目标 agent + 目标 inputch」,这是"输出可寻址到具体 agent"
(子→父、父→指定子)的**数据面**;真正的跨 agent 投递在里程碑 N4。
未登记的输出通道 ok=false —— 表示由传输层 device 自行处理(qq/webui 这类)。
已登记目标的输出通道,在 output_list_channels 里会标出「目标: <agent> / inputch <名字>」。

## 验收

`internal/agent/core/output_grant_test.go`(3 项):
- 默认完整授权:全部输出门生成 + 列表含全部
- 白名单收窄:三个过滤点同时生效(工具表 / 列表 / 直调被拒)
- 目标解析:绑定/解析、未登记由传输层处理、列表标出目标、空名报错

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:13:19 +08:00
a2dcd96fee chore: 撤回误提交的未跟踪文件 internal/plugins/deepsearch_e2e_test.go
该文件是工作区里**别人 in-flight** 的未跟踪文件(我上一步用 git add -A internal/ 时被顺带收进来)。
它不是我这次改动的一部分,因此从索引里撤出,文件本体保留在工作区(仍为未跟踪状态),
由它的作者决定何时提交。
2026-09-13 09:07:02 +08:00
3c961b7bd7 feat(channel): N1a —— inputch 一等化(归属插件/归属 agent/容量/共享登记表)+ 单工具多视图总览
设计:docs/zh/resident-subagent-design.md §4.6(里程碑 N1a)。

## 用户要求

「父 agent 可以看到所有已注册的 inputch 以及 inputch 的划分情况,用**单工具多视图**方式构筑」

## 登记层(internal/agent/io/inputch.go)

inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个),
所以登记表以 inputch 为键,每条记录:

    名字 / 归属插件 / 归属 agent(被划给谁) / 容量 / 默认回程 outputch / 记忆策略(ChannelDef)

- 新增 `ChannelRegistry`,设计成**可共享对象**(`*ChannelRegistry`):
  根 agent 与驻留子共用同一份,"划入/授权"才有意义;`SetChannelRegistry` 注入。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
  保留已有 Owner/Capacity/Output(否则一次 reload 就把父做的划分清空)。
- `Assign` 语义按设计 R6 默认:**读写授权,不转移所有权**(Plugin 与 Owner 分别记录)。
- 原 `inputChannels map[string]ChannelDef` 被登记表取代;`GetInputChannelDef` 保持兼容。
- `plugin.Registry` 注册时带上**归属插件名**(此前完全无归属信息)。

## 总览工具(internal/agent/core/inputch.go)

单工具 **`input_channels`** + `view` 参数(不是一堆小工具):

    all(默认)= 全部已注册(带归属插件)
    mine       = 划给本 agent 的
    unassigned = 尚未划出的
    by_agent   = 划分情况总览(按归属分组)
    detail     = 单个 inputch 全字段(需 name)

未知 view **报错并列出可用值**(拼错不得被静默当成默认视图);登记表为空时明确说明。

## 验收

- `internal/agent/io/inputch_test.go`:归属记录、重载保划分、Assign/视图数据面、
  **跨 manager 共享登记表**、策略查询向后兼容 —— 5 项
- `internal/agent/core/inputch_test.go`:单工具多视图逐视图断言(含未知 view 与空表)—— 2 项
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/... ./internal/plugin/...` 干净
2026-09-13 09:06:50 +08:00
f2ec46480e refactor(core)!: N0 无状态化 —— 删除 Agent.currentOutputChannel,通道只跟输入事件/帧走
驻留式子 agent 设计(docs/zh/resident-subagent-design.md)的里程碑 N0。

## 问题

`a.currentOutputChannel` 是 **agent 级可变字段**,只在 prepare 段写入,而被打断任务
恢复时**不重新 prepare**(resumeTask 只 rebase 前缀)。于是中断任务 prepare 时把它
覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两个任务串台。

后果不只是标签错:工具提示词里那句"当前输入来源通道是 X,对应输出门工具是
output_send__X"会诱导模型**把回复主动发到错误的通道**。

## 两处一起改(用户指出的两件事)

1. **内核不应持有"当前通道"**:通道是随输入事件带进来的,路由发生在**进内核之前**,
   输出是 agent 的**主动调用**。删除该字段,改为一律从输入事件推导
   (`outputChannelOf(evt)`)或读本任务的帧(`f.OutputChannel`)。
2. **提示词不应预设 outputch**:删掉"当前输入来源通道是 X → 用 output_send__X"那两行,
   改为"不要假设当前通道是固定值;先看消息本身与上下文的来源信息,不确定时先调
   output_list_channels"。

## 改动面(把通道一路显式传下去,而不是读共享状态)

- `agent.go`:删字段
- `task.go`:新增 `outputChannelOf` / `isCriticalChannel`;帧记录通道;
  安全点与 setCritical 用帧/事件推导;步骤内事件标签改用 `f.OutputChannel`;
  `executeToolCall(f.CurTool, f.OutputChannel)`;`callLLMWithFallback(..., f.OutputChannel)`
- `process.go`:`chatStreamWithFallback` / `accumulateStream` 增加 channel 参数
  (增量事件的 channel 标签由此而来)
- `stage.go`:`runStage` 从 `ctx.Extra["output_channel"]` 读(发起方写入)
- `eventloop.go`:`emitResponse` 用 `outputChannelOf(evt)`;stageCtx 带上通道
- `spawn.go` / `toolcall.go`:`executeSpawnChild` 的 parentChannel 由调用方(帧)传入
  (子任务完成通知要回到**发起这次 spawn 的那个任务**的通道)
- `distill.go`:删掉 consolidation 路径里的赋值
- `tooldefs.go`:删掉提示词里的通道预设

## 验收

- `scheduler_channel_routing_test.go`(N0 守卫):中断任务跑过之后,被恢复任务的
  输出通道仍是它自己的(改前实测为 cli,期望 qq)
- `TestCriticalSection_ConsolidationMarked`:补上推导链
  「输入事件 → 通道 → isCriticalChannel → scheduler.critical」的集成断言
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/...` 干净
- 残留 `currentOutputChannel` 引用为 0(只剩描述历史的注释)
2026-09-13 08:59:15 +08:00
3956610134 docs(resident-subagent): inputch 是最基本的输入路由单位(插件可注册多个)
按用户补充收窄定义:
- inputch 是**最基本的输入路由单位** —— 路由粒度到此为止(比「插件」细、比「通道名字符串」实)
- **由插件注册,且一个插件可注册多个**(登记接口即现有 RegisterInputChannel,
  调 N 次就是 N 个 inputch;同一插件的多个 inputch 彼此独立,可绑给不同 agent、可分别限额)
- 「划入输入通道」的单位 = **inputch**(不是插件、不是通道组)
- 通道注册层以 inputch 为键(一个插件 → N 个 inputch),每个 inputch 带
  归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析

新增测试点 S21(同一插件两个 inputch 分别划给父与子,互不串台)。
2026-09-13 08:40:04 +08:00
fc41e160db docs(resident-subagent): 驻留式子 agent 设计稿(轻量内核 · 两级记忆 · 父子中断)
把用户口述的设计固化成文档,与已落地的 input-scheduler-design.md 配套。

定下来的核心:
- inputch = 对「中断输入/排队输入」两者的高层抽象,是**路由与分配**单位
  (路由发生在进内核之前;类别与级别是每条输入的属性,不是 inputch 的属性)
- 输出是 agent 的**主动调用**(output_send__{通道});内核不持「当前通道」可变状态、
  提示词不预设 outputch
- 通道不配对:父**划入若干输入通道** + 授权**一组可用输出通道**;输出可寻址到具体 agent
- 驻留子 = **独立轻量内核**:读 temp∪main、写 temp(记忆层按 agent 作用域化 ——
  这才是「轻量」的真正理由,不是砍功能)
- 两条独立中断阶梯:父侧 子的主动消息=L3 / contextfull=L4;子侧 **父的消息=L4**
  (L4 通则:某 agent 的 L4 只属于它的内核 ⇒ isKernelLevelSource 泛化为「该 agent 的上级」;
  子内部一切来源被夹到 ≤L3,所以父的消息确定能打断子)
- 父对子 6 个动作:创建/发送消息/查看/压缩/回收/销毁(原语在内核、决策在父的模型);
  插件与工具由父授权,**默认完整授权**
- contextfull 三处置:压缩=保留(清处理表)/ 回收=取消(promote 选中的 temp 进 main)/
  销毁=立刻销毁并移除
- inputch 处理表:子持有、父 pull;主动写入优先,否则系统自动写;生命周期 = 当前上下文窗口
- 登记表 + 硬约束:父可随时销毁,父退出必须销毁全部,子不得比父活得久
- 工作式轻量子 agent **原样保留**,两类并存

含 20 个测试点(S1–S20)与 8 个里程碑(N0–N7,每步独立可验收)、
12 条待确认决策(全部带可逆默认值)。
2026-09-13 08:39:40 +08:00
158395bfdf test(scheduler): 优先级压力测试(100 排队 + 100 中断各级混合、嵌套到 4 帧上限)
按需造一个固定内容的 fakeprovider,做两件事:

E4:混合载荷(用户指定的形状)
  100 条排队输入 + 100 条中断(L1/L2/L3/L4 各 25)混合打入。每条中断都等到
  “该被它打断的受害者正在跑”时才注入——调度器是单线程的,闭着眼睛猛灌只会
  让绝大多数中断退化成排队,压力就压不到抢占/挂起路径上。
  判据:200 个任务全部到达终态、Rejected=0、各级登记数=25、**各级抢占数都>0**、
  排空后 Suspended==Resumed、每次“取消流式段”都换来一次挂起。

E5:嵌套到结构上限
  排队任务运行中依次注入 L1→L2→L3→L4(每级都等上一级在跑)。判据:栈深峰值
  恰好 4(= 结构上限,此时 canSuspend()=false),恢复顺序严格 LIFO
  [L3,L2,L1,排队],Suspended==Resumed==4。

顺带修掉两处:
- **Resumed 双计**:nextRef 弹栈与 resumeTask 各计一次,使“排空后
  Suspended==Resumed”这条不变量失真。现在只在 resumeTask 计(nextRef 只负责选出)。
- 新增分级可观测:SchedulerStats.InterruptsByLevel[1..4] / PreemptsByLevel[1..4]。
  只看总数会掩盖“总数一样但级别分布完全不同”,按级别验收才是这套调度器的判据。
  注意 PreemptsByLevel 是“判定可抢占并进入 immediate”的次数,与 Suspended 不等价:
  受害者可能在让位生效前就自行结束,此时抢占者只是“下一个运行”,没有挂起发生。

教训:provider 必须感知 ctx 取消。第一版没做,结果 LLM完成==任务数、挂起≈0——
抢占全落在“步骤之间”,流式段(真正需要保存/恢复现场的地方)一次都没压到。

验收:go test ./... 37 包 ok 0 FAIL;-race 全绿;压力连跑 3 次稳定;
新内核 go build 通过并真实启动冒烟 OK。
2026-09-13 07:31:32 +08:00
f3232000f4 feat(scheduler): L4 也归内核级插件 —— WebUI 终止按钮可用“立即打断”
上一提交把 L4 写成“内核独占(panic / selfip)”,漏了内核级插件这类来源。
用户澄清:**内核级插件应当能声明 L4,用于实现中断能力**,例如 WebUI 的终止按钮。

判据(两道闸,纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3。在这里夹而不是只按 source 判,
     是因为 source 是插件自报字段、可以冒名;本函数所在位置能确知“来自外部进程”。
  2. core:isKernelLevelSource(source) 查 pluginReg.IsBuiltinPlugin,只有编译期内置
     插件(init() 自注册的工厂)才承认 L4。
source 约定 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段——
否则带设备身份的 WebUI 来源会被误判成外部插件而拿不到 L4。

改动:
- core: interruptLevel(evt, privileged bool);新增 isKernelLevelSource;
  requestPreempt 不再夹取(级别已由 interruptLevel 解析,否则内核级插件的 L4 被削掉)。
- eventloop: 传入 a.isKernelLevelSource(evt.Source)。
- proc 桥: 新增 clampExternalPriority,pubSdkInjectOpts 一律夹取。
- internal/sdk: 再导出 PriorityL1..L4(内置插件用 sdk.PriorityL4)。
- webui handleChatInterrupt(终止按钮)声明 PriorityL4。
- timer 声明 PriorityL3:定时器是“时钟那种实时工作”,比 QQ 那类可无限等待的
  异步消息高(L1)——这是对用户“它不是时钟那种实时工作”的直接推论,可改。
- 测试: L4 特权矩阵(非特权夹取 / 特权承认)、source 判据(内置、内置/实例、
  外部、空、前缀不误匹配)、内核级插件 L4 一路到达调度器、proc 夹取两条。
- 设计稿 §2/§3.2/§11.1/§14/§15 按“L4 = 内核 + 内核级插件”更正。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿(含 webui/timer)。
2026-09-13 07:18:03 +08:00
cb32032f76 feat(scheduler)!: 中断/排队两类别模型 + 插件声明 L1-L3、L4 内核独占
用户澄清推翻了早期设计的三处前提,本提交按新模型重做调度核心(行为有意变化):

1) 类别由注入 API 决定,与通道名无关
   - InjectInterrupt*                      -> TaskInterrupt(带级别,可被严格更高级中断打断)
   - InjectText*/InjectInputSync*/内核自循环 -> TaskQueued(无级别,可被任何中断打断)
   - 删除按通道名推断的 taskLevel():qq 走 InjectInterruptTextOpts,本就是中断

2) 级别只属于中断
   - 插件在 InjectOptions.Priority 声明 L1-L3(空/非法降级 L1,声明 L4 夹到 L3)
   - L4 内核独占:新增 raiseKernelInterrupt(panic / selfip);requestKernelPreempt 不夹取
   - panic 现在产生一条带 kernel 标记的 L4 中断;L4 自身 panic 不再产生新 L4(防自我放大)

3) 选择结构:四容器固定次序,删除统一比较器
   - immediate(抢占者立即运行)-> 中断队列 L4..L1 -> 栈顶(与队头比级别) -> 排队 FIFO
   - 删除 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁(根因是抢占者进了队列)
   - 中断栈上界改为结构推论 = 4(= 中断级数);删除“超限转 pendingInterrupts”降级

公开 SDK(feature 分支有意新增,纯追加):InjectOptions.Priority + PriorityL1/2/3;
内核 io / proc 桥 / 插件模板同步透传。

设计稿 §2/§3/§4.1/§6.3/§9/§11/§12/§13/§15 按新模型重写。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿;
e2e(抢占-挂起-恢复)+ 压力(200 排队 + 50 中断,L1/L2/L3 轮转)通过。
2026-09-13 07:00:51 +08:00
86a702b3b0 fix(scheduler)!: 中断栈语义(嵌套抢占 LIFO),并修掉抢占空转
用户指正:存在**中断被中断**的场景,所以被打断的现场要进**中断栈**。
我此前把 suspendPool 明确写成“不是栈、按优先级取”,是错的。

改动:
- suspendPool 改名 suspendStack,恢复纪律改为**严格 LIFO(只比栈顶)**;
  栈内不做优先级重排——嵌套抢占天然使栈自底向上基础级递增,
  且“后被打断的先恢复”才是栈语义。取出即弹栈。
- 修掉一个由此暴露的真 bug(抢占空转):一次抢占生效后,被挂起的原任务
  会因饥饿防护提升有效级,与抢占者同级;此时若按“先到先服务”,原任务
  (入队更早)会被立刻选回,抢占者永远排不到 —— 抢占等于没发生。
  现在**同级时 pendingInterrupts 优先于其它两类**,保证抢占必然生效。
- 状态 DTO:SuspendPool/suspend_pool → SuspendStack/suspend_stack
- 设计稿:§2 用语更正(它**就是**中断栈)、§4.1 选择函数(候选只含栈顶 +
  pending 同级优先,并说明为何必需)、§6.2/§6.3/§9/§11 用例同步

测试新增 scheduler_stack_test.go 3 项:
- 嵌套 L1→L2→L3,恢复严格 LIFO(B 先于 A)
- 只比栈顶:人为构造“栈底 L3、栈顶 L2”,必须取栈顶(区分两种实现)
- 嵌套下的深度上限

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:30:23 +08:00
d4764e3682 fix(scheduler)!: D1 更正为「中断从上一个任务之前的完整状态开始」,并实现现场合回
用户明确语义(我此前对 D1 的解析就是错的——当时回答里的“A”指的是 git 选项,
D1 实际要的是方案 B):

  中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall),
  然后中断在「上个任务前的那个完整状态」上开始运行;
  中断结束后再把被挂起的任务与其上下文现场加载回中断任务之上,并继续运行。

实现:
- 删除 SeedMsgs 与 D1=A 的“只读前缀”机制:中断任务不再继承被打断任务的任何内容,
  它就是普通新任务,正常走完整 prepare(system prompt + timeline + 自己的输入)
- TaskFrame 新增 PrefixLen(基础前缀长度)与 InputBlocks;
  stepPrepare 在 buildMessages 之后记录 PrefixLen
- 新增 rebaseFramePrefix:恢复时重建基础前缀(中断已提交进 a.context,
  重建的 timeline 含中断效果=“加载回中断之上”),再把本任务自己的尾部
  (Stage 上下文 + 工具轮产物 + 占位)接回;并补回 IsInterrupt 标记与多模态块
- resumeTask 在 runTaskSteps 之前调用 rebaseFramePrefix
- 设计稿 §5.3 改写为「已定:D1=B」并写明实现对应;§6.2 补“重建前缀→接回尾部”;
  §12 的 D1 行更新

测试:
- TestPreempt_HigherPreemptsAndResumes 改为断言「中断不继承、恢复后看得见中断内容」
- 新增 TestPreempt_ResumeRebaseRestoresTailDecorations(前缀重建后尾部装饰补回)
- 原 TestPreempt_SeedPathDoesNotLeakInterruptFlag 随之删除(机制已不存在)

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:24:20 +08:00
8372f5bd8f fix(scheduler)!: 撤掉“优先级=可配置策略表”的错误设计,回归内核内部属性
用户指正:**优先级是内核内部属性**,不是配置项,更不该由插件声明。
我此前把它建模成“策略表 + 字符串解析”,甚至准备接配置中心
(core.agent.priority.<channel>)——方向性错误,故整体撤销。

撤销:
- 删除 ParseLevel(字符串解析只服务于“外部可配”这个错误前提)
- 删除 AgentConfig.PriorityLookup / Agent.priorityLookup 及 taskLevel 中的查表分支;
  taskLevel 回归为纯内核内部规则(cli/webui/http→L3,system/_consolidation_→L1,
  其余 L1),注释明确“不对外暴露、不做运维可调项”
- 设计稿 §3.2 改写为“内核内部属性,不做成配置项”,并删除 §15 里
  “ChannelDef.Priority / InjectOptions.Priority 进公开 SDK”这一方向(同属外化)
- 未触碰配置中心(registry.go/main.go 的优先级配置一行未加)

同时落地 D6(与本撤销无关、此前遗漏的承诺):
- AgentConfig.MaxToolTurns + runTaskSteps 在发起新一轮 LLM 前按 f.Turn 收尾;
  0 = 不限;cmd/homed/main.go 从既有 core.agent.max_tool_turns 取值
- 新增 task_test.go 2 项:上限 3 时恰好跑 3 批工具/3 次 LLM 并收尾;
  0 = 不限(跑完脚本)

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:17:49 +08:00
98d67559d1 fix(scheduler): 补齐 M3 与设计稿的两处语义偏离(发现即修)
两处都不是风格差异,而是真的偏离设计语义(其一为回归),
已按“先写判据确认失败、再修”的方式处理,判据保留为回归测试。

1. 空闲时到达的中断永远不会被处理(设计 §5.1 ③ 未落地)
   调度器空闲时只阻塞在 select{InputChan, selfInputCh, ctx.Done},
   而 pendingInterrupts 不是 channel——interceptLoop 把中断入队后
   没有任何东西唤醒调度器,中断要等“下一条输入”才被看到。
   修复:调度器加 wake channel,enqueueInterrupt 非阻塞 signalWake,
   空闲分支增加 wake 分支。

2. 临界区内只“不让位”却仍被“取消”(设计 §4.3/§5.2)
   requestPreempt 不判临界区,interceptLoop 照常 cancelLLM,
   于是正在流式的记忆整理被中断,stepLLM 以 error 提前结束——
   整理任务被砍掉一半,而设计要求的是“请求排队等它结束”。
   修复:scheduler 增加原子 critical 标志(帧仍只由调度器读写),
   runInputTask 在 prepare 后设置、结束(含挂起)时清除;
   requestPreempt 在临界区内不 arm、不取消,中断只入队。

- 新增 scheduler_regression_test.go 2 项(先失败后通过)
- 验收:agent 全量 + -race;全仓 vet 通过
2026-09-13 06:10:44 +08:00
6bd3313a22 docs(scheduler): 回写 M1–M7 实现状态与 5 处实现期偏差
- 里程碑表补提交号与验收结果
- 记下与设计稿的偏差:M3 拆分、a.mu 移除、interceptCh 删除、
  M0 伪时钟未做(用阻塞 provider 替代)、工具执行临界区由结构保证
2026-09-13 00:48:20 +08:00
f11de37bf2 feat(scheduler): M7 可观测性 + 压力测试 + 端到端测试
设计依据 docs/zh/input-scheduler-design.md §11.5(O1/O2)、§11.6(E1/E2)。

- 可观测性:KernelStatus 新增 Scheduler 段(running/三集合深度/计数/
  深度上限),由 GetKernelStatus 从 DumpScheduler 原子快照填充;
  新增 events.EventScheduler,挂起/恢复各发一条(action/task/level)
- TaskKind.String() 便于日志与状态输出
- 新增 scheduler_e2e_test.go 3 项:
  · 压力:200 排队输入 + 50 中断全部经真实 loop 执行,结束时三集合排空、
    LLM 调用数精确等于输入数、无 Rejected
  · 可观测性:挂起/恢复事件齐备,状态快照计数一致
  · 端到端:完整启动 schedulerLoop+interceptLoop,经真实 channel 投递
    L1 任务与 L4 中断,验证「LLM 流式中断 → 挂起 → 中断先完成 → 原任务恢复」
    整条链路(LLM 调用数 = 丢弃1+中断1+恢复1+常规1)
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:45:28 +08:00
4e4e0ad656 feat(scheduler): M6 任务级回执 + 断链点统一为终态事件
设计依据 docs/zh/input-scheduler-design.md §7(I5)、§11.3(X1/X2/X4)。

- 新增 emitSkippedReply:跳过路径(去重命中、空输入)给同步调用方一个
  skipped 终态,但**不发 agent_output 事件**(避免 WebUI 聊天记录凭空多出
  空消息)。修复前 cli/clawhubadapter 这类无超时的同步注入在去重命中时永久挂起
- 回执按任务归属:中断任务的回执只写自己的 ResponseCh,绝不误投给被挂起的等待者;
  被挂起任务恢复并结束后才拿到自己的回执
- 新增 task_terminal_test.go 3 项:X1 回执不误投、X2 空输入有 skipped 终态、
  X4 无超时同步调用方 1s 内拿到终态(回归判据)
- 更新 M3a 去重用例:由「不得回执」改为「必须有 skipped 终态」
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:39:56 +08:00
a971fc8877 feat(scheduler): M5 饥饿防护(抢占计数提升有效级 + 抢占冷却)
设计依据 docs/zh/input-scheduler-design.md §9、§11.5(G1/G2)。

- Task 增加 PreemptCount / LastPreemptAt;effectiveLevel(t) =
  min(L4, Level + min(PreemptCount, 2)):被抢占越多越“值钱”,
  逐步追上抢占它的流,但封顶 L4 因而抢不过真正的紧急输入
- 选择函数 taskBefore 改用有效级;requestPreempt 与 preemptGrantedFor
  同样以有效级比较
- 抢占冷却 preemptCooldown=2s:刚被抢占的任务期内不再被抢占,
  避免同一任务被反复打断到永不完结
- 新增 scheduler_starvation_test.go 4 项:提升与封顶、冷却期内不得再抢占、
  提升后同级不得抢占而更高可、选择函数确实用有效级
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:38:30 +08:00
7565248f61 feat(scheduler): M4 临界区显式化 + 清掉被 pendingInterrupts 取代的 interceptCh
设计依据 docs/zh/input-scheduler-design.md §4.3、§11.1(P5/P6)。

- 临界区语义显式化:让位检查**只在 step 之间**做,执行中的 step
  (工具 RPC / ONNX / CAS 落盘)天然不可抢占;_consolidation_ 整任务
  经 inCriticalSection() 判为不可抢占(它直接改图库)
- 删除 interceptCh 与 drainInterrupts:M3b 起中断一律走 pendingInterrupts,
  旧的「同行注入 + 三处 drain + 批次放弃」已无写入者,属死代码
- 新增 scheduler_critical_test.go 3 项:
  P5/P6 工具执行中 arm 了让位信号也不得挂起、必须等工具返回后的安全点;
  _consolidation_ 判为临界区;无抢占时同批工具必须全部执行(新语义回归)
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:34:39 +08:00
c69a1f11af feat(scheduler): M3a+M3b 任务生命周期重构 + 四级优先级抢占
设计依据 docs/zh/input-scheduler-design.md §3–§8、§14。

M3a(行为等价的所有权重构):
- processInput 拆为 prepareInputTask / runTaskSteps / finishInputTask,
  帧覆盖 prepare→step…→finish;提交与回执只在 finish 段发生一次,
  为安全点挂起做准备(挂起不重复提交)
- process() 不再持 a.mu(挂起不能持锁),a.mu 字段随之移除
- TaskFrame 增加任务层现场(Evt/CleanInput/IsInterrupt/StartedAt/Terminal/
  Level/SeedMsgs)与 taskTerminal / outcomeSuspended
- 新增 task_lifecycle_test.go 5 项:正常恰好一次终态、去重 skipped、
  on_input 短路、错误终态、consolidation 路由

M3b(优先级与抢占):
- interceptLoop 重写:只做「收中断 → 定级 → requestPreempt → 必要时取消
  LLM」,绝不触碰帧(不变量 I2);三条降级路径与 interceptCh 兜底退场,
  改为统一的 pendingInterrupts
- scheduler:pendingInterrupts / suspendPool / 让位信号,nextRef 在三集合上
  按统一排序键取值;深度上限 4(canSuspend 在安全点拦下)
- 抢占判据 incoming.level > running.level;相等与更低只入队
- 安全点只在 step 之间;执行中的 step(工具 RPC/ONNX/CAS)天然不可抢占;
  _consolidation_ 整任务视为临界区
- 恢复走 resumeTask:从 frame.Step 继续,不重跑 prepare
- D1=A:suspend 把被打断任务的只读前缀交给抢占比它的中断任务(SeedMsgs)
- 新增 scheduler_preempt_test.go 5 项:抢占-挂起-恢复(含 R1/R5)、同级更低
  不抢占、深度上限、空闲中断不丢、seed 路径不污染标志位

验收:agent 全量 + -race 通过;全仓 build/vet 通过
2026-09-13 00:25:52 +08:00
716ee46471 docs(scheduler): M3 拆为 M3a(所有权重构)/M3b(抢占语义)
真正的中途挂起要求帧跨越 prepare→step…→finish 全生命周期;若只让
process() 可挂起,processInput 会在挂起返回后继续 context.Append 与
emitResponse,造成重复提交。故 M3 分为两步:M3a 行为等价的所有权重构
(含移除 process() 整轮持有的 a.mu),M3b 再引入优先级与抢占。
2026-09-12 23:51:13 +08:00
7082a50365 feat(scheduler): M2 调度器骨架(就绪队列 + 选择函数 + 快照 + 任务级 panic 隔离)
设计依据 docs/zh/input-scheduler-design.md §14 M2。

- 新增 scheduler.go:四级 Level 常量(默认 L1,显式才是特权)、
  Task/TaskKind、scheduler(有界队列/running/统计)、纯函数 pickTaskIndex
  (排序键 -Level → EnqueuedAt → ID)、schedulerLoop/pumpInbox/executeTask、
  DumpScheduler 原子快照
- eventLoop 退场,职责由 schedulerLoop 承担;M2 全部任务为 L1,
  因而行为等价于原先的 channel FIFO
- 每任务 panic 隔离(不变量 I6):panic 只失败该任务,调度器存活,
  取代原先「重启整个循环」
- Start() 改起 schedulerLoop;interceptLoop 暂不动(M3 重写)
- 新增 scheduler_test.go:Q1 排序 4 组、Q4 背压、生命周期、K1 panic 隔离、
  O1 快照一致性、Level 取值契约
- 验收:agent 全量 + -race 通过
2026-09-12 23:49:55 +08:00
9a588780b9 refactor(scheduler): M1 把 process() 拆成 step 状态机 + TaskFrame(行为等价)
设计依据 docs/zh/input-scheduler-design.md §14 M1。

- 新增 task.go:Step 游标、TaskFrame,以及 stepPrepare/stepLLM/
  stepToolBegin/stepToolExec/stepToolAfter/stepTurnEnd 六个 step;
  原函数内嵌的 provider 回退/重试抽为 resolveProviders + callLLMWithFallback
- process() 改为驱动状态机的薄壳:签名不变,调用方(processInput/
  processConsolidation/测试)零改动
- StepToolExec 显式标注为临界区:工具副作用不可回滚,执行中不是安全点
- 步数上限护栏:转移缺失时以错误退出而非死循环
- 新增 task_test.go:R3(工具往返结果与配对正确)、X3(多轮必然终止)、
  批中途中断必须放弃剩余工具、未知 step 必须失败退出
- 验收:既有 agent 全量测试通过;-race 通过(TMPDIR 指向真实磁盘,
  /tmp tmpfs 已 98% 满会导致链接失败,与代码无关)
2026-09-12 23:47:12 +08:00
06edff1af2 docs(scheduler): 输入调度器设计稿(四级优先级/可抢占/现场保存 + 测试点与里程碑)
背景:现状排队与中断两条语义建立在串行 eventLoop 上,存在队头阻塞、
中断三条隐式降级路径、回执无任务归属、断链点静默、背压策略分裂、
假取消、不可观测七个已确认问题。

设计:四级内核预定义优先级(严格大于才抢占)、显式临界区、
单调度线程 + 单中断线程、step 化任务帧与安全点、suspendPool/pendingInterrupts/
readyQueue 三集合统一选择函数、任务级 responseCh。

含 11 组测试点(优先级/恢复/回执/队列/深度饥饿并发/端到端),
测试方式与预期结果逐条写明;M0–M7 里程碑逐步实现。
公开 SDK 在 v1 保持冻结(diff 必须为 0)。
2026-09-12 23:39:10 +08:00
163c5f70b3 chore(sdk-mirror): 同步 vendored SDK 到 SDK main(browser_search 现代 Bing 版式解析修复)
镜像追平 SDK main(`ebd700e`):
- browser 示例:browser_search 三处根因修复(www.bing.com 302 → cn.bing.com;
  标题取 h2 > a 而非块内第一个 <a>;摘要兼容 p.b_lineclamp*)并解开 /ck/a 跳转包装,
  解析不出结果时显式报错;附真实响应夹具与 5 项单测;版本 2.4.0 → 2.4.1。

注:SDK 仓新增的 example/deepsearch(联网检索 + SearXNG 生命周期托管)与
example/vikunja(任务管理)按本仓策略(.gitignore 忽略 example/ 下未跟踪文件)不入镜像。
2026-09-12 23:39:10 +08:00
ca5e62f775 docs(plan): 记忆与多模态目标纠正(媒体为一等节点、同一指纹空间、音频 unsupported)
把计划里过时的描述式索引路线改正:媒体/媒体块是 L3 一等节点与原生边,
multimodal doc/context 的向量与裁剪须与 text/媒体块在同一指纹空间共同参与;
音频在该模型下明确 unsupported(不做文本描述式索引)。
2026-09-12 20:21:01 +08:00
07e08352b9 feat(waiter,devicebridge): 设备桥连接带授权态,命令处理器类型统一
- `runDeviceBridgeLoop`/`connectDeviceBridge` 增加 `authorized` 入参(未授权不再尝试建立桥连);
- `Bridge.OnCmd` 的处理器类型改为具名 `BridgeCmdHandler`,与 waiter 侧签名对齐。
2026-09-12 20:21:01 +08:00
8f40b91dea feat(ohos): 设备桥会话管理、状态/设备信息载荷与能力路由完善
- 新增 `DeviceBridgeSession.ets`:会话生命周期(创建/复用/回收)与 TTS 会话显式 shutdown;
- `BridgeCaps`/`BridgeRouter` 与内核实际支持的本机命令保持一一对应,补齐 status/deviceinfo;
- 页面与状态存储调整(DevicePage/Index/SettingsPage/StatusStore/SubPage)、新增 ScreensuePage;
- module.json5 与 string.json 同步(新增页面与文案)。
2026-09-12 20:21:01 +08:00
d1662e77cf chore(sdk-mirror): 同步 vendored SDK 到 SDK main(qq 插件重写、browser 会话超时必填、路牌 1.3.0)
核心仓 `third_party/homeagent-sdk` 是 SDK 仓的 vendored 副本,内容以 SDK 仓 main 为准
(本仓 `.gitignore` 忽略 example/ 下的未跟踪文件,已跟踪的镜像文件用 `git add -u` 更新)。
本次把镜像追平到 SDK main(`8c10b7e`):

- qq 插件重写(+498 行):`output_send` 回声/自激路径的根因修复与去重;
- browser 示例:交互式会话 `timeout` 由默认 10m 改为**必填**(附参数校验与测试);
- `meta.Version` 1.2.0 → **1.3.0**(SDK v1.2.0 已定版,SDK main 按纪律推进到下一个中版本)。
2026-09-12 20:21:01 +08:00
dc4982464d fix(packaging): SHA256SUMS 只列本批产物,且用平铺名(此前会带上历史版本、且与附件名不符)
v1.2.2 出包时发现:dist/ 跨多次构建累积,而清单用 `find $DIST_DIR` 全目录扫,
于是 SHA256SUMS 里混进了 1.2.0/1.2.1 的包名——用户从发布页下载这份清单后
`sha256sum -c` 必然报「文件缺失」(那些包并不在本页)。

两处一起修:
- 按本批 `$PKG_VERSION` 过滤(只列这次真正打出来的产物);
- 名字用 basename(平铺名),与发布页附件名一致;哈希取真实路径(此前若直接
  对 basename 求哈希会找不到文件——就在 `deb/`、`tar/` 子目录里)。

验证:对含 1.2.0/1.2.1/1.2.2 的 dist/ 跑新逻辑 → 4 条(旧逻辑 12 条);
并拿发布页真下载的 SHA256SUMS 逐条核对四个产物的实际哈希 → 全部一致。
2026-09-12 19:18:26 +08:00
fe883d5362 test(plugins): 真实插件产物先校验平台再 exec,错误平台给可读提示而不是 exec format error
排查知识库改动是否引入回归时,internal/plugins 6 个测试全红、报
"fork/exec .../plugin.bin: exec format error"。查了半小时才发现与代码无关:
早前验证跨平台示例构建时,最后一次构建(darwin/arm64)把
`example/*/build/plugin.bin` 覆盖成了 Mach-O arm64,而 realPluginBinary
只按文件名找候选、**不校验平台**,于是拿 macOS 产物在本机 exec。

两个陷阱一起堵:
- 校验魔数(ELF / Mach-O / PE),平台不符则 **SKIP 并给出可直接粘贴的重建命令**
  (`hmapdev build --target <host> --no-bundle`),不再用误导性的 exec format error;
- 明确写出「产物缺失即 skip」的语义,避免"全绿其实什么都没验"
  (本次对照实验里,改动前的 worktree 因无产物而全绿,看着像通过)。

反向验证:把 weather 产物换成 PE 假头 → 相关测试转为 SKIP 且提示平台与重建命令 ✓。
2026-09-12 18:38:24 +08:00
5fbd6514c2 fix(knowledge): 知识库检索改为「稠密 + 词法」两路融合(真实 KB 自检索 MRR 0.271→0.376)
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手:

- **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致;
- **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索
  top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声;
- 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。

先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、
去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。

改动:
- `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。
  权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** /
  0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目
  (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build);
- `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级
  只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的
  差距来源,且当时没有任何报错;
- Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。

**顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为
writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志,
且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush
(buildTreeLocked / writeIndexLocked)。

判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索
top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。
另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、
词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。

**反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、
把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值,
注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据
(语义层由 vector 包测试证明、接线层由知识库测试钉住)。

顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量,
生产改动当时已提交,测试一直未入库)。
2026-09-12 18:14:22 +08:00
5aaae93367 feat(healthcheck): 内核状态快照报出内核版本号与 ONNX 模型启用状态
healthcheck_kernel 此前没有任何「ONNX 模型是否在用」的信息,只报「向量可用/不可用」,
分不清「统一多模态空间已加载」与「退回到词嵌入/TF-IDF 路径」;人格卡要求
「版本以运行时快照为准」,也缺一个可查字段(build.version 早就在,但没人知道)。

- KernelStatus 新增 onnx 段:enabled / provider / dim / fingerprint / modalities / reason。
  判据取 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」
  —— 后者在模型缺失 / 运行时缺失时也为真,报出去就是假绿。
- 未启用时 reason 给**具体原因**:未配置(说明会走回退路径)/ 打开失败的具体错误。
  homed 把「配置的 provider 名」与「打开失败原因」透传给 Agent,仅供状态报告。
- ProviderAdapter 新增 Modalities()(可选能力,按接口断言取用,不改公开契约)。
- healthcheck_kernel 的工具描述同步说明它回答这两件事。

**顺带修一个真实 panic**:collectKernelStatus 的 knowledge 是**接口**参数,
(*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic,
而 healthcheck_kernel 正是走这条路径(panic 发生在工具 goroutine 里)。
GetKernelStatus 改为先按具体指针判空、再赋给接口;并加刻画测试钉住这个成因
(一旦不再 panic 说明参数形状已变,守卫与该测试应同步删除)。

验证:单测 4 例(已启用 / 打开失败 / 未配置 / 未加载)+ 刻画测试;
隔离实例 E2E 7/7:正例 provider=chineseclip → enabled=true、dim=512、模态 2;
反例 provider=nonexistent → enabled=false 且 reason 含具体错误与 provider 名,
真实对话仍通。
2026-09-12 14:56:25 +08:00
46e014a8ca feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:57:06 +08:00
ea21803acb docs(branching): 明确开发者文档的发布归属——以 rel 分支的形态为准,再合入 main
用户裁定:开发者文档应当在每个 rel 分支被修正为对应 rel 的形式,随后合入 main。

新增 §二.7,写清:
- 规则与做法(release 上按本版口径改 → cherry-pick 到 main,遵守 §三 只 pick 不 merge)
- 为什么不能直接改 main:main 语义是「下一个未发布版本」;assets/docs 会随发行包
  分发并在 WebUI 被阅读,服务的是「这一版」;版本号/工具名/机制有无都随版变动
- main 上描述「下一版才有」的行为必须显式标注(如「(下一版)」)
- 反例表(本仓真实踩过):人格卡写死 v0.9.0 + 已删除的 C ABI、架构文档把已移除的
  描述式索引/引用计数写成现行、README 停在旧版本
- 配套硬约束:任何会被当作事实的文本不得写死版本号,须插值或读运行时快照并加测试
2026-09-12 13:05:24 +08:00
10367b384e feat(webui): 首启人格向导(默认 / 自定义 / 稍后)+ 一次性标记
接续人格配置项化(597f07c):现在人格是 core.agent.personal_prompt,
本次加上「首启问一次」的界面,之后不再打扰。

后端(GET/POST /api/v1/persona):
- GET  → {initialized, current_prompt, file_override}
        未设置时 current_prompt 回落到内置默认模板;存在 personal/personal.md
        时报告 file_override(它会覆盖配置项,向导据此提示用户)
- POST → {"mode":"default"|"custom"|"later","content":"…"}
        写配置 + 打一次性标记 core.internal.persona_initialized;
        custom 返回 restart_required=true(人格在启动时载入);
        「稍后」= 保留当前默认 + 打标记,**绝不阻塞任何流程**
- 空内容的 custom 与未知 mode 一律 400,且**不打标记**(否则向导会被跳过)

前端(dashboard.html):
- 首启拉一次 /api/v1/persona,未初始化则弹向导(复用一直没人用的 .confirm-* 样式)
- 「自定义…」第一次点击展开文本域并预填当前人格,再次点击才提交(避免误提交)
- 中英双语走既有 __() 机制;保存失败/空内容用 toast 提示

测试:TestPersonaWizardFlow(首启状态、later 打标记不改人格、custom 写入 + 需重启、
空内容与未知 mode 被拒且不打标记)、TestPersonaWizardReportsFileOverride。

E2E(真实实例):首启 initialized=false → POST later → initialized=true,
config 中标记=1、人格键为默认模板;前端页面含向导函数。
2026-09-12 13:02:18 +08:00
ce8bc27db8 docs(architecture): 记忆流转图对齐统一多模态空间
流程图里 Context/Prune/DocStore 三行仍只写 StaticEmbedder 与 TF-IDF,
读起来像"向量化只有词嵌入一条路",与 1.2.0 实际(多模态统一空间为主,
带 fingerprint;词嵌入/TF-IDF 是降级层)不符。

- Context Append:补三层向量层级说明
- Context Prune:改为 DenseCosine(仅同指纹比较)→ StaticEmbedder 回退
- DocStore:改为稠密向量 + dense_fp 同指纹要求(不符即重算)
- 中英双版同步
2026-09-12 12:46:21 +08:00
7318a10828 feat(persona): 人格设定配置项化 + 默认模板契约测试 + 腐坏告警
起因(v1.2.0 压测):线上实例内核日志/接口都报 1.2.0,agent 被问版本时却按人格卡
自述 v0.9.0 + C ABI v2(该机制 v1.0.0 已删除)。根因是人格只有「文件」一个来源且无人
维护——写死的版本号必然随发版腐坏。

改动:
1. 新增配置项 core.agent.personal_prompt(多行文本),默认值为内置模板
   config.DefaultPersonaPrompt,随其它默认值同批播种(老安装不注入,语义不变)
2. 默认模板**不含任何版本号字面量**,并显式要求「被问到版本/构建信息时以运行时快照
   (healthcheck_kernel)为准」——从根上消掉这类腐坏
3. 人格来源优先级:personal/personal.md(存在且非空)> 配置项 > 无
   启动日志明确打印来源;文件含腐坏内容(版本号字面量 / 已删除机制的说法)时告警并
   建议迁移到配置项
4. internal/agent.PersonaStaleHints:腐坏检测(版本号正则 + 已删除机制词表)

契约测试(防复发):
- TestDefaultPersonaPromptHasNoVersionLiterals:默认模板不得含 v?\d+\.\d+\.\d+,
  且必须含「运行时快照」要求
- TestPersonaPromptRegisteredWithDefault:注册存在、默认值一致、播种真的写入
- TestPersonaStaleHints:线上人格卡原文必须被识别(v0.9.0 / C ABI v2),干净文本不误报

验证:go build ./cmd/homed ok;go vet 三个包 ok;go test ./internal/config ./internal/agent ok;
端到端两场景(无文件→来源=配置项 1307 字节;有旧文件→来源=文件 + 告警列出 v0.9.0 与 C ABI v2)。
2026-09-12 12:42:43 +08:00
b15d0bd114 refactor(plugin)!: 重编提示与模板路径改用 hmapdev;文档全面对齐 1.2.0
工具链在 SDK 1.2.0 更名为 hmapdev(原 plugindev)。核心侧三处功能耦合同步:

1. 用户可见报错:旧 C ABI 产物 / 协议版本不匹配 / 共享段版本不匹配
   三处「请用配套 plugindev 重编」→ hmapdev(对应两条测试断言同步)
2. e2e_template_test 的模板路径改为 tools/hmapdev/templates,
   并保留旧路径回退(旧 SDK 检出仍能跑测试)
3. 注释与文档同步

文档更新(用户可见面):
- assets/docs/{zh,en}/PLUGIN_DEV.md:工具链章节整体改为 hmapdev,
  补改名说明与 SDK 存储目录迁移;命令示例全部更新
- assets/docs/{zh,en}/ARCHITECTURE.md:**流程图与章节对齐 v1.2.0** ——
  · 向量化章节改为三层降级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退),
    写明「同指纹且同维度才参与融合」
  · 媒体记忆章节重写:媒体是一等记忆块(无独立 GC / 无引用计数 / 无描述式索引 /
    正文不再写 media marker),并写明 reembedStaleMedia 的跨空间迁移与写回
- README{,_EN}.md、docs/zh/plugin-interface-matrix.md:工具名与模板路径同步
  (历史条目标注「当时名为 plugindev」)

验证:go test ./internal/plugin/ ./internal/plugin/proc/ ok,
含 4 条真实模板 E2E(模板路径切换后仍通过)。
2026-09-12 12:42:43 +08:00
34628720f2 fix(memory): 修 beta.2 压测发现的三个向量/文档缺陷
来源:v1.2.0-beta.2 全方位压测(报告 /var/tmp/stress/REPORT.md)

1. 文档向量迁移结果不落盘(生产已复现)
   - BuildDenseIndex 改完内存不置 dirty;docStore.Stop() 全仓无调用者 → flush 成死代码
   - 后果:每次启动重算同一批文档(线上 496 篇约 17s),磁盘 dense_fp 永不收敛
   - 修:迁移当场落盘(抽出 flushLocked 以免重入锁)+ main.go 关停链 defer docStore.Stop()
   - 生产证据:线上 488 篇文档仅 4 篇含 dense_fp,且这 4 篇均为运行期 Insert 的新文档
2. 块指纹对但维度错时污染文档向量(健壮性缺口)
   - denseFor 只校验 b.Fingerprint,不校验长度;FuseVectors 取最大维度并跳过长度不符者
     → 512 维文本 + 2048 维块 = 2048 维且打上当前指纹
     → 该文档在检索侧被长度守卫永久跳过,且每次启动重算(不收敛)
   - 修:denseFor 要求 len(b.Vector) == 空间维度
   - 可达性:内核两个块产出点均成对取自同一行(it.Vec ↔ it.VecModel),故属防御性修复
3. Insert 与 loadAll 的 ID 约定不对称(低)
   - Insert 落盘任意 <id>.json,loadAll 只加载 doc_ 前缀 → 自定义 ID 文档重启后静默消失
   - 修:loadAll 只要求 .json(空 ID 仍跳过)

回归测试 4 条:迁移跨重启落盘 + 已对齐 0 重算(用向量空间调用计数判定,
不靠日志)、坏块不参与融合且同维度正常块仍参与、自定义 ID 可加载、Stop 落盘。

反向验证(纪律要求):临时回退本次修复后,前 3 条均变红且报错正是缺陷签名
(dim=999/space-OLD-999 永不收敛、文档向量 2048 维、文件重启后消失);恢复后全绿。

验证:go build ./cmd/homed ok;go vet ./internal/memory/document ./cmd/homed ok;
go test ./internal/memory/... 7 包全绿。
2026-09-12 11:05:11 +08:00
85223fc1c9 docs(matrix): 记录 v1.2.x 的接口扩展,以及本次审计抓出的两处漂移
§九 原本只写到 v1.1.x。补上 1.2.0 的接口增量(注入侧的 InjectOptions 与六个
*Opts 变体、ContextPolicy 取值、ChannelDef.ContextPolicy 与它的 JSON tag),
并明确一件容易被误读的事:**「接口纯追加」不等于「无需重编」**——同版把插件运行
协议升到了 2(fd3 布局改变),协议不匹配会在握手时被明确拒绝,这两件事必须分开说。

同时把这次扩展自己抓出来的两处漂移入档(都属于本节第 3 条要防的类型):

1. 模板接线守卫 `TestProcTemplate_CoversAllCoreMethods` 红了:模板不再发
   io.injectTextNoMem(改走 io.injectText + NoMemory),而内核保留该 id 是刻意的
   向后兼容面。修的是判据(显式 deprecated 表 + 反向保护)。
2. mocksdk 与公共 SDK 机械求差,差集为旧的三参数 InjectInputSync(通道类插件闭环
   要调的方法);git log -S 证实从来就缺,已补齐。

验证表按**本机实跑结果**填写:示例 vet 17/17、plugindev 测试全绿、
sdk -race -count=5 通过、mocksdk 差集为空。
2026-09-12 09:41:39 +08:00
dc80855540 feat(status): 暴露构建源码地址,WebUI 状态页给出 AGPL §13 的源码入口
选了 AGPL-3.0-only 之后,§13(Remote Network Interaction)就不只是声明问题:
把修改过的版本作为网络服务提供出去时,必须给使用者取得 Corresponding Source 的机会。
只在仓库里放 LICENSE 并不自动满足这一条——**使用者拿到的是服务,不是仓库**。

所以把它做进产品里,而不是写进文档就算完:

- `internal/meta.SourceURL`:本次构建对应的源码地址,默认指向本仓库。
  注释里写明「修改后对外部署的分支必须改指向自己的仓库」,并给出 -ldflags 覆盖方式
  (`-X .../internal/meta.SourceURL=<你的仓库>`),不需要改源码。
- `BuildStatus.SourceURL`(`json:"source_url,omitempty"`)+ 内核状态填充:
  走已有的 `/api/v1/kernel` 构建身份链路,不新增端点。
- WebUI 状态页在「版本」行下渲染「源码 / Source」链接(URL 经 escHtml 转义后进属性;
  `target="_blank" rel="noopener noreferrer"`)。
- 用 `omitempty`:未注入该值的旧构建不会在 JSON 里多出一个空字段。

验证:
- `internal/plugins/webui/dashboard.html` 的两个内联 script 块 `node --check` 均通过;
- 全仓库 `go build ./...` 通过,`internal/meta/meta.go` gofmt 干净
  (`internal/agent/core/status.go` 有**既有**的 gofmt 差异,与本改动无关,按纪律未整体重排);
- 端到端:本地构建 homed → 冷启动 → `GET /api/v1/kernel` 的 `build.source_url`
  实测为 `https://gitcode.com/JianFeeeee/HomeAgent`。
2026-09-12 09:41:39 +08:00
0b1c201b5c docs: README 更新到 v1.2.0,并纠正已在 v1.2.0 删除的机制
README 的「项目状态」停在 **v1.1.1**,而且其中两条描述与当前实现**相反**:
「媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中,描述是可检索的
语义记忆」与「引用计数式 GC(有引用者绝不删)」——这两套机制正是 v1.2.0 拆掉的。

本次不重写历史条目(它们记录了演进),而是:

1. 「项目状态」顶部新增 **v1.2.0** 条目:统一多模态向量空间(模型中立 SPI +
   Chinese-CLIP 默认,含「纯文本语义弱于 MLLM 型嵌入器」这一已知代价)、
   媒体升为图记忆一等节点(并写明具体删掉了什么)、数据面全量走共享内存
   (协议 2、不支持滚动升级)、注入标志位、发行包默认启用 ONNX 且随包模型、
   homed 改走 WSL2、以及三个安装链静默失败的修复。
2. 在 v1.2.0 条目后加一条显式提示:下方历史条目中的「描述式索引」与
   「媒体引用计数式 GC」**已在 v1.2.0 移除**——避免读者按旧文档理解现行行为。
3. 「下载」:Windows 安装器的版本号改到 v1.2.0,并写明自 v1.2.0 起因 homed
   不再支持 Windows 原生,安装器改为引导到 WSL2 并在其中按 Linux 方式安装;
   同时注明安装器含 AGPL 许可页。

中英双份同步。SDK 仓 README 另在 SDK 仓提交(d893bfa)。
2026-09-12 09:27:28 +08:00
5e10f012cb docs(license): 核心仓采用 AGPL-3.0-only,并写进包内与安装器
本仓此前**没有任何许可文件**,README 里也没有许可声明,而 rpm 元数据里甚至写着
`--license "Proprietary"`(与我们实际的分发意图相反)。

## 选择了什么

`LICENSE`:GNU Affero 通用公共许可证第 3 版官方全文(gnu.org 正本,
661 行 / 34523 字节,
sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0)。

选 AGPL-3.0-only 的理由:GPL 家族里**传染性最强**的一档,并且不允许选后续版本。
它比 GPL-3.0 多出 §13(Remote Network Interaction)——通过网络提供服务时也要向
使用者提供源码。这正是「最严格」在 GPL 家族里的落点。

依赖许可已核对为全部宽松且兼容:go-sqlite3 / gojieba / gopher-lua / bubbletea /
bubbles / lipgloss / yaml.v3(MIT)、golang.org/x/{sys,text}(BSD-3)、
Chinese-CLIP 产物(Apache-2.0,与 GPLv3+/AGPLv3 双向兼容)、ONNX Runtime(MIT)。
没有 GPL-2.0-only 这类与 AGPL 不兼容的依赖。

## 落在哪些地方

- `README.md` / `README_EN.md`:新增「许可 / License」章节,写明 §13 的含义、
  插件因**静态链接 SDK 源码**而成为衍生作品须同许可发布、以及随包第三方组件清单
- `deploy/packaging/package-linux.sh`:
  · 新增 `stage_license()`,**四个变体(full/server/client/tar)全带**
    `/usr/share/doc/homeagent/{LICENSE,copyright}`(copyright 为 DEP-5 机器可读格式,
    含第三方条目)
  · fpm 的 `--license "Proprietary"` → `"AGPL-3.0-only"`
- `deploy/packaging/installer.nsi`:新增 MUI 许可页
  (`..\..\LICENSE`,NSIS 以 .nsi 所在目录解析相对路径)
- `third_party/homeagent-sdk/LICENSE`:vendored SDK 的许可一并入库 —— 本仓
  `.gitignore` 有意不镜像 SDK 的 README/tools/package/example,但依赖的许可
  应当随依赖可见

## 待办(下一步)

README 的「项目状态」仍停在 v1.1.1,且写着已被 v1.2.0 **删除**的机制
(引用计数式 GC、`[<mime> <digest>] <描述>` 描述式索引)——单独一个提交修。
2026-09-12 09:17:03 +08:00
727768e083 docs(git): 修正上一提交的错字(但声两仓 → 但两仓) 2026-09-12 09:01:59 +08:00
0247206dfe docs(git): 写清「两仓 main 同步推进」的前提,修正 SDK 路牌
§七.4 原来只说「在 1.1.x 线发布期间,两仓 main 上的值都是 1.2.0」,容易被读成
「两仓 main 永远同值」,我正是据此把 SDK main 也推到了 1.3.0(已回退为 1.2.0)。

补写前提:推进以**该中版本已正式发布**为条件。
- 核心切出 release/v1.2.x 后 1.2.0 归发布线所有 → main 立即到 1.3.0(beta 也算占号)
- SDK 因 §七.2(beta 不发 SDK)要等核心正式 tag 才定版 → 在那之前 main 停在 1.2.0

并明确:**此阶段核心 main(1.3.0) 与 SDK main(1.2.0) 故意不对称**,
不是遗漏同步。§三 的 SDK 表行同步修正。
2026-09-12 09:01:34 +08:00
fbd3adabea fix(sdk-mirror): vendored SDK 路牌回到 1.2.0(SDK 不跟 beta 发版)
SDK 仓 44bd915 把 main 的 meta.Version 推到 1.3.0 是错的(12cabcb 已改回 1.2.0)。
按 §七.2,beta 不伴随 SDK 发版:SDK 1.2.0 要等核心的**正式** tag 才定版打 tag
(§七.3),在那之前 1.2.0 仍是 SDK 尚未发布的中版本,路牌不得越过它。

核心 main 的 1.3.0 不受影响(1.2.0 已归 release/v1.2.x 所有),两仓在此阶段
故意不对称——这一点已写进 SDK meta.go 的注释,避免再被「对齐」回去。

本提交只同步 core 里跟踪的镜像:sdk/ 目录与 SDK 仓仍然逐字节一致。
2026-09-12 09:00:41 +08:00
f778c613f1 docs(git): 1.2.x tag 历史补上 v1.2.0-beta.1
release/v1.2.x 已按 §2.4 走出 beta 通道:tag v1.2.0-beta.1(提交 215804c),
注释里写明通道、不兼容点与验收证据;beta 阶段不发 SDK(§七.2)。
正式 tag 待试运行无回退问题后再打,届时同步 SDK 仓。

同时把 tag 名资产整理记录在案:历史大写 tag V0.8.0 / V0.7.1 已按用户确认删除
(提交本身未受影响),现在主仓 tag 全部符合 SemVer 小写 v 约定。
2026-09-12 08:54:09 +08:00
7a418346a4 build(packaging): 统一 -buildvcs=false,版本/提交只认 ldflags 注入
发布分支的产物上出现了 `vcs.revision=1715b5c`——一个本机任何仓库都不存在的提交。

原因:VCS 信息**不进 build cache key**(Go 文档明确说明 VCS 变化不会触发重建),
命中缓存时会把上一次的 revision 一并带回来。

而 build.sh 本来就用 ldflags 注入 meta.Version / meta.Commit(权威来源),
所以这个额外信号既不可靠又会误导溯源:拿 `go version -m` 去查源码提交,会指向
一个幽灵提交——正是本项目一直在治的「静默不一致」。

处置:三处 go build 统一 `-buildvcs=false`,并在 LDFLAGS 旁写明溯源方法
(`strings homed | grep -x '<短 hash>'`,meta.Commit 是字符串常量)。

验证:重新构建 homed linux/amd64 →
  · `go version -m` 中 vcs.revision 0 处(此前 1 处且是错误值)
  · strings 中恰好 1 处等于当前 HEAD 短 hash
  · `-tags=onnxruntime` 仍在
2026-09-12 08:42:18 +08:00
a070cb563d docs(git): 分支对齐更新到 2026-09-12(1.2.x 线开启)
- main 路牌调到 1.3.0;release/v1.2.x 承载 1.2.0(vendored SDK 定版 1.2.0)
- release/v1.1.x 按 §2.6 退役(保留供追溯);两个历史 feature 分支
  (memory-media / plugin-proc-migration)已从远端删除,旧表待删项清掉
- 新增「1.2.x 发布线 tag 历史」表(当前尚无 tag),并写明它与存量插件
  **不兼容**(RPC 协议 2、fd3 布局改变、不支持滚动升级)以及
  **不满足 §2.4 跳级条件**的理由(改动面大,非单点修复)
- SDK 仓的 release/v1.2.x 尚未创建:按 §七.3 随核心**正式** tag 一起做
2026-09-12 08:39:05 +08:00
685ed7e4a0 fix(knowledge): 覆盖同名条目时摘掉旧向量
从一次真实的知识库更新里发现:在线实例更新一个已有条目之后,
knowledge_count=32 而 vector_count=33——多出来的那一条是上一版的副本。

成因:vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id 去重;
而 Store.Add 走的是「写 content.md + 覆盖 items[id] + Insert 向量」。
文件与内存条目都被正确替换了,只有向量索引多留了一份。

危害不在于多占内存:**检索可能命中已被替换掉的旧内容**,而且完全静默——
条目数看起来是对的,只有向量数比条目数多。

修法:Insert 之前先 s.vec.Remove(id)(Remove 已按 id 过滤 docs 与倒排索引)。
回归测试 TestAddOverwriteReplacesVector 钉住 knowledge_count / vector_count /
content.md 三者都必须只剩新版。

注:该文件在 origin/main 上本就有 32 行 gofmt 差异(结构体字段注释对齐),
不属本次改动,按纪律不做整体重排。
2026-09-12 08:30:22 +08:00
dd99105d66 fix(build): GUI 输出目录用 --config.directories.output,-o 是 --mac 的别名
electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的
Building 段),不是 output。于是 `-o "$BUILD_DIR"` 被当成 macOS 的 target
列表,报:

  ⨯ Unknown target: /home/program/trueagent/build

路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的
——这也是它看起来像「路径错」而实际是「参数位置错」的原因,v1.0.1 与
v1.0.3 两次发布都因此手工组装过 GUI。

改用 --config.directories.output=<dir>,已实测确认产物落在指定目录。

同时把 GUI 构建失败降级为警告:homed/waiter/initconfig 是发布主体,
而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
set -euo pipefail 下不接住的话,一个可选组件会让整轮跨平台构建全废——
v1.0.3 就是这样只产出了 linux/amd64 三个二进制、arm64 与 windows
压根没跑到。
2026-09-12 08:26:12 +08:00
1fb5700662 chore(meta): main 的版本路牌推到 1.3.0
按 docs/git-branching.md §2.1,main 的 meta.Version 始终是**下一个未发布中版本**。
1.2.x 线已开(release/v1.2.x 承载 1.2.0),所以 main 指向 1.3.0。

它标记「main 正在积攒 1.3 的东西」,不表示 1.3.0 已经存在——1.3.0 没有任何 tag。
1.2.0 已由 release/v1.2.x 承载;main 不能再挂着 1.2.0,否则 main 的版本号
就与某一次发布的版本号相同,违反 §2.1「main 的版本号不是任何一次发布的版本号」。

两仓同步:SDK 仓 main 也在同一时刻推到 1.3.0(commit 44bd915)——SDK 版本跟随
核心中版本(§七.1),且两仓 main 都表示下一个未发布中版本(§七.4)。
本 commit 同时把 vendored SDK 镜像(third_party/homeagent-sdk/meta/meta.go)
更新到与 SDK 仓一致。

SDKCompatibleVersion 保持 1.2.0:那是本内核**实际实现并兼容的最高 SDK 接口版本**,
与「下一个未发布中版本」是两件事(参见 e4be966 时 main 的 1.2.0 / SDK 兼容 1.1.0)。

**此 commit 不 cherry-pick 到发布分支**(§五:版本号 bump 不跨分支搬)。
2026-09-12 08:25:30 +08:00
f1091676f8 style: gofmt 两处对齐(graphmedia_test.go 注释、tfidf.go 结构体字段)
这两处是本次特性分支带入的格式回归(origin/main 上不脏)。
纯空白/对齐调整,无语义变化;不整体重排文件。
2026-09-12 08:23:30 +08:00
eb4762a2b8 merge: 多模态统一向量空间与子进程数据面收敛(feature/multimodal-embedding)
合入 43 个提交,主线内容:
- 统一多模态向量空间:公共 provider SPI(pkg/embedding)+ 注册表,
  chineseclip 为 text+image 默认空间(512 维,Apache-2.0),qwen3vl 保留
- 共享内存接管全部数据面:工具调用帧、Cleaner、输入/输出 lane、媒体块、
  文档/知识正文;RPC 只传偏移描述符(协议版本 2)
- 图记忆原生媒体节点:媒体是一等节点与边,删除 media_refs 与描述式索引
- 注入行为可声明 no_memory / context_policy(默认不裁剪)
- SDK 1.2.0:注入标志位纯追加 + 示例 hmap 随发版
- 发行包默认启用 ONNX 向量空间;本批起模型与运行库随 server/full 包发布
- homed 放弃 Windows 原生支持,改走 WSL2
2026-09-12 08:21:34 +08:00
4d2028fbef chore(sdk): 同步 vendored SDK 镜像到 1.2.0
core 仓里跟踪的 SDK 镜像落后于 third_party/homeagent-sdk(那是个独立仓库):
已提交的 sdk/memory.go 仍带 MediaAttachment.Description,而 SDK 仓 b2eafdf 已把它
删掉(媒体不再以文本描述参与索引)。磁盘上的文件其实就是 SDK 仓当前的版本,
只是 core 的索引没有跟上——于是**干净检出 main 拿到的是一个与它构建所用 SDK
不一致的镜像**。

clean worktree 能编译(内核已不再引用 Description),所以这个不一致不会被构建
发现,只能靠比对发现——属于本项目一直在治的「静默不一致」。

同步内容:sdk/memory.go 与 SDK 仓 HEAD 逐字节一致。
2026-09-12 08:21:21 +08:00
d4c5e808c7 feat(packaging): 模型与 ONNX Runtime 随 server/full 包发布
模型与运行库是发行版能力的一部分,不做成「装完再自己下载」:

- package-linux.sh:新增 stage_multimodal_assets(),打 server/full 前校验产物
  SHA256SUMS、逐文件非空、运行库架构与目标一致,缺一即失败;client 包不含。
  顺带修掉三个让打包在最后一步才炸的既有缺陷:
  · 版本串直接取 git describe(v1.0.0-68-gxxx-dirty)不是合法包版本——deb 要求
    数字开头、rpm 不允许 '-'。以前只有显式 VERSION=1.0.3 才打得出来;默认路径
    从来没通过过。现在归一化,非数字开头时显式报错。
  · 三处 mktemp -d 落在 /tmp(本机 9.8GB tmpfs),而 staging 要复制 719MB 模型,
    中途 ENOSPC;报错文本指向某个 .onnx 文件,看着像资产坏了。改为落在与构建产物
    同盘的 build/.stage-tmp。
  · 开工前删掉旧的 SHA256SUMS:失败时脚本直接退出、不重算,留着像在为残缺产物背书。
- setup.sh:把包内 /usr/lib/homeagent/models/chinese-clip-vit-b16-onnx 软链到
  <dataDir>/models/…(不复制 754MB、保持 dataDir 可迁移、已有自定义目录不覆盖)
- homeagent.service:ExecStart 改 /usr/bin/homed(deb 装在那里,此前写 /usr/local/bin,
  装了也不会被 unit 用上)、加 ONNXRUNTIME_DIR 与 StateDirectory、MemoryMax 2G→8G
  (实测常驻约 4.5GB,2G 会在首次全量建索引时被 cgroup OOM)
- control-{full,server}:补 libstdc++6 / libgcc-s1(libonnxruntime.so 需要)
- providers/{chineseclip,qwen3vl}:findOnnxLib 支持 ONNXRUNTIME_DIR / ONNX_ML_DIR
  与包内 /usr/lib/homeagent/onnxruntime,随包的运行库才真的会被用上
- postinst:修掉两个让「装完即用」失效的点——它检查 /lib/systemd/system/ 下的 unit
  而 deb 装到 /etc/systemd/system/,于是 daemon-reload/enable **从未执行**;以及
  setup.sh 的失败被 `|| true` 吞掉(正是 initconfig 静默缺陷被藏住的原因)。现在
  三个候选路径都查、失败可见并给出补救命令、首装 start / 升级 restart。
- docs/zh/multimodal-space.md:新增「随包分发」一节,并修正播种判据的说明

验收(从真实 deb 走一遍,不是读脚本):
- 包内 initconfig 已是动态链接,凭据真的写进 config.db
- 解包 → 按 postinst 顺序跑 setup.sh → 包内 homed 冷启动:
  multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990
  modalities=[text image]
- 全新安装的默认值确实被播种(core.plugin.dir / provider / model_dir 都在)
- 真实对话拿到回复(3.4s,回复中含唯一标记)
- 包内模型 SHA256SUMS 5/5 通过;包内 ORT 与源同 sha256;server 包 722MB
  (旧版 17MB,差额即模型与运行库);full 包同样含全部资产;client 包不含
2026-09-12 08:10:44 +08:00
5299e18cd8 fix(config): 全新安装的默认值播种判据改为显式标记
播种判据曾经是「config 表为空」。而发行包的 postinst 先跑 setup.sh →
initconfig,后者会写一行 webui.listen_addr,于是**全新安装**被判定为
"已有配置"并整体跳过播种:没有 core.plugin.dir(装完 0 个插件)、没有
core.memory.* 路径、也没有随包模型对应的多模态 provider——754MB 产物与
24MB 运行库全成死重量。同样的机制此前已在协议 2 迁移演练里被观察到。

也不能改成"每次都补缺键":老安装升级时被注进新默认值,会让它突然去加载
一个 1.8GB 的模型,那是刻意要避免的静默变重。

故改为显式标记 core.internal.seed_version:
  有标记                        → 已播种,返回
  无标记但有 core.daemon.data_dir → 老安装,只补标记、不播种
  两者都没有                    → 全新安装,播种并打标记

测试:TestSeedDefaultsAfterInitconfigPrepopulate(精确复现 initconfig 的
那一行写入)、TestSeedDefaultsDoesNotInjectIntoLegacyInstall。
2026-09-12 08:10:34 +08:00
6a3439a49c fix(initconfig): 必须带 cgo 构建,且失败不再静默
cmd/initconfig 通过 database/sql 使用 mattn/go-sqlite3,而 build.sh 一直用
CGO_ENABLED=0 构建它:该库在非 cgo 下退化成 static_mock.go 里的桩,sql.Open
是惰性的所以不报错、第一次 Exec 才失败,而 main.go 丢掉了所有返回值。合起来
是一个完全静默的空操作——打印凭据、退出码 0、config.db 里一个字节都没写。
安装脚本把这份凭据写进 credentials.txt,用户照着登录必然失败,全程无报错。

- build.sh: initconfig 改 CGO_ENABLED=1,并写明为何不能图省事去掉 cgo
- main.go: 每个 Exec 都检查;写完**回读比对**(不看返回码,看真实落盘内容),
  不一致即非零退出

反向验证:仍用 CGO_ENABLED=0 构建时,现在 stderr 报
"Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work"
且 exit 1(此前是 exit 0 并把凭据照打印出来)。
2026-09-12 08:10:26 +08:00
ba0b5a1fe0 feat(release): 发行版默认启用本地向量空间(onnxruntime 标签 + chineseclip 默认)
用户要求:后续发行版默认带 ONNX 模型能力。这条要求落到两处,并顺带修掉一个
被它**暴露出来**的真缺陷。

## 1. 构建默认带 onnxruntime(deploy/packaging/build.sh)

`HOMED_TAGS` 默认 `onnxruntime`,需要极简构建时显式 `HOMED_TAGS=` 关闭。
不带标签时 provider 仍注册、但打开即报「requires build tag」并优雅降级——
不静默假装成功。运行期还需要 `libonnxruntime.so`(provider 按
/opt/onnxruntime、/usr/local/lib、/usr/lib 顺序查找),缺失时同样是
「日志里明确错误 + 降级」。

## 2. 新装默认选 chineseclip(internal/config/registry.go)

`SeedDefaults` 写入:
  core.memory.multimodal_space.provider = chineseclip
  core.memory.multimodal_space.options.model_dir = <dataDir>/models/chinese-clip-vit-b16-onnx

选它而不是 qwen3vl:后者实测常驻 9.4GB,多数机器装不下;chineseclip 是
1.99GB(实测,见下)。同时更新两个 ConfigDef 的默认值与描述(WebUI 显示用)。

**老安装不会自动拿到这两个默认值**,这是有意的:`seedDBValues` 对非空配置库
直接返回,`GetString` 缺键时回落到调用方默认值(main.go 传的是空串)。
升级就静默加载 ~1.8GB 模型不是无副作用的事,应由部署显式开启。已写进文档。

## 3. 修掉 ORT 环境被重复初始化 + 误销毁(internal/nlp/onnx.go)

这是「默认带标签」才暴露的缺陷:此前不带标签时进程内不会有多个 ORT 消费者。

- `NewONNXParser` 无条件 `InitializeEnvironment()` → 若多模态 provider 先初始化,
  这里报「The onnxruntime has already been initialized」并**降级**(实测日志:
  `ONNX parser init: init onnx env: ... using fallback`)。
- 更严重的是失败路径与 `Close()` 里的 `DestroyEnvironment()`:它会把别人
  (多模态 provider)正在用的进程级环境一起拆掉,让对方的会话失效。

改为:初始化前先 `IsInitialized()`;**任何消费者都不销毁环境**(随进程存活),
只销毁自己的会话。providers/chineseclip 与 providers/qwen3vl 本来就是这个约定,
现在三处一致。

## 验证(实测)

- 全新数据目录启动:配置库出现上述两个默认值。
- 模型未安装:`multimodal space active` 不出现,代之以明确错误
  (点名缺失的 embed_config.json 路径 + 已注册 provider 列表)+ 降级,不静默。
- 模型就位:`multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990
  modalities=[text image]`。
- 内存:同一份 homed,启用时 RSS **1.99GB**(峰值 2.09GB),不启用 **0.17GB**。
- NLP 修复:日志由 `ONNX parser init: ... using fallback` 变为 `dep parser initialized`。
- 构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
  `bash -n deploy/packaging/build.sh` 通过。

## 未做(明确记录)

- `libonnxruntime.so`(24MB)与 Chinese-CLIP 产物(754MB)目前都需自行安装/导出,
  发行版尚未打包它们。若要让「默认启用」在干净机器上真正开箱可用,需要决定
  是随包分发、安装时下载、还是保持文档指引。
2026-09-12 00:10:32 +08:00
bfdb395731 feat(memory): 新增 chineseclip provider —— text+image 的小体积可商用向量空间
## 为什么

用户决定「本轮不覆盖 video,先支持 text+image」。这一刀正好解锁了此前
「小 + 可商用 + 覆盖视频」三者不可兼得的僵局:不要求视频后,唯一同时满足
**小、可商用、中文原生** 的选项是 Chinese-CLIP ViT-B/16。

实测对比(同机、真实跑出来的数字):

| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 常驻内存 | 754MB / **1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
| 视频 | 无 | 有 | 有 |

本机可用内存只有 5.3GB,Qwen 的 9.4GB 无法进程内使用;而 ORT format + mmap
那条路被证实当前不通(转换器对三段图段错误;走通还需同时升 ORT 运行时与
Go 绑定,v1.36 要求 API 29 而本机只有 28)。1.15GB 则可以直接进程内跑。

**代价已写进包注释与文档**:CLIP 是双塔对比学习,text↔image 是强项,但纯文本
语义明显弱于 MLLM 型嵌入器;文本检索仍由既有词向量/TF-IDF 路径兜底。
需要更强文本语义或视频时切回 qwen3vl。

## 内容

- `providers/chineseclip/`:按公共 SPI 实现的 provider(注册名 `chineseclip`),
  含 BERT WordPiece 分词器、图像预处理、ONNX 双塔推理、无标签 stub。
- `scripts/export_chineseclip_onnx.py`:从官方权重导出规范产物 + 冻结参考,
  自带逐用例 PyTorch 对比与覆盖度断言(计划集合≠执行集合即非零退出)。
- `cmd/homed/main.go`:空白导入两个 provider,由配置选其一。
- `go.mod`:`golang.org/x/text` 由间接依赖转为直接依赖(删音标需要 NFD)。

## 实现要点

- **分词器逐 token 对齐官方**。第一版探针自己拼 BertTokenizer(只给 vocab.txt、
  没删音标、中文没逐字切),中文被整体切成 [UNK],三个不同句子产出几乎相同的
  向量(余弦 0.98)——差点把「模型坏了」当成结论。官方配置是 do_lower_case=true
  + 删音标生效 + 中文逐字切分;`TestTokenizerMatchesOfficialReference` 钉住
  逐 token 一致。
- **图像缩放自写 bicubic**(复刻 PIL 的 precompute_coeffs + a=-0.5 核),不引
  golang.org/x/image:它未进本机模块缓存,且最新版要求把整个工具链升到 Go 1.26,
  为一个缩放函数动工具链不划算。
- **归一化在 provider 侧**(两个塔的图里都没归一化),检索按余弦。
- **指纹覆盖全部影响语义的产物**:两个 ONNX 图 + vocab.txt + embed_config.json,
  读不到就写 MISSING(跳过等于对缺件不敏感)。
- 会话 Run 用 runMu 串行化(ORT 会话不保证并发安全),创建/销毁用 mu。

## 模态范围

只声明 `text` 与 `image`;`audio`/`video` 明确返回 `ErrUnsupportedModality`,
绝不用别的模型向量冒充(这是「音频明确 unsupported」纪律的落地)。

## 验证(实测)

导出侧:10 个用例(5 文本 + 5 图像)ONNX vs 官方 PyTorch 全部
`cos = 1.000000000`,覆盖度断言 10/10 通过。

Go 侧(`CHINESECLIP_MODEL_DIR=... go test -tags onnxruntime ./providers/chineseclip/ -v`):
11/11 通过,其中
- 文本 5 用例 `cos = 1.000000000000`(逐位一致)
- 图像 4 纯色用例 `cos = 1.000000`(与官方预处理在 6 位小数内一致)
- 跨模态判别:红图对「红色」文本高于「蓝色」文本
- 模态拒绝 / 空输入 / 指纹稳定 / 产物缺失报错

顺带修掉测试自身的一个假通过:参考向量是**未归一化**的原始输出(模长 10~36),
原先「点积当余弦 + 单侧下界」会让 13.6 也判过,已改为真余弦 + 双侧容差。

构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
`providers/... pkg/... internal/config/... internal/memory/vector/...` 回归通过
(qwen3vl 的 TestVideoModelInputMRope 需要 QWEN_ONNX_MODEL_DIR 指向含视频档的
v3 目录,缺该环境变量时用的是只有文本+图像的目录,与本改动无关)。

## 未做(明确记录)

- 发行版默认 provider 与构建标签变更:留下一提交(涉及打包与模型分发策略)。
- 模型产物(754MB)不进仓库,由导出脚本生成。
2026-09-11 23:58:53 +08:00
d1959cbe80 feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。

## 1. 注入标志位(内核侧)

- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
  no_memory / context_policy / cleaner_name;策略在入口**校验**,
  非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
  (零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。

## 2. 修掉「输入无条件裁剪」这个真缺陷

eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:

  优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
          > 默认**不裁剪**

查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。

## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)

原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。

现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。

顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。

## 4. homed 放弃 Windows 原生,改走 WSL2

插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。

- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
  **不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
  procEnvForShm 返回 nil。顺手修掉两处长期编译错误
  (cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
  注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
  **linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
  「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
  确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
  linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
  (/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
  (否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
  stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
  目标下明确拒绝。

## 5. 插件调用点统一写明意图

- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
  **带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。

## 6. 版本

meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。

## 测试

- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
  nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
  payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
  **跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
  到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
  落盘幂等、内容哈希稳定。

验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
      go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
      ./internal/agent/{core,io}/... ./pkg/...
2026-09-11 20:31:50 +08:00
a37bc7333e refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心,
`type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码
(BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对
`//go:build onnxruntime` 的 stub。加任何新模型都要改内核。

现在核心只认一个模型无关的公共契约(pkg/embedding):
- 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider
- 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口
- 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别)
- 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释

改动:
- 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表
  (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值)
- providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册
- internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口);
  ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口
  (那正是「核心为每个新模态长方法」的坏味道)
- http embedder 也变成普通 provider(注册名 http)
- cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 +
  透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动
- config:multimodal_space.type/onnx./http.* → provider + options.*
- 删除 internal/memory/qwen(整体搬迁)

测试:
- pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector
- vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、
  两个哨兵 errors.Is 互通
- providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→
  未知模态哨兵),并明确断言 Info 不声明 video

已知未完成(不得当作已验证):
- 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧
  video 模板缺少 processor 按时间组插入的字面时间戳文本
  (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、
  Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言
  通过不能证明与官方实现一致。修复属 provider 内部工作。
- 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000)

验证:go build ./... ;go vet -tags onnxruntime ./... ;
go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/...
;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
2026-09-11 18:26:19 +08:00
1a02971f88 feat(memory): 千问三段式 ONNX 嵌入补齐——可复现导出脚本 + Go 侧首次完整验证
此前三段式拆分后 ONNX 路径从未从 Go 侧跑通:embedder_onnx_test.go 仍引用
分段前的 API(e.renderInput、TextTower.onnx、旧目录),go vet -tags onnxruntime
直接编译失败。导出脚本只在 /tmp 且硬编码本机路径、从第三个目录拷贝固定形状的
Vision.onnx,完全不可复现。音频会被视觉塔编码,静默往统一空间灌入错误坐标。

本提交补齐这些缺口:

一、可复现导出脚本(scripts/export_qwen3vl_embedding_onnx.py)
- 自动拉取模型(HuggingFace 优先,失败回落 ModelScope,支持 HF_ENDPOINT 镜像);
- 导出 TokenEmbedding + Transformer + Vision 三段图,图文共用同一 token
  embedding、28 层 Transformer、last-token 池化与 fingerprint;
- 双重自检(不可省):分段 PyTorch vs 完整模型 + 导出后的 ONNX vs 完整模型,
  cos < 0.999999 即非零退出——「能加载」不等于「算得对」;
- 默认把 L2 归一化后的冻结参考向量写入产物目录(qwen_reference.json)——
  Go 测试据此做逐维冻结回归,且「该目录是哪次导出的」从文件本身可追溯;
- --verify-only 校验既有产物不重新导出,可用来确认线上在用的图没坏。

关键实测结论(已写入 docs/zh/multimodal-space.md 与长期记忆):
原生多帧视频不可行——Qwen3-VL 视觉塔把 grid_thw 当 Python 值消费
(grid_thw.tolist()),legacy tracer 固化为常量,导出后图中根本没有 grid_thw
输入,换帧数调用直接 Invalid input name: grid_thw。故视觉塔固定 (1,48,48),
视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint),音频明确
unsupported。

二、模态边界(vector.ErrModalityUnsupported)
- 新增 vector.ErrModalityUnsupported:表示「该模态不在本统一空间的原生覆盖
  范围内」,与普通错误语义不同——调用方应把它当「永远不会有向量」而非
  「本次失败、下次重试」;
- qwen.EmbedImageDense 按 mime 拒绝 audio/* 与 video/*:此前它会拿视觉塔
  去解音频字节,往统一空间灌入语义错误的坐标且静默;
- reembedStaleMedia 对 ErrModalityUnsupported 不计失败、不重试、不用别的
  模型向量顶替(TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 守住)。

三、Go ONNX 测试首次完整通过
- 重写 embedder_onnx_test.go:修复编译 + 文本冻结回归 + 图像冻结回归 +
  两条阴性对照(不同输入必须不同、图像与文本必须不同)+ 不支持模态断言;
- 参考值从产物目录的 qwen_reference.json 读取(不在测试里硬编码浮点);
- 用线上部署产物实测全部通过(text cos=0.999999940, image cos=0.999999762)。

四、.gitignore 修复
- /scripts/ 此前被列在「运行时产物」下,但它是作者维护的工具目录
  (模型导出、侧车、部署校验),deploy/systemd/embed-sidecar.service 直接
  引用 scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向
  不存在的文件。改为只忽略 __pycache__。

五、文档(docs/zh/multimodal-space.md)
- 获取/启用/产物契约/模态边界/验证/资源成本/与现有部署产物的等价性。

验证:go build ./...、go vet ./...、go vet -tags onnxruntime ./...、
go test -short 全部通过;ONNX 标签测试对线上部署产物全部通过。
2026-09-11 13:45:25 +08:00
5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00
dae01f9c06 refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。

改为与文本块完全一致的生命周期:

1. 一等记忆块直接由所在层持有
   - ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
   - 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
   - Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
     同一块不同时存在于两层

2. 删除平行生命周期账本
   - media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
     AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
   - 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
   - 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
     与"删除文本块即删除内容"同一语义

3. L3 原生结构
   - memory_blocks / memory_block_edges(contains/depicts/derived_from)
   - 边端点必须是真实图节点,不再用 owner 字符串伪装关系
   - BlocksForNode 支持 sentence --contains--> block 反查

4. SDK 与检索同步
   - 插件附件/标记直接变成块,不再 AddRef
   - 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)

测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。

注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
2026-09-11 10:57:22 +08:00
e44164f5bd feat(memory): Graph 原生一等记忆块节点与结构边(§13.12)
媒体/文本在 L3 不再是正文标记反解出的代理实体,而是带原生
modality/digest/MIME/size/vector/fingerprint 的 memory_blocks 节点;
contains/depicts/derived_from 等语义边落在 memory_block_edges,
端点必须是真实图节点(block/entity/sentence),不复用 owner 字符串。

本步只建立存储与查询能力,不接入 media_refs,也不改变 L0/L2 路径。
2026-09-11 10:08:59 +08:00
96c1d7baae fix(memory): 千问文本塔补齐 tokenizer post_processor 与真实 ONNX 回归
验证 Go 端到端路径时发现:HuggingFace 的 tokenizer.json 带 TemplateProcessing
post_processor,规则是 `$A <|endoftext|>`——即每段输入末尾都会追加一个
`<|endoftext|>`(151643)。它正是图内 last-token 池化的锚点:

- 漏掉它:ONNX 仍能运行(不会报错),但池化取到的是模板末尾的 assistant
  起始符,而非 post token,整条嵌入向量与上游不一致;
- 截断语义:HuggingFace 在 truncation=true 时先把正文截到 maxLen-1,
  再保留末尾 post token(实测 600×"记忆"→ [511 正文][151643])。

改动:
1. Tokenizer 新增 encodeModelInput(text, maxLen):Encode 后追加 post token,
   并在超长时先截到 maxLen-1;缺 <|endoftext|> 直接报错(防静默错误)。
2. Embedder.VectorizeDense 改用 encodeModelInput。
3. 测试:
   - TestEncodeModelInputPostProcessor:短文本+post token;长文本按 512 截断
     且尾 token 为 post token(用 600×"记忆"确保真的触发截断分支)。
   - embedder_onnx_test.go(onnxruntime 标签):用 Python onnxruntime 1.28
     生成的冻结参考向量验证完整 Go 路径(模板渲染→BPE→ONNX→L2 normalize),
     逐维 diff ≤ 2e-5;同时断言模型输入恰好 23 token 且末尾是 post token。
     产物不在时跳过(与 tokenizer_test 相同约定)。
   - 修正先前测试误用 400×"记忆":BPE 把"记忆"合并为单 token,400 次只有
     400 token 不触发截断;改用 600 次后确实走到 maxLen-1 分支。

其余(ORT 库路径、指纹纳入 .onnx.data)一并随本提交带上。

验证:go test ./internal/memory/qwen 与 -tags onnxruntime 全绿;
FP32 图与 PyTorch 在短/长/等长批次/真实 padding 批次上余弦均 ≥0.99999994。
2026-09-11 03:09:01 +08:00
8e88ae789f feat(memory): 千问文本塔 ONNX 嵌入器(onnxruntime 标签,含 stub)
与 internal/memory/clip 同模式:`//go:build onnxruntime` 编真实实现,无标签时
走 stub,默认构建不链接 onnxruntime、行为不变。

加载契约(目录由 core.memory.multimodal_space.model_dir 指定):
TextTower.onnx + 外部权重分片、tokenizer.json、embed_config.json。
图内已含 last-token 池化,输出即 [batch, dim];L2 归一化在 Go 侧做。

两个刻意的设计选择:

1. **EmbedImageDense 明确报错,不返回零向量**
   导出的是文本塔,视觉塔未导出。返回零向量会让「写入了但检索不到」,
   把跨模态检索失效变成静默故障;明确报错则调用方(mediaref.go)log 后
   跳过写向量,文本路径不受影响。

2. **Fingerprint 只哈希图文件 + 配置 + 外部权重的文件名与大小**
   该目录有 6.5GB 权重分片,启动时全读一遍要几十秒、会阻塞 homeagent 启动。
   换模型必然改变文件集合或大小,足以识别切换;代价是理论上存在
   「大小相同但内容不同」的漏判,对本地单机部署可接受。已写入注释。

模板渲染(renderInstructionInput)放在无构建标签的 tokenizer.go,因此可被
测试覆盖:参考数据里有该模板串的用例,逐 token 对齐验证——模板差一个字符,
池化取到的「最后一个有效 token」位置就变,向量就不同,且不会报错。

验证:6 个测试全绿;go build ./...(stub)与 go build -tags onnxruntime
(真实实现)均通过。
2026-09-11 00:16:45 +08:00
e985151da9 feat(memory): 千问字节级 BPE 分词器 + 与上游逐条对齐的回归测试
为「千问嵌入模型导出 ONNX 并内嵌」的 Go 侧准备。CLIP 那套 tokenizer 不能复用:
CLIP 是「小写化 + 空白规整 + 词表 BPE」,千问是 **GPT-2 式字节级 BPE**
(先按字节映射到安全 unicode,再对映射结果做合并),中文与空白输入的切分
完全不同。

实现中撞到两个与上游对齐的坑,都由测试暴露:

1. **`\s+(?!\S)` 的语义依赖正则回溯**,不是「等价于 `\s+`」。
   `\s+` 先贪婪吃完整段空白,发现后面是非空白导致 `(?!\S)` 失败,于是回退
   一个字符,**正好留下末尾一个空白**给前面以 ` ?` / `[^…]?` 开头的分支合并。
   这直接决定切分点:`"   leading"` 会切成 `"  "` + `" leading"`,
   而不是 `"   "` + `"leading"`。RE2 不支持 lookaround,近似改写必然对不上,
   所以改成按分支顺序**显式实现**(含 `\s*[\r\n]+` 的回溯语义)。
   实测:近似改写时 26 条里错 3 条,全部是空白串用例。

2. **Go 的 `\s` 只有 ASCII,且 regexp 不支持二进制属性 `\p{White_Space}`**
   (只支持 script/category,直接写会报 invalid character class range)。
   而上游 Rust regex 的 `\s` 正是 White_Space。改用 `unicode.IsSpace` 作为
   唯一判据,避免全角空格/NBSP/行分隔符的切分点漂移。

另外特殊 token(`<|im_start|>` 等 24 个 AddedToken)必须**整体优先匹配**并
按长度降序,否则会被 BPE 拆成子 token,模型收到的输入就变了——且不会报任何错。

验证:`testdata/qwen_tokenizer_reference.json` 由 HuggingFace 真实 tokenizer
生成(26 条用例,覆盖中/英/中英混排/数字/各类空白形态/标点/emoji/特殊 token/
长文本/空串/单字符边界),Go 实现逐条精确对齐。字节↔unicode 映射另测双射性
(有碰撞会让不同字节编成同一 token,静默产生错误输入)。
2026-09-11 00:11:58 +08:00
b393b7072c fix(ctx): prune 的查询向量改用插件 Cleaner 清洗后的有效内容(§13.8)
ContextPolicy=prune 上线时直接把**原始**工具结果传给 RelevanceContext.Prune,
而 Prune 的入参是**相关性查询向量**——它决定保留/归档哪些上下文事件。于是
ANSI 转义、base64、JSON 包装等噪声全被编进查询向量,打分失真,裁掉本该
保留的事件。

而 ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」,裁剪正是
在向量化——所以这是**回归契约**,不是新增能力。此前只在构建事件向量
(context.go 的 toolOutputClean)时用了 Cleaner,裁剪查询这一处漏了。

回退规则(Cleaner 是计算层优化,不能因它失效而丢内容):
- 未注册 Cleaner → 原文
- RPC 失败 → 原文(proc 侧 cleanerProxy 已有此保证)
- 返回空串 → 原文(空串会让查询向量退化成零向量,所有事件相关性相同,
  等于随机裁剪)

验证:TestToolOutputForQueryAppliesCleaner(Cleaner 被调用恰好一次且用其
结果;无 Cleaner / nil stageHost 回退原文)、
TestToolOutputForQueryEmptyCleanFallsBack。

顺带把 §13.13 第 5 条(反向大结果)按核实结论结掉为「不做」:核实发现根本
不存在 llm.chat(llm.* 只映射切换 LLM 源),唯一可能返回大结果的 doc.query
没有任何外部插件使用且已被 CapDocMemory 能力门限制。留成永久 TODO 只会误导。
2026-09-10 23:59:12 +08:00
50ba4a64cb feat(shm): 文档/知识正文入共享内存 + 协议版本 bump 到 2(§13.13)
## 数据面补齐

- doc.insert / doc.insertWithMedia:新增 doc_ref / attachments_ref,
  模板序列化后 putValueInArena
- knowledge.add:新增 content_ref(内容是 JSON 字符串,读出后再解一层)
- 抽出通用 resolveJSONRef(resolveBlocks 也改用它),三处共用一套
  “共享优先、内联回退”逻辑

## 协议版本 bump:让错配显式失败,而不是静默坏

这是本轮更重要的部分。§13.6/§13.13 改了内核→插件 payload 的承载方式,
两种错配都不会报错、只会静默失效:

- v1 插件只读内联 args(tool/cleaner/output)→ 遇到 v2 内核拿到空参数
- v2 插件发 blocks_ref → v1 内核反序列化时静默忽略(旧内核
  io.setToolBlocks 还是桩实现)

现场表现为“输出变空 / 图注入没反应”,极难定位。所以把 ProtocolVersion
与模板 procProtocolVersion 一起 bump 到 2:双方都是等值校验,v1 插件遇上
v2 内核会在建链时明确报“协议版本不匹配…请用配套 plugindev 重编”。

测试里把“错误必须带出重编指令”也断言上了——生产上碰到它的现场就是
“只更新了内核没重编插件”,光报“不匹配”定位不到行动。

testdata 8 个插件的 protocol 同步更新(badprotoplugin 仍用 999 验证拒绝)。
工具链已重建并安装(协议 2,内嵌 blocks_ref/doc_ref/content_ref),
回滚副本 plugindev.bak-20260910-232544。

验证:-race 全绿。新增 KnowledgeAddViaArena(12000B 正文)、
KnowledgeAddInline、DocInsertViaArena、SetToolBlocks 三例 +
真实模板 e2e + 协议不匹配断言强化。

§13.13 第 5 条(反向大结果)范围更大——需把内核→插件的应答路径整体改成
“大结果写段 + 返回 ref”,涉及 callCore 的返回处理与 doc.query/llm.chat 等
所有读大结果的 method。已在 plan.md 标注未做,不冒充完成。
2026-09-10 23:26:16 +08:00
15d912ef34 feat(shm): 媒体块入共享内存 + 实现 setToolBlocks 桩(§13.13)
两个问题叠在一起,先发现的是第二个:

1. **io.setToolBlocks 在内核侧是桩实现**——直接返回“多模态注入待共享段
   二进制通道落地”。也就是**子进程插件调 SetToolBlocks 必然失败**(模板
   只 log 一行),只有内置插件(multimodal)能用。之前还有测试把这个错误
   当作预期行为断言着。
2. 媒体块(injectMedia 系列 + setToolBlocks)把 blocks 内联在 RPC JSON 里,
   而本地生成的图/音频是 base64 data URL,一张图可达数 MB。

顺带修掉一条与设计相悖的旧注释:injectMediaParams 写着“data URL 已是
base64 文本、再套一层二进制不会更小,所以走 JSON”。那只算了体积,漏了
两件更重要的事——内联时整份 base64 要在 RPC 报文里再编码/再拷贝一遍;
以及内容本体不在共享段里,插件回调就无法就地改写,只能各持一份拷贝。
共享内存的意义是后者。

实现:
- injectMediaParams 加 BlocksRef;新增 resolveBlocks(共享优先、内联回退)
- 真正实现 MethodIOSetToolBlocks(空块明确报错,不静默成功)
- CoreSDK 接口加 SetToolBlocks,procCore 转调 internal/sdk
- 模板:SetToolBlocks / injectMedia 三兄弟经 putValueInArena 传 blocks_ref;
  同步调用用 mediaArgsOwned 拿释放函数,**不能在应答返回前释放槽**,
  否则内核读到已释放内存

验证(-race 全绿):
- TestCoreHandler_SetToolBlocksViaArena / Inline / EmptyRejected
- TestE2E_RealTemplateSetToolBlocksViaArena:真实 SDK 模板编译的插件,
  9000 字节 base64 图经 blocks_ref 完整送达

工具链已同步:/usr/local/bin/plugindev 重建为新协议(内嵌 blocks_ref),
回滚副本 plugindev.bak-20260910-224255。注意内核与全部插件必须同批替换
——只换一边会静默坏(新内核+旧插件=输出 payload 变空;新插件+旧内核=
媒体注入静默失效),已记入长期记忆。
2026-09-10 22:45:19 +08:00
2a4315cfe4 docs(plan): 修正 §13.13 判据——按「回调能否就地改写」而非 payload 大小
之前把共享内存的理由写浅了(当成“省管道 / 避免大 payload”)。真实目的是
找回 .so 时代的能力:插件回调(Cleaner / Stage / 工具)与内核同进程时可以
直接就地改写参数与结果;多进程化后若靠 RPC 来回发消息,回调就只能
“读一份、回发一份”,丢失就地改写语义。共享内存是把内容放进段里、回调就地
改、只回描述符。

因此判据应是「插件回调要能就地改写的内容有没有留在段里」,不是 payload 大小。

按新判据核实:回调主线已达成——
- Stage:invokeStage 只发 {Stage, Seq},插件就地改写,应答只回 DirtyFields 计数
- Cleaner:发 {Scope,Name,Frame,InputLen},回 TextRef(16B 描述符),内容不随 RPC 走
- tool.invoke:内核标定 Frame,结果 ResultRef,after_toolcall 可在段内再改

未入内存的重新排序,第一条换成 io.setToolBlocks:它直接破坏“结果媒体能被
after_toolcall 就地改写”——插件只能推一份 base64 拷贝过去(ai_image 大图)。
2026-09-10 21:47:23 +08:00
b09f459aea docs(plan): 修正 §13.5 的错误判断,建账 §13.13 剩余内联路径
§13.5 之前写的「内核侧未接线」是方向性错误:我把分配方认错了。
实际是**插件侧** putInArena 做 arena.alloc + 写入 + 传 text_ref,内核侧
resolveText 读回。分配在插件侧并非缺口——§13.2 的模型就是「插件经 RPC
向内核申请/归还」,内核独占分配器;procCore.InjectText 拿到普通字符串
也是对的,因为共享内存是跨进程的内部实现、不对插件开发者暴露。

新增 §13.13,把「全量数据交互入共享内存」这个总目标的尾巴显式建账,
区分数据面(要入)与控制面(不该入,几十字节搬进去反而多两次 RPC):

已入:StageContext / 事件环 / tool.invoke / cleaner.invoke /
      output.invoke(本次 §13.6)/ io.injectText(§13.5)
未入:① 媒体块 io.injectMedia / injectMediaSync /
      injectInterruptMedia / setToolBlocks(本地大图 base64 可达数 MB,
      最大一条)② doc.insert / insertWithMedia ③ knowledge.add
      ④ 插件反向调内核读大结果仍内联
2026-09-10 21:34:42 +08:00
e2672c56d6 feat(shm): 输出通道 payload 走共享内存调用帧(§13.6)
§13.3 给 ToolInvokeParams 加了调用帧,但 OutputInvokeParams 没跟上——
payload 仍内联在 RPC JSON 里。核实后确认这个 lane 只做了半边:注入侧
(injectParams.TextRef + resolveText)可用,输出侧从内核到插件仍是内联。

改法照搬工具调用的 funccall 帧模型:

- OutputInvokeParams 加 Frame/ArgsLen(Args 仅留给直连 RPC 的测试)
- invokeOutput 序列化参数 → Alloc 帧 → 写帧 → 只传偏移描述符 → defer Free
- 帧尾不预留结果区:output 应答很小("ok"/status map),走 RPC 应答字段即可。
  但 OutputInvokeResult 支持插件把大结果写回帧(ResultRef),并识别
  sharedRefFlagExpand 单独归还扩容块——与 invokeTool 一致。
- 模板 output.invoke 用 frameInput 从帧读参数,无帧才回退内联 Args

为什么要做:output payload 在真机上可能含图片/文件描述等大字段,内联会
撑爆 stdin/stdout 管道。

验证:
- TestPlugin_OutputPayloadViaArena:9000 字节 payload 经帧完整送达(插件回报
  实收长度,不是只看返回值)+ 调用后 arena 归零
- TestE2E_RealTemplateOutputPayloadViaFrame:同上,但用真实 SDK 模板编译的
  插件——生产插件走的就是模板,模板不读帧则此改动等于没做
- TestPlugin_OutputChannelReportsRealFailure 仍绿:同步等真实结果、失败必须
  上报(§9.4)的语义未被破坏
- go test -race ./internal/plugin/... 全绿

plan.md §13.5 也如实标注:注入 side 内核→插件方向仍未接线(procCore.InjectText
直接传字符串,从不 Alloc/Put 构造 TextRef),不是仅打勾的项。
2026-09-10 21:28:56 +08:00
b71a88a471 chore(shm): 补齐 §13 验证项,删除统一区域遗留死代码
三件事,都是 plan.md §13 里未打勾的项:

1. §13.3「Bench: ToolInvoke 延迟对比」补测(bench_test.go)
   改造后原有的 BenchmarkToolInvoke 仍走 legacy 内联 Args,已不代表生产
   路径。拆成 inline / frame 两个子基准并各测两个尺寸(-count=3 取中位):

     payload      inline(RPC 报文)   frame(共享内存帧)   差
     16 B          26.4 µs/op           48.0 µs/op        +21.6 µs
     32 KiB       695.6 µs/op          391.7 µs/op       −303.9 µs

   取舍随尺寸翻转:小 payload 多付 ~22µs 帧固定开销,大 payload 省掉整份
   JSON 编解码与管道拷贝、快 44%。因为线上工具结果动辄几十 KB 且 22µs
   相对 LLM 往返 2-8 秒可忽略,所以统一走帧而不按大小分叉(§13.3 第 4 条)
   是对的。

2. 删除 §13.1 遗留死代码(evtring.go)
   allocEvtRing 自统一共享区域之后从未被调用——旧版它单独建 memfd + eventfd,
   现在只有一个 memfd,事件环只是区内的一个 segment(NewEvtRing 操作区内
   切片)。留着会让人误以为事件环还有独立段。

3. 修正误导性注释(plugin.go)
   仍写着旧 3-fd 布局「3=StageContext, 4=事件环, 5=通知」,与实现
   (procExtraFilesForShm 只返回 memfd+evtfd)和 shmpass_unix.go 的权威
   注释互相矛盾。

plan.md:§13.1/13.3/13.4/13.8 的验证项按实测打勾,并注明证据(提交号 /
测试名 / 实测数字),不留无依据的勾。
2026-09-10 21:18:16 +08:00
e218d0f100 fix(agent): 工具循环占位不再驱动重复发送,输出回执/子任务结果幂等
生产现象:单轮内 output_send__qq 被调用 34 次、持续 514 秒,直到 QQ 插件
自己的循环保险拒绝发送才停下(problem.md)。根因是多环节叠加,核心侧修四处:

1. 工具轮补位文案(process.go)
   通用占位「请根据以上工具结果继续。」对纯输出通道调用是错的:异步通道
   (qq/wechat)的回复只能经 output_send__* 交付,所以模型「已完成回复」的
   表达形式就是一个工具调用,紧随其后的「请继续」会被读成「还要再做一步」,
   而能做的「一步」恰好还是再发一条消息。
   改为按上一批工具的性质选文案:全部是 output_send__* 时补
   「若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。」
   同时每轮先移除旧占位再补一条,避免占位在 prompt 前缀里线性累积。
   (该占位是 zen 网关「最后一条必须是 user」的传输层附加物,HEAD 版本是
   无条件内联追加、从不移除。)

2. 输出成功回执(output.go)
   「已通过 [qq] 通道发送: map[status:sent]」这类富回执会被读成「这步成功,
   继续下一步」。成功改为只回极简标记。

3. proc 桥标量透传(internal/plugin/proc/plugin.go)
   插件返回 "ok" 时不再伪造 {status:sent} 覆盖插件真实返回值,否则只改
   output.go 不生效。

4. 子任务结果幂等(spawn.go)
   child_result 原先读到即删,而完成通知长期留在持久上下文里
   (formatMergedTimeline 每轮重新注入),第二次查询必然得到
   「不存在或已过期」这个永久失败信号,模型据此认为任务未完成而反复重试。
   改为保留结果 + delivered 标记,重复查询返回明确提示;结果按上限有界淘汰。

顺带:agent.go 去掉文档层显式向量器注入(TF-IDF 已内置为 fallback),
cmd/homed/main.go 同步 document.NewStore 的 tokenizer 参数。

测试:internal/agent/core/tooloop_test.go(5 例)、spawn_test.go(3 例)。
2026-09-10 20:36:39 +08:00
b096e8ba2c refactor(shm): 变长块分配器 + 工具调用 funccall 调用帧(§13.2/§13.3)
按架构约束推进两步:

1) 分配器收回内核后,定长槽失去了唯一存在理由
   (定长槽只是为了绕开"跨进程无法安全变长分配"),
   于是 arena 换成**变长块分配器**:first-fit + 邻块合并,
   块头 16B(size/state/owner/prevSize)。内核可以按需标定每块大小,
   payload 不再受固定槽容量限制(旧上限 16KB)。

   - Alloc/Put/Read/Free/ReclaimOwner 全在内核进程内,一把 sync.Mutex
   - Free 走块链校验 offset 是已分配块的数据起点 + owner 匹配,
     伪造引用不能改动分配器状态
   - Read 允许块内偏移(调用帧的结果区就在帧块中间),但不许跨块边界
   - arena 4MB,底层 memfd 惰性分配:未触碰的页不占物理内存

2) 工具调用 payload **始终**走共享内存,取消按大小切内联的分支。
   按 funccall 模型,内核(caller)标定调用帧交给插件(callee):

       [0, ArgsLen)              参数 JSON
       [ArgsLen, Frame.Length)   结果区(内核预留的预算)

   结果超出预算时插件才 `arena.alloc` 扩容块,引用上打
   sharedRefFlagExpand 让内核单独归还。Cleaner 复用同一帧模型。
   ToolInvokeParams.Args / ToolInvokeResult.Result 仅剩给直连 RPC 的
   测试(process/bench 不建 Host);生产路径永远走帧。

测试:
- TestArena_*(分配/归属/回收/并发唯一/耗尽/超限/伪造 offset/
  相邻合并/对齐/布局校验)
- TestPlugin_ToolInvokeArgsResultViaArena:大**小** payload 都经帧往返,
  结果一致且 arena 归零("小 payload 同样走调用帧"是本轮行为变更)
- TestPlugin_ArenaAllocFreeAcrossProcess、TestE2E_* 保持通过
- 顺手修 process_test.go 一处预存 doc-comment 分段
2026-09-10 18:54:36 +08:00
f6f92a8a6e fix(plugins): 修复两处预存缺陷(agentcli 临界区 / localuse 脚本转义)
agentcli cleanupLoop:
持锁期间为每个过期终端 go func 调 term.Close()(内部会 Kill 进程并等
<-t.done),临界区被拉长且与 TerminalSession 退出路径交错。改为持锁
只做筛选与摘除,锁外再逐个关闭。

localuse 脚本转义(两处真实 bug):
1. AppleScript 注入:handleScreensue 只把 `"` 转义为 `\"`,未转义 `\`。
   内容结尾的反斜杠会吃掉闭合引号,使后续内容逃逸出字符串字面量。
   改为 escapeAppleScriptString:**先转义反斜杠再转义双引号**(顺序
   不可颠倒,否则刚插入的反斜杠会被二次转义)。
2. PowerShell 路径被错误加倍反斜杠:handleScreensee 把 Windows 临时
   路径的 `\` 写成 `\\`。但 PowerShell 单引号串里反斜杠是普通字符
   (转义符是反引号),加倍会让截图保存到错误路径。
   新增 psSingleQuote 统一处理,并把 4 处零散的 `ReplaceAll(x,"'","''")`
   收敛到该 helper。
2026-09-10 17:56:57 +08:00
c03de9878d fix(proc): EvtConsumer 生命周期——消除 host.Close 后的 SIGSEGV
预存缺陷(非本次重构引入,但会稳定复现崩溃):

Stop() 只 close 了 stop channel,而 Run() 阻塞在 evtfd.Read 里,
根本没有机会检查 stop。调用方在 Stop 后释放 ringData(host.Close
会 munmap 整个区域),Run 一旦从 Read 恢复就会读已解除映射的内存:
**SIGSEGV,recover 捕不到**。实测 TestEventRing_OverflowStillDelivers
约 50% 概率触发。

两次尝试与结论:
1. os.File.SetReadDeadline 无效——eventfd/pipe 经 os.NewFile 包装后
   **不会**注册进 Go netpoller(os.NewFile 对非 open 得到的 fd 一律按
   非 pollable 处理),Read 是阻塞 syscall,SetReadDeadline 返回错误。
2. 改为 poll(2) 显式加超时(evtpoll_unix.go),消费循环每 100ms 回到
   stop 检查。

新增 API 契约:
- Stop() 非阻塞,仅请求退出
- Wait() 阻塞至 Run 退出;**返回后才能释放 ringData**
- drainEvents 每条事件后检查 stop,避免慢 handler 拖延退出

其他:
- evtring_test.go 三个用例改为 defer Wait() → defer Stop()(LIFO 保证
  Wait 先于 host.Close 完成)
- 溢出用例的 handler 改为非阻塞投递:写入了 8292 条事件而 channel 只
  消费 1 条,阻塞投递会让 drainEvents 卡在 handler 里,Stop 无法退出
2026-09-10 17:56:54 +08:00
ca584bda3f refactor(shm): 内核独占共享槽池,插件经 RPC 申请/归还(§13.2/§13.3)
推翻前两版跨进程分配器设计,根因是"共享内存里放了只被单一进程
更新的可变游标":

- v1 在 SuperBlock 放 arenaUsed 游标,内核 CAS bump。但插件模板里
  arenaUsed 是**进程本地变量**,两进程各自 bump 必写同一段内存;
  arenaReset 还会重置共享游标覆盖对方数据。
- v2 把位图 CAS 下沉到插件模板,正确但把分配器实现泄漏进插件运行时,
  且插件必须与内核保持位图布局同步。

新设计(用户明确的架构约束):内核全权管理共享内存,插件通过
syscall 风格 RPC 申请/归还,内核返回偏移与大小。分配器只存在于内核
进程内,一把 sync.Mutex 即可。共享内存是内部实现,不对插件开发者
暴露——SDK 公开 API 仍是普通字符串/Map。

主要改动:
- arena.go: 定长槽 + 位图,Alloc/Put/Read/Free/ReclaimOwner,内核独占;
  槽头记录 owner,Free 校验归属;payload 超槽容量退回内联 RPC
- protocol.go: 新增 arena.alloc / arena.free(CapCore)
- capability.go: 登记两个新 method(checkAllMethodsClassified 要求)
- corehandler.go: 实现 arena.alloc/free;Cleaner 改为内核预分配
  请求槽 + 响应槽(插件完全不分配)
- plugin.go: Start 领 ownerID;handleExit 调 ReclaimOwner 回收残留槽
- unified.go: 移除坏的 bump 分配器;arena 基址 8 字节对齐
- 删除 toollane.go: ring 状态机是死代码(从未接线),且把 RPC 已有的
  请求 ID 关联/错误传递/ctx 取消重新实现了一遍。控制面保留 RPC,
  只把 payload 搬进共享槽。
- 测试: TestArena_*(归属/回收/并发唯一/耗尽/超限/非法引用/布局)+
  TestPlugin_ArenaAllocFreeAcrossProcess(真进程申请→写入→随业务
  RPC 回传→归还→内核读回一致且池归零)
2026-09-10 17:56:50 +08:00
cf3c99233b fix(shm): 审核修复——arena 并发安全 + ToolCall ring 抢占 + arenaRead 校验
§13.2: arenaAlloc 改 CAS bump(修复并发覆盖);arenaRead 增加 generation + arena 边界校验
§13.3: Reserve 从 Store+Load 改 CAS-free→Reserved 状态机抢占帧(修复并发分配重复);
  增加 RESERVED 中间态;SetReading/SetReady 改 CAS 返回 bool;ReleaseFrame 清零跳过 state
§13.5/13.6: 注入方法接入 resolveText(从 arena 读 SharedRef);cleanerProxy 加 arenaMu 串行化+每次重置
§13.8: ContextPolicy prune topK 改为 maxContextSize-1(不再硬编码 20)
  tool.register 校验 context_policy 只允许 none/prune
2026-09-10 17:03:32 +08:00
2bc813be13 feat(shm): InputChannel/OutputChannel lane 基础设施(§13.5/§13.6)
- injectParams 加 TextRef SharedRef(兼容旧 Text 字段)
- resolveText 辅助函数:优先 SharedRef,否则内联 Text
- 核心注入方法待迁移至 resolveText
2026-09-10 16:14:17 +08:00
9cfdfc9de8 feat(shm): arena + ContextPolicy(§13.2/§13.8)
- unified.go: arena 分配器 (alloc/write/read/reset)
- NewHost: 分配空间含 arena
- SDK ToolDef 加 ContextPolicy string
- StageAfterToolcall 后检查 prune 策略触发 RelevanceContext.Prune
2026-09-10 15:49:47 +08:00
772a494223 feat(ctx): ContextPolicy tool 上下文策略(§13.8)
- SDK ToolDef 加 ContextPolicy string(默认 none / 可设 prune)
- StageAfterToolcall 后检查工具 ContextPolicy=prune → RelevanceContext.Prune
- 所有现有测试通过
2026-09-10 15:41:23 +08:00
5608abdf5a feat(shm): Cleaner 迁移到 SharedRef(§13.4)
- CleanerInvokeParams/Result 的 Text 改为 TextRef SharedRef
- cleanerProxy: 写 text 到 arena,传 SharedRef,读结果 SharedRef
- 插件侧 cleaner.invoke: 从 SharedRef 读 input,清洗后写回 arena 返回 TextRef
- NewHost(): 分配空间含 arena(256KB)
2026-09-10 15:30:00 +08:00
cf098a1d4f feat(shm): arena allocator for unified region (§13.2)
- SuperBlock 新增 arenaOff/arenaCap/arenaUsed 字段
- arenaAlloc: append-only bump 分配,offset 0 保留给空语义
- arenaWrite: 写入并返回 SharedRef(含 generation)
- arenaRead: 按 SharedRef 切片读取
- arenaReset: 压实后重置游标
2026-09-10 14:33:44 +08:00
a8e3633174 feat(shm): ToolCall ring buffer(§13.3) — 栈帧模型 ring,push/pop 帧状态机
- ToolCallRing: 定长帧 ring buffer (64 frames × 112B),状态机 FREE→CALLING→READING→READY→FREE
- Reserve: 环形扫描找 FREE 帧,全忙返回 ErrToolCallRingFull 背压
- SharedRef: pack/unpack 辅助函数
- unified.go: arena 分配器(arenaAlloc/arenaWrite/arenaRead)
- 5 个单测覆盖 init/reserve/背压/find/reuse
2026-09-10 12:33:58 +08:00
ad016e4410 feat(shm): 统一共享内存区域(§13.1) — 单 memfd 容纳 SuperBlock+StageContext+EvtRing
- unified.go: SuperBlock 布局 + SharedRef 类型
- NewHost(): 两段 memfd 合并为单一 memfd,fd 3 = 统一区域,fd 4 = eventfd
- 子进程侧: testdata 插件从 SuperBlock 解析 ctxOff/evtOff
- streaming 测试: EvtConsumer 协程在 host.Close 前 Stop+Wait 防 SIGSEGV
2026-09-10 11:42:22 +08:00
7a328679da feat(proc): Cleaner 跨进程修复(tool/input/output) + 共享内存安全模式 + plan §13
- Cleaner 协议统一为 cleaner.invoke(scope,name,text),覆盖工具/输入/输出三类
- 新增 ShmSecurityMode (safe/debug/full),Linux 审计探针,Windows crypto nonce
- plan.md 追加 §13 步骤分组
2026-09-10 10:21:50 +08:00
580d5f5501 feat(doc): dense vector index for unified text+media retrieval
文档层引入稠密向量索引,与媒体检索共享同一多模态空间:
- Doc 加 DenseVec 字段(json:-,运行时计算)
- Consume/QueryScored 优先使用 denseSearchScored(brute-force cosine),
  未配置时退化到 TF-IDF 倒排检索
- buildDenseIndex 在 Agent 启动时为全部文档一次性计算稠密向量
- L0 RelevanceContext 支持 denseSpace(Prune 使用稠密余弦),
  退化到 fastText 稀疏余弦

vector 包新增 DenseCosine([]float64 brute-force cosine)。

验证:492 篇文档 brute-force ~300ms,全部测试通过。
2026-09-09 18:56:44 +08:00
6c2039f5c9 feat(vector): pluggable multimodal vector space
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3
向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索:
  - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译
  - http:外部向量 API 服务(Jina v5 / OpenAI / 自建)

跨模态融合权重改为 CrossModalFusionConfig 可配置结构体,
移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。

模型切换自动迁移:
  - StaleVecDigestsAll 支持全模态(image+audio+video)
  - 启动时并发重算(ONNX 4 workers / API 8 workers)
  - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug

实测对比(492 篇生产文档 + 3 张真实图片):
  - TF-IDF:MRR 0.457(精确匹配快,语义差)
  - fastText:MRR 0.530(语义中等,延迟 8ms)
  - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms)
  - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611

See docs/embedding-comparison.md for full benchmark.
2026-09-09 17:38:34 +08:00
6f8056d236 refactor(clip): CLIP 收敛为稠密 MultimodalEmbedder,不污染稀疏 Vectorizer/TF-IDF 语义
文本相似度检索是层次化系统:TF-IDF 高频削弱加权(idf<0.1 丢弃)+
倒排剪枝(只召回共享特征者)+ cosine。CLIP 512 维稠密向量若以
map[string]float64 稀疏形式实现 vector.Vectorizer 并塞进 vector.Store,
会让 512 维全部成为倒排 key → 候选集≈全库、剪枝失效,且绕过 TF-IDF
高频削弱,与既有文本检索语义错配。

收敛:
- vector.MultimodalEmbedder 改为独立稠密接口(VectorizeDense/
  EmbedImageDense/Fingerprint/Dim/Loaded/Close),不再继承稀疏 Vectorizer
- clip.Embedder 删除稀疏垫片 Vectorize/EmbedImage/denseToVector,
  只产出稠密向量;文档/知识/上下文层继续用 TF-IDF/fastText 稀疏路径
- 分层明确:文本→文本走 TF-IDF/fastText;文本↔图像、图像↔图像走
  CLIP 稠密 QueryMedia(媒体层独立稠密余弦,原样保留)
2026-09-09 10:26:10 +08:00
98365f3a55 feat(clip): 多模态向量器(CLIP ONNX)——文本/图像 512 维共享空间 + 媒体向量写入与重算
- internal/memory/clip:CLIP ONNX 向量器(onnxruntime 构建标签控制,默认构建不链接 ONNX)
  - clip.New(modelDir) 加载 text.onnx/vision.onnx(输出 text_embed/image_embed [batch,512])
  - 实现 vector.Vectorizer + vector.MultimodalEmbedder(Vectorize/EmbedImage + Dense 变体)
  - 词级 BPE tokenizer:merges 合并后词末片段带 </w> 查 vocab,与官方 encode 逐 id 对齐
  - EmbedImage:解码→resize 224→NCHW→normalize→vision session
  - Fingerprint(text+vision 文件 sha256)供模型切换检测
  - stub 版(无 onnxruntime 标签)保持默认构建行为不变
- vector/store.go:新增 MultimodalEmbedder 接口
- media.Store:新增 StaleVecDigests(currentModel)——查 vec_model 不匹配/缺失的图片
- agent core:AgentConfig.ClipEmbedder + Agent.clipEmb 接线;
  describePendingMedia 描述成功后 EmbedImageDense→SetVec;
  新增 reembedStaleMedia 启动补算历史无向量图片
- config:core.memory.media.clip_model_dir(未配置退化为现有 fastText/TF-IDF 行为)
- cmd/homed:读 clip_model_dir 加载 CLIP,失败仅记日志不阻塞启动

测试:TestSmokeLoadAndEncode(文本语义 cat>dog 0.914>physics 0.740)、
TestCrossModalAlignment(red-image vs red-text 0.063>blue -0.009,与 Python 一致)、
TestTokEnd(与官方 encode 逐 id 对齐)、TestStaleVecDigests,含 -race 全绿
2026-09-09 10:23:31 +08:00
8c9d96e065 feat(media): media.Store 加向量存储与跨模态检索
media.Item 新增 Vec []float64 和 VecModel 字段,视觉嵌入向量以 JSON
TEXT 存库(为什么不存 BLOB:Go 的 json.Marshal 对 []float64 是自然的,
而 SQLite BLOB 是 []byte 多一层序列化;单条最多 23KB,TEXT 够用)。

initSchema 加 ALTER TABLE 迁移 vec/vec_model 两列(幂等)。scanItem
扩展读回。新增 SetVec 和 QueryMedia 方法。

QueryMedia 对所有已嵌入媒体做余弦相似度检索,维度不一致的项自动跳过
——这是跨模态检索的核心:查询可以是图片也可以是文本,被查的媒体库
里每个 item 也有视觉向量,两者在同一空间比对,谁的相似度更高就召回谁。

配套 5 个单测覆盖:基本相似度排序、无向量项被跳过、维度不匹配过滤、
空查询安全、向量持久化正确性。
2026-09-07 22:31:39 +08:00
e9856c8f65 feat(media): media.Item 加视觉嵌入字段,Store 加 QueryMedia 跨模态检索
路线 3(完整跨模态检索)的媒体层基础:

- Item 新增 Vec []float64(视觉嵌入向量,JSON 序列化存库)和 VecModel
  (模型标识,用于模型切换后触发重算)
- initSchema 加 ALTER TABLE 迁移 vec/vec_model 两列(幂等,列已存在可忽略)
- scanItem 扩展读回 vec/vec_model
- SetVec(digest, vec, model) 写入向量
- QueryMedia(queryVec, model, topK):对所有已嵌入媒体做余弦相似度检索,
  维度不一致的项自动跳过(防不同模型空间的向量被混算)

混合检索的设计意图:查询可以是图片也可以是文本(经向量化后调用 QueryMedia),
被查的媒体库里每个 item 也有视觉向量。两者在同一空间比对,谁相似度更高召回谁。
不再区分「图片查询」还是「文本查询」——向量空间的相似度自动决定。
2026-09-07 17:11:10 +08:00
0af38a29c6 feat(vector): Vectorizer 接口加 EmbedImage,支持多模态嵌入扩展
Vectorizer 新增可选的 EmbedImage(img []byte, mime string) (Vector, error):
支持视觉嵌入的实现者(如 CLIP/MobileCLIP)覆写此方法;不支持的
(StaticEmbedder、TFIDFVectorizer)返回 ErrNotSupported 调用方按文本降级。

这是路线 3(完整跨模态检索)的接口基础:后续在 media.Store 里,
Describe 时同时算视觉向量并存库,QueryMedia 做跨模态混合检索。
2026-09-07 17:00:29 +08:00
b889934bba docs: 同步仓库文档到 v1.1.1,补媒体记忆与接口扩展规则
五处文档此前停在 v1.0.0,而 v1.1.0/v1.1.1 都已发布并在现网运行。
本轮补齐三层记忆的媒体架构、模型工具的媒体参数、通信面 method 数,
以及冻结解除后的替代约束。

## 中英双语 OVERVIEW.md

三层记忆段只写了 Context/Document/Graph 三层,而 v1.1.0 起图片/音频是
三层里的一类节点。补上媒体记忆的架构概要:CAS + 引用计数 GC + 标记格式
(描述文本才是持久语义记忆,blob 是可淘汰的缓存)+ 插件边界贯通。

## 中英双语 ARCHITECTURE.md

- 「记忆工具」表补 `memory_commit`/`doc_commit` 的 `media_digests` 与
  `sentence_text`(后者从未暴露给模型,而它是媒体绑定链的必经环节)
- 「三层记忆」段新增媒体记忆子节:CAS 设计表(寻址/完整性/写入原子性/引用/GC)、
  标记格式、可选性(`enabled=false` 时整条链路静默退化)
- 「三个通信面」从 51 改为 55 个 method,新增四个 media method 的说明
- 「SDK 四通道」代码示例补媒体注入三方法 + 与 SetToolBlocks 的区别

## plugin-interface-matrix.md

- 状态从「完成 v2」改「完成 v3」,v3 记录 v1.1.1 的接口扩展
- §二 A3 DocMemoryAPI 补 InsertWithMedia
- §二 A4 补三个媒体注入方法 + 为何不能搭 SetToolBlocks 的车
- §二 A5 补 MediaAttachment 类型 + Triple/Doc/TextEvent 的扩展字段
- §六「新获得的能力」补 SetToolBlocks 已落地、媒体入记忆、插件主动发起
  带媒体的对话
- §七 冻结检查点补第 5 条(冻结已解除,取代它的是 §九)
- 新增 §九「v1.1.x 的接口扩展规则」:冻结解除后的三条硬约束
  (只增不减签名不改 / 新增方法方向 / 模板接线六处失败链)+ 验证方式
  (存量插件 17/17、旧产物 4/4 建链、模板断言、压测 -race)

## 为何分立两笔 commit

前一笔(SDK 仓 README + 内核 README)改的是给**插件开发者**看的文本,
本笔改的是给**架构师与维护者**看的技术文档。受众与改动层次不同,
放在同一个 commit 会让追溯时看不出"文档在哪一层跟上了代码"。
2026-09-06 15:07:14 +08:00
8cd884027f docs: 项目状态与 plan 同步到 v1.1.1,vendored SDK meta 注释对齐 SDK 仓
三处失同步,都会让读者拿到错的现状:

## README / README_EN

「项目状态」段的最新条目还停在 v1.0.0,而 1.1.0 与 1.1.1 都已发布。补上两条,
并把顶部特性摘要与下载段的 Windows 安装器版本号一起更新(那里写死了
`HomeAgent_v1.0.0_*_win64.exe`,照着它去 release 页面找是找不到文件的)。

## plan.md §12.1

标题还是「待用户决策后执行」,而四个决策点早已全部落定并执行完毕。改为已完成,
并记下实际演进已超出当初设想的地方(这些后来都写进了 docs/git-branching.md):
发布分支改为一个中版本一条、三级通道由 tag 区分、SDK 版本跟随核心中版本且
patch 位恒为 .0、beta 阶段不发 SDK、main 永不作发版分支。

同时修掉一处会误导追溯的陈述:§12.1 原文说 `v1.0.0` tag 指向 feature 分支中间点
`670efcd` 需要重打——那件事早已做完,现在指向 `release/v1.0.x` 上的 `9b92a04`。

## plan.md §12.5

「无 Windows 真机验证」这条仍然成立,但补一句区分:Windows NSIS 安装器从 v1.0.0
起就随每个正式版作为 release 资产发布了。**能打出包 ≠ 包里的共享内存/事件对象在
真机上能跑通**,混为一谈会让人以为这项已经关闭。

## vendored SDK meta

主仓跟踪 `third_party/homeagent-sdk/meta/meta.go`,而该文件在 SDK 仓 main 上刚补了
版本路牌的说明注释。两仓这份文件必须逐字一致——否则下次谁改了哪边说不清,
而它正是「两仓中版本对齐」这条纪律的载体。仅注释差异,无行为变化。
2026-09-06 10:56:42 +08:00
e4be9667ec chore(meta): main 的版本路牌推到 1.2.0
按 docs/git-branching.md §2.1,main 的 meta.Version 始终是**下一个未发布中版本**。
1.1.x 线正在发布中(release/v1.1.x 承载 v1.1.0 / v1.1.1),所以 main 指向 1.2.0。

它标记「main 正在积攒 1.2 的东西」,不表示 1.2.0 已经存在——1.2.0 没有任何 tag。
已发布的版本号一律看对应的 release/vX.Y.x 分支与 tag。

两仓同步:SDK 仓 main 也是 1.2.0(SDK 版本跟随核心中版本,见 §七.1),
而 release/v1.1.x 上 SDK 定版 1.1.0。

**此 commit 不 cherry-pick 到发布分支**(§五:版本号 bump 不跨分支搬)。
2026-09-06 09:54:55 +08:00
1b4643089c Merge branch 'feature/sdk-multimodal' — 多模态贯通插件边界
公开 SDK 新增媒体字段与媒体注入接口,内核实现对应 RPC 与桥接层,
text/image/audio 三条输入路径归一成一条 processInput 主干。
同步 docs/git-branching.md:SDK 版本语义、beta 不发 SDK、main 永不作发版分支。

存量插件零改动零重编(17 个 example 类型检查通过)。
2026-09-06 09:54:03 +08:00
8c6b593a54 docs(git): SDK 仓版本语义与发版联动,并明确 main 永不作发版分支
三条此前没写进规范、于是被我在实际操作中做错的规则:

## 一、SDK 版本号跟随核心的中版本,patch 位恒为 .0(新 §七.1)

整条核心 1.1.x 线共用 SDK 1.1.0;只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升。
核心的 patch 位专用于 bugfix 与漏洞修复,这类改动不碰公开 SDK 接口,SDK 版本号
没有理由跟着动。

**为什么不逐位对齐**:SDK 版本号是插件开发者的依赖声明。若核心每发一个 bugfix
就给 SDK 推一个新号,开发者要么被迫跟版、要么怀疑自己版本过时,而接口其实一个
字都没变。让 SDK 号只在**接口可能变化的中版本边界**上跳,开发者只需关心「我在
为哪个中版本写插件」。

因此「两仓版本对齐」在本规范里指**中版本对齐**(核心 1.1.x ↔ SDK 1.1.0),
不是三位全等。核心 1.1.1 配 SDK 1.1.0 就是对齐状态。§四 的措辞同步修正。

## 二、beta 阶段不发 SDK(新 §七.2)

核心的 alpha/beta tag 不伴随 SDK 仓发版:SDK 仓在这一阶段不打 tag、不建 release。

**为什么**:beta 是核心自己的测试阶段,此时 SDK 接口尚未固定。若此刻给 SDK 发版,
插件开发者会照着一个还会变的接口写代码——那是无效开发。接口没定就没有可依赖的
契约,发出去的版本号是一个假承诺。

这条约束的对象是 **SDK 仓的发版动作**,不是核心二进制里有没有 SDK 代码。主仓用
`replace => ./third_party/homeagent-sdk`,任何核心构建都必然含 vendored SDK 源码,
那是构建机制决定的,不在约束范围内。

核心打**正式** tag 时 SDK 才随之发版(§七.3):SDK 仓也有自己的 `release/vX.Y.x`,
定版为 `X.Y.0`,打 tag、建 release、传 5 平台 plugindev 产物。同一中版本内的后续
核心 patch 不重复发 SDK。

## 三、main 永远不是发版分支(补进 §四)

版本号 bump、打 tag、构建产物、上传附件,全部只在 `release/vX.Y.x` 上做。
**即使某个改动刚合进 main、即使 main 此刻可部署,也不从 main 打 tag。**
main 的版本号是「下一个未发布中版本」的路牌,不是任何一次发布的版本号。

这条本该是 §2.1「main 的 meta.Version 始终是下一个未发布版本」的直接推论,但
只写了状态、没写禁令,于是留下了「main 可部署 ⇒ 可以从 main 发版」的误读空间。
§三 的分支表补上 main 现值 `1.2.0` 与 1.1.x 线的 tag 历史,让路牌语义有实例可对。

## 四、公开接口改动是 feature,不是发布准备(补进 §六)

它必须走 `feature/xxx` → 合回 main → cherry-pick 到发布分支,不允许当成「发布
分支上的 bug 修复」直接提交进 release——发布分支冻结功能(§2.3),而接口是最
典型的功能面。§五 补上这条路径的命令示例,以及 SDK 仓同步发版的命令。
2026-09-06 09:52:52 +08:00
687e5655bc feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。

## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。

媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。

## 二、内核桥接层:原先在静默裁字段

`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:

- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
  而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
  → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
  于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
  (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。

规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。

标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。

## 三、跨进程链路:不接线就是全体外部插件编译失败

`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。

## 四、统一输入主干:把模态从「函数选择」降级为「字段」

`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。

媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。

最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**

`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。

## 五、模型可调用的三个工具

`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。

标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。

## 六、WebUI 上传走真实媒体链路

图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。

## 测试

- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)

压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。

测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。

存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
2026-09-06 09:51:31 +08:00
e8d12db871 fix(packaging): amd64 GUI 从未走过 electron 缓存,且空壳 node_modules 被当作已安装
干净 worktree 上打包时 GUI 被静默跳过。两个缺陷叠加,都属于「所有外层
检查都通过,只有嵌套的运行时缺失,而没有任何东西喊出来」。

## 一:electron 架构名与 Debian 架构名混用

electron 官方发布物命名用 x64/arm64,Debian 用 amd64/arm64。缓存查找
一直统一用 TAR_ARCH(amd64),于是 electron-v*-linux-x64.zip 永远命中
不到。arm64 两边恰好同名,所以上次修 arm64 GUI 架构污染(743b963)时
这个不一致没暴露。

推论:v1.0.3 的 amd64 GUI 实际是靠「回退到 host node_modules/electron/
dist」这条路组装的,不是走缓存——那条回退只在目标架构 == host 架构时
才允许,恰好成立所以没出错。干净 checkout 里没有完整 node_modules,
回退路径也没有,GUI 就消失了。

修法:单独映射 ELECTRON_ARCH(amd64→x64,arm64→arm64)。

## 二:判 node_modules 目录存在,而非判 electron 包存在

npm install 失败(离线/网络受限)会留下只有一两个条目的空壳
node_modules。原判据 [ ! -d node_modules ] 认为「已安装」,于是跳过
install → ever 读不到版本 → 缓存匹配退化到通配 → host dist 也没有 →
静默跳过 GUI。包名、目录名、变体名全部正确,只是没有 GUI。

修法:判据改为 electron/package.json 是否存在;目录在而包缺失时明确
说明「疑似上次 npm install 未完成」再重试;install 失败给出明确提示
而不是继续往下走。

顺带给 ever 加兜底:读不到已安装版本时从 package.json 的依赖声明取
数字部分(那里是 "^33.0.0" 这类范围,仅用于给缓存匹配一个提示)。

## 验证

干净 worktree(/tmp/rel104,release/v1.0.x)上重跑:
  node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)
  electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
  electron runtime: electron-v33.4.11-linux-x64.zip
  GUI built: build/homeagent-gui-linux-amd64 (263M, x86-64)

file -b 确认 electron 二进制为 x86-64,与目标架构一致(该硬校验由
743b963 引入,此处继续生效)。
2026-09-05 14:58:30 +08:00
aac88ce5ee Merge branch 'feature/memory-media' — 记忆系统支持二进制多媒体节点
四层实现 + 五个缺陷修复。方案 B+C(用户选定):内容寻址存储 + 描述文本
作为持久语义记忆。

## 四层

L1 CAS(internal/memory/media)
  元数据进 SQLite,blob 落盘 blobs/ab/cdef…,.tmp + rename 保证不会把
  半写文件当完整内容读。Get 每次重验 digest——CAS 的全部保证都建立在
  「文件名 == 内容摘要」上,喂一张损坏的图给模型会得到无法追溯的幻觉。
  AddRef 幂等且只在真插入时才涨计数(虚高则 GC 永远不敢清理),
  DropRef 用 MAX(0, ref_count-1)。GC 两段式 + minAge 保护刚落盘还没来得及
  AddRef 的项;被引用的内容即便超容量也永不删除——宁可超限也不能悬空。

L0/L2 接入(internal/agent/core/mediaref.go)
  只捕获 data URL:http(s) 会把一次对话变成一次网络请求(超时/鉴权/SSRF)。
  digest 先 stage 后 bind——媒体在 process() 期间被捕获,而承载它的
  ContextEvent 要等 process() 返回后才 Append,此刻还没有 owner_id。
  归档时先 AddRef 到新 owner 再 DropOwner 旧的:反序会让计数瞬时归零,
  并发 GC 会把仍被引用的内容当孤儿清掉。

后台循环(internal/agent/core/medialoop.go)
  GC 定时清理让容量上限真正生效(此前 max_mb 注册了却无调用方)。
  描述生成走后台而非对话路径:视觉模型一次调用生产实测 9.6s,放在对话里
  会给每张图的回复加十几秒,而描述的价值是几个月后还能检索到——这一轮
  模型本来就直接看着图。逐条而非批量:批量拿回来是一整段文字,无法可靠
  切分回各自的 digest。默认关闭,开启后每 30s 最多 4 条。

L3 图库反查(internal/agent/core/graphmedia.go)
  只做引用不建描述节点(方案 A):图库的实体与关系来自描述文本的 NLP
  提取,检索能力已具备;若节点名取自描述,描述重新生成后同一张图会留下
  多个语义模糊的节点。媒体实体名用「图片 <短digest>」——digest 不变则
  名字不变。CommitWithMedia 新增而非改 Commit 签名(后者有 31 个调用点)。

## 五个缺陷

1. rc.SetMediaStore 从未被调用 → L0→L2 引用转移在生产静默失效
2. 三元组全被实体名校验拒绝时仍释放引用并删文档 → 数据丢失
3. 媒体入 L3 依赖 NLP 提取器碰巧提出合规三元组 → 时好时坏
4. L3 媒体检索没有任何调用方 → 能存进去,agent 拿不出来
5. 两处数据竞争(remotedevice bufio.Writer / agentcli 共享读缓冲)

前四个都是「手工调 API 的单测无法发现」的类型:函数正确,但没接上,
或只在理想输入下正确。第 2 个做了反向验证(回退修复后测试确实 FAIL)。

## 验证

medialive 自动触发链实测(-tags medialive,源/模型/密钥由调用方经环境
变量显式指定):只注入一个 image 事件,七个阶段全由生产代码自己触发。
真实 claude-opus-5 通过——第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。
配阴性对照:不给记忆时不该「记得」,否则阳性用例可能只是模型猜配色。

551 篇生产归档文档干跑:媒体正则零误命中;28 篇文档在旧逻辑下会被删除
而信息并未进图库,新逻辑保留。

全仓 go build / go vet / go test / go test -race 全绿,SDK 冻结 diff = 0。
2026-09-05 13:56:57 +08:00
a7b54fef7f test(memory): 修正数据丢失回归用例的构造——它被媒体三元组修复本身弄失效了
两个修复之间产生了耦合:TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty
的前提是「三元组全被 validEntityName 拒绝」,而同批引入的
mediaTriplesFromText 会为正文里的 [image/png <digest>] 标记产出合规的
「图片 <digest>」三元组,于是 ec=4 rc=2、绑定成功、释放引用变成正确行为,
用例的前提消失。

(注意当时 GC 断言并未触发——内容没丢,只是"引用被释放"这条断言不再
适用于该构造。)

改法:正文不再含媒体标记,媒体引用直接 AddRef 挂上。这模拟的是更危险的
组合——文档持有媒体引用,但正文里的媒体标记已在清洗中丢失,于是有引用
要释放却没有句子能承载它。那正是这个守卫要防的情形。

反向验证重做后仍成立:回退守卫 → FAIL(引用被释放 + GC 删掉了本该保留的
内容);恢复修复 → ok。

教训:只跑针对性测试不足以发现修复之间的耦合。提交前我跑的是
internal/agent/core 与 internal/memory,当时通过是因为缺陷二尚未修完;
两个修复都落地后的第一次全仓回归才暴露它。
2026-09-05 12:08:33 +08:00
387b28ce09 fix(memory): 媒体归档三缺陷——数据丢失、L3 入库不可靠、L3 检索未接线
自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的
单测无法发现」的类型。附带该实测本身。

## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失)

archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。
但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求
2–50 字符),于是「无错但一条也没写进去」真实发生:

  [agent] doc→graph: doc_xxx → 0 entities, 0 relations
  [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库)
  被记忆引用的内容被 GC 删除了(清 1 条/318 字节)

图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述
彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」
是错的:ec=0 rc=0 时它什么也没挂。

修法(用户选定 B+A):
  B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2
     搬到 L3」,搬不过去就不该删源,下轮再试。
  A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为
     mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需
     释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空
     引用(内容还在,可由后续一致性检查清理)也不能丢内容。

反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该
保留的内容」;恢复修复后 PASS。

## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性)

媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM
的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体
记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。

但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。

新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记
直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0
relations 变为 ec=4 rc=2 且拿到句子 id。

三个设计点:
  - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、
    补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest
    不变则名字不变,长度也天然合规。
  - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解
    到 digest——绑定从概率事件变成确定行为。
  - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该
    因为还没描述就不存在于图谱。
  - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8,
    图库里会留下乱码实体名。同时清 Markdown 强调符。

这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖
「描述文本 → 提取三元组 → 图谱节点」这条链路。

## 缺陷三:L3 媒体检索没有任何调用方(接线缺失)

第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被
调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式
调了 doc_query,回答「没有找到那张图片的任何记录」。

接两个入口:
  - buildMemoryContext(自动注入,每次 LLM 调用都走)
  - memory_recall 工具结果末尾(显式查询)

关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」,
媒体挂在句子上,必须经 关系→句子→media_refs 反查。

一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。
Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省
token,细节留给 memory_recall)。改为用命中的实体名再查一次关系,
深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关
媒体并挤占 token。

## medialive 自动触发链实测

internal/agent/core/medialive_test.go,medialive build tag,默认
go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项
Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者
机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。

  MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \
  MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \
  go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v

只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 →
引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。
另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型
猜常见配色。上游不可用时 Skip 而非假 PASS。

真实 claude-opus-5 实测通过:第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。

## 测试

graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound
计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、
关系→句子映射、L3 检索接线(自动注入与显式查询两路)。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全绿,SDK 冻结 diff = 0。
2026-09-05 12:03:30 +08:00
775d9e8a2e fix(memory): core.New 漏接 rc.SetMediaStore,L0→L2 引用转移在生产从未生效
RelevanceContext.transferMediaRefs 依赖 c.mediaStore,而该字段只有
SetMediaStore() 能设置。搜遍全仓非测试代码,调用点为零——core.New() 里
没有,cmd/homed/main.go 里也没有。

上一层(f855893)把 AgentConfig.MediaStore 接到了 Agent.mediaStore,
漏了 rc 这一路。

后果是静默的:Prune 归档时 c.mediaStore 为 nil,transferMediaRefs 直接
return,而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。

## 为什么测试没抓到

mediaref_test.go 里我手工调了 rc.SetMediaStore(ms) 才测转移逻辑。
**测试验证了函数正确,没验证它被接上了。** 与 findPluginPID 那次同一
个教训:测了一件不会自然发生的事。

## 顺带全字段审计

写脚本比对 AgentConfig 的 32 个字段与 New() 函数体的引用情况,
确认无第二处漏接。
2026-09-05 12:02:34 +08:00
968b01f26e fix(plugins): 修复隔离全量测试暴露的两处数据竞争
-go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。
两处独立缺陷,互不相关。

## 缺陷一(remotedevice,8 处 race):连接写无串行化

WARNING: DATA RACE
  Read at 0x... by goroutine 28:
    bufio.(*Writer).Available() / writeFrameHeader / PushData
  Previous write at 0x... by goroutine 27:
    bufio.(*Writer).Flush() / writeFrame / handleWS

同一连接的 bufio.Writer 被两条并发路径写:
  - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong
  - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine

bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这
不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的
hello_ack 回写并发时被 -race 稳定抓到。

修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条
下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途
被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走
同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。

关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。

## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写

WARNING: DATA RACE
  Write at 0x... by goroutine 26:
    os.File.Read / (*linuxPty).Read / reader
  Previous read at 0x... by goroutine 25:
    runtime.slicecopy / readLoop

readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine
(t.session.Read(buf) 持续覆写),自己又在读到结果后
copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层
fd,专门在 reader 写下一段时读,跑 -race 稳定复现。

修法:readResult 携带 data field,reader 每次读完后把数据复制进自己
分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf
保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。

## 验证

  - 两个插件包 -race -count=2 全过
  - 全仓 go build / go vet / go test 通过
  - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL
  - SDK 冻结 diff = 0

其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd
/ TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。
2026-09-05 07:26:10 +08:00
6190a5a587 fix(plugins): 修复隔离全量测试暴露的两处数据竞争
-go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。
两处独立缺陷,互不相关。

## 缺陷一(remotedevice,8 处 race):连接写无串行化

WARNING: DATA RACE
  Read at 0x... by goroutine 28:
    bufio.(*Writer).Available() / writeFrameHeader / PushData
  Previous write at 0x... by goroutine 27:
    bufio.(*Writer).Flush() / writeFrame / handleWS

同一连接的 bufio.Writer 被两条并发路径写:
  - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong
  - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine

bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这
不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的
hello_ack 回写并发时被 -race 稳定抓到。

修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条
下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途
被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走
同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。

关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。

## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写

WARNING: DATA RACE
  Write at 0x... by goroutine 26:
    os.File.Read / (*linuxPty).Read / reader
  Previous read at 0x... by goroutine 25:
    runtime.slicecopy / readLoop

readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine
(t.session.Read(buf) 持续覆写),自己又在读到结果后
copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层
fd,专门在 reader 写下一段时读,跑 -race 稳定复现。

修法:readResult 携带 data field,reader 每次读完后把数据复制进自己
分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf
保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。

## 验证

  - 两个插件包 -race -count=2 全过
  - 全仓 go build / go vet / go test 通过
  - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL
  - SDK 冻结 diff = 0

其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd
/ TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。
2026-09-05 05:53:17 +08:00
28fc833a6f feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏
媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

## 为何不把媒体建成图库实体

图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经
mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content,
蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。

若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成
(换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
多个语义模糊的节点。代价换不来能力。

所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6]
一张紫蓝红三色带图」,要能从这条句子取回那份字节。

## CommitWithMedia:新增方法而非改签名

Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要
的返回值改全部签名不划算。新增 CommitWithMedia 返回
map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。

## digest 靠正则从文本反解

三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/
Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进
结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身
是我们自己按固定格式写进文本的,反解是最省的可靠做法。

配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行
撑爆且无助人眼辨认),media_refs 主键要完整 digest。

**前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。
完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。

## 顺带修掉 L2→L3 的引用泄漏

这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移,
漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销
媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest,
media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被
GC 回收。

新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从
描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。

顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到
graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC
正在跑就会把内容当孤儿清掉。

## 顺带修 Pending 的排除逻辑遗漏(承上一提交)

## 测试

graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip:
写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0)
确认被引用的内容不被清。

其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿
假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路
静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全
要么唯一命中要么明确报歧义。

TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时
entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行
affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关,
该字段只用于日志,故记录现状不改行为。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
2026-09-04 22:52:30 +08:00
74a24f93d7 feat(memory): 媒体 GC 与描述生成两条后台循环
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
2026-09-04 22:00:03 +08:00
2879e76883 fix(test): 崩溃隔离测试误杀同机生产插件,且断言无效
## 现象

生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律
(126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/
journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax=
infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run
命令,以及 Pdeathsig 绑创建线程的可能——全部排除。

## 真因:测试杀了生产的进程

用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline:

  /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel

findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含
editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足
这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test
的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。

之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。

## 更严重的是这个测试本身无效

旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了
对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。
**它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了
这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。

## 修法

findPluginPID 增加 root 硬约束:/proc/<pid>/exe 必须以测试自己的 plgDir
为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal
——这不是可选过滤器,是防误杀的前提。

  - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。
  - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀
    比较永远不命中,退化成静默 Skip(那样测试就白跑了)。

断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。
两步都成立才能证明隔离生效。

## 验证

修复后跑 TestRealPlugin_CrashDoesNotKillKernel:
  - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓
  - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓
  - 四个 TestRealPlugin_* 全部 PASS
2026-09-04 21:45:17 +08:00
98dcb2556c fix(test): 崩溃隔离测试误杀同机生产插件,且断言无效
## 现象

生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律
(126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/
journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax=
infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run
命令,以及 Pdeathsig 绑创建线程的可能——全部排除。

## 真因:测试杀了生产的进程

用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline:

  /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel

findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含
editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足
这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test
的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。

之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。

## 更严重的是这个测试本身无效

旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了
对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。
**它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了
这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。

## 修法

findPluginPID 增加 root 硬约束:/proc/<pid>/exe 必须以测试自己的 plgDir
为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal
——这不是可选过滤器,是防误杀的前提。

  - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。
  - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀
    比较永远不命中,退化成静默 Skip(那样测试就白跑了)。

断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。
两步都成立才能证明隔离生效。

## 验证

修复后跑 TestRealPlugin_CrashDoesNotKillKernel:
  - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓
  - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓
  - 四个 TestRealPlugin_* 全部 PASS
2026-09-04 21:44:05 +08:00
f855893d1c feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移
a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。

## 媒体进入对话有两条路,两条都只把文字留给记忆

  1. 用户直接发图 → processMediaInput → mediaToBlocks
     ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
     base64 随 message 数组发给模型后就丢了。
  2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
     ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。

于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。

现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。

## 改动

internal/agent/core/mediaref.go(新)
  - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
    拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
    请求(超时、鉴权、SSRF 全来了),不在本层解决。
  - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
    ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
    故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
  - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
    context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
    只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
  - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
    落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
    GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
    几个月后仍可被检索。

ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
  - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
    会让每条事件都多一个字段进 context.json。
  - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。

RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
  **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
  计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
  为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
  出现在方法签名上)。

media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
  owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
  AddRef 不报错,DropOwner 也永远匹配不到。

## 配置

core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。

关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。

## 测试(11 例)

入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。

## 尚未接入

L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
2026-09-04 20:53:32 +08:00
5c214cac23 fix(packaging): arm64 GUI 塞了 x86-64 electron——按目标架构取运行时并强制校验
## 现象

v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的
aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的
homeagent-full_1.0.1_arm64.deb:

  usr/bin/homed                    ELF 64-bit ARM aarch64   ✓
  usr/bin/waiter                   ELF 64-bit ARM aarch64   ✓
  usr/lib/homeagent-gui/electron   ELF 64-bit x86-64        ✗

在 arm64 机器上装完,双击 GUI 得到 Exec format error。

## 根因

build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里
永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是
命名,内容从未跟着目标架构变。

这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容
不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、
主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。

## 修法:三层取 + 一道强制校验

1. 优先从 electron 缓存取目标架构的 zip
   (~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)。
   版本号从已安装的 node_modules/electron/package.json 读,保证运行时
   与 app 依赖一致。
2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标
   架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。
3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构,
   不符就删掉 GUI 目录并跳过。

第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」——
宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构
标注,日常构建就能看见。

## 验证

下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB,
unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包,
三个 arm64 deb 实测:

  server   homed=aarch64  waiter=aarch64
  full     homed=aarch64  waiter=aarch64  electron=aarch64
  client                  waiter=aarch64  electron=aarch64
  amd64 对照                              electron=x86-64

arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。
2026-09-04 20:12:35 +08:00
9b7b2675da fix(packaging): arm64 GUI 塞了 x86-64 electron——按目标架构取运行时并强制校验
## 现象

v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的
aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的
homeagent-full_1.0.1_arm64.deb:

  usr/bin/homed                    ELF 64-bit ARM aarch64   ✓
  usr/bin/waiter                   ELF 64-bit ARM aarch64   ✓
  usr/lib/homeagent-gui/electron   ELF 64-bit x86-64        ✗

在 arm64 机器上装完,双击 GUI 得到 Exec format error。

## 根因

build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里
永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是
命名,内容从未跟着目标架构变。

这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容
不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、
主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。

## 修法:三层取 + 一道强制校验

1. 优先从 electron 缓存取目标架构的 zip
   (~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)。
   版本号从已安装的 node_modules/electron/package.json 读,保证运行时
   与 app 依赖一致。
2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标
   架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。
3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构,
   不符就删掉 GUI 目录并跳过。

第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」——
宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构
标注,日常构建就能看见。

## 验证

下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB,
unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包,
三个 arm64 deb 实测:

  server   homed=aarch64  waiter=aarch64
  full     homed=aarch64  waiter=aarch64  electron=aarch64
  client                  waiter=aarch64  electron=aarch64
  amd64 对照                              electron=x86-64

arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。
2026-09-04 20:12:35 +08:00
e2d04a31a7 docs(git): 发布分支改为一个中版本一条,补三级发布通道规范
## 一个中版本一条发布分支

原规范写 release/vX.Y.Z(含 patch 位),实践中 1.0.0 与 1.0.1 各建了一条
分支,导致同一条 1.0.x 发布线被切成互不相连的碎片——追溯时无法用一条
分支看完整条线的演进。改为 release/vX.Y.x,patch 位用 x 占位,
承载该中版本全部 patch 直到下一条中版本分支切出。

## 三级发布通道(alpha / beta / 正式)

通道由 tag 区分而非分支:三者共用同一条 release/vX.Y.x。

  alpha  vX.Y.Z-alpha.N  功能齐了未充分验证    仅内部自测
  beta   vX.Y.Z-beta.N   alpha 问题已修        小范围试用
  正式   vX.Y.Z          通过验证可上现网      所有用户

这是 semver 标准预发布语义(1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0),
版本比较逻辑天然认得,无需额外约定。允许跳级但要在发布说明写明理由;
alpha/beta 产物不上现网——预发布通道的存在就是为了不拿 24/7 服务冒险。

## 回流仍是 cherry-pick

明确不改 merge:merge 会把已发布的版本号带进 main,与「main 的
meta.Version 始终是下一个未发布版本」直接矛盾。

新补一条:修复落地当天要 pick 到所有活跃 feature 分支,否则它们合回
main 时可能带回旧代码(2026-09-04 的 stage 双重解锁修复即同时 pick 到
main 与 feature/memory-media)。

## 运维纪律沉淀

把今天走通的部署流程写进第四节,其中两条是踩过的坑:
  - 备份配置库用 sqlite3 .backup 而非 cp(WAL 模式下 cp 可能拿到
    不一致快照)
  - install -m 0755 替换而非 cp(原子 rename,不写坏运行中的进程镜像)
健康检查列出六项,含「一次真实对话」与「fatal error 计数为 0」。

## 当前分支对齐

第三节更新为 2026-09-04 的实际状态,并记录 1.0.x 的 tag 历史表
(含 v1.0.2 未使用的原因、v1.0.3 直接跳正式 tag 的理由)。
按 patch 号命名的历史发布分支标注为应当删除的遗留形态。
2026-09-04 20:11:19 +08:00
aa3ff5a12f fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:45:19 +08:00
e272c686d3 fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:43:05 +08:00
4ef3371701 test(memory): 媒体存储的压力、冒烟与长稳测试
a822674 的 CAS 层之上补齐三类验证。

## 压力测试(stress_test.go,9 例)

核心不是吞吐数字,而是并发下的不变量。为此写了 checkRefIntegrity:
用 SQL 对比每个 digest 的 ref_count 与 media_refs 实际行数。这条对不上
就意味着 GC 的判断依据是错的——计数偏低会误删有引用的内容,虚高会让
孤儿永远清不掉。所有并发用例收尾都验它。

  - 32 goroutine 并发 Put 同一内容 → digest 一致、磁盘只 1 份
  - 400 个不同内容并发入库 → 无丢条目、逐条回读无内容串位
  - 24 worker × 40 轮引用增删风暴(含故意重复 AddRef 验并发下的幂等)
  - GC 与读写并发 1.5s → 实测 11508 次 Put / 604 轮 GC,受保护内容零失败
  - Describe 与 Search/Pending 并发 → 无 database is locked
  - 容量上限持续加压 → 上限 256KB 收尾 98KB,有引用项全存活
  - 重度 churn 后重开 → 磁盘文件数 == 元数据条数,无双向孤儿
  - 4MB 单文件往返(see_video 10 帧 × 2MB 是现实上限附近)
  - data URL 往返 ×50(SetToolBlocks 给出的实际形态)

-race -count=3 干净。

## 冒烟测试(smoke_test.go,6 场景)

走真实数据路径:真 PNG(自建 IHDR/IDAT/IEND + zlib)、真 data URL、
真 sha256、真 GC、真重启,而不是随机字节。

  - 同一张截图连问 5 轮 → 磁盘 1 份、5 个 context 引用
  - see_video 6 帧内容各异 → 各存一份、共享一个 owner
  - 描述落库后按关键词检索命中(方案 C 最关键的一环:blob 可被淘汰,
    描述会长期留在记忆里)
  - L0→L2 归档时引用从 context owner 转到 document owner,期间内容可读
  - 别的工具留下的一次性图被 GC 清掉,被记忆引用的一个不少
  - 全生命周期跨重启:描述、引用、内容、磁盘一致性全部完好

第一次跑挂在「6 帧只搜到 5 条」,看着像存储丢帧,实际是夹具的
palette[(variant+y*3/h)%5] 只有 5 色,variant=0 与 5 产出逐字节相同的
PNG,被 CAS 正确去重。已把 variant 写进像素保证帧间真不同,并把这段
经过记进注释——误报本身证明了去重在工作,也证明冒烟确实有能力发现
「帧数对不上」这类问题。

冒烟原先是 internal/memory/media/smoke/ 下带 //go:build smoke 的独立
main,得记着加 -tags smoke 才跑得到,那种早晚被忘掉。已搬成普通测试,
随 go test ./... 一起跑,冒烟的意义才真正成立。

## 长稳测试(soak_test.go,-short 下跳过)

60 秒五路混合负载。实测:put=281885 get=1644084 gc=9142
describe=53875 search=23268 refOps=187926,零失败。收尾 20 个受保护项
内容字节一致、ref_count 全为 1;8MB 上限下实际占用 139KB / 48 条,
说明 28 万次写入产生的孤儿被持续清理,无无界增长。

描述者从 Pending() 取项再 Describe(),GC 随时可能在这两步之间清掉它。
这是正常竞态,故忽略 unknown digest 并注明原因;5 万多次调用没把它
升级成计数错位,印证了 Describe 对已删项返回错误而非静默建条目的选择。

全仓 go build / go vet / go test 通过,SDK 接口冻结 diff 为 0。
2026-09-04 11:29:18 +08:00
a82267484a feat(memory): 内容寻址媒体存储(CAS)——图记忆支持二进制多媒体节点的底座
此前四层记忆全是纯文本载体,没有任何一层能存二进制:
  L0 ContextEvent  — Input/Response/ToolResults[].Output 全 string
  L1 text.Event    — 同上
  L2 document.Doc  — Summary/Content/Tags 全 string
  L3 图库           — sentences.text TEXT UNIQUE,节点身份就是那串文本

于是 multimodal 插件注入的图只在本轮对话内可见(走 message 数组,不经
记忆),下一轮起只剩 ToolResultItem.Output 里那句
"[已将图片注入后续对话] /tmp/x.png"——一条路径字符串。那个文件被删或
被覆盖之后连线索都断了。

## 为何内容寻址而不是存路径

- 路径会失效。/tmp 下的探针图、下载缓存、别的进程的临时产物,记忆里
  留个路径等于留个悬空指针。
- 同一内容常被反复注入(连问几轮同一张截图、see_video 相邻帧高度相似),
  按 sha256 寻址天然去重。
- 内容即身份,与 L3 图库 sentences.text UNIQUE 思路一致:文本节点用文本
  本身做身份,媒体节点用内容摘要做身份。

## 结构

元数据(SQLite media.db)与内容(磁盘 blobs/ 两级前缀分桶)分离,不把
blob 塞进库:单张图动辄几 MB,塞进去让每次 VACUUM/备份都拖着几百 MB 走,
WAL 也会迅速膨胀。

  media(digest PK, kind, mime, size, width, height, origin_path, tool,
        description, described_by, ref_count, first_seen, last_seen)
  media_refs(digest, owner_kind, owner_id, created_at, PK 三列)

digest 既是主键也是文件名,所以没有 Path 字段——路径由 digest 推导,
不落库(落了就又是个会失效的引用)。origin_path 仅供人类溯源,注释里
明确标注不可用于读取。owner_kind 预留 context/document/graph_sentence。

## 几处刻意的决定

- Get 强制校验 digest:CAS 的全部保证建立在「文件名 == 内容摘要」上,
  位翻转或外部误改必须被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
- 先写 .tmp 再 rename:中途崩溃不留半个 blob 被当成完整内容读走。
- AddRef 幂等:只有真插进 media_refs 才递增,否则计数虚高会让 GC 永远
  不敢清。DropRef 用 MAX(0,...) 兜底防负数。
- Put 的空描述不冲掉已有描述(先到的可能来自更强的模型),但尺寸/工具名
  这类前一次缺失的信息会被补写。Describe 是显式操作,允许覆盖。
- GC 两段 + minAge 保护:刚 Put 还没 AddRef 的项 refcount 也是 0,minAge
  防「落地后还没挂上就被清掉」。有引用的项永不删除,即使超容量——宁可
  超限也不断引用。

## 测试

21 例,覆盖去重 / MIME 归类 / 损坏检测 / 无残留临时文件 / AddRef 幂等 /
计数不为负 / DropOwner / GC 保留有引用项 / minAge 保护 / 容量淘汰 /
描述覆盖与补写策略 / Search 按描述与 kind 过滤 / Pending / data URL
往返 / Stats / 跨重启持久化。

本 commit 只加存储层,尚未接入 L0/L2/L3 与描述生成。
2026-09-04 11:03:36 +08:00
6bf34e051b fix(release): upload_assets.py 按 go.mod 定位仓库根,不再数 dirname
脚本从 scripts/ 移到 deploy/scripts/ 后目录深度 1→2,而两层 dirname
是写死的,于是资产目录解析成 deploy/dist/release,上传直接
FileNotFoundError(v1.0.1 首次上传即因此失败)。

这与 v0.7.2 的 2c5f9ff 把 package/ 移到 deploy/packaging/ 打断
build.sh 的 PROJECT_ROOT 是同一个坑:目录搬家没更新相对路径。改成
向上找 go.mod,以后脚本放哪都不会错。

顺带把两个静默失败改为显式报错:目录不存在、目录下无可识别产物
(原先前者抛裸 FileNotFoundError,后者会打出 ALL OK 却一个都没传)。
2026-09-04 10:34:51 +08:00
4f31f942a5 fix(build): arm64 交叉编译补 CXX——「刻意不设 CXX」的注释判断是错的
build.sh 的 linux/arm64 分支此前刻意不设 CXX,注释理由是「设了会让
Go 用 aarch64 的 g++ 去链接,而它对 host 产生的 .o 报 file format
not recognized」。

那个判断是错的。那个报错的真因是 cmd/{homed,waiter}/*.syso(x86-64
COFF Windows 资源对象)被 Go 无条件链进了目标,与 CXX 无关。四组对照:

  syso 在   + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 在   + 有 CXX → 000000.o: file format not recognized
  syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 隐藏 + 有 CXX → 成功,ELF aarch64

两个条件缺一不可。之前诊断时只单独试了其中一个,得出错误结论后写进
注释固化了下来,于是 arm64 的 homed 一直编不出(v1.0.0 发布时 arm64
deb 里只有 waiter/initconfig)。

本脚本的 hide_syso_for_target 已处理 syso 那半,这里补上 CXX 那半。
实测 v1.0.1:build.sh linux/arm64 直接产出 ELF aarch64,arm64 的
full/server deb 里 homed 与 waiter 均为 aarch64。
2026-09-04 10:24:55 +08:00
453 changed files with 690273 additions and 22942 deletions

8
.gitignore vendored
View File

@ -31,6 +31,7 @@ cmd/gui/dist/
third_party/homeagent-sdk/bin/ third_party/homeagent-sdk/bin/
third_party/homeagent-sdk/tools/ third_party/homeagent-sdk/tools/
third_party/homeagent-sdk/package/ third_party/homeagent-sdk/package/
third_party/homeagent-sdk/scripts/
third_party/homeagent-sdk/.gitignore third_party/homeagent-sdk/.gitignore
third_party/homeagent-sdk/README* third_party/homeagent-sdk/README*
third_party/homeagent-sdk/example/ third_party/homeagent-sdk/example/
@ -48,7 +49,12 @@ codegraph.json
/adapters/ /adapters/
/knowledge/ /knowledge/
/memory/ /memory/
/scripts/ # 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
# 不是运行期产物deploy/systemd/embed-sidecar.service 直接引用
# scripts/embed_sidecar.py忽略它会让那份 unit 在别人的机器上指向不存在的文件。
# 只忽略其中的缓存。
/scripts/__pycache__/
__pycache__/
terminal_locked_log.txt terminal_locked_log.txt
dist/ dist/

46
.golangci.yml Normal file
View File

@ -0,0 +1,46 @@
# golangci-lint 配置 —— 「超大函数/超大文件」治理的防复发闸门。
#
# 背景main 上曾有 4 个 ≥300 行函数、12 个 ≥200 行函数(见审查报告)。
# 没有复杂度 linter 是它们能长期存活的直接原因。本配置先以 **warn-only**
# 起步:`issues.exit-code: 0`,只产出清单、不阻断构建。等历史债降到可接受
# 水位后,再把 exit-code 改成 1 收成硬门禁。
#
# 运行make lint-full需先 `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`
# 注意:这是 golangci-lint v1 的配置格式v2 的 `linters.default` 写法不同)。
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- funlen # 函数长度
- gocyclo # 圈复杂度
- lll # 行宽
- dupl # 重复代码
- govet # 与 make lint 对齐的基线
linters-settings:
funlen:
lines: 150
statements: 100
gocyclo:
min-complexity: 30
lll:
line-length: 140
dupl:
threshold: 200
issues:
# 起步阶段不阻断warn-only。收紧后改为 1。
exit-code: 0
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
# 测试与生成/夹具代码不受长度类规则约束。
- path: _test\.go
linters: [funlen, dupl, gocyclo]
- path: internal/plugin/proc/testdata
linters: [funlen, dupl]
- path: third_party/
linters: [funlen, dupl, gocyclo, lll]

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -1,4 +1,17 @@
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt .PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions
# HOMED_TAGS 默认带 onnxruntime发行版**默认启用**本地向量空间(与
# deploy/packaging/build.sh 保持一致)。
#
# 曾经这里是空 tags实测的后果2026-09-15 热部署):`make build` 产出的
# homed 只有 33MB而 onnxruntime 版是 84MB启动日志里
# 「multimodal space active: provider=chineseclip」整行消失少加载一个插件
# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制
# package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」
# 与「发行构建」不是同一个东西,部署时无从察觉。
# 需要极简构建时显式 HOMED_TAGS= 关掉。
HOMED_TAGS ?= onnxruntime
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
BINARY=homed BINARY=homed
CLI_BINARY=waiter CLI_BINARY=waiter
@ -17,13 +30,15 @@ all: build build-cli
build: build:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))" @echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \
|| echo "WARN: 本次构建不含 onnxruntime本地向量空间不可用HOMED_TAGS= 显式关掉时才符合预期)"
build-cli: build-cli:
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/ CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)" @echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
build-gui: build-gui:
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox @cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
@ -61,3 +76,22 @@ fmt:
lint: lint:
$(GO) vet ./... $(GO) vet ./...
# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version
# GUI/鸿蒙各有自版本字段手工改必漂——用脚本拉齐check 版给门禁用。
sync-client-versions:
@bash deploy/scripts/sync-client-versions.sh
check-client-versions:
@bash deploy/scripts/sync-client-versions.sh --check
# lint-full在 vet 之外跑 golangci-lint阈值见 .golangci.yml起步 warn-only
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
.PHONY: lint-full
lint-full:
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run; \
else \
echo "golangci-lint 未安装,跳过(阈值见 .golangci.yml"; \
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
fi

126
README.md
View File

@ -12,6 +12,8 @@
homed内核零 IO PluginSDK 插件所有 IO 能力 homed内核零 IO PluginSDK 插件所有 IO 能力
``` ```
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia``InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆digest 是回到字节的钥匙。
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。 **v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
## 设计要点 ## 设计要点
@ -24,6 +26,16 @@ homed内核零 IO ← PluginSDK → 插件(所有 IO 能力)
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交 - **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
- **Graph 层**SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组 - **Graph 层**SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
**输入调度:两类别 + 四级中断** — 输入不直接进 LLM先进调度器。
排队(待办工作)与中断(按"有多不能等"分 L1~L4两类高级可抢占低级并保存现场
中断栈同级不抢占。L4 只归内核与内核级插件(如 WebUI 终止按钮)。
见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「输入调度器与中断机制」。
**驻留式子 agent** — 内核可派驻轻量内核的子 agent自己的调度器与 temp 图记忆,
共享通道登记表),把长任务/积压交出去并行做。主 agent 忙久了,内核还会把排队输入
交给临时**分诊助手**:简单的直接处理,需要主 agent 的立刻回「忙碌中,请稍候」,
用户不再干等。见 [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)。
## 架构图 ## 架构图
### 一、消息处理时序 ### 一、消息处理时序
@ -32,6 +44,7 @@ homed内核零 IO ← PluginSDK → 插件(所有 IO 能力)
sequenceDiagram sequenceDiagram
participant U as 用户/插件 participant U as 用户/插件
participant IO as IOManager participant IO as IOManager
participant SCH as 输入调度器
participant EV as eventLoop participant EV as eventLoop
participant CTX as RelevanceContext participant CTX as RelevanceContext
participant LLM as LLM+工具循环 participant LLM as LLM+工具循环
@ -39,7 +52,14 @@ sequenceDiagram
participant MEM as 三层记忆 participant MEM as 三层记忆
U->>IO: InjectInput(type, payload) U->>IO: InjectInput(type, payload)
IO->>EV: inputCh IO->>SCH: inputCh
rect lavender
Note over SCH: 两类别 + 四级中断L1~L4
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
end
SCH->>EV: 选中一个任务开始跑
rect lavender rect lavender
Note over EV: processTextInput Note over EV: processTextInput
EV->>ST: StageOnInput 插件可改写/短路 EV->>ST: StageOnInput 插件可改写/短路
@ -53,7 +73,7 @@ sequenceDiagram
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能 EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
EV->>ST: StagePreAction 插件可预拦截 EV->>ST: StagePreAction 插件可预拦截
loop 工具循环 loop 工具循环
LLM->>LLM: drainInterrupts LLM->>LLM: 安全点:中断求值/让位
LLM->>LLM: LLM Chat LLM->>LLM: LLM Chat
LLM->>ST: StagePostAction 插件可修改/短路 LLM->>ST: StagePostAction 插件可修改/短路
alt 无tool call alt 无tool call
@ -108,7 +128,7 @@ flowchart TB
end end
subgraph D[② Document 文件记忆] subgraph D[② Document 文件记忆]
DS[DocStore JSON+TF-IDF] DS[DocStore JSON+TF-IDF]
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
Q2 -->|原始时间戳写入上下文| RC Q2 -->|原始时间戳写入上下文| RC
CD[FindColdDocs 72h] -->|docToTriples| G CD[FindColdDocs 72h] -->|docToTriples| G
@ -178,22 +198,78 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
cmd/homed/ 守护进程入口,组装所有子系统 cmd/homed/ 守护进程入口,组装所有子系统
cmd/waiter/ CLI 客户端Unix socket cmd/waiter/ CLI 客户端Unix socket
internal/ internal/
├── agent/core/ Agent 核心事件循环、LLM 工具循环、7 阶段管道 ├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
├── agent/api/ LLM Provider + 8 个 Lua 适配器 ├── agent/api/ LLM ProviderLua 适配层provider.go 调 vm
├── memory/ 三层记忆Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版) ├── memory/ 三层记忆Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF ├── knowledge/ 知识库(文件系统 + TF-IDF
├── plugin/ 插件注册表 + 子进程加载器stdio RPC + 共享内存段 + 事件环) ├── plugin/ 插件注册表 + 子进程加载器stdio RPC + 共享内存段 + 事件环)
├── plugins/ 内置 11 个插件webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr ├── plugins/ 内置 18 个插件webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等
├── sdk/ PluginSDKTool/Stage/Event 三通道) ├── sdk/ PluginSDKTool/Stage/Event 三通道)
├── config/ SQLite 配置中心 ├── config/ SQLite 配置中心
├── events/ 事件总线 ├── events/ 事件总线
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本 └── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例 外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
``` ```
## 项目状态 ## 项目状态
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s **v1.3.x 线**v1.3.1v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**
- **驻留式子 agent**:内核可派驻轻量内核的子 agent自己的调度器、自己的 temp 图记忆、
共享通道登记表)。父经 `resident_agents`list/create/send/inspect/compress/reclaim/destroy
派活与收活inputch 可划给子,输入**在进内核之前**就已路由到子。
- **输出通道可寻址到具体 agent**`AllowedOutputs` 授权集合(三处过滤点一致),
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
- **输入调度器**:排队/中断两类别 + 四级中断L1~L4+ 抢占/挂起/恢复/中断栈;
同级不抢占、有饥饿防护与抢占冷却L4 只归内核与内核级插件WebUI 终止按钮)。
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
- 修掉一批真实缺陷:销毁驻留子时入站 inputch`child/<id>`)注册残留、
子的轮次永远显示 0`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
设备心跳 pong 忘了 Flush每 60 秒掉线、Lua 插件桥与 SDK 1.3.0 对齐。
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
`pkg/embedding``Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
落在**同一空间**512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB
`qwen3vl` 保留2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
文本检索仍由既有词向量 / TF-IDF 兜底CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**
这是已知并写进文档的代价。
- **媒体是图数据库的一等节点与边****彻底删除**「用文本描述式索引图片」这套将就机制,
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
是块的**迁移**,不是复制、也不靠引用保活。
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
RPC 只传偏移描述符;**RPC 协议升到 2**fd3 布局改变,**不支持滚动升级**——
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`**默认仍记入记忆、默认不裁剪**
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**
- **发行包默认启用** ONNX 向量空间并把模型754MB与 ONNX Runtime24MB
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2jieba 词库内嵌进二进制。
- 修掉三个**安装链静默失败**`initconfig``CGO_ENABLED=0` 是空操作(打印凭据却一个字节
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件、deb 的 `postinst`
查错 unit 路径导致 `enable` 从未执行。
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
>
> 另有**两项性能断言的量纲需要更正**2026-09-20 实测):
> 「崩溃到恢复 <1s」不成立 —— 崩溃后是**线性退避重启**,即 1s / 2s / 3s`procRestartBackoff=1s × 第 n 次`
> 首次重启就要等 1s。且 5 分钟窗口内第 **4** 次崩溃即停止自动重启待人工介入(`procMaxRestarts=3`,判定为 `n > 3`)。
> 该断言写下时v1.0.0)退避值已是 1s故从未成立。
> 「RPC 往返 p50 24.1µs」与当前实测同量级但不吻合本机 `BenchmarkToolInvoke` 实测
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
> 保留原文不修改,以免伪造历史。
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用媒体永久算「被引用」GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string、**`memory_commit``sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储CAS + SQLite 元数据 + 磁盘 blob`Get` always 重校 digest贯通 L0上下文事件/L2文档/L3图谱句子三层引用计数式 GC有引用者绝不删。视觉模型生成的描述文本是持久语义记忆blob 只是可被容量 GC 淘汰的缓存。
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。** **v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。**
@ -218,7 +294,7 @@ internal/
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent | | **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
- Linux`.deb`amd64/arm64)、`.rpm`x86_64)、`.tar.gz` - Linux`.deb`amd64/arm64)、`.rpm`x86_64)、`.tar.gz`
- Windows`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`NSIS 安装向导 - Windows`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`NSIS 安装向导 AGPL 许可页)。 v1.2.0 起因 `homed` 不再支持 Windows 原生依赖 fd 继承与共享内存段内偏移解引用安装器改为引导到 **WSL2**并把 Linux 包送进发行版里按 Linux 方式安装
- 免安装`homeagent-bin-<os>_<arch>.tar.gz` homed/waiter/initconfig - 免安装`homeagent-bin-<os>_<arch>.tar.gz` homed/waiter/initconfig
- 校验`SHA256SUMS` - 校验`SHA256SUMS`
@ -232,4 +308,32 @@ make test # go test ./...
make install # 安装到系统 make install # 安装到系统
``` ```
依赖Go 1.25+, CGo (go-sqlite3), Linux/Windows 依赖Go 1.25+, CGo (go-sqlite3), Linux
> `homed` 需 Linux依赖 fd 继承与共享内存段的段内偏移解引用,见
> `cmd/homed/platform_windows.go`Windows 上只构建 `waiter.exe`
> `homed` 跑在 WSL2 里见「下载」。macOS 可构建 `waiter`/`initconfig`
> `homed` 需在原生 macOS 构建。
## 许可
本项目以 **GNU Affero 通用公共许可证第 3 版AGPL-3.0-only** 发布全文见 [LICENSE](LICENSE)
它是 GPL 家族里**传染性最强**的一档不仅分发时须提供完整对应源码
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
任何人把改过的 HomeAgent 对外提供网络服务都必须让该服务的使用者拿到改动后的源码
插件与本项目通过公开 SDK **静态链接**SDK 源码会进入插件二进制因此插件是本项目的
衍生作品需以相同许可发布子进程隔离不改变这一点因为被链接的是 SDK 代码本身
### 随包分发的第三方组件
| 组件 | 许可 | 位置 |
|---|---|---|
| Chinese-CLIP ViT-B/16ONNX 产物 | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
| ONNX Runtime`libonnxruntime.so` | MIT | `/usr/lib/homeagent/onnxruntime/` |
| jieba 词库内嵌进二进制 | MIT | 源码 `internal/memory/jiebadict/` |
| Go 依赖go-sqlite3gojiebabubbletea | MIT / BSD-3 / Apache-2.0 | 均为宽松许可 AGPL-3.0 兼容 |
这些组件**保持各自原有许可**不在本项目的 AGPL 授权范围内发行包把它们的许可全文放在
`/usr/share/doc/homeagent/licenses/`并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`

View File

@ -12,6 +12,11 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
homed (kernel, zero IO) PluginSDK plugins (all IO capabilities) homed (kernel, zero IO) PluginSDK plugins (all IO capabilities)
``` ```
**Since v1.1.1 media reaches the plugin boundary**: plugins and the model can both read and
write images/audio in memory (`InsertWithMedia`, `InjectInputMedia`). Media lives in plain-text
memory as a `[<mime> <short digest>] <description>` marker — the description is the searchable
semantic memory, the digest is the key back to the bytes.
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over **Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
@ -27,6 +32,19 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions - **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations - **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
**Input Scheduling: 2 classes + 4 interrupt levels** — Input does not go straight to the LLM;
it first enters the scheduler. Two classes (queued = pending work, interrupt = ranked L1L4 by
"how urgent") with preemption and frame saving (interrupt stack); same level never preempts
same level. L4 belongs only to the kernel and kernel-level plugins (e.g. the WebUI stop button).
See "Input Scheduler & Interrupt Mechanism" in [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md).
**Resident sub-agents** — The kernel can station lightweight-kernel child agents (their own
scheduler and temp graph memory, sharing the channel registry) to run long or backlogged work
in parallel. When the main agent stays busy, the kernel hands queued input to a temporary
**triage assistant**: simple items are handled directly, items needing the main agent get an
immediate "busy, please wait" — users no longer wait in silence.
See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md).
## Architecture Diagrams ## Architecture Diagrams
### 1. Message Processing Sequence ### 1. Message Processing Sequence
@ -35,6 +53,7 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
sequenceDiagram sequenceDiagram
participant U as User/Plugin participant U as User/Plugin
participant IO as IOManager participant IO as IOManager
participant SCH as Input Scheduler
participant EV as eventLoop participant EV as eventLoop
participant CTX as RelevanceContext participant CTX as RelevanceContext
participant LLM as LLM+Tool Loop participant LLM as LLM+Tool Loop
@ -42,7 +61,14 @@ sequenceDiagram
participant MEM as Three-Layer Memory participant MEM as Three-Layer Memory
U->>IO: InjectInput(type, payload) U->>IO: InjectInput(type, payload)
IO->>EV: inputCh IO->>SCH: inputCh
rect lavender
Note over SCH: 2 task classes + 4 interrupt levels (L1-L4)
SCH->>SCH: same level never preempts -> ready/interrupt queue
SCH->>SCH: higher level -> preempt (frame pushed to interrupt stack)
SCH->>SCH: offload (main agent busy too long -> temporary triage assistant)
end
SCH->>EV: pick one task and run it
rect lavender rect lavender
Note over EV: processTextInput Note over EV: processTextInput
EV->>ST: StageOnInput Plugin can rewrite/short-circuit EV->>ST: StageOnInput Plugin can rewrite/short-circuit
@ -56,7 +82,7 @@ sequenceDiagram
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
EV->>ST: StagePreAction Plugin can pre-intercept EV->>ST: StagePreAction Plugin can pre-intercept
loop Tool loop loop Tool loop
LLM->>LLM: drainInterrupts LLM->>LLM: safe point: interrupt eval / yield
LLM->>LLM: LLM Chat LLM->>LLM: LLM Chat
LLM->>ST: StagePostAction Plugin can modify/short-circuit LLM->>ST: StagePostAction Plugin can modify/short-circuit
alt No tool call alt No tool call
@ -164,22 +190,114 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
cmd/homed/ Daemon entry, assembles all subsystems cmd/homed/ Daemon entry, assembles all subsystems
cmd/waiter/ CLI client (Unix socket) cmd/waiter/ CLI client (Unix socket)
internal/ internal/
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline ├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
├── agent/api/ LLM Provider + 8 Lua adapters ├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template) ├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
├── knowledge/ Knowledge base (filesystem + TF-IDF) ├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring) ├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr) ├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
├── sdk/ PluginSDK (Tool/Stage/Event three channels) ├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center ├── config/ SQLite config center
├── events/ Event bus ├── events/ Event bus
└── internal/lua/adapters/ 8 LLM protocol adapter scripts └── internal/lua/adapters/ 10 LLM protocol adapter scripts
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/` External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
``` ```
## Project Status ## Project Status
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s. **v1.3.x line** (v1.3.1v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
(consistent across all three filter points) lets parent/child deliver to each other;
device capabilities became output channels too (one `device/<id>` per device).
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1L4)
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
plugins (e.g. the WebUI stop button).
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
to its own temp instance).
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
directly, items needing the main agent get an immediate "busy, please wait", so users no
longer wait 10+ minutes in silence.
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
not a copy, and not kept alive by references.
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade**
kernel and all plugins must be rebuilt and installed together.
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
favour of WSL2; the jieba dictionary is embedded in the binary.
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
that printed credentials without writing any, fresh installs were misdetected as "already
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
`postinst` looked for the unit in the wrong path so `enable` never ran.
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
plugins must match — see License).
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
>
> **Two performance claims also need correcting** (measured 2026-09-20):
> "crash-to-recovery under 1s" does not hold — restarts are **linearly backed off**, i.e.
> 1s / 2s / 3s (`procRestartBackoff=1s × nth crash`). Even the *first* restart waits 1s.
> And the **4th** crash within a 5-minute window stops automatic restarts pending human
> intervention (`procMaxRestarts=3`, tested as `n > 3`).
> The backoff was already 1s when this claim was written (v1.0.0), so it never held.
> "RPC round-trip p50 24.1µs" is the right order of magnitude but does not match current
> measurements: `BenchmarkToolInvoke` on this machine gives inline/small **30.4µs**,
> frame/small 51.5µs, inline/large 767µs, frame/large 398µs.
> The original text is left unedited rather than rewritten, so the history isn't falsified.
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
plugins and the model. The public SDK gains media fields and three media injection methods
(paired with [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),
shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge
layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a
plugin were discarded, `Doc` kept only three fields, and `Remove` never released references
(media stayed "referenced" forever, so GC could never reclaim it). `processTextInput` and
`processMediaInput` were unified into a single `processInput`, which finally gives the media
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
chat log** (the media path published a map while the subscriber asserted a string),
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
**v1.1.0** — Memory system supports **binary multimedia nodes**. Content-addressed media store
(CAS + SQLite metadata + on-disk blobs, `Get` always re-verifies the digest) wired through L0
(context events) / L2 (documents) / L3 (graph sentences), with reference-counted GC (referenced
items are never deleted). The description text produced by the vision model is the durable
semantic memory; the blob is only a cache that capacity GC may evict.
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.** **v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
@ -204,7 +322,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent | | **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz` - Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer) - Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig) - Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
- Verification: `SHA256SUMS` - Verification: `SHA256SUMS`
@ -218,4 +336,36 @@ make test # go test ./...
make install # Install to system make install # Install to system
``` ```
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows. Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
> `homed` requires Linux (it relies on fd inheritance and intra-segment offset
dereferencing of the shared memory region; see `cmd/homed/platform_windows.go`).
On Windows only `waiter.exe` is built and `homed` runs under WSL2 (see Downloads).
macOS can build `waiter`/`initconfig`; `homed` must be built on native macOS.
## License
This project is released under the **GNU Affero General Public License, version 3
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
source when you distribute the software, **you must also offer the source to users who interact
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
as a network service therefore has to make the modified source available to that service's users.
Plugins are **statically linked** against this project through the public SDK (the SDK source
ends up inside the plugin binary), so plugins are derivative works and must be released under
the same license. Process isolation does not change this — what is linked is the SDK code itself.
### Third-party components shipped with the packages
| Component | License | Location |
|---|---|---|
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
These components keep their own licenses and are not relicensed by this project. Full texts are
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
as `AGPL-3.0-only`.

View File

@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
RelevanceContext — In-memory events[] + JSON persistence RelevanceContext — In-memory events[] + JSON persistence
Append: Each input, CleanTemplateText → three-branch vector(textForVector) Append: Each input, CleanTemplateText → three-branch vector(textForVector)
agent→Response, user→Input, cold_storage→Input+Response agent→Response, user→Input, cold_storage→Input+Response
StaticEmbedder pretrained word embedding / TF-IDF fallback Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding TF-IDF (fallback)
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10 Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
├── Keep → timeline → chronologically sorted → system prompt ├── Keep → timeline → chronologically sorted → system prompt
└── Low score → Document layer archive (original timestamp) └── Low score → Document layer archive (original timestamp)
Save: 5s debounce write to disk Save: 5s debounce write to disk
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
↓ Prune archive ↑ LLM active recall ↓ Prune archive ↑ LLM active recall
② Document (File Memory) ② Document (File Memory)
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex) DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs) Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
Read: Read:
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only) ├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
→ triples → GraphDB.Commit → triples → GraphDB.Commit
``` ```
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback ### Vectorization: Unified Multimodal Space (primary) → Word Embedding TF-IDF (fallback)
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`): Vectorization degrades through three layers by availability; **each missing layer reports an explicit
error and never pretends to succeed**:
**Primary Strategy — Pretrained Word Embedding (aligned 300d)** **① Unified multimodal space (primary path, since v1.2.0)**
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
**Model sources** (aligned 300d)
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English - Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model) - Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English - Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
@ -189,6 +200,43 @@ All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedd
| `doc_query` | Search from Document | | `doc_query` | Search from Document |
| `doc_commit` | Write to Document | | `doc_commit` | Write to Document |
Since v1.1.1 `memory_commit` and `doc_commit` accept `media_digests`, and the kernel appends the
`[<mime> <short digest>] <description>` marker into the sentence/body — **the kernel builds the
marker, the model only supplies the digest**. Requiring the caller to know the format would mean a
single typo silently breaks reference binding with no error anywhere in the chain. `memory_commit`
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
nowhere to attach them.
### Media Memory (since v1.2.0: first-class memory blocks)
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
structural edges (e.g. `sentence --contains--> block`) express ownership.
| Concern | Approach | Why |
|---|---|---|
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
**Description-based indexing is gone**: the old implementation embedded a
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
unified space, and no media marker is written into the body.
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
another model's vector**.
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
### Other Memory Layers ### Other Memory Layers
- **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types - **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types
@ -259,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Method | Registration Mechanism | Compilation | Usage | | Method | Registration Mechanism | Compilation | Usage |
|--------|----------------------|-------------|-------| |--------|----------------------|-------------|-------|
| Built-in | `init()``RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. | | Built-in | `init()``RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. | | External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. | | Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter | | SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
@ -278,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
| Dimension | Built-in Plugin | External Plugin | | Dimension | Built-in Plugin | External Plugin |
|-----------|----------------|-----------------| |-----------|----------------|-----------------|
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function | | Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess | | Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API | | Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) | | Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` | | Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
@ -296,7 +344,7 @@ Common ground:
| Plane | Mechanism | Why this choice | | Plane | Mechanism | Why this choice |
|---|---|---| |---|---|---|
| Control | stdio JSON-RPC (NDJSON frames), 51 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders | | Control | stdio JSON-RPC (NDJSON frames), 55 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
| Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly | | Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly |
| Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter | | Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter |
@ -334,8 +382,23 @@ sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler) sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)
// v1.1.1 media APIs (all additive, no signature changes)
sdk.DocMemory().InsertWithMedia(doc, attachments) // attachments with Data land in CAS; Digest-only ones reference existing content
sdk.InjectInputMedia(source, channel, text, blocks) // media reaches the model in *this* turn
sdk.InjectInputMediaSync(...) // same, and waits for the reply
sdk.InjectInterruptMedia(...) // media-bearing interrupt, can preempt current processing
``` ```
How media injection differs from `SetToolBlocks`: the latter is only callable inside a tool handler
and its media reaches the model with the **next** tool message; these three let a plugin
**initiate a turn that carries media** — it goes out with this turn's message and is automatically
stored in CAS with a memory reference attached. `Triple` and `Doc` gained `MediaDigests` /
`Attachments` correspondingly.
`internal/sdk/` is the bridge implementation for this layer and is not subject to the public
interface freeze (see `docs/git-branching.md` §6).
### Plugin Interface ### Plugin Interface
```go ```go
@ -392,7 +455,74 @@ Extended fields:
- Relation extension: Confidence - Relation extension: Confidence
## Interrupt Mechanism ## Input Scheduler & Interrupt Mechanism
Inputs do not go straight to the LLM — they first enter the **input scheduler**
(`internal/agent/core/scheduler.go`). Full design:
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
### Two task classes
| Class | Level | Meaning |
|-------|-------|---------|
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
| `TaskInterrupt` | L1L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
### Four interrupt levels
| Level | Meaning | Typical source |
|-------|---------|----------------|
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
plugin never gets preemption rights by accident. L4 declared by an external plugin is
**clamped to L3** (`clampPluginLevel`).
### Preemption and suspension
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
this is why messages normally wait for the running task to finish.
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
and resumed later; the stack is never re-sorted by priority.
- **Starvation guard**: preemption count raises the effective level
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
### Stop (user presses stop / `/stop`)
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
② short-circuit the x messages **already queued at the moment of stop** during their
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
Inputs arriving **after** the stop are unaffected.
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
task, and counting only `sched.queue` yields 0.
### Resident sub-agents and timely feedback
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
temp graph memory, sharing the channel registry.
- Parent→child control plane: `resident_agents`
(list / create / send / inspect / compress / reclaim / destroy).
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
configurable), the kernel hands queued inputs to a temporary **triage assistant**
(`offload_*` config): simple ones are handled directly, ones needing the main agent
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
- The triage assistant gets **no inputch** (it receives no plugin user input) and
**all output channels** (results must reach the original channel).
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
discarded with a per-item log entry).
### Legacy three-path view (still present, now a layer beneath the scheduler)
``` ```
interceptLoop (goroutine) interceptLoop (goroutine)
@ -402,15 +532,28 @@ interceptLoop (goroutine)
└── (c) InjectInput() → Trigger new processing when idle └── (c) InjectInput() → Trigger new processing when idle
``` ```
Three delivery paths: Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
| Path | Effect | Timing | ## Context Budget
|------|--------|--------|
| cancelLLM | Cancel current HTTP request | On context.Canceled |
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
Code: `internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts` `internal/agent/core/tokenbudget.go``ComputeTokenBudget`:
```
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
├── memory recall budget = (targetUsage - fixed) / 3
└── context events budget = remaining 2/3
```
**Window ≠ working band**: a source's real window may reach 1M, but near-full windows
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
working band separately. If the model name (e.g. `AUTO`) yields no window,
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
declare `core.llm.sources.<name>.context_window` explicitly.
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
budget, actual injection was still a few hundred characters.
## Configuration System ## Configuration System

View File

@ -25,6 +25,19 @@ The significance lies in clear responsibility boundaries: the kernel focuses on
Three progressive layers — context, cold archive, long-term graph memory — form an information decay and consolidation pipeline from short-term to persistent storage. Three progressive layers — context, cold archive, long-term graph memory — form an information decay and consolidation pipeline from short-term to persistent storage.
**Media Memory (since v1.1.0)** — Images and audio are not attachments; they are a kind of node in all three layers:
- **Content-addressed store (CAS)**: addressed by digest, metadata in SQLite and blobs on disk, identical bytes
stored once. Every `Get` re-verifies the digest (silently returning corrupt data is worse than an error).
- **Reference-counted GC**: `owner_kind/owner_id/digest` is the primary key; context events, documents and graph
sentences each hold their own references. **Referenced items are never deleted** — only unowned content past
`minAge` is reclaimed.
- **The description text is the durable semantic memory**: what the vision model produced is written into
plain-text memory as a `[<mime> <short digest>] <description>` marker and participates in vector retrieval and
distillation; the blob is only a cache that capacity GC may evict. Months later "that purple-blue-red
three-band chart" is still findable — via the description, not the bytes.
- **Reaches the plugin boundary since v1.1.1**: plugins read and write media through `InsertWithMedia` /
`InjectInputMedia`; the model attaches media via the `media_digests` argument of `memory_commit` / `doc_commit`.
## What It Actually Does ## What It Actually Does
Code is in the project root, implemented in Go. Code is in the project root, implemented in Go.

View File

@ -29,75 +29,80 @@ type Plugin interface {
| Method | Use Case | Complexity | | Method | Use Case | Complexity |
|--------|----------|------------| |--------|----------|------------|
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain | | **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo | | **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` | | **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
--- ---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 1. Quick Start: Using the plugindev Toolchain ## 1. Quick Start: Using the hmapdev Toolchain
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types. `hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
### Installation ### Installation
```bash ```bash
cd homeagent-sdk/tools/plugindev cd homeagent-sdk/tools/hmapdev
go build -o plugindev go build -o hmapdev
# Add plugindev to PATH or use directly # Add hmapdev to PATH or use directly
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
``` ```
### SDK Version Management ### SDK Version Management
`plugindev sdk` manages local SDK versions: `hmapdev sdk` manages local SDK versions:
```bash ```bash
plugindev sdk list # list installed SDK versions hmapdev sdk list # list installed SDK versions
plugindev sdk current # show current SDK version hmapdev sdk current # show current SDK version
plugindev sdk latest # show latest available version hmapdev sdk latest # show latest available version
plugindev sdk install v0.8.0 # install a specific version hmapdev sdk install v1.2.0 # install a specific version
plugindev sdk use v0.8.0 # switch to a version hmapdev sdk use v1.2.0 # switch to a version
plugindev sdk path # show current SDK path hmapdev sdk path # show current SDK path
``` ```
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`. SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
### Source Debugging ### Source Debugging
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed: `hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
```bash ```bash
plugindev debug [dir] # dir defaults to the current directory hmapdev debug [dir] # dir defaults to the current directory
``` ```
### Creating a Go Plugin ### Creating a Go Plugin
```bash ```bash
plugindev init myplugin hmapdev init myplugin
cd myplugin cd myplugin
# Edit plugin code # Edit plugin code
vim plugin.go vim plugin.go
# Build and package (default is a multi-platform bundle, see below) # Build and package (default is a multi-platform bundle, see below)
plugindev build hmapdev build
# Output: dist/myplugin_bundle.hmap # Output: dist/myplugin_bundle.hmap
# Single-platform build: # Single-platform build:
plugindev build --no-bundle hmapdev build --no-bundle
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64) # Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
``` ```
### Creating a Lua Plugin ### Creating a Lua Plugin
```bash ```bash
plugindev init myluaplugin --lua hmapdev init myluaplugin --lua
cd myluaplugin cd myluaplugin
# Edit plugin code # Edit plugin code
vim main.lua vim main.lua
# Local test # Local test
lua main.lua lua main.lua
# Build and package # Build and package
plugindev build hmapdev build
# Output: dist/myluaplugin_lua.hmap # Output: dist/myluaplugin_lua.hmap
``` ```
@ -128,16 +133,16 @@ myluaplugin/
### Build & Package ### Build & Package
`plugindev build` automatically handles compilation and packaging: `hmapdev build` automatically handles compilation and packaging:
```bash ```bash
cd myplugin cd myplugin
plugindev build # default bundle mode (multi-platform) hmapdev build # default bundle mode (multi-platform)
plugindev build --no-bundle # single-target build (per plg.json targets) hmapdev build --no-bundle # single-target build (per plg.json targets)
plugindev build --target linux/amd64 # append a target on top of plg.json targets hmapdev build --target linux/amd64 # append a target on top of plg.json targets
plugindev build --outdir dist # output directory (default: dist) hmapdev build --outdir dist # output directory (default: dist)
plugindev build --sdk-path <path> # SDK path override (go.mod replace) hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable) hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
``` ```
Execution process: Execution process:
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel > - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
> skips legacy artifacts with an actionable error instead of crashing. > skips legacy artifacts with an actionable error instead of crashing.
> - **Business code needs no changes** — the public SDK interface is unchanged; just > - **Business code needs no changes** — the public SDK interface is unchanged; just
> rebuild with the new `plugindev`. > rebuild with the new `hmapdev` (formerly `plugindev`).
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving > - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
> `plugin.so` there is harmless); it only distinguishes Lua plugins. > `plugin.so` there is harmless); it only distinguishes Lua plugins.
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain. > - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
### Build Targets & Multi-platform Bundle ### Build Targets & Multi-platform Bundle
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation. **`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
```bash ```bash
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
plugindev build --bundle # explicitly enable bundle (same as above) hmapdev build --bundle # explicitly enable bundle (same as above)
plugindev build --no-bundle # disable bundle, build per plg.json targets hmapdev build --no-bundle # disable bundle, build per plg.json targets
``` ```
Notes: Notes:
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### Entry Point ### Entry Point
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly, `hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
which is the entry point when the kernel loads the plugin: which is the entry point when the kernel loads the plugin:
```go ```go
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
} }
``` ```
At build time, `plugindev build` auto-generates subprocess runtime code At build time, `hmapdev build` auto-generates subprocess runtime code
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` / (`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same `z_proc_shm_windows.go`). All three platforms share the same entry point and the same
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
@ -594,23 +599,26 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
### Lua SDK API ### Lua SDK API
The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors. The `sdk.*` API of Lua plugins is aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
> Historical note: the 1.11.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding).
**Registration** **Registration**
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `sdk.log(level, msg)` | Log output | | `sdk.log(level, msg)` | Log output |
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` | | `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) | | `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) |
| `sdk.register_api(name)` | Register API | | `sdk.register_api(name)` | Register API |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` | | `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above | | `sdk.register_input_channel(name, def)` | Register input channel; `def` as above |
| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` |
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash | | `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
**Stage hook context** **Stage hook context**
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`. Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`.
**Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability): **Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability):
@ -639,20 +647,37 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
| `sdk.inject_text(source, channel, text)` | Deliver text message | | `sdk.inject_text(source, channel, text)` | Deliver text message |
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery | | `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation | | `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Unavailable in Lua**: always returns `(nil, err)`. It waits for this turn's reply while a Lua callback holds the plugin lock, so it would self-deadlock. Use a Go plugin for synchronous waits, or the async injectors below |
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same (unavailable) |
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Unavailable in Lua** (same as `inject_input_sync`) |
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form.
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)** **Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
| Sub-table | Functions | | Sub-table | Functions |
|-----------|-----------| |-----------|-----------|
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` | | `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` | | `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` | | `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | | `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` | | `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` |
| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` | | `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` |
| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` |
| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` |
| `sdk.json.*` | `encode(val)`, `decode(str)` | | `sdk.json.*` | `encode(val)`, `decode(str)` |
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` | | `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content.
> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall.
--- ---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
@ -779,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|---------|------|----------| |---------|------|----------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory | | [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface | | [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring | | [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder | | [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation | | [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) | | [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
@ -792,6 +817,12 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions | | [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation | | [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback | | [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
### Built-in Plugins ### Built-in Plugins

View File

@ -90,8 +90,8 @@ eventLoop() → processTextInput()
RelevanceContext — 内存 events[] + JSON持久化 RelevanceContext — 内存 events[] + JSON持久化
Append: 每次输入, CleanText → 三分支向量(textForVector) Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response agent事件→Response, 用户事件→Input, cold_storage→Input+Response
StaticEmbedder 预训练词嵌入 / TF-IDF 回退 向量层级:统一多模态空间(主,带 fingerprint StaticEmbedder 词嵌入 TF-IDF回退
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条 Prune: DenseCosine仅同指纹才比较→ 退化 StaticEmbedder CosineSimilarity保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt ├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档 (原始时间戳) └── 低分 → Document 层归档 (原始时间戳)
Save: 5s debounce 写盘 Save: 5s debounce 写盘
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
↓ Prune 归档 ↑ LLM 主动召回 ↓ Prune 归档 ↑ LLM 主动召回
② Document (文件记忆) ② Document (文件记忆)
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex DocStore — JSON文件 + 稠密向量统一多模态空间dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs) 写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
读取: 读取:
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读) ├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
→ 三元组 → GraphDB.Commit → 三元组 → GraphDB.Commit
``` ```
### 向量化:预训练词嵌入 + TF-IDF 回退 ### 向量化:统一多模态空间(主)→ 词嵌入 TF-IDF回退
所有向量化统一使用 `StaticEmbedder``internal/memory/static_embedder.go` 向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**
**主策略 — 预训练词嵌入(词对齐 300 ** **① 统一多模态空间主路径v1.2.0 **
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`512 维、Apache-2.0、中文原生;
亦可选 `qwen3vl` 或外部 `http` provider。provider 经 `pkg/embedding` 公共 SPI 注册,
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
(跨坐标系的向量混进去会算出两边都不像的方向)。
**② 词嵌入(文本兜底)** — `StaticEmbedder``internal/memory/static_embedder.go`
**模型来源**(词对齐 300 维)
- 模型来源ConceptNet Numberbatch77 语对齐)/ fastText 中文 / fastText 英文 - 模型来源ConceptNet Numberbatch77 语对齐)/ fastText 中文 / fastText 英文
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型) - 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
- 路径名含 `numberbatch` → 自动下载 ConceptNet`cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文 - 路径名含 `numberbatch` → 自动下载 ConceptNet`cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
@ -189,6 +198,37 @@ eventLoop() → processTextInput()
| `doc_query` | 从 Document 搜索 | | `doc_query` | 从 Document 搜索 |
| `doc_commit` | 写入 Document | | `doc_commit` | 写入 Document |
`memory_commit``doc_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
### 媒体记忆v1.2.0 起:一等记忆块)
媒体不是外挂内容,而是**记忆的一等节点**`internal/memory/media/` 是内容寻址仓储CAS
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
| 关注点 | 做法 | 为何 |
|---|---|---|
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
正文里不再有 media marker。
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
`reembedStaleMedia()``vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`
**绝不拿别的模型的向量顶替**
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
不报错不 panic。
### 其他记忆层 ### 其他记忆层
- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型 - **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型
@ -257,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 | | 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------| |------|----------|------|------|
| 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 | | 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 | | 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 | | Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 | | SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
@ -276,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 维度 | 内置插件 | 外部插件 | | 维度 | 内置插件 | 外部插件 |
|------|----------|----------| |------|----------|----------|
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 | | 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 | | 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap`ZIP 归档),通过 WebUI 或 pluginmgr API 安装 | | 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap`ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms, capabilities 等) | | 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms, capabilities 等) |
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` | | 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
@ -294,10 +334,14 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 面 | 机制 | 为何这么选 | | 面 | 机制 | 为何这么选 |
|---|---|---| |---|---|---|
| 控制面 | stdio JSON-RPCNDJSON 帧51`core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 | | 控制面 | stdio JSON-RPCNDJSON 帧55`core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型lost update 原样复现 | | 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型lost update 原样复现 |
| 通知面 | 事件环 + 平台通知Linux eventfd / macOS pipe / Windows Event | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 | | 通知面 | 事件环 + 平台通知Linux eventfd / macOS pipe / Windows Event | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
v1.1.1 新增 4 个 method51 → 55`doc.insertWithMedia``io.injectMedia`
`io.injectMediaSync``io.injectInterruptMedia`。**媒体块走 JSON 而非共享段二进制通道**——
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要跟其余 51 个 method 分道。
**子进程生命周期管理** **子进程生命周期管理**
- 每子进程一根专职 `waitLoop``cmd.Wait()` 唯一调用点)——不依赖 stdout EOF - 每子进程一根专职 `waitLoop``cmd.Wait()` 唯一调用点)——不依赖 stdout EOF
因为插件 fork 的孙子进程browser 拉 chromium、editdoc 拉 python继承同一 stdout 因为插件 fork 的孙子进程browser 拉 chromium、editdoc 拉 python继承同一 stdout
@ -330,8 +374,20 @@ sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道详见 output_send__qq_help", handler) sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道详见 output_send__qq_help", handler)
// v1.1.1 媒体接口(全部新增,无签名变更)
sdk.DocMemory().InsertWithMedia(doc, attachments) // 带 Data 的落进 CAS只给 Digest 的引用已有内容
sdk.InjectInputMedia(source, channel, text, blocks) // 媒体在「本轮」就发给模型
sdk.InjectInputMediaSync(...) // 同上并同步等回复
sdk.InjectInterruptMedia(...) // 带媒体的中断,可抢占当前处理
``` ```
媒体注入与 `SetToolBlocks` 的区别:后者只能在工具处理函数内部调用,且媒体要等**下一条**
tool message 才到模型手上;前三个是插件**主动发起一轮带媒体的对话**,媒体随本轮消息发出,
并自动落进 CAS、挂上媒体记忆引用。`Triple``Doc` 相应新增 `MediaDigests``Attachments`
`internal/sdk/` 是这层的桥接实现,不受公开接口冻结约束(见 `docs/git-branching.md` §六)。
### Plugin 接口 ### Plugin 接口
```go ```go
@ -388,7 +444,64 @@ type Plugin interface {
- Relation 扩展Confidence - Relation 扩展Confidence
## 中断机制 ## 输入调度器与中断机制
输入不直接进 LLM —— 它们先进**输入调度器**`internal/agent/core/scheduler.go`)。
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
### 两类任务
| 类别 | 级别 | 语义 |
|------|------|------|
| `TaskQueued` 排队输入 | 无级别(恒 0 | 待办工作。任何中断(≥ L1都能抢它 |
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
### 四级中断
| 级别 | 含义 | 典型来源 |
|------|------|----------|
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
未声明级别时取 **L1**`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
外部插件声明 L4 会被**夹到 L3**`clampPluginLevel`)。
### 抢占与挂起
- **同级不能抢占同级**`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
- 被抢占的任务压入**中断栈**LIFO`suspendStack` 保存现场,稍后恢复;
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`
封顶 L4确保低级别流不会被困。
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`2s内不再被抢
避免高优先级流把同一个任务反复打断到永不完结。
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4最多挂起 4 帧)。
### 停止(用户按停止按钮 / `/stop`
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
当新输入再跑一遍。停止之后**新到**的输入不受影响。
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
### 驻留式子 agent 与及时反馈
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
- **驻留子**是轻量内核的独立 agent自己的调度器、自己的 temp 图记忆、共享的通道登记表。
- 父对子的控制面:`resident_agents`list / create / send / inspect / compress / reclaim / destroy
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m可配内核把排队输入交给
一个临时**分诊助手**`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
- 回收/销毁时它手头的**残余任务由父显式决定**`residual=keep`(转回父队列,默认)
`drop`(明确丢弃,逐条记日志)。
### 旧版三路径(仍存在,但已是调度器之下的一层)
``` ```
interceptLoop (goroutine) interceptLoop (goroutine)
@ -398,15 +511,26 @@ interceptLoop (goroutine)
└── (c) InjectInput() → 空闲时触发新处理 └── (c) InjectInput() → 空闲时触发新处理
``` ```
三种投递路径: 代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
| 路径 | 效果 | 时机 | ## 上下文预算
|------|------|------|
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
代码:`internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts` `internal/agent/core/tokenbudget.go``ComputeTokenBudget`
```
maxCtx = provider.MaxContextTokens() // 声明窗口per-source context_window 优先)
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
└── 上下文事件预算 = 其余 2/3
```
**窗口 ≠ 工作面**:源的真实窗口可能到 1M但接近满窗口时注意力涣散、
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
实测:预算 400K 时实际注入仍只有几百字符。
## 配置系统 ## 配置系统

View File

@ -25,6 +25,17 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。 三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。
**媒体记忆v1.1.0 起)** — 图片/音频不是附属物,而是三层里的一类节点:
- **内容寻址存储CAS**digest 寻址,元数据在 SQLite、blob 在磁盘,相同字节只存一份,
每次 `Get` 重校 digest磁盘损坏静默返回脏数据比报错更危险
- **引用计数 GC**`owner_kind/owner_id/digest` 三元组为主键,上下文事件/文档/图谱句子各自持引用;
**有引用者绝不删除**,仅回收无主且超过 `minAge` 的内容
- **描述文本才是持久语义记忆**:视觉模型生成的描述以
`[<mime> <短digest>] <描述>` 标记形式写进纯文本记忆,参与向量检索与蒸馏;
blob 只是可被容量 GC 淘汰的缓存。几个月后“那张紫蓝红三色带图”仍可检索,靠的是描述而不是字节
- **v1.1.1 起贯通插件边界**:插件可通过 `InsertWithMedia` / `InjectInputMedia` 读写媒体,
模型可用 `memory_commit` / `doc_commit``media_digests` 参数关联媒体
## 它实际做了什么 ## 它实际做了什么
代码位于项目仓库根目录Go 语言实现。 代码位于项目仓库根目录Go 语言实现。

View File

@ -30,75 +30,80 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 | | 方式 | 适用场景 | 复杂度 |
|------|---------|--------| |------|---------|--------|
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 | | **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 | | **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 | | **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
--- ---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 一、快速开始:使用 plugindev 工具链 ## 一、快速开始:使用 hmapdev 工具链
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型 `hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
> 改名说明1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`SDK 存储目录同时由
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
### 安装 ### 安装
```bash ```bash
cd homeagent-sdk/tools/plugindev cd homeagent-sdk/tools/hmapdev
go build -o plugindev go build -o hmapdev
# 将 plugindev 加入 PATH 或直接使用 # 将 hmapdev 加入 PATH 或直接使用
# 也可从 SDK 的 release 附件下载预编译二进制hmapdev_linux_amd64 等)
``` ```
### SDK 版本管理 ### SDK 版本管理
`plugindev sdk` 子命令管理本地 SDK 版本: `hmapdev sdk` 子命令管理本地 SDK 版本:
```bash ```bash
plugindev sdk list # 列出已安装的 SDK 版本 hmapdev sdk list # 列出已安装的 SDK 版本
plugindev sdk current # 显示当前使用的 SDK 版本 hmapdev sdk current # 显示当前使用的 SDK 版本
plugindev sdk latest # 显示最新可用版本 hmapdev sdk latest # 显示最新可用版本
plugindev sdk install v0.8.0 # 安装指定版本 hmapdev sdk install v1.2.0 # 安装指定版本
plugindev sdk use v0.8.0 # 切换使用版本 hmapdev sdk use v1.2.0 # 切换使用版本
plugindev sdk path # 显示当前 SDK 路径 hmapdev sdk path # 显示当前 SDK 路径
``` ```
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/``plugindev init` 自动读取当前 SDK 版本填充 `go.mod` SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/``hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`
### 源码调试 ### 源码调试
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境: `hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
```bash ```bash
plugindev debug [dir] # dir 默认当前目录 hmapdev debug [dir] # dir 默认当前目录
``` ```
### 创建 Go 插件 ### 创建 Go 插件
```bash ```bash
plugindev init myplugin hmapdev init myplugin
cd myplugin cd myplugin
# 编辑插件代码 # 编辑插件代码
vim plugin.go vim plugin.go
# 编译打包 # 编译打包
plugindev build # 默认多平台 bundle见下节 hmapdev build # 默认多平台 bundle见下节
# 输出: dist/myplugin_bundle.hmap # 输出: dist/myplugin_bundle.hmap
# 单平台构建: # 单平台构建:
plugindev build --no-bundle hmapdev build --no-bundle
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64) # 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
``` ```
### 创建 Lua 插件 ### 创建 Lua 插件
```bash ```bash
plugindev init myluaplugin --lua hmapdev init myluaplugin --lua
cd myluaplugin cd myluaplugin
# 编辑插件代码 # 编辑插件代码
vim main.lua vim main.lua
# 本地测试 # 本地测试
lua main.lua lua main.lua
# 编译打包 # 编译打包
plugindev build hmapdev build
# 输出: dist/myluaplugin_lua.hmap # 输出: dist/myluaplugin_lua.hmap
``` ```
@ -129,16 +134,16 @@ myluaplugin/
### 编译打包 ### 编译打包
`plugindev build` 会自动完成编译和打包: `hmapdev build` 会自动完成编译和打包:
```bash ```bash
cd myplugin cd myplugin
plugindev build # 默认 bundle 模式(多平台合集) hmapdev build # 默认 bundle 模式(多平台合集)
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标 hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
plugindev build --outdir dist # 指定输出目录(默认 dist hmapdev build --outdir dist # 指定输出目录(默认 dist
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次) hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
``` ```
执行过程: 执行过程:
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
| 文件 | 用途 | 关键字段 | | 文件 | 用途 | 关键字段 |
|------|------|---------| |------|------|---------|
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`| | `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 | | `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀** 每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
> >
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物 > - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。 > 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。 > - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨), > - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。 > 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。 > - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
### 构建目标与多平台打包bundle ### 构建目标与多平台打包bundle
**`plugindev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。 **`hmapdev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
```bash ```bash
plugindev build # 默认 bundle输出 dist/myplugin_bundle.hmap hmapdev build # 默认 bundle输出 dist/myplugin_bundle.hmap
plugindev build --bundle # 显式开启 bundle同上 hmapdev build --bundle # 显式开启 bundle同上
plugindev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建 hmapdev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
``` ```
注意: 注意:
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### 入口点 ### 入口点
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口: `hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
```go ```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
} }
``` ```
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。 编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
### PluginSDK 核心 API ### PluginSDK 核心 API
@ -587,23 +592,26 @@ lua main.lua
### Lua SDK API ### Lua SDK API
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。 Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐**SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
> 历史提醒1.11.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
**注册类** **注册类**
| 函数 | 说明 | | 函数 | 说明 |
|------|------| |------|------|
| `sdk.log(level, msg)` | 日志输出 | | `sdk.log(level, msg)` | 日志输出 |
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description``parameters``no_memory``cleaner` | | `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description``parameters``no_memory``context_policy``"none"`/`"prune"`)、`cleaner` |
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope``nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) | | `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope``nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
| `sdk.register_api(name)` | 注册 API | | `sdk.register_api(name)` | 注册 API |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory``cleaner` | | `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory``context_policy``cleaner` |
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 | | `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 | | `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
**阶段钩子上下文** **阶段钩子上下文**
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``final_text``no_memory``response`(已响应时)、`tool_calls``tool_results` `register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``reasoning_content``final_text``no_memory``context_msgs``token_usage``memory``extra``errors``response`(已响应时)、`tool_calls``tool_results`
**Stage 写回**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐): **Stage 写回**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
@ -632,20 +640,37 @@ end)
| `sdk.inject_text(source, channel, text)` | 投递文本消息 | | `sdk.inject_text(source, channel, text)` | 投递文本消息 |
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 | | `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 | | `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Lua 中不可用**:恒返回 `(nil, err)`。它要等本轮回复而 Lua 回调持有插件锁,必然自锁。需要同步等待请用 Go 插件,或用下面的异步注入 |
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上(不可用) |
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Lua 中不可用**(同 `inject_input_sync` |
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
`blocks` 每项形如:`{ type="text", text="..." }``{ type="image_url", image_url={ url="...", detail="high" } }``{ type="audio_url", audio_url={ url="..." } }``opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。
**数据类(与子进程外部插件对齐,均返回 `(result, err)`** **数据类(与子进程外部插件对齐,均返回 `(result, err)`**
| 子表 | 函数 | | 子表 | 函数 |
|------|------| |------|------|
| `sdk.memory.*` | `recall(query, depth)``commit({triples})``introspect()``merge(source, target)``purge(criteria, hard)` | | `sdk.memory.*` | `recall(query, depth)``commit({triples})`triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests``introspect()``merge(source, target)``purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)``insert({id,title,content})``remove(id)``stats()` | | `sdk.doc.*` | `query(text, top_k)``insert({id,title,content})``insert_with_media(doc, attachments)``remove(id)``stats()` |
| `sdk.knowledge.*` | `search(query, limit)``add(tag, content)``list()` | | `sdk.knowledge.*` | `search(query, limit)``add(tag, content)``list()` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | | `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
| `sdk.llm.*` | `list_sources()``set_source(name)``current_source()` | | `sdk.llm.*` | `list_sources()``set_source(name)``current_source()` |
| `sdk.social.*`(只读) | `get_person(name)``get_network(name, depth)``get_trait(name, trait)``get_relations(name)``list_persons()` | | `sdk.social.*`(只读) | `get_person(name)``get_network(name, depth)``get_trait(name, trait)``get_relations(name)``list_persons()` |
| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数handler 收到 `{type,source,timestamp,payload}` |
| `sdk.plugin_mgr.*` | `reload_one(name)``list_loaded()``is_disabled(name)` |
| `sdk.json.*` | `encode(val)``decode(str)` | | `sdk.json.*` | `encode(val)``decode(str)` |
| `sdk.http.*` | `get(url)``post(url, body, content_type)` | | `sdk.http.*` | `get(url)``post(url, body, content_type)` |
`attachments` 每项:`{ digest=, mime=, name=, data=<base64> }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。
> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。
--- ---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
@ -772,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|------|------|------| |------|------|------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 | | [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 | | [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 | | [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理PreAction 注入 + 定时打断双提醒 | | [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理PreAction 注入 + 定时打断双提醒 |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作4 种写入模式,沙箱隔离 | | [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作4 种写入模式,沙箱隔离 |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取SSRF、浏览器渲染合并自 web/webfetch | | [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取SSRF、浏览器渲染合并自 web/webfetch |
@ -785,6 +810,12 @@ pmgr.ReloadPlugins() // 重载所有插件
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 | | [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 | | [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 | | [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序failback 模式的核心插件 |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
### 内置插件 ### 内置插件

View File

@ -1,6 +1,6 @@
{ {
"name": "homeagent-gui", "name": "homeagent-gui",
"version": "1.0.0", "version": "1.4.0",
"author": "JianFeeeee <jianfeeeee@homeagent.local>", "author": "JianFeeeee <jianfeeeee@homeagent.local>",
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent", "homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard", "description": "HomeAgent Desktop GUI - Multi-connection management dashboard",

View File

@ -696,6 +696,10 @@ async function refreshDataOnly() {
try { try {
state.kernel = await api("/kernel"); state.kernel = await api("/kernel");
} catch (e) {} } catch (e) {}
try {
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
state.runtime = await api("/runtime");
} catch (e) {}
try { try {
var s = await api("/settings"); var s = await api("/settings");
state.settings = s.settings || {}; state.settings = s.settings || {};
@ -775,6 +779,10 @@ async function refreshAll() {
try { try {
state.kernel = await api("/kernel"); state.kernel = await api("/kernel");
} catch (e) {} } catch (e) {}
try {
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
state.runtime = await api("/runtime");
} catch (e) {}
try { try {
var s = await api("/settings"); var s = await api("/settings");
state.settings = s.settings || {}; state.settings = s.settings || {};
@ -1004,6 +1012,249 @@ function statCard(l, v) {
); );
} }
// ===== 运行态面板:阶段管道 + 中断队列 =====
//
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
// 数据来自 /api/v1/runtimeKernelStatus 的运行态子集)。
var RT_LEVELS = [
{ lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" },
{ lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" },
{ lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" },
{ lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" },
];
// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
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" },
];
// 图标一律内联 SVG24x24 / currentColor不用 emoji/符号字符充当图标。
var RT_ICO = {
in: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M21 12H8"/><path d="M13 6l-6 6 6 6"/></svg>',
act: '<svg class="rt-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"/></svg>',
tool: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M14.5 6.5a3.8 3.8 0 0 1 5 5L10 21l-5-5z"/><path d="M14.5 6.5 17.5 9.5"/></svg>',
out: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg>',
done: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
loop: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M21 3.5V9h-5.5"/></svg>',
};
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;
}
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整行只剩文字看上去就是「这块空着」。
function rtSlots(depth, slots, cls) {
var n = Math.max(5, Math.min(16, slots || 5));
var d = depth || 0;
var out = '<span class="rt-slots ' + (cls || "") + '">';
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
// 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
return out + "</span>";
}
// 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();
}
function renderRuntimePanel() {
var title = __("运行态", "Runtime");
var rt = state.runtime;
// 工具格的滑入动画只在「新到一条工具调用」那一次播放overview 是整块
// innerHTML 重建,节点每次都是新的;若无条件带动画类,任何重渲染都会闪一下。
var toolFlash = !!state.toolFlash;
state.toolFlash = false;
if (!rt) {
return (
'<div class="card"><h2>' + title + '</h2><p class="rt-empty">' +
__("运行态数据不可用", "runtime unavailable") + "</p></div>"
);
}
var sc = rt.scheduler || {};
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 residents = rt.residents || [];
var byLv = sc.interrupts_by_level || [];
var preLv = sc.preempts_by_level || [];
var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
// 至少 5 格0 时也有可见形状
var qSlots = Math.max(5, Math.min(16, maxQ));
var html = '<div class="card"><h2>' + title + "</h2>";
// 四个数字块(沿用本 app 的 statCard 风格)
html +=
'<div class="grid-4">' +
statCard(__("排队", "Ready"), ready, "") +
statCard(__("中断", "Pending"), pending, "") +
statCard(__("栈", "Stack"), stack + "/" + maxStack, "") +
statCard(__("子代理", "Subagents"), residents.length, "") +
"</div>";
// ---- 阶段管道:五个等大表框 ----
var g = rtPhaseGroup(state.pipelinePhase || "");
html +=
'<div class="rt-section-title">' + __("阶段管道", "Stage pipeline") +
(g < 0 ? " " + __("(空闲)", "(idle)") : "") + "</div>";
html += '<div class="rt-pipe-row' + (g < 0 ? " rt-pipe-idle" : "") + '">';
html += RT_PIPE_GROUPS.map(function (s, i) {
var items = (state.stageTrail || []).filter(function (t) {
return (t.g | 0) === i;
});
// 「工具」是循环格:一轮里可能调几十次工具/输出通道,全部追加会把这一格
// 撑成长条,反而看不出「现在在调什么」。只留**最新一条**,右侧给本轮累计
// 次数(与 WebUI 同一口径,见 internal/plugins/webui/dashboard.js
var cls = "rt-pipe-events";
var body;
if (!items.length) {
body = '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
} else if (s.loop) {
cls += " rt-pipe-scroll";
var total = 0;
for (var k = 0; k < items.length; k++) total += items[k].n || 1;
var latest = items[items.length - 1];
var lkind = latest.kind || "stage";
var lico = lkind === "output" ? RT_ICO.out : lkind === "tool" ? RT_ICO.tool : "";
body =
'<span class="rt-chip rt-chip-' + lkind + (toolFlash ? " rt-chip-enter" : "") +
'" title="' + escHtml(latest.label) + '">' + lico +
'<b class="rt-chip-t">' + escHtml(latest.short || latest.label) + "</b>" +
(latest.n > 1 ? '<i class="rt-chip-n">x' + latest.n + "</i>" : "") +
"</span>" +
'<i class="rt-scroll-count" title="' +
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
} else {
body = items
.map(function (t) {
var kind = t.kind || "stage";
var ico =
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
return (
'<span class="rt-chip rt-chip-' + kind + '" title="' +
escHtml(t.label) + '">' + ico +
'<b class="rt-chip-t">' + escHtml(t.short || t.label) + "</b>" +
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
"</span>"
);
})
.join("");
}
return (
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
"<b>" + __(s.zh, s.en) + "</b>" +
(s.loop
? '<em class="rt-loop" title="' +
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
'">' + RT_ICO.loop + "</em>"
: "") +
'</div><div class="' + cls + '">' + body + "</div></div>"
);
}).join("");
html += "</div>";
// ---- 中断队列五个等大表框L4/L3/L2/L1 + 排队)----
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
html += '<div class="rt-queues">';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
var reg = byLv[L.lv] || 0;
var pre = preLv[L.lv] || 0;
var desc = __(L.zh, L.en);
html +=
'<div class="rt-qcell ' + L.cls + (depth ? " rt-active" : "") +
'" title="' + escHtml(desc) + '">' +
'<div class="rt-qhead"><b>' + L.name + "</b><span>" + escHtml(desc) + "</span></div>" +
'<div class="rt-qnum">' + depth + "</div>" +
rtSlots(depth, qSlots, L.cls) +
'<div class="rt-qmeta">' + reg + " " + __("登记", "reg") + " · " +
pre + " " + __("抢占", "pre") + "</div></div>";
});
// 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级)
html +=
'<div class="rt-qcell rt-qcell-queued rt-lv-q' + (ready ? " rt-active" : "") +
'" title="' + __("排队(无级别,纯 FIFO", "queued (no priority, FIFO)") + '">' +
'<div class="rt-qhead"><b>' + __("排队", "queued") + "</b><span>FIFO</span></div>" +
'<div class="rt-qnum">' + ready + "</div>" +
rtSlots(ready, qSlots, "rt-lv-q") +
'<div class="rt-qmeta">' + __("无级别", "no priority") + "</div></div>";
html += "</div></div>";
return html;
}
// 开源许可卡:协议标识 + 协议全文 + 源码仓库。
// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」——
// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。
function renderLegalCard() {
var b = ((state.kernel || {}).build) || {};
var src = b.source_url || "";
var lic = b.license || "";
var licURL = b.license_url || "";
if (!lic && !src) return "";
function row(key, val) {
return (
'<div class="kv-row"><span class="key">' + escHtml(key) +
'</span><span class="val">' + val + "</span></div>"
);
}
function a(href, text) {
return (
'<a href="' + escHtml(href) +
'" target="_blank" rel="noopener noreferrer">' + escHtml(text) + "</a>"
);
}
var rows = "";
if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic));
if (src) rows += row(__("源码仓库", "Source"), a(src, src));
// 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。
var note =
lic && lic.toUpperCase().indexOf("AGPL") >= 0
? '<p class="rt-empty">' +
__(
"网络服务条款§13把修改后的版本作为网络服务对外提供时必须向使用者提供取得对应源码的途径。",
"Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.",
) +
"</p>"
: "";
return '<div class="card"><h2>' + __("开源许可", "License") + "</h2>" + rows + note + "</div>";
}
function renderOverview() { function renderOverview() {
var s = state.status || {}; var s = state.status || {};
var k = state.kernel; var k = state.kernel;
@ -1018,7 +1269,25 @@ function renderOverview() {
"uptime", "uptime",
) + ) +
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") + statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
statCard(__("版本", "Version"), s.version || "0.1.0", "version") + statCard(
__("版本", "Version"),
(function () {
// 构建身份取自 /kernel 的 build-ldflags 注入的真实版本/commit
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
// 拿不到数据时会向用户展示一个不存在的版本号。
var b = (k && k.build) || {};
var v = b.version || s.version || "";
if (!v) return "-";
var sha =
b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : "";
return (
"v" + escHtml(v) +
'<div class="stat-sub">' + escHtml(b.kernel_name || "HomeAgent") +
(sha ? " · " + escHtml(sha) : "") + "</div>"
);
})(),
"version",
) +
"</div>"; "</div>";
if (k) { if (k) {
html += html +=
@ -1082,6 +1351,7 @@ function renderOverview() {
"</span></div>" + "</span></div>" +
"</div></div>"; "</div></div>";
} }
html += renderRuntimePanel();
html += html +=
'<div class="card"><h2>' + '<div class="card"><h2>' +
__("运行时", "Runtime") + __("运行时", "Runtime") +
@ -1094,6 +1364,7 @@ function renderOverview() {
) + ) +
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") + statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
"</div></div>"; "</div></div>";
html += renderLegalCard();
document.getElementById("view-overview").innerHTML = html; document.getElementById("view-overview").innerHTML = html;
} }
@ -5211,17 +5482,48 @@ async function connectFetchSSE(url) {
var phase = p.phase || ""; var phase = p.phase || "";
var tool = p.tool || ""; var tool = p.tool || "";
if (p.channel !== "_consolidation_") { if (p.channel !== "_consolidation_") {
if (phase === "pre_action") // 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
// 与 WebUI 同一套 g阶段组编号见 rtPhaseGroup。
if (phase === "on_input") {
state.stageTrail = [];
rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input"));
} else if (phase === "pre_action") {
rtTrailPush(
1,
"stage",
__("组装上下文并思考", "assemble context and think"),
__("思考", "think"),
);
state.chatStage = __("AI 思考中...", "AI thinking..."); state.chatStage = __("AI 思考中...", "AI thinking...");
else if (phase === "before_toolcall") { } else if (phase === "before_toolcall") {
if (tool)
rtTrailPush(
2,
tool.indexOf("output_") === 0 ? "output" : "tool",
tool,
rtShortTool(tool),
);
if (tool) state.toolFlash = true;
state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
if (tool && (state.pendingTools || []).indexOf(tool) === -1) { if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
if (!state.pendingTools) state.pendingTools = []; if (!state.pendingTools) state.pendingTools = [];
state.pendingTools.push(tool); state.pendingTools.push(tool);
rerenderChatIfActive(); rerenderChatIfActive();
} }
} else if (phase === "before_output") } else if (phase === "before_output") {
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
state.chatStage = __("生成回复中...", "Generating response..."); state.chatStage = __("生成回复中...", "Generating response...");
} else if (phase === "after_output") {
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
}
state.pipelinePhase = phase;
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
state.pipelineTimer = setTimeout(function () {
state.pipelinePhase = "";
if (state.currentView === "overview") renderOverview();
}, 2500);
if (state.currentView === "overview") renderOverview();
} }
var badge = document.getElementById("chat-stage"); var badge = document.getElementById("chat-stage");
if (badge) { if (badge) {

View File

@ -2191,3 +2191,284 @@ td .switch {
.kv-row .val .switch { .kv-row .val .switch {
margin-right: 4px; margin-right: 4px;
} }
/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)=====
两块都统一成**等大表框**,与 KPI 卡同一种骨架。
此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」,
也看不到四级中断队列的积压。 */
.stat-card .stat-sub {
margin-top: 4px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.3px;
margin: 14px 0 8px;
text-transform: uppercase;
}
.rt-empty {
font-size: 12px;
color: var(--text-muted);
padding: 4px 0;
}
/* 行内小图标:一律 SVG不使用 emoji/符号字符。 */
.rt-ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: -1.5px;
}
.rt-pipe-row {
display: grid;
/* 100px 下限:容器再窄也要保证 5 个框一行不留「4 个 + 1 个」的孤行 */
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin: 2px 0 8px;
transition: opacity 0.3s var(--ease-out);
}
.rt-pipe-idle {
opacity: 0.55;
}
.rt-pipe-cell {
display: flex;
flex-direction: column;
gap: 8px;
padding: 11px 12px 10px;
border-radius: var(--radius-md);
background: var(--bg-input);
border: 1px solid var(--border-color);
min-width: 0;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out),
background 0.25s var(--ease-out);
}
.rt-pipe-cell.active {
border-color: var(--accent);
background: var(--accent-bg);
box-shadow: var(--shadow-md);
}
.rt-pipe-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-pipe-head > b {
font-size: 13.5px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rt-pipe-head .rt-ico {
width: 15px;
height: 15px;
color: var(--text-secondary);
}
.rt-pipe-cell.active .rt-pipe-head > b,
.rt-pipe-cell.active .rt-pipe-head .rt-ico {
color: var(--accent);
}
.rt-pipe-events {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 23px;
}
/* 「工具」格的滚动视口:固定一行高,只露最新一条(与 WebUI 同一口径)。 */
.rt-pipe-scroll {
flex-wrap: nowrap;
height: 23px;
min-height: 23px;
overflow: hidden;
align-items: center;
}
.rt-pipe-scroll .rt-chip {
flex: 0 1 auto;
min-width: 0;
max-width: none;
}
.rt-chip-enter {
animation: rt-chip-scroll-in 0.3s var(--ease-out);
}
@keyframes rt-chip-scroll-in {
from {
transform: translateY(115%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.rt-scroll-count {
flex: 0 0 auto;
margin-left: auto;
font-style: normal;
font-size: 10.5px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.rt-loop {
display: inline-flex;
color: var(--accent);
font-weight: 700;
}
.rt-chip {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
max-width: 170px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--bg-hover);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.rt-chip-t {
font-weight: inherit;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-chip-tool {
border-color: rgba(136, 192, 208, 0.42);
color: var(--frost-300);
}
.rt-chip-output {
border-color: var(--accent);
color: var(--accent);
}
.rt-chip-stage {
opacity: 0.7;
}
.rt-chip-none {
opacity: 0.35;
border-style: dashed;
}
.rt-chip-n {
font-style: normal;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
/* 队列5 个等大框L4/L3/L2/L1 + 排队),一行排开 */
.rt-queues {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin-bottom: 4px;
}
.rt-qcell {
display: flex;
flex-direction: column;
gap: 7px;
padding: 11px 12px 10px;
border-radius: var(--radius-md);
background: var(--bg-input);
border: 1px solid var(--border-color);
min-width: 0;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
.rt-qhead {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.rt-qhead > b {
font-size: 16px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
.rt-qhead > span {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rt-qnum {
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.rt-qmeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 10.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; }
.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; }
.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); }
.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); }
.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; }
/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */
.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); }
.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); }
.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); }
.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); }
.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; }
/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */
.rt-qcell.rt-qcell-queued {
border-style: dashed;
}
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
用进度条时队列为 0 宽度就是 0整行只剩文字看上去就是「这块空着」。 */
.rt-slots {
display: flex;
align-items: stretch;
gap: 3px;
height: 16px;
min-width: 0;
}
.rt-slots > i {
flex: 1 1 0;
min-width: 3px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.05);
transition:
background 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); }
.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); }
.rt-slots-more {
flex: 0 0 auto;
align-self: center;
margin-left: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}

876
cmd/homed/bootstrap.go Normal file
View File

@ -0,0 +1,876 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// defaultSystemPrompt 是内置默认人格模板:不含版本号字面量,
// 被问版本时以运行时快照为准(历史上写死版本号导致实例自称旧版本)。
const defaultSystemPrompt = `你是 HomeAgent一个持续运行的个人管家。
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
如需切换回复通道,使用 output_set_channel。
如需异步发送消息或通知,使用 output_send 指定通道和内容。
使用 output_list_channels 查看可用通道及其能力。
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
- knowledge_* — 知识库(查阅预设知识文档)
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
- person_* — 人物特质与社交关系网
- llm_* — LLM 源管理(列出/切换模型提供商)
- output_* — 输出通道管理(切换/发送消息)
- timer_set — 设置定时提醒
- plgreload — 热重载插件
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent避免阻塞当前对话。
- describe_image — 描述用户上传的图片
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
命令与文件操作策略:
- cmd_run 经完整 shellbash执行支持管道、分号、&&、命令替换、heredoc、重定向。
- 多步交互式程序vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
- 写文件优先 files_write原子+留档),生成多行内容时可用 heredoc 或 files_write
不要用 echo 拼接长文本。
- 读用户发来的文件用 files_read向 webui 回传图片/文件用 output_send__webui(type=image/file)。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
// setupLogging 初始化日志:行号前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func setupLogging(dataDir string) string {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
// 文件日志:同时输出到控制台和 data/log/ 目录
logDir := filepath.Join(dataDir, "log")
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("[homed] warning: cannot create log dir: %v", err)
} else {
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("[homed] warning: cannot open log file: %v", err)
} else {
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
log.Printf("[homed] logging to %s", logPath)
}
}
return logDir
}
// ensureDataDirs 建好启动期需要的全部目录,返回 agent 的 overlayfs 工作目录。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func ensureDataDirs(dataDir string) string {
agentWorkDir := filepath.Join(dataDir, "agentfs")
dirs := []string{
dataDir,
filepath.Join(dataDir, "snapshots"),
filepath.Join(dataDir, "plugins"),
filepath.Join(dataDir, "changesets"),
filepath.Join(dataDir, "memory"),
filepath.Join(dataDir, "memory", "raw"),
filepath.Join(dataDir, "adapters"),
agentWorkDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("create dir %s: %v", d, err)
}
}
return agentWorkDir
}
// memoryStack 聚合记忆侧组件:图库、索引器、社交图、蒸馏器。
type memoryStack struct {
db *memory.GraphDB
indexer *memory.Indexer
social *social.SocialStore
distiller *pipeline.Distiller
}
// initMemoryStack 初始化图记忆 / 索引 / 社交图 / 蒸馏管线。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initMemoryStack(dataDir string) (*memoryStack, func()) {
memDB, err := memory.NewGraphDB(filepath.Join(dataDir, "memory", "graph.db"))
if err != nil {
log.Printf("[homed] warning: memory init failed: %v", err)
memDB = nil
} else {
log.Printf("[homed] graph memory initialized")
}
memIdx := memory.NewIndexer(memDB)
memIdx.Sync() // 启动时立即同步避免前30分钟空窗
socialStore := social.New(memDB)
distiller := pipeline.NewDistiller(memDB, dataDir, pipeline.DistillerConfig{
Interval: 10 * time.Minute,
RetentionDays: 7,
BatchSize: 50,
})
if memDB != nil {
// 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发
// defer而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
// 停机由调用点注册的 cleanup 负责(见下方返回值)。
distiller.Start()
}
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
func() {
// 与原 main 的两个 defer 同序LIFO先停蒸馏器再关图库。
if memDB != nil {
distiller.Stop()
}
if memDB != nil {
memDB.Close()
}
}
}
// startMemoryCandidateConsumer 起一个常驻 goroutine把 eventbus 上的 memory_candidate 事件写进文本记忆并喂给蒸馏管线。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startMemoryCandidateConsumer(ctx context.Context, iom *agentIO.IOManager, textMem *text.Memory, memDB *memory.GraphDB, distiller *pipeline.Distiller) {
go func() {
for {
select {
case <-ctx.Done():
return
case evt, ok := <-iom.OutputChan():
if !ok {
return
}
if evt.Target == "memory" && evt.Type == "memory_candidate" {
source, _ := evt.Payload["source"].(string)
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
toolResults, _ := evt.Payload["tool_results"].([]interface{})
agentID, _ := evt.Payload["agent_id"].(string)
if input != "" && textMem != nil {
te := text.Event{
Timestamp: time.Now().Unix(),
Source: source,
Input: input,
Response: response,
ToolsUsed: toolsUsed,
AgentID: agentID,
}
if err := textMem.Append(te); err != nil {
log.Printf("[homed] text memory append: %v", err)
}
}
if input != "" && memDB != nil {
distiller.Append("agent", "user", input)
}
if response != "" && memDB != nil {
distiller.Append("agent", "assistant", response)
}
// 工具输出接入蒸馏管线
for _, tr := range toolResults {
if trMap, ok := tr.(map[string]interface{}); ok {
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
distiller.Append("agent", "tool", text)
}
}
}
}
}
}
}()
}
// initLLMProviders 按配置注册全部 LLM 源(每个源经 Lua 适配器协议转换),并把各适配器的并发额度汇总回 Lua VM。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initLLMProviders(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *agentAPI.ProviderManager {
providerMgr := agentAPI.NewProviderManager()
adapterConcurrency := map[string]int{}
for _, src := range cfg.LLM.Sources {
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
continue
}
key := src.APIKey
if key == "" {
key = baseAPIKey
}
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
Model: src.Model,
BaseURL: src.BaseURL,
APIKey: key,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
ContextWindow: src.ContextWindow,
MaxConcurrent: src.MaxConcurrent,
Priority: src.Priority,
Vision: src.Vision,
Audio: src.Audio,
}, luaVM, src.Name, src.Adapter)
providerMgr.Register(src.Name, luaProvider)
if src.Adapter != "" {
adapterConcurrency[src.Adapter] += src.MaxConcurrent
}
}
luaVM.ConfigureConcurrency(adapterConcurrency)
if cfg.LLM.Provider != "" {
providerMgr.SetDefault(cfg.LLM.Provider)
}
return providerMgr
}
// initDocStore 启动文档记忆。flush 的唯一入口是 Stop(),所以关停时必须调用它。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initDocStore(cfg *types.Config) (*document.Store, func()) {
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err)
}
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
// 于是迁移结果永不落盘、每次启动白算一遍。
return docStore, func() { docStore.Stop() }
}
// initMediaStore 按开关启动内容寻址的媒体存储;开不起来只告警(媒体记忆非对话必需品)。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initMediaStore(cfgReg *internalConfig.ConfigRegistry, cfg *types.Config) (*media.Store, func()) {
var mediaStore *media.Store
if cfgReg.GetBool("core.memory.media.enabled", true) {
mediaDir := cfgReg.GetString("core.memory.media.dir",
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
ms, err := media.New(mediaDir)
if err != nil {
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
log.Printf("[homed] warning: media store: %v媒体记忆已禁用", err)
} else {
mediaStore = ms
st := mediaStore.Stats()
log.Printf("[homed] media store active: %v 条 / %v 字节",
st["count"], st["total_bytes"])
}
}
return mediaStore, func() {
if mediaStore != nil {
mediaStore.Close()
}
}
}
// initMultimodalSpace 从公共注册表打开多模态向量 provider。返回 (空间, provider 名, 失败原因, cleanup):后两个值只用于状态报告。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initMultimodalSpace(cfgReg *internalConfig.ConfigRegistry) (vector.MultimodalEmbedder, string, string, func()) {
var multimodalSpace vector.MultimodalEmbedder
// 这两个值只用于状态报告healthcheck_kernel 的 onnx 段):
// 「配了哪个 provider」与「为什么没启用」避免只能看到 false 却不知原因。
var mmProviderName, mmErr string
var closeAdapted func()
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
mmProviderName = mmProvider
opts := map[string]string{}
const optPrefix = "core.memory.multimodal_space.options."
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
}
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
if err != nil {
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v多模态向量检索已禁用已注册: %s",
mmProvider, err, strings.Join(embedding.Names(), ", "))
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
provider.Close()
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v多模态向量检索已禁用", mmProvider, err)
} else {
multimodalSpace = adapted
info := provider.Info()
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
shortFP := info.Fingerprint
if len(shortFP) > 12 {
shortFP = shortFP[:12]
}
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
mmProvider, info.Dimension, shortFP, info.Modalities)
}
}
if closeAdapted == nil {
closeAdapted = func() {}
}
return multimodalSpace, mmProviderName, mmErr, closeAdapted
}
// initKnowledgeStore 启动知识库。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initKnowledgeStore(cfg *types.Config) *knowledge.Store {
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err)
} else {
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
}
return ks
}
// loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func loadPersonality(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) *agentPkg.Personality {
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] 人格来源=文件 %s优先于配置项%d 字节", personalPath, len(personality.Content))
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
}
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt%d 字节", len(pv))
} else {
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
}
return personality
}
// newStageAndRegistry 建阶段管道与插件注册表,把内核依赖接到注册表上。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func newStageAndRegistry(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, iom *agentIO.IOManager,
evBus *events.Bus, memDB *memory.GraphDB, textMem *text.Memory, docStore *document.Store,
mediaStore *media.Store, ks *knowledge.Store, providerMgr *agentAPI.ProviderManager,
dataDir string) (*agentCore.StageHost, *plugin.Registry) {
stageHost := agentCore.NewStageHost()
pluginReg := plugin.NewRegistry()
pluginReg.SetIOManager(iom)
pluginReg.SetEventBus(evBus)
pluginReg.SetMemory(memDB)
pluginReg.SetTextMemory(textMem)
pluginReg.SetDocStore(docStore)
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路nil 时静默降级
pluginReg.SetKnowledge(ks)
pluginReg.SetProviderManager(providerMgr)
pluginReg.SetConfigRegistry(cfgReg)
pluginReg.SetPluginDir(cfg.Plugin.Dir)
pluginReg.SetDataDir(dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
return stageHost.RegisterTool(name, def, handler)
})
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
stageHost.RegisterStage(stage, handler)
})
pluginReg.SetAPIRegistrar(func(name string) error {
return nil
})
pluginReg.SetToolCleaner(stageHost)
return stageHost, pluginReg
}
// newMainAgent 组装主 Agent把内核各面IO/记忆/文档/知识/媒体/社交/文本/插件/状态)接进 AgentConfig。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, provider agentAPI.Provider,
providerMgr *agentAPI.ProviderManager, iom *agentIO.IOManager, memDB *memory.GraphDB,
memIdx *memory.Indexer, trk *tracker.Tracker, docStore *document.Store, ks *knowledge.Store,
socialStore *social.SocialStore, textMem *text.Memory, mediaStore *media.Store,
personality *agentPkg.Personality, pluginReg *plugin.Registry, embedder *memory.StaticEmbedder,
multimodalSpace vector.MultimodalEmbedder, mmProviderName, mmErr string,
stageHost *agentCore.StageHost, evBus *events.Bus) *agentCore.Agent {
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
if sysPrompt == "" {
sysPrompt = defaultSystemPrompt
}
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
// DataDir驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
DataDir: cfg.Daemon.DataDir,
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
Offload: agentCore.OffloadOptions{
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
},
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
MultimodalSpace: multimodalSpace,
EmbeddingProvider: mmProviderName,
EmbeddingError: mmErr,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
InputProcessing: cfg.InputProcessing,
})
return agent
}
// initONNXParser 初始化依存句法分析器(内嵌 ONNX 模型,失败则退回规则引擎)。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func initONNXParser(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) {
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
ModelPath: modelPath,
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
})
if err != nil {
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
} else {
nlp.SetDefaultParser(onnxParser)
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
}
}
// loadPlugins 建插件目录、按启动模式决定 allowlist然后加载全部插件。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
stageHost *agentCore.StageHost, bootMode, dataDir string) {
// Auto-create plugins directory (without hardcoding plugin names)
os.MkdirAll(cfg.Plugin.Dir, 0755)
// failback 受限启动:仅装载 failback 插件集webfetch/files/cmd 为内核内置,
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
if bootMode == "failback" {
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集guard 是 failback 权威)
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
list = strings.Join(task.Plugins(), ",")
}
var names []string
for _, s := range strings.Split(list, ",") {
if s = strings.TrimSpace(s); s != "" {
names = append(names, s)
}
}
pluginReg.SetLoadAllowlist(names)
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
}
// Load all plugins — each scans its own dir and is loaded via factory or .so
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
}
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
}
// startAgentRuntime 接线技能索引、起日志管理、启动 agent返回逆序关停的 cleanup。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
// 技能索引接线skillmgr 插件实现 SkillIndexProvider 时注入 agent方案B prompt 注入)
if sp := pluginReg.Get("skillmgr"); sp != nil {
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
agent.SetSkillIndexProvider(prov)
log.Printf("[homed] skill index wired from skillmgr plugin")
}
}
// 日志管理:层级压缩 + 保留策略
logManager := logpkg.NewManager(logDir, cfgReg)
go logManager.Start(ctx)
agent.Start()
return func() {
// 与原 main 的两个 defer 同序LIFO先停 agent再停日志管理。
agent.Stop()
logManager.Stop()
}
}
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
started := false
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
st := agent.GetKernelStatus()
llmOK := st != nil && st.LLM.Available
tools := 0
if st != nil {
tools = len(st.Tools)
}
uptime := int64(0)
if st != nil {
if d, err := time.ParseDuration(st.Uptime); err == nil {
uptime = int64(d.Seconds())
}
}
return &ipc.Status{
PID: os.Getpid(),
Boot: bootMode,
UptimeSec: uptime,
LLMOK: &llmOK,
Tools: tools,
LastDiag: lastDiagSummary(dataDir),
}
})
if err := ipcServer.Start(); err != nil {
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
} else {
}
return ipcServer, func() {
if started {
ipcServer.Stop()
}
}
}
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
sup.SetTracker(trk)
sup.RegisterAgent("main")
// 真实存活源 + 重启通道daemon 心跳语义由此修正lastHB 只在确认存活时更新),
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
restartCh := make(chan struct{}, 1)
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
st := agent.GetKernelStatus()
if st == nil {
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
}
h := types.HealthHealthy
if !st.LLM.Available {
h = types.HealthDegraded
}
return time.Now(), h, nil
})
sup.SetRestartHandler(func(id types.AgentID) {
select {
case restartCh <- struct{}{}:
default:
}
})
return restartCh
}
// startHeartbeat 每 5s 触碰 <data>/heartbeatguard 据此判定 worker 存活/卡死),返回停止函数。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startHeartbeat(dataDir string, ctx context.Context) func() {
// 心跳:每 5s 触碰 <data>/heartbeatguard 据此判定工作进程是否存活/卡死
hbPath := filepath.Join(dataDir, "heartbeat")
hbStop := make(chan struct{})
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
writeHB := func() {
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
f.Close()
}
}
writeHB()
for {
select {
case <-t.C:
writeHB()
case <-hbStop:
return
case <-ctx.Done():
return
}
}
}()
return func() { close(hbStop) }
}
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
restartRequested := false
select {
case <-sigCh:
log.Printf("[homed] shutting down...")
case <-restartCh:
restartRequested = true
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
}
stopHeartbeat()
pluginReg.StopAll()
if trk != nil {
trk.Stop()
}
if err := cfgReg.Flush(); err != nil {
log.Printf("[homed] flush config: %v", err)
}
sup.Shutdown()
log.Printf("[homed] stopped")
if restartRequested {
os.Exit(exitRestartRequested)
}
}
// initLuaVM 起 Lua VMLLM 协议适配启动失败只告警cleanup 为 no-op。
func initLuaVM(cfg *types.Config) (*luapkg.VM, func()) {
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
if err := luaVM.Start(); err != nil {
log.Printf("[homed] warning: lua vm init failed: %v", err)
return luaVM, func() {}
}
return luaVM, luaVM.Stop
}
// initSupervisor 起守护管理(代理生命周期管理);起不来是致命错误。
func initSupervisor(cfg *types.Config) *supervisor.Daemon {
sup := supervisor.New(cfg)
if err := sup.Start(); err != nil {
log.Fatalf("start supervisor: %v", err)
}
return sup
}
// initTracker 起 overlayfs 变更追踪;无 overlayfs 支持时降级为非致命告警。
func initTracker(cfg *types.Config, agentWorkDir string) *tracker.Tracker {
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
tracker.WithKeepChangesets(100),
tracker.WithMaxChangesetAge(30*24*time.Hour),
)
if err := trk.Init(); err != nil {
log.Printf("[homed] warning: tracker init: %v", err)
} else {
if err := trk.Start(); err != nil {
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
} else {
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
}
}
return trk
}
// initKernelAPI 建内核与插件之间的两个通道IOManagerIO 抽象层)+ EventBus事件总线
func initKernelAPI() (*agentIO.IOManager, *events.Bus) {
iom := agentIO.NewIOManager()
evBus := events.NewBus()
log.Printf("[homed] kernel API ready: IOManager + EventBus")
return iom, evBus
}
// initTextMemory 起文本记忆启动失败只告警cleanup 为 no-op。
func initTextMemory(cfg *types.Config) (*text.Memory, func()) {
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
if err := textMem.Start(); err != nil {
log.Printf("[homed] warning: text memory start: %v", err)
return textMem, func() {}
}
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
return textMem, textMem.Stop
}
// resolveBaseAPIKey 解析兜底 API key配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
func resolveBaseAPIKey(cfg *types.Config) string {
apiKey := cfg.LLM.APIKey
if apiKey == "" {
apiKey = os.Getenv("LLM_API_KEY")
}
if apiKey == "" {
apiKey = os.Getenv("DEEPSEEK_API_KEY")
}
return apiKey
}
// wirePluginSDK 把内核各面注入每个插件的 PluginSDK阶段6 将替换遗留的 util.Configure
func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey string, sup *supervisor.Daemon,
trk *tracker.Tracker, cfg *types.Config, stageHost *agentCore.StageHost, memIdx *memory.Indexer,
agent *agentCore.Agent) {
pluginReg.SetLuaVM(luaVM)
pluginReg.SetBaseAPIKey(baseAPIKey)
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
pluginReg.SetTracker(trk)
pluginReg.SetConfig(cfg)
pluginReg.SetStageHost(stageHost)
pluginReg.SetIndexer(memIdx)
pluginReg.SetStatusProvider(agent)
pluginReg.SetTerminalAPI(agent)
}
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
//
// 优先级CLI --webui > 核心配置 webui.listen_addr仅当它被改成非内置默认值
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
//
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
// config_webui 表还没建(表只在插件注册 def 时创建PluginSettings.Set 的
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收webui.SetListenOverride
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
if strings.TrimSpace(httpAddr) != "" {
return strings.TrimSpace(httpAddr)
}
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
// 否则会把用户在设置页里改过的插件 addr 顶掉。
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
return v
}
return ""
}
// options 是 worker 的命令行参数。
type options struct {
dataDir string
httpAddr string
cliSocket string
role string
boot string
}
// parseFlags 解析命令行参数。
func parseFlags() options {
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
flag.Parse()
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
}
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
//
// 触发条件(见 internal/config.MaybeCompact空闲页 >= 1MB 且占页数 >= 25%。
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
before := int64(-1)
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
before = st.Size()
}
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
if err != nil {
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
return
}
if !done {
return
}
after := before
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
after = st.Size()
}
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
}

View File

@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
//
// 历史 bugmain() 拆分时函数体内残留一句 `defer distiller.Stop()`
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
// distillOnce绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
// 真实调用方式断言,才照得出来。
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
dir := t.TempDir()
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
t.Fatal(err)
}
st, cleanup := initMemoryStack(dir)
if st == nil || st.distiller == nil {
cleanup()
t.Fatal("initMemoryStack 未返回蒸馏器")
}
if st.db == nil {
cleanup()
t.Skip("图库未初始化,无法验证蒸馏接线")
}
if st.distiller.Stopped() {
cleanup()
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉defer Stop 残留?)")
}
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
cleanup()
if !st.distiller.Stopped() {
t.Fatal("cleanup 之后蒸馏器应已停止")
}
}

View File

@ -2,645 +2,184 @@ package main
import ( import (
"context" "context"
"flag"
"fmt"
"io"
"log" "log"
"os"
"os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"time"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta" "gitcode.com/JianFeeeee/HomeAgent/internal/meta"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" // 空白导入内置 provider它们各自在 init 里注册到 pkg/embedding。
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" // 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker" _ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types" _ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
) )
// main 是 worker 进程的启动序列。
//
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
// - 阶段实现体在同包 bootstrap.go与这里的调用一一对应
// - 需要逆序释放的资源由阶段函数返回 cleanup在**原位** defer 注册,
// 因此释放顺序与拆分前完全一致。
func main() { func main() {
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)") // 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)") // 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)") // platform_windows.go。
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)") requireSupportedPlatform()
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
flag.Parse() opt := parseFlags()
// 父守护模式:只负责拉起/守护 worker不初始化 agent 内核 // 父守护模式:只负责拉起/守护 worker不初始化 agent 内核
if *role == "guard" { if opt.role == "guard" {
runGuard(resolveDataDir(*dataDir)) runGuard(resolveDataDir(opt.dataDir))
return return
} }
log.Printf("[homed] role=agent boot=%s", *boot) log.Printf("[homed] role=agent boot=%s", opt.boot)
if *dataDir == "" { if opt.dataDir == "" {
*dataDir = resolveDataDir(*dataDir) opt.dataDir = resolveDataDir(opt.dataDir)
} }
if *cliSocket == "" { if opt.cliSocket == "" {
*cliSocket = filepath.Join(*dataDir, "cli.sock") opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
} }
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) logDir := setupLogging(opt.dataDir)
// 文件日志:同时输出到控制台和 data/log/ 目录
logDir := filepath.Join(*dataDir, "log")
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("[homed] warning: cannot create log dir: %v", err)
} else {
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("[homed] warning: cannot open log file: %v", err)
} else {
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
log.Printf("[homed] logging to %s", logPath)
}
}
log.Printf("[homed] starting %s", meta.FullVersion()) log.Printf("[homed] starting %s", meta.FullVersion())
agentWorkDir := filepath.Join(*dataDir, "agentfs") agentWorkDir := ensureDataDirs(opt.dataDir)
dirs := []string{
*dataDir,
filepath.Join(*dataDir, "snapshots"),
filepath.Join(*dataDir, "plugins"),
filepath.Join(*dataDir, "changesets"),
filepath.Join(*dataDir, "memory"),
filepath.Join(*dataDir, "memory", "raw"),
filepath.Join(*dataDir, "adapters"),
agentWorkDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("create dir %s: %v", d, err)
}
}
// ======================================================================== // ---- 基础设施层:记忆、技能 ----
// 基础设施层:记忆、技能
// ========================================================================
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db")) mem, closeMem := initMemoryStack(opt.dataDir)
if err != nil { defer closeMem()
log.Printf("[homed] warning: memory init failed: %v", err)
memDB = nil
} else {
log.Printf("[homed] graph memory initialized")
}
if memDB != nil {
defer memDB.Close()
}
memIdx := memory.NewIndexer(memDB) // ---- 配置中心SQLite 持久化,唯一配置源) ----
memIdx.Sync() // 启动时立即同步避免前30分钟空窗
socialStore := social.New(memDB)
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{ cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
Interval: 10 * time.Minute,
RetentionDays: 7,
BatchSize: 50,
})
if memDB != nil {
distiller.Start()
defer distiller.Stop()
}
// ========================================================================
// 配置中心SQLite 持久化,唯一配置源)
// ========================================================================
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db"))
defer cfgReg.Close() defer cfgReg.Close()
cfgReg.SeedDefaults(*dataDir) cfgReg.SeedDefaults(opt.dataDir)
// LLM 配置写前留档config_set 写 core.llm.* 前自动快照guard 恢复用基线 // LLM 配置写前留档config_set 写 core.llm.* 前自动快照guard 恢复用基线
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json")) cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
cfg := cfgReg.ToConfig() cfg := cfgReg.ToConfig()
// 共享词嵌入蒸馏提取Phase 3 TransE 验证)与 Agent 上下文复用同一实例, // 共享词嵌入蒸馏提取Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。 // 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...) embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
distiller.SetEmbedder(embedder) mem.distiller.SetEmbedder(embedder)
// ======================================================================== // ---- Lua VMLLM 协议适配) ----
// Lua VMLLM 协议适配)
// ========================================================================
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters")) luaVM, closeLuaVM := initLuaVM(cfg)
if err := luaVM.Start(); err != nil { defer closeLuaVM()
log.Printf("[homed] warning: lua vm init failed: %v", err)
} else {
defer luaVM.Stop()
}
// ======================================================================== // ---- 守护管理(代理生命周期管理) ----
// 守护管理(代理生命周期管理)
// ========================================================================
sup := supervisor.New(cfg) sup := initSupervisor(cfg)
if err := sup.Start(); err != nil {
log.Fatalf("start supervisor: %v", err)
}
// ======================================================================== // ---- 变更追踪overlayfs ----
// 变更追踪overlayfs
// ========================================================================
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir, trk := initTracker(cfg, agentWorkDir)
tracker.WithKeepChangesets(100),
tracker.WithMaxChangesetAge(30*24*time.Hour),
)
if err := trk.Init(); err != nil {
log.Printf("[homed] warning: tracker init: %v", err)
} else {
if err := trk.Start(); err != nil {
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
} else {
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
}
}
// ======================================================================== // ========================================================================
// 内核 APIIOManagerIO 抽象层) + EventBus事件总线 // 内核 APIIOManagerIO 抽象层) + EventBus事件总线
// 所有插件通过这两个通道与核心交互 // 所有插件通过这两个通道与核心交互
// ======================================================================== // ========================================================================
iom := agentIO.NewIOManager() iom, evBus := initKernelAPI()
evBus := events.NewBus()
log.Printf("[homed] kernel API ready: IOManager + EventBus")
// ======================================================================== // ---- 文本记忆 + 记忆蒸馏管线 ----
// 文本记忆 + 记忆蒸馏管线
// ========================================================================
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text")) textMem, closeTextMem := initTextMemory(cfg)
if err := textMem.Start(); err != nil { defer closeTextMem()
log.Printf("[homed] warning: text memory start: %v", err)
} else {
defer textMem.Stop()
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
}
ctx, stop := context.WithCancel(context.Background()) ctx, stop := context.WithCancel(context.Background())
defer stop() defer stop()
go func() { startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.distiller)
for {
select {
case <-ctx.Done():
return
case evt, ok := <-iom.OutputChan():
if !ok {
return
}
if evt.Target == "memory" && evt.Type == "memory_candidate" {
source, _ := evt.Payload["source"].(string)
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
toolResults, _ := evt.Payload["tool_results"].([]interface{})
agentID, _ := evt.Payload["agent_id"].(string)
if input != "" && textMem != nil { // ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
te := text.Event{
Timestamp: time.Now().Unix(),
Source: source,
Input: input,
Response: response,
ToolsUsed: toolsUsed,
AgentID: agentID,
}
if err := textMem.Append(te); err != nil {
log.Printf("[homed] text memory append: %v", err)
}
}
if input != "" && memDB != nil { baseAPIKey := resolveBaseAPIKey(cfg)
distiller.Append("agent", "user", input)
}
if response != "" && memDB != nil {
distiller.Append("agent", "assistant", response)
}
// 工具输出接入蒸馏管线 providerMgr := initLLMProviders(cfg, luaVM, baseAPIKey)
for _, tr := range toolResults {
if trMap, ok := tr.(map[string]interface{}); ok {
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
distiller.Append("agent", "tool", text)
}
}
}
}
}
}
}()
// ========================================================================
// LLM Provider 管理(多源,通过 Lua 适配器协议转换)
// ========================================================================
apiKey := cfg.LLM.APIKey
if apiKey == "" {
apiKey = os.Getenv("LLM_API_KEY")
}
if apiKey == "" {
apiKey = os.Getenv("DEEPSEEK_API_KEY")
}
baseAPIKey := apiKey
providerMgr := agentAPI.NewProviderManager()
adapterConcurrency := map[string]int{}
for _, src := range cfg.LLM.Sources {
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
continue
}
key := src.APIKey
if key == "" {
key = baseAPIKey
}
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
Model: src.Model,
BaseURL: src.BaseURL,
APIKey: key,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
ContextWindow: src.ContextWindow,
MaxConcurrent: src.MaxConcurrent,
Priority: src.Priority,
Vision: src.Vision,
Audio: src.Audio,
}, luaVM, src.Name, src.Adapter)
providerMgr.Register(src.Name, luaProvider)
if src.Adapter != "" {
adapterConcurrency[src.Adapter] += src.MaxConcurrent
}
}
luaVM.ConfigureConcurrency(adapterConcurrency)
if cfg.LLM.Provider != "" {
providerMgr.SetDefault(cfg.LLM.Provider)
}
provider := providerMgr.Default() provider := providerMgr.Default()
// L1 failback受限 worker 启动即跑恢复梯子probe→还原DNS/proxy→还原config+ReloadFromConfig→probe // L1 failback受限 worker 启动即跑恢复梯子probe→还原DNS/proxy→还原config+ReloadFromConfig→probe
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard不进入主 agent 循环。 // 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard不进入主 agent 循环。
if *boot == "failback" { if opt.boot == "failback" {
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey) runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
} }
// ======================================================================== // ---- 文档记忆 + 知识库 ----
// 文档记忆 + 知识库
// ========================================================================
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents")) docStore, closeDocStore := initDocStore(cfg)
if err := docStore.Start(); err != nil { defer closeDocStore()
log.Printf("[homed] warning: document store: %v", err)
}
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
if err := ks.Start(); err != nil { defer closeMediaStore()
log.Printf("[homed] warning: knowledge store: %v", err)
} else {
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
}
// ======================================================================== multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
// 人格设定 defer closeMultimodal()
// ========================================================================
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md") ks := initKnowledgeStore(cfg)
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
}
// ======================================================================== // ---- 人格设定 ----
// 阶段管道StageHost+ 插件系统Registry
// ========================================================================
stageHost := agentCore.NewStageHost() personality := loadPersonality(cfg, cfgReg)
pluginReg := plugin.NewRegistry() // ---- 阶段管道StageHost+ 插件系统(Registry ----
pluginReg.SetIOManager(iom)
pluginReg.SetEventBus(evBus)
pluginReg.SetMemory(memDB)
pluginReg.SetTextMemory(textMem)
pluginReg.SetDocStore(docStore)
pluginReg.SetKnowledge(ks)
pluginReg.SetProviderManager(providerMgr)
pluginReg.SetConfigRegistry(cfgReg)
pluginReg.SetPluginDir(cfg.Plugin.Dir)
pluginReg.SetDataDir(*dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { docStore, mediaStore, ks, providerMgr, opt.dataDir)
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
return stageHost.RegisterTool(name, def, handler)
})
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
stageHost.RegisterStage(stage, handler)
})
pluginReg.SetAPIRegistrar(func(name string) error {
return nil
})
pluginReg.SetToolCleaner(stageHost)
// ======================================================================== // ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
// ========================================================================
defaultPrompt := `你是 HomeAgent一个持续运行的个人管家。 agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。 docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
如需切换回复通道,使用 output_set_channel。 multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
如需异步发送消息或通知,使用 output_send 指定通道和内容。
使用 output_list_channels 查看可用通道及其能力。
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具: wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
- knowledge_* — 知识库(查阅预设知识文档)
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
- person_* — 人物特质与社交关系网
- llm_* — LLM 源管理(列出/切换模型提供商)
- output_* — 输出通道管理(切换/发送消息)
- timer_set — 设置定时提醒
- plgreload — 热重载插件
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent避免阻塞当前对话。
- describe_image — 描述用户上传的图片
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
命令与文件操作策略:
- cmd_run 经完整 shellbash执行支持管道、分号、&&、命令替换、heredoc、重定向。
- 多步交互式程序vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
- 写文件优先 files_write原子+留档),生成多行内容时可用 heredoc 或 files_write
不要用 echo 拼接长文本。
- 读用户发来的文件用 files_read向 webui 回传图片/文件用 output_send__webui(type=image/file)。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
if sysPrompt == "" {
sysPrompt = defaultPrompt
}
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
Personality: personality,
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
InputProcessing: cfg.InputProcessing,
})
// 通过 Registry 将内核依赖注入每个插件的 PluginSDK阶段6 将替换遗留的 util.Configure
pluginReg.SetLuaVM(luaVM)
pluginReg.SetBaseAPIKey(baseAPIKey)
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
pluginReg.SetTracker(trk)
pluginReg.SetConfig(cfg)
pluginReg.SetStageHost(stageHost)
pluginReg.SetIndexer(memIdx)
pluginReg.SetStatusProvider(agent)
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂) // 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
cli.DefaultSocket = *cliSocket cli.DefaultSocket = opt.cliSocket
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080 // webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它
// 保留 CLI --webui 与 webui.listen_addr 配置对监听地址的覆盖。 webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
webuiListenAddr := *httpAddr
if webuiListenAddr == "" {
webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080")
}
if ps := cfgReg.PluginConfig("webui"); ps != nil {
if v, _ := ps.Get("addr"); v == nil {
_ = ps.Set("addr", webuiListenAddr)
}
}
// ======================================================================== // ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ----
// 依存句法分析器(内嵌 ONNX 模型 / 规则引擎)
// ========================================================================
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "") initONNXParser(cfg, cfgReg)
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
ModelPath: modelPath,
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
})
if err != nil {
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
} else {
nlp.SetDefaultParser(onnxParser)
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
}
// Auto-create plugins directory (without hardcoding plugin names) loadPlugins(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
os.MkdirAll(cfg.Plugin.Dir, 0755)
// failback 受限启动:仅装载 failback 插件集webfetch/files/cmd 为内核内置 // 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论) // 而 SQLite 的 DELETE 不会缩小文件。
if *boot == "failback" { compactConfigDB(cfgReg)
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集guard 是 failback 权威)
if task, terr := recovery.LoadTask(recovery.TaskPath(*dataDir)); terr == nil && len(task.Plugins()) > 0 {
list = strings.Join(task.Plugins(), ",")
}
var names []string
for _, s := range strings.Split(list, ",") {
if s = strings.TrimSpace(s); s != "" {
names = append(names, s)
}
}
pluginReg.SetLoadAllowlist(names)
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
}
// Load all plugins — each scans its own dir and is loaded via factory or .so stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil { defer stopRuntime()
log.Printf("[homed] warning: load plugins: %v", err)
}
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
// 技能索引接线skillmgr 插件实现 SkillIndexProvider 时注入 agent方案B prompt 注入) _, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
if sp := pluginReg.Get("skillmgr"); sp != nil { defer closeIPC()
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
agent.SetSkillIndexProvider(prov)
log.Printf("[homed] skill index wired from skillmgr plugin")
}
}
// 日志管理:层级压缩 + 保留策略 restartCh := startSupervisorRuntime(sup, trk, agent)
logManager := logpkg.NewManager(logDir, cfgReg)
go logManager.Start(ctx)
defer logManager.Stop()
agent.Start()
defer agent.Stop()
// PING/ACK 心跳服务worker 监听 unix socketguard 发 PING、worker 回 ACK
// (含自诊断 kernel 状态快照),替换纯文件心跳。文件心跳保留作回退。
ipcServer := ipc.NewServer(*dataDir, func() *ipc.Status {
st := agent.GetKernelStatus()
llmOK := st != nil && st.LLM.Available
tools := 0
if st != nil {
tools = len(st.Tools)
}
uptime := int64(0)
if st != nil {
if d, err := time.ParseDuration(st.Uptime); err == nil {
uptime = int64(d.Seconds())
}
}
return &ipc.Status{
PID: os.Getpid(),
Boot: *boot,
UptimeSec: uptime,
LLMOK: &llmOK,
Tools: tools,
LastDiag: lastDiagSummary(*dataDir),
}
})
if err := ipcServer.Start(); err != nil {
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
} else {
defer ipcServer.Stop()
}
sup.SetTracker(trk)
sup.RegisterAgent("main")
// 真实存活源 + 重启通道daemon 心跳语义由此修正lastHB 只在确认存活时更新),
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
restartCh := make(chan struct{}, 1)
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
st := agent.GetKernelStatus()
if st == nil {
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
}
h := types.HealthHealthy
if !st.LLM.Available {
h = types.HealthDegraded
}
return time.Now(), h, nil
})
sup.SetRestartHandler(func(id types.AgentID) {
select {
case restartCh <- struct{}{}:
default:
}
})
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d", log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters())) cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
log.Printf("[homed] kernel ready, waiting for plugin IO...") log.Printf("[homed] kernel ready, waiting for plugin IO...")
// ======================================================================== // ---- 等待退出信号 ----
// 等待退出信号
// ========================================================================
sigCh := make(chan os.Signal, 1) stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
// 心跳:每 5s 触碰 <data>/heartbeatguard 据此判定工作进程是否存活/卡死
hbPath := filepath.Join(*dataDir, "heartbeat")
hbStop := make(chan struct{})
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
writeHB := func() {
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
f.Close()
}
}
writeHB()
for {
select {
case <-t.C:
writeHB()
case <-hbStop:
return
case <-ctx.Done():
return
}
}
}()
restartRequested := false
select {
case <-sigCh:
log.Printf("[homed] shutting down...")
case <-restartCh:
restartRequested = true
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
}
close(hbStop)
pluginReg.StopAll()
if trk != nil {
trk.Stop()
}
if err := cfgReg.Flush(); err != nil {
log.Printf("[homed] flush config: %v", err)
}
sup.Shutdown()
log.Printf("[homed] stopped")
if restartRequested {
os.Exit(exitRestartRequested)
}
} }

View File

@ -0,0 +1,9 @@
//go:build !windows
package main
// requireSupportedPlatform 在受支持的平台上不做任何事。
//
// 平台策略见 platform_windows.go只有 homed 放弃 Windows 原生支持
// (插件体系依赖 fd 继承与共享内存段内偏移Windows 用户走 WSL2。
func requireSupportedPlatform() {}

View File

@ -0,0 +1,44 @@
//go:build windows
package main
import (
"fmt"
"os"
)
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
//
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
//
// homed 的插件体系建立在两个原语上——**继承的 fd**Single memfd: 统一共享
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
//
// Windows 的等价物是命名内核对象CreateFileMappingW / OpenEventW句柄表
// 没有 fd 继承语义os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
// 导致改写型插件在某个平台上静默失效」的教训§9.2)。
//
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
// WSL2 里就是普通 linux/amd64走与我们测试矩阵完全相同的那条路径。
//
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
func requireSupportedPlatform() {
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "请改用 WSL2")
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap")
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed与 Linux 主机完全相同,无需额外配置")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
os.Exit(2)
}

View File

@ -17,6 +17,36 @@ func randomSecret(n int) string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// must 让失败真正停下来。
//
// 这里曾经把所有 db.Exec 的返回值丢掉,配合 CGO_ENABLED=0 构建go-sqlite3
// 退化成静态桩),得到的是一个**完全静默的空操作**:打印凭据、退出码 0、
// config.db 里一个字节都没写。调用方(安装脚本)无法区分成败,用户装完
// 照着 credentials.txt 登录必然失败。
func must(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "initconfig: %v\n", err)
os.Exit(1)
}
}
// verify 回读刚写入的值。
//
// 只看 Exec 有没有报错不够:驱动被换掉(如上面的桩)、路径不对、写入被丢弃,
// 都可能返回 nil 而什么都没落下。这里把真实落盘的值读回来,与预期逐一比对,
// 不一致就非零退出——"初始化脚本说自己成功了"必须由数据库内容佐证。
func verify(db *sql.DB, table, key, want string) {
var got string
if err := db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, table), key).Scan(&got); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 回读 %s.%s 失败: %v\n", table, key, err)
os.Exit(1)
}
if got != want {
fmt.Fprintf(os.Stderr, "initconfig: %s.%s 与写入值不一致(读回 %q\n", table, key, got)
os.Exit(1)
}
}
func main() { func main() {
dataDir := flag.String("data", "", "data directory") dataDir := flag.String("data", "", "data directory")
webuiUsername := flag.String("username", "admin", "webui username") webuiUsername := flag.String("username", "admin", "webui username")
@ -33,16 +63,24 @@ func main() {
dbPath := *dataDir + "/config.db" dbPath := *dataDir + "/config.db"
db, err := sql.Open("sqlite3", dbPath) db, err := sql.Open("sqlite3", dbPath)
if err != nil { must(err)
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
os.Exit(1)
}
defer db.Close() defer db.Close()
db.Exec("PRAGMA journal_mode=WAL") // 尽早验证数据库真的可用sql.Open 是惰性的,不碰一次不会暴露驱动问题。
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
os.Exit(1)
}
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080") fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
os.Exit(1)
}
const listenAddr = ":8080"
if _, err := db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", listenAddr); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 写入 webui.listen_addr 失败: %v\n", err)
os.Exit(1)
}
pw := *webuiPassword pw := *webuiPassword
if pw == "" { if pw == "" {
@ -53,13 +91,28 @@ func main() {
apiKey = randomSecret(16) apiKey = randomSecret(16)
} }
pt := "config_webui" const pt = "config_webui"
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)) if _, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
os.Exit(1)
}
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt) ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
db.Exec(ws, "api_key", apiKey) for _, kv := range [][2]string{
db.Exec(ws, "username", *webuiUsername) {"api_key", apiKey},
db.Exec(ws, "password", pw) {"username", *webuiUsername},
db.Exec(ws, "session_ttl_hours", "24") {"password", pw},
{"session_ttl_hours", "24"},
} {
if _, err := db.Exec(ws, kv[0], kv[1]); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 写入 %s.%s 失败: %v\n", pt, kv[0], err)
os.Exit(1)
}
}
verify(db, pt, "api_key", apiKey)
verify(db, pt, "username", *webuiUsername)
verify(db, pt, "password", pw)
verify(db, "config", "webui.listen_addr", listenAddr)
fmt.Printf("API_KEY=%s\n", apiKey) fmt.Printf("API_KEY=%s\n", apiKey)
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername) fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)

123
cmd/memgc/main.go Normal file
View File

@ -0,0 +1,123 @@
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
//
// 为什么需要这个命令噪音闸门internal/memory.IsNoiseEntity只能拦住
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
//
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
//
// 用法(默认 dry-run只列不删
//
// memgc -db /home/newqqagent/memory/graph.db
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
//
// 清理生产库前请先备份sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
package main
import (
"flag"
"fmt"
"log"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
func main() {
path := flag.String("db", "", "graph.db 路径(必填)")
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq")
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
flag.Parse()
if *path == "" {
flag.Usage()
log.Fatal("memgc: 必须指定 -db")
}
g, err := memory.NewGraphDB(*path)
if err != nil {
log.Fatalf("memgc: open %s: %v", *path, err)
}
defer g.Close()
if *sceneStats {
stats, err := g.SceneStats()
if err != nil {
log.Fatalf("memgc: scene stats: %v", err)
}
fmt.Printf("场景 %d 个:\n", len(stats))
for _, st := range stats {
fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
st.UpdatedAt.Format("2006-01-02 15:04"))
}
return
}
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
// 没有任何场景引用,不补挂就永远吃不到场景召回。
if *tagScene != "" {
if *entityGlob == "" {
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob如 '*QQ*');不做自动猜测")
}
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
if err != nil {
log.Fatalf("memgc: tag scene: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
} else {
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q未写库\n", n, *tagScene)
}
return
}
junk, err := g.NoiseEntities()
if err != nil {
log.Fatalf("memgc: scan: %v", err)
}
fmt.Printf("噪音实体 %d 个:\n", len(junk))
for _, e := range junk {
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
}
de, dr, err := g.PurgeNoise(!*apply)
if err != nil {
log.Fatalf("memgc: purge: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
} else {
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d未写库加 -apply 才落地)\n", de, dr)
}
if *orphans {
list, err := g.OrphanEntities()
if err != nil {
log.Fatalf("memgc: orphans: %v", err)
}
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
for _, e := range list {
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
}
n, err := g.PurgeOrphans(!*apply)
if err != nil {
log.Fatalf("memgc: purge orphans: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
} else {
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
}
}
if *apply {
fmt.Println("提示:运行中的进程会在下一个 archive 心跳Indexer.Sync重建实体名向量索引无需重启。")
}
}

View File

@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "com.example.homeagent", "bundleName": "com.example.homeagent",
"vendor": "HomeAgent", "vendor": "HomeAgent",
"versionCode": 1000000, "versionCode": 1004000,
"versionName": "1.0.0", "versionName": "1.4.0",
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。 // 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。 // 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
"icon": "$media:layered_image", "icon": "$media:layered_image",

View File

@ -32,6 +32,10 @@ export class ApiClient {
this.conn = conn; this.conn = conn;
} }
clearConnection(): void {
this.conn = null;
}
getConnection(): ConnectionConfig | null { getConnection(): ConnectionConfig | null {
return this.conn; return this.conn;
} }

View File

@ -0,0 +1,19 @@
import { bundleManager } from '@kit.AbilityKit';
/**
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
*
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
* 实测已经漂过app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
*/
export function appVersion(): string {
try {
const info: bundleManager.BundleInfo =
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
return info.versionName;
} catch (e) {
return '';
}
}

View File

@ -0,0 +1,37 @@
/**
* 附件的字节获取与解码(网络 / 沙箱 I/O
*
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
* 这两条路由在后端是 requireWeb但对 API Key 客户端同等放行,
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
*
* 从 components/Attachment.ets 抽出:缩略图与详情大图都走同一条解码路径。
*/
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { apiClient } from './ApiClient';
/** 下载并解码成 PixelMap任何一步失败都返回 undefined调用方显示占位。 */
export async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
try {
// 本地待上传的图片:直接读沙箱文件,不走网络
if (url.startsWith('file://')) {
const path: string = url.substring(7);
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const localSrc: image.ImageSource = image.createImageSource(f.fd);
const localPm: image.PixelMap = await localSrc.createPixelMap();
await localSrc.release();
fileIo.closeSync(f);
return localPm;
}
const abs: string = apiClient.absoluteUrl(url);
const resp = await apiClient.getBinary(abs, 15000);
const src: image.ImageSource = image.createImageSource(resp.data);
const pm: image.PixelMap = await src.createPixelMap();
await src.release();
return pm;
} catch (e) {
return undefined;
}
}

View File

@ -0,0 +1,103 @@
/**
* 附件的解析与格式化(纯函数,无 UI、无平台 I/O
*
* 后端 Attachment 只有四个字段type / url / size / name
* internal/plugins/webui/handler.go没有 mime、没有像素尺寸、没有本地路径。
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
*
* 从 components/Attachment.ets 抽出:附件卡与附件详情都要用这几个函数,
* 放在 common 里两边共用,也不必让 UI 文件承担这段纯逻辑。
*/
import { ChatAttachment } from '../model/Model';
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const o: Record<string, Object> = raw as Record<string, Object>;
const url: string = o['url'] as string ?? '';
if (url.length === 0) {
return undefined;
}
const t: string = o['type'] as string ?? 'file';
const a: ChatAttachment = {
type: t === 'image' ? 'image' : 'file',
url: url,
size: o['size'] as number ?? 0,
name: o['name'] as string ?? fileNameOf(url),
};
return a;
}
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
export function attachmentFromChannelOutput(
outputType: string, url: string, size: number): ChatAttachment | undefined {
if (url.length === 0) {
return undefined;
}
if (outputType !== 'image' && outputType !== 'file') {
return undefined;
}
const a: ChatAttachment = {
type: outputType,
url: url,
size: size,
name: fileNameOf(url),
};
return a;
}
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
export function fileNameOf(url: string): string {
let s: string = url;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const i: number = s.lastIndexOf('/');
const name: string = i >= 0 ? s.substring(i + 1) : s;
return name.length > 0 ? name : '附件';
}
/** 人类可读字节数,口径对齐后端 formatBytesKB 以上保留一位小数)。 */
export function formatBytes(n: number): string {
if (n <= 0) {
return '';
}
if (n < 1024) {
return n.toString() + ' B';
}
const kb: number = n / 1024;
if (kb < 1024) {
return oneDecimal(kb) + ' KB';
}
const mb: number = kb / 1024;
if (mb < 1024) {
return oneDecimal(mb) + ' MB';
}
return oneDecimal(mb / 1024) + ' GB';
}
function oneDecimal(v: number): string {
return (Math.round(v * 10) / 10).toString();
}
/** 由文件名后缀猜测类型标签。后端不返回 mime只能这样标注。 */
export function extLabel(name: string): string {
const i: number = name.lastIndexOf('.');
if (i < 0 || i === name.length - 1) {
return '未知类型';
}
return name.substring(i + 1).toUpperCase();
}
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
export function sanitize(name: string): string {
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
if (s.length === 0) {
s = 'attachment';
}
return s;
}

View File

@ -4,16 +4,71 @@ import { pasteboard } from '@kit.BasicServicesKit';
import { deviceInfo } from '@kit.BasicServicesKit'; import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit'; import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI'; import { componentSnapshot } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { camera, cameraPicker } from '@kit.CameraKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { appVersion } from './AppVersion';
// ===== 能力结果 ===== // ===== 能力结果 =====
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
export const LOCAL_DEVICE_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
'camerasue',
];
export interface CapResult { export interface CapResult {
status: string; // 'ok' | 'error' status: string; // 'ok' | 'error'
output: string; output: string;
error: string; error: string;
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
// DeviceBridge 不要再发 cmd_result否则网关会把后续分块挂在一条已完成的
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
chunked?: boolean;
} }
interface DeviceStatusPayload {
device_id: string;
status: string;
hostname: string;
platform: string;
arch: string;
uptime: number;
}
interface DeviceDetails {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
brand: string;
manufacturer: string;
model: string;
series: string;
sdk_api_version: number;
security_patch: string;
abi_list: string;
device_type: string;
}
interface DeviceInfoPayload {
device_id: string;
name: string;
kind: string;
caps: string[];
info: DeviceDetails;
}
const APP_STARTED_AT: number = Date.now();
function okResult(output: string): CapResult { function okResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' }; const r: CapResult = { status: 'ok', output: output, error: '' };
return r; return r;
@ -24,6 +79,19 @@ function errResult(errMsg: string): CapResult {
return r; return r;
} }
/**
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
* 而 import BridgeCaps两边成环。分层也更干净能力实现不碰 socket。
*/
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
// chunkedResult结果已由能力自己分块发出不再回 cmd_result。
function chunkedResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
return r;
}
// ===== screensee截取本应用当前画面前台时为整屏可见内容===== // ===== screensee截取本应用当前画面前台时为整屏可见内容=====
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root'; const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
@ -44,53 +112,215 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。 * 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
*/ */
export async function capScreensee(): Promise<CapResult> { export async function capScreensee(): Promise<CapResult> {
let full: image.PixelMap | null = null;
let packer: image.ImagePacker | null = null;
try { try {
const full: image.PixelMap = await captureScreenPixelMap(); full = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo(); const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 720; const maxW: number = 420;
let targetW: number = info.size.width; const maxH: number = 640;
let targetH: number = info.size.height; let scale: number = 1;
if (targetW > maxW) { if (info.size.width > maxW) {
targetH = Math.floor(targetH * maxW / targetW); scale = maxW / info.size.width;
targetW = maxW;
} }
let packed: ArrayBuffer; if (info.size.height * scale > maxH) {
if (targetW !== info.size.width) { scale = maxH / info.size.height;
await full.scale(targetW / info.size.width, targetH / info.size.height);
} }
const packer: image.ImagePacker = image.createImagePacker(); if (scale < 1) {
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 }; await full.scale(scale, scale);
packed = await packer.packing(full, opt); }
packer.release(); packer = image.createImagePacker();
full.release(); const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
const packed: ArrayBuffer = await packer.packing(full, opt);
const helper: util.Base64Helper = new util.Base64Helper(); const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(packed)); const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
if (b64.length > 950000) {
return errResult('当前画面数据过大,请稍后重试');
}
return okResult('data:image/jpeg;base64,' + b64); return okResult('data:image/jpeg;base64,' + b64);
} catch (e) { } catch (e) {
const msg: string = e instanceof Error ? e.message : String(e); return errResult('无法读取当前应用画面,请保持应用在前台后重试');
return errResult('screensee failed: ' + msg); } finally {
if (packer !== null) {
packer.release();
}
if (full !== null) {
full.release();
}
}
}
// ===== camerasue系统相机抓拍 =====
//
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
// CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
//
// 结果落在应用沙箱saveUri 指向 filesDir不写系统媒体库也就不需要
// READ_IMAGEVIDEO 这类受限权限。
const CAMERASUE_MAX_B64: number = 950000;
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
/**
* camerasue 实现。
*
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
* `<N秒>` = 录 N 秒视频。
*
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MBbase64 后还要再膨胀
* 1/3既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件agent 拿路径。
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
*/
export async function capCamerasue(context: common.UIAbilityContext,
rawArgs: string,
sendChunked: DataChunkSender | null): Promise<CapResult> {
const raw: string = rawArgs.trim();
let videoSeconds: number = 0;
if (raw.length > 0) {
const digits: RegExp = new RegExp('^\\d+$');
if (!digits.test(raw)) {
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
}
videoSeconds = parseInt(raw, 10);
if (videoSeconds <= 0 || videoSeconds > 300) {
return errResult('录像时长需在 1~300 秒之间');
}
}
const isVideo: boolean = videoSeconds > 0;
const ext: string = isVideo ? '.mp4' : '.jpg';
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
try {
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
const f: fileIo.File = fileIo.openSync(filePath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.closeSync(f);
} catch (e) {
return errResult('无法创建相机输出文件');
}
try {
const profile: cameraPicker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: fileUri.getUriFromPath(filePath),
};
if (isVideo) {
profile.videoDuration = videoSeconds;
}
const mediaType: cameraPicker.PickerMediaType = isVideo
? cameraPicker.PickerMediaType.VIDEO
: cameraPicker.PickerMediaType.PHOTO;
const res: cameraPicker.PickerResult =
await cameraPicker.pick(context, [mediaType], profile);
if (res.resultCode !== 0 || res.resultUri.length === 0) {
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
}
} catch (e) {
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
}
if (isVideo) {
return readAndSendVideo(filePath, sendChunked);
}
return readPhotoAsBase64(filePath);
}
/** 读回录像并以二进制分块回传网关聚合后落盘agent 拿文件路径。 */
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
if (stat.size <= 0) {
return errResult('录像文件为空,请重试');
}
if (stat.size > CAMERASUE_MAX_VIDEO) {
return errResult('录像文件过大(超过 64MB请缩短时长');
}
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
if (sendChunked === null) {
return errResult('录像回传通道未就绪,请重试');
}
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
// 分块已代表本次请求的完整结果DeviceBridge 不再回 cmd_result。
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('录像读取失败,请重试');
}
}
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
function readPhotoAsBase64(filePath: string): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
if (b64.length > CAMERASUE_MAX_B64) {
return errResult('照片数据过大,请降低分辨率后重试');
}
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('照片读取失败,请重试');
} }
} }
// ===== clipboardsee / clipboardsue ===== // ===== clipboardsee / clipboardsue =====
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
/**
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
*/
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const result: PermissionRequestResult =
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
return result.authResults.length > 0 && result.authResults[0] === 0;
} catch (e) {
return false;
}
}
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> { export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
// 说明READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予, const granted: boolean = await ensureClipboardPermission(context);
// 这里直接尝试读取;系统拒绝时回错误信息。 if (!granted) {
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
}
try { try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard(); const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData(); const has: boolean = await clip.hasData();
if (!has) { if (!has) {
const empty: CapResult = { status: 'ok', output: '', error: '' }; return okResult('');
return empty;
} }
const data: pasteboard.PasteData = await clip.getData(); const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText(); const txt: string = data.getPrimaryText();
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' }; return okResult(txt ?? '');
return out;
} catch (e) { } catch (e) {
const msg: string = e instanceof Error ? e.message : String(e); return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
} }
} }
@ -99,10 +329,9 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard(); const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await clip.setPasteData(data); await clip.setPasteData(data);
return okResult('written ' + text.length + ' chars'); return okResult('clipboard written');
} catch (e) { } catch (e) {
const msg: string = e instanceof Error ? e.message : String(e); return errResult('剪贴板写入失败,请保持应用在前台后重试');
return errResult('clipboardsue failed: ' + msg);
} }
} }
@ -112,13 +341,11 @@ class TtsSession {
private engine: textToSpeech.TextToSpeechEngine | null = null; private engine: textToSpeech.TextToSpeechEngine | null = null;
async speak(text: string): Promise<CapResult> { async speak(text: string): Promise<CapResult> {
if (text.length > 4000) {
return errResult('朗读内容过长,请缩短到 4000 字以内');
}
try { try {
if (this.engine === null) { if (this.engine === null) {
const extra: Record<string, Object> = {
'style': 'interaction-broadcast',
'locate': 'CN',
'name': 'EngineName',
};
const params: textToSpeech.CreateEngineParams = { const params: textToSpeech.CreateEngineParams = {
language: 'zh-CN', language: 'zh-CN',
person: 0, person: 0,
@ -133,8 +360,7 @@ class TtsSession {
this.engine.speak(text, sp); this.engine.speak(text, sp);
return okResult('speaking'); return okResult('speaking');
} catch (e) { } catch (e) {
const msg: string = e instanceof Error ? e.message : String(e); return errResult('语音服务暂时不可用,请稍后重试');
return errResult('speakeruse failed: ' + msg);
} }
} }
@ -156,40 +382,47 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
return ttsSession.speak(text); return ttsSession.speak(text);
} }
// ===== deviceinfo ===== export function shutdownSpeakerUse(): void {
ttsSession.shutdown();
export function capDeviceInfo(): CapResult {
const lines: string[] = [];
lines.push('brand=' + deviceInfo.brand);
lines.push('manufacturer=' + deviceInfo.manufacture);
lines.push('model=' + deviceInfo.productModel);
lines.push('series=' + deviceInfo.productSeries);
lines.push('osFullName=' + deviceInfo.osFullName);
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
lines.push('abiList=' + deviceInfo.abiList);
lines.push('deviceType=' + deviceInfo.deviceType);
return okResult(lines.join('\n'));
} }
// ===== screensue 内容解析 ===== // ===== status / deviceinfo =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload { export function capStatus(deviceId: string): CapResult {
duration: number; // 秒0 表示常驻直到用户关闭 const payload: DeviceStatusPayload = {
content: string; device_id: deviceId,
status: 'online',
hostname: 'ohos-phone',
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
};
return okResult(JSON.stringify(payload));
} }
export function parseScreensue(rawArgs: string): ScreensuePayload { export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
const p: ScreensuePayload = { duration: 5, content: '' }; const details: DeviceDetails = {
let rest: string = rawArgs.trim(); hostname: 'ohos-phone',
const tokens: string[] = rest.split(/\s+/); platform: 'OpenHarmony',
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) { arch: deviceInfo.abiList,
p.duration = parseInt(tokens[0], 10); os_release: deviceInfo.osFullName,
rest = tokens.slice(1).join(' '); version: appVersion(),
} else { cpus: 0,
rest = tokens.join(' '); brand: deviceInfo.brand,
} manufacturer: deviceInfo.manufacture,
p.content = rest.trim(); model: deviceInfo.productModel,
return p; series: deviceInfo.productSeries,
sdk_api_version: deviceInfo.sdkApiVersion,
security_patch: deviceInfo.securityPatchTag,
abi_list: deviceInfo.abiList,
device_type: deviceInfo.deviceType,
};
const payload: DeviceInfoPayload = {
device_id: deviceId,
name: deviceName,
kind: 'ohos-phone',
caps: LOCAL_DEVICE_CAPS,
info: details,
};
return okResult(JSON.stringify(payload));
} }

View File

@ -0,0 +1,173 @@
/**
* 设备桥协议:消息结构、帧构造与分块切片。
*
* 与 homed 的 remotedevice 插件对齐internal/plugins/remotedevice
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼"
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
*
* 注意DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
*/
import { CapResult } from './BridgeCaps';
import { appVersion } from './AppVersion';
// ===== 协议消息(与 remotedevice 插件对齐)=====
export interface HelloDeviceInfo {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
}
export interface HelloDevice {
device_id: string;
name: string;
kind: string;
authorized: boolean;
caps: string[];
info: HelloDeviceInfo;
}
export interface HelloMessage {
op: string;
device: HelloDevice;
}
export interface BindMessage {
op: string;
device_id: string;
token: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
status: string;
output: string;
error: string;
}
export interface DataStartMessage {
op: string;
req_id: string;
kind: string;
mime: string;
total: number;
chunk_size: number;
}
export interface DataEndMessage {
op: string;
req_id: string;
status: string;
total?: number;
error?: string;
}
export const CHUNK_SIZE: number = 8192;
// ===== 命令处理器回调 =====
// 返回 CapResult二进制大结果通过 dataHandler 分块回传。
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
// ===== 帧构造 =====
export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
caps: string[], hostname: string,
authorized: boolean): string {
const info: HelloDeviceInfo = {
hostname: hostname,
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: appVersion(),
cpus: 0,
};
const device: HelloDevice = {
device_id: deviceId,
name: name,
kind: kind,
authorized: authorized,
caps: caps,
info: info,
};
const hello: HelloMessage = { op: 'hello', device: device };
return JSON.stringify(hello);
}
export function bridgeBindFrame(deviceId: string, token: string): string {
const bind: BindMessage = {
op: 'bind',
device_id: deviceId,
token: token,
};
return JSON.stringify(bind);
}
export function bridgeResultFrame(reqId: string, status: string,
output: string, errMsg: string): string {
const result: CmdReply = {
op: 'cmd_result',
req_id: reqId,
status: status,
output: output,
error: errMsg,
};
return JSON.stringify(result);
}
export function bridgeDataStartFrame(reqId: string, kind: string, mime: string,
total: number): string {
const startMsg: DataStartMessage = {
op: 'cmd_data_start',
req_id: reqId,
kind: kind,
mime: mime,
total: total,
chunk_size: CHUNK_SIZE,
};
return JSON.stringify(startMsg);
}
export function bridgeDataEndFrame(reqId: string): string {
const endMsg: DataEndMessage = {
op: 'cmd_data_end',
req_id: reqId,
status: 'ok',
};
return JSON.stringify(endMsg);
}
export function bridgeEventFrame(deviceId: string, eventType: string, detail: string): string {
const payload: Record<string, string> = { 'detail': detail };
const msg: Record<string, Object> = {
'op': 'event',
'device_id': deviceId,
'type': eventType,
'payload': payload,
};
return JSON.stringify(msg);
}
export function bridgeStatusFrame(deviceId: string, status: string): string {
const msg: Record<string, Object> = {
'op': 'status',
'device_id': deviceId,
'status': status,
};
return JSON.stringify(msg);
}
/** 按 CHUNK_SIZE 切二进制;切片顺序即发送顺序。 */
export function bridgeChunkSlices(bytes: Uint8Array): Uint8Array[] {
const out: Uint8Array[] = [];
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
out.push(bytes.slice(off, end));
}
return out;
}

View File

@ -1,17 +1,20 @@
import { deviceBridge, CmdReply } from './DeviceBridge'; import { deviceBridge } from './DeviceBridge';
import { import {
CapResult, CapResult,
DataChunkSender,
capScreensee, capScreensee,
capCamerasue,
capClipboardSee, capClipboardSee,
capClipboardsue, capClipboardsue,
capSpeakerUse, capSpeakerUse,
capDeviceInfo, capDeviceInfo,
parseScreensue, capStatus,
ScreensuePayload,
} from './BridgeCaps'; } from './BridgeCaps';
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
import { connStore } from './ConnStore';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
// screensue 展示回调由 UI 注册Index 挂全局悬浮层) // screensue 展示回调由 UI 注册:窄屏整页,宽屏右侧内容栏。
export type ScreensueHandler = (payload: ScreensuePayload) => void; export type ScreensueHandler = (payload: ScreensuePayload) => void;
let screensueHandler: ScreensueHandler | null = null; let screensueHandler: ScreensueHandler | null = null;
@ -25,10 +28,23 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
appContext = ctx; appContext = ctx;
} }
/** 解析 homeagent-* 命令:返回能力名与参数串。 */ /** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
function splitCapability(command: string): string[] { function splitCapability(command: string): string[] {
const cmd: string = command.trim(); let start: number = 0;
const idx: number = cmd.indexOf(' '); while (start < command.length && isCommandSpace(command.charAt(start))) {
start = start + 1;
}
let cmd: string = command.substring(start);
if (cmd.startsWith('homeagent-')) {
cmd = cmd.substring('homeagent-'.length);
}
let idx: number = -1;
for (let i: number = 0; i < cmd.length; i++) {
if (isCommandSpace(cmd.charAt(i))) {
idx = i;
break;
}
}
if (idx < 0) { if (idx < 0) {
return [cmd]; return [cmd];
} }
@ -36,51 +52,84 @@ function splitCapability(command: string): string[] {
return out; return out;
} }
function isCommandSpace(ch: string): boolean {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
}
function hasArgs(args: string): boolean {
return args.trim().length > 0;
}
async function executeCommand(reqId: string, command: string): Promise<CapResult> { async function executeCommand(reqId: string, command: string): Promise<CapResult> {
const parts: string[] = splitCapability(command); const parts: string[] = splitCapability(command);
const name: string = parts[0]; const name: string = parts[0];
const args: string = parts.length > 1 ? parts[1] : ''; const args: string = parts.length > 1 ? parts[1] : '';
// screensee 截屏回传data URL 走文本结果,服务端兼容)
if (name === 'screensee') { if (name === 'screensee') {
if (hasArgs(args)) {
return errRes('screensee 不接受额外参数');
}
return capScreensee(); return capScreensee();
} }
if (name === 'screensue') { if (name === 'screensue') {
if (!hasArgs(args)) {
return errRes('screensue 需要展示内容');
}
const payload: ScreensuePayload = parseScreensue(args); const payload: ScreensuePayload = parseScreensue(args);
if (payload.content.length === 0) {
return errRes('screensue 需要展示内容');
}
if (screensueHandler !== null) { if (screensueHandler !== null) {
screensueHandler(payload); screensueHandler(payload);
return okRes('shown'); return okRes('内容已显示');
} }
return errRes('screensue: display layer not ready'); return errRes('展示界面尚未就绪,请保持应用在前台后重试');
}
if (name === 'camerasue') {
if (appContext === null) {
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
}
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
};
return capCamerasue(appContext, args, sender);
} }
if (name === 'clipboardsee') { if (name === 'clipboardsee') {
if (hasArgs(args)) {
return errRes('clipboardsee 不接受额外参数');
}
if (appContext === null) { if (appContext === null) {
return errRes('clipboardsee: app context missing'); return errRes('应用界面尚未就绪,请保持应用在前台后重试');
} }
return capClipboardSee(appContext); return capClipboardSee(appContext);
} }
if (name === 'clipboardsue') { if (name === 'clipboardsue') {
if (args.length === 0) { if (!hasArgs(args)) {
return errRes('clipboardsue: empty text'); return errRes('clipboardsue 需要写入文字');
} }
return capClipboardsue(args); return capClipboardsue(args);
} }
if (name === 'speakeruse') { if (name === 'speakeruse') {
if (args.length === 0) { if (!hasArgs(args)) {
return errRes('speakeruse: empty text'); return errRes('speakeruse 需要朗读文字');
} }
return capSpeakerUse(args); return capSpeakerUse(args);
} }
if (name === 'deviceinfo' || name === 'status') { if (name === 'status') {
return capDeviceInfo(); if (hasArgs(args)) {
return errRes('status 不接受额外参数');
}
return capStatus(connStore.getDeviceId());
} }
if (name === 'camerasue') { if (name === 'deviceinfo') {
return errRes('camerasue: camera capture not supported on this build'); if (hasArgs(args)) {
return errRes('deviceinfo 不接受额外参数');
}
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
} }
if (name === 'computeruse') { return errRes('不支持的本机能力:' + name);
return errRes('computeruse: not applicable to touch-only device');
}
return errRes('unsupported homeagent capability: ' + name);
} }
function okRes(output: string): CapResult { function okRes(output: string): CapResult {

View File

@ -0,0 +1,223 @@
/**
* 聊天页的纯格式化/判定逻辑(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出:这些函数只吃数据吐字符串/布尔,
* 抽出来后气泡、工具卡、渠道头像三个组件可以共用同一份口径。
*/
import { ChatMessage, ToolCallInfo } from '../model/Model';
/**
* 由文件名后缀推断 Content-Type。
* 后端按 multipart 部件的 Content-Type 判定 image/file
* 给错会让图片被当成普通文件(缩略图就没了)。
*/
export function mimeOf(name: string, isImage: boolean): string {
const i: number = name.lastIndexOf('.');
const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : '';
if (ext === 'png') {
return 'image/png';
}
if (ext === 'jpg' || ext === 'jpeg') {
return 'image/jpeg';
}
if (ext === 'webp') {
return 'image/webp';
}
if (ext === 'gif') {
return 'image/gif';
}
if (ext === 'bmp') {
return 'image/bmp';
}
if (ext === 'heic' || ext === 'heif') {
return 'image/heic';
}
if (isImage) {
return 'image/jpeg';
}
if (ext === 'pdf') {
return 'application/pdf';
}
if (ext === 'txt' || ext === 'log' || ext === 'md') {
return 'text/plain';
}
if (ext === 'json') {
return 'application/json';
}
return 'application/octet-stream';
}
/** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */
export function stringifyField(raw: Object | undefined): string {
if (raw === undefined || raw === null) {
return '';
}
if (typeof raw === 'string') {
return raw as string;
}
try {
return JSON.stringify(raw);
} catch (e) {
return String(raw);
}
}
/**
* ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的
* if 分支重新求值。这是 ArkUI V1 渲染模型决定的ForEach 对相同键
* 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以
* 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后
* 永远不会再次求值——气泡里的这些面板就永远不出现。
*
* 反过来content_delta 不进 structSig正文文本靠 MarkdownView
* 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。
* 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段
* 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。
*/
export function structSig(msg: ChatMessage): string {
let s: string = msg.id.toString();
const rc: string | undefined = msg.reasoningContent;
s += '_r' + (rc !== undefined ? rc.length.toString() : '0');
s += '_ro' + (msg.reasoningOpen === true ? '1' : '0');
const tcs: ToolCallInfo[] | undefined = msg.toolCalls;
if (tcs !== undefined) {
s += '_t' + tcs.length.toString();
for (let i = 0; i < tcs.length; i++) {
const tc: ToolCallInfo = tcs[i];
s += '_' + (tc.status ?? '');
s += '_' + (tc.open === true ? 'o' : 'c');
s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0');
s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0');
s += '_' + (tc.plugin ?? '');
}
} else {
s += '_t0';
}
s += '_a' + (msg.attachment !== undefined ? '1' : '0');
s += '_src' + (msg.source ?? '');
s += '_f' + (msg.isFinal === true ? '1' : '0');
s += '_s' + (msg.isStreaming === true ? '1' : '0');
return s;
}
/** 折叠时也要能看出思考在增长:显示字数 */
export function reasoningLenLabel(msg: ChatMessage): string {
const rc: string | undefined = msg.reasoningContent;
if (rc === undefined || rc.length === 0) {
return '';
}
return rc.length.toString() + ' 字';
}
/**
* 是否仍在执行。
* 判据是 status 而不是 result后端 status=ok 的工具也可能返回空串,
* 用 result 判断会让这类调用永远显示"调用中"。
*/
export function tcRunning(tc: ToolCallInfo): boolean {
const s: string | undefined = tc.status;
return s === undefined || s.length === 0 || s === 'running';
}
export function tcError(tc: ToolCallInfo): boolean {
return tc.status === 'denied' || tc.status === 'error';
}
/** 工具卡左侧色条accent 由调用方从 palette 取) */
export function tcLeftColor(tc: ToolCallInfo, accent: string): string {
if (tcError(tc)) {
return '#DB3694';
}
if (tcRunning(tc)) {
return accent;
}
return 'rgba(23, 169, 100, 0.8)';
}
/** 工具卡状态图标颜色 */
export function tcIcoColor(tc: ToolCallInfo, accent: string): string {
if (tcError(tc)) {
return '#DB3694';
}
if (tcRunning(tc)) {
return accent;
}
return 'rgba(23, 169, 100, 0.9)';
}
export function tcStateLabel(tc: ToolCallInfo): string {
if (tc.status === 'denied') {
return '已拒绝';
}
if (tcRunning(tc)) {
return '调用中';
}
return '完成';
}
export function tcStateColor(tc: ToolCallInfo): string {
if (tc.status === 'denied') {
return '#FF9EC6';
}
if (tcRunning(tc)) {
return '#A3B8FF';
}
return '#6EE7A8';
}
/**
* 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。
* 纯文本 78% 好看;但工具卡/思考卡是"面板"78% 会把里面的状态文字和
* 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。
*/
export function bubbleMaxWidth(msg: ChatMessage): string {
const hasPanels: boolean =
(msg.toolCalls !== undefined && msg.toolCalls.length > 0) ||
(msg.reasoningContent !== undefined && msg.reasoningContent.length > 0);
return hasPanels ? '92%' : '78%';
}
/**
* 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项:
* 本机自己发的消息在后端会被写成 webui/<device_id>,那仍然是"我发的"
* 不能当成渠道消息挂上别人的头像。
*/
export function isChannelMsg(source: string, deviceId: string): boolean {
if (source.length === 0 || source === 'webui') {
return false;
}
return source !== 'webui/' + deviceId;
}
/** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */
export function isSelfMsg(role: string, channel: boolean): boolean {
return role === 'user' && !channel;
}
/** 渠道名展示webui/<id> 只显示 <id>,其余原样。 */
export function chanLabel(src: string): string {
if (src.startsWith('webui/')) {
return src.substring(6);
}
return src;
}
/** 渠道首字母(大写),用作头像文字。 */
export function chanLetter(src: string): string {
const label: string = chanLabel(src);
if (label.length === 0) {
return '?';
}
return label.substring(0, 1).toUpperCase();
}
/** 由渠道名散列出稳定色,避免每次渲染换色。 */
export function chanColor(src: string): string {
const label: string = chanLabel(src);
let h: number = 0;
for (let i = 0; i < label.length; i++) {
h = (h * 31 + label.charCodeAt(i)) % 360;
}
return 'hsl(' + h.toString() + ', 52%, 46%)';
}

View File

@ -0,0 +1,119 @@
/**
* /chat/history 响应解析(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径,
* 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { parseAttachment } from './AttachmentMeta';
import { stringifyField } from './ChatFormat';
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
export interface ParsedHistory {
msgs: ChatMessage[];
/** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
offset: number;
/** 服务端是否还有更早的历史 */
hasMore: boolean;
/**
* 服务端下发的增量游标(响应里的 last_seq
*
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
* jianf 说的“暴露数据查询 api前端轮询后 patch 视图”那条路)。
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
*/
lastSeq: number;
}
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
export function parseHistoryPayload(
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
const rawList: Object | undefined = obj['messages'] as Object | undefined;
if (rawList === undefined || rawList === null) {
return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 };
}
const arr: Object[] = rawList as Object[];
const msgs: ChatMessage[] = [];
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const role: string = item['role'] as string ?? '';
const content: string = item['content'] as string ?? '';
const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
// 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
if (role.length === 0 || (content.length === 0 && att === undefined)) {
continue;
}
const msg: ChatMessage = {
id: alloc(),
role: role,
content: content,
isFinal: true,
};
// seq服务端单调递增序号增量游标与 keyed 对账的定位符。
// 缺失(旧后端/本地乐观消息)时保持 undefined不编造。
const seqVal: Object | undefined = item['seq'];
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
msg.seq = seqVal as number;
}
if (att !== undefined) {
msg.attachment = att;
}
// 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls
// 这里必须还原,否则刷新后思考与工具卡就凭空消失。
const rc: string = item['reasoning_content'] as string ?? '';
if (rc.length > 0) {
msg.reasoningContent = rc;
}
const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']);
if (tcs !== undefined) {
msg.toolCalls = tcs;
}
// 渠道/设备来源:后端 ChatMsg.source用于区分 channel_output 等非 webui 消息
const src: string = item['source'] as string ?? '';
if (src.length > 0) {
msg.source = src;
}
msgs.push(msg);
}
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
const hasMore: boolean = obj['has_more'] === true;
// last_seq增量游标。缺失时回退到本页最大 seq保证游标不会倒退。
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
for (let i = 0; i < msgs.length; i++) {
const sq: number | undefined = msgs[i].seq;
if (sq !== undefined && sq > lastSeq) {
lastSeq = sq;
}
}
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
}
/** 后端 tool_calls 条目带 tool 和 name 两份args/result 可能是对象也可能是字符串。 */
export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const arr: Object[] = raw as Object[];
if (arr.length === 0) {
return undefined;
}
const tcs: ToolCallInfo[] = [];
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
if (name.length === 0) {
continue;
}
const tc: ToolCallInfo = {
name: name,
args: stringifyField(item['args']),
result: stringifyField(item['result']),
status: item['status'] as string ?? undefined,
plugin: item['plugin'] as string ?? undefined,
};
tcs.push(tc);
}
return tcs.length > 0 ? tcs : undefined;
}

View File

@ -0,0 +1,206 @@
/**
* 发送 / 中断(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出POST /chat 与 POST /chat/file 的请求体、
* 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。
*/
import { ChatMessage, ChatAttachment } from '../model/Model';
import { apiClient } from './ApiClient';
import { connStore } from './ConnStore';
import { userMessage, isTimeout } from './UserError';
import { chatStore } from './ChatStore';
import { parseAttachment } from './AttachmentMeta';
import { http } from '@kit.NetworkKit';
interface SendChatBody {
message: string;
client_msg_id: string;
/** 非空时后端编码 source = "webui/<device_id>"agent 可见来源设备 */
device_id?: string;
device_name?: string;
}
/** POST /chat/interrupt 的请求体。 */
interface InterruptBody {
/** true = 停止(立即结束当前推理 + 短路已排队消息false/省略 = 普通中断。 */
stop: boolean;
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
message?: string;
}
/**
* 纯文本发送POST /chat。
* 带附件的情况走 sendChatFile后端收下附件后自己写会话并触发 agent
*/
export async function sendChatText(text: string): Promise<void> {
const trimmed: string = text.trim();
if (chatStore.isLoading()) {
return;
}
const cur = connStore.getCurrentConnection();
if (cur === null) {
return;
}
if (trimmed.length === 0) {
return;
}
// 重置 SSE 标记
chatStore.setSseActive(false);
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed };
chatStore.pushNew(userMsg);
chatStore.setLoading(true);
chatStore.setStage('等待 AI 回复...');
chatStore.forceRefresh();
chatStore.requestScroll();
const bodyObj: SendChatBody = {
message: trimmed,
client_msg_id: Date.now().toString(36),
// 必须带设备身份:后端没有 device_id 就把来源编码成 webui
// agent 会以为消息来自网页端。device_id 非空时后端编码
// source = "webui/<device_id>" 并注入设备上下文。
device_id: connStore.ensureDeviceId(),
device_name: connStore.getDeviceName(),
};
// 如果 SSE 已连接POST 作为触发器(响应由 SSE 推送渲染);
// 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
try {
const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
// SSE 已经处理了响应,跳过 POST 消息创建
if (chatStore.sseActive()) {
chatStore.setLoading(false);
chatStore.setStage('');
chatStore.forceRefresh();
chatStore.requestScroll();
return;
}
const parsed: Record<string, string> = JSON.parse(resp.body) as Record<string, string>;
const respText: string = parsed['response'] ?? '(无响应)';
const reasoning: string = parsed['reasoning_content'] ?? '';
const last: ChatMessage | null = chatStore.lastMessage();
if (last !== null && last.role === 'assistant' && !last.isFinal) {
last.content = respText;
last.isFinal = true;
last.isStreaming = false;
if (reasoning.length > 0 && last.reasoningContent === undefined) {
last.reasoningContent = reasoning;
}
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
// 已有最终消息,合并(不应发生,但防御性处理)
if (respText.length > last.content.length) {
last.content = respText;
}
} else {
const msg: ChatMessage = {
id: chatStore.allocId(),
role: 'assistant',
content: respText,
isFinal: true,
};
if (reasoning.length > 0) {
msg.reasoningContent = reasoning;
}
chatStore.pushNew(msg);
}
chatStore.setLoading(false);
chatStore.setStage('');
chatStore.forceRefresh();
chatStore.requestScroll();
} catch (e) {
// 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
// 原始错误只进 hilog之前把 e.message 拼进 chatStage 会把
// "Failed to connect to the server."、内网地址直接摆到聊天流里)。
if (isTimeout(e)) {
chatStore.setStage('请求已发送,等待回复...');
} else {
chatStore.setStage(userMessage('chat.send', e));
}
chatStore.forceRefresh();
chatStore.requestScroll();
}
}
/**
* 带附件发送POST /chat/filemultipart字段与 WebGUI 一致。
* 后端收下后自身会把用户消息与附件写进会话并触发 agent
* 回复照常从 SSE 过来,所以这里不再走 /chat。
*/
export async function sendChatFile(text: string, path: string, name: string,
size: number, isImage: boolean, mime: string): Promise<void> {
if (connStore.getCurrentConnection() === null) {
return;
}
const att: ChatAttachment = {
type: isImage ? 'image' : 'file',
// 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
url: 'file://' + path,
size: size,
name: name,
};
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text };
userMsg.attachment = att;
chatStore.pushNew(userMsg);
chatStore.setLoading(true);
chatStore.setStage('正在上传附件...');
chatStore.setSseActive(false);
chatStore.forceRefresh();
chatStore.requestScroll();
const parts: http.MultiFormData[] = [
{ name: 'file', contentType: mime, remoteFileName: name, filePath: path },
{ name: 'message', contentType: 'text/plain', data: text },
{ name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
{ name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
{ name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
];
try {
const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
if (uploaded !== undefined) {
userMsg.attachment = uploaded;
}
chatStore.setStage('等待 AI 回复...');
} catch (e) {
chatStore.setStage(userMessage('chat.upload', e));
chatStore.setLoading(false);
}
chatStore.forceRefresh();
chatStore.requestScroll();
}
/**
* 停止当前生成(停止按钮)。
*
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
* - stop:true无 message= ①立即结束当前 LLM 推理(不重试);
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
*
* 为什么必须带 stop此前这里 POST 的是 null空 body后端把空内容当成
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
*/
export async function interruptChat(): Promise<void> {
if (connStore.getCurrentConnection() === null) {
return;
}
try {
const body: InterruptBody = { stop: true };
await apiClient.post('/chat/interrupt', body);
} catch (e) {
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
// 否则按钮会一直停在“停止”态,用户以为没生效。
}
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
// 若后端稍后真的推来终态SSE 处理器会再刷一次(幂等)。
chatStore.setLoading(false);
chatStore.setStage('已停止');
chatStore.forceRefresh();
}

View File

@ -0,0 +1,196 @@
/**
* SSE 事件 → 聊天流状态的翻译层(无 UI 依赖)。
*
* 从 pages/ChatPage.ets 抽出。这里只认「事件帧」与「往状态里写什么」,
* 具体状态由 ChatStreamSink 提供 —— 这样本文件不必 import ChatStore
* 两边不会形成 ArkTS 里被拒绝的模块循环依赖。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { attachmentFromChannelOutput } from './AttachmentMeta';
import { SseEvent } from './SseClient';
import { stringifyField } from './ChatFormat';
import { stageTrail } from './StageTrail';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */
export interface ChatStreamSink {
allocId(): number;
lastMessage(): ChatMessage | null;
/** 取/建"未定稿的助手消息" */
ensureAssistant(): ChatMessage;
/** 取/建同名未完成工具卡 */
ensureToolCall(name: string): ToolCallInfo;
/** 追加一条新消息并播入场动画 */
pushNew(msg: ChatMessage): void;
setLoading(v: boolean): void;
setStage(s: string): void;
setSseActive(v: boolean): void;
/** 防抖刷新(合并高频 delta */
refresh(): void;
/** 请求滚到底 */
requestScroll(): void;
/** sync_required补拉历史 */
reloadHistory(): void;
}
export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void {
try {
// 服务端 data 字段是完整 sdk.Event{type, source, payload, timestamp}
// 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。
const frame: Record<string, Object> = JSON.parse(ev.data) as Record<string, Object>;
const inner: Object | undefined = frame['payload'];
const payload: Record<string, Object> =
inner !== undefined && inner !== null ? inner as Record<string, Object> : frame;
const frameType: string = frame['type'] as string ?? '';
const type: string = ev.event.length > 0 ? ev.event : frameType;
// 诊断只记事件类型(内容可能含隐私,不落盘)
hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type);
if (type === 'agent_output') {
const content: string = payload['content'] as string ?? '';
// channel_output 携带图片/文件url/size/output_type 三个字段在 payload 顶层,
// 它是一条独立的附件消息,不能合并进上一条文本气泡。
const kind: string = payload['kind'] as string ?? '';
if (kind === 'channel_output') {
const att: ChatAttachment | undefined = attachmentFromChannelOutput(
payload['output_type'] as string ?? '',
payload['url'] as string ?? '',
payload['size'] as number ?? 0);
if (att !== undefined) {
const amsg: ChatMessage = {
id: sink.allocId(),
role: 'assistant',
content: content,
isFinal: true,
source: 'channel',
attachment: att,
};
sink.pushNew(amsg);
sink.setLoading(false);
sink.setStage('');
sink.setSseActive(false);
sink.refresh();
sink.requestScroll();
return;
}
}
const last: ChatMessage | null = sink.lastMessage();
if (last !== null && last.role === 'assistant' && !last.isFinal) {
last.content = content;
last.isFinal = true;
last.isStreaming = false;
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
// POST 已经创建了最终消息,仅合并内容(如果有增量)
if (content.length > last.content.length) {
last.content = content;
}
} else {
const msg: ChatMessage = {
id: sink.allocId(),
role: 'assistant',
content: content,
isFinal: true,
};
sink.pushNew(msg);
}
sink.setLoading(false);
sink.setStage('');
sink.setSseActive(false);
sink.refresh();
sink.requestScroll();
} else if (type === 'reasoning') {
const rc: string = payload['content'] as string ?? '';
if (rc.length > 0) {
sink.setStage('AI 思考中...');
// 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
// 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。
const last: ChatMessage = sink.ensureAssistant();
last.reasoningContent = rc;
sink.refresh();
sink.requestScroll();
}
} else if (type === 'sync_required') {
// 断线重连时服务端要求补拉历史ring 里没有可重放的聚合事件)
sink.reloadHistory();
} else if (type === 'agent_error') {
// 后端错误一律转人话,技术细节不上 UI
sink.setLoading(false);
sink.setStage('本轮处理失败,请重试');
sink.refresh();
} else if (type === 'content_delta') {
const delta: string = payload['content'] as string ?? '';
if (delta.length > 0) {
sink.setSseActive(true);
const last: ChatMessage = sink.ensureAssistant();
last.content += delta;
sink.refresh();
sink.requestScroll();
}
} else if (type === 'reasoning_delta') {
const delta: string = payload['content'] as string ?? '';
if (delta.length > 0) {
sink.setSseActive(true);
sink.setStage('AI 思考中...');
const last: ChatMessage = sink.ensureAssistant();
if (last.reasoningContent === undefined) {
last.reasoningContent = '';
}
last.reasoningContent += delta;
sink.refresh();
}
} else if (type === 'tool_call') {
const toolName: string = payload['tool'] as string ?? '';
const toolStatus: string = payload['status'] as string ?? '';
const toolPlugin: string = payload['plugin'] as string ?? '';
if (toolName.length > 0) {
sink.setStage('工具调用: ' + toolName);
const target: ToolCallInfo = sink.ensureToolCall(toolName);
if (toolPlugin.length > 0) {
target.plugin = toolPlugin;
}
const argsText: string = stringifyField(payload['args']);
if (argsText.length > 0) {
target.args = argsText;
}
if (toolStatus.length > 0) {
// 后端只在工具执行【结束】时发 tool_callstatus=ok/denied/interrupted
// 所以拿到 status 就意味着这次调用已收尾result 一并落卡。
target.status = toolStatus;
target.result = stringifyField(payload['result']);
} else {
target.status = 'running';
}
sink.refresh();
sink.requestScroll();
}
} else if (type === 'stage') {
const phase: string = payload['phase'] as string ?? '';
const channel: string = payload['channel'] as string ?? '';
const stageTool: string = payload['tool'] as string ?? '';
if (channel !== '_consolidation_') {
// 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。
// 两者是独立消费者,轨迹不依赖任何聊天状态。
stageTrail.onStage(phase, stageTool);
if (phase === 'pre_action') {
sink.setStage('AI 思考中...');
} else if (phase === 'before_toolcall') {
sink.setStage('工具调用: ' + stageTool);
// 关键tool_call 事件只在执行【结束】后才发,所以"调用中"这一态
// 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。
if (stageTool.length > 0) {
const tc: ToolCallInfo = sink.ensureToolCall(stageTool);
if (tc.status === undefined) {
tc.status = 'running';
}
}
} else if (phase === 'before_output') {
sink.setStage('生成回复中...');
}
sink.refresh();
}
}
} catch (e) {
// ignore parse errors
}
}

View File

@ -0,0 +1,602 @@
/**
* 聊天流状态源(单例)。
*
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
*
* 为什么数组不进 AppStorageStatusStore 已经踩过一次 —— 数组同步语义不可靠。
* 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播,
* 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。
*
* 订阅组件的接法(见 components/ChatStream.ets
* @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0;
* onRev(): void { this.messages = chatStore.messages(); }
* ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前
* this.messages = this.messages.slice())的渲染语义完全一致。
*
* SSE 事件的翻译在 common/ChatSse.ets本类实现它的 ChatStreamSink 接口,
* 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { SseClient, SseEvent } from './SseClient';
import { connStore } from './ConnStore';
import { apiClient } from './ApiClient';
import { CHAT_PAGE_SIZE } from './Constants';
import { ParsedHistory, parseHistoryPayload } from './ChatHistory';
import { applyChatSse, ChatStreamSink } from './ChatSse';
// ===== AppStorage 键:页面/聊天流/输入区共用 =====
export const K_CHAT_REV: string = 'chatRev';
export const K_CHAT_SCROLL_REV: string = 'chatScrollRev';
export const K_CHAT_LOADING: string = 'chatBusy';
export const K_CHAT_STAGE: string = 'chatStageText';
export const K_CHAT_CONNECTED: string = 'chatSseUp';
const SSE_RECONNECT_MS: number = 5000;
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致3s。 */
const CHAT_POLL_MS: number = 3000;
/**
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
* applyServerMessages 同口径)。
*
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
* 其中一条误判成重复而丢弃“App 发出的消息不显示”就是这个表现)。
* seq 是服务端分配的唯一序号,才是可靠的定位符。
*
* 规则:
* - 服务端消息带 seq本地已有同 seq → 原地更新(工具卡/最终文本是
* 原地改的,不产生新 seq只靠 after 拿不到,必须靠尾部探测更新);
* 本地没有 → 追加。
* - 服务端消息无 seq旧后端退化为「本地末尾同角色同内容则认领」。
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
*
* tailOnly只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
* 避免把历史中间的消息插进来造成顺序错乱。
*/
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
tailOnly: boolean, alloc: () => number): ChatMessage[] {
const out: ChatMessage[] = local.slice();
for (let i = 0; i < incoming.length; i++) {
const sm: ChatMessage = incoming[i];
const sq: number | undefined = sm.seq;
let found: number = -1;
if (sq !== undefined) {
// 从尾部往前找:新消息总在尾部,省掉全表扫描
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
if (out[j].seq === sq) {
found = j;
break;
}
}
}
if (found >= 0) {
// 原地更新:保留本地 id组件按 id 复用,不重建气泡),
// 只覆盖服务端权威字段。
const prev: ChatMessage = out[found];
if (prev.content !== sm.content) {
prev.content = sm.content;
}
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
prev.reasoningContent = sm.reasoningContent;
}
if (sm.toolCalls !== undefined) {
prev.toolCalls = sm.toolCalls;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
if (sm.source !== undefined) {
prev.source = sm.source;
}
prev.isFinal = true;
prev.isStreaming = false;
continue;
}
if (tailOnly) {
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
let maxLocalSeq: number = 0;
for (let j = 0; j < out.length; j++) {
const ls: number | undefined = out[j].seq;
if (ls !== undefined && ls > maxLocalSeq) {
maxLocalSeq = ls;
}
}
if (sq !== undefined && sq > maxLocalSeq) {
sm.id = alloc();
out.push(sm);
}
continue;
}
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
if (sq !== undefined) {
let claimed: number = -1;
for (let j = out.length - 1; j >= 0; j--) {
const lm: ChatMessage = out[j];
if (lm.seq !== undefined) {
break;
}
if (lm.role === sm.role) {
claimed = j;
break;
}
}
if (claimed >= 0) {
const prev: ChatMessage = out[claimed];
prev.seq = sq;
prev.isFinal = true;
prev.isStreaming = false;
if (sm.source !== undefined) {
prev.source = sm.source;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
continue;
}
}
sm.id = alloc();
out.push(sm);
}
return out;
}
class ChatStore implements ChatStreamSink {
private msgs: ChatMessage[] = [];
private nextId: number = 1;
private refreshTimer: number = -1;
private reconnectTimer: number = -1;
/** 分页历史:当前已加载消息在服务端全量中的起始下标 */
private offset: number = 0;
/** 是否还有更早历史可向上加载 */
private hasEarlier: boolean = false;
private loadingOlder: boolean = false;
/** 正在为新消息播入场动画的 id */
private newIds: number[] = [];
// SSE 正在为当前轮次推送内容时置 true阻止 POST 响应重复创建消息
private sseActiveForTurn: boolean = false;
/** 增量游标:本地已知的最大服务端 seq对应 WebUI 的 state.chatLastSeq */
private lastSeq: number = 0;
/** 增量轮询中进行中,避免重入 */
private polling: boolean = false;
private pollTimer: number = -1;
private sse: SseClient = new SseClient();
init(): void {
AppStorage.setOrCreate<boolean>(K_CHAT_LOADING, false);
AppStorage.setOrCreate<string>(K_CHAT_STAGE, '');
AppStorage.setOrCreate<boolean>(K_CHAT_CONNECTED, false);
AppStorage.setOrCreate<number>(K_CHAT_REV, 0);
AppStorage.setOrCreate<number>(K_CHAT_SCROLL_REV, 0);
}
// ===================== 读取 =====================
messages(): ChatMessage[] {
return this.msgs;
}
findMessage(id: number): ChatMessage | undefined {
for (let i = 0; i < this.msgs.length; i++) {
if (this.msgs[i].id === id) {
return this.msgs[i];
}
}
return undefined;
}
lastMessage(): ChatMessage | null {
if (this.msgs.length === 0) {
return null;
}
return this.msgs[this.msgs.length - 1];
}
isFresh(id: number): boolean {
return this.newIds.indexOf(id) >= 0;
}
hasMore(): boolean {
return this.hasEarlier;
}
isFetchingOlder(): boolean {
return this.loadingOlder;
}
isLoading(): boolean {
return AppStorage.get<boolean>(K_CHAT_LOADING) ?? false;
}
setLoading(v: boolean): void {
AppStorage.set<boolean>(K_CHAT_LOADING, v);
}
setStage(s: string): void {
AppStorage.set<string>(K_CHAT_STAGE, s);
}
setConnected(v: boolean): void {
AppStorage.set<boolean>(K_CHAT_CONNECTED, v);
}
sseActive(): boolean {
return this.sseActiveForTurn;
}
setSseActive(v: boolean): void {
this.sseActiveForTurn = v;
}
/** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
latestAttachment(): ChatAttachment | undefined {
for (let i = this.msgs.length - 1; i >= 0; i--) {
const a: ChatAttachment | undefined = this.msgs[i].attachment;
if (a !== undefined) {
return a;
}
}
return undefined;
}
// ===================== 列表变更 =====================
allocId(): number {
return this.nextId++;
}
/** 追加一条消息并播入场动画 */
pushNew(msg: ChatMessage): void {
this.msgs.push(msg);
this.markNew(msg.id);
}
/** 标记新消息,触发入场动画 */
markNew(msgId: number): void {
const arr: number[] = this.newIds.slice();
arr.push(msgId);
this.newIds = arr;
// 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的
// refresh()50ms 防抖后换新数组引用)那一次一起触发。
setTimeout(() => {
const idx: number = this.newIds.indexOf(msgId);
if (idx >= 0) {
const updated: number[] = this.newIds.slice();
updated.splice(idx, 1);
this.newIds = updated;
this.forceRefresh();
}
}, 250);
}
/**
* 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
* 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
*/
ensureToolCall(name: string): ToolCallInfo {
const last: ChatMessage = this.ensureAssistant();
if (last.toolCalls === undefined) {
last.toolCalls = [];
}
for (let i = 0; i < last.toolCalls.length; i++) {
const t: ToolCallInfo = last.toolCalls[i];
// 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
// 已完成的那张不能被后来的调用覆盖。
const st: string | undefined = t.status;
if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) {
return t;
}
}
const created: ToolCallInfo = { name: name, args: '' };
last.toolCalls.push(created);
return created;
}
/**
* 保证存在一条"未定稿的助手消息",返回它。
* 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
* 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。
*/
ensureAssistant(): ChatMessage {
const last: ChatMessage | null = this.lastMessage();
if (last !== null && last.role === 'assistant' && last.isFinal !== true) {
return last;
}
const msg: ChatMessage = {
id: this.allocId(),
role: 'assistant',
content: '',
isStreaming: true,
isFinal: false,
};
this.pushNew(msg);
return msg;
}
/** 流式/高频变更的通知信号:让 ChatStream 滚到底 */
requestScroll(): void {
const cur: number = AppStorage.get<number>(K_CHAT_SCROLL_REV) ?? 0;
AppStorage.set<number>(K_CHAT_SCROLL_REV, cur + 1);
}
/** 防抖刷新:合并高频 SSE delta最多 ~20fps */
refresh(): void {
if (this.refreshTimer >= 0) {
return;
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = -1;
this.msgs = this.msgs.slice();
this.bump();
}, 50);
}
/** 强制立即刷新(用于状态切换等需要即时响应的场景) */
forceRefresh(): void {
if (this.refreshTimer >= 0) {
clearTimeout(this.refreshTimer);
this.refreshTimer = -1;
}
this.msgs = this.msgs.slice();
this.bump();
}
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
bump(): void {
const cur: number = AppStorage.get<number>(K_CHAT_REV) ?? 0;
AppStorage.set<number>(K_CHAT_REV, cur + 1);
}
cancelRefresh(): void {
if (this.refreshTimer >= 0) {
clearTimeout(this.refreshTimer);
this.refreshTimer = -1;
}
}
// ===================== 折叠开关 =====================
reasoningOpen(msgId: number): boolean {
const m: ChatMessage | undefined = this.findMessage(msgId);
return m !== undefined && m.reasoningOpen === true;
}
setReasoningOpen(msgId: number, open: boolean): void {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m !== undefined) {
m.reasoningOpen = open;
}
}
toolOpen(msgId: number, index: number): boolean {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) {
return false;
}
return m.toolCalls[index].open === true;
}
setToolOpen(msgId: number, index: number, open: boolean): void {
const m: ChatMessage | undefined = this.findMessage(msgId);
if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) {
m.toolCalls[index].open = open;
}
}
// ===================== 历史 =====================
private parseHistory(obj: Record<string, Object>): ParsedHistory {
return parseHistoryPayload(obj, () => this.allocId());
}
async loadHistory(): Promise<void> {
try {
// 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
if (this.msgs.length === 0) {
this.msgs = parsed.msgs;
} else {
// 与本地未回显的消息按 seq 对账,而不是整表替换。
//
// 为什么sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
// (“客户端侧发出的消息不显示”)。
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
}
this.offset = parsed.offset;
this.hasEarlier = parsed.hasMore;
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
this.forceRefresh();
this.requestScroll();
this.startPolling();
} catch (e) {
// ignore history load failure
}
}
/**
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
*
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
* after=<seq> 游标commit 9711177WebUI 前端据此 3s 轮询增量并 patch
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
* 永远进不来**(页面不会加载新的聊天信息)。
*
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
* 不会产生新 seq单靠 after 拿不到这些更新。
*/
async pollIncremental(): Promise<void> {
if (this.polling) {
return;
}
this.polling = true;
try {
if (this.lastSeq <= 0) {
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
this.polling = false;
await this.loadHistory();
return;
}
const resp = await apiClient.getWithTimeout(
'/chat/history?after=' + this.lastSeq, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
let changed: boolean = false;
if (parsed.msgs.length > 0) {
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
changed = true;
}
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
try {
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
const tail: ParsedHistory = this.parseHistory(tailObj);
if (tail.msgs.length > 0) {
const before: number = this.msgs.length;
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
() => this.allocId());
if (this.msgs.length !== before) {
changed = true;
}
}
} catch (e) {
// 尾部探测失败不影响增量结果
}
if (changed) {
this.forceRefresh();
}
} catch (e) {
// 轮询失败静默下一拍会重试SSE 仍在负责流式渲染)
} finally {
this.polling = false;
}
}
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
startPolling(): void {
if (this.pollTimer >= 0) {
return;
}
this.pollTimer = setInterval(() => {
this.pollIncremental();
}, CHAT_POLL_MS);
}
stopPolling(): void {
if (this.pollTimer >= 0) {
clearInterval(this.pollTimer);
this.pollTimer = -1;
}
}
/**
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
* 触顶yOffset 接近 0且有更早历史时由 onDidScroll 触发。
*/
async loadOlder(): Promise<void> {
if (this.loadingOlder || !this.hasEarlier) {
return;
}
this.loadingOlder = true;
try {
const before: number = this.offset;
if (before <= 0) {
this.hasEarlier = false;
return;
}
const resp = await apiClient.getWithTimeout(
'/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
if (parsed.msgs.length === 0) {
this.hasEarlier = false;
return;
}
// 前置插入新页更早的在前追加到当前列表头部id 用新分配的避免与新消息撞号
this.msgs = parsed.msgs.concat(this.msgs);
this.offset = parsed.offset;
this.hasEarlier = parsed.hasMore;
this.bump();
} catch (e) {
// 失败保留 hasEarlier允许下次滚动重试
} finally {
this.loadingOlder = false;
}
}
/** sync_required断线重连后服务端要求补拉历史 */
reloadHistory(): void {
this.loadHistory();
}
// ===================== SSE 连接 =====================
connect(): void {
const cur = connStore.getCurrentConnection();
if (cur === null) {
return;
}
// 增量轮询与 SSE 同时拉起SSE 负责 token 级流式观感,
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
this.startPolling();
this.sse.close();
this.sse.connect(cur, '/chat/events',
(ev: SseEvent) => {
applyChatSse(ev, this);
},
() => {
this.setConnected(false);
this.scheduleReconnect();
},
() => {
this.setConnected(true);
}).catch(() => {
this.setConnected(false);
this.scheduleReconnect();
});
}
scheduleReconnect(): void {
if (this.reconnectTimer >= 0) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = -1;
this.connect();
}, SSE_RECONNECT_MS);
}
cancelReconnect(): void {
if (this.reconnectTimer >= 0) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = -1;
}
}
/** 页面消失:断线、停表,避免后台空转 */
disconnect(): void {
this.cancelReconnect();
this.stopPolling();
this.sse.close();
this.cancelRefresh();
}
}
export const chatStore: ChatStore = new ChatStore();

View File

@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */ /** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40; export const CHAT_PAGE_SIZE: number = 40;
// ===== AppStorage 跨页面信号键 =====
//
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
// 中间还隔着 Index逐层传回调会把两个无关页面耦在一起。
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
export const K_HAS_CONN: string = 'hasConn';
/** 外部请求切换主 Tab-1 = 无请求),由 Index 监听 */
export const K_REQUESTED_TAB: string = 'requestedTab';
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
// ===== sakura / frost palette (style.css :root) ===== // ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)'; export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)'; export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';

View File

@ -1,75 +1,8 @@
import { webSocket } from '@kit.NetworkKit'; import { webSocket } from '@kit.NetworkKit';
import { DeviceInfo } from '../model/Model';
import { CapResult } from './BridgeCaps'; import { CapResult } from './BridgeCaps';
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
// ===== 协议消息(与 remotedevice 插件对齐)===== bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
bridgeChunkSlices } from './BridgeProtocol';
interface HelloDeviceInfo {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
}
interface HelloDevice {
device_id: string;
name: string;
kind: string;
authorized: boolean;
caps: string[];
info: HelloDeviceInfo;
}
interface HelloMessage {
op: string;
device: HelloDevice;
}
interface BindMessage {
op: string;
device_id: string;
token: string;
}
interface CmdMessage {
op: string;
req_id: string;
command: string;
cmd_type: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
status: string;
output: string;
error: string;
}
interface DataStartMessage {
op: string;
req_id: string;
kind: string;
mime: string;
total: number;
chunk_size: number;
}
interface DataEndMessage {
op: string;
req_id: string;
status: string;
total?: number;
error?: string;
}
const CHUNK_SIZE: number = 8192;
// ===== 命令处理器回调 =====
// 返回 CapResult二进制大结果通过 dataHandler 分块回传。
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
export class DeviceBridgeClient { export class DeviceBridgeClient {
private ws: webSocket.WebSocket = webSocket.createWebSocket(); private ws: webSocket.WebSocket = webSocket.createWebSocket();
@ -81,14 +14,15 @@ export class DeviceBridgeClient {
private caps: string[] = []; private caps: string[] = [];
private hostname: string = 'ohos'; private hostname: string = 'ohos';
private connected: boolean = false; private connected: boolean = false;
private everConnected: boolean = false; private bound: boolean = false;
private manualClose: boolean = false; private manualClose: boolean = false;
private reconnectTimer: number = -1; private reconnectTimer: number = -1;
private connectionGeneration: number = 0;
private cmdHandler: BridgeCmdHandler | null = null; private cmdHandler: BridgeCmdHandler | null = null;
private onStateChange: ((open: boolean) => void) | null = null; private onStateChange: ((open: boolean) => void) | null = null;
isConnected(): boolean { isConnected(): boolean {
return this.connected; return this.connected && this.bound;
} }
getDeviceId(): string { getDeviceId(): string {
@ -117,37 +51,40 @@ export class DeviceBridgeClient {
} }
private async openAndRegister(authorized: boolean): Promise<void> { private async openAndRegister(authorized: boolean): Promise<void> {
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
try { try {
this.ws.off('open'); this.ws.off('open');
this.ws.off('message'); this.ws.off('message');
this.ws.off('close'); this.ws.off('close');
this.ws.off('error'); this.ws.off('error');
this.ws.close().catch(() => {
// ignore stale socket close failure
});
} catch (e) { } catch (e) {
// ignore // ignore stale socket cleanup failure
} }
this.ws = webSocket.createWebSocket(); this.connectionGeneration = this.connectionGeneration + 1;
this.bindWsEvents(authorized); const generation: number = this.connectionGeneration;
// 鉴权必须走请求头,不能拼 ?token= const socket: webSocket.WebSocket = webSocket.createWebSocket();
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里, this.ws = socket;
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer this.connected = false;
// 查询参数一律视为未授权 → 握手被 401 顶掉, this.bound = false;
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。 this.bindWsEvents(socket, authorized, generation);
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token
// 如果我们再带 ?token=<webui apiKey>remotedevice 的 ServeWS
// 会拿它和 ws_token 比对并 401。留空反而放行。
const opts: webSocket.WebSocketRequestOptions = { const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(), header: this.authHeader(),
}; };
try { try {
await this.ws.connect(this.url, opts); await socket.connect(this.url, opts);
} catch (e) { } catch (e) {
this.connected = false; if (generation === this.connectionGeneration && !this.manualClose) {
this.scheduleReconnect(); this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
} }
} }
/** 握手请求头X-API-Key + Authorization 双写,兼容不同后端校验实现。 */ /** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
private authHeader(): Record<string, string> { private authHeader(): Record<string, string> {
const h: Record<string, string> = {}; const h: Record<string, string> = {};
if (this.token.length > 0) { if (this.token.length > 0) {
@ -157,38 +94,48 @@ export class DeviceBridgeClient {
return h; return h;
} }
private bindWsEvents(authorized: boolean): void { private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
this.ws.on('open', (err: Error, value: Object) => { socket.on('open', (err: Error, value: Object) => {
if (generation !== this.connectionGeneration || this.manualClose) {
socket.close().catch(() => {
// ignore stale socket close failure
});
return;
}
this.connected = true; this.connected = true;
this.everConnected = true; this.bound = false;
this.cancelReconnect();
this.sendHello(authorized); this.sendHello(authorized);
this.sendBind(); this.sendBind();
if (this.onStateChange !== null) {
this.onStateChange(true);
}
}); });
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => { socket.on('message', (err: Error, value: string | ArrayBuffer) => {
if (typeof value === 'string') { if (generation === this.connectionGeneration && typeof value === 'string') {
this.handleTextFrame(value); this.handleTextFrame(value);
} }
}); });
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => { socket.on('close', (err: Error, value: webSocket.CloseResult) => {
this.connected = false; this.handleSocketEnd(generation);
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
}); });
this.ws.on('error', (err: Error) => { socket.on('error', (err: Error) => {
this.connected = false; this.handleSocketEnd(generation);
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
}); });
} }
private handleSocketEnd(generation: number): void {
if (generation !== this.connectionGeneration) {
return;
}
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
private notifyState(open: boolean): void {
if (this.onStateChange !== null) {
this.onStateChange(open);
}
}
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.manualClose || this.reconnectTimer >= 0) { if (this.manualClose || this.reconnectTimer >= 0) {
return; return;
@ -211,18 +158,20 @@ export class DeviceBridgeClient {
} }
} }
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */ /** 更新本地授权状态并在已绑定连接上同步到服务端。 */
updateAuthorized(authorized: boolean): void { updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized; this.lastAuthorized = authorized;
if (this.connected) { if (this.connected && this.bound) {
this.sendHello(authorized); this.sendHello(authorized);
} }
} }
disconnect(): void { disconnect(): void {
this.manualClose = true; this.manualClose = true;
this.connectionGeneration = this.connectionGeneration + 1;
this.cancelReconnect(); this.cancelReconnect();
this.connected = false; this.connected = false;
this.bound = false;
try { try {
this.ws.off('open'); this.ws.off('open');
this.ws.off('message'); this.ws.off('message');
@ -234,40 +183,17 @@ export class DeviceBridgeClient {
} catch (e) { } catch (e) {
// ignore // ignore
} }
if (this.onStateChange !== null) { this.notifyState(false);
this.onStateChange(false);
}
} }
private sendHello(authorized: boolean): void { private sendHello(authorized: boolean): void {
this.lastAuthorized = authorized; this.lastAuthorized = authorized;
const info: HelloDeviceInfo = { this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
hostname: this.hostname, this.hostname, authorized));
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.0',
cpus: 0,
};
const device: HelloDevice = {
device_id: this.deviceId,
name: this.name,
kind: this.kind,
authorized: authorized,
caps: this.caps,
info: info,
};
const hello: HelloMessage = { op: 'hello', device: device };
this.send(JSON.stringify(hello));
} }
private sendBind(): void { private sendBind(): void {
const bind: BindMessage = { this.send(bridgeBindFrame(this.deviceId, this.token));
op: 'bind',
device_id: this.deviceId,
token: this.token,
};
this.send(JSON.stringify(bind));
} }
// ===== 命令处理 ===== // ===== 命令处理 =====
@ -280,67 +206,69 @@ export class DeviceBridgeClient {
return; return;
} }
const op: string = obj['op'] as string ?? ''; const op: string = obj['op'] as string ?? '';
if (op === 'cmd') { if (op === 'bind_ack') {
const reqId: string = obj['req_id'] as string ?? ''; const accepted: boolean = obj['ok'] === true;
const command: string = obj['command'] as string ?? ''; if (accepted && this.connected && !this.manualClose) {
if (reqId.length === 0 || command.length === 0) { this.bound = true;
return; this.cancelReconnect();
} this.notifyState(true);
if (!this.lastAuthorized) { } else {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权'); this.bound = false;
return; this.notifyState(false);
} try {
this.dispatchCommand(reqId, command); this.ws.close().catch(() => {
} else if (op === 'hello_ack' || op === 'bind_ack') { // ignore bind rejection close failure
if (this.onAck !== null) { });
this.onAck(op); } catch (e) {
this.scheduleReconnect();
}
} }
return;
} }
if (op !== 'cmd' || !this.bound) {
return;
}
const reqId: string = obj['req_id'] as string ?? '';
const command: string = obj['command'] as string ?? '';
if (reqId.length === 0 || command.length === 0) {
return;
}
if (!this.lastAuthorized) {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
return;
}
this.dispatchCommand(reqId, command);
} }
onAck: ((op: string) => void) | null = null;
private dispatchCommand(reqId: string, command: string): void { private dispatchCommand(reqId: string, command: string): void {
if (this.cmdHandler === null) { if (this.cmdHandler === null) {
this.sendResult(reqId, 'error', '', 'no capability handler registered'); this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
return; return;
} }
const handler: BridgeCmdHandler = this.cmdHandler; const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => { handler(reqId, command).then((res: CapResult) => {
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
if (res.chunked === true) {
return;
}
this.sendResult(reqId, res.status, res.output, res.error); this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => { }).catch((e: Object) => {
const msg: string = e instanceof Error ? e.message : String(e); this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
this.sendResult(reqId, 'error', '', msg);
}); });
} }
sendResult(reqId: string, status: string, output: string, errMsg: string): void { sendResult(reqId: string, status: string, output: string, errMsg: string): void {
const result: CmdReply = { this.send(bridgeResultFrame(reqId, status, output, errMsg));
op: 'cmd_result',
req_id: reqId,
status: status,
output: output,
error: errMsg,
};
this.send(JSON.stringify(result));
} }
// ===== 二进制分块回传(协议与 GUI 客户端一致)===== // ===== 二进制分块回传(协议与 GUI 客户端一致)=====
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void { sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
const startMsg: DataStartMessage = { this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
op: 'cmd_data_start', const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
req_id: reqId, for (let i = 0; i < chunks.length; i++) {
kind: kind, const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
mime: mime,
total: bytes.byteLength,
chunk_size: CHUNK_SIZE,
};
this.send(JSON.stringify(startMsg));
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
const view: Uint8Array = bytes.slice(off, end);
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
try { try {
this.ws.send(ab).catch(() => { this.ws.send(ab).catch(() => {
// ignore per-chunk failure; end frame reports error below // ignore per-chunk failure; end frame reports error below
@ -349,32 +277,15 @@ export class DeviceBridgeClient {
break; break;
} }
} }
const endMsg: DataEndMessage = { this.send(bridgeDataEndFrame(reqId));
op: 'cmd_data_end',
req_id: reqId,
status: 'ok',
};
this.send(JSON.stringify(endMsg));
} }
sendEvent(eventType: string, detail: string): void { sendEvent(eventType: string, detail: string): void {
const payload: Record<string, string> = { 'detail': detail }; this.send(bridgeEventFrame(this.deviceId, eventType, detail));
const msg: Record<string, Object> = {
'op': 'event',
'device_id': this.deviceId,
'type': eventType,
'payload': payload,
};
this.send(JSON.stringify(msg));
} }
sendStatus(status: string): void { sendStatus(status: string): void {
const msg: Record<string, Object> = { this.send(bridgeStatusFrame(this.deviceId, status));
'op': 'status',
'device_id': this.deviceId,
'status': status,
};
this.send(JSON.stringify(msg));
} }
send(text: string): void { send(text: string): void {
@ -388,7 +299,3 @@ export class DeviceBridgeClient {
} }
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient(); export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
return [];
}

View File

@ -0,0 +1,105 @@
import { common } from '@kit.AbilityKit';
import { deviceBridge } from './DeviceBridge';
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
import { connStore } from './ConnStore';
import { ConnectionConfig } from '../model/Model';
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
let bridgeStarting: boolean = false;
let foregroundActive: boolean = false;
let rootUIReady: boolean = false;
let bridgeGeneration: number = 0;
let stateTrackingReady: boolean = false;
function ensureBridgeStateTracking(): void {
if (stateTrackingReady) {
return;
}
stateTrackingReady = true;
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
deviceBridge.setStateListener((open: boolean) => {
AppStorage.set<boolean>('deviceBridgeConnected', open);
});
}
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
export function deviceGatewayUrl(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
*/
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
foregroundActive = true;
setBridgeAppContext(context);
installCmdRouter();
ensureBridgeStateTracking();
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
return;
}
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
return;
}
bridgeStarting = true;
bridgeGeneration = bridgeGeneration + 1;
const generation: number = bridgeGeneration;
const deviceId: string = connStore.ensureDeviceId();
try {
await deviceBridge.connect(
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
if (!foregroundActive || generation !== bridgeGeneration) {
deviceBridge.disconnect();
}
} catch (e) {
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
}
if (generation === bridgeGeneration) {
bridgeStarting = false;
}
}
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
rootUIReady = true;
startForegroundBridge(context);
}
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
export function stopForegroundBridge(): void {
foregroundActive = false;
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
shutdownSpeakerUse();
deviceBridge.disconnect();
}
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
deviceBridge.disconnect();
await startForegroundBridge(context);
}

View File

@ -0,0 +1,73 @@
/**
* 设备页的纯逻辑:本机 device_id 兜底与在线设备列表解析。
*
* 从 pages/DevicePage.ets 抽出(非 UI可被其它页面/桥复用)。
*/
import { DeviceInfo } from '../model/Model';
import { connStore } from './ConnStore';
// ===== 二级页面路由 id页面与一级入口列表共用=====
export const SUB_NONE: string = '';
export const SUB_LOCAL: string = 'local';
export const SUB_CAPS: string = 'caps';
export const SUB_GATEWAY: string = 'gateway';
export const SUB_LIST: string = 'list';
/**
* 本机 device_id桥里已有就用桥的其次读持久化都没有则生成一个并落盘。
* 生成后必须持久化,否则每次冷启动换 id网关侧会累积成一堆幽灵设备。
*
* 判定顺序与原 DevicePage.aboutToAppear 一致:桥的 id 优先于持久化的 id。
*/
export function resolveDeviceId(bridgeId: string): string {
let id: string = bridgeId;
if (id.length === 0) {
id = connStore.getDeviceId();
}
if (id.length === 0) {
id = 'ohos-' + Date.now().toString(36);
try {
connStore.saveDeviceId(id);
} catch (e) {
// ignore persist failure
}
}
return id;
}
/**
* 解析 /device/online 响应体。
*
* apiClient 已自动前置 /api/v1调用方只写其后的部分
* (否则会拼成 /api/v1/api/v1/device/online 并 404
*/
export function parseOnlineDevices(parsed: Record<string, Object>): DeviceInfo[] {
const devs: Object = parsed['devices'];
const list: DeviceInfo[] = [];
if (devs === undefined || devs === null) {
return list;
}
const arr: Object[] = devs as Object[];
for (let i = 0; i < arr.length; i++) {
const d: Record<string, Object> = arr[i] as Record<string, Object>;
const capsArr: Object = d['caps'];
const caps: string[] = [];
if (capsArr !== undefined && capsArr !== null) {
const cArr: Object[] = capsArr as Object[];
for (let j = 0; j < cArr.length; j++) {
caps.push(cArr[j] as string);
}
}
const info: DeviceInfo = {
deviceId: d['device_id'] as string ?? '',
name: d['name'] as string ?? '',
kind: d['kind'] as string ?? '',
online: true,
authorized: d['authorized'] as boolean ?? false,
caps: caps,
};
list.push(info);
}
return list;
}

View File

@ -0,0 +1,282 @@
/**
* Markdown 解析器(无 UI 依赖)。
*
* 从 components/StaticMarkdown.ets 抽出:解析与渲染分家后,
* 解析规则可以单独被复用/测试,渲染组件也回到可读长度。
*
* 覆盖:标题、段落、代码围栏、无序/有序列表、引用块、分隔线、表格,
* 以及行内的粗体/斜体/行内代码/链接。
*/
// ── Types ──────────────────────────────────────────────────────────────────────
export interface MdBlock {
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
level?: number;
items?: string[];
text?: string;
lang?: string;
codeLines?: string[];
headers?: string[];
rows?: string[][];
}
export interface MdSpan {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
link?: boolean;
linkUrl?: string;
}
// ── Inline parser ──────────────────────────────────────────────────────────────
export function parseInline(text: string): MdSpan[] {
const spans: MdSpan[] = [];
let i: number = 0;
while (i < text.length) {
// Inline code (backtick)
if (text[i] === '`') {
const end: number = text.indexOf('`', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), code: true });
i = end + 1;
continue;
}
}
// Bold: **text**
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
const end: number = text.indexOf('**', i + 2);
if (end > i + 1) {
spans.push({ text: text.substring(i + 2, end), bold: true });
i = end + 2;
continue;
}
}
// Italic: *text* (single asterisk)
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
const end: number = text.indexOf('*', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), italic: true });
i = end + 1;
continue;
}
}
// Link: [text](url)
if (text[i] === '[') {
const cb: number = text.indexOf(']', i + 1);
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
const cp: number = text.indexOf(')', cb + 2);
if (cp > cb + 1) {
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
i = cp + 1;
continue;
}
}
}
// Plain run
let j: number = i + 1;
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
j++;
}
spans.push({ text: text.substring(i, j) });
i = j;
}
return spans;
}
// ── Block parser helpers ───────────────────────────────────────────────────────
function isHr(line: string): boolean {
if (line.length < 3) {
return false;
}
const ch: string = line[0];
if (ch !== '-' && ch !== '*' && ch !== '_') {
return false;
}
for (let k = 0; k < line.length; k++) {
if (line[k] !== ch) {
return false;
}
}
return true;
}
function isOlStart(line: string): boolean {
if (line.length < 3) {
return false;
}
let k: number = 0;
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
k++;
}
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
}
function isTableSep(line: string): boolean {
if (!line.includes('-')) {
return false;
}
for (let k = 0; k < line.length; k++) {
const c: string = line[k];
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
return false;
}
}
return true;
}
/** 表格行 → 单元格数组(去掉首尾空串产生的空单元格)。 */
function parseTableRow(row: string): string[] {
const cells: string[] = [];
const parts: string[] = row.split('|');
for (let p = 0; p < parts.length; p++) {
const c: string = parts[p].trim();
if (c.length > 0) {
cells.push(c);
}
}
return cells;
}
// ── Block parser ───────────────────────────────────────────────────────────────
export function parseBlocks(content: string): MdBlock[] {
if (content.length === 0) {
return [];
}
const lines: string[] = content.split('\n');
const blocks: MdBlock[] = [];
let i: number = 0;
while (i < lines.length) {
const line: string = lines[i];
// Empty line
if (line.trim().length === 0) {
i++;
continue;
}
// Code fence
if (line.startsWith('```')) {
const langEnd: number = line.indexOf('`', 3);
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) {
i++;
}
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
continue;
}
// Heading
if (line.startsWith('#')) {
let level: number = 0;
while (level < line.length && line[level] === '#') {
level++;
}
if (level <= 6 && level < line.length && line[level] === ' ') {
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
i++;
continue;
}
}
// Horizontal rule
if (isHr(line.trim())) {
blocks.push({ type: 'hr' });
i++;
continue;
}
// Unordered list
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
items.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'list', items: items });
continue;
}
// Ordered list
if (isOlStart(line)) {
const items: string[] = [];
while (i < lines.length && isOlStart(lines[i])) {
const dotIdx: number = lines[i].indexOf('. ');
items.push(lines[i].substring(dotIdx + 2));
i++;
}
blocks.push({ type: 'ol', items: items });
continue;
}
// Blockquote
if (line.startsWith('> ')) {
const qLines: string[] = [];
while (i < lines.length && lines[i].startsWith('> ')) {
qLines.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
continue;
}
// Table
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
const tLines: string[] = [];
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
tLines.push(lines[i]);
i++;
}
if (tLines.length >= 2) {
const headers: string[] = parseTableRow(tLines[0]);
const rows: string[][] = [];
for (let k = 1; k < tLines.length; k++) {
if (!isTableSep(tLines[k].trim())) {
rows.push(parseTableRow(tLines[k]));
}
}
if (headers.length > 0) {
blocks.push({ type: 'table', headers: headers, rows: rows });
}
}
continue;
}
// Paragraph: collect consecutive non-special lines
{
const paraLines: string[] = [];
while (i < lines.length) {
const ln: string = lines[i];
if (ln.trim().length === 0) {
break;
}
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
break;
}
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
break;
}
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
break;
}
paraLines.push(ln);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: 'para', text: paraLines.join('\n') });
}
}
}
return blocks;
}

View File

@ -0,0 +1,174 @@
/**
* 插件数据获取(无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:三处接口的取数、合并口径与字段解析
* 都放在这里,页面只负责把结果落到 @State。
*
* 数据源对齐 WebGUI renderPlugins
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
import { apiClient } from './ApiClient';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
/** 拉取并合并插件列表;失败时抛错,由调用方转成人话提示。 */
export async function fetchPluginRows(): Promise<PluginRow[]> {
// ---- kernel: loaded plugins + tool ownership ----
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
return rows;
}
function strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时由调用方退回列表已有信息。
*/
export async function fetchPluginDetail(name: string): Promise<PluginDetail> {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = strArray(o['tags']);
d.files = strArray(o['files']);
return d;
}

View File

@ -0,0 +1,78 @@
/**
* 插件状态展示口径(纯函数,无 UI 依赖)。
*
* 从 pages/PluginsPage.ets 抽出:列表行与详情页共用同一套状态判定/文案/配色,
* 抽成模块后两处不会再各写一份。
*
* 状态口径与 WebGUI 一致:已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。
*/
import { PluginRow } from '../model/Model';
/** 'loaded' | 'pending' | 'disabled' | 'notloaded' */
export function pluginStatusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
export function pluginStatusText(plugin: PluginRow): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
/** 状态点颜色未加载态用主题里的弱化色mutedColor 由调用方从 palette 取)。 */
export function pluginStatusColor(plugin: PluginRow, mutedColor: string): string {
const s: string = pluginStatusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return mutedColor;
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
export function pluginRowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(pluginStatusText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
/** 详情页状态行:状态 + 内置/外部 + 已废弃 + 工具数(顺序与原实现一致) */
export function pluginDetailStatusLine(row: PluginRow, deprecated: boolean): string {
const parts: string[] = [];
parts.push(pluginStatusText(row));
parts.push(row.external ? '外部' : '内置');
if (deprecated) {
parts.push('已废弃');
}
if (row.tools !== undefined && row.tools.length > 0) {
parts.push(row.tools.length.toString() + ' 个工具');
}
return parts.join(' · ');
}

View File

@ -0,0 +1,182 @@
/**
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
*
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
* 超出工程「单文件 ≤400 行」的约定而「screensue 内容怎么解析、怎么渲染」
* 与「设备能力怎么实现」本就是两件事。
*/
import { util } from '@kit.ArkTS';
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}
/**
* 判断 agent 下发的 screensue 内容是不是 HTML。
*
* 服务端两侧协议都允许 HTMLlocaluse 的 local_screensue 在 Linux 用 browsh/w3m
* 渲染 HTMLremotedevice 的工具说明写的就是「显示内容/HTML」
*
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
* `'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
*
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
*/
export function looksLikeHtml(content: string): boolean {
return findHtmlStart(content) >= 0;
}
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
function findHtmlStart(content: string): number {
for (let i = 0; i < content.length; i++) {
if (content.charAt(i) !== '<') {
continue;
}
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
if (next === '/') {
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
if (isAsciiLetter(after)) {
return i;
}
continue;
}
if (isAsciiLetter(next)) {
return i;
}
}
return -1;
}
function isAsciiLetter(ch: string): boolean {
if (ch.length === 0) {
return false;
}
const c: number = ch.charCodeAt(0);
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
/**
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
*
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
*/
export function screensueHtmlDocument(content: string): string {
let body: string = content.trim();
// 反复剥成对的包裹引号agent 把整段 HTML 当命令参数传时的常见形态)。
while (body.length >= 2) {
const first: string = body.charAt(0);
const last: string = body.charAt(body.length - 1);
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
body = body.substring(1, body.length - 1).trim();
continue;
}
break;
}
const idx: number = findHtmlStart(body);
if (idx < 0) {
return '';
}
if (idx > 0) {
body = body.substring(idx);
}
return body;
}
/**
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
*
* 为什么必须上 Web不再用 RichTextRichText 只认极小标签子集,
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
* HTML 文档(含 @keyframes 与 <svg>RichText 下只能看到源码。用户明确要求引入 webview。
*
* 为什么用 base64 而不是明文 loadDataencoding 非 base64 时按 URL 规则转义,
* 一个几 KB 的完整文档会撞上长度/转义问题base64 是整篇加载的推荐方式,
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8
*
* 片段(非完整文档)补一层 shell加 <meta viewport> 让窄屏排版正确,
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML走纯文本渲染
*/
export function screensueWebData(content: string, dark: boolean): string {
const fragment: string = screensueHtmlDocument(content);
if (fragment.length === 0) {
return '';
}
if (hasHtmlShell(fragment)) {
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
return base64Utf8(fragment);
}
const fg: string = dark ? '#E8ECF4' : '#1B2430';
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<style>html,body{margin:0;padding:0}'
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
+ fragment + '</body></html>';
return base64Utf8(wrapped);
}
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
function hasHtmlShell(s: string): boolean {
const head: string = s.substring(0, 400).toLowerCase();
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
}
/**
* UTF-8 字符串 → base64。
*
* 不能把 UTF-16 码元直接交给 Base64Helper那样中文会变成乱码。
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
*/
export function base64Utf8(s: string): string {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let code: number = s.charCodeAt(i);
// 代理对emoji 等)合成成一个码点。
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
const next: number = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
i++;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else if (code < 0x10000) {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
} else {
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
}
const helper: util.Base64Helper = new util.Base64Helper();
return helper.encodeToStringSync(new Uint8Array(bytes));
}

View File

@ -0,0 +1,313 @@
/**
* 设置数据模型与纯解析逻辑(无 UI 依赖)。
*
* 从 pages/SettingsPage.ets 抽出:/settings 的响应解析、key 分区归类、
* 分页取项都是纯函数,页面只负责把结果落到 @State。
*/
/** One settings key card rendered in the editor list. */
export interface SettingEntry {
key: string;
displayName: string;
description: string;
type: string; // bool | int | duration | select | password | text | string
options: string[];
value: string; // raw string value as stored by backend
dirty: boolean;
}
export interface SettingsSection {
id: string;
title: string;
count: number;
}
export interface SettingMetaRaw {
key: string;
type: string;
displayName: string;
description: string;
category: string;
options: string[];
}
export const SETTINGS_PAGE_SIZE: number = 40;
/**
* 二级页面标识。
* 一级入口列表components/SettingsRootEntries.ets与页面路由表分开成文件后
* 这些 id 必须只有一个来源 —— 否则改一处就会"点了没反应"。
*/
export const SUB_NONE: string = '';
export const SUB_STATUS: string = 'status';
export const SUB_CONNECTIONS: string = 'connections';
export const SUB_APPEARANCE: string = 'appearance';
export const SUB_BACKEND: string = 'backend';
export const SUB_SECTION: string = 'section';
/** /settings 响应解析结果 */
export interface ParsedSettings {
meta: Record<string, SettingMetaRaw>;
values: Record<string, string>;
}
/** 分区统计结果 */
export interface SectionStats {
sections: SettingsSection[];
/** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */
pluginKeyCount: number;
/** 涉及的插件个数 */
pluginConfigCount: number;
}
/**
* 解析 GET /settings 响应meta 定义 + 当前值。
* 值统一转成字符串(后端可能给 bool/number/嵌套对象)。
*/
export function parseSettingsPayload(body: string): ParsedSettings {
const obj: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
const metaStore: Record<string, SettingMetaRaw> = {};
const valuesStore: Record<string, string> = {};
const rawMeta: Object | undefined = obj['meta'];
if (rawMeta !== undefined && rawMeta !== null) {
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
for (const mk of Object.keys(mObj)) {
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
const optsArr: Object | undefined = item['options'];
const opts: string[] = [];
if (optsArr !== undefined && optsArr !== null) {
const oa: Object[] = optsArr as Object[];
for (let i = 0; i < oa.length; i++) {
opts.push(oa[i] as string);
}
}
const meta: SettingMetaRaw = {
key: item['key'] as string ?? mk,
type: item['type'] as string ?? 'string',
displayName: item['display_name'] as string ?? '',
description: item['description'] as string ?? '',
category: item['category'] as string ?? '',
options: opts,
};
metaStore[mk] = meta;
}
}
const rawVals: Object | undefined = obj['settings'];
if (rawVals !== undefined && rawVals !== null) {
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
for (const vk of Object.keys(vObj)) {
if (vk.length === 0) {
continue;
}
const val: Object = vObj[vk];
let strVal: string;
if (typeof val === 'string') {
strVal = val as string;
} else if (typeof val === 'boolean' || typeof val === 'number') {
strVal = String(val);
} else {
strVal = JSON.stringify(val);
}
valuesStore[vk] = strVal;
}
}
const parsed: ParsedSettings = { meta: metaStore, values: valuesStore };
return parsed;
}
export function metaCategory(metaStore: Record<string, SettingMetaRaw>, key: string): string {
const m: SettingMetaRaw | undefined = metaStore[key];
return m !== undefined && m.category.length > 0 ? m.category : '';
}
export function categoryTitle(cat: string): string {
const map: Record<string, string> = {
'agent': '智能体',
'daemon': '守护进程',
'llm': '大模型',
'sources': '数据源',
'input': '输入',
'paths': '路径',
'resources': '资源',
'defaults': '默认值',
'snapshot': '快照',
'rollback': '回滚',
};
const t: string | undefined = map[cat];
return t !== undefined ? t : cat;
}
/**
* 把 key 归到分区:
* - core.* → 'core/<category>',展示在「核心」二级页下
* - plugin.* → 'plugin/<name>',仅用于计数;实际编辑在插件详情页里,
* 不在这里列出(否则同一批 key 会有两个入口)
* - 其余 → 'other'
*/
export function buildSections(
valuesStore: Record<string, string>, metaStore: Record<string, SettingMetaRaw>): SectionStats {
const ids: string[] = [];
const counts: Record<string, number> = {};
const titles: Record<string, string> = {};
let pluginKeys: number = 0;
const plugNames: string[] = [];
for (const key of Object.keys(valuesStore)) {
if (key.startsWith('plugin.')) {
// 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。
// 这里只统计,用于提示有多少项在那边。
pluginKeys = pluginKeys + 1;
const rest: string = key.substring('plugin.'.length);
const dot: number = rest.indexOf('.');
const plugName: string = dot > 0 ? rest.substring(0, dot) : rest;
if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) {
plugNames.push(plugName);
}
continue;
}
let secId: string;
if (key.startsWith('core.')) {
const cat: string = metaCategory(metaStore, key);
secId = cat.length > 0 ? 'core/' + cat : 'core/misc';
if (titles[secId] === undefined) {
titles[secId] = cat.length > 0 ? categoryTitle(cat) : '未分类';
}
} else {
secId = 'other';
if (titles[secId] === undefined) {
titles[secId] = '其他';
}
}
if (counts[secId] === undefined) {
counts[secId] = 0;
ids.push(secId);
}
counts[secId] = counts[secId] + 1;
}
ids.sort((a: string, b: string): number => a.localeCompare(b));
const secs: SettingsSection[] = [];
for (const id of ids) {
secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 });
}
const stats: SectionStats = {
sections: secs,
pluginKeyCount: pluginKeys,
pluginConfigCount: plugNames.length,
};
return stats;
}
export function pickInitialSection(sections: SettingsSection[]): string {
for (let i = 0; i < sections.length; i++) {
if (sections[i].id === 'core/agent') {
return 'core/agent';
}
}
return sections.length > 0 ? sections[0].id : 'core';
}
export function keyInSection(
metaStore: Record<string, SettingMetaRaw>, key: string, secId: string): boolean {
if (secId === 'other') {
return !key.startsWith('core.') && !key.startsWith('plugin.');
}
if (secId.startsWith('core/')) {
if (!key.startsWith('core.')) {
return false;
}
const cat: string = secId.substring('core/'.length);
return cat === 'misc'
? metaCategory(metaStore, key).length === 0
: metaCategory(metaStore, key) === cat;
}
if (secId.startsWith('plugin/')) {
const p: string = secId.substring('plugin/'.length);
return key.startsWith('plugin.' + p + '.');
}
return false;
}
export function activeSectionTitle(sections: SettingsSection[], secId: string): string {
for (let i = 0; i < sections.length; i++) {
if (sections[i].id === secId) {
return sections[i].title;
}
}
return '配置项';
}
/** 某个分区的配置项(按 key 字典序,最多 PAGE_SIZE*4 项) */
export function buildEntries(
valuesStore: Record<string, string>,
metaStore: Record<string, SettingMetaRaw>,
secId: string): SettingEntry[] {
const entries: SettingEntry[] = [];
const keys: string[] = Object.keys(valuesStore).filter((k: string): boolean => {
return keyInSection(metaStore, k, secId);
});
keys.sort((a: string, b: string): number => a.localeCompare(b));
const limit: number = Math.min(keys.length, SETTINGS_PAGE_SIZE * 4);
for (let i = 0; i < limit; i++) {
const key: string = keys[i];
const meta: SettingMetaRaw | undefined = metaStore[key];
const entry: SettingEntry = {
key: key,
displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key,
description: meta !== undefined ? meta.description : '',
type: meta !== undefined ? meta.type : 'string',
options: meta !== undefined ? meta.options : [],
value: valuesStore[key] ?? '',
dirty: false,
};
entries.push(entry);
}
return entries;
}
/** 覆盖某个 entry 的 value/dirty返回新数组保持 @State 数组替换语义) */
export function withEntryMarked(
entries: SettingEntry[], key: string, value: string, dirty: boolean): SettingEntry[] {
const next: SettingEntry[] = [];
for (let i = 0; i < entries.length; i++) {
const e: SettingEntry = entries[i];
if (e.key === key) {
const copy: SettingEntry = {
key: e.key,
displayName: e.displayName,
description: e.description,
type: e.type,
options: e.options,
value: value,
dirty: dirty,
};
next.push(copy);
} else {
next.push(e);
}
}
return next;
}
/**
* 从 picker 返回的 URI 里取图片后缀(带点)。
* Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败,
* 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。
*/
export function imageExt(uri: string): string {
let s: string = uri;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const dot: number = s.lastIndexOf('.');
const slash: number = s.lastIndexOf('/');
if (dot > slash && dot < s.length - 1) {
const ext: string = s.substring(dot).toLowerCase();
if (ext.length <= 5) {
return ext;
}
}
return '.jpg';
}

View File

@ -0,0 +1,180 @@
/**
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
*
* 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。
* 为什么不放进 StatusStore轨迹来自 SSE 流、与 /status、/kernel 的轮询无关,
* 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累
* SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。
*
* 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
* 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
* 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
*/
/** 一个阶段组 */
export interface StageGroup {
/** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */
group: number;
label: string;
en: string;
}
/** 轨迹里的一条事件 */
export interface StageEvent {
group: number;
/** 'stage' | 'tool' | 'output' */
kind: string;
label: string;
short: string;
count: number;
}
/** 阶段组定义,索引即 group */
export const STAGE_GROUPS: StageGroup[] = [
{ group: 0, label: '输入', en: 'in' },
{ group: 1, label: '行动', en: 'act' },
{ group: 2, label: '工具', en: 'tool' },
{ group: 3, label: '输出', en: 'out' },
{ group: 4, label: '结束', en: 'done' },
];
/** 当前阶段SSE 驱动)。空串 = 空闲。 */
export const K_STAGE_PHASE: string = 'stagePhase';
/** 轨迹版本号:数组不进 AppStorage靠它触发订阅组件重取快照 */
export const K_STAGE_REV: string = 'stageRev';
/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */
const IDLE_AFTER_MS: number = 2500;
const MAX_ITEMS: number = 24;
/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string {
let n: string = name;
const i: number = n.lastIndexOf('__');
if (i >= 0) {
n = n.substring(i + 2);
}
return n.length > 14 ? n.substring(0, 13) + '…' : n;
}
class StageTrail {
private items: StageEvent[] = [];
private timerId: number = -1;
init(): void {
AppStorage.setOrCreate<string>(K_STAGE_PHASE, '');
AppStorage.setOrCreate<number>(K_STAGE_REV, 0);
}
/** 只给订阅组件读;调用方不要持有它 */
snapshot(): StageEvent[] {
return this.items;
}
currentPhase(): string {
return AppStorage.get<string>(K_STAGE_PHASE) ?? '';
}
/** 按阶段分组取条目 */
eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.items.length; i++) {
const e: StageEvent = this.items[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
/**
* 收到一条 stage 事件。phase 取值与内核一致:
* on_input / pre_action / post_action / before_toolcall / after_toolcall /
* before_output / after_output。
*/
onStage(phase: string, tool: string): void {
if (phase.length === 0) {
return;
}
if (phase === 'on_input') {
// 新的一轮:清空上一轮的轨迹
this.items = [];
this.push(0, 'stage', '输入', '输入');
} else if (phase === 'pre_action') {
this.push(1, 'stage', '组装上下文并思考', '思考');
} else if (phase === 'before_toolcall' && tool.length > 0) {
// output_* 是输出通道工具,与普通工具用不同配色区分
const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool';
this.push(2, kind, tool, shortTool(tool));
} else if (phase === 'before_output') {
this.push(3, 'stage', '生成回复', '生成');
} else if (phase === 'after_output') {
this.push(4, 'stage', '本轮完成', '完成');
} else {
// post_action / after_toolcall 不单独记:它们与相邻格重复,
// 逐条记会把轨迹刷成噪音。
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
return;
}
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
this.bump();
}
private push(group: number, kind: string, label: string, short: string): void {
// 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏
const n: number = this.items.length;
if (n > 0) {
const last: StageEvent = this.items[n - 1];
if (last.group === group && last.kind === kind && last.short === short) {
last.count = last.count + 1;
return;
}
}
this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 });
if (this.items.length > MAX_ITEMS) {
this.items.shift();
}
}
private armIdleTimer(): void {
if (this.timerId !== -1) {
clearTimeout(this.timerId);
}
this.timerId = setTimeout(() => {
this.timerId = -1;
AppStorage.set<string>(K_STAGE_PHASE, '');
this.bump();
}, IDLE_AFTER_MS);
}
private bump(): void {
const cur: number = AppStorage.get<number>(K_STAGE_REV) ?? 0;
AppStorage.set<number>(K_STAGE_REV, cur + 1);
}
}
export const stageTrail: StageTrail = new StageTrail();
/**
* 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致,
* 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。
*/
export function phaseGroup(phase: string): number {
if (phase === 'on_input') {
return 0;
}
if (phase === 'pre_action' || phase === 'post_action') {
return 1;
}
if (phase === 'before_toolcall' || phase === 'after_toolcall') {
return 2;
}
if (phase === 'before_output') {
return 3;
}
if (phase === 'after_output') {
return 4;
}
return -1;
}

View File

@ -27,6 +27,68 @@ export interface StatGroup {
fields: StatField[]; fields: StatField[];
} }
// ===== 运行态快照(/runtime=====
//
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
// 两者数据源不同(/kernel vs /runtime所以分开取、分开存。
/** 一条队列(四级中断之一,或排队队列) */
export interface RuntimeQueue {
/** 4/3/2/10 表示排队队列(无级别) */
lv: number;
name: string;
desc: string;
depth: number;
registered: number;
preempted: number;
}
/** 一次刷新的运行态快照 */
export interface RuntimeSnapshot {
/** 排队队列深度(无级别,纯 FIFO */
ready: number;
pending: number;
stack: number;
maxStack: number;
subagents: number;
/** 格槽数(按全场最大深度缩放,至少 5 */
slots: number;
/** 五条队列L4/L3/L2/L1 + 排队 */
queues: RuntimeQueue[];
}
interface LevelDef {
lv: number;
name: string;
desc: string;
}
/** 四级中断的定义(顺序即 L4→L1 */
const LEVEL_DEFS: LevelDef[] = [
{ lv: 4, name: 'L4', desc: '内核独占' },
{ lv: 3, name: 'L3', desc: '交互' },
{ lv: 2, name: 'L2', desc: '消息' },
{ lv: 1, name: 'L1', desc: '后台' },
];
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
function numAt(arr: Object[] | undefined, i: number): number {
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
return 0;
}
const v: number = arr[i] as number;
return isNaN(v) ? 0 : v;
}
/** 格槽数:按全场最大深度缩放,至少 5 格0 时也要有可见形状)、最多 16 格 */
function clampSlots(maxQ: number): number {
if (maxQ < 5) {
return 5;
}
return maxQ > 16 ? 16 : maxQ;
}
// ===== AppStorage 键:摘要卡与明细页共用 ===== // ===== AppStorage 键:摘要卡与明细页共用 =====
export const K_UP: string = 'statUp'; export const K_UP: string = 'statUp';
export const K_VERSION: string = 'statVersion'; export const K_VERSION: string = 'statVersion';
@ -37,10 +99,14 @@ export const K_TOOLS: string = 'statTools';
export const K_ERR: string = 'statErr'; export const K_ERR: string = 'statErr';
export const K_LOADING: string = 'statLoading'; export const K_LOADING: string = 'statLoading';
export const K_REV: string = 'statRev'; export const K_REV: string = 'statRev';
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
export const K_BUILD: string = 'statBuildSub';
class StatusStore { class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage数组同步语义太脆 */ /** 明细分组:只有明细页读它,不进 AppStorage数组同步语义太脆 */
private groups: StatGroup[] = []; private groups: StatGroup[] = [];
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
init(): void { init(): void {
AppStorage.setOrCreate<boolean>(K_UP, false); AppStorage.setOrCreate<boolean>(K_UP, false);
@ -52,12 +118,17 @@ class StatusStore {
AppStorage.setOrCreate<string>(K_ERR, ''); AppStorage.setOrCreate<string>(K_ERR, '');
AppStorage.setOrCreate<boolean>(K_LOADING, false); AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.setOrCreate<number>(K_REV, 0); AppStorage.setOrCreate<number>(K_REV, 0);
AppStorage.setOrCreate<string>(K_BUILD, '');
} }
getGroups(): StatGroup[] { getGroups(): StatGroup[] {
return this.groups; return this.groups;
} }
getRuntime(): RuntimeSnapshot | undefined {
return this.runtimeSnapshot;
}
isUp(): boolean { isUp(): boolean {
return AppStorage.get<boolean>(K_UP) ?? false; return AppStorage.get<boolean>(K_UP) ?? false;
} }
@ -67,8 +138,11 @@ class StatusStore {
this.fail(noConnectionMessage()); this.fail(noConnectionMessage());
return; return;
} }
AppStorage.setOrCreate<boolean>(K_LOADING, true); // setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
AppStorage.setOrCreate<string>(K_ERR, ''); // init() 已把所有键种好,刷新阶段必须用 set否则摘要卡会永远停在
// 版本 "-"、插件 0 的初始状态。
AppStorage.set<boolean>(K_LOADING, true);
AppStorage.set<string>(K_ERR, '');
try { try {
const resp = await apiClient.getWithTimeout('/status', 8000); const resp = await apiClient.getWithTimeout('/status', 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>; const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
@ -79,10 +153,10 @@ class StatusStore {
const agents: number = obj['agents'] as number ?? 0; const agents: number = obj['agents'] as number ?? 0;
const startedAt: string = obj['startedAt'] as string ?? ''; const startedAt: string = obj['startedAt'] as string ?? '';
AppStorage.setOrCreate<boolean>(K_UP, true); AppStorage.set<boolean>(K_UP, true);
AppStorage.setOrCreate<string>(K_VERSION, version); AppStorage.set<string>(K_VERSION, version);
AppStorage.setOrCreate<number>(K_AGENTS, agents); AppStorage.set<number>(K_AGENTS, agents);
AppStorage.setOrCreate<string>(K_STARTED, startedAt); AppStorage.set<string>(K_STARTED, startedAt);
const systemFields: StatField[] = [ const systemFields: StatField[] = [
{ label: '版本', value: version }, { label: '版本', value: version },
@ -98,12 +172,13 @@ class StatusStore {
]; ];
await this.collectKernel(groups); await this.collectKernel(groups);
await this.collectRuntime();
this.groups = groups; this.groups = groups;
this.bump(); this.bump();
} catch (e) { } catch (e) {
this.fail(userMessage('status.refresh', e)); this.fail(userMessage('status.refresh', e));
} }
AppStorage.setOrCreate<boolean>(K_LOADING, false); AppStorage.set<boolean>(K_LOADING, false);
} }
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */ /** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
@ -119,8 +194,8 @@ class StatusStore {
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0; const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0; const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount); AppStorage.set<number>(K_PLUGINS, pluginCount);
AppStorage.setOrCreate<number>(K_TOOLS, toolCount); AppStorage.set<number>(K_TOOLS, toolCount);
const kernelFields: StatField[] = [ const kernelFields: StatField[] = [
{ label: 'Agent ID', value: agentId }, { label: 'Agent ID', value: agentId },
@ -130,8 +205,66 @@ class StatusStore {
if (startTime.length > 0) { if (startTime.length > 0) {
kernelFields.push({ label: '内核启动', value: formatTime(startTime) }); kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
} }
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build-ldflags 注入)。
// 为何不能只用 /status 的 version那里只有一个版本号分不清是哪个内核、
// 哪次构建;许可标识更是完全没有。
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
let lic: string = '';
let licURL: string = '';
let srcURL: string = '';
if (build !== undefined && build !== null) {
const bVer: string = build['version'] as string ?? '';
const bCommit: string = build['commit'] as string ?? '';
const bName: string = build['kernel_name'] as string ?? 'HomeAgent';
const bSdk: string = build['sdk_compatible'] as string ?? '';
const bTime: string = build['build_time'] as string ?? '';
lic = build['license'] as string ?? '';
licURL = build['license_url'] as string ?? '';
srcURL = build['source_url'] as string ?? '';
if (bVer.length > 0) {
AppStorage.set<string>(K_VERSION, 'v' + bVer);
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
}
let sub: string = bName;
if (bCommit.length > 0 && bCommit !== 'unknown') {
sub = sub + ' · ' + bCommit.substring(0, 7);
}
AppStorage.set<string>(K_BUILD, sub);
if (bCommit.length > 0) {
kernelFields.push({ label: 'Commit', value: bCommit });
}
if (bSdk.length > 0) {
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
}
if (bTime.length > 0 && bTime !== 'unknown') {
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
}
}
groups.push({ title: '内核', fields: kernelFields }); groups.push({ title: '内核', fields: kernelFields });
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
// 也看不出网络服务场景下的 §13 义务。
const legalFields: StatField[] = [];
if (lic.length > 0) {
legalFields.push({ label: '许可协议', value: lic });
}
if (licURL.length > 0) {
legalFields.push({ label: '协议全文', value: licURL });
}
if (srcURL.length > 0) {
legalFields.push({ label: '源码仓库', value: srcURL });
}
if (legalFields.length > 0) {
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
legalFields.push({
label: '网络条款',
value: '把修改后的版本作为网络服务对外提供时必须向使用者提供取得对应源码的途径§13',
});
}
groups.push({ title: '开源许可', fields: legalFields });
}
// LLMprovider / 可用源 / 是否可用 // LLMprovider / 可用源 / 是否可用
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>; const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
if (llm !== undefined && llm !== null) { if (llm !== undefined && llm !== null) {
@ -218,18 +351,98 @@ class StatusStore {
} }
} }
/**
* 取运行态快照(/runtime。失败不影响已经取到的明细分组
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
*/
private async collectRuntime(): Promise<void> {
try {
const resp = await apiClient.getWithTimeout('/runtime', 8000);
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
const residents: Object[] | undefined = r['residents'] as Object[];
let ready: number = 0;
let pending: number = 0;
let stack: number = 0;
let maxStack: number = 4;
let qArr: Object[] | undefined = undefined;
let regArr: Object[] | undefined = undefined;
let preArr: Object[] | undefined = undefined;
if (sc !== undefined && sc !== null) {
ready = sc['ready_queue_depth'] as number ?? 0;
pending = sc['pending_interrupts'] as number ?? 0;
stack = sc['suspend_stack'] as number ?? 0;
maxStack = sc['max_suspend_depth'] as number ?? 4;
qArr = sc['interrupt_queues'] as Object[];
regArr = sc['interrupts_by_level'] as Object[];
preArr = sc['preempts_by_level'] as Object[];
}
// 四级语义引用内核的定义internal/agent/core/scheduler.go
// 前端只负责把它们画出来,不自己起名字。
const queues: RuntimeQueue[] = [];
let maxQ: number = 1;
for (let i = 0; i < LEVEL_DEFS.length; i++) {
const d: LevelDef = LEVEL_DEFS[i];
const depth: number = numAt(qArr, d.lv);
if (depth > maxQ) {
maxQ = depth;
}
queues.push({
lv: d.lv,
name: d.name,
desc: d.desc,
depth: depth,
registered: numAt(regArr, d.lv),
preempted: numAt(preArr, d.lv),
});
}
if (ready > maxQ) {
maxQ = ready;
}
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
// 所以 lv 用 0 标记「无级别」。
queues.push({
lv: 0,
name: '排队',
desc: 'FIFO',
depth: ready,
registered: 0,
preempted: 0,
});
const subagents: number = residents !== undefined && residents !== null
? residents.length : 0;
this.runtimeSnapshot = {
ready: ready,
pending: pending,
stack: stack,
maxStack: maxStack,
subagents: subagents,
slots: clampSlots(maxQ),
queues: queues,
};
} catch (e) {
// /runtime 不可用(旧后端):保留上一次快照,不清空
}
}
private fail(msg: string): void { private fail(msg: string): void {
AppStorage.setOrCreate<string>(K_ERR, msg); AppStorage.set<string>(K_ERR, msg);
AppStorage.setOrCreate<boolean>(K_UP, false); AppStorage.set<boolean>(K_UP, false);
AppStorage.setOrCreate<boolean>(K_LOADING, false); AppStorage.set<boolean>(K_LOADING, false);
this.groups = []; this.groups = [];
this.runtimeSnapshot = undefined;
this.bump(); this.bump();
} }
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */ /** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
private bump(): void { private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0; const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.setOrCreate<number>(K_REV, cur + 1); AppStorage.set<number>(K_REV, cur + 1);
} }
} }

View File

@ -0,0 +1,265 @@
/**
* 「外观」二级页面:主题三选一 + 自定义背景图。
*
* 从 pages/SettingsPage.ets 抽出。
* themeMode / bgImage / bgOpacity 用 @Link 与一级页面共享(一级页的
* 外观行要显示当前主题名,两边必须是同一份数据)。
*/
import { connStore } from '../common/ConnStore';
import { AppSettings, emptySettings } from '../model/Model';
import { applyThemeMode } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD,
ANIM_NORMAL } from '../common/Constants';
import { imageExt } from '../common/SettingsModel';
import { userMessage } from '../common/UserError';
import { SubPageLayer, PlainCard } from './SubPage';
import { MotionBase } from './MotionBase';
import { picker, fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
@Component
export struct AppearancePane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Link themeMode: string;
@Link bgImage: string;
@Link bgOpacity: number;
onBack?: () => void;
onToast?: (msg: string, isError: boolean) => void;
@State pickingBg: boolean = false;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private toast(msg: string, isError: boolean): void {
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
if (cb !== undefined) {
cb(msg, isError);
}
}
private applyTheme(mode: string): void {
this.themeMode = mode;
this.persistSettings();
// 立即翻转全局主题标志,整个 UI 随之切换
applyThemeMode(mode);
}
private async pickBackgroundImage(): Promise<void> {
if (this.pickingBg) {
return;
}
this.pickingBg = true;
try {
const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
const srcUri: string = result.photoUris[0];
const ctx = getContext(this) as common.UIAbilityContext;
// 文件名带时间戳Image 组件按 src 字符串做内存缓存,
// 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。
// 后缀必须保留Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败,
// 表现就是"设置了背景图但没生效"onError 里能看到 decode 失败)。
const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri);
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
const destFile = fileIo.openSync(destPath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.copyFileSync(srcFile.fd, destFile.fd);
fileIo.closeSync(srcFile);
fileIo.closeSync(destFile);
this.removeOldBgFile();
// Image 只认带协议头的沙箱 URI裸路径会被当成资源名而静默失败
this.bgImage = 'file://' + destPath;
this.persistSettings();
this.toast('背景图已设置', false);
} catch (e) {
this.toast(userMessage('settings.pickBg', e), true);
}
this.pickingBg = false;
}
/** 删除上一张背景图文件,避免沙箱里越攒越多 */
private removeOldBgFile(): void {
const old: string = this.bgImage;
if (old.length === 0) {
return;
}
const path: string = old.startsWith('file://') ? old.substring(7) : old;
try {
fileIo.unlinkSync(path);
} catch (e) {
// 文件可能已不存在,忽略
}
}
private clearBackgroundImage(): void {
this.removeOldBgFile();
this.bgImage = '';
this.persistSettings();
this.toast('已清除背景图', false);
}
private onBgOpacityChange(value: number): void {
this.bgOpacity = value / 100;
this.persistSettings();
}
private persistSettings(): void {
const s: AppSettings = emptySettings();
const old: AppSettings = connStore.getSettings();
s.lang = old.lang;
s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system');
s.currentConnId = old.currentConnId;
s.bgImage = this.bgImage;
s.bgOpacity = this.bgOpacity;
connStore.saveSettings(s);
AppStorage.set<string>('bgImage', this.bgImage);
AppStorage.set<number>('bgOpacity', this.bgOpacity);
}
@Builder
ThemeOption(label: string, mode: string) {
// 按压缩放由 MotionBase 统一flexWeight: 1 让三枚选项在 Row 里继续等分。
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
Column() {
Text(label)
.fontSize(12)
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.justifyContent(FlexAlign.Center)
.width('100%')
.height(34)
.borderRadius(RADIUS_MD)
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
// 覆盖它上面的所有状态驱动属性。
.border({
width: 1,
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
})
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.onClick(() => {
this.applyTheme(mode);
})
}
}
build() {
SubPageLayer({
title: '外观',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
PlainCard({ caption: '主题' }) {
Row({ space: 8 }) {
this.ThemeOption('跟随系统', 'system')
this.ThemeOption('浅色', 'light')
this.ThemeOption('深色', 'dark')
}
.width('100%')
Text(this.themeMode === 'system'
? '当前跟随系统,系统切换深浅色时自动跟随'
: (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题'))
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
PlainCard({ caption: '背景图' }) {
Row() {
Column() {
Text('自定义背景图')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.bgImage.length > 0) {
Button('更换')
.height(28)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.pickBackgroundImage();
})
Button('清除')
.height(28)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor('#E84026')
.onClick(() => {
this.clearBackgroundImage();
})
} else {
Button(this.pickingBg ? '选择中...' : '选择图片')
.height(28)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.pickBackgroundImage();
})
}
}
.width('100%')
if (this.bgImage.length > 0) {
Row() {
Text('透明度')
.fontSize(11)
.fontColor(this.palette().textMuted)
Slider({
value: Math.round(this.bgOpacity * 100),
min: 5,
max: 60,
step: 1,
})
.layoutWeight(1)
.selectedColor(this.palette().accent)
.trackColor(this.palette().bgHover)
.margin({ left: 8, right: 8 })
.onChange((v: number, mode: SliderChangeMode) => {
if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) {
this.onBgOpacityChange(v);
}
})
Text(Math.round(this.bgOpacity * 100).toString() + '%')
.fontSize(11)
.fontColor(this.palette().textSecondary)
.width(32)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 12 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -4,107 +4,20 @@ import { common } from '@kit.AbilityKit';
import { apiClient } from '../common/ApiClient'; import { apiClient } from '../common/ApiClient';
import { userMessage } from '../common/UserError'; import { userMessage } from '../common/UserError';
import { ChatAttachment } from '../model/Model'; import { ChatAttachment } from '../model/Model';
import { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta';
import { loadPixelMap } from '../common/AttachmentImage';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants'; import { COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase'; import { MotionBase } from './MotionBase';
import { PlainCard } from './SubPage'; import { PlainCard } from './SubPage';
/** /**
* 附件解析与展示 * 附件 UI气泡内的附件卡 + 附件详情二级页内容
* *
* 后端 Attachment 只有四个字段type / url / size / name * 解析/格式化parseAttachment、formatBytes、extLabel…在 common/AttachmentMeta.ets
* internal/plugins/webui/handler.go没有 mime、没有像素尺寸、没有本地路径 * 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
* *
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀) * 附件卡:图片显示缩略图,文件显示一枚文件条
* 这两条路由在后端是 requireWeb但对 API Key 客户端同等放行,
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
*/
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const o: Record<string, Object> = raw as Record<string, Object>;
const url: string = o['url'] as string ?? '';
if (url.length === 0) {
return undefined;
}
const t: string = o['type'] as string ?? 'file';
const a: ChatAttachment = {
type: t === 'image' ? 'image' : 'file',
url: url,
size: o['size'] as number ?? 0,
name: o['name'] as string ?? fileNameOf(url),
};
return a;
}
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
export function attachmentFromChannelOutput(
outputType: string, url: string, size: number): ChatAttachment | undefined {
if (url.length === 0) {
return undefined;
}
if (outputType !== 'image' && outputType !== 'file') {
return undefined;
}
const a: ChatAttachment = {
type: outputType,
url: url,
size: size,
name: fileNameOf(url),
};
return a;
}
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
export function fileNameOf(url: string): string {
let s: string = url;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const i: number = s.lastIndexOf('/');
const name: string = i >= 0 ? s.substring(i + 1) : s;
return name.length > 0 ? name : '附件';
}
/** 人类可读字节数,口径对齐后端 formatBytesKB 以上保留一位小数)。 */
export function formatBytes(n: number): string {
if (n <= 0) {
return '';
}
if (n < 1024) {
return n.toString() + ' B';
}
const kb: number = n / 1024;
if (kb < 1024) {
return oneDecimal(kb) + ' KB';
}
const mb: number = kb / 1024;
if (mb < 1024) {
return oneDecimal(mb) + ' MB';
}
return oneDecimal(mb / 1024) + ' GB';
}
function oneDecimal(v: number): string {
return (Math.round(v * 10) / 10).toString();
}
/** 由文件名后缀猜测类型标签。后端不返回 mime只能这样标注。 */
export function extLabel(name: string): string {
const i: number = name.lastIndexOf('.');
if (i < 0 || i === name.length - 1) {
return '未知类型';
}
return name.substring(i + 1).toUpperCase();
}
/**
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
* 点击进入附件详情二级页面WebGUI 是新开标签页,移动端改为二级页)。 * 点击进入附件详情二级页面WebGUI 是新开标签页,移动端改为二级页)。
*/ */
@Component @Component
@ -428,36 +341,3 @@ export struct AttachmentDetailContent {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
} }
} }
/** 下载并解码成 PixelMap任何一步失败都返回 undefined调用方显示占位。 */
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
try {
// 本地待上传的图片:直接读沙箱文件,不走网络
if (url.startsWith('file://')) {
const path: string = url.substring(7);
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const localSrc: image.ImageSource = image.createImageSource(f.fd);
const localPm: image.PixelMap = await localSrc.createPixelMap();
await localSrc.release();
fileIo.closeSync(f);
return localPm;
}
const abs: string = apiClient.absoluteUrl(url);
const resp = await apiClient.getBinary(abs, 15000);
const src: image.ImageSource = image.createImageSource(resp.data);
const pm: image.PixelMap = await src.createPixelMap();
await src.release();
return pm;
} catch (e) {
return undefined;
}
}
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
function sanitize(name: string): string {
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
if (s.length === 0) {
s = 'attachment';
}
return s;
}

View File

@ -0,0 +1,172 @@
/**
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
*
* 从 pages/SettingsPage.ets 抽出。
* 取数/分区/落库都留在页面(它同时要显示"几个分类 · 几项"和错误态),
* 这里只负责渲染与把用户动作转成回调。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { SettingsSection, SettingEntry, activeSectionTitle } from '../common/SettingsModel';
import { SubPageLayer, NavGroup, NavRow, PlainCard } from './SubPage';
import { SettingsEntryCard } from './SettingsEntryCard';
@Component
export struct BackendSectionsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop sections: SettingsSection[] = [];
@Prop pluginKeyCount: number = 0;
@Prop pluginConfigCount: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
@Prop activeSection: string = '';
/** 宽屏右栏正显示分区明细时高亮左侧对应行 */
@Prop highlightRows: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
onOpenSection?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '核心配置',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
showRefresh: true,
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
if (this.errorText.length > 0) {
PlainCard({ caption: '状态' }) {
Text(this.errorText)
.fontSize(12)
.fontColor('#E84026')
}
}
NavGroup({ caption: '核心' }) {
ForEach(this.sections, (sec: SettingsSection, idx: number) => {
NavRow({
icon: $r('app.media.ic_tune'),
title: sec.title,
subtitle: sec.count.toString() + ' 项配置',
showDivider: idx < this.sections.length - 1,
selected: this.highlightRows && this.activeSection === sec.id,
onTap: () => {
const cb: ((id: string) => void) | undefined = this.onOpenSection;
if (cb !== undefined) {
cb(sec.id);
}
},
})
}, (sec: SettingsSection) => sec.id + sec.count.toString())
}
// 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口
if (this.pluginKeyCount > 0) {
Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' +
this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.width('100%')
.padding({ left: 4, right: 4 })
}
if (this.sections.length === 0 && !this.busy) {
Text('未获取到配置分类。检查后端连接后点击刷新。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}
@Component
export struct SectionEntriesPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop sections: SettingsSection[] = [];
@Prop activeSection: string = '';
@Prop entries: SettingEntry[] = [];
@Prop busy: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
onSaveValue?: (key: string, value: string) => void;
onSaveCurrent?: (key: string) => void;
onEdit?: (key: string, value: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: activeSectionTitle(this.sections, this.activeSection),
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
showRefresh: true,
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
ForEach(this.entries, (entry: SettingEntry) => {
SettingsEntryCard({
entry: entry,
onSaveValue: (key: string, value: string) => {
const cb: ((k: string, v: string) => void) | undefined = this.onSaveValue;
if (cb !== undefined) {
cb(key, value);
}
},
onSaveCurrent: (key: string) => {
const cb: ((k: string) => void) | undefined = this.onSaveCurrent;
if (cb !== undefined) {
cb(key);
}
},
onEdit: (key: string, value: string) => {
const cb: ((k: string, v: string) => void) | undefined = this.onEdit;
if (cb !== undefined) {
cb(key, value);
}
},
})
}, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
if (!this.busy && this.entries.length === 0) {
Text('该分区暂无配置项')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding(16)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -0,0 +1,139 @@
/**
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
*
* 从 components/ChatComposer.ets 拆出 —— 两者都是"输入区上方的独立浮层"
* 数据与动作全部由输入区传入。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
ANIM_NORMAL } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { formatBytes } from '../common/AttachmentMeta';
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
@Component
export struct ChatAttachMenu {
@StorageProp('themeIsDark') private isDark: boolean = true;
onPickImage?: () => void;
onPickFile?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
@Builder
AttachOption(icon: Resource, label: string, tap: () => void) {
// 按压反馈交给 MotionBase每个实例自带独立按压态
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Row({ space: 6 }) {
Image(icon)
.width(15)
.height(15)
.fillColor(this.palette().textSecondary)
.draggable(false)
Text(label)
.fontSize(12)
.fontColor(this.palette().textPrimary)
}
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_PILL)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.onClick(tap)
}
}
build() {
// 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐
Row() {
Row({ space: 8 }) {
this.AttachOption($r('app.media.ic_image'), '图片', () => {
const cb: (() => void) | undefined = this.onPickImage;
if (cb !== undefined) {
cb();
}
})
this.AttachOption($r('app.media.ic_file'), '文件', () => {
const cb: (() => void) | undefined = this.onPickFile;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ bottom: 8 })
.hitTestBehavior(HitTestMode.Transparent)
// 加号菜单由 if 控制,进出场只能靠 transition配合 toggle 处的
// animateTo展开时两枚胶囊从加号上方浮起而不是硬闪出来。
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */
@Component
export struct ChatPendingChip {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop name: string = '';
@Prop byteSize: number = 0;
@Prop isImage: boolean = false;
@Prop uploading: boolean = false;
onRemove?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 按压反馈交给 MotionBase全宽预览条
MotionBase({ pressEnabled: true }) {
Row({ space: 8 }) {
Image(this.isImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
.width(15)
.height(15)
.fillColor(this.palette().accent)
.draggable(false)
Column({ space: 1 }) {
Text(this.name)
.fontSize(12)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.uploading ? '上传中...' : formatBytes(this.byteSize))
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.uploading) {
LoadingProgress()
.width(14)
.height(14)
.color(this.palette().accent)
} else {
Image($r('app.media.ic_close'))
.width(13)
.height(13)
.fillColor(this.palette().textMuted)
.draggable(false)
.onClick(() => {
const cb: (() => void) | undefined = this.onRemove;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.margin({ bottom: 8 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
}
}
}

View File

@ -0,0 +1,247 @@
/**
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
*
* 从 pages/ChatPage.ets 抽出(原来是 Avatar / ChanAvatar / BubbleSlot /
* MessageBubble / BubbleBody 五个 @Builder
*
* 传参约定一切都在构造时快照进来。ForEach 的键structSig只在
* 消息"结构"变化时改变(新增思考/工具/附件/来源、定稿),结构一变
* 气泡就整条重建,因此结构类字段不需要二次更新;唯一会在键不变时
* 持续变化的是正文 content所以它单独用基本类型 @Prop 传(与
* MarkdownView 的 @Prop content 走同一条响应式链路,流式渲染不变)。
*/
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
ANIM_NORMAL } from '../common/Constants';
import { bubbleMaxWidth, chanColor, chanLabel, chanLetter } from '../common/ChatFormat';
import { AttachmentCard } from './Attachment';
import { MarkdownView } from './MarkdownView';
import { ChatReasoningCard, ChatToolCard } from './ChatToolCard';
@Component
export struct ChatBubble {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop msgId: number = 0;
@Prop role: string = '';
/** 正文:唯一会在 ForEach 键不变时持续变化的字段 */
@Prop content: string = '';
@Prop isStreaming: boolean = false;
@Prop isFinal: boolean = false;
@Prop reasoningContent: string = '';
@Prop reasoningOpen: boolean = false;
@Prop toolCalls: ToolCallInfo[] = [];
@Prop attachment: ChatAttachment | undefined = undefined;
@Prop source: string = '';
/** 是否"别处来的"消息(渠道/设备) */
@Prop channel: boolean = false;
/** 是否自己发的(右对齐、"我"头像) */
@Prop mine: boolean = false;
/** 入场动画阶段 */
@Prop fresh: boolean = false;
onOpenAttachment?: (att: ChatAttachment) => void;
onToggleReasoning?: (msgId: number) => void;
onToggleTool?: (msgId: number, index: number) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 思考卡/工具卡是否是"面板":气泡宽度要放宽,见 common/ChatFormat */
private maxWidth(): string {
const msg: ChatMessage = {
id: this.msgId,
role: this.role,
content: this.content,
toolCalls: this.toolCalls,
};
if (this.reasoningContent.length > 0) {
msg.reasoningContent = this.reasoningContent;
}
return bubbleMaxWidth(msg);
}
@Builder
Avatar() {
Text(this.role === 'user' ? '我' : 'AI')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(this.role === 'user' ? this.palette().msgUserText : this.palette().accent)
.textAlign(TextAlign.Center)
.width(28)
.height(28)
.borderRadius(RADIUS_PILL)
.backgroundColor(this.role === 'user' ? this.palette().msgUserBg : this.palette().accentBg)
.margin({ top: 2 })
}
@Builder
ChanAvatar() {
Text(chanLetter(this.source))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width(28)
.height(28)
.borderRadius(RADIUS_PILL)
.backgroundColor(chanColor(this.source))
.margin({ top: 2 })
}
/**
* 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。
*
* 原来 BubbleBody 直接放进 Row它的 constraintSize maxWidth 是百分比
* 78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带
* 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding
* 于是 92% 算出来的宽度里包含了这些 padding再加上 28 的头像和 8 的
* 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来,
* 分栏后左栏只有 420vp溢出的十几 vp 直接被栏宽裁掉 —— 表现为
* 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp
* 被 clip 的问题是同一个成因)。
*
* 修法同 MarkdownView用 layoutWeight(1) 拿"剩余空间"而不是百分比。
* 槽自身无 padding外框宽 == 内容宽 == 头像与间距之外的真实可用宽度,
* 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。
*/
@Builder
BubbleSlot() {
Column() {
this.BubbleBody()
}
.layoutWeight(1)
.alignItems(this.mine ? HorizontalAlign.End : HorizontalAlign.Start)
}
@Builder
BubbleBody() {
Column() {
// 渠道来源名(对齐 GUI 的 msg-chan-name只有别处来的消息才显示
if (this.channel) {
Text(chanLabel(this.source))
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ bottom: 4 })
}
// Reasoning card (assistant only)
if (this.role === 'assistant' && this.reasoningContent.length > 0) {
ChatReasoningCard({
content: this.reasoningContent,
open: this.reasoningOpen,
sweeping: this.isFinal !== true && this.isStreaming === true,
fresh: this.fresh,
onToggle: () => {
const cb: ((id: number) => void) | undefined = this.onToggleReasoning;
if (cb !== undefined) {
cb(this.msgId);
}
},
})
}
// 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页
if (this.attachment !== undefined) {
AttachmentCard({
att: this.attachment,
mine: this.role === 'user',
onTap: () => {
const a: ChatAttachment | undefined = this.attachment;
const cb: ((att: ChatAttachment) => void) | undefined = this.onOpenAttachment;
if (a !== undefined && cb !== undefined) {
cb(a);
}
},
})
}
// Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn
if (this.content.length > 0) {
if (this.role === 'assistant') {
MarkdownView({
content: this.content,
isStreaming: this.isStreaming === true,
isDark: this.isDark,
})
} else {
Text(this.content)
.fontSize(15)
.lineHeight(24)
.fontColor(this.palette().msgBubbleText)
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
.constraintSize({ maxWidth: '100%' })
.margin({ top: this.attachment !== undefined ? 8 : 0 })
}
}
// Tool cards
if (this.toolCalls.length > 0) {
Column() {
ForEach(this.toolCalls, (tc: ToolCallInfo, tci: number) => {
ChatToolCard({
tc: tc,
index: tci,
onToggle: (index: number) => {
const cb: ((id: number, i: number) => void) | undefined = this.onToggleTool;
if (cb !== undefined) {
cb(this.msgId, index);
}
},
})
}, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name)
}
// 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。
// 让它自适应最大宽约束由气泡内容框向下传递ChatToolCard 内部用 layoutWeight 取满。
.alignItems(HorizontalAlign.Start)
.margin({ top: this.content.length > 0 ? 6 : 0 })
}
}
.constraintSize({ maxWidth: this.maxWidth() })
.clip(true)
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(this.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg)
.borderRadius({
topLeft: RADIUS_MD,
topRight: RADIUS_MD,
bottomLeft: this.role === 'assistant' ? 4 : RADIUS_MD,
bottomRight: this.role === 'user' ? 4 : RADIUS_MD,
})
.border({ width: 1, color: this.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder })
.shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 })
.alignItems(HorizontalAlign.Start)
// bubbleGrow: 气泡入场缩放效果
.scale({
x: this.fresh ? 0.95 : 1,
y: this.fresh ? 0.95 : 1,
})
.animation({ duration: 200, curve: Curve.EaseOut })
}
build() {
Row({ space: 8 }) {
if (this.channel) {
this.ChanAvatar()
this.BubbleSlot()
} else if (this.role === 'user') {
this.BubbleSlot()
this.Avatar()
} else {
this.Avatar()
this.BubbleSlot()
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配,
// 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。
.justifyContent(this.mine ? FlexAlign.End : FlexAlign.Start)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
// 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0)
.opacity(this.fresh ? 0 : 1)
.translate({ y: this.fresh ? 8 : 0 })
.animation({ duration: 180, curve: Curve.EaseOut })
}
}

View File

@ -0,0 +1,361 @@
/**
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
*
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 的层2 + AttachMenu /
* AttachOption / PendingAttachmentChip 三个 @Builder外加选图/选文件、
* 沙箱落盘、带附件发送这一整套方法)。
*
* 输入区自己持有文本与附件状态;只有会影响【列表底部留白】的三项
* inputMultiLine / attachMenuOpen / pendingName用 @Link 与页面共享。
*/
import { ChatMessage } from '../model/Model';
import { userMessage } from '../common/UserError';
import { connStore } from '../common/ConnStore';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
import { mimeOf } from '../common/ChatFormat';
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
import { fileNameOf } from '../common/AttachmentMeta';
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
import { picker, fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { MeasureOptions } from '@ohos.measure';
/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */
const INPUT_FONT_SIZE: number = 14;
const INPUT_INNER_PAD: number = 16;
/** 单行态左右让位:左边加号 42+8右边发送键 44+8 */
const INPUT_LEFT_GAP: number = 50;
const INPUT_RIGHT_GAP: number = 52;
@Component
export struct ChatComposer {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
/** 输入框是否已进入多行态(页面用它算列表底部留白) */
@Link inputMultiLine: boolean;
/** 加号菜单是否展开(同上) */
@Link attachMenuOpen: boolean;
/** 待发送附件的展示名(同上) */
@Link pendingName: string;
@State inputText: string = '';
@State pendingSize: number = 0;
@State pendingIsImage: boolean = false;
@State uploading: boolean = false;
private pendingPath: string = '';
private pendingMime: string = '';
/** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */
private inputRowWidth: number = 0;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 输入框底:高不透明度 + blur保证背景内容不会透过输入文字 */
private inputSolidBg(): string {
return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)';
}
/**
* 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。
*
* 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。
* 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关,
* 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。
*/
private recomputeMultiLine(text: string): void {
const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP
- INPUT_INNER_PAD * 2;
if (avail <= 0) {
return;
}
let multi: boolean = text.indexOf('\n') >= 0;
if (!multi && text.length > 0) {
const opt: MeasureOptions = {
textContent: text,
fontSize: INPUT_FONT_SIZE,
};
const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt);
// measureTextSize 返回 px可用宽度是 vp换算后再比
const widthVp: number = this.getUIContext().px2vp(size.width as number);
multi = widthVp > avail;
}
if (multi !== this.inputMultiLine) {
this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => {
this.inputMultiLine = multi;
});
}
}
// ===================== 附件:选择与上传 =====================
/** 从图库挑一张图 */
private async pickImage(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false;
});
try {
const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new picker.PhotoViewPicker();
const result = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return;
}
this.stagePickedFile(result.photoUris[0], true);
} catch (e) {
chatStore.setStage(userMessage('chat.pickImage', e));
chatStore.forceRefresh();
}
}
/** 从文件管理器挑一个文件 */
private async pickFile(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false;
});
try {
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1;
const docPicker = new picker.DocumentViewPicker();
const uris: string[] = await docPicker.select(options);
if (uris.length === 0) {
return;
}
this.stagePickedFile(uris[0], false);
} catch (e) {
chatStore.setStage(userMessage('chat.pickFile', e));
chatStore.forceRefresh();
}
}
/**
* 把 picker 给的 URI 复制到应用沙箱。
* http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径,
* 直接把 picker 的 media:// URI 交过去会读不到内容。
*/
private stagePickedFile(srcUri: string, isImage: boolean): void {
try {
const ctx = getContext(this) as common.UIAbilityContext;
const name: string = fileNameOf(srcUri);
const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name;
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
const destFile = fileIo.openSync(destPath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.copyFileSync(srcFile.fd, destFile.fd);
fileIo.closeSync(srcFile);
fileIo.closeSync(destFile);
const stat = fileIo.statSync(destPath);
this.clearPendingFile();
this.pendingPath = destPath;
this.pendingName = name;
this.pendingSize = stat.size;
this.pendingIsImage = isImage;
this.pendingMime = mimeOf(name, isImage);
} catch (e) {
chatStore.setStage(userMessage('chat.stageFile', e));
}
chatStore.forceRefresh();
}
/** 丢弃待发送附件,并删掉沙箱里的临时副本 */
private clearPendingFile(): void {
if (this.pendingPath.length > 0) {
try {
fileIo.unlinkSync(this.pendingPath);
} catch (e) {
// 已不存在,忽略
}
}
this.pendingPath = '';
this.pendingName = '';
this.pendingSize = 0;
this.pendingIsImage = false;
this.pendingMime = '';
}
/** 发送:有附件走 multipartPOST /chat/file否则走 POST /chat */
private async send(): Promise<void> {
if (this.loading || this.uploading) {
return;
}
// 没连后端时直接返回、且不清输入/不动附件:与拆分前 sendChat 的守卫顺序一致
if (connStore.getCurrentConnection() === null) {
return;
}
const text: string = this.inputText.trim();
if (this.pendingPath.length > 0) {
const path: string = this.pendingPath;
const name: string = this.pendingName;
const size: number = this.pendingSize;
const isImage: boolean = this.pendingIsImage;
const mime: string = this.pendingMime;
this.inputText = '';
this.inputMultiLine = false;
this.uploading = true;
await sendChatFile(text, path, name, size, isImage, mime);
this.uploading = false;
this.clearPendingFile();
chatStore.forceRefresh();
chatStore.requestScroll();
return;
}
if (text.length === 0) {
return;
}
this.inputText = '';
this.inputMultiLine = false;
await sendChatText(text);
}
// ===================== UI =====================
build() {
NavFloatOverlay({ tab: 0 }) {
// 加号展开的两个选项(图片 / 文件),点一次收起
if (this.attachMenuOpen) {
ChatAttachMenu({
onPickImage: () => {
this.pickImage();
},
onPickFile: () => {
this.pickFile();
},
})
}
// 待发送附件预览(选好图片/文件、还没点发送时显示)
if (this.pendingName.length > 0) {
ChatPendingChip({
name: this.pendingName,
byteSize: this.pendingSize,
isImage: this.pendingIsImage,
uploading: this.uploading,
onRemove: () => {
this.clearPendingFile();
chatStore.forceRefresh();
},
})
}
// Stack 而不是 Column加号与发送按钮钉死在底部这一行不动
// 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。
Stack({ alignContent: Alignment.Bottom }) {
// 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮
Row() {
FloatIconButton({
icon: $r('app.media.ic_plus'),
onTap: () => {
// 菜单展开会同时改变列表底部留白,用 animateTo 把
// 列表内边距和菜单进出场拉到同一个时钟上。
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = !this.attachMenuOpen;
});
},
})
Blank()
if (this.loading) {
Button() {
Image($r('app.media.ic_stop'))
.width(16)
.height(16)
.fillColor(Color.White)
}
.width(44)
.height(44)
.type(ButtonType.Circle)
.backgroundColor('#77809A')
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
.onClick(() => {
interruptChat();
})
} else {
Button() {
Image($r('app.media.ic_send'))
.width(18)
.height(18)
.fillColor(Color.White)
}
.width(44)
.height(44)
.type(ButtonType.Circle)
.backgroundColor(this.palette().accent)
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
.onClick(() => {
this.send();
})
}
}
.width('100%')
.height(44)
.alignItems(VerticalAlign.Center)
.onAreaChange((_o: Area, n: Area) => {
// 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。
const w: number = n.width as number;
if (Math.abs(w - this.inputRowWidth) > 0.5) {
this.inputRowWidth = w;
this.recomputeMultiLine(this.inputText);
}
})
// 上层:输入框。
// 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行;
// 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。
//
// 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成
// 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。
// 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行,
// 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。
Row() {
TextArea({
placeholder: '输入消息...',
text: this.inputText,
})
.layoutWeight(1)
// 不写死高度:单行 44随文字换行自动增高最多约 5 行后内部滚动
.constraintSize({ minHeight: 44, maxHeight: 168 })
.fontSize(INPUT_FONT_SIZE)
.fontColor(this.palette().textPrimary)
.placeholderFont({ size: 13 })
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.inputSolidBg())
.backdropBlur(24)
.borderRadius(22)
.border({ width: 1, color: this.palette().glassBorder })
.padding({
left: INPUT_INNER_PAD,
right: INPUT_INNER_PAD,
top: 11,
bottom: 11,
})
.enterKeyType(EnterKeyType.Send)
.onChange((value: string) => {
this.inputText = value;
this.recomputeMultiLine(value);
})
.onSubmit(() => {
this.send();
})
}
.width('100%')
// 多行时必须显式写 0给 .padding() 传 undefined 在增量更新时会被当作
// "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。
.padding(this.inputMultiLine
? { left: 0, right: 0 }
: { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP })
.margin({ bottom: this.inputMultiLine ? 52 : 0 })
// 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。
// 不设 None 的话 padding 区域仍属于 Row会把点击吞掉 —— 发送键点不动。
// None = 自身不响应、子节点TextArea照常响应触摸落到下层那一行。
.hitTestBehavior(HitTestMode.None)
.animation({ duration: 260, curve: Curve.Friction })
}
.width('100%')
}
}
}

View File

@ -0,0 +1,285 @@
/**
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
*
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。
* 消息数组来自 common/ChatStore.ets用版本号 K_CHAT_REV 订阅,
* 版本变化时重取一次快照数组引用每次都是新的ForEach 的渲染语义
* 与拆分前 this.messages = this.messages.slice() 完全一致)。
*/
import { ChatMessage } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { SUB_CONNECTIONS } from '../common/SettingsModel';
import { navBar } from '../common/NavBarController';
import { ChatAttachment } from '../model/Model';
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat';
import { connStore } from '../common/ConnStore';
import { ChatBubble } from './ChatBubble';
import { PageTopBar } from './PageTopBar';
@Component
export struct ChatStream {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
@StorageProp(K_CHAT_STAGE) private stage: string = '';
/** 数组快照的订阅信号 */
@StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0;
/** "滚到底"请求信号 */
@StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0;
@State messages: ChatMessage[] = [];
/** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */
@Prop bottomPad: number = 210;
onOpenAttachment?: (att: ChatAttachment) => void;
private scroller: Scroller = new Scroller();
private autoScrolling: boolean = false;
/** 滚动世代号scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
private scrollGen: number = 0;
private navHidden: boolean = false;
aboutToAppear(): void {
this.messages = chatStore.messages();
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
//
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
// 构造之前就完成了requestScroll 递增的 chatScrollRev 就成了“挂载前
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
// “消息加载好了却停在顶部/中间,不滚到最新”。
if (this.messages.length > 0) {
this.scrollToBottom();
}
}
private onChatRev(): void {
this.messages = chatStore.messages();
}
private onScrollReq(): void {
this.scrollToBottom();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
* bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
* 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
*/
private opaqueBottomBg(): string {
return '#FF' + this.palette().bgPrimary.substring(1);
}
private transparentBottomBg(): string {
return '#00' + this.palette().bgPrimary.substring(1);
}
private scrollToBottom(): void {
this.autoScrolling = true;
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
// 用递增间隔重试到 ~1s让后几帧的布局增长也跟得上。
//
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
const gen: number = ++this.scrollGen;
const delays: number[] = [50, 120, 220, 360, 550, 800];
for (let i = 0; i < delays.length; i++) {
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.scroller.scrollEdge(Edge.Bottom);
}, delays[i]);
}
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.autoScrolling = false;
this.navHidden = false;
navBar.setVisible(true);
}, 900);
}
/**
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar
* 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
* 而布局padding不再依赖 navVisible故翻转只触发 GPU 变换,
* 不会引起布局回流——这是滑动流畅的关键。
*/
private handleScrollDirection(yOffset: number, state: ScrollState): void {
if (this.autoScrolling) {
return;
}
// 触顶(近顶部 60vp且服务端还有更早历史 → 向上懒加载下一页
if (yOffset < 60 && chatStore.hasMore()) {
chatStore.loadOlder();
}
if (state === ScrollState.Idle) {
if (this.navHidden) {
this.navHidden = false;
navBar.setVisible(true);
}
} else {
// Scroll / Fling向下/惯性滚动时隐藏导航与输入栏
if (!this.navHidden) {
this.navHidden = true;
navBar.setVisible(false);
}
}
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 层1消息列表铺满全屏内容从顶栏遮罩下方穿过时逐渐淡出
Column() {
Scroll(this.scroller) {
Column() {
ForEach(this.messages, (msg: ChatMessage, idx: number) => {
ChatBubble({
msgId: msg.id,
role: msg.role,
content: msg.content,
isStreaming: msg.isStreaming === true,
isFinal: msg.isFinal === true,
reasoningContent: msg.reasoningContent ?? '',
reasoningOpen: msg.reasoningOpen === true,
toolCalls: msg.toolCalls ?? [],
attachment: msg.attachment,
source: msg.source ?? '',
channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()),
mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())),
fresh: chatStore.isFresh(msg.id),
onOpenAttachment: (att: ChatAttachment) => {
const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment;
if (cb !== undefined) {
cb(att);
}
},
onToggleReasoning: (id: number) => {
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id));
chatStore.forceRefresh();
});
},
onToggleTool: (id: number, index: number) => {
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index));
chatStore.forceRefresh();
});
},
})
}, (msg: ChatMessage, idx: number) => structSig(msg))
if (this.loading) {
Row({ space: 8 }) {
LoadingProgress()
.width(16)
.height(16)
.color(this.palette().accent)
Text(this.stage.length > 0 ? this.stage : '处理中...')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.padding({ left: 52, top: 6, bottom: 6 })
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
.padding({
left: 14,
right: 14,
top: 76,
bottom: this.bottomPad,
})
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
this.handleScrollDirection(yOffset, state);
})
}
.width('100%')
.height('100%')
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
//
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
if (this.messages.length === 0 && !this.loading) {
Column({ space: 10 }) {
Image($r('app.media.ic_link'))
.width(34)
.height(34)
.fillColor(this.palette().textMuted)
.draggable(false)
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
Text(this.hasConn
? '在下方输入框发送第一条消息'
: '请先在“后端连接”里填写服务地址与 API Key')
.fontSize(12)
.fontColor(this.palette().textMuted)
.textAlign(TextAlign.Center)
if (!this.hasConn) {
Button('去设置连接')
.height(34)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.margin({ top: 4 })
.onClick(() => {
// 跨页信号:切到设置 Tab并让设置页直接打开连接二级页
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
})
}
}
.width('100%')
.height('100%')
.padding({ left: 44, right: 44 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
.hitTestBehavior(HitTestMode.Transparent)
}
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None触摸完全穿透
PageTopBar({ title: '聊天' })
// 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
// 而不是在玻璃后面清晰可见PageTopBar 顶部淡出手法的镜像,方向相反)。
Column()
.width('100%')
.height(170)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[this.transparentBottomBg(), 0.0],
[this.opaqueBottomBg(), 0.6],
[this.opaqueBottomBg(), 1.0],
],
})
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
}
}

View File

@ -0,0 +1,253 @@
/**
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
*
* 从 pages/ChatPage.ets 抽出(原来分别是 ReasoningCard / ToolCard 两个 @Builder
* 折叠状态与开关动作都交回调用方(状态在 chatStore 里,且展开/收起要在
* animateTo 帧内完成 —— 那需要组件上下文)。
*/
import { ToolCallInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
ANIM_NORMAL } from '../common/Constants';
import { tcRunning, tcError, tcLeftColor, tcIcoColor, tcStateLabel, tcStateColor } from '../common/ChatFormat';
@Component
export struct ChatReasoningCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 思考正文(已折叠时也带着,展开不再请求) */
@Prop content: string = '';
@Prop open: boolean = false;
/** 流式中:显示转圈 + 扫光条 */
@Prop sweeping: boolean = false;
/** 入场动画阶段:扫光条起始偏移靠它切换 */
@Prop fresh: boolean = false;
onToggle?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 两层结构,原因见 ChatBubble 的布局说明:
// 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度,
// 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满;
// 内层 holder Column 自身无 padding所以它的子节点写 width('100%')
// 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。
Row() {
Column() {
Row({ space: 7 }) {
Image($r('app.media.ic_sparkle'))
.width(12)
.height(12)
.fillColor(this.palette().accent)
Text('思考过程')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
// 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡
if (this.sweeping) {
LoadingProgress()
.width(11)
.height(11)
.color(this.palette().accent)
}
Blank()
Text(this.content.length > 0 ? this.content.length.toString() + ' 字' : '')
.fontSize(9.5)
.fontColor(this.palette().textMuted)
Image($r('app.media.ic_chevron_down'))
.width(14)
.height(14)
.fillColor(this.palette().textMuted)
.rotate({ angle: this.open ? 180 : 0 })
.animation({ duration: 200, curve: Curve.EaseOut })
}
.width('100%')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.onClick(() => {
const cb: (() => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb();
}
})
if (this.open) {
Text(this.content)
.fontSize(11.5)
.lineHeight(17)
.fontColor(this.palette().textTertiary)
.width('100%')
.padding({ left: 10, right: 10, bottom: 8 })
.maxLines(24)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.wordBreak(WordBreak.BREAK_ALL)
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep
if (this.sweeping) {
Stack() {
Row()
.height(2)
.borderRadius(2)
.width('200%')
.linearGradient({
angle: 90,
colors: [
['rgba(255,255,255,0.01)', 0],
[this.palette().accent, 0.35],
['rgba(255,255,255,0.01)', 0.5],
[this.palette().accent, 0.65],
['rgba(255,255,255,0.01)', 1],
],
})
.opacity(0.7)
.translate({ x: this.fresh ? '0%' : '-50%' })
.animation({ duration: 1200, curve: Curve.Linear })
}
.width('100%')
.clip(true)
.height(2)
.margin({ top: 6 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
.margin({ bottom: 6 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed })
}
}
@Component
export struct ChatToolCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop tc: ToolCallInfo;
/** 在所属消息 toolCalls 里的下标:开关动作要交回调用方按 (msgId, index) 定位 */
@Prop index: number = 0;
onToggle?: (index: number) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
// 同 ChatReasoningCard外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽
Row() {
Column() {
Row({ space: 6 }) {
if (tcError(this.tc)) {
Image($r('app.media.ic_error'))
.width(13).height(13)
.fillColor(tcIcoColor(this.tc, this.palette().accent))
} else if (tcRunning(this.tc)) {
LoadingProgress()
.width(12).height(12)
.color(tcIcoColor(this.tc, this.palette().accent))
} else {
Image($r('app.media.ic_check'))
.width(13).height(13)
.fillColor(tcIcoColor(this.tc, this.palette().accent))
}
Text(this.tc.name)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 名字可长可短,必须让它占剩余宽度并可收缩,
// 否则右侧状态文字会被挤出气泡78% 宽度 + clip 直接切掉)
.layoutWeight(1)
if (this.tc.plugin !== undefined && this.tc.plugin.length > 0) {
Text(this.tc.plugin)
.fontSize(9.5)
.fontColor(this.palette().textMuted)
.maxLines(1)
.flexShrink(0)
}
// 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标"
Text(tcStateLabel(this.tc))
.fontSize(9.5)
.fontWeight(FontWeight.Medium)
.fontColor(tcStateColor(this.tc))
.flexShrink(0)
Image($r('app.media.ic_chevron_down'))
.width(13).height(13)
.fillColor(this.palette().textMuted)
.flexShrink(0)
.rotate({ angle: this.tc.open === true ? 180 : 0 })
.animation({ duration: 150, curve: Curve.EaseOut })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.onClick(() => {
const cb: ((index: number) => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb(this.index);
}
})
if (this.tc.open === true) {
Column() {
if (this.tc.args.length > 0 && this.tc.args !== '{}') {
Text('参数')
.fontSize(9.5).fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ top: 6, bottom: 2 })
Text(this.tc.args)
.fontSize(11)
.fontColor(this.palette().preText)
.backgroundColor(this.palette().preBg)
.borderRadius(4)
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
}
if (this.tc.result !== undefined && this.tc.result.length > 0) {
Text('结果')
.fontSize(9.5).fontWeight(FontWeight.Medium)
.fontColor(this.palette().textMuted)
.margin({ top: 6, bottom: 2 })
Text(this.tc.result)
.fontSize(11)
.fontColor(this.palette().preText)
.backgroundColor(this.palette().preBg)
.borderRadius(4)
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
.maxLines(8)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 展开内容整体用 if 挂载transition 让参数/结果随 chevron 一起淡入
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.margin({ bottom: 4 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3, top: 1, right: 1, bottom: 1 },
color: {
left: tcLeftColor(this.tc, this.palette().accent),
top: this.palette().kvBorder,
right: this.palette().kvBorder,
bottom: this.palette().kvBorder,
},
})
}
}

View File

@ -0,0 +1,356 @@
/**
* 「后端连接」二级页面。
*
* 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、
* 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥)
* 都属于这一个功能域,收在一个组件里。
*
* connections / currentId 用 @Link 与一级页面共享:一级页的入口行
* 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。
*/
import { apiClient } from '../common/ApiClient';
import { connStore } from '../common/ConnStore';
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
import { SubPageLayer, PlainCard } from './SubPage';
import { common } from '@kit.AbilityKit';
@Component
export struct ConnectionsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Link connections: ConnectionConfig[];
@Link currentId: string;
onBack?: () => void;
onToast?: (msg: string, isError: boolean) => void;
@State showAddForm: boolean = false;
@State addFormVisible: boolean = false;
/**
* 表单当前在编辑哪条连接:空串表示新建。
*
* 之前只有"添加"入口ConnStore.updateConnection 写好了却没有任何调用者,
* 于是地址填错的连接只能删掉重建API Key 也得重敲)。同一套表单
* 靠这个 id 区分保存走 add 还是 update。
*/
@State editingId: string = '';
@State editUrl: string = '';
@State editApiKey: string = '';
@State editName: string = '';
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private toast(msg: string, isError: boolean): void {
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
if (cb !== undefined) {
cb(msg, isError);
}
}
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
private syncConnFlag(): void {
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
}
private currentConnName(): string {
for (let i = 0; i < this.connections.length; i++) {
if (this.connections[i].id === this.currentId) {
return this.connections[i].name;
}
}
return '未配置';
}
private selectConnection(id: string): void {
connStore.setCurrent(id).then(() => {
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('已切换连接', false);
});
}
private addConnection(): void {
const name: string = this.editName.trim();
const url: string = this.editUrl.trim();
const apiKey: string = this.editApiKey.trim();
if (name.length === 0 || url.length === 0) {
this.toast('名称和地址不能为空', true);
return;
}
if (this.editingId.length > 0) {
this.updateConnection(this.editingId, name, url, apiKey);
return;
}
connStore.addConnection(name, url, apiKey).then(() => {
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已添加', false);
});
}
/**
* 保存对已有连接的修改。
*
* 修改当前生效的连接后必须重新 setConnectionApiClient 持有的是
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
*/
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
connStore.updateConnection(id, name, url, apiKey).then(() => {
this.closeConnForm();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已更新', false);
});
}
/** 打开表单id 为空是新建非空是编辑并回填原值API Key 一并带出,避免用户重敲)。 */
private openConnForm(conn: ConnectionConfig | null): void {
this.showAddForm = true;
this.addFormVisible = false;
if (conn === null) {
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
} else {
this.editingId = conn.id;
this.editName = conn.name;
this.editUrl = conn.url;
this.editApiKey = conn.apiKey;
}
setTimeout(() => {
this.addFormVisible = true;
}, 30);
}
private closeConnForm(): void {
this.showAddForm = false;
this.addFormVisible = false;
this.editingId = '';
this.editName = '';
this.editUrl = '';
this.editApiKey = '';
}
private deleteConnection(id: string): void {
connStore.deleteConnection(id).then(() => {
this.connections = connStore.getConnections();
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
} else {
apiClient.clearConnection();
}
this.syncConnFlag();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已删除', false);
});
}
build() {
SubPageLayer({
title: '后端连接',
tab: 3,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
Column() {
PlainCard({ caption: '当前连接' }) {
Row() {
Column({ space: 3 }) {
Text(this.currentConnName())
.fontSize(15)
.fontColor(this.palette().textPrimary)
Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Button('+ 添加')
.height(30)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.openConnForm(null);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.showAddForm) {
Column() {
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
.fontSize(12)
.fontColor(this.palette().textSecondary)
.margin({ bottom: 10 })
TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
.margin({ bottom: 10 })
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl })
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
.margin({ bottom: 10 })
.onChange((v: string) => {
this.editUrl = v;
})
TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
.height(36).fontSize(13).fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border })
.type(InputType.Password).margin({ bottom: 12 })
.onChange((v: string) => {
this.editApiKey = v;
})
Row() {
Button('取消')
.height(30)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.closeConnForm();
})
Blank()
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.addConnection();
})
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().kvBorder })
.margin({ top: 12 })
.alignItems(HorizontalAlign.Start)
.opacity(this.addFormVisible ? 1 : 0)
.translate({ y: this.addFormVisible ? 0 : 12 })
.animation({ duration: 220, curve: Curve.EaseOut })
}
}
PlainCard({ caption: '全部连接' }) {
if (this.connections.length === 0) {
Text('暂无连接。点击上方“添加”配置后端地址。')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
ForEach(this.connections, (conn: ConnectionConfig) => {
Row() {
Circle({ width: 8, height: 8 })
.fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
.margin({ right: 10 })
Column() {
Text(conn.name)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(conn.url)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (conn.id !== this.currentId) {
Button('切换')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.selectConnection(conn.id);
})
} else {
Text('使用中')
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().accentBg)
.margin({ right: 6 })
}
Button('编辑')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.margin({ right: 6 })
.onClick(() => {
this.openConnForm(conn);
})
Button('删除')
.height(26)
.fontSize(11)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor('#E84026')
.onClick(() => {
this.deleteConnection(conn.id);
})
}
.width('100%')
.padding(10)
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3 },
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
})
.margin({ bottom: 6 })
// 键里带上 name/urlForEach 对相同键只更新绑定、不重跑 @Builder 体,
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -0,0 +1,316 @@
/**
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
*
* 从 pages/DevicePage.ets 抽出(原来是 LocalDeviceContent / CapsContent /
* GatewayContent / OnlineDevicesContent 四个 @Builder + KvRow
* 每个面板自带 SubPageLayer 外壳(标题、所属 Tab、返回、刷新
* 页面只保留路由分发 —— 与 SettingsPage 拆出的三个 Pane 同一套做法。
*/
import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL,
ANIM_ENTER } from '../common/Constants';
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
import { MotionBase } from './MotionBase';
import { PlainCard, SubPageLayer } from './SubPage';
/** 通用 KV 行(面板之间共用) */
@Component
export struct DeviceKvRow {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop label: string = '';
@Prop value: string = '';
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Row() {
Text(this.label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
Blank()
Text(this.value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
}
}
/** 二级:本机设备(基本信息 + 远程控制授权) */
@Component
export struct DeviceLocalPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop deviceId: string = '';
@Prop authorized: boolean = false;
onBack?: () => void;
onToggleAuth?: (on: boolean) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '本机设备',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '基本信息' }) {
DeviceKvRow({ label: '设备 ID', value: this.deviceId.length > 0 ? this.deviceId : '未注册' })
DeviceKvRow({ label: '名称', value: 'HomeAgent OHOS' })
DeviceKvRow({ label: '类型', value: 'phone' })
}
PlainCard({ caption: '权限控制' }) {
Row() {
Column({ space: 2 }) {
Text('允许 agent 控制本机')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
const cb: ((on: boolean) => void) | undefined = this.onToggleAuth;
if (cb !== undefined) {
cb(on);
}
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026')
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width('100%')
.margin({ top: 12 })
.padding({ left: 4 })
}
}
}
}
/** 二级:设备能力(本机能被 agent 调用的能力清单) */
@Component
export struct DeviceCapsPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
onBack?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '设备能力',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '能力清单' }) {
Text('agent 通过设备桥可调用的本机能力:')
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(999)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ right: 6, bottom: 6 })
}, (cap: string) => cap)
}
.width('100%')
}
}
}
}
/** 二级:设备通道(网关地址 / Token / 连接状态) */
@Component
export struct DeviceGatewayPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop bridgeUrl: string = '';
@Prop bridgeToken: string = '';
@Prop bridgeConnected: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '设备通道',
tab: 2,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
}) {
PlainCard({ caption: '连接信息' }) {
DeviceKvRow({ label: '网关地址', value: this.bridgeUrl.length > 0 ? this.bridgeUrl : '-' })
DeviceKvRow({ label: 'Token', value: this.bridgeToken.length > 0 ? '已从连接继承' : '未配置' })
DeviceKvRow({ label: '状态', value: this.bridgeConnected ? '已连接' : '未连接' })
}
PlainCard({ caption: '操作' }) {
Row() {
Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
}
}
}
/** 二级:接入的设备(网关侧在线设备列表) */
@Component
export struct DeviceListPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop devices: DeviceInfo[] = [];
onBack?: () => void;
onRefresh?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
SubPageLayer({
title: '接入的设备',
tab: 2,
showRefresh: true,
onBack: () => {
const cb: (() => void) | undefined = this.onBack;
if (cb !== undefined) {
cb();
}
},
onRefresh: () => {
const cb: (() => void) | undefined = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
}) {
DeviceOnlineList({ devices: this.devices })
}
}
}
/** 在线设备列表本体(抽出来只是为了让 DeviceListPane 的 build 更短) */
@Component
struct DeviceOnlineList {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop devices: DeviceInfo[] = [];
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
if (this.devices.length === 0) {
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.devices, (dev: DeviceInfo) => {
// PlainCard 是自定义组件transition 不能直接挂在它上面(会生成 __Common__ 包装),
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
Column() {
PlainCard({ caption: '' }) {
Row() {
Circle({ width: 8, height: 8 })
.fill(dev.online ? '#17A964' : '#77809A')
.margin({ right: 10 })
Column() {
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
.fontSize(11)
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(dev.caps.length.toString() + ' 能力')
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.width('100%')
}
}
.width('100%')
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,92 @@
/**
* 设备页一级入口列表(本机 / 通道两组)。
*
* 从 pages/DevicePage.ets 抽出(原来是 RootEntries 一个 @Builder
* 宽屏高亮由组件自己读 AppStorage 的 isWideScreen 决定,
* 页面只负责给 activeSub 与四个打开动作。
*/
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
import { SUB_CAPS, SUB_GATEWAY, SUB_LIST, SUB_LOCAL } from '../common/DeviceModel';
import { NavGroup, NavRow } from './SubPage';
@Component
export struct DeviceRootEntries {
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@Prop activeSub: string = '';
@Prop deviceId: string = '';
@Prop bridgeConnected: boolean = false;
@Prop bridgeUrl: string = '';
@Prop loadingDevices: boolean = false;
@Prop deviceCount: number = 0;
onOpen?: (id: string) => void;
private capsCount(): number {
return LOCAL_DEVICE_CAPS.length;
}
private open(id: string): void {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
}
build() {
// 一级入口列表整体作为一个容器根节点:@Component 的 build() 只允许一个根,
// 页面侧仍是 `.padding(...)` 的 Column逐项布局与拆分前一致。
Column() {
NavGroup({ caption: '本机' }) {
NavRow({
icon: $r('app.media.ic_phone'),
title: '本机设备',
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
value: this.bridgeConnected ? '在线' : '离线',
selected: this.isWide && this.activeSub === SUB_LOCAL,
onTap: () => {
this.open(SUB_LOCAL);
},
})
NavRow({
icon: $r('app.media.ic_bolt'),
title: '设备能力',
subtitle: this.capsCount().toString() + ' 项能力',
value: '',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_CAPS,
onTap: () => {
this.open(SUB_CAPS);
},
})
}
NavGroup({ caption: '通道' }) {
NavRow({
icon: $r('app.media.ic_gateway'),
title: '设备通道',
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
value: this.bridgeConnected ? '已连接' : '未连接',
selected: this.isWide && this.activeSub === SUB_GATEWAY,
onTap: () => {
this.open(SUB_GATEWAY);
},
})
NavRow({
icon: $r('app.media.ic_devices_multi'),
title: '接入的设备',
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
value: this.deviceCount.toString() + ' 台',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_LIST,
onTap: () => {
this.open(SUB_LIST);
},
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,291 @@
/**
* 插件详情(二级页面内容)。
*
* 从 pages/PluginsPage.ets 抽出。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 配置 / 操作。
*/
import { PluginRow, PluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
ANIM_FAST, COLOR_ERROR } from '../common/Constants';
import { pluginDetailStatusLine, pluginStatusColor } from '../common/PluginStatus';
import { PlainCard } from './SubPage';
import { SettingsEditor } from './SettingsEditor';
@Component
export struct PluginDetailPane {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop detail: PluginDetail;
/** 当前查看详情的插件名(可能还没拿到 detail.name */
@Prop activeName: string = '';
@Prop busy: boolean = false;
@Prop errorText: string = '';
/** 一级列表里对应的那一行;取不到详情时用它兜底,也为操作按钮提供状态 */
@Prop row: PluginRow | undefined = undefined;
onToggle?: () => void;
onRemove?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
// ---- 取值助手ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private isBuiltin(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? !r.external : false;
}
private isDisabled(): boolean {
const r: PluginRow | undefined = this.row;
return r !== undefined ? r.disabled : false;
}
private tools(): string[] {
const r: PluginRow | undefined = this.row;
if (r === undefined) {
return [];
}
return r.tools ?? [];
}
private statusLine(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginDetailStatusLine(r, this.detail.deprecated) : '未加载';
}
private statusColor(): string {
const r: PluginRow | undefined = this.row;
return r !== undefined ? pluginStatusColor(r, this.palette().textMuted) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
Badges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.statusColor())
Text(this.statusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 工具清单来自一级列表已合并的 kernel.tools按 plugin 归属) */
@Builder
ToolsCard() {
if (this.tools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.tools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.tools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
ActionsCard() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.isDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.isDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.isDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const cb: (() => void) | undefined = this.onToggle;
if (cb !== undefined) {
cb();
}
})
Blank()
if (!this.isBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const cb: (() => void) | undefined = this.onRemove;
if (cb !== undefined) {
cb();
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
build() {
// 单一根节点:与原来作为 @Builder 直接铺在 SubPageLayer 的 Column 里同构,
// 保持 alignItems Start避免文本被默认居中对齐。
Column() {
if (this.busy) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.Badges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.ToolsCard()
// 插件配置plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.ActionsCard()
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,166 @@
/**
* 插件一级列表(列表卡 + 三种占位态)。
*
* 从 pages/PluginsPage.ets 抽出:列表只负责"选谁"
* 描述/工具/启停全部下沉到详情页。
*/
import { PluginRow } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD,
ANIM_FAST, ANIM_ENTER } from '../common/Constants';
import { pluginRowSubtitle, pluginStatusColor } from '../common/PluginStatus';
import { noConnectionMessage } from '../common/UserError';
import { MotionBase } from './MotionBase';
/**
* 单个插件行卡。
*
* 外层 Column 只为承载 transition.transition() 不能直接挂在自定义组件
* 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
* 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
*/
@Component
export struct PluginListCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugin: PluginRow;
/** 是否当前选中(宽屏高亮左侧列表项)。单独用基本类型传,选中态变更才能触发更新 */
@Prop active: boolean = false;
onTap?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
MotionBase({ pressEnabled: true }) {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(this.plugin.name)
.fontSize(15)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (this.plugin.version !== undefined && this.plugin.version.length > 0) {
Text('v' + this.plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(pluginStatusColor(this.plugin, this.palette().textMuted))
Text(pluginRowSubtitle(this.plugin))
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.active ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.active ? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.active ? this.palette().accent : this.palette().glassBorder,
})
// 选中态的底色/描边渐变MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center)
.onClick(() => {
const cb: (() => void) | undefined = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
.width('100%')
.margin({ bottom: 10 })
// ForEach key 含 loaded/disabled启停会整行重挂载靠 transition 变成交叉淡入
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
/**
* 一级列表整体:占位态 + 行卡 ForEach。
* 取数留在页面里,这里只吃数据。
*/
@Component
export struct PluginListView {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop plugins: PluginRow[] = [];
@Prop busy: boolean = false;
@Prop activeName: string = '';
@Prop hasConn: boolean = false;
onSelect?: (name: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Column() {
// ── 加载中 / 未配置 / 空列表三种占位态 ──
if (this.busy && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.hasConn) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.busy && this.plugins.length === 0 && this.hasConn) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.plugins, (plugin: PluginRow) => {
PluginListCard({
plugin: plugin,
active: this.activeName === plugin.name,
onTap: () => {
const cb: ((name: string) => void) | undefined = this.onSelect;
if (cb !== undefined) {
cb(plugin.name);
}
},
})
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
.width('100%')
}
}

View File

@ -0,0 +1,65 @@
/**
* 安装表单悬浮卡。
*
* 从 pages/PluginsPage.ets 抽出(浮在内容之上的独立图层,与列表/详情无关)。
* 轻提示条与设置页共用,见 components/ToastBar.ets。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
ANIM_ENTER } from '../common/Constants';
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Component
export struct PluginInstallForm {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop url: string = '';
onUrlChange?: (v: string) => void;
onInstall?: () => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.url })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
const cb: ((v: string) => void) | undefined = this.onUrlChange;
if (cb !== undefined) {
cb(v);
}
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
const cb: (() => void) | undefined = this.onInstall;
if (cb !== undefined) {
cb();
}
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}

View File

@ -0,0 +1,385 @@
import {
ThemePalette, DARK_PALETTE, LIGHT_PALETTE,
RADIUS_SM, RADIUS_MD, ANIM_FAST,
COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300,
} from '../common/Constants';
import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore';
import {
stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup,
K_STAGE_PHASE, K_STAGE_REV,
} from '../common/StageTrail';
/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */
const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A'];
/** 阶段管道里的「工具」格(循环格):一轮内可能调几十次,只露最新一条 */
const TOOL_GROUP: number = 2;
/** 索引 = lv0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4 */
function lvColor(lv: number): string {
if (lv < 0 || lv > 4) {
return COLOR_ACCENT;
}
return LV_COLORS[lv];
}
/**
* 运行态面板:阶段管道 + 中断队列。
*
* 与 WebUI / 桌面版同一套设计语言:**等大表框**。
* 此前鸿蒙端完全没有运行态展示「状态」Tab 已并入设置页 + 二级明细),
* 这里补在二级明细页顶部。
*
* 两块数据来源不同:
* - 队列 / 计数StatusStore 轮询 /runtime靠 K_REV 通知;
* - 阶段 + 本轮轨迹StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。
*/
@Component
export struct RuntimePanel {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏(平板/折叠5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0;
@StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0;
@StorageProp(K_STAGE_PHASE) private phase: string = '';
@State private snap: RuntimeSnapshot | undefined = undefined;
/** 格槽下标ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */
@State private slotIdx: number[] = [];
@State private trail: StageEvent[] = [];
aboutToAppear(): void {
this.pull();
}
private onStatusRev(): void {
this.pull();
}
private onStageRev(): void {
this.trail = stageTrail.snapshot();
}
private pull(): void {
const s: RuntimeSnapshot | undefined = statusStore.getRuntime();
this.snap = s;
if (s !== undefined) {
const idx: number[] = [];
for (let i = 0; i < s.slots; i++) {
idx.push(i);
}
this.slotIdx = idx;
} else {
this.slotIdx = [];
}
this.trail = stageTrail.snapshot();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 该阶段本轮发生的事件 */
private eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
/** 该阶段本轮事件的**最后一条**(最新)。没有则 undefined。 */
private latestEvent(group: number): StageEvent | undefined {
let out: StageEvent | undefined = undefined;
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
out = e;
}
}
return out;
}
/** 该阶段本轮事件条数合计(同一工具连调会累加到 count 上) */
private totalCount(group: number): number {
let n: number = 0;
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
n += e.count;
}
}
return n;
}
@Builder
tile(label: string, value: string, warn: boolean) {
Column({ space: 2 }) {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(warn ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
Text(label)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
}
/** 阶段管道的一格 */
@Builder
stageCell(g: StageGroup, active: boolean) {
Column({ space: 6 }) {
Row({ space: 5 }) {
// 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源,
// 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。
// 不用 Circle().fill():那是 SDK 26 起的 API本工程兼容版本是 6.1.1(24)。
Row()
.width(7)
.height(7)
.borderRadius(4)
.backgroundColor(active ? this.palette().accent : this.palette().textMuted)
Text(g.label)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(active ? this.palette().accent : this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
// 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着。
//
// 「工具」格是循环格:一轮里可能调几十次工具/输出通道。把每一次都追加成
// 一行,这格会被撑成长条,反而看不出「现在在调什么」。所以它只保留**最新
// 一条**,右侧给本轮累计次数(与 WebUI/桌面版同一口径)。
Column({ space: 3 }) {
if (g.group === TOOL_GROUP) {
if (this.eventsOf(g.group).length > 0) {
Row({ space: 3 }) {
Text(this.latestEvent(g.group)?.short ?? '')
.fontSize(9)
.fontColor(this.latestEvent(g.group)?.kind === 'output' ? this.palette().accent
: (this.latestEvent(g.group)?.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('x' + this.totalCount(g.group).toString())
.fontSize(9)
.fontColor(this.palette().textMuted)
}
.width('100%')
} else {
Text('无')
.fontSize(9)
.fontColor(this.palette().textMuted)
.opacity(0.5)
}
} else {
ForEach(this.eventsOf(g.group), (e: StageEvent) => {
Row({ space: 3 }) {
Text(e.short)
.fontSize(9)
.fontColor(e.kind === 'output' ? this.palette().accent
: (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.count > 1) {
Text('x' + e.count.toString())
.fontSize(9)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
}, (e: StageEvent, i: number) => i.toString() + ':' + e.short)
if (this.eventsOf(g.group).length === 0) {
Text('无')
.fontSize(9)
.fontColor(this.palette().textMuted)
.opacity(0.5)
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(active ? this.palette().accentBg : this.palette().bgHover)
.border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
/** 中断队列的一格 */
@Builder
queueCell(q: RuntimeQueue) {
Column({ space: 5 }) {
Row({ space: 4 }) {
Text(q.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(lvColor(q.lv))
.maxLines(1)
Text(q.desc)
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Text(q.depth.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.maxLines(1)
// 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0
// 整格只剩文字,看上去就是「这块空着」。
Row({ space: 2 }) {
ForEach(this.slotIdx, (i: number) => {
Row()
.layoutWeight(1)
.height(11)
.borderRadius(2)
.backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover)
}, (i: number) => q.lv.toString() + '-' + i.toString())
}
.width('100%')
if (q.lv > 0) {
Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
} else {
Text('无级别')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: 1,
color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder,
style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid,
})
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
build() {
Column() {
Text('运行态')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.margin({ bottom: 8 })
if (this.snap === undefined) {
Text('运行态数据不可用')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ top: 4, bottom: 4 })
} else {
// 四个数字块
Row({ space: 8 }) {
this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0)
this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0)
this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(),
this.snap.stack > 0)
this.tile('子代理', this.snap.subagents.toString(), false)
}
.width('100%')
// 阶段管道:等大表框,事件落在所属阶段那一格
Text('阶段管道')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS, (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'w' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n0' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n1' + g.group.toString())
// 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
// 中断队列五个等大表框L4/L3/L2/L1 + 排队)
Text('队列')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(this.snap.queues, (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'wq' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq0' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq1' + q.lv.toString())
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(16)
.margin({ bottom: 14 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
}
}

View File

@ -0,0 +1,197 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
import { GradientBackground } from './GradientBackground';
import { PageTopBar } from './PageTopBar';
import { MotionBase } from './MotionBase';
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
import { webview } from '@kit.ArkWeb';
/**
* agent 主动推送的前台内容页。
*
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
* 从而让左侧一级页面和主导航保持可见、可操作。
*
* 内容可能是纯文本,也可能是 HTML服务端两侧协议都允许见 ScreensueHtml
*
* HTML 走 **Web 组件**用户明确要求agent 推的常是完整文档 —— 带 <style>
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
* 对这些一律不渲染,实测只能看到满屏源码。
*
* 安全:内容来自 agent第三方所以显式关掉 JS 与本地文件访问 ——
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
* 纯文本仍走 Text无需开销也不该把文本塞进 Web
*/
@Component
export struct ScreensuePage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop pushedText: string = '';
@Prop countdown: number = 0;
onClose: () => void = () => {
};
/** 内容是不是 HTML决定走 Web 还是 Text。 */
private htmlMode(): boolean {
return looksLikeHtml(this.pushedText);
}
/** HTML 的 base64 载荷(空串表示不是 HTML。 */
private webData(): string {
return screensueWebData(this.pushedText, this.isDark);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
GradientBackground()
Scroll() {
Column({ space: 14 }) {
Row({ space: 8 }) {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
Blank()
if (this.countdown > 0) {
Text(this.countdown.toString() + 's')
.fontSize(12)
.fontColor(this.palette().textMuted)
} else {
Text('常驻')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
Column() {
if (this.htmlMode()) {
// HTML整篇交给 Web 渲染base64 loadData见 screensueWebData
// 高度固定 420vpWeb 不参与父级自适应测量,给 height('100%')
// 会在 Scroll 里塌成 0。内容区本身可滚。
ScreenWebView({ data: this.webData(), isDark: this.isDark })
.width('100%')
.height(420)
} else {
Text(this.pushedText)
.fontSize(16)
.lineHeight(25)
.fontColor(this.palette().textPrimary)
.width('100%')
.textAlign(TextAlign.Start)
.copyOption(CopyOptions.LocalDevice)
}
}
.width('100%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.align(Alignment.Top)
PageTopBar({ title: '推送内容' })
Row() {
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('关闭')
.height(42)
.padding({ left: 22, right: 22 })
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.backgroundColor(this.palette().accent)
.borderRadius(21)
.onClick(() => {
this.onClose();
})
}
}
.width('100%')
.padding({ left: 18, right: 18, bottom: 22 })
.justifyContent(FlexAlign.End)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 18 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
.width('100%')
.height('100%')
.backgroundColor(this.palette().bgPrimary)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
*
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web
* `WebviewController` 与 Web 组件是一对一绑定的必须等组件挂载onControllerAttached
* 才能真正 loadData把它隔离在这里ScreensuePage 只管布局与倒计时。
*/
@Component
struct ScreenWebView {
// @Watch 挂在这里是必须的ScreensuePage 在 `if (screensueVisible)` 里常驻,
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
// 实测:连推两条不同 HTML倒计时变了、Web 里还是旧画面。
@Prop @Watch('onDataChanged') data: string = '';
@Prop isDark: boolean = true;
private controller: webview.WebviewController = new webview.WebviewController();
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001。 */
private attached: boolean = false;
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
onDataChanged(): void {
if (this.attached) {
this.load();
}
}
build() {
Web({ src: '', controller: this.controller })
// ★ 内容来自 agent第三方显式关闭脚本与本地文件访问。
// javaScriptAccess 的默认值是 true不写这一行等于放任远端内容执行脚本。
.javaScriptAccess(false)
.fileAccess(false)
.domStorageAccess(false)
.onlineImageAccess(false)
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
.horizontalScrollBarAccess(false)
.verticalScrollBarAccess(false)
.darkMode(WebDarkMode.Off)
.backgroundColor(Color.Transparent)
// 控制器挂载完才 loadData过早调用会抛 17100001控制器未与组件关联
.onControllerAttached(() => {
// 挂载完成才允许 loadData此前的变更由 onDataChanged 记着,这里补一次。
this.attached = true;
this.load();
})
.width('100%')
.height('100%')
}
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
private load(): void {
if (this.data.length === 0) {
return;
}
try {
this.controller.loadData(this.data, 'text/html', 'base64');
} catch (e) {
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
}
}
}

View File

@ -0,0 +1,215 @@
/**
* 单个配置项卡片。
*
* 从 pages/SettingsPage.ets 抽出:按 type 分发控件bool/select/password/text/其他),
* 值的保存与「未保存」标记交回页面(页面上持有 valuesStore 与 entries
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
import { SettingEntry } from '../common/SettingsModel';
@Component
export struct SettingsEntryCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop entry: SettingEntry;
/** bool 开关 / select 选项:值已知,直接落库 */
onSaveValue?: (key: string, value: string) => void;
/** 输入类控件的保存:由页面取该 key 的最新编辑值再落库 */
onSaveCurrent?: (key: string) => void;
/** 输入框内容变化:只更新本地标记,不请求 */
onEdit?: (key: string, value: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private saveValue(value: string): void {
const cb: ((key: string, value: string) => void) | undefined = this.onSaveValue;
if (cb !== undefined) {
cb(this.entry.key, value);
}
}
private saveCurrent(): void {
const cb: ((key: string) => void) | undefined = this.onSaveCurrent;
if (cb !== undefined) {
cb(this.entry.key);
}
}
private editValue(value: string): void {
const cb: ((key: string, value: string) => void) | undefined = this.onEdit;
if (cb !== undefined) {
cb(this.entry.key, value);
}
}
build() {
Column() {
Row() {
Text(this.entry.displayName)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.entry.dirty ? '未保存' : this.entry.type)
.fontSize(10)
.fontColor(this.entry.dirty ? '#D99A2B' : this.palette().textMuted)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover)
}
.width('100%')
Text(this.entry.key)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 1 })
if (this.entry.description.length > 0) {
Text(this.entry.description)
.fontSize(11)
.fontColor(this.palette().textSecondary)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
// control row per type
if (this.entry.type === 'bool') {
Row() {
Text(this.entry.value === 'true' ? 'true' : 'false')
.fontSize(12)
.fontColor(this.entry.value === 'true' ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.entry.value === 'true' })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.saveValue(on ? 'true' : 'false');
})
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'select' && this.entry.options.length > 0) {
Flex({
direction: FlexDirection.Row,
justifyContent: FlexAlign.Start,
alignItems: ItemAlign.Center,
wrap: FlexWrap.Wrap,
}) {
ForEach(this.entry.options, (opt: string) => {
Button(opt)
.height(26)
.fontSize(11)
.margin({ right: 6, bottom: 6 })
.backgroundColor(this.entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(this.entry.value === opt ? Color.White : this.palette().textSecondary)
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
this.saveValue(opt);
})
}, (opt: string) => opt)
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'password') {
Row() {
TextInput({ text: this.entry.value })
.height(36)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.type(InputType.Password)
.layoutWeight(1)
.onChange((v: string) => {
this.editValue(v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.margin({ left: 8 })
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 8 })
} else if (this.entry.type === 'text') {
TextArea({ text: this.entry.value })
.width('100%')
.fontSize(13)
.fontColor(this.palette().textPrimary)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.constraintSize({ minHeight: 60, maxHeight: 200 })
.margin({ top: 8 })
.onChange((v: string) => {
this.editValue(v);
})
Row() {
Blank()
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 6 })
} else {
// string / int / duration
Row() {
TextInput({ text: this.entry.value })
.height(36)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.layoutWeight(1)
.onChange((v: string) => {
this.editValue(v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.margin({ left: 8 })
.onClick(() => {
this.saveCurrent();
})
}
.width('100%')
.margin({ top: 8 })
}
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgCard)
.border({
width: 1,
color: this.entry.dirty ? '#D99A2B' : this.palette().glassBorder,
})
.margin({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,95 @@
/**
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
*
* 从 pages/SettingsPage.ets 抽出:页面本身只剩数据流与导航表,
* 这一层是纯布局 —— 所有计数/文案都由页面算好传进来。
*/
import { handleNavOnScroll } from '../common/NavBarController';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { PageTopBar, NavFloatOverlay, NavFloatRow } from './PageTopBar';
import { SettingsRootEntries } from './SettingsRootEntries';
import { ToastBar } from './ToastBar';
@Component
export struct SettingsHome {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 一级入口列表所需的计数/文案(见 SettingsRootEntries */
@Prop connCount: number = 0;
@Prop connName: string = '';
@Prop sectionCount: number = 0;
@Prop coreKeys: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
@Prop themeLabel: string = '';
@Prop activeSub: string = '';
@Prop isWide: boolean = false;
/** 保存进行中:悬浮区显示转圈 */
@Prop savingCount: number = 0;
@Prop toastMsg: string = '';
@Prop toastIsError: boolean = false;
onOpen?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
SettingsRootEntries({
connCount: this.connCount,
connName: this.connName,
sectionCount: this.sectionCount,
coreKeys: this.coreKeys,
busy: this.busy,
errorText: this.errorText,
themeLabel: this.themeLabel,
activeSub: this.activeSub,
isWide: this.isWide,
onOpen: (id: string) => {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
},
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
}
.width('100%')
.height('100%')
PageTopBar({ title: '设置' })
// 一级悬浮区:仅在保存进行中显示一个转圈;
// 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌)
NavFloatOverlay({ tab: 3 }) {
NavFloatRow() {
if (this.savingCount > 0) {
LoadingProgress()
.width(14)
.height(14)
.color(this.palette().accent)
}
}
}
ToastBar({ msg: this.toastMsg, isError: this.toastIsError })
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
}

View File

@ -0,0 +1,100 @@
/**
* 设置一级页的入口列表。
*
* 从 pages/SettingsPage.ets 抽出:纯展示 + 跳转回调,
* 所有计数/文案由页面算好传进来(页面才是这些状态的持有者)。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { SUB_STATUS, SUB_CONNECTIONS, SUB_BACKEND, SUB_SECTION, SUB_APPEARANCE } from '../common/SettingsModel';
import { StatusSummaryCard } from './StatusCards';
import { NavGroup, NavRow } from './SubPage';
@Component
export struct SettingsRootEntries {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 连接配置条数 */
@Prop connCount: number = 0;
/** 当前生效连接的展示名 */
@Prop connName: string = '';
/** 后端配置:分类数 / 核心项数 / 是否加载中 / 错误文案 */
@Prop sectionCount: number = 0;
@Prop coreKeys: number = 0;
@Prop busy: boolean = false;
@Prop errorText: string = '';
/** 当前主题的中文名(一级行右侧摘要值) */
@Prop themeLabel: string = '';
/** 宽屏分栏时用来高亮右栏对应的入口行 */
@Prop activeSub: string = '';
@Prop isWide: boolean = false;
onOpen?: (id: string) => void;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private open(id: string): void {
const cb: ((id: string) => void) | undefined = this.onOpen;
if (cb !== undefined) {
cb(id);
}
}
build() {
Column() {
// 运行状态摘要原「状态」Tab整卡可点进入明细二级页
StatusSummaryCard({
onTap: () => {
this.open(SUB_STATUS);
},
})
NavGroup({ caption: '连接' }) {
NavRow({
icon: $r('app.media.ic_link'),
title: '后端连接',
subtitle: this.connCount.toString() + ' 个连接配置',
value: this.connName,
selected: this.isWide && this.activeSub === SUB_CONNECTIONS,
onTap: () => {
this.open(SUB_CONNECTIONS);
},
})
NavRow({
icon: $r('app.media.ic_tune'),
title: '核心配置',
subtitle: this.sectionCount.toString() + ' 个分类 · ' + this.coreKeys.toString() + ' 项',
value: this.busy ? '加载中' : (this.errorText.length > 0 ? '不可用' : ''),
showDivider: false,
selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION),
onTap: () => {
this.open(SUB_BACKEND);
},
})
}
NavGroup({ caption: '个性化' }) {
NavRow({
icon: $r('app.media.ic_theme'),
title: '外观',
subtitle: '主题与背景图',
value: this.themeLabel,
showDivider: false,
selected: this.isWide && this.activeSub === SUB_APPEARANCE,
onTap: () => {
this.open(SUB_APPEARANCE);
},
})
}
if (this.errorText.length > 0) {
Text(this.errorText)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4, right: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -4,285 +4,13 @@
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering. * Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
* Uses Span children inside Text for inline bold/italic/code/link formatting. * Uses Span children inside Text for inline bold/italic/code/link formatting.
* *
* Covers: headings, paragraphs, code fences, unordered/ordered lists, * 解析规则在 common/MarkdownParser.ets已抽出见那里
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links. * 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
*/ */
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants'; import { RADIUS_SM } from '../common/Constants';
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
// ── Types ──────────────────────────────────────────────────────────────────────
export interface MdBlock {
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
level?: number;
items?: string[];
text?: string;
lang?: string;
codeLines?: string[];
headers?: string[];
rows?: string[][];
}
export interface MdSpan {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
link?: boolean;
linkUrl?: string;
}
// ── Inline parser ──────────────────────────────────────────────────────────────
export function parseInline(text: string): MdSpan[] {
const spans: MdSpan[] = [];
let i: number = 0;
while (i < text.length) {
// Inline code (backtick)
if (text[i] === '`') {
const end: number = text.indexOf('`', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), code: true });
i = end + 1;
continue;
}
}
// Bold: **text**
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
const end: number = text.indexOf('**', i + 2);
if (end > i + 1) {
spans.push({ text: text.substring(i + 2, end), bold: true });
i = end + 2;
continue;
}
}
// Italic: *text* (single asterisk)
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
const end: number = text.indexOf('*', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), italic: true });
i = end + 1;
continue;
}
}
// Link: [text](url)
if (text[i] === '[') {
const cb: number = text.indexOf(']', i + 1);
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
const cp: number = text.indexOf(')', cb + 2);
if (cp > cb + 1) {
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
i = cp + 1;
continue;
}
}
}
// Plain run
let j: number = i + 1;
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
j++;
}
spans.push({ text: text.substring(i, j) });
i = j;
}
return spans;
}
// ── Block parser helpers ───────────────────────────────────────────────────────
function isHr(line: string): boolean {
if (line.length < 3) {
return false;
}
const ch: string = line[0];
if (ch !== '-' && ch !== '*' && ch !== '_') {
return false;
}
for (let k = 0; k < line.length; k++) {
if (line[k] !== ch) {
return false;
}
}
return true;
}
function isOlStart(line: string): boolean {
if (line.length < 3) {
return false;
}
let k: number = 0;
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
k++;
}
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
}
function isTableSep(line: string): boolean {
if (!line.includes('-')) {
return false;
}
for (let k = 0; k < line.length; k++) {
const c: string = line[k];
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
return false;
}
}
return true;
}
// ── Block parser ───────────────────────────────────────────────────────────────
export function parseBlocks(content: string): MdBlock[] {
if (content.length === 0) {
return [];
}
const lines: string[] = content.split('\n');
const blocks: MdBlock[] = [];
let i: number = 0;
while (i < lines.length) {
const line: string = lines[i];
// Empty line
if (line.trim().length === 0) {
i++;
continue;
}
// Code fence
if (line.startsWith('```')) {
const langEnd: number = line.indexOf('`', 3);
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) {
i++;
}
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
continue;
}
// Heading
if (line.startsWith('#')) {
let level: number = 0;
while (level < line.length && line[level] === '#') {
level++;
}
if (level <= 6 && level < line.length && line[level] === ' ') {
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
i++;
continue;
}
}
// Horizontal rule
if (isHr(line.trim())) {
blocks.push({ type: 'hr' });
i++;
continue;
}
// Unordered list
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
items.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'list', items: items });
continue;
}
// Ordered list
if (isOlStart(line)) {
const items: string[] = [];
while (i < lines.length && isOlStart(lines[i])) {
const dotIdx: number = lines[i].indexOf('. ');
items.push(lines[i].substring(dotIdx + 2));
i++;
}
blocks.push({ type: 'ol', items: items });
continue;
}
// Blockquote
if (line.startsWith('> ')) {
const qLines: string[] = [];
while (i < lines.length && lines[i].startsWith('> ')) {
qLines.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
continue;
}
// Table
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
const tLines: string[] = [];
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
tLines.push(lines[i]);
i++;
}
if (tLines.length >= 2) {
const parseRow = (row: string): string[] => {
const cells: string[] = [];
const parts: string[] = row.split('|');
for (let p = 0; p < parts.length; p++) {
const c: string = parts[p].trim();
if (c.length > 0) {
cells.push(c);
}
}
return cells;
};
const headers: string[] = parseRow(tLines[0]);
const rows: string[][] = [];
for (let k = 1; k < tLines.length; k++) {
if (!isTableSep(tLines[k].trim())) {
rows.push(parseRow(tLines[k]));
}
}
if (headers.length > 0) {
blocks.push({ type: 'table', headers: headers, rows: rows });
}
}
continue;
}
// Paragraph: collect consecutive non-special lines
{
const paraLines: string[] = [];
while (i < lines.length) {
const ln: string = lines[i];
if (ln.trim().length === 0) {
break;
}
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
break;
}
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
break;
}
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
break;
}
paraLines.push(ln);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: 'para', text: paraLines.join('\n') });
}
}
}
return blocks;
}
// ── Component ──────────────────────────────────────────────────────────────────
@Component @Component
export struct StaticMarkdownView { export struct StaticMarkdownView {

View File

@ -2,9 +2,10 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants'; import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase'; import { MotionBase } from './MotionBase';
import { RuntimePanel } from './RuntimePanel';
import { import {
statusStore, StatGroup, StatField, compactDuration, statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
} from '../common/StatusStore'; } from '../common/StatusStore';
/** /**
@ -19,6 +20,8 @@ export struct StatusSummaryCard {
@StorageProp('themeIsDark') private isDark: boolean = true; @StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false; @StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_VERSION) private version: string = '-'; @StorageProp(K_VERSION) private version: string = '-';
/** 内核名 · commit。与版本号分开一行版本号本身没有内核身份。 */
@StorageProp(K_BUILD) private buildSub: string = '';
@StorageProp(K_STARTED) private startedAt: string = ''; @StorageProp(K_STARTED) private startedAt: string = '';
@StorageProp(K_AGENTS) private agents: number = 0; @StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0; @StorageProp(K_PLUGINS) private plugins: number = 0;
@ -117,10 +120,15 @@ export struct StatusSummaryCard {
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具) // 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
//
// 必须是**子组件**@Prop 单向下发)而不是本组件里的 @Builder
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
// 不会用新值重跑 builder —— 实测K_PLUGINS 已经是 35
// 但 @Builder 里画的还是首次的 0永远显示 '-'。
Row({ space: 10 }) { Row({ space: 10 }) {
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT) KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN) KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
} }
.width('100%') .width('100%')
.margin({ top: 14 }) .margin({ top: 14 })
@ -130,14 +138,24 @@ export struct StatusSummaryCard {
Text('版本') Text('版本')
.fontSize(12) .fontSize(12)
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
Text(this.version) Column() {
.fontSize(12) Text(this.version)
.fontWeight(FontWeight.Medium) .fontSize(12)
.fontColor(this.palette().textSecondary) .fontWeight(FontWeight.Medium)
.layoutWeight(1) .fontColor(this.palette().textSecondary)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 10 }) // 内核名 + commit光有版本号会分不清是哪个内核、哪次构建
Text(this.buildSub)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('明细') Text('明细')
.fontSize(12) .fontSize(12)
.fontColor(this.palette().accent) .fontColor(this.palette().accent)
@ -201,6 +219,51 @@ export struct StatusSummaryCard {
} }
} }
/**
* 能力计数小卡(插件 / 工具)。
*
* 单独成组件而非 @Builder@Builder 的按值参数不会随父组件重渲染而刷新,
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
* 变化重渲染时,子组件拿到新值并重绘。
*/
@Component
struct KpiTile {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource = $r('app.media.ic_plug');
@Prop label: string = '';
@Prop value: number = 0;
@Prop tint: string = '';
build() {
Row({ space: 8 }) {
Image(this.icon)
.width(16)
.height(16)
.fillColor(this.tint)
.draggable(false)
Column({ space: 1 }) {
Text(this.value > 0 ? this.value.toString() : '-')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text(this.label)
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/** /**
* 运行状态明细:设置页「运行状态」二级页面的内容。 * 运行状态明细:设置页「运行状态」二级页面的内容。
* *
@ -271,6 +334,11 @@ export struct StatusDetailContent {
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。 // 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。 // 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
// 后者是进这个页面最先想看的。
RuntimePanel()
ForEach(this.groups, (g: StatGroup) => { ForEach(this.groups, (g: StatGroup) => {
Column() { Column() {
Text(g.title) Text(g.title)

View File

@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground';
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen'; export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
export function markSubPageOpen(open: boolean): void { export function markSubPageOpen(open: boolean): void {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open); if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
} else {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
}
} }
/** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */ /** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */

View File

@ -0,0 +1,48 @@
/**
* 右下角轻提示条(设置页与插件页共用)。
*
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
* 用 bordered 表达这个差异width 0 的边框不占位、不可见)。
*
* 配色由调用方决定:颜色标志必须在调用方落 animateTo 之前先写好,
* 否则第一帧会用上一条 toast 的配色。
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, ANIM_NORMAL } from '../common/Constants';
@Component
export struct ToastBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop msg: string = '';
@Prop isError: boolean = false;
/** 是否带一圈语义色描边(插件页用,设置页不用) */
@Prop bordered: boolean = false;
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
build() {
if (this.msg.length > 0) {
Row() {
Text(this.msg)
.fontSize(13)
.fontColor(this.isError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.isError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: this.bordered ? 1 : 0,
color: this.isError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}

View File

@ -4,6 +4,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { connStore } from '../common/ConnStore'; import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient'; import { apiClient } from '../common/ApiClient';
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants'; import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */ /** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
function storedThemeMode(): string { function storedThemeMode(): string {
@ -31,6 +32,7 @@ export default class EntryAbility extends UIAbility {
} }
onDestroy(): void { onDestroy(): void {
stopForegroundBridge();
console.info('[HomeAgent] ability onDestroy'); console.info('[HomeAgent] ability onDestroy');
} }
@ -86,6 +88,7 @@ export default class EntryAbility extends UIAbility {
apiClient.setConnection(cur); apiClient.setConnection(cur);
} }
this.reapplyStoredTheme(); this.reapplyStoredTheme();
startForegroundBridge(this.context);
startUI(); startUI();
}).catch(() => { }).catch(() => {
startUI(); startUI();
@ -107,6 +110,7 @@ export default class EntryAbility extends UIAbility {
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏 // init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
this.reapplyStoredTheme(); this.reapplyStoredTheme();
this.applySystemBar(); this.applySystemBar();
startForegroundBridge(this.context);
startUI(); startUI();
}).catch((e: Error) => { }).catch((e: Error) => {
console.error('[HomeAgent] connStore init failed: ' + e.message); console.error('[HomeAgent] connStore init failed: ' + e.message);
@ -129,10 +133,12 @@ export default class EntryAbility extends UIAbility {
} }
onForeground(): void { onForeground(): void {
startForegroundBridge(this.context);
console.info('[HomeAgent] ability onForeground'); console.info('[HomeAgent] ability onForeground');
} }
onBackground(): void { onBackground(): void {
stopForegroundBridge();
console.info('[HomeAgent] ability onBackground'); console.info('[HomeAgent] ability onBackground');
} }
} }

View File

@ -26,6 +26,14 @@ export interface ChatMessage {
toolCalls?: ToolCallInfo[]; toolCalls?: ToolCallInfo[];
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */ /** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
source?: string; source?: string;
/**
* 服务端单调递增序号(后端 ChatMsg.seq
*
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
* 本地乐观消息没有 seq服务端回显后靠 seq 认领并去重。
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
*/
seq?: number;
/** 图片/文件附件(后端 ChatMsg.attachment */ /** 图片/文件附件(后端 ChatMsg.attachment */
attachment?: ChatAttachment; attachment?: ChatAttachment;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,28 @@
import { deviceBridge } from '../common/DeviceBridge'; import { deviceBridge } from '../common/DeviceBridge';
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
import { connStore } from '../common/ConnStore'; import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient'; import { apiClient } from '../common/ApiClient';
import { noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController'; import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { DeviceInfo } from '../model/Model'; import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
import { MotionBase } from '../components/MotionBase'; WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { common } from '@kit.AbilityKit'; import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
import { DeviceRootEntries } from '../components/DeviceRootEntries';
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { markSubPageOpen, subPageParam } from '../components/SubPage';
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应) SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
const LOCAL_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
/** 二级页面标识 */
const SUB_NONE: string = '';
const SUB_LOCAL: string = 'local';
const SUB_CAPS: string = 'caps';
const SUB_GATEWAY: string = 'gateway';
const SUB_LIST: string = 'list';
/**
* 设备页:只做"页面壳"。
*
* 拆分后的分工(拆分前这里是 560 行的单文件):
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets路由 id 也在那)
* 本文件保留导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
*/
@Component @Component
export struct DevicePage { export struct DevicePage {
@StorageProp('themeIsDark') private isDark: boolean = true; @StorageProp('themeIsDark') private isDark: boolean = true;
@ -39,56 +32,28 @@ export struct DevicePage {
@StorageProp('isWideScreen') private isWide: boolean = false; @StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */ /** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@State activeSub: string = SUB_NONE; @State activeSub: string = SUB_NONE;
@State bridgeConnected: boolean = false; @StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
@State bridgeUrl: string = ''; @State bridgeUrl: string = '';
@State bridgeToken: string = ''; @State bridgeToken: string = '';
@State deviceId: string = ''; @State deviceId: string = '';
@State authorized: boolean = false; @State authorized: boolean = false;
@State devices: DeviceInfo[] = []; @State devices: DeviceInfo[] = [];
@State loadingDevices: boolean = false; @State loadingDevices: boolean = false;
@State lastError: string = '';
@State toastMsg: string = ''; @State toastMsg: string = '';
@State toastIsError: boolean = false; @State toastIsError: boolean = false;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */ /** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack(); private navStack: NavPathStack = new NavPathStack();
private autoConnectTried: boolean = false;
aboutToAppear(): void { aboutToAppear(): void {
this.deviceId = deviceBridge.getDeviceId(); // id 判定顺序:桥里的 id 优先于持久化的 id都没有才生成并落盘
if (this.deviceId.length === 0) { this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
this.deviceId = connStore.getDeviceId();
}
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
}
this.authorized = connStore.getDeviceAuth(); this.authorized = connStore.getDeviceAuth();
// Gateway URL derives from current connection // Gateway URL derives from current connection
const cur = connStore.getCurrentConnection(); const cur = connStore.getCurrentConnection();
if (cur !== null) { if (cur !== null) {
this.bridgeUrl = this.gatewayUrlOf(cur.url); this.bridgeUrl = deviceGatewayUrl(cur.url);
this.bridgeToken = cur.apiKey; this.bridgeToken = cur.apiKey;
} }
installCmdRouter();
try {
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
} catch (e) {
// ignore context errors
}
deviceBridge.setStateListener((open: boolean) => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.bridgeConnected = open;
});
if (open) {
this.lastError = '';
this.showToast('设备网关已连接', false);
this.refreshDevices();
}
});
this.refreshDevices(); this.refreshDevices();
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来 // 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(2, this.navStack, () => { registerNavStack(2, this.navStack, () => {
@ -104,37 +69,6 @@ export struct DevicePage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
} }
/**
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
*
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
* 直接把 http:// 传进 connect() 会在 native 层报
* "protocol failed" + "ParseUrl failed"NETSTACK websocket_exec.cpp
* 表现为设备通道永远连不上。所以这里必须做协议替换。
*/
private gatewayUrlOf(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
scheme = 'ws://';
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
scheme = 'ws://';
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/** /**
* 打开二级页面。 * 打开二级页面。
* *
@ -170,45 +104,9 @@ export struct DevicePage {
// ignore persist failure // ignore persist failure
} }
deviceBridge.updateAuthorized(on); deviceBridge.updateAuthorized(on);
if (!this.bridgeConnected) {
this.connectBridge();
}
this.showToast(on ? '已授权agent 可下发能力命令' : '已取消授权', false); this.showToast(on ? '已授权agent 可下发能力命令' : '已取消授权', false);
} }
// ===================== gateway connection =====================
private async connectBridge(): Promise<void> {
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
this.lastError = noConnectionMessage();
return;
}
this.lastError = '';
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
}
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
const name: string = 'HomeAgent OHOS';
await deviceBridge.connect(
this.bridgeUrl, this.bridgeToken, this.deviceId,
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
}
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
private maybeAutoConnect(): void {
if (this.autoConnectTried || this.bridgeConnected) {
return;
}
this.autoConnectTried = true;
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
this.connectBridge();
}
}
private showToast(msg: string, isError: boolean): void { private showToast(msg: string, isError: boolean): void {
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色 // 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
this.toastIsError = isError; this.toastIsError = isError;
@ -231,31 +129,7 @@ export struct DevicePage {
const resp = await apiClient.get('/device/online'); const resp = await apiClient.get('/device/online');
if (resp.status >= 200 && resp.status < 300) { if (resp.status >= 200 && resp.status < 300) {
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>; const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const devs: Object = parsed['devices']; const list: DeviceInfo[] = parseOnlineDevices(parsed);
const list: DeviceInfo[] = [];
if (devs !== undefined && devs !== null) {
const arr: Object[] = devs as Object[];
for (let i = 0; i < arr.length; i++) {
const d: Record<string, Object> = arr[i] as Record<string, Object>;
const capsArr: Object = d['caps'];
const caps: string[] = [];
if (capsArr !== undefined && capsArr !== null) {
const cArr: Object[] = capsArr as Object[];
for (let j = 0; j < cArr.length; j++) {
caps.push(cArr[j] as string);
}
}
const info: DeviceInfo = {
deviceId: d['device_id'] as string ?? '',
name: d['name'] as string ?? '',
kind: d['kind'] as string ?? '',
online: true,
authorized: d['authorized'] as boolean ?? false,
caps: caps,
};
list.push(info);
}
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.devices = list; this.devices = list;
}); });
@ -275,7 +149,17 @@ export struct DevicePage {
Column() { Column() {
Scroll() { Scroll() {
Column() { Column() {
this.RootEntries() DeviceRootEntries({
activeSub: this.activeSub,
deviceId: this.deviceId,
bridgeConnected: this.bridgeConnected,
bridgeUrl: this.bridgeUrl,
loadingDevices: this.loadingDevices,
deviceCount: this.devices.length,
onOpen: (id: string) => {
this.openSub(id);
},
})
} }
.width('100%') .width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 }) .padding({ left: 16, right: 16, top: 76, bottom: 174 })
@ -287,9 +171,6 @@ export struct DevicePage {
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state); handleNavOnScroll(state);
}) })
.onAppear(() => {
this.maybeAutoConnect();
})
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
@ -349,61 +230,58 @@ export struct DevicePage {
}) })
} }
/** 二级页面路由表 */ /**
* 二级页面路由表。
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
*/
@Builder @Builder
SubDestination(name: string, param: object) { SubDestination(name: string, param: object) {
NavDestination() { NavDestination() {
if (name === SUB_LOCAL) { if (name === SUB_LOCAL) {
SubPageLayer({ DeviceLocalPane({
title: '本机设备', deviceId: this.deviceId,
tab: 2, authorized: this.authorized,
onBack: () => { onBack: () => {
this.closeSub(); this.closeSub();
}, },
}) { onToggleAuth: (on: boolean) => {
this.LocalDeviceContent() this.toggleAuthorized(on);
} },
})
} else if (name === SUB_CAPS) { } else if (name === SUB_CAPS) {
SubPageLayer({ DeviceCapsPane({
title: '设备能力',
tab: 2,
onBack: () => { onBack: () => {
this.closeSub(); this.closeSub();
}, },
}) { })
this.CapsContent()
}
} else if (name === SUB_GATEWAY) { } else if (name === SUB_GATEWAY) {
SubPageLayer({ DeviceGatewayPane({
title: '设备通道', bridgeUrl: this.bridgeUrl,
tab: 2, bridgeToken: this.bridgeToken,
bridgeConnected: this.bridgeConnected,
onBack: () => { onBack: () => {
this.closeSub(); this.closeSub();
}, },
}) {
this.GatewayContent()
}
} else if (name === SUB_LIST) {
SubPageLayer({
title: '接入的设备',
tab: 2,
onBack: () => {
this.closeSub();
},
showRefresh: true,
onRefresh: () => { onRefresh: () => {
this.refreshDevices(); this.refreshDevices();
}, },
}) { })
this.OnlineDevicesContent() } else if (name === SUB_LIST) {
} DeviceListPane({
devices: this.devices,
onBack: () => {
this.closeSub();
},
onRefresh: () => {
this.refreshDevices();
},
})
} }
} }
.hideTitleBar(true) .hideTitleBar(true)
.backgroundColor(Color.Transparent) .backgroundColor(Color.Transparent)
} }
@Builder @Builder
Toast() { Toast() {
if (this.toastMsg.length > 0) { if (this.toastMsg.length > 0) {
@ -424,269 +302,6 @@ export struct DevicePage {
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
} }
// ===================== 一级入口列表 =====================
@Builder
RootEntries() {
NavGroup({ caption: '本机' }) {
NavRow({
icon: $r('app.media.ic_phone'),
title: '本机设备',
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
value: this.bridgeConnected ? '在线' : '离线',
selected: this.isWide && this.activeSub === SUB_LOCAL,
onTap: () => {
this.openSub(SUB_LOCAL);
},
})
NavRow({
icon: $r('app.media.ic_bolt'),
title: '设备能力',
subtitle: this.capsCount().toString() + ' 项能力',
value: '',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_CAPS,
onTap: () => {
this.openSub(SUB_CAPS);
},
})
}
NavGroup({ caption: '通道' }) {
NavRow({
icon: $r('app.media.ic_gateway'),
title: '设备通道',
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
value: this.bridgeConnected ? '已连接' : '未连接',
selected: this.isWide && this.activeSub === SUB_GATEWAY,
onTap: () => {
this.openSub(SUB_GATEWAY);
},
})
NavRow({
icon: $r('app.media.ic_devices_multi'),
title: '接入的设备',
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
value: this.devices.length.toString() + ' 台',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_LIST,
onTap: () => {
this.openSub(SUB_LIST);
},
})
}
}
private capsCount(): number {
return LOCAL_CAPS.length;
}
// ===================== 二级:本机设备 =====================
@Builder
LocalDeviceContent() {
PlainCard({ caption: '基本信息' }) {
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
this.KvRow('名称', 'HomeAgent OHOS')
this.KvRow('类型', 'phone')
}
PlainCard({ caption: '权限控制' }) {
Row() {
Column({ space: 2 }) {
Text('允许 agent 控制本机')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.toggleAuthorized(on);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026')
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width('100%')
.margin({ top: 12 })
.padding({ left: 4 })
}
}
// ===================== 二级:设备能力 =====================
@Builder
CapsContent() {
PlainCard({ caption: '能力清单' }) {
Text('agent 通过设备桥可调用的本机能力:')
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(999)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ right: 6, bottom: 6 })
}, (cap: string) => cap)
}
.width('100%')
}
}
// ===================== 二级:设备通道 =====================
@Builder
GatewayContent() {
PlainCard({ caption: '连接信息' }) {
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
}
if (this.lastError.length > 0) {
Text(this.lastError)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -8 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
PlainCard({ caption: '操作' }) {
Row() {
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
// 因为父容器的 .animation() 到不了子节点。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button(this.bridgeConnected ? '断开' : '连接网关')
.height(34)
.fontSize(12)
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.border({
width: this.bridgeConnected ? 1 : 0,
color: this.palette().btnGhostBorder,
})
.onClick(() => {
if (this.bridgeConnected) {
deviceBridge.disconnect();
} else {
this.connectBridge();
}
})
}
Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.refreshDevices();
})
}
}
.width('100%')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态bind 携带 Token 完成身份绑定。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
}
// ===================== 二级:接入的设备 =====================
@Builder
OnlineDevicesContent() {
if (this.devices.length === 0) {
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.devices, (dev: DeviceInfo) => {
// PlainCard 是自定义组件transition 不能直接挂在它上面(会生成 __Common__ 包装),
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
Column() {
PlainCard({ caption: '' }) {
Row() {
Circle({ width: 8, height: 8 })
.fill(dev.online ? '#17A964' : '#77809A')
.margin({ right: 10 })
Column() {
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
.fontSize(11)
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(dev.caps.length.toString() + ' 能力')
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.width('100%')
}
}
.width('100%')
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
}
// ===================== 通用 KV 行 =====================
@Builder
KvRow(k: string, v: string) {
Row() {
Text(k)
.fontSize(13)
.fontColor(this.palette().textSecondary)
Blank()
Text(v)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
}
} }
const RADIUS_MD: number = 10; const RADIUS_MD: number = 10;

View File

@ -7,12 +7,15 @@ import { apiClient } from '../common/ApiClient';
import { navBar } from '../common/NavBarController'; import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry'; import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model'; import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants'; import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase'; import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground'; import { GradientBackground } from '../components/GradientBackground';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter'; import { ScreensuePage } from '../components/ScreensuePage';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps'; import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { snapshotComponentId } from '../common/BridgeCaps';
import { ScreensuePayload } from '../common/ScreensueHtml';
import { window, display } from '@kit.ArkUI'; import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
@ -81,6 +84,8 @@ struct Index {
/** 底部手势条高度vp */ /** 底部手势条高度vp */
@State bottomGesture: number = 16; @State bottomGesture: number = 16;
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true; @StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
/** 外部请求切换主 Tab未连接时聊天空态的“去设置连接”用 */
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
private swiper: SwiperController = new SwiperController(); private swiper: SwiperController = new SwiperController();
private screensueTimer: number = -1; private screensueTimer: number = -1;
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点 private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
@ -99,6 +104,12 @@ struct Index {
if (cur !== null) { if (cur !== null) {
apiClient.setConnection(cur); apiClient.setConnection(cur);
} }
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
// 所以此处读到的连接状态就是真实的启动态。
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
// 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取 // 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取
const st = connStore.getSettings(); const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? ''); AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
@ -110,14 +121,14 @@ struct Index {
this.syncSystemBar(); this.syncSystemBar();
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44 // 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44
this.resolveSafeArea(); this.resolveSafeArea();
// 设备桥:注册命令路由与 screensue 悬浮层回调 // 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
installCmdRouter();
registerScreensueHandler((payload: ScreensuePayload) => { registerScreensueHandler((payload: ScreensuePayload) => {
this.showScreensue(payload); this.showScreensue(payload);
}); });
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
} }
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭0=常驻)。 */ /** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏0=常驻)。 */
private showScreensue(payload: ScreensuePayload): void { private showScreensue(payload: ScreensuePayload): void {
this.screensueText = payload.content; this.screensueText = payload.content;
this.screensueCountdown = payload.duration; this.screensueCountdown = payload.duration;
@ -189,6 +200,23 @@ struct Index {
AppStorage.set<number>('currentTab', this.currentTab); AppStorage.set<number>('currentTab', this.currentTab);
} }
/**
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
*
* 为什么不能直接改 AppStorage 的 currentTabIndex 的 currentTab 是
* @StateSwiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
*/
private onRequestedTab(): void {
const t: number = this.requestedTab;
AppStorage.set<number>(K_REQUESTED_TAB, -1);
if (t < 0 || t >= this.tabs.length) {
return;
}
this.currentTab = t;
navBar.setVisible(true);
}
private syncSystemBar(): void { private syncSystemBar(): void {
const dark: boolean = this.isDark; const dark: boolean = this.isDark;
const bg: string = dark ? '#000000' : '#F1F3F5'; const bg: string = dark ? '#000000' : '#F1F3F5';
@ -218,7 +246,7 @@ struct Index {
private updateWideScreen(w: number): void { private updateWideScreen(w: number): void {
const wide: boolean = w >= WIDE_MIN_WIDTH; const wide: boolean = w >= WIDE_MIN_WIDTH;
if (wide !== this.isWide) { if (wide !== this.isWide) {
AppStorage.setOrCreate<boolean>('isWideScreen', wide); AppStorage.set<boolean>('isWideScreen', wide);
} }
} }
@ -230,6 +258,10 @@ struct Index {
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。 * 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/ */
onBackPress(): boolean { onBackPress(): boolean {
if (this.screensueVisible) {
this.closeScreensue();
return true;
}
return handleBackPress(this.currentTab, this.isWide); return handleBackPress(this.currentTab, this.isWide);
} }
@ -339,51 +371,42 @@ struct Index {
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击 // 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent) .hitTestBehavior(HitTestMode.Transparent)
// screensue 悬浮层agent 推送给用户看的内容(置顶展示) // screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
// 左侧一级页面与主导航保持可见、可操作。
if (this.screensueVisible) { if (this.screensueVisible) {
Column() { if (this.isWide) {
Row() { Row() {
Circle({ width: 8, height: 8 }) Column()
.fill(this.palette().accent) .width(WIDE_NAV_BAR_WIDTH)
Text('agent 推送') .height('100%')
.fontSize(12) .hitTestBehavior(HitTestMode.None)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary) ScreensuePage({
.margin({ left: 8 }) pushedText: this.screensueText,
Blank() countdown: this.screensueCountdown,
if (this.screensueCountdown > 0) { onClose: () => {
Text(this.screensueCountdown.toString() + 's')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
Text('关闭')
.fontSize(12)
.fontColor(this.palette().accent)
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
.onClick(() => {
this.closeScreensue(); this.closeScreensue();
}) },
})
.layoutWeight(1)
.height('100%')
.padding({ top: this.topInset })
} }
.width('100%') .width('100%')
.margin({ bottom: 10 }) .height('100%')
.hitTestBehavior(HitTestMode.Transparent)
Scroll() { } else {
Text(this.screensueText) ScreensuePage({
.fontSize(15) pushedText: this.screensueText,
.fontColor(this.palette().textPrimary) countdown: this.screensueCountdown,
.width('100%') onClose: () => {
} this.closeScreensue();
.constraintSize({ maxHeight: 320 }) },
.scrollBar(BarState.Auto) })
.align(Alignment.Top) .width('100%')
.height('100%')
.padding({ top: this.topInset })
} }
.width('86%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
} }
} }
.width('100%') .width('100%')

View File

@ -1,24 +1,26 @@
import { apiClient } from '../common/ApiClient'; import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError'; import { userMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController'; import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model'; import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants'; import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { MotionBase } from '../components/MotionBase'; import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar'; import { PluginDetailPane } from '../components/PluginDetailPane';
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { PluginListView } from '../components/PluginListView';
import { SettingsEditor } from '../components/SettingsEditor'; import { PluginInstallForm } from '../components/PluginsOverlays';
import { ToastBar } from '../components/ToastBar';
interface InstallBody { import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
url: string;
}
/** 二级页面标识:插件详情 */ /** 二级页面标识:插件详情 */
const SUB_NONE: string = ''; const SUB_NONE: string = '';
const SUB_DETAIL: string = 'detail'; const SUB_DETAIL: string = 'detail';
interface InstallBody {
url: string;
}
@Component @Component
export struct PluginsPage { export struct PluginsPage {
@StorageProp('themeIsDark') private isDark: boolean = true; @StorageProp('themeIsDark') private isDark: boolean = true;
@ -58,134 +60,14 @@ export struct PluginsPage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
} }
/** /** 取数口径见 common/PluginApi.ets这里只负责把结果落到 @State。 */
* 数据源对齐 WebGUI renderPlugins
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
private async loadPlugins(): Promise<void> { private async loadPlugins(): Promise<void> {
if (!apiClient.hasConnection()) { if (!apiClient.hasConnection()) {
return; return;
} }
this.loading = true; this.loading = true;
try { try {
// ---- kernel: loaded plugins + tool ownership ---- const rows: PluginRow[] = await fetchPluginRows();
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
// 列表整体重建ForEach key 含 loaded/disabled启停会整行重挂载 // 列表整体重建ForEach key 含 loaded/disabled启停会整行重挂载
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。 // 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
@ -199,20 +81,6 @@ export struct PluginsPage {
}); });
} }
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
private statusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
private async togglePlugin(plugin: PluginRow): Promise<void> { private async togglePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name; const name: string = plugin.name;
const action: string = plugin.disabled ? 'enable' : 'disable'; const action: string = plugin.disabled ? 'enable' : 'disable';
@ -280,29 +148,14 @@ export struct PluginsPage {
} }
/** /**
* GET /plugins/{name} —— 后端返回插件清单字段 * 详情数据落地
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。 * 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
*/ */
private async loadDetail(name: string): Promise<void> { private async loadDetail(name: string): Promise<void> {
this.detailLoading = true; this.detailLoading = true;
this.detailError = ''; this.detailError = '';
try { try {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000); const d: PluginDetail = await fetchPluginDetail(name);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = this.strArray(o['tags']);
d.files = this.strArray(o['files']);
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载V1 给它们默认透明度过渡 // 详情字段一次性落地:条件卡片在 animateTo 帧内挂载V1 给它们默认透明度过渡
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d; this.detail = d;
@ -327,21 +180,6 @@ export struct PluginsPage {
}); });
} }
private strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
private findRow(name: string): PluginRow | null { private findRow(name: string): PluginRow | null {
for (let i = 0; i < this.plugins.length; i++) { for (let i = 0; i < this.plugins.length; i++) {
if (this.plugins[i].name === name) { if (this.plugins[i].name === name) {
@ -387,8 +225,15 @@ export struct PluginsPage {
Column() { Column() {
Scroll() { Scroll() {
Column() { Column() {
this.ListStates() PluginListView({
this.PluginList() plugins: this.plugins,
busy: this.loading,
activeName: this.activeName,
hasConn: apiClient.hasConnection(),
onSelect: (name: string) => {
this.openDetail(name);
},
})
} }
.width('100%') .width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 }) .padding({ left: 16, right: 16, top: 76, bottom: 174 })
@ -411,7 +256,15 @@ export struct PluginsPage {
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。 // 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
NavFloatOverlay({ tab: 1 }) { NavFloatOverlay({ tab: 1 }) {
if (this.showInstallForm) { if (this.showInstallForm) {
this.InstallForm() PluginInstallForm({
url: this.installUrl,
onUrlChange: (v: string) => {
this.installUrl = v;
},
onInstall: () => {
this.installPlugin();
},
})
} }
NavFloatRow() { NavFloatRow() {
FloatIconButton({ FloatIconButton({
@ -441,7 +294,7 @@ export struct PluginsPage {
} }
} }
this.Toast() ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
@ -493,503 +346,29 @@ export struct PluginsPage {
this.loadDetail(this.activeName); this.loadDetail(this.activeName);
}, },
}) { }) {
this.DetailContent() PluginDetailPane({
detail: this.detail,
activeName: this.activeName,
busy: this.detailLoading,
errorText: this.detailError,
row: this.activeRow() ?? undefined,
onToggle: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
},
onRemove: () => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
},
})
} }
} }
} }
.hideTitleBar(true) .hideTitleBar(true)
.backgroundColor(Color.Transparent) .backgroundColor(Color.Transparent)
} }
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Builder
InstallForm() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
this.installUrl = v;
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
this.installPlugin();
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
/** 加载中 / 未配置 / 空列表三种占位态 */
@Builder
ListStates() {
if (this.loading && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!apiClient.hasConnection()) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
/**
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
*/
@Builder
PluginList() {
ForEach(this.plugins, (plugin: PluginRow) => {
// 外层 Column 只为承载 transition.transition() 不能直接挂在自定义组件
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
Column() {
MotionBase({ pressEnabled: true }) {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(plugin.name)
.fontSize(15)
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (plugin.version !== undefined && plugin.version.length > 0) {
Text('v' + plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(this.statusDotColor(plugin))
Text(this.rowSubtitle(plugin))
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.activeName === plugin.name
? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.activeName === plugin.name
? this.palette().accent : this.palette().glassBorder,
})
// 选中态的底色/描边渐变MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.openDetail(plugin.name);
})
}
}
.width('100%')
.margin({ bottom: 10 })
// ForEach key 含 loaded/disabled启停会整行重挂载靠 transition 变成交叉淡入
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
/**
* 二级页面:插件详情。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
*/
@Builder
DetailContent() {
if (this.detailLoading) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.detailError.length > 0) {
Text(this.detailError)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.DetailBadges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.DetailTools()
// 插件配置plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.DetailActions()
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
DetailBadges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.activeStatusColor())
Text(this.detailStatusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
private detailStatusLine(): string {
const parts: string[] = [];
parts.push(this.activeStatusText());
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
if (this.detail.deprecated) {
parts.push('已废弃');
}
const n: number = this.activeTools().length;
if (n > 0) {
parts.push(n.toString() + ' 个工具');
}
return parts.join(' · ');
}
/** 工具清单来自一级列表已合并的 kernel.tools按 plugin 归属) */
@Builder
DetailTools() {
if (this.activeTools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.activeTools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
DetailActions() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.activeIsDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.activeIsDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.activeIsDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
})
Blank()
if (!this.activeIsBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
// ---- 详情页取值助手ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private activeIsBuiltin(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? !r.external : false;
}
private activeIsDisabled(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? r.disabled : false;
}
private activeTools(): string[] {
const r: PluginRow | null = this.activeRow();
if (r === null) {
return [];
}
return r.tools ?? [];
}
private activeStatusText(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeText(r) : '未加载';
}
private activeStatusColor(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: 1,
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
private statusDotColor(plugin: PluginRow): string {
return this.statusBadgeColor(plugin);
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
private rowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(this.statusBadgeText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
private statusBadgeText(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
private statusBadgeColor(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return this.palette().textMuted;
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,14 @@
}, },
{ {
"name": "ohos.permission.GET_NETWORK_INFO" "name": "ohos.permission.GET_NETWORK_INFO"
},
{
"name": "ohos.permission.READ_PASTEBOARD",
"reason": "$string:read_pasteboard_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
} }
], ],
"abilities": [ "abilities": [

View File

@ -3,6 +3,10 @@
{ {
"name": "app_name", "name": "app_name",
"value": "HomeAgent" "value": "HomeAgent"
},
{
"name": "read_pasteboard_reason",
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
} }
] ]
} }

View File

@ -9,30 +9,56 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
``` ```
HomeAgent/ HomeAgent/
├── AppScope/ 应用级配置与图标 ├── AppScope/ 应用级配置与图标
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
├── entry/src/main/ ├── entry/src/main/
│ ├── ets/ │ ├── ets/
│ │ ├── common/ 通信与全局状态 │ │ ├── common/ 通信、状态与纯逻辑(无 UI
│ │ │ ├── ApiClient.ets REST 客户端X-API-Key 鉴权、超时、二进制附件) │ │ │ ├── ApiClient.ets REST 客户端X-API-Key 鉴权、超时、二进制附件)
│ │ │ ├── SseClient.ets SSE 长连接Last-Event-ID 断线续传) │ │ │ ├── SseClient.ets SSE 长连接Last-Event-ID 断线续传)
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent │ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
│ │ │ ├── BridgeRouter.ets 桥请求路由 │ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
│ │ │ ├── BridgeCaps.ets 能力声明 │ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
│ │ │ ├── ConnStore.ets 连接配置持久化 │ │ │ ├── UserError.ets 错误转人类可读文案
│ │ │ ├── StatusStore.ets 运行状态缓存 │ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航 │ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小 │ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译ChatStreamSink 接口)
│ │ │ ── UserError.ets 错误转人类可读文案 │ │ │ ── ChatSession.ets 发送/中断POST /chat、/chat/file
│ │ │ ├── ChatHistory.ets 历史载荷与 tool_calls 解析
│ │ │ ├── ChatFormat.ets ForEach 键、工具卡状态/配色、渠道判定
│ │ │ ├── AttachmentMeta.ets 附件解析与格式化(纯函数)
│ │ │ ├── AttachmentImage.ets 附件字节获取与解码(沙箱/远端)
│ │ │ ├── DeviceBridge.ets 设备桥客户端socket 生命周期与命令分发)
│ │ │ ├── BridgeProtocol.ets 设备桥协议消息与帧构造
│ │ │ ├── BridgeRouter.ets 桥请求路由
│ │ │ ├── BridgeCaps.ets 能力声明
│ │ │ ├── DeviceBridgeSession.ets 前台桥生命周期、网关地址推导
│ │ │ ├── DeviceModel.ets 设备页纯逻辑device_id 兜底、在线设备解析)
│ │ │ ├── PluginApi.ets 插件列表/详情接口
│ │ │ ├── PluginStatus.ets 插件状态判定与配色
│ │ │ ├── SettingsModel.ets 设置载荷解析、分类归并、分页、路由 id
│ │ │ └── MarkdownParser.ets Markdown 解析(块/行内/表格)
│ │ ├── components/ 可复用组件 │ │ ├── components/ 可复用组件
│ │ │ ├── MarkdownView.ets 流式 Markdown增量渲染 │ │ │ ├── MarkdownView.ets 流式 Markdown增量渲染
│ │ │ ├── StaticMarkdown.ets 静态 Markdown历史消息一次成型 │ │ │ ├── StaticMarkdown.ets 静态 Markdown历史消息一次成型
│ │ │ ├── Attachment.ets 附件卡 + 详情 │ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
│ │ │ ├── StatusCards.ets 状态卡片 │ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
│ │ │ ├── SettingsEditor.ets 配置编辑器 │ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮 │ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
│ │ │ ├── SubPage.ets 二级页容器 │ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
│ │ │ ├── ChatAttachBar.ets 加号菜单 + 待发送附件条
│ │ │ ├── SettingsHome.ets / SettingsRootEntries.ets / SettingsEntryCard.ets 设置一级页
│ │ │ ├── ConnectionsPane.ets / AppearancePane.ets / BackendSettingsPane.ets 设置二级页
│ │ │ ├── PluginListView.ets / PluginDetailPane.ets / PluginsOverlays.ets 插件页
│ │ │ ├── DeviceRootEntries.ets / DevicePanes.ets 设备页
│ │ │ ├── SettingsEditor.ets 配置编辑器
│ │ │ ├── StatusCards.ets 状态卡片
│ │ │ ├── ToastBar.ets 统一提示条(插件页与设置页共用)
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
│ │ │ ├── SubPage.ets 二级页容器 / NavGroup / NavRow / PlainCard
│ │ │ ├── MotionBase.ets 统一按压反馈与入场动画
│ │ │ └── GradientBackground.ets │ │ │ └── GradientBackground.ets
│ │ ├── model/Model.ets 共享类型定义 │ │ ├── model/Model.ets 共享类型定义
│ │ ├── pages/ 页面 │ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
│ │ │ ├── Index.ets Tab 容器(入口) │ │ │ ├── Index.ets Tab 容器(入口)
│ │ │ ├── ChatPage.ets 对话 │ │ │ ├── ChatPage.ets 对话
│ │ │ ├── DevicePage.ets 设备 │ │ │ ├── DevicePage.ets 设备
@ -45,11 +71,30 @@ HomeAgent/
└── oh-package.json5 依赖 └── oh-package.json5 依赖
``` ```
约定:**单个 `.ets` 不超过 400 行**,页面只做页面壳(导航栈 + 数据编排),
可复用结构进 `components/`,无 UI 的逻辑进 `common/`
这条约定有一个边界,别用反了:
> **不到 400 行的文件不要为了拆分而拆分。** `@Component` 的 `build()` 只允许一个根节点,
> 把原来多节点的 `@Builder` 改成组件时会多出一层 `Column` 包裹 —— 布局等价是**推理**出来的、
> 不是看出来的,每拆一次都要付一次"未上机验证"的账。所以拆分只用来解决真实的可读性/维护性
> 问题(超长文件、职责混杂),而不是凑行数。`pages/Index.ets` 目前 396 行就属于"不动"的一类:
> 没越线,余量本身也是有用的缓冲;等它真越线了再拆,并且优先看是不是又长出了大 `@Builder`。
## 编译 ## 编译
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。 需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0` 本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`
0. **前置条件:`oh_modules/` 必须存在**`ohpm install` 的产物)。
它被 `.gitignore` 忽略,所以干净 clone 后没有;而 hvigor **不会**自动补齐它 ——
实测把 `oh_modules/` 移走后构建不会触发 `ohpm install`,而是直接报一堆
`arkts-no-untyped-obj-literals`(依赖类型声明缺失),且不会重建该目录。
所以clone 后先 `ohpm install`,之后别把这个目录当垃圾清掉。
`entry/build/``.hvigor/` 是纯构建产物,可以随时删除(冷构建 ~8s
1. **准备签名配置**`build-profile.json5` 含密码明文,未入库): 1. **准备签名配置**`build-profile.json5` 含密码明文,未入库):
```bash ```bash
@ -64,19 +109,27 @@ HomeAgent/
2. **构建 HAP** 2. **构建 HAP**
```bash ```bash
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的 cd cmd/ohos/HomeAgent
/path/to/command-line-tools/bin/hvigorw \ # ⚠️ 不要用仓库里的 ./hvigorw它是符号链接启动脚本按 $(dirname $0) 定位,
--mode module -p module=entry@default assembleHap --no-daemon # 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。
# 一律用 command-line-tools 里的绝对路径(本机为 /opt/huawei/command-line-tools/bin/hvigorw
/opt/huawei/command-line-tools/bin/hvigorw \
assembleHap --mode module -p product=default --no-daemon
``` ```
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。 产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
3. **安装到设备** 3. **安装到设备**`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**
否则下面这个路径不存在):
```bash ```bash
hdc install entry/build/default/outputs/default/entry-default-signed.hap hdc install entry/build/default/outputs/default/entry-default-signed.hap
``` ```
路径里的目录名随构建模式而变:默认是 `default/`,若用 `-p product=<名字>` 则是该产品名。
拿不准就先 `find entry/build -name '*.hap'` 找一下。同目录还有 `entry-default-unsigned.hap`
`hdc install` 要用带 `-signed` 的那个。
## 连接 homed ## 连接 homed
首次启动在「设置」里填: 首次启动在「设置」里填:
@ -97,3 +150,19 @@ HomeAgent/
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。 - **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。 - **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
- 项目代码部分由 AI 辅助生成,改动请自行评估。 - 项目代码部分由 AI 辅助生成,改动请自行评估。
## 改动后的运行时验证清单
构建通过只能证明编译期没问题ArkUI 的状态绑定、过渡动画与手势行为
必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
- [ ] 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
- [ ] 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
- [ ] 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
- [ ] 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
- [ ] 进插件列表与插件详情,确认状态色、开关与卸载正常
- [ ] 进出设备页四个二级页,确认授权开关与在线设备列表正常
- [ ] 宽屏(>=600vp下确认左右分栏、返回手势与返回键行为
模拟器在无图形/无提权环境里可能起不来(需要写 `~/.Huawei` 等宿主目录),
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。

View File

@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
/conn use <name> switch to saved connection /conn use <name> switch to saved connection
/conn del <name> delete saved connection /conn del <name> delete saved connection
Server commands (sent to agent): Server commands (local 与 remote 行为一致):
/status system status /status system status
/kernel kernel status /kernel kernel status
/settings [prefix] list settings /settings [prefix] list settings
@ -32,10 +32,27 @@ Server commands (sent to agent):
/plugin list list installed plugins /plugin list list installed plugins
/plugin install <url> install plugin /plugin install <url> install plugin
/plugin remove <name> remove plugin /plugin remove <name> remove plugin
/plugin disable <name> disable plugin
/plugin enable <name> enable plugin
/plugin info <name> plugin details /plugin info <name> plugin details
/memory query <text> query graph memory /memory query <text> query graph memory
/knowledge list knowledge base /memory graph dump full graph memory snapshot
/memory text [n] recent text-memory events + stats
/memory context [q] assembled memory context (what gets injected)
/memory tools memory tool definitions + tool prompt
/knowledge list knowledge base (+stats)
/knowledge delete <name> delete knowledge item /knowledge delete <name> delete knowledge item
/config dump kernel config (JSON)
/tracker change-tracking stats
/tracker rollback roll back this session's file changes
/adapters list loaded Lua adapters
/adapters remove <name> remove a Lua adapter
/network network status + LLM endpoints
/runtime scheduler / residents / channel topology
/terminals list terminal sessions
/cmd/history command execution history
/terminal create|write|read|close … (local mode; calls agentcli tools)
/persona show persona (/persona set default|custom|later [text])
/agents list agents /agents list agents
/chat <text> send to agent /chat <text> send to agent
@ -54,6 +71,26 @@ Any other text is sent to the agent directly.`)
reconnect() reconnect()
return true return true
// /stop 与 /interrupt取消当前生成可附带一句新指令
// 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case
// 于是它像普通文本一样被发给了 Agent。
// 本地交给 CLI 插件(内核优先级 L3远端走 WebUI 的 chat/interrupt
// (内核优先级 L4。两条路都是“真中断”不是发一句话。
case cmd == "/stop" || cmd == "/interrupt" ||
strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "):
msg := stopMessage(cmd)
if rc := state.RemoteConn(); rc != nil {
body := fmt.Sprintf(`{"message":%q}`, msg)
if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil {
fmt.Fprintf(out, "interrupt failed: %v\n", err)
} else {
fmt.Fprintln(out, "interrupt sent")
}
} else {
state.Send(cmd)
}
return true
case strings.HasPrefix(cmd, "/connect "): case strings.HasPrefix(cmd, "/connect "):
cfg.Socket = strings.TrimSpace(cmd[9:]) cfg.Socket = strings.TrimSpace(cmd[9:])
cfg.Remote = "" cfg.Remote = ""
@ -143,7 +180,7 @@ Any other text is sent to the agent directly.`)
rc.DoAPI("PUT", "/api/v1/settings", body) rc.DoAPI("PUT", "/api/v1/settings", body)
fmt.Fprintln(out, "ok") fmt.Fprintln(out, "ok")
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
@ -152,7 +189,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("GET", "/api/v1/settings", "") d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
@ -172,7 +209,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body) d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
@ -182,7 +219,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "") d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
@ -192,17 +229,56 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "") d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
case strings.HasPrefix(cmd, "/memory query "): // disable/enable本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
q := strings.TrimSpace(cmd[14:]) case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
verb := "disable"
name := strings.TrimSpace(cmd[16:])
if strings.HasPrefix(cmd, "/plugin enable ") {
verb = "enable"
name = strings.TrimSpace(cmd[15:])
}
if name == "" {
fmt.Fprintln(out, "usage: /plugin disable|enable <name>")
return true
}
if rc := state.RemoteConn(); rc != nil { if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "") d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
}
return true
case strings.HasPrefix(cmd, "/memory "):
sub := strings.TrimSpace(cmd[8:])
if rc := state.RemoteConn(); rc != nil {
switch {
case strings.HasPrefix(sub, "query "):
q := strings.TrimSpace(strings.TrimPrefix(sub, "query "))
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
printJSON(out, d)
case sub == "graph":
d, _ := rc.DoAPI("GET", "/api/v1/memory/graph", "")
printJSON(out, d)
case sub == "text" || strings.HasPrefix(sub, "text "):
d, _ := rc.DoAPI("GET", "/api/v1/memory/text", "")
printJSON(out, d)
case sub == "context" || strings.HasPrefix(sub, "context "):
q := strings.TrimSpace(strings.TrimPrefix(sub, "context"))
d, _ := rc.DoAPI("GET", "/api/v1/memory/context?q="+q, "")
printJSON(out, d)
case sub == "tools":
d, _ := rc.DoAPI("GET", "/api/v1/memory/tools", "")
printJSON(out, d)
default:
fmt.Fprintln(out, "usage: /memory query <text> | /memory graph | /memory text [n] | /memory context [q] | /memory tools")
}
} else {
state.Send(cmd)
} }
return true return true
@ -212,16 +288,126 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "") d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send(cmd[1:]) state.Send(cmd)
} }
return true return true
case cmd == "/knowledge": case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
if rc := state.RemoteConn(); rc != nil { if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "") d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
printJSON(out, d) printJSON(out, d)
} else { } else {
state.Send("/knowledge") state.Send(cmd)
}
return true
case cmd == "/config":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/config", "")
printJSON(out, d)
} else {
state.Send("/config")
}
return true
case cmd == "/tracker":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/tracker", "")
printJSON(out, d)
} else {
state.Send("/tracker")
}
return true
case cmd == "/tracker rollback":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("POST", "/api/v1/tracker/rollback", "")
printJSON(out, d)
} else {
state.Send("/tracker rollback")
}
return true
case cmd == "/adapters" || strings.HasPrefix(cmd, "/adapters remove "):
if rc := state.RemoteConn(); rc != nil {
if strings.HasPrefix(cmd, "/adapters remove ") {
name := strings.TrimSpace(cmd[17:])
d, _ := rc.DoAPI("DELETE", "/api/v1/adapters/"+name, "")
printJSON(out, d)
} else {
d, _ := rc.DoAPI("GET", "/api/v1/adapters", "")
printJSON(out, d)
}
} else {
state.Send(cmd)
}
return true
case cmd == "/persona" || strings.HasPrefix(cmd, "/persona set "):
if rc := state.RemoteConn(); rc != nil {
if strings.HasPrefix(cmd, "/persona set ") {
rest := strings.TrimSpace(cmd[13:])
mode := rest
content := ""
if idx := strings.IndexByte(rest, ' '); idx > 0 {
mode = rest[:idx]
content = strings.TrimSpace(rest[idx+1:])
}
body := fmt.Sprintf(`{"mode":%q,"content":%q}`, mode, content)
d, _ := rc.DoAPI("POST", "/api/v1/persona", body)
printJSON(out, d)
} else {
d, _ := rc.DoAPI("GET", "/api/v1/persona", "")
printJSON(out, d)
}
} else {
state.Send(cmd)
}
return true
case cmd == "/terminals":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/terminals", "")
printJSON(out, d)
} else {
state.Send("/terminals")
}
return true
case cmd == "/cmd/history":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "")
printJSON(out, d)
} else {
state.Send("/cmd/history")
}
return true
case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "):
if rc := state.RemoteConn(); rc != nil {
// 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建),
// 不静默当聊天发出去,直接说明。
fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具")
} else {
state.Send(cmd)
}
return true
case cmd == "/runtime":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/runtime", "")
printJSON(out, d)
} else {
state.Send("/runtime")
}
return true
case cmd == "/network":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/network", "")
printJSON(out, d)
} else {
state.Send("/network")
} }
return true return true
@ -239,6 +425,16 @@ Any other text is sent to the agent directly.`)
} }
} }
// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。
func stopMessage(cmd string) string {
for _, prefix := range []string{"/interrupt", "/stop"} {
if strings.HasPrefix(cmd, prefix) {
return strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
}
}
return ""
}
func printJSON(out io.Writer, d map[string]interface{}) { func printJSON(out io.Writer, d map[string]interface{}) {
if d == nil { if d == nil {
fmt.Fprintln(out, "(no data)") fmt.Fprintln(out, "(no data)")

View File

@ -39,7 +39,7 @@ type msgEntry struct {
type daemonHandler struct { type daemonHandler struct {
// homed 连接 // homed 连接
homeMu sync.Mutex homeMu sync.Mutex
homeConn net.Conn homeConn net.Conn
homeR *bufio.Reader homeR *bufio.Reader
homeCfg *Config homeCfg *Config
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
if dg == "" || dt == "" { if dg == "" || dt == "" {
return return
} }
// 设备桥重连循环WS 断开时自动重连 // 设备桥重连循环WS 断开时自动重连,并保留配置中的本地授权状态。
go runDeviceBridgeLoop(dg, dt) go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
} }
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。 // runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
func runDeviceBridgeLoop(gateway, token string) { func runDeviceBridgeLoop(gateway, token string, authorized bool) {
for { for {
bridge, err := connectDeviceBridge(gateway, token) bridge, err := connectDeviceBridge(gateway, token, authorized)
if err != nil { if err != nil {
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err) log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string) {
} }
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。 // connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) { func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
if hostname == "" { if hostname == "" {
hostname = "local" hostname = "local"
@ -355,12 +355,17 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
} }
bridge := client.New(gw, token, deviceID, hostname, caps, info) bridge := client.New(gw, token, deviceID, hostname, caps, info)
bridge.SetAuthorized(authorized)
// 注册命令处理器 // 注册命令处理器cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
cr := client.NewCmdRouter() cr := client.NewCmdRouter()
cr.Handle("homeagent-", handleHomeagentCmd) cr.Handle("homeagent-", handleHomeagentCmd)
cr.HandleDefault(handleShellCmd) cr.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command string) { bridge.OnCmd(func(reqID, command, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
cr.Dispatch(reqID, command) cr.Dispatch(reqID, command)
}) })
@ -371,8 +376,6 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
// 设置全局变量供 sendBridgeResult 使用 // 设置全局变量供 sendBridgeResult 使用
deviceBridge = bridge deviceBridge = bridge
deviceBridgeID = deviceID deviceBridgeID = deviceID
auth := true // daemon 模式默认授权(配置已指定)
bridge.SetAuthorized(auth)
return bridge, nil return bridge, nil
} }

View File

@ -15,6 +15,7 @@ import (
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client" "gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
) )
// ===== 设备桥管理 ===== // ===== 设备桥管理 =====
@ -46,6 +47,9 @@ func startDeviceBridge(addr, token string) error {
"platform": runtime.GOOS, "platform": runtime.GOOS,
"arch": runtime.GOARCH, "arch": runtime.GOARCH,
"cpus": runtime.NumCPU(), "cpus": runtime.NumCPU(),
// 客户端版本与内核同源internal/metadeviceinfo 回显的软件版本
// 因此与 homed 一致,不再是一个空缺字段。
"version": meta.Version,
} }
// 确保 gateway URL 格式正确 // 确保 gateway URL 格式正确
@ -61,13 +65,22 @@ func startDeviceBridge(addr, token string) error {
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info) bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
cmdRouter = client.NewCmdRouter() cmdRouter = client.NewCmdRouter()
// 注册命令处理器 // cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
cmdRouter.Handle("homeagent-", handleHomeagentCmd) cmdRouter.Handle("homeagent-", handleHomeagentCmd)
cmdRouter.HandleDefault(handleShellCmd) cmdRouter.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command string) { bridge.OnCmd(func(reqID, command, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
cmdRouter.Dispatch(reqID, command) cmdRouter.Dispatch(reqID, command)
}) })
// agent 主动投递output_send__device/<id>)→ 终端显示。
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnDataTTS 音频等)。
bridge.OnPush(func(reqID, typ, payload, meta string) {
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
})
if err := bridge.Start(); err != nil { if err := bridge.Start(); err != nil {
return fmt.Errorf("device bridge: %w", err) return fmt.Errorf("device bridge: %w", err)
} }
@ -767,4 +780,4 @@ func sanitizeID(s string) string {
} }
} }
return sb.String() return sb.String()
} }

View File

@ -11,6 +11,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
) )
const ( const (
@ -129,8 +131,15 @@ func main() {
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入") daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
testCap := flag.String("test-cap", "", "测试本地能力screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue如 --test-cap screensue") testCap := flag.String("test-cap", "", "测试本地能力screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue如 --test-cap screensue")
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数") testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
showVersion := flag.Bool("version", false, "打印版本并退出")
flag.Parse() flag.Parse()
// 版本号直接来自 internal/meta与 homed 同一事实源,不可能各写一个)。
if *showVersion {
fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime)
return
}
// 本地能力测试模式(无需连接服务器) // 本地能力测试模式(无需连接服务器)
if *testCap != "" { if *testCap != "" {
runCapTest(*testCap, *testCapArgs) runCapTest(*testCap, *testCapArgs)
@ -270,9 +279,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
addrLabel = cfg.Remote addrLabel = cfg.Remote
} }
if colors { if colors {
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset) fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
} else { } else {
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel) fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
} }
fmt.Println("Type /help for commands.") fmt.Println("Type /help for commands.")

364
demo.md
View File

@ -1,364 +0,0 @@
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
---
## 0. 背景与目标
框架目标:**内核零 IO、插件承载所有 IO**`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
**Failback 的定位所有错误的一层兜底Safe-Mode 式),而非针对单一场景。**
- 主 agent全量 LLM agent是一切骚操作的执行者可能把自己搞到无法自愈的任意状态
LLM 源改坏 / 系统网络proxy、DNS、host破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
这些错误无法在**同一个被污染环境内**用自身操作自救。
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
---
## 1. 现状盘点(代码勘查结论)
### 1.1 守护进程(`internal/supervisor/daemon.go`
- 主 agent in-process 常驻,`agent.Start()``cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
- `healthLoop``checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor``AggregateResult().LLMAPIReachable`**`daemon.go:132`)。
- `failCount >= MaxRetries`(默认 3`handleFailure`
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`
- `restartAgent``daemon.go:166`**只改内存状态再重新 Register不真重启任何进程**,几乎空转。
关键问题:
- 探测是系统级HTTP/DNS/TCP回滚只作用于 `<data>/agentfs` overlay 的 upper**二者对象错位**。
- `AgregateResult` 在无 LLM endpoint 时恒 healthy机制形同虚设。
- `lastHB` 每轮都置 `time.Now()``Uptime` 无意义。
- `RollbackPolicy``HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`
### 1.2 文件追踪(`internal/tracker`
- overlayfs 三层:`lower/upper/work → merged``tracker.go:156`)。
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256`changeset.go:56``PreAction/PostAction` 前后 diff`toolcall.go:86-94`)。
- **lower 恒空**`Init``MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets`FileChange.Content`(本应存回滚原文)**从未回填**。
结论overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**
`qq.napcat_url``qq.listen``qq.files_dir``qq.remote_dir``dm/group_policy``plugin.go:120-130`)。
- 插件在 `Start()``getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
### 1.4 LLM 源与"恢复即生效"
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
---
## 2. 现实环境:日志记录模式内参
> 看真实 systemd 托管的 HomeAgent`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
### 2.1 systemd 托管现状(样例)
```
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
```
- 实测数据区:`/home/newqqagent/` 下有 `log/``config.db``agentfs/`overlay merged`snapshots/``changesets/``knowledge/``memory/``plugins/`
`cli.sock``adapters/``homed.log``memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
- 启动段确认:`[files] started, sandbox: /`**files 沙箱=全主机 `/` 实锤**`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`LLM 走本地 mock
### 2.2 日志目录与格式(核心)
- **目录配置**`core.log.path`,默认 `<dataDir>/log``internal/config/registry.go:415,508`)。
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log``cmd/homed/main.go:75`
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))``main.go:80`)——
**同时进 stderrsystemd 捕获到 journald/`journalctl -u`)与文件**
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
- **层级压缩 + 保留**`internal/log/manager.go:26-28` + `compressor.go`
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$``compressor.go:16`)。
- 保留策略:`core.log.retention`default forever`core.log.retention_months`default 3
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`
| 位置 | 日志行内容 | 备注 |
|---|---|---|
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id不记 args** |
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`|
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
| `process.go:89/109/121/125` | LLM fallback`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
| 插件侧 | Lua 插件 `sdk.log``print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall``{tool, plugin, args, result, status}``process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
需要的话由 `diag_log_scan``executing tool: cmd_*` 前缀做签名匹配即可。
### 2.4 崩溃 / 重启观察
- `NRestarts=0`MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**pid 3310036
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`03:23 / 05:23 / 07:23约每 2h
`stages.go:139``RunStage` recover 吞掉 → **进程未真崩**systemd 未见重启。
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
---
## 3. 设计演进(讨论全过程)
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
- 意图LLM 不可达 → 回滚 agent 文件改动 → 自愈。
### 3.1 第一次纠正:回滚对象错位
- 回滚只作用于 overlayfs upper而真正能改坏网络的路径files 默认 `/`,可写 `/etc/resolv.conf``/etc/hosts`、代理配置)与 LLM 配置config.db、adapters 目录)**都不在 overlay 内**。
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
### 3.2 第二次纠正:授权不能靠外部插件主动配合
- 阶段管道是被动通知(`RunStage` 收集 error不主动拒绝拒绝依赖各插件 handler。
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置IO 组件崩溃是正交偶发轴,不并进来。
### 3.3 第三次纠正LLM 源才是核心config.db + ReloadFromConfig 已具钩子)
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测QuickChat+ 在 handleFailure 里触发 RFO 复检**。
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件proxy/DNS** —— 这类连救援 LLM 都连不上。
### 3.4 定稿架构一lastFailback独立进程第一道防线
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd+ 单一任务。
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
- systemd 只绑定**静态、极小、永驻的 guard**guard 作为父进程拉起 worker`homed` 全量 agent
- guard 通过 IPCunix socket / 匿名 pipe收 worker 心跳;
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
- worker 可崩可换systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**
- guard 抗 OOM`oom_score_adj` 拉低,优先杀 worker 而非 guard。
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role非独立二进制
- **不新建二进制**。`homed` 拆两个入口:
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
- `homed --role=agent`:主 agent工作进程guard 的子进程)。
- `homed --role=agent --boot=failback`:恢复用 agent受限 bootstrap
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent只加载
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件****常见错误检测插件**
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
- guard 配置**独立 YAML**,不复用 config.db逃生舱知识必须脱离 agent 可达区)。
### 3.7 recoveryDiag崩溃取证 / 根因定位插件(省 token 关键)
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
- 工具集:
| 工具 | 作用 |
|---|---|
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
| `diag_log_scan` | 时间窗内命中已知错误签名panic/provider failed/unreachable/sql/OOM|
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff"改了什么"|
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
- 崩溃类别 → 恢复分支决策表:
| 结论类 | 走分支 |
|---|---|
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent无需 agent 推理)|
| 系统网络类 | 还原 DNS/proxy → 重载主 agent第一步小修命中即停|
| 进程失稳类OOM/panic| 不还原配置,检查内存/泄漏 → 重建 worker |
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
- 只有"未知/混合"消耗 token前几类近乎 0 token。
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
### 3.8 三层防御总览(最终)
```
L0 平时:核心只读探活 + 写前快照config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
L1 failbackguard 拉起Safe-Mode 式兜底):按诊断分支逐类恢复——
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
L2 最后手段agentfs 回滚最近快照 + 重启(应用快照前系统配置)
```
- failback 是**所有错误LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
---
## 4. 最终架构(定稿)
### 4.1 进程拓扑(同一二进制,两个 role
```
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
├─ exec ──▶ homed --role=agent # 主 agent可崩
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
```
- guard先起持有恢复知识锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
- workerguard 子进程,心跳经 IPC崩溃由 guard reap + 判型。
- failback agent = 受限启动webfetch+files+cmd + 恢复知识库 + recoveryDiag单一任务"恢复主 agent"N 轮有界。
### 4.2 guard 独立 YAML 示例
> 现状实现§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
```yaml
role: guard
llm:
sources:
- name: rescue
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
api_key: ${GUARD_RESCUE_KEY}
adapter: ... # 锚定/SNI 型适配器
recovery:
max_attempts: 4 # 可配置尝试轮次
attempt_timeout: 120s
knowledge_base: /opt/homeagent/recovery/
trigger_prompt: "你是恢复 agent唯一任务让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
plugins: [webfetch, files, cmd]
last_resort:
action: reboot # restart_app | reboot
snapshot_before: true
```
### 4.3 guard 恢复状态机N 轮有界)
```
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
1. 固化追溯exit/信号、panic、journal、OOM 上下文 → 永久区
2. 拉起 failback agent受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag
for attempt in 1..N:
(可选先 diag_triage/diag_loc 判型)
failback 尝试恢复
guard 每轮复检主 agent 是否可达/存活
├─ 成功 → 结束,交回主 agent
└─ 超时/失败 → kill 重建,进入下一轮
3. N 轮未成 → 取消 failback agent
→ agentfs 回滚崩溃前最近快照
→ 依 yaml 执行最后手段restart_app 或 reboot
```
### 4.4 systemd 绑定(极简,杜绝风暴)
```
[Unit] # guard
OnFailure=... # 备用通常不触发guard 稳定)
[Service] # guard
Restart=always # guard 静态稳定 → 几乎不重启
ExecStart=/usr/local/bin/homed --role=guard ...
# No StartLimit needed for loop 情况guard 不崩
```
- 主 agent 崩 → 只触发 guard 内部 failbacksystemd 仅看 guard看不到 worker 崩溃循环。
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
- guard 抗 OOM`oom_score_adj` 拉低。
---
## 5. 尚未落地的接口 / 下一步
**已完成**
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`config.db integrity_check + LLM 源字段校验sqlite3 CLI 优先、缺失回退内核 Settings`diag_log_scan`(日志签名按类计数)、`diag_delta`baseline vs 现状 diff`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
- **结论落盘 + 知识库回流**`diag_loc``persist`(缺省 true→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add``diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev``go build -o ... .`
- 注意:本环境 `snapshots/``changesets/` 均为空direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir未来接 guard 时由快照解包目录提供。
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件guard 恢复的外部基线homed 启动即挂 `<data>/llm_snapshot.json`
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM``TestLLMSnapshotFile``TestSetLLMSnapshotFile`
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db拉起 worker`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`restart_app / reboot
- 心跳worker 每 5s 触碰 `<data>/heartbeat`agent 角色 goroutineguard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
- 插件注册表加 `SetLoadAllowlist(names)`白名单外插件含已注册工厂一律跳过failback 40 工具 → 4 工具实测通过。
现状 bug 修复supervisor/network/tracker
- **monitor 无 endpoint 恒 healthy**`internal/network/monitor.go` + `pkg/types``NetworkCheckResult``EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error`NewMonitor` 初始化空切片消除启动竞态daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url生产从此健康检查有真实目标。
- **lastHB 恒置 now**`internal/supervisor/daemon.go``checkAgent` 接入真实存活源 `SetHeartbeatSource`homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
- **restartAgent 只改内存空转**:增 `SetRestartHandler`homed 注册为"清理后以 `exitRestartRequested=42` 退出"不再假装成功guard 把 42 识别为"请求重建"`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
- **tracker 三缺陷**`internal/tracker/``captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB`diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
剩余:
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard`--role=guard`),确认 failback 插件recoverydiag就位。
---
## 附录真实日志节选systemd 托管示例,`/home/newqqagent`
```
# systemd unit
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
# 日志文件与格式log.Printf 标准格式)
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
# 工具调用摘要process.go
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
... process.go:226: [agent] tool <name> result: <截断100字符>
# 启动 & 沙箱
homed[3330844]: [files] started, sandbox: /
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
# 重复 in-process panic被 RunStage recover 吞掉,未进程级崩溃)
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
# 一次性干净重启systemd 手动/触发 Startedpid 3310036 → 3330844
homed[3310036]: [homed] stopped
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
# 运行状态
systemctl show homeagent.service -p NRestarts → 0
systemctl show homeagent.service -p MainPID → 3330844自 08-03 起稳定)
# 归档
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
```

View File

@ -6,9 +6,11 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/homed -data /var/lib/homeagent ExecStart=/usr/bin/homed -data /var/lib/homeagent
Restart=always Restart=always
RestartSec=10 RestartSec=10
Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime
StateDirectory=homeagent
StartLimitBurst=3 StartLimitBurst=3
StartLimitInterval=60s StartLimitInterval=60s
@ -26,7 +28,9 @@ DeviceAllow=/dev/dsp rw
# Resource limits # Resource limits
LimitNOFILE=65536 LimitNOFILE=65536
LimitNPROC=256 LimitNPROC=256
MemoryMax=2G # Chinese-CLIP 本身实测约 1.15GB;加文档稠密索引、词向量、插件与
# ORT arena 后生产实例约 4.5GB。2GB 会在首次全量建索引时被 cgroup OOM。
MemoryMax=8G
CPUQuota=100% CPUQuota=100%
[Install] [Install]

View File

@ -15,6 +15,13 @@ BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}" GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}" LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
# 版本与提交的**权威来源**是上面注入的 meta.Version / meta.Commit不是 Go 自带的
# VCS 戳。后者不进 build cache keyGo 文档明确说明 VCS 变化不会触发重建),
# 命中缓存时会把上一次的 revision 一并带回来——实测发布分支的产物上就出现了
# 1715b5c本机任何仓库都不存在的提交用 `go version -m` 溯源会指向幽灵提交。
# 统一 -buildvcs=false宁可没有这个信号也不要一个错的。
# 溯源请用:`strings homed | grep -m1 '^<短 hash>$'`meta.Commit 是字符串常量)。
TARGET="${1:-native}" TARGET="${1:-native}"
COMPONENT="${2:-all}" COMPONENT="${2:-all}"
@ -27,10 +34,19 @@ COMPONENT="${2:-all}"
case "$TARGET" in case "$TARGET" in
native) GOOS="" GOARCH="" ;; native) GOOS="" GOARCH="" ;;
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;; linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
# arm64 刻意不设 CXX设了会让 Go 用 aarch64 的 g++ 去链接, # arm64 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 用宿主 g++ 编出
# 而它对 host 产生的 .o 报 "file format not recognized" # x86-64 的 .o链接时报 "Relocations in generic ELF (EM: 183)"183 = aarch64
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译gcc 能编 C++)。 #
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;; # 此处曾有一条注释写着「arm64 刻意不设 CXX」理由是设了会报
# "file format not recognized"。那个判断是错的:那个报错的真因是
# cmd/{homed,waiter}/*.sysox86-64 COFF Windows 资源对象)被链进了目标,
# 与 CXX 无关。四组对照:
# syso 在 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 在 + 有 CXX → 000000.o: file format not recognized
# syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 隐藏 + 有 CXX → 成功ELF aarch64
# 本脚本的 hide_syso_for_target 已处理前一个条件,这里补上后一个。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;; darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;; darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
# Windows 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++ # Windows 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++
@ -46,7 +62,7 @@ case "$TARGET" in
;; ;;
*) *)
echo "Unknown target: $TARGET" echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]" echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
echo " [all|homed|waiter|initconfig|gui]" echo " [all|homed|waiter|initconfig|gui]"
exit 1 exit 1
esac esac
@ -109,8 +125,21 @@ build_homed() {
# Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。 # Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。
# 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的 # 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的
# 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld # 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld
#
# HOMED_TAGS 默认带 onnxruntime发行版**默认启用**本地向量空间。
# 不带这个标签时 providers/chineseclip 与 providers/qwen3vl 仍会注册,
# 但打开时报「requires build tag」并优雅降级不静默假装成功
# 需要极简构建时可显式 HOMED_TAGS= 关掉。
#
# 运行期还需要 libonnxruntime.soprovider 按 /opt/onnxruntime、
# /usr/local/lib、/usr/lib 顺序查找);缺失时同样是「日志里的明确错误 +
# 降级」,不会假装启用。
local _cc="${CC:-cc}" local _cc="${CC:-cc}"
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \ local _tags="${HOMED_TAGS-onnxruntime}"
local -a _tagargs=()
if [ -n "$_tags" ]; then _tagargs=(-tags "$_tags"); fi
CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
${_tagargs[@]+"${_tagargs[@]}"} \
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/ -ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))" echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
} }
@ -122,28 +151,74 @@ build_waiter() {
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] waiter ${plat}$out" echo "[BUILD] waiter ${plat}$out"
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \ CGO_ENABLED=0 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/ -ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/
echo " OK ($(du -h "$out" | cut -f1))" echo " OK ($(du -h "$out" | cut -f1))"
} }
# ---- initconfig (CGO-free 配置初始化器) ---- # ---- initconfig(必须 cgo写 config.db 用的是 go-sqlite3----
# #
# NSIS 安装包installer.nsi:220 File "..\build\initconfig.exe")与 # 这里**必须** CGO_ENABLED=1。此前写的是 CGO_ENABLED=0而 cmd/initconfig 通过
# package-linux.sh 的 stage_variant 都引用它,但此前 build.sh 从不构建它—— # database/sql 使用 mattn/go-sqlite3CGO_ENABLED=0 时该库退化成 static_mock.go
# Windows 安装包构建会直接失败在缺文件上。 # 里的桩sql.Open 是懒的所以不报错、第一次 Exec 才失败;而 main.go 当时忽略
# 了所有错误——于是 initconfig 打印凭据、退出码 0、一个字节都没写进 config.db。
# 安装脚本把这份凭据写进 credentials.txt用户照它登录必然失败全程无报错。
#
# NSIS 安装包installer.nsi与 package-linux.sh 的 stage_variant 都引用它,
# 但此前 build.sh 从不构建它——Windows 安装包构建会直接失败在缺文件上。
build_initconfig() { build_initconfig() {
local plat="${GOOS:-linux}/${GOARCH:-amd64}" local plat="${GOOS:-linux}/${GOARCH:-amd64}"
local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}" local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}"
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] initconfig ${plat}$out" echo "[BUILD] initconfig ${plat}$out"
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \ CGO_ENABLED=1 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/ -ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
echo " OK ($(du -h "$out" | cut -f1))" echo " OK ($(du -h "$out" | cut -f1))"
} }
# ---- linux-payload给 Windows 安装器用的 Linux 包)----
#
# Windows 不再安装 homed.exehomed 依赖 fd 继承 + 统一共享内存区的段内偏移
# 解引用Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go
# Windows 安装器改为引导到 WSL2并把 **Linux 包**送进发行版里安装。
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
# build/linux-payload/installer.nsi 从这里 File /r 打进安装包)。
#
# 复用 package-linux.sh 的产物而不是在这里另行编译WSL 里跑的就是普通
# linux/amd64安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
stage_linux_payload() {
local src="$PROJECT_ROOT/dist/linux"
local out="$BUILD_DIR/linux-payload"
rm -rf "$out"
mkdir -p "$out"
local found=0
for f in "$src"/*.deb "$src"/*.tar.gz; do
[ -f "$f" ] || continue
cp "$f" "$out/"
found=$((found + 1))
done
if [ "$found" -eq 0 ]; then
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
echo " Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
return 1
fi
echo "[BUILD] linux-payload ← $found 个包"
ls -1 "$out" | sed 's/^/ /'
}
# ---- gui (Electron) ---- # ---- gui (Electron) ----
#
# 输出目录必须用 --config.directories.output**不能用 -o**
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
# Unknown target: /home/program/trueagent/build
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
build_gui() { build_gui() {
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host" echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
@ -161,22 +236,52 @@ build_gui() {
# 不传 --configelectron-builder 默认从 package.json 的 "build" 键读配置。 # 不传 --configelectron-builder 默认从 package.json 的 "build" 键读配置。
# 传 --config package.json 会让它把**整个** package.json 当配置校验, # 传 --config package.json 会让它把**整个** package.json 当配置校验,
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。 # 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
(cd "$gui_dir" && npx electron-builder \ #
--linux --win --mac \ # GUI 失败不中断整体构建homed/waiter/initconfig 是发布的主体,
--x64 --arm64 \ # 而 GUI 依赖 electron 运行时下载离线机器、arm64 缺缓存都会失败)。
-p never \ # set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
-o "$BUILD_DIR") if (cd "$gui_dir" && npx electron-builder \
echo " OK" --linux --win --mac \
--x64 --arm64 \
-p never \
--config.directories.output="$BUILD_DIR"); then
echo " OK"
else
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig"
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
return 0
fi
} }
# ---- dispatch ---- # ---- dispatch ----
case "$COMPONENT" in if [ "${GOOS:-}" = "windows" ]; then
all) build_homed; build_waiter; build_initconfig; build_gui ;; # Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
homed) build_homed ;; # 需要的是Linux 包(送进 WSL 安装)+ Windows 侧客户端waiter CLI / GUI
waiter) build_waiter ;; case "$COMPONENT" in
initconfig) build_initconfig ;; all) build_waiter; stage_linux_payload; build_gui ;;
gui) build_gui ;; waiter) build_waiter ;;
*) payload) stage_linux_payload ;;
echo "Unknown component: $COMPONENT" gui) build_gui ;;
exit 1 homed|initconfig)
esac echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2或用 linux/amd64 目标)。" >&2
echo "原因见 cmd/homed/platform_windows.go。" >&2
exit 1
;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
else
case "$COMPONENT" in
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
fi

View File

@ -76,6 +76,10 @@ Function GenKey
FunctionEnd FunctionEnd
!insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_WELCOME
; 许可页AGPL-3.0-only全文在仓库根 LICENSE
; NSIS 的 File/!insertmacro 相对路径以**本 .nsi 所在目录**为基准解析,
; 而本文件在 deploy/packaging/,故仓库根是 ..\..\ 。
!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
!insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_DIRECTORY
!if "${HAS_CREDENTIALS}" == "1" !if "${HAS_CREDENTIALS}" == "1"
@ -216,17 +220,26 @@ FunctionEnd
Section "Install" SEC_INSTALL Section "Install" SEC_INSTALL
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
CreateDirectory "$INSTDIR\data" CreateDirectory "$INSTDIR\data"
CreateDirectory "$INSTDIR\data\log" CreateDirectory "$INSTDIR\data\log"
CreateDirectory "$INSTDIR\data\plugins" CreateDirectory "$INSTDIR\data\plugins"
CreateDirectory "$INSTDIR\data\adapters" CreateDirectory "$INSTDIR\data\adapters"
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
; 解引用Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go
; Windows 侧改为引导到 WSL2把 **Linux 包**送进发行版里按 Linux 的方式安装。
; 所以这里带的是 linux/amd64 的 payload不是 homed.exe。
!if "${HAS_CORE}" == "1" !if "${HAS_CORE}" == "1"
File "..\..\build\initconfig.exe" SetOutPath "$PLUGINSDIR\linux-payload"
File "..\..\build\homed.exe" File /r "..\..\build\linux-payload\*.*"
SetOutPath "$INSTDIR"
!endif !endif
!if "${HAS_WAITER}" == "1" !if "${HAS_WAITER}" == "1"
; waiter 是 CLI 客户端WSL 侧会装上 Linux 版Windows 侧仍可保留原生版
; (它只是个客户端,不走插件体系)。
File "..\..\build\waiter.exe" File "..\..\build\waiter.exe"
!endif !endif
@ -237,11 +250,24 @@ Section "Install" SEC_INSTALL
!endif !endif
!if "${HAS_CORE}" == "1" !if "${HAS_CORE}" == "1"
DetailPrint "初始化配置数据库..." ; 在 WSL2 里安装 homed。凭据页面上收的那三个透传进去避免
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"' ; 「界面显示一份、config.db 里另一份」导致登录不上。
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
Pop $0 Pop $0
Pop $1
${If} $0 != 0 ${If} $0 != 0
DetailPrint "警告: 数据库初始化可能未成功完成" ; 退出码含义见 install-via-wsl.ps120/21 是「WSL 或发行版缺失,需要先装」,
; 属于可指引的用户动作,不当成安装失败来恐吓人。
${If} $0 == 20
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows再重新运行本安装程序。"
${ElseIf} $0 == 21
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
${Else}
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0。$\r$\n$\r$\n可进入 WSL 手动排查wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
${EndIf}
${Else}
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
${EndIf} ${EndIf}
!endif !endif

View File

@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
Architecture: ARCH_PLACEHOLDER Architecture: ARCH_PLACEHOLDER
Maintainer: HomeAgent Team <team@homeagent.ai> Maintainer: HomeAgent Team <team@homeagent.ai>
Installed-Size: INSTALLED_SIZE_PLACEHOLDER Installed-Size: INSTALLED_SIZE_PLACEHOLDER
Depends: libc6 (>= 2.28) Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
Section: utils Section: utils
Priority: optional Priority: optional
Homepage: https://github.com/trueagent/HomeAgent Homepage: https://github.com/trueagent/HomeAgent

View File

@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
Architecture: ARCH_PLACEHOLDER Architecture: ARCH_PLACEHOLDER
Maintainer: HomeAgent Team <team@homeagent.ai> Maintainer: HomeAgent Team <team@homeagent.ai>
Installed-Size: INSTALLED_SIZE_PLACEHOLDER Installed-Size: INSTALLED_SIZE_PLACEHOLDER
Depends: libc6 (>= 2.28) Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
Section: utils Section: utils
Priority: optional Priority: optional
Homepage: https://github.com/trueagent/HomeAgent Homepage: https://github.com/trueagent/HomeAgent

View File

@ -2,24 +2,63 @@
set -e set -e
SERVICE_NAME="homeagent" SERVICE_NAME="homeagent"
SERVICE_FILE="/lib/systemd/system/${SERVICE_NAME}.service"
HOMED_BIN="/usr/bin/homed" HOMED_BIN="/usr/bin/homed"
DATA_DIR="/var/lib/homeagent" DATA_DIR="/var/lib/homeagent"
SETUP_SH="/usr/lib/homeagent/setup.sh"
# unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查
# /lib/systemd/system/merged-usr 下等于 /usr/lib/systemd/system那里没有
# 这个文件)——于是 daemon-reload 与 enable **从未执行过**:装完不会开机自启,
# 而 postinst 全程无报错。这里三个候选位置都看一下。
find_unit() {
for p in "/etc/systemd/system/${SERVICE_NAME}.service" \
"/usr/lib/systemd/system/${SERVICE_NAME}.service" \
"/lib/systemd/system/${SERVICE_NAME}.service"; do
if [ -f "$p" ]; then
printf '%s' "$p"
return 0
fi
done
return 1
}
case "$1" in case "$1" in
configure) configure)
if [ -f "$HOMED_BIN" ]; then if [ -f "$HOMED_BIN" ]; then
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# 初始化凭据和数据库 # 初始化凭据和数据库
if [ -x /usr/lib/homeagent/setup.sh ]; then #
HOMEAGENT_DATA="$DATA_DIR" /usr/lib/homeagent/setup.sh || true # 这里不能再用 `|| true` 吞失败setup.sh 靠 initconfig 写 config.db
# 而 initconfig 曾因 CGO_ENABLED=0 静默空操作(凭据只进了
# credentials.txt、没进数据库用户拿它登录必然失败安装却一声不响。
# 失败必须看得见,并给出可直接执行的补救命令。
if [ -x "$SETUP_SH" ]; then
if ! HOMEAGENT_DATA="$DATA_DIR" "$SETUP_SH"; then
echo "E: homeagent 初始化失败——凭据可能未写入 config.db。" >&2
echo "E: 请手动重试HOMEAGENT_DATA=$DATA_DIR $SETUP_SH" >&2
fi
else
echo "W: 未找到 $SETUP_SH跳过凭据初始化。" >&2
fi fi
# 注册 systemd 服务 # 注册 systemd 服务
if [ -f "$SERVICE_FILE" ]; then if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload 2>/dev/null || true if find_unit >/dev/null; then
systemctl enable "$SERVICE_NAME" 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
# 首次安装就拉起来,装完即可用;升级时重启以真正加载新二进制
# (仅 enable 不会让已在运行的进程换用新文件)。
if [ -z "${2:-}" ]; then
systemctl start "$SERVICE_NAME" 2>/dev/null || \
echo "W: homeagent 服务未能启动请检查systemctl status $SERVICE_NAME" >&2
else
systemctl restart "$SERVICE_NAME" 2>/dev/null || \
echo "W: homeagent 服务未能重启请检查systemctl status $SERVICE_NAME" >&2
fi
else
echo "W: 未找到 ${SERVICE_NAME}.service未启用服务。" >&2
fi
fi fi
fi fi
;; ;;

View File

@ -8,16 +8,32 @@ CRED_FILE="${DATA_DIR}/credentials.txt"
CONFIG_DB="${DATA_DIR}/config.db" CONFIG_DB="${DATA_DIR}/config.db"
WAITER_CONF="${DATA_DIR}/waiter.yaml" WAITER_CONF="${DATA_DIR}/waiter.yaml"
INITCONFIG_BIN="/usr/bin/initconfig" INITCONFIG_BIN="/usr/bin/initconfig"
BUNDLED_MODEL_DIR="/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
MODEL_LINK="${DATA_DIR}/models/chinese-clip-vit-b16-onnx"
# 如果已经初始化过,跳过 # 模型随 server/full 包安装到只读的 /usr/lib配置默认仍指向 dataDir/models。
# 用符号链接把两者接起来,既不复制 754MB也保持 dataDir 可迁移语义。
# 用户已有自定义目录时绝不覆盖;升级时既有链接自然指向新版包内容。
if [ -d "$BUNDLED_MODEL_DIR" ]; then
mkdir -p "${DATA_DIR}/models"
if [ ! -e "$MODEL_LINK" ] && [ ! -L "$MODEL_LINK" ]; then
ln -s "$BUNDLED_MODEL_DIR" "$MODEL_LINK"
fi
fi
# 如果已经初始化过,只跳过凭据/数据库生成;上面的模型链接仍须在升级时补齐。
if [ -f "$CONFIG_DB" ] && [ -f "$CRED_FILE" ]; then if [ -f "$CONFIG_DB" ] && [ -f "$CRED_FILE" ]; then
exit 0 exit 0
fi fi
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# 生成随机凭据 # 生成随机凭据
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)") #
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
WEBUI_USER="${WEBUI_USER:-admin}" WEBUI_USER="${WEBUI_USER:-admin}"
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}" WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"

View File

@ -5,23 +5,56 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build" BUILD_DIR="${PROJECT_ROOT}/build"
DIST_DIR="${PROJECT_ROOT}/dist/linux" DIST_DIR="${PROJECT_ROOT}/dist/linux"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}" VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}"
# git describe 给出的是 v1.0.0-68-gba0b5a1-dirty 这类描述串,它不是合法的包版本:
# deb 的 Version 必须以数字开头rpm 的 Version 不允许 '-'(那是版本/发布的分隔符)。
# 以前只有显式传 VERSION=1.0.3 才打得出来,默认路径一跑就死在 dpkg-deb 上——
# 而且死在 stage 之后,前面每条日志都是真的,只有最后一个产物没生成。
PKG_VERSION="${VERSION#v}"
case "$PKG_VERSION" in
[0-9]*) ;;
*) echo "ERROR: 包版本必须以数字开头(得到 '$VERSION')。请显式设置 VERSION=x.y.z 后重试。" >&2; exit 1 ;;
esac
PKG_VERSION="$(printf '%s' "$PKG_VERSION" | sed -e 's/-/+/g')"
PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux" PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}" GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
ARCH="${1:-amd64}" # amd64 or arm64 ARCH="${1:-amd64}" # amd64 or arm64
# server/full 发行包默认带 Chinese-CLIP ONNX 产物与 ONNX Runtime。
# 二进制大资产不进 git发布环境通过这两个目录提供已验证的产物若缺失
# server/full 打包必须明确失败,不能生成一个「默认启用但装完不能用」的假包。
CHINESECLIP_BUNDLE_DIR="${CHINESECLIP_BUNDLE_DIR:-$BUILD_DIR/model-assets/chinese-clip-vit-b16-onnx}"
ONNXRUNTIME_ASSET_DIR="${ONNXRUNTIME_ASSET_DIR:-$BUILD_DIR/runtime-assets/$ARCH}"
ONNXRUNTIME_LIB="${ONNXRUNTIME_LIB:-$ONNXRUNTIME_ASSET_DIR/libonnxruntime.so}"
ONNXRUNTIME_LICENSE="${ONNXRUNTIME_LICENSE:-$ONNXRUNTIME_ASSET_DIR/LICENSE}"
ONNXRUNTIME_NOTICES="${ONNXRUNTIME_NOTICES:-$ONNXRUNTIME_ASSET_DIR/ThirdPartyNotices.txt}"
# 打包 staging 会把 719MB 模型真的复制一份,临时目录必须落在构建目录所在的磁盘,
# 不能落在系统临时目录:本机 /tmp 是 9.8GB tmpfs一次 full 包 staging 就能写满,
# 而且失败发生在 cp 进行到一半,报出来是 "No space left on device"——看上去像
# 资产/版本有问题,实际只是临时目录选错了文件系统。
STAGE_TMP="${BUILD_DIR}/.stage-tmp"
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
# 两者在 arm64 上恰好同名amd64 上不同——此前缓存查找统一用 TAR_ARCH
# amd64于是 electron-v*-linux-x64.zip 永远命中不到amd64 GUI 只能
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
# node_modulesGUI 就被静默跳过。故单独映射。
ACTION="${2:-all}" # all, build, deb, tar, rpm ACTION="${2:-all}" # all, build, deb, tar, rpm
DEB_ARCH="$ARCH" DEB_ARCH="$ARCH"
RPM_ARCH="$ARCH" RPM_ARCH="$ARCH"
TAR_ARCH="$ARCH" TAR_ARCH="$ARCH"
case "$ARCH" in case "$ARCH" in
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;; amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;; arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;; *) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
esac esac
echo "=== HomeAgent Linux Packager ===" echo "=== HomeAgent Linux Packager ==="
echo "Version: $VERSION" echo "Version: $VERSION"
[ "$PKG_VERSION" = "$VERSION" ] || echo "Package: $PKG_VERSION (normalized for deb/rpm)"
echo "Arch: $ARCH" echo "Arch: $ARCH"
echo "" echo ""
@ -83,7 +116,7 @@ restore_syso() {
} }
# ensure both are always restored on exit # ensure both are always restored on exit
restore_all() { restore_gomod; restore_syso; } restore_all() { restore_gomod; restore_syso; rmdir "$STAGE_TMP" 2>/dev/null || true; }
trap restore_all EXIT trap restore_all EXIT
# ---- build Go binaries via existing build.sh ---- # ---- build Go binaries via existing build.sh ----
@ -93,35 +126,42 @@ build_go() {
prepare_gomod || true prepare_gomod || true
hide_syso hide_syso
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed" 2>&1 || {
echo "WARNING: homed build failed (CGO/sqlite3 issue). Server/full packages may be incomplete."
}
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter" 2>&1 || {
echo "WARNING: waiter build failed."
}
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig" 2>&1 || {
echo "WARNING: initconfig build failed包内将缺少首次配置初始化器。"
}
local suffix="linux_${ARCH}" local suffix="linux_${ARCH}"
local homed_bin="$BUILD_DIR/homed_$suffix" local homed_bin="$BUILD_DIR/homed_$suffix"
local waiter_bin="$BUILD_DIR/waiter_$suffix" local waiter_bin="$BUILD_DIR/waiter_$suffix"
local initconfig_bin="$BUILD_DIR/initconfig_$suffix"
if [ ! -f "$homed_bin" ]; then # 先删旧产物:否则本次构建失败后,残留文件会让「产物存在」判据假绿。
echo "ERROR: homed binary not found at $homed_bin" rm -f "$homed_bin" "$waiter_bin" "$initconfig_bin"
exit 1
fi bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed"
if [ ! -f "$waiter_bin" ]; then test -x "$homed_bin"
echo "ERROR: waiter binary not found at $waiter_bin" if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
exit 1 echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包:$homed_bin" >&2
return 1
fi fi
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1))" bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter"
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))" test -x "$waiter_bin"
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig"
test -x "$initconfig_bin"
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1), onnxruntime)"
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
echo " initconfig: $initconfig_bin ($(du -h "$initconfig_bin" | cut -f1))"
echo "" echo ""
} }
# ---- build GUI (manual directory assembly, avoids electron-packager network issues) ---- # ---- build GUI (manual directory assembly, avoids electron-packager network issues) ----
#
# electron 运行时必须按**目标架构**取,不能用 host 的
# node_modules/electron/dist——那里永远是 host 架构(本机 x64
# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带
# -arm64、homed/waiter 确实是 aarch64但里面的 electron 是 x86-64
# 在 arm64 机器上一启动就是 Exec format error从未被交叉验证过
#
# 现在改为优先从 electron 缓存里取对应架构的 zip并在最后做
# 一道强制校验:架构不符就删掉目录并跳过 GUI宁可不发也不发坏包。
build_gui() { build_gui() {
local gui_dir="$PROJECT_ROOT/cmd/gui" local gui_dir="$PROJECT_ROOT/cmd/gui"
local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}" local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
@ -133,22 +173,81 @@ build_gui() {
echo ">>> Building GUI directory for linux/$ARCH..." echo ">>> Building GUI directory for linux/$ARCH..."
if [ ! -d "$gui_dir/node_modules" ]; then # 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
#
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
# node_modules目录存在但 electron 缺失。只看目录会以为"已安装"
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
# 静默跳过 GUI——包名和目录名全都正确只是没有 GUI没有任何一步报错。
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
if [ -d "$gui_dir/node_modules" ]; then
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
fi
echo " npm install..." echo " npm install..."
(cd "$gui_dir" && npm install --production) if ! (cd "$gui_dir" && npm install --production); then
echo " WARNING: npm install 失败——离线环境下这是预期的。"
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
fi
fi fi
local electron_dir="$gui_dir/node_modules/electron/dist" # electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
if [ ! -f "$electron_dir/electron" ]; then # 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped." # "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
return local ever
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
if [ -z "$ever" ]; then
ever=$(python3 -c "
import json, re
d = json.load(open('$gui_dir/package.json'))
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
print(m.group(1) if m else '')
" 2>/dev/null || true)
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
fi
mkdir -p "$gui_out"
# 优先:缓存里的目标架构 zip~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip
local zip=""
if [ -n "$ever" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -z "$zip" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -n "$zip" ]; then
echo " electron runtime: $(basename "$zip")"
unzip -q -o "$zip" -d "$gui_out"
else
# 回退:仅当目标架构 == host 架构时才能用 host 的 dist
local host_arch
case "$(uname -m)" in
x86_64) host_arch=amd64 ;;
aarch64|arm64) host_arch=arm64 ;;
*) host_arch=unknown ;;
esac
if [ "$TAR_ARCH" != "$host_arch" ]; then
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
rm -rf "$gui_out"
return
fi
local electron_dir="$gui_dir/node_modules/electron/dist"
if [ ! -f "$electron_dir/electron" ]; then
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
rm -rf "$gui_out"
return
fi
echo " electron runtime: host node_modules (同架构 $host_arch)"
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
fi fi
mkdir -p "$gui_out/resources/app/node_modules" mkdir -p "$gui_out/resources/app/node_modules"
mkdir -p "$gui_out/resources/app/renderer" mkdir -p "$gui_out/resources/app/renderer"
# copy electron runtime (binary + shared libs)
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
rm -f "$gui_out/resources/default_app.asar" 2>/dev/null rm -f "$gui_out/resources/default_app.asar" 2>/dev/null
# copy app source # copy app source
@ -190,7 +289,28 @@ LAUNCHER
chmod +x "$gui_out/homeagent-gui" chmod +x "$gui_out/homeagent-gui"
chmod +x "$gui_out/electron" chmod +x "$gui_out/electron"
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))" # 最后一道强制校验electron 二进制的实际架构必须匹配目标架构。
# 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、
# homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上
# 双击才发现 Exec format error。
local want_pat
case "$TAR_ARCH" in
amd64) want_pat="x86-64" ;;
arm64) want_pat="aarch64" ;;
*) want_pat="" ;;
esac
if [ -n "$want_pat" ]; then
local got
got=$(file -b "$gui_out/electron" 2>/dev/null || echo "")
if ! printf '%s' "$got" | grep -q "$want_pat"; then
echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}"
echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。"
rm -rf "$gui_out"
return
fi
fi
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))"
echo "" echo ""
} }
@ -215,6 +335,8 @@ stage_variant() {
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service" cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig" [ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
stage_setup "$staging" stage_setup "$staging"
stage_license "$staging"
stage_multimodal_assets "$staging"
stage_gui "$staging" stage_gui "$staging"
;; ;;
server) server)
@ -223,9 +345,12 @@ stage_variant() {
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service" cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig" [ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
stage_setup "$staging" stage_setup "$staging"
stage_license "$staging"
stage_multimodal_assets "$staging"
;; ;;
client) client)
cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter" cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter"
stage_license "$staging"
stage_gui "$staging" stage_gui "$staging"
;; ;;
esac esac
@ -260,6 +385,127 @@ stage_setup() {
fi fi
} }
# 项目自身的许可:**所有变体**都要带client 也分发 waiter 与 GUI
#
# deb 按 Debian 惯例给 /usr/share/doc/homeagent/copyrightDEP-5 机器可读格式),
# 同时把 LICENSE 全文放进去rpm 的许可走 fpm 的 --license 元数据。
# 与 stage_multimodal_assets 的 licenses/ 分工:那里放**第三方**(模型/运行库)的
# 许可全文,这里放本项目自己的。
stage_license() {
local staging="$1"
local docdir="$staging/usr/share/doc/homeagent"
mkdir -p "$docdir"
cp "$PROJECT_ROOT/LICENSE" "$docdir/LICENSE"
cat > "$docdir/copyright" <<'EOF'
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: HomeAgent
Source: https://gitcode.com/JianFeeeee/HomeAgent
Files: *
Copyright: HomeAgent contributors
License: AGPL-3.0-only
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, version 3 of the License.
.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
.
You should have received a copy of the GNU Affero General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.
.
The license is AGPL-3.0-only: no later version may be chosen. Note the network
clause (§13 Remote Network Interaction) — offering modified versions of this
software to users over a network also requires offering them the source.
.
Full text: /usr/share/doc/homeagent/LICENSE
Files: usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/*
Copyright: OFA-Sys / Chinese-CLIP authors
License: Apache-2.0
Full text: /usr/share/doc/homeagent/licenses/Chinese-CLIP-Apache-2.0.txt
Comment: pre-trained model artifacts; NOT covered by this package's AGPL grant
Files: usr/lib/homeagent/onnxruntime/*
Copyright: Microsoft Corporation
License: MIT
Full text: /usr/share/doc/homeagent/licenses/ONNX-Runtime-MIT.txt
Comment: license texts and third-party notices under licenses/ONNX-Runtime-*
EOF
chmod 644 "$docdir/LICENSE" "$docdir/copyright"
}
# server/full 的 ONNX 资产。模型与运行库是发行版能力的一部分,不是可选下载:
# 只要打 server/full 包两者缺一就失败。client 包不运行 homed故不携带。
stage_multimodal_assets() {
local staging="$1"
local model_dst="$staging/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
local ort_dst="$staging/usr/lib/homeagent/onnxruntime"
local licenses="$staging/usr/share/doc/homeagent/licenses"
if [ ! -d "$CHINESECLIP_BUNDLE_DIR" ]; then
echo "ERROR: Chinese-CLIP 产物目录不存在:$CHINESECLIP_BUNDLE_DIR" >&2
echo "先运行 scripts/export_chineseclip_onnx.py再通过 CHINESECLIP_BUNDLE_DIR 指向产物。" >&2
return 1
fi
for f in TextEncoder.onnx VisionEncoder.onnx embed_config.json vocab.txt reference.json SHA256SUMS; do
if [ ! -s "$CHINESECLIP_BUNDLE_DIR/$f" ]; then
echo "ERROR: Chinese-CLIP 产物缺少或为空:$CHINESECLIP_BUNDLE_DIR/$f" >&2
return 1
fi
done
if ! (cd "$CHINESECLIP_BUNDLE_DIR" && sha256sum -c SHA256SUMS); then
echo "ERROR: Chinese-CLIP SHA256SUMS 校验失败,拒绝打包。" >&2
return 1
fi
if [ ! -s "$ONNXRUNTIME_LIB" ]; then
echo "ERROR: ONNX Runtime 不存在:$ONNXRUNTIME_LIB" >&2
echo "通过 ONNXRUNTIME_ASSET_DIR 或 ONNXRUNTIME_LIB 指向与目标架构匹配的资产。" >&2
return 1
fi
for notice in "$ONNXRUNTIME_LICENSE" "$ONNXRUNTIME_NOTICES"; do
if [ ! -s "$notice" ]; then
echo "ERROR: ONNX Runtime 许可证资产缺失:$notice" >&2
return 1
fi
done
local runtime_desc
runtime_desc=$(file -b "$ONNXRUNTIME_LIB")
case "$ARCH" in
amd64) printf '%s' "$runtime_desc" | grep -qE 'x86-64|x86_64' || {
echo "ERROR: ONNX Runtime 架构不是 amd64$runtime_desc" >&2; return 1; } ;;
arm64) printf '%s' "$runtime_desc" | grep -qE 'aarch64|ARM aarch64' || {
echo "ERROR: ONNX Runtime 架构不是 arm64$runtime_desc" >&2; return 1; } ;;
esac
mkdir -p "$model_dst" "$ort_dst" "$licenses"
cp -a "$CHINESECLIP_BUNDLE_DIR/." "$model_dst/"
install -m 0755 "$ONNXRUNTIME_LIB" "$ort_dst/libonnxruntime.so"
# 许可证随二进制分发Chinese-CLIP = Apache-2.0ONNX Runtime = MIT
# 同时携带其 ThirdPartyNotices含 MKL/protobuf/zlib 等第三方条款)。
cp /usr/share/common-licenses/Apache-2.0 "$licenses/Chinese-CLIP-Apache-2.0.txt"
cp "$ONNXRUNTIME_LICENSE" "$licenses/ONNX-Runtime-MIT.txt"
cp "$ONNXRUNTIME_NOTICES" "$licenses/ONNX-Runtime-ThirdPartyNotices.txt"
cat > "$licenses/MODEL-SOURCES.txt" <<EOF
Chinese-CLIP ViT-B/16
upstream: https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16
license: Apache-2.0
exported-by: scripts/export_chineseclip_onnx.py
dimensions: 512
modalities: text,image
ONNX Runtime
upstream: https://github.com/microsoft/onnxruntime
license: MIT (see ONNX-Runtime-MIT.txt and ONNX-Runtime-ThirdPartyNotices.txt)
EOF
echo " ONNX assets: model=$(du -sh "$model_dst" | cut -f1) runtime=$(du -h "$ort_dst/libonnxruntime.so" | cut -f1)"
}
# ---- create .deb ---- # ---- create .deb ----
build_deb() { build_deb() {
local variant="$1" local variant="$1"
@ -267,9 +513,9 @@ build_deb() {
local deb_dir="${DIST_DIR}/deb" local deb_dir="${DIST_DIR}/deb"
mkdir -p "$deb_dir" mkdir -p "$deb_dir"
local pkg_name="homeagent-${variant}_${VERSION}_${DEB_ARCH}.deb" local pkg_name="homeagent-${variant}_${PKG_VERSION}_${DEB_ARCH}.deb"
local deb_root local deb_root
deb_root="$(mktemp -d)" deb_root="$(mktemp -d "$STAGE_TMP/deb.XXXXXX")"
mkdir -p "$deb_root/DEBIAN" mkdir -p "$deb_root/DEBIAN"
@ -277,7 +523,7 @@ build_deb() {
local installed_size_kb local installed_size_kb
installed_size_kb=$(du -sk "$staging" | cut -f1) installed_size_kb=$(du -sk "$staging" | cut -f1)
sed -e "s/VERSION_PLACEHOLDER/$VERSION/g" \ sed -e "s/VERSION_PLACEHOLDER/$PKG_VERSION/g" \
-e "s/ARCH_PLACEHOLDER/$DEB_ARCH/g" \ -e "s/ARCH_PLACEHOLDER/$DEB_ARCH/g" \
-e "s/INSTALLED_SIZE_PLACEHOLDER/$installed_size_kb/g" \ -e "s/INSTALLED_SIZE_PLACEHOLDER/$installed_size_kb/g" \
"$control_file" > "$deb_root/DEBIAN/control" "$control_file" > "$deb_root/DEBIAN/control"
@ -306,13 +552,13 @@ build_tar() {
local tar_dir="${DIST_DIR}/tar" local tar_dir="${DIST_DIR}/tar"
mkdir -p "$tar_dir" mkdir -p "$tar_dir"
local archive_name="homeagent_${VERSION}_linux_${TAR_ARCH}.tar.gz" local archive_name="homeagent_${PKG_VERSION}_linux_${TAR_ARCH}.tar.gz"
local archive_dir="homeagent-${VERSION}-linux-${TAR_ARCH}" local archive_dir="homeagent-${PKG_VERSION}-linux-${TAR_ARCH}"
# build combined staging # build combined staging
local staging local staging
staging="$(mktemp -d)" staging="$(mktemp -d "$STAGE_TMP/tar.XXXXXX")"
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent" mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent" "$staging/etc/systemd/system"
# copy all available binaries # copy all available binaries
for bin in homed waiter initconfig; do for bin in homed waiter initconfig; do
@ -323,6 +569,9 @@ build_tar() {
# setup script # setup script
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh" local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
[ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh" [ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh"
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
stage_license "$staging"
stage_multimodal_assets "$staging"
# GUI if available # GUI if available
local gui_src="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}" local gui_src="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
@ -351,7 +600,7 @@ build_rpm() {
local rpm_dir="${DIST_DIR}/rpm" local rpm_dir="${DIST_DIR}/rpm"
mkdir -p "$rpm_dir" mkdir -p "$rpm_dir"
local pkg_name="homeagent-${variant}-${VERSION}-1.${RPM_ARCH}.rpm" local pkg_name="homeagent-${variant}-${PKG_VERSION}-1.${RPM_ARCH}.rpm"
# find fpm # find fpm
local fpm_bin="$(command -v fpm 2>/dev/null || true)" local fpm_bin="$(command -v fpm 2>/dev/null || true)"
@ -400,7 +649,7 @@ build_rpm() {
-a "$RPM_ARCH" \ -a "$RPM_ARCH" \
--description "HomeAgent ${variant^} package" \ --description "HomeAgent ${variant^} package" \
--url "https://github.com/trueagent/HomeAgent" \ --url "https://github.com/trueagent/HomeAgent" \
--license "Proprietary" \ --license "AGPL-3.0-only" \
-C "$staging" \ -C "$staging" \
-p "$rpm_dir/$pkg_name" \ -p "$rpm_dir/$pkg_name" \
. 2>&1 . 2>&1
@ -411,7 +660,7 @@ build_rpm() {
main() { main() {
local target_arch="$ARCH" local target_arch="$ARCH"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR" "$STAGE_TMP"
case "$ACTION" in case "$ACTION" in
all|build) all|build)
@ -428,6 +677,10 @@ main() {
mkdir -p "$DIST_DIR" mkdir -p "$DIST_DIR"
# 上次成功构建留下的校验和必须在本次开工前删掉:本次若中途失败,脚本直接退出、
# 不重算 SHA256SUMS旧的它会一直躺在 dist 里,看上去像在为这一批残缺产物背书。
rm -f "$DIST_DIR/SHA256SUMS"
for variant in full server client; do for variant in full server client; do
echo "" echo ""
echo "==============================================" echo "=============================================="
@ -435,7 +688,7 @@ main() {
echo "==============================================" echo "=============================================="
local staging local staging
staging=$(mktemp -d) staging=$(mktemp -d "$STAGE_TMP/stage.XXXXXX")
stage_variant "$variant" "$staging" stage_variant "$variant" "$staging"
case "$ACTION" in case "$ACTION" in
@ -457,9 +710,26 @@ main() {
echo "=== Done! Packages in: $DIST_DIR ===" echo "=== Done! Packages in: $DIST_DIR ==="
echo "" echo ""
echo "Summary:" echo "Summary:"
find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort | while read -r f; do # 只列**本批**产物dist/ 会跨多次构建累积,用 find 全目录会让清单SHA256SUMS
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
# v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
for f in "${release_files[@]}"; do
echo " $(du -h "$f" | cut -f1) $f" echo " $(du -h "$f" | cut -f1) $f"
done done
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
# 名字用**平铺名**basename下载页的附件名就是平铺的
# 清单里若写 ./deb/xxx.deb用户下载后 `sha256sum -c` 会找不到文件。
if [ ${#release_files[@]} -gt 0 ]; then
(
cd "$DIST_DIR"
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
for f in "${release_files[@]}"; do
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
done | sort -k2 > SHA256SUMS
)
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
fi
} }
main main

View File

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# 构建 Windows 安装器NSIS
#
# ❗安装器**不往 Windows 装 homed**homed 依赖 fd 继承与统一共享内存区的段内偏移
# 解引用Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go。所以安装器的
# 职责是**引导 WSL2并把 Linux 包送进发行版里按 Linux 方式安装**
# deploy/packaging/windows/install-via-wsl.ps1
#
# 用法: VERSION=1.3.10 bash deploy/packaging/package-windows.sh <server|client|full> [arch]
# 前置: 先产出对应的 Linux 包VERSION=x bash deploy/packaging/package-linux.sh amd64
#
# 为什么不复用 build.sh 的 stage_linux_payload那一段把 dist/linux 下**所有** deb+tar
# 都塞进 payload而 server/full 的 deb 各带 ~719MB 的 Chinese-CLIP 模型 ⇒ 任何变体的
# 安装器都会膨胀到 ~2.4GB。WSL 侧脚本只取 payload 里的**第一个** .deb
# install-via-wsl.ps1:141所以这里按变体只放对应的那一个包。
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# 三个目录都可覆盖:发布件常在 tag 的干净 worktree 里构建,而这个脚本本身
# 可能只存在于 main例如刚补的驱动脚本还没进 tag——那种情况下用主仓的脚本 +
# DIST_LINUX/BUILD_DIR/DIST_RELEASE 指向 worktree避免"脚本不存在"或产物错位。
BUILD_DIR="${BUILD_DIR:-$PROJECT_ROOT/build}"
DIST_LINUX="${DIST_LINUX:-$PROJECT_ROOT/dist/linux}"
DIST_RELEASE="${DIST_RELEASE:-$PROJECT_ROOT/dist/release}"
# ❗NSIS 的 `File` 路径是**相对 .nsi 所在目录**解析的:在 tag 的 worktree 里构建时,
# 必须用**该 tag 里的** installer.nsi否则它会去主仓的 build/linux-payload 找载荷
# (实测报 `File: "..\..\build\linux-payload\*.*" -> no files found`)。
# 用 tag 里的 .nsi 也正是"发布件与当时的脚本同源"的正确做法。
NSI="${NSI:-$PROJECT_ROOT/deploy/packaging/installer.nsi}"
if [ ! -f "$NSI" ]; then
echo "[FAIL] 找不到 NSIS 脚本: $NSI" >&2
exit 1
fi
VARIANT="${1:-server}"
ARCH="${2:-amd64}"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags 2>/dev/null || echo 0.0.0)}"
VERSION="${VERSION#v}"
case "$VARIANT" in
server) DEB_GLOB="homeagent-server_${VERSION}_${ARCH}.deb"; SUFFIX="Server"; WANT_GUI=0; WANT_WAITER=0 ;;
full) DEB_GLOB="homeagent-full_${VERSION}_${ARCH}.deb"; SUFFIX="Full"; WANT_GUI=1; WANT_WAITER=1 ;;
client) DEB_GLOB="homeagent-client_${VERSION}_${ARCH}.deb"; SUFFIX="Client"; WANT_GUI=1; WANT_WAITER=1 ;;
*) echo "用法: $0 <server|client|full> [arch]" >&2; exit 2 ;;
esac
DEB="$(ls -1 "$DIST_LINUX/deb/$DEB_GLOB" "$DIST_LINUX/$DEB_GLOB" 2>/dev/null | head -1 || true)"
if [ -z "$DEB" ]; then
echo "[FAIL] 找不到 $DEB_GLOB" >&2
echo " 先产出 Linux 包VERSION=$VERSION bash deploy/packaging/package-linux.sh $ARCH" >&2
echo " (安装器的作用是把 Linux 包送进 WSL2所以必须先有 Linux 包)" >&2
exit 1
fi
# client/full 还要带 Windows GUIHAS_GUI=1。本机缺 electron-builder若 build/ 下
# 没有可用的 win32-x64 payload 就**明确失败**,不产出"装完没有界面"的半残包。
if [ "$WANT_GUI" = 1 ]; then
if [ -z "$(ls -1 "$BUILD_DIR"/homeagent-gui-win32-x64/*.exe 2>/dev/null | head -1 || true)" ]; then
echo "[FAIL] 变体 $VARIANT 需要 Windows GUI payloadbuild/homeagent-gui-win32-x64/*.exe" >&2
echo " 本机无 electron-buildernpm i -g electron-builder &&" >&2
echo " bash deploy/packaging/build.sh windows/amd64 gui" >&2
echo " (只装内核+CLI 的 WSL 场景请用 server 变体)" >&2
exit 1
fi
fi
if [ "$WANT_WAITER" = 1 ]; then
echo "[BUILD] waiter.exeWindows 侧 CLICGO 关闭,跨平台安全)"
( cd "$PROJECT_ROOT" && GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
go build -buildvcs=false -trimpath -o "$BUILD_DIR/waiter.exe" ./cmd/waiter )
fi
# ---- 变体定向 payload只放本变体那一个 Linux 包 ----
rm -rf "$BUILD_DIR/linux-payload"
mkdir -p "$BUILD_DIR/linux-payload"
cp "$DEB" "$BUILD_DIR/linux-payload/"
echo "[STAGE] payload ← $(basename "$DEB")$(du -h "$DEB" | cut -f1)"
if [ -z "$(ls -1 "$BUILD_DIR/linux-payload" 2>/dev/null | head -1 || true)" ]; then
echo "[FAIL] payload 为空:$BUILD_DIR/linux-payload" >&2
exit 1
fi
echo "[BUILD] makensis -DVARIANT=$VARIANT -DPRODUCT_VERSION=$VERSIONnsi: $NSI"
makensis -V2 -DVARIANT="$VARIANT" -DPRODUCT_VERSION="$VERSION" "$NSI"
OUT="$BUILD_DIR/HomeAgent_v${VERSION}_${SUFFIX}_win64.exe"
if [ ! -f "$OUT" ]; then
echo "[FAIL] 未找到产物 $OUT" >&2
exit 1
fi
mkdir -p "$DIST_RELEASE"
cp "$OUT" "$DIST_RELEASE/"
echo "[OK] $(basename "$OUT")$(du -h "$OUT" | cut -f1))→ $DIST_RELEASE/"
echo " 它会在 Windows 侧引导 WSL2并把 $(basename "$DEB") 送进去安装homed 跑在 WSL 里)。"

View File

@ -0,0 +1,259 @@
<#
.SYNOPSIS
在 WSL2 中安装 HomeAgenthomed + 插件 + WebUI
.DESCRIPTION
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
本脚本因此把 Windows 安装流程变成一条引导链:
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
「WSL 里就是普通 linux/amd64」这一点必须保持成立否则等于又开了第三个平台。
.PARAMETER PayloadDir
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
优先取 *.deb没有 deb 时回退 *.tar.gz。
.PARAMETER Distro
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
.PARAMETER DataDir
WSL 内的数据目录。默认 /var/lib/homeagent与 Linux 原生安装一致)。
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
.NOTES
⚠️ 本脚本在开发环境Linux中只能做语法/逻辑审查,**未在真实 Windows + WSL
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
便于定位到具体哪一步与预期不符。
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$PayloadDir,
[string]$Distro = "",
[string]$DataDir = "/var/lib/homeagent",
[string]$ApiKey = "",
[string]$WebUIUser = "",
[string]$WebUIPass = "",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$script:StageNo = 0
$script:DistroName = $Distro
function Write-Stage([string]$Text) {
$script:StageNo++
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor DarkGray
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
Write-Host ("=" * 64) -ForegroundColor DarkGray
}
function Write-Ok([string]$Text) { Write-Host "$Text" -ForegroundColor Green }
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
function Fail([string]$Text, [string]$Hint = "") {
Write-Host ""
Write-Host " 安装中止:$Text" -ForegroundColor Red
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
exit 1
}
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
Write-Stage "前置检查"
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
}
Write-Ok "管理员权限"
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Write-Warn2 "未找到 wsl.exe"
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
Write-Host ""
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
Write-Host " wsl --install" -ForegroundColor White
Write-Host " 然后重启 Windows再重新运行本安装程序。"
Write-Host ""
Write-Host " Windows 10 需 2004+ 且启用虚拟机平台Windows 11 开箱可用)"
exit 20
}
Write-Ok "wsl.exe 可用"
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
Write-Stage "检测 WSL 与发行版"
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
# 用 --status 取默认发行版,再单独枚举列表。
$distros = @()
try {
$raw = (& wsl.exe -l -q 2>$null | Out-String)
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
} catch {
$distros = @()
}
if ($distros.Count -eq 0) {
Write-Warn2 "WSL 已安装,但没有任何发行版"
Write-Host ""
Write-Host " 请先安装发行版(推荐 Ubuntu" -ForegroundColor Yellow
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
Write-Host ""
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
exit 21
}
if ($script:DistroName -eq "") {
try {
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
ForEach-Object { ($_ -split ":")[1].Trim() })
} catch { }
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
}
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', ')"
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
Write-Stage "确保使用 WSL2"
# WSL1 没有真正的 Linux 内核、没有 systemd且在共享内存/事件语义上与 WSL2 不同。
# homed 依赖 eventfd + mmap 语义WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
try {
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
if ($verLine -match "\b1\b") {
Write-Warn2 "该发行版当前是 WSL1正在升级为 WSL2 ..."
& wsl.exe --set-version $script:DistroName 2
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行wsl --set-version $($script:DistroName) 2" }
}
} catch { }
& wsl.exe --set-default-version 2 | Out-Null
Write-Ok "已使用 WSL2"
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
Write-Stage "准备 Linux 安装包"
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($deb) {
$pkg = $deb.FullName
$pkgKind = "deb"
} elseif ($tar) {
$pkg = $tar.FullName
$pkgKind = "tar"
} else {
Fail "$PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
}
Write-Ok "使用 $(Split-Path $pkg -Leaf)$pkgKind"
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
Write-Stage "把安装包送入 WSL"
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
$winPath = (Resolve-Path $pkg).Path
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
Write-Host " 源:$mntPath"
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动wsl -d $($script:DistroName)" }
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
Write-Ok "已送到 /tmp/homeagent-install/"
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
Write-Stage "在 WSL 内安装 homed"
# 凭据经环境变量传给 setup.sh它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
$credEnv = ""
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
# 安装逻辑复用 Linux 侧deb 走 aptpostinst 会调用 setup.sh 生成凭据与 config.db
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
# 「WSL 里就是普通 linux/amd64」必须保持成立否则等于又开了第三个平台。
if ($pkgKind -eq "deb") {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
export HOMEAGENT_DATA='$DataDir'
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
"@
} else {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
mkdir -p /opt/homeagent /tmp/homeagent-extract
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
cd /tmp/homeagent-extract
# deb /usr/bin/homed + /usr/lib/homeagent/setup.sh
# /
install -m 0755 homed /usr/bin/homed
install -m 0755 waiter /usr/bin/waiter
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
if [ -f homeagent.service ]; then
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
fi
mkdir -p /usr/lib/homeagent
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
export HOMEAGENT_DATA='$DataDir'
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
"@
}
if ($LASTEXITCODE -ne 0) {
Fail "WSL 内安装失败(退出码 $LASTEXITCODE" "可进入 WSL 手动排查wsl -d $($script:DistroName)"
}
Write-Ok "安装完成"
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
Write-Stage "启动 homed 与自启配置"
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
systemctl enable homeagent 2>/dev/null || true
systemctl restart homeagent
echo ' systemd homeagent '
else
# systemdWSL2 nohup Windows
pkill -f '/usr/bin/homed' 2>/dev/null || true
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
echo ' nohup systemd'
fi
"@
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " HomeAgent 已在 WSL2$($script:DistroName))内安装完成" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " WebUIhttp://localhost:8080" -ForegroundColor White
Write-Host " WSL2 会把 WSL 内的端口映射到 Windows 的 localhost无需额外配置"
Write-Host ""
if ($creds) {
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt" -ForegroundColor Yellow
Write-Host $creds
} else {
Write-Host " 未读到凭据文件,请进入 WSL 检查cat $DataDir/credentials.txt" -ForegroundColor Yellow
}
Write-Host ""
Write-Host " 常用操作(在 PowerShell 中):"
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
Write-Host ""
Write-Host " 注意WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
Write-Host " 可创建一个登录时触发的计划任务执行:"
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
exit 0

View File

@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# 客户端版本与内核版本同步。
#
# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源,
# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、
# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、
# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。
#
# 用法:
# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本
# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本
# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1
#
# 同步目标:
# cmd/gui/package.json version
# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode
#
# waiter 不在此列:它直接引用 internal/meta.Version同一进程内编译
# 没有第二份版本字段可漂。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CHECK=0
VERSION=""
for arg in "$@"; do
case "$arg" in
--check) CHECK=1 ;;
*) VERSION="$arg" ;;
esac
done
# 未显式给版本时,从内核唯一事实源读。
if [ -z "$VERSION" ]; then
VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
fi
[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号internal/meta/meta.go 里没找到 Version" >&2; exit 1; }
# versionCode 规则X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数,
# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码1.4.0 → 1004000
CODE="$(python3 - "$VERSION" <<'PY'
import re, sys
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1])
if not m:
sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1])
print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3)))
PY
)"
GUI_PKG="$ROOT/cmd/gui/package.json"
OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5"
DRIFT=0
note() { printf ' %-52s %s\n' "$1" "$2"; }
# ── GUI ──
gui_cur="$(python3 - "$GUI_PKG" <<'PY'
import json, sys
print(json.load(open(sys.argv[1]))["version"])
PY
)"
if [ "$gui_cur" != "$VERSION" ]; then
DRIFT=1
if [ "$CHECK" -eq 1 ]; then
note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION"
else
python3 - "$GUI_PKG" "$VERSION" <<'PY'
import json, sys
p, v = sys.argv[1], sys.argv[2]
d = json.load(open(p))
d["version"] = v
# indent=2 保留原格式;末尾补换行,避免 diff 噪声
with open(p, "w") as f:
json.dump(d, f, indent=2, ensure_ascii=False)
f.write("\n")
PY
note "cmd/gui/package.json" "$gui_cur$VERSION"
fi
fi
# ── 鸿蒙 ──
ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')"
if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then
DRIFT=1
if [ "$CHECK" -eq 1 ]; then
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE"
else
# app.json5 带注释,不是严格 JSON用 sed 定点替换两个字段。
sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP"
sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP"
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code$VERSION/$CODE"
fi
fi
echo "内核版本: $VERSION (versionCode $CODE)"
if [ "$CHECK" -eq 1 ]; then
if [ "$DRIFT" -eq 1 ]; then
echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2
exit 1
fi
echo "sync-client-versions: OK客户端与内核版本一致"
fi

View File

@ -32,13 +32,17 @@ ARTIFACT_SUFFIXES = (
".rpm", ".rpm",
".pkg", ".pkg",
"_win64.exe", "_win64.exe",
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh
".hmap",
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
"SHA256SUMS.plugins",
) )
def is_artifact(name: str) -> bool: def is_artifact(name: str) -> bool:
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES) return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]: def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
q = urllib.parse.urlencode({"file_name": filename}) q = urllib.parse.urlencode({"file_name": filename})
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}" url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
@ -66,19 +70,41 @@ def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
return 0, f"{type(e).__name__}: {e}" return 0, f"{type(e).__name__}: {e}"
def project_root() -> str:
"""向上找带 go.mod 的目录作为仓库根。
为何不数 dirname本脚本初版在 scripts/(深度 1移到 deploy/scripts/
(深度 2后写死的两层 dirname 就指向了 deploy/dist/release上传直接
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
"""
d = os.path.dirname(os.path.abspath(__file__))
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "go.mod")):
return d
d = os.path.dirname(d)
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd给 ASSET_DIR 一个机会
return os.getcwd()
def main() -> int: def main() -> int:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print(__doc__) print(__doc__)
return 2 return 2
tag, token = sys.argv[1], sys.argv[2] tag, token = sys.argv[1], sys.argv[2]
outdir = os.environ.get("ASSET_DIR") or os.path.join( outdir = os.environ.get("ASSET_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), project_root(), "dist", "release"
"dist",
"release",
) )
if not os.path.isdir(outdir):
print(f"error: 资产目录不存在: {outdir}")
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
return 2
files = sys.argv[3:] or sorted( files = sys.argv[3:] or sorted(
f for f in os.listdir(outdir) if is_artifact(f) f for f in os.listdir(outdir) if is_artifact(f)
) )
if not files:
print(f"error: {outdir} 下没有可识别的发布产物")
return 2
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True) print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
failed = [] failed = []
for name in files: for name in files:

View File

@ -0,0 +1,20 @@
[Unit]
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/newqqagent
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
Restart=on-failure
RestartSec=5
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
Environment=JINA_PORT=18999
Environment=JINA_DIMENSION=768
Environment=OMP_NUM_THREADS=8
Environment=MKL_NUM_THREADS=8
Environment=TOKENIZERS_PARALLELISM=false
[Install]
WantedBy=multi-user.target

View File

@ -1,7 +1,8 @@
# Git 分支管理规范 # Git 分支管理规范
> 生效2026-08-31。适用:**本仓TrueAgent/HomeAgent与 third_party/homeagent-sdkSDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用 > 生效2026-08-312026-09-04 修订(三级发布通道 + 单条发布分支2026-09-06 修订SDK 仓版本语义与发版联动,见 §七2026-09-13 修订(发布线路牌随 patch 推进 + 版本号不得固化落库)
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + taghotfix 只进 released 分支并 cherry-pick 回 main。** > 适用:**本仓TrueAgent/HomeAgent与 third_party/homeagent-sdkSDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
> 核心原则一句话:**main 唯一长命、永远可部署一切新工作在特性分支一个中版本一条发布分支alpha/beta/正式由 tag 区分hotfix 只进发布分支并 cherry-pick 回 main。**
--- ---
@ -11,18 +12,24 @@
|---|---|---|---|---| |---|---|---|---|---|
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** | | `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 | | `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
| `release/vX.Y.Z` | 中命(从切出到下个版本发布 | main | 打 tag → 构建发布 | ✅ **发布产物来源** | | `release/vX.Y.x` | 中命(**整个中版本生命周期** | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
| hotfix直接提交 release 分支) | 随 release 分支 | release 分支 | **cherry-pick 回 main** | ✅ | | hotfix直接提交发布分支) | 随发布分支 | 发布分支 | **cherry-pick 回 main** | ✅ |
``` ```
main ──────────────── E ──────────────── G ────────────────(永远可部署) main ──────────────── E ──────────────── G ────────────────(永远可部署)
│ ▲ │ ▲
│ feature/xxx │ cherry-pickhotfix 逐个 pick 回) │ feature/xxx │ cherry-pick修复逐个 pick 回)
├── A ── B ──(合回)───────────────────┤ ├── A ── B ──(合回)───────────────────┤
│ │ │ │
└── release/v1.2.0 release/v1.2.0 └── release/v1.0.x ────────────────────────────────────────────────
├─(tag v1.2.0)→ 构建发布 ├─(hotfix) F ← 版本特定严重 bug │ │ │
└─ 退役(可删可留) └─ F 被 separately cherry-pick 到 main ├─(tag v1.0.0-alpha.1) 内部验证 │ │
├─(tag v1.0.0-beta.1) 小范围试用 │ │
├─(tag v1.0.0) 正式发布 │ │
├─(hotfix) F ─────────────────────┤ │
├─(tag v1.0.1) patch 发布 │ │
├─(hotfix) H ────────────────────────────────────┤
└─(tag v1.0.3) patch 发布
``` ```
--- ---
@ -32,8 +39,9 @@ main ──────────────── E ────────
### 1. `main`(唯一长命分支) ### 1. `main`(唯一长命分支)
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。 - **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
- 积攒**下一个版本**的功能feature 分支完成即合回main 持续向前。 - 积攒**下一个版本**的功能feature 分支完成即合回main 持续向前。
- **main 上不直接开发**。所有改动经 feature 分支合入hotfix 经 cherry-pick 注入。 - **main 上不直接开发**。所有改动经 feature 分支合入hotfix 经 cherry-pick 注入。
- **main 的 `internal/meta.Version` 始终是下一个未发布版本**,不随 patch 发布变动。
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证): - 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
- `make test` 全绿 - `make test` 全绿
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空 - 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
@ -41,80 +49,223 @@ main ──────────────── E ────────
### 2. `feature/xxx`(新特性/修复) ### 2. `feature/xxx`(新特性/修复)
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration``feature/webui-narrow-fix` - 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration``feature/memory-media`
- **从 main 开出**`git checkout -b feature/xxx main` - **从 main 开出**`git checkout -b feature/xxx main`
- 完成后合回 main - 完成后合回 main
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit二选一在团队内固定 - 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit二选一在团队内固定
- 多人:走 PRreview 后合入)。 - 多人:走 PRreview 后合入)。
- 合回后删除 feature 分支(避免累积)。 - 合回后删除 feature 分支(避免累积)。
### 3. `release/vX.Y.Z`(发布) ### 3. `release/vX.Y.x`(发布分支:一个中版本一条
- **从 main 的某个可部署点切出**`git checkout -b release/v1.2.0 main` - **命名用 `x` 占位 patch 位**`release/v1.0.x` 承载 1.0.0 → 1.0.1 → … → 1.0.N 全部发布,
- 切出后**冻结功能**——release 分支上只做:版本号 bump、发布准备、bug 修复、文档。 直到 `release/v1.1.x` 切出为止。**不要按 patch 号建分支**`release/v1.0.1``release/v1.0.3` 各一条会把
- 打 tag → 构建发布安装包 → 上传(附件命名规范见历史记录)。 同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
- **现网部署永远用 release tag 的构建产物**,不是 main 头部、更不是 feature - **从 main 的某个可部署点切出**`git checkout -b release/v1.0.x main`
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
- **发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走**`release/v1.1.x` 末态
`1.1.1``release/v1.2.x` 末态 `1.2.2``release/v1.3.x` 末态 `1.3.6`)。
⚠️ **不要只用构建参数(`-ldflags -X ...meta.Version`)打版本号而不改源码**
二进制自称 1.3.4、源码路牌还停在 1.3.0追溯时对不上账2026-09-13 真实踩过,
1.3.11.3.4 四个 patch 都是这么打的,`release/v1.3.x` 的路牌一直没动)。
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
### 4. hotfix只属于此版本的严重 bug ### 4. 三级发布通道alpha / beta / 正式
通道**由 tag 区分,不由分支区分**——三者共用同一条 `release/vX.Y.x`
| 通道 | tag 形式 | 含义 | 受众 |
|---|---|---|---|
| alpha | `vX.Y.Z-alpha.N` | 功能齐了但未充分验证,可能有已知缺陷 | 仅内部/开发者自测 |
| beta | `vX.Y.Z-beta.N` | alpha 问题已修,等待真实环境暴露长尾问题 | 小范围试用、愿意承担风险的用户 |
| 正式 | `vX.Y.Z` | 通过验证,可上现网 | 所有用户 |
- **推进顺序**alpha → beta → 正式,逐级向前,**每级都是同一条分支上的新 tag**。
这也是 semver 的标准预发布语义(`1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0`
包管理器与版本比较逻辑天然认得,无需额外约定。
- **允许跳级**:若改动小、验证充分(如仅一处已定位并有回归测试覆盖的内核修复),
可直接打正式 tag。跳级要在发布说明里写明理由。
- alpha/beta 的构建产物**可以上传 release 附件**,但必须在 gitcode release 上勾选
"预发布"标记,且发布说明首行标注通道与已知风险。
- **beta 未清零的严重问题不得进正式**:正式 tag 意味着"我们认为它能上 24/7 现网"。
- **发版动作只在发布分支上做**:版本号 bump、打 tag、构建产物、上传 release 附件,
全部发生在 `release/vX.Y.x` 上。**main 永远不是发版分支**——即使某个改动刚刚合进 main、
即使 main 此刻可部署,也不从 main 打 tag、不拿 main 的构建产物发布。
main 的版本号是「下一个未发布中版本」的路牌,不是任何一次发布的版本号。
### 5. hotfix发布后发现的严重 bug
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。 - **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
- **动作**:直接把修复提交到 **release 分支**(不收进 main 的开发流)→ 该 release 分支重新构建、打 patch tag`v1.2.1`)发布。 - **动作**:直接把修复提交到**发布分支** → 该分支重新构建、打下一个 patch tag`v1.0.4`)发布。
- **关键hotfix 必须 cherry-pick 回 main** - **关键hotfix 必须 cherry-pick 回 main**
```bash ```bash
# 在 release 分支上提交修复(代码部分与版本号 bump 分开提交) # 在发布分支上提交修复(代码部分与版本号 bump 分开提交)
git checkout release/v1.0.x
git commit -m "fix(x): ..." # ① 修复本身 git commit -m "fix(x): ..." # ① 修复本身
git commit -m "chore: bump v1.2.1" # ② 版本号(此 commit 不 pick 回 main git commit -m "chore(release): bump v1.0.4" # ② 版本号(此 commit 不 pick 回 main
git tag -a v1.0.4 -m "..."
# 回到 main只挑修复本身 # 回到 main只挑修复本身
git checkout main git checkout main
git cherry-pick <修复commit的sha> # 只 pick ①,不 pick ② git cherry-pick <修复①的sha> # 只 pick ①,不 pick ②
``` ```
> **为什么 cherry-pick 而不是 merge**release 分支只承载该版本特有的补丁merge 会把 release 分支的版本号/发布相关改动一并带进 main 造成冲突。逐个 cherry-pick 修复 commit 让 main 精确地只获得修复本身。**版本号 bump 不要 pick 回 main**main 的版本号应始终是下一个未发布版本)。 > **为什么 cherry-pick 而不是 merge**发布分支只承载该版本特有的补丁merge 会把
> 版本号/发布相关改动一并带进 main 造成冲突,并让 main 的 `meta.Version` 变成
> 已发布的旧版本号。逐个 cherry-pick 让 main 精确地只获得修复本身。
> **版本号 bump 不要 pick 回 main。**
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并 release 回 main**。这是本规范刻意为之——除非 release 分支上有 main 想要的**功能级**改动(罕见),否则 release 永不 merge 回 main - **同时存在多个活跃 feature 分支时**:修复也要 pick 到那些分支,否则它们合回 main
可能带回旧代码。实践做法是修复落地当天就 pick 到全部活跃分支
(如 2026-09-04 的 stage 双重解锁修复同时 pick 到 `main` 与 `feature/memory-media`)。
### 5. release 分支退役 - **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并发布分支回 main**。
这是本规范刻意为之——除非发布分支上有 main 想要的**功能级**改动(罕见),
否则发布分支永不 merge 回 main。
- **下个版本发布 = 此 release 分支生命周期结束**(不再维护)。 ### 6. 发布分支退役
- **下个中版本发布 = 上一条发布分支生命周期结束**`release/v1.1.x` 出现即 `release/v1.0.x` 退役)。
- 退役后可删可留: - 退役后可删可留:
- 删除保持仓库干净tag 已保留全部历史,删分支不丢东西)。 - 删除保持仓库干净tag 已保留全部历史,删分支不丢东西)。
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好,推荐与本仓库一样保留已打 tag 的历史分支做对照)。 - 保留:便于追溯该发布线的历史构建(对 24/7 现网友好)。
- 本仓对现网多代版本并行维护时,保留近期 release 分支是合理的。 - **按 patch 号命名的历史发布分支应当合并/删除**:它们是本规范修订前的遗留形态,
内容已被对应的 `release/vX.Y.x` 完全包含,保留只会让"哪条才是这条线"变得含糊。
--- ---
## 三、当前分支对齐2026-08-31 执行 ### 7. 开发者文档的发布归属(以 rel 分支的形态为准
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
为什么不能直接改 main
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
做法:
```bash
git switch release/v1.2.x
# 按这一版口径修改版本号、当前工具名hmapdev、已移除机制不再写成现行
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
git commit -m "docs: 按 v1.2.x 口径修正 …"
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick不 merge
```
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
不得让读者以为它已发布。
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**
| 现象 | 后果 |
|---|---|
| 人格卡写死 `v0.9.0C ABI v2` | 内核接口/日志报 1.2.0agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
| 人格文本在**播种时**就把 `meta.Version` 插值写进配置库 | 装机那天即冻住版本号:内核 1.3.x 的实例仍向用户自称 `v1.0.3`2026-09-13 用户当场发现) |
| 发布线只用 `-ldflags -X` 打版本、源码 `meta.Version` 不动 | 二进制自称 1.3.4、源码路牌仍是 1.3.0,溯源对不上账;同类还有给 SDK 误发 patch tag§七.1 要求 patch 位恒为 `.0` |
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
要么**在渲染时**用 `meta.Version` 插值(系统提示词占位符 `{{kernel_version}}` 即此机制),
要么要求读运行时快照,并用测试钉住(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
**"插值"指的是每次组装时现算,不是把算好的结果固化进配置库/文档** ——
固化过的版本号与写死没有区别,而且更难发现。
---
## 三、当前分支对齐2026-09-13 更新)
### 主仓TrueAgent ### 主仓TrueAgent
| 现存分支 | 状态 | 处理 | | 分支 | 状态 | 处理 |
|---|---|---| |---|---|---|
| `main` | `48b5c24` [origin/main] | ✅ 保持不变(规范基线) | | `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 **`1.4.0`** —— `1.3.0` 已归发布线所有) | ✅ 保持 |
| `feature/plugin-proc-migration` | 原 `update``69a138c`(领先 main 5文档基线 + Part 0.1/0.2 + 本规范) | ✅ **已对齐重命名**2026-08-31 | | `release/v1.3.x` | **本条发布线**`meta.Version` = **`1.3.6`**(该线最后一个 patch承载 `v1.3.1``v1.3.6`vendored SDK 定版 `1.3.0` | ✅ 保持 |
| `backup-local`SDK 仓) | `7092d15`ahead 3, behind 14含 `ignore example/recoverydiag` 敏感提交) | ⚠️ 遗留本地分支,功能已合入 main**保留不删**(无远端,删除即永久丢失) | | `release/v1.2.x` | 承载 `v1.2.0` / `v1.2.1` / `v1.2.2`,末态 `meta.Version` = `1.2.2` | 📦 已退役§2.6),保留供追溯 |
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1`,末态 `meta.Version` = `1.1.1` | 📦 已退役,保留供追溯 |
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
> - `v1.3.0` 是**已撤回**的坏 tag设备输出通道名 `device/<id>` 里的 `/` 拼进 LLM 函数名
> `output_send__device/<id>`,上游按**整条请求** 400全量对话不可用修复见 `v1.3.1`)。
> - `feature/*` 分支(`input-semantics`、`multimodal-embedding`、`memory-media`、`plugin-proc-migration`
> 均已合入并删除。
### SDK 仓homeagent-sdk ### SDK 仓homeagent-sdk
| 现存分支 | 状态 | 处理 | | 分支 | 状态 | 处理 |
|---|---|---| |---|---|---|
| `main` | `61f307b` v1.2.0 | ✅ 保持不变 | | `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.4.0`**)——`1.3.0` 已随核心**正式** tag 定版(§七.3),故路牌推进 | ✅ 保持 |
| `update` | `5648519`(领先 main 1Part 0.2 模板修复) | ⚠️ 与主仓 `update` 对齐重命名 | | `release/v1.3.x` | `meta.Version` = `1.3.0`,承载 tag `v1.3.0`5 平台 `hmapdev` + `SHA256SUMS` + 4 个源码归档) | ✅ 与核心对应 |
| `backup-local` | `7092d15`ahead 3, behind 14遗留调试分支 | ⚠️ 可选清理 | | `release/v1.2.x` | `meta.Version` = `1.2.0`,承载 tag `v1.2.0`;同线的 `v1.2.1` 属**误发的 patch tag**(§七.1 违规),其 gitcode 条目标题已标注「(已撤回)」 | 📦 已退役 |
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应(已退役) |
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
> `update` 整改工作分支按规范应为 `feature/plugin-proc-migration`多进程插件化整改8-9 周大特性) > ❗**SDK 仓不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`
> 是否重命名由执行人确认;不重命名则视为偏离规范的既有分支,须在文档记录其存在。 > 2026-09-13 曾误发 `v1.3.1`(文档用),**已撤回**(远端 tag 已删,本地 commit `05b7a20` 可恢复);
> `v1.2.1` 是同一类历史遗留。
> ❗**现网 SDK store 例外**:本机 `hmapdev` store 用 `--from` 装的是 SDK 源码构建的 1.3.0
> 与 tag 内容一致。
### 1.0.x 发布线 tag 历史
| tag | 提交 | 通道 | 说明 |
|---|---|---|---|
| `v1.0.0` | `9b92a04` | 正式 | 外部插件从 C ABI 迁移到子进程 + 共享内存 |
| `v1.0.1` | `e671a8c` | 正式 | 多模态 bugfix假成功、能力声明与回退链、see_video 帧数语义) |
| `v1.0.3` | `26dc76f` | 正式 | 内核 stage 协调器双重解锁(直接跳正式:单点修复 + 反向验证 + 全类审计) |
> `v1.0.2` 未使用:该号从未发布也无 tag留空以免与任何本地构建混淆。
### 1.1.x 发布线 tag 历史
| tag | 提交 | 通道 | SDK | 说明 |
|---|---|---|---|---|
| `v1.1.0` | `579d7db` | 正式 | 1.0.0 | 记忆系统支持二进制多媒体节点CAS 媒体存储 + L0/L2/L3 贯通) |
| `v1.1.0-beta.1` | `7a57a14` | beta | 不发 | 打包链路验证GUI 架构污染 + 空壳 node_modules。按 §七.2beta 不伴随 SDK 发版 |
| `v1.1.1` | 见发布说明 | 正式 | **1.1.0** | 多模态贯通插件边界SDK 首次随核心正式版发布 |
> `v1.1.0-beta.1` 的提交序在 `v1.1.0` **之后**(它多含一个打包修复),
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
> 已知代价beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
### 1.2.x 发布线 tag 历史
| tag | 提交 | 通道 | SDK | 说明 |
|---|---|---|---|---|
| `v1.2.0-beta.1` | `215804c` | beta | 不发 | 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存RPC 协议 **2**,与 1.x 不兼容)。按 §七.2beta 不伴随 SDK 发版 |
| (正式 tag 待打) | — | — | — | 试运行 beta 无回退问题后打 `v1.2.0`,并同步 SDK 仓 `release/v1.2.x` + `v1.2.0` |
> 1.2.x 与存量插件**不兼容**RPC 协议升到 2fd3 布局改变),存量外部插件必须用
> 新版 plugindev 重编为 `plugin.bin`——**不支持滚动升级**,内核与插件须同批重建、同批安装。
> 按 §2.4,跳级直发正式版需在发布说明里列明「单点修复 / 反向验证 / 全类审计」三项;
> 本次改动面大(统一多模态向量空间 + 协议 2 + 数据面全量迁移),不满足跳级条件。
--- ---
## 四、现网部署与版本对应(运维纪律) ## 四、现网部署与版本对应(运维纪律)
- **现网 homed 永远部署 `release/vX.Y.Z` 分支打出的 tag 构建**,路径见 `Makefile``make build` → `build/homed`)。 - **现网 homed 永远部署 `release/vX.Y.x` 分支 tag 构建产物**,路径见 `Makefile``make build` → `build/homed`)。
- systemd 服务(`/usr/local/bin/homed`)替换前:备份旧二进制 → 停服 → 替换 → 起服 → 健康检查(`scripts/verify_deploy.sh`)。 - systemd 服务(`/usr/local/bin/homed`)替换流程:
- **改造期间update 整改)现网不得部署 main 或 feature 的中间态**——只有发版才用 release。 1. 备份旧二进制(`homed.bak.pre<版本>.<时间戳>`
2. 备份配置库(**用 `sqlite3 .backup`,不用 `cp`**——WAL 模式下 cp 可能拿到不一致快照)
3. 记录当前插件建链清单,供重启后逐项比对
4. `install -m 0755` 替换(原子 rename不会写坏正在运行的进程镜像
5. `systemctl restart homeagent`
6. 健康检查:版本号、插件清单无缺失、`/api/v1/status`、一次真实对话、`fatal error` 计数为 0
- **改造期间现网不得部署 main 或 feature 的中间态**——只有发版才用发布分支的 tag。
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本, - 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(两仓版本对齐是第一优先级)。 发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓版本对齐是第一优先级**,见 §七)。
- **客户端版本必须与内核同步**GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`
内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`
发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段);
鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读,
不再硬编码。
--- ---
@ -128,28 +279,234 @@ git checkout -b feature/xxx
git checkout main && git merge --no-ff feature/xxx # 或 squash git checkout main && git merge --no-ff feature/xxx # 或 squash
git branch -d feature/xxx git branch -d feature/xxx
# 发布 # 开一条新中版本的发布线
git checkout -b release/v1.2.0 main git checkout -b release/v1.1.x main
git commit -am "chore: bump v1.2.0" # 版本号 git commit -am "chore(release): bump v1.1.0-alpha.1"
git tag v1.2.0 git tag -a v1.1.0-alpha.1 -m "..." # alpha内部验证
# ... 构建发布 ... # ... 修问题 ...
git commit -am "chore(release): bump v1.1.0-beta.1"
git tag -a v1.1.0-beta.1 -m "..." # beta小范围试用
# ... 真实环境验证 ...
git commit -am "chore(release): bump v1.1.0"
git tag -a v1.1.0 -m "..." # 正式
# hotfix发布后 # hotfix发布后——注意是同一条 release/v1.0.x不新建分支
git checkout release/v1.2.0 git checkout release/v1.0.x
git commit -am "fix(x): 严重 bug" # ① 修复 git commit -am "fix(x): 严重 bug" # ① 修复
git commit -am "chore: bump v1.2.1" # ② 版本号 git commit -am "chore(release): bump v1.0.4" # ② 版本号
git tag v1.2.1 git tag -a v1.0.4 -m "..."
git checkout main git checkout main
git cherry-pick <修复①的sha> # ③ 只挑修复 git cherry-pick <修复①的sha> # ③ 只挑修复
# 若有活跃 feature 分支,也 pick 过去
git checkout feature/xxx && git cherry-pick <main 上那个 pick 的 sha>
# release 退役(可选) # 公开 SDK 接口改动feature不是 hotfix先进 main再 pick 到发布分支
git branch -d release/v1.2.0 # tag 已保存历史,删分支不丢东西 git checkout -b feature/sdk-xxx main
# ... 改 third_party/homeagent-sdk/sdk/ 与内核桥接层 ...
git checkout main && git merge --no-ff feature/sdk-xxx
git checkout release/v1.1.x
git cherry-pick <feature 的各 sha> # 只挑改动,不挑 main 的版本号
git commit -am "chore(release): bump v1.1.1" # 发布分支自己的版本号
git tag -a v1.1.1 -m "..."
# SDK 仓同步(仅在核心打正式 tag 时,见 §七.2/§七.3
cd third_party/homeagent-sdk
git checkout -b release/v1.1.x main
git commit -am "chore(release): SDK 1.1.01.1.x 线全程共用)"
git tag -a v1.1.0 -m "..."
# 发布分支退役(下个中版本发布后,可选)
git branch -d release/v1.0.x # tag 已保存历史,删分支不丢东西
``` ```
--- ---
## 六、本规范与「接口冻结」约束的关系 ## 六、本规范与「接口冻结」约束的关系
- feature 分支合回 main 的门禁(`git diff sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。 > **接口冻结已到期v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
- 插件多进程化整改(`feature/plugin-proc-migration` 或现 `update`**不满足接口冻结不等于不能合并**—— > 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。
接口冻结约束的是「公开 SDK 不变」,整改若突破需走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七) > 迁移完成v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则
> 本节保留历史条款,但**不再作为合回门禁**。
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
- **公开接口的改动本身是 feature不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
——发布分支冻结功能§2.3),接口是最典型的功能面。
---
## 七、SDK 仓的版本语义与发版联动
### 1. SDK 版本号跟随核心的中版本patch 位恒为 `.0`
| 核心版本 | 对应 SDK 版本 |
|---|---|
| 1.1.0 / 1.1.1 / 1.1.2 / … / 1.1.N | **1.1.0**(全线共用,不随核心 patch 变动) |
| 1.2.0 起 | **1.2.0** |
- 核心的 patch 位(`x`)专用于 **bugfix 与漏洞修复**,这类改动不触碰公开 SDK 接口,
因此 SDK 版本号没有理由跟着动。
- **为什么不逐位对齐**SDK 版本号是插件开发者的依赖声明。若核心每发一个 bugfix 就把 SDK
也推一个新号,开发者要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
让 SDK 号只在**接口可能变化的中版本边界**上跳,开发者只需关心「我在为哪个中版本写插件」。
- 因此「两仓版本对齐」在本规范里指**中版本对齐**(核心 1.1.x ↔ SDK 1.1.0
不是三位全等。核心 1.1.1 配 SDK 1.1.0 就是对齐状态。
### 2. beta 阶段不发 SDK
- **核心的 alpha/beta tag 不伴随 SDK 仓发版**SDK 仓在这一阶段**不打 tag、不建 release**。
- **为什么**beta 是核心自己的测试阶段,此时 SDK 接口尚未固定。若此刻给 SDK 发版,
插件开发者会照着一个还会变的接口写代码——**那是无效开发**。接口没定就没有可依赖的契约,
发出去的版本号是一个假承诺。
- 这条约束的对象是 **SDK 仓的发版动作**,不是核心二进制里有没有 SDK 代码。
主仓 `go.mod` 用 `replace => ./third_party/homeagent-sdk`,任何核心构建都必然含 vendored
SDK 源码,这是构建机制决定的,不在本条约束范围内。
### 3. 正式发布时 SDK 随核心一起发
核心打**正式 tag**`vX.Y.Z`无预发布后缀SDK 仓同步执行:
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release
4. 上传 5 平台 `hmapdev`(插件开发工具链)产物 + `SHA256SUMS`。
同一中版本内的后续核心 patch1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0
没有新东西要发。只有接口再次变化并进入下一个中版本时SDK 才发 1.2.0。
### 5. 发版产物清单(可复现)
**推 tag ≠ 完成发版**:还要打包产物、建 gitcode release 条目、上传附件。
2026-09-13 出现过"tag 推了、release 条目和产物都没有"的情况(`v1.3.1``v1.3.6`
事后才补 —— 记录在此以免重犯。
**核心仓**(在 tag 的**干净 worktree** 里构建,不要用带其它会话改动的工作区):
| 产物 | 生成方式 |
|---|---|
| `homeagent_<版本>_linux_amd64.tar.gz` | `VERSION=<版本> bash deploy/packaging/package-linux.sh amd64` |
| `homeagent-client_<版本>_amd64.deb`、`-server`、`-full` | 同上server/full 需要 Chinese-CLIP 与 ONNX Runtime 资产目录(`build/model-assets/`、`build/runtime-assets/` |
| `SHA256SUMS` | **全部产物生成完毕之后**统一计算(边打边算会漏掉后生成的包) |
| 4 个源码归档(`.zip` / `.tar.gz` / `.tar.bz2` / `.tar` | gitcode 打 tag 时自动生成,无需上传 |
**SDK 仓**`VERSION=<版本> bash package/build.sh all hmapdev` ⇒
`hmapdev_{linux,darwin}_{amd64,arm64}` + `hmapdev_windows_amd64.exe` + `SHA256SUMS`。
**Windows 安装器WSL 安装型)**`homed` **不再装到 Windows**(插件体系依赖 fd 继承与
共享内存区段内偏移解引用Windows 句柄模型表达不了),安装器的职责是**引导 WSL2 并把
Linux 包送进发行版里安装**。产物 `HomeAgent_v<版本>_{Server,Client,Full}_win64.exe`
```bash
# 先有 Linux 包(安装器送进 WSL 的就是它),再打安装器
VERSION=<版本> bash deploy/packaging/package-linux.sh amd64
VERSION=<版本> bash deploy/packaging/package-windows.sh server amd64 # 只装内核+CLI 的 WSL 场景
VERSION=<版本> bash deploy/packaging/package-windows.sh client amd64 # 需要 Windows GUI payload
```
- `package-windows.sh` 会按**变体只放对应的那一个 deb** 进 payload。为什么WSL 侧脚本只取
payload 里第一个 `.deb``install-via-wsl.ps1`),而 server/full 的 deb 各带 ~719MB 模型 ——
照 `build.sh` 的 `stage_linux_payload`(把所有 deb+tar 全塞)打出来会是 ~2.4GB 的安装器。
- `client`/`full` 变体还带 Windows GUI需要 electron-builder 产出
`build/homeagent-gui-win32-x64/`;缺它就**明确失败**,不产出"装完没有界面"的半残包。
**上传**(两仓同一个脚本):
```bash
# 核心仓
python3 deploy/scripts/upload_assets.py <tag> <token> # 默认上传 dist/release 下可识别的产物
# SDK 仓hmapdev_* 没有扩展名,不会被自动识别 ⇒ 必须显式列文件名)
GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
python3 deploy/scripts/upload_assets.py <tag> <token> hmapdev_linux_amd64 ...
```
- 脚本先向 `releases/<tag>/upload_url` 取 **OBS 预签名 URL** 再 PUT ⇒ **release 条目必须先存在**
- alpha/beta 的产物可以上传,但必须在 release 条目上勾选**预发布**标志§2.4
- 校验和必须覆盖**全部**附件,否则等于没有校验。
- ❗❗**gitcode 的 release 附件是"同名只写一次"**(实测:同名两次不同内容,下载始终是第一次那份;
且没有可用的删除接口 —— release JSON 不含 `id`,附件列表接口 404`DELETE .../attach_files/<>`
只要数字 id。**后果**`SHA256SUMS` 若第一次上传时只覆盖了部分平台,之后**永远改不回来** ——
1.3.11.3.10 都踩了:首次只传了 linux/amd64后来补 arm64/darwin/win 时合并重传**全部无效**
线上那份至今只有 4 项。
⇒ **纪律:首次上传 `SHA256SUMS` 前必须已打包全部平台**;分批上传时**先传产物、最后传校验和**
且校验和只传一次。补救只能换名(如 `SHA256SUMS.complete`)或重建 release要重传全部产物
- ❗**流水线脚本必须 `set -e`(或显式检查每步)**:否则某一步失败(例如驱动脚本在 tag 里
不存在)之后它仍会继续跑到上传,把**半成品校验和**推上去覆盖全量的那份。
实测v1.3.10 的校验和被 4 项覆盖掉,只能重建。)
- ❗**在 tag 的 worktree 里构建时,驱动脚本要么已进该 tag要么支持目录覆盖**
新补的脚本只存在于 main去 tag 的 worktree 里调就是 `No such file or directory`。
现 `package-windows.sh` 支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,
可以"用主仓的脚本 + 产物目录指向 worktree"。
- ❗**分批上传时,后一轮必须在全量产物上重算 `SHA256SUMS`**,不能只算本轮那几个文件:
同名附件会**覆盖**前一轮的校验和(实测:先传 amd64 的 9 个资产,后补 arm64 时
只算了 arm64 的 4 个,结果 amd64 的校验和从 release 上消失 ⇒ 已下载的包失去校验依据,
只能把产物全部下回来重算)。要么一次打包全部平台再算,要么后一轮把**已上传的**
也纳入计算。
### 4. 版本号在两仓 main 上的含义
两仓的 `main` 都遵守 §2.1`meta.Version` 是**下一个未发布中版本**。
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
**但两仓「同步推进」是有条件的**(这一点曾导致误判,现补写清楚):
推进的前提是**该中版本已经正式发布过**。具体到当前:
- 核心:切出 `release/v1.2.x` 后1.2.0 就归发布线所有main 立即推进到 `1.3.0`
**即使 1.2.0 目前只有 beta tag**beta 不上现网,但发布线已占住这个号)。
- SDK因为 §七.2 **beta 不发 SDK**SDK 1.2.0 要等核心的**正式** tag 才定版、
建 `release/v1.2.x`、打 `v1.2.0`(§七.3。在那之前SDK 的「下一个未发布中版本」
仍然是 `1.2.0`,其 main 不得越过它。
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
---
## 八、公开 SDK 接口的演进规则
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
> 取代它的是下面三条更弱、但仍然硬的规则。
### 1. 只增不减,签名不改
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
实例v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
而那些插件根本不关心媒体。
### 2. 新增方法必须是「插件调用、内核实现」方向
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
每个存量插件都会因未实现而编译失败。
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
每个外部插件都**编不过**——是硬失败,不是软降级。
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
### 4. 「接口纯追加」不等于「无需重编」
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
### 5. 合回 main 前要同步的东西
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
`TestProcTemplate_CoversAllCoreMethods`
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`
4. 并发安全(`go test -race -count=5 ./sdk/`)。

View File

@ -1,72 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef const char* (*verfn)(void);
static const char* call_ver(void* f){ return ((verfn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"unsafe"
)
func main() {
// Go 用 dlopen 加载纯 C shimshim 本身常驻,无所谓)
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
if shim == nil {
fmt.Println("shim 加载失败:", C.GoString(C.dlerror()))
os.Exit(1)
}
openName := C.CString("shim_open")
closeName := C.CString("shim_close")
symName := C.CString("shim_sym")
shimOpen := C.dlsym(shim, openName)
shimClose := C.dlsym(shim, closeName)
shimSym := C.dlsym(shim, symName)
C.free(unsafe.Pointer(openName))
C.free(unsafe.Pointer(closeName))
C.free(unsafe.Pointer(symName))
fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym)
// 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装)
load := func(path string) unsafe.Pointer {
cp := C.CString(path)
defer C.free(unsafe.Pointer(cp))
return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
}
ver := func(h unsafe.Pointer) string {
n := C.CString("probe_version")
defer C.free(unsafe.Pointer(n))
f := C.dlsym(h, n)
if f == nil { return "<no sym>" }
return C.GoString(C.call_ver(f))
}
fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---")
h1 := load("./probe.so")
fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1))
rc := C.dlclose(h1)
fmt.Printf("2) dlclose rc=%d\n", int(rc))
// 换内容V1 -> V2同路径
in, _ := os.ReadFile("probe_v2.so")
os.WriteFile("probe.so", in, 0755)
fmt.Println("3) 磁盘 probe.so 内容替换为 V2同路径")
h2 := load("./probe.so")
fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2))
if h1 == h2 {
fmt.Println(" => 句柄相同:未卸载,仍是旧代码")
} else {
fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
}
}

View File

@ -1,49 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void* (*openfn)(const char*);
typedef int (*closefn)(void*);
static void* c_open(void* f, const char* p){ return ((openfn)f)(p); }
static int c_close(void* f, void* h){ return ((closefn)f)(h); }
*/
import "C"
import (
"fmt"
"os"
"strings"
"unsafe"
)
func cnt(s string) int {
b, _ := os.ReadFile("/proc/self/maps")
n := 0
for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } }
return n
}
func main() {
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
no := C.CString("shim_open"); nc := C.CString("shim_close")
fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc)
C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc))
// 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件
qp := C.CString("/home/newqqagent/plugins/qq/plugin.so")
h := C.c_open(fo, qp)
C.free(unsafe.Pointer(qp))
fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so"))
rc := C.c_close(fc, h)
fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so"))
if cnt("qq/plugin.so") > 0 {
fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关")
} else {
fmt.Println("\n✅ 卸载成功")
}
}

Some files were not shown because too many files have changed in this diff Show More