mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
4 Commits
feature/re
...
v1.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 68835c18db | |||
| 89db544671 | |||
| a021055011 | |||
| d17c18665c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,7 +31,6 @@ cmd/gui/dist/
|
||||
third_party/homeagent-sdk/bin/
|
||||
third_party/homeagent-sdk/tools/
|
||||
third_party/homeagent-sdk/package/
|
||||
third_party/homeagent-sdk/scripts/
|
||||
third_party/homeagent-sdk/.gitignore
|
||||
third_party/homeagent-sdk/README*
|
||||
third_party/homeagent-sdk/example/
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# 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]
|
||||
11
Makefile
11
Makefile
@ -61,14 +61,3 @@ fmt:
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
|
||||
# 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
|
||||
|
||||
@ -599,26 +599,23 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
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.1–1.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).
|
||||
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.
|
||||
|
||||
**Registration**
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sdk.log(level, msg)` | Log output |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `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_api(name)` | Register API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` |
|
||||
| `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 |
|
||||
|
||||
**Stage hook context**
|
||||
|
||||
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 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 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):
|
||||
|
||||
@ -647,37 +644,20 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
||||
| `sdk.inject_text(source, channel, text)` | Deliver text message |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||
| `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)`)**
|
||||
|
||||
| Sub-table | Functions |
|
||||
|-----------|-----------|
|
||||
| `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})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `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.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.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"> :
|
||||
|
||||
@ -592,26 +592,23 @@ lua main.lua
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐至 **SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
**注册类**
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `sdk.log(level, msg)` | 日志输出 |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`context_policy`(`"none"`/`"prune"`)、`cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
|
||||
| `sdk.register_api(name)` | 注册 API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`context_policy`、`cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
|
||||
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
|
||||
|
||||
**阶段钩子上下文**
|
||||
|
||||
`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`。
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
|
||||
**Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
|
||||
|
||||
@ -640,37 +637,20 @@ end)
|
||||
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
||||
| `sdk.inject_interrupt(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)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
| `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})`、`insert_with_media(doc, attachments)`、`remove(id)`、`stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `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.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.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"> :
|
||||
|
||||
@ -696,10 +696,6 @@ async function refreshDataOnly() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -779,10 +775,6 @@ async function refreshAll() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -1012,249 +1004,6 @@ function statCard(l, v) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 运行态面板:阶段管道 + 中断队列 =====
|
||||
//
|
||||
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
|
||||
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
|
||||
// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。
|
||||
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" },
|
||||
];
|
||||
// 图标一律内联 SVG(24x24 / 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() {
|
||||
var s = state.status || {};
|
||||
var k = state.kernel;
|
||||
@ -1269,25 +1018,7 @@ function renderOverview() {
|
||||
"uptime",
|
||||
) +
|
||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||
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",
|
||||
) +
|
||||
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||
"</div>";
|
||||
if (k) {
|
||||
html +=
|
||||
@ -1351,7 +1082,6 @@ function renderOverview() {
|
||||
"</span></div>" +
|
||||
"</div></div>";
|
||||
}
|
||||
html += renderRuntimePanel();
|
||||
html +=
|
||||
'<div class="card"><h2>' +
|
||||
__("运行时", "Runtime") +
|
||||
@ -1364,7 +1094,6 @@ function renderOverview() {
|
||||
) +
|
||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||
"</div></div>";
|
||||
html += renderLegalCard();
|
||||
document.getElementById("view-overview").innerHTML = html;
|
||||
}
|
||||
|
||||
@ -5482,48 +5211,17 @@ async function connectFetchSSE(url) {
|
||||
var phase = p.phase || "";
|
||||
var tool = p.tool || "";
|
||||
if (p.channel !== "_consolidation_") {
|
||||
// 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
|
||||
// 与 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"),
|
||||
);
|
||||
if (phase === "pre_action")
|
||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||
} else if (phase === "before_toolcall") {
|
||||
if (tool)
|
||||
rtTrailPush(
|
||||
2,
|
||||
tool.indexOf("output_") === 0 ? "output" : "tool",
|
||||
tool,
|
||||
rtShortTool(tool),
|
||||
);
|
||||
if (tool) state.toolFlash = true;
|
||||
else if (phase === "before_toolcall") {
|
||||
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||
if (!state.pendingTools) state.pendingTools = [];
|
||||
state.pendingTools.push(tool);
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
} else if (phase === "before_output") {
|
||||
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
|
||||
} else if (phase === "before_output")
|
||||
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");
|
||||
if (badge) {
|
||||
|
||||
@ -2191,284 +2191,3 @@ td .switch {
|
||||
.kv-row .val .switch {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,869 +0,0 @@
|
||||
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 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、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")
|
||||
}
|
||||
if memDB != nil {
|
||||
}
|
||||
|
||||
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 {
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
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),
|
||||
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>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startHeartbeat(dataDir string, ctx context.Context) func() {
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
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 VM(LLM 协议适配);启动失败只告警,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 建内核与插件之间的两个通道:IOManager(IO 抽象层)+ 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@ -2,19 +2,49 @@ 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/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/clawhubadapter"
|
||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
"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"
|
||||
|
||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||
@ -22,164 +52,699 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||
)
|
||||
|
||||
// main 是 worker 进程的启动序列。
|
||||
//
|
||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
||||
// 因此释放顺序与拆分前完全一致。
|
||||
func main() {
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
opt := parseFlags()
|
||||
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()
|
||||
|
||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||
if opt.role == "guard" {
|
||||
runGuard(resolveDataDir(opt.dataDir))
|
||||
if *role == "guard" {
|
||||
runGuard(resolveDataDir(*dataDir))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
||||
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||
|
||||
if opt.dataDir == "" {
|
||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
||||
if *dataDir == "" {
|
||||
*dataDir = resolveDataDir(*dataDir)
|
||||
}
|
||||
|
||||
if opt.cliSocket == "" {
|
||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
||||
if *cliSocket == "" {
|
||||
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||
}
|
||||
|
||||
logDir := setupLogging(opt.dataDir)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||
|
||||
agentWorkDir := ensureDataDirs(opt.dataDir)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 基础设施层:记忆、技能 ----
|
||||
// ========================================================================
|
||||
// 基础设施层:记忆、技能
|
||||
// ========================================================================
|
||||
|
||||
mem, closeMem := initMemoryStack(opt.dataDir)
|
||||
defer closeMem()
|
||||
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")
|
||||
}
|
||||
if memDB != nil {
|
||||
defer memDB.Close()
|
||||
}
|
||||
|
||||
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
|
||||
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{
|
||||
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()
|
||||
cfgReg.SeedDefaults(opt.dataDir)
|
||||
cfgReg.SeedDefaults(*dataDir)
|
||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json"))
|
||||
cfg := cfgReg.ToConfig()
|
||||
|
||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||
mem.distiller.SetEmbedder(embedder)
|
||||
distiller.SetEmbedder(embedder)
|
||||
|
||||
// ---- Lua VM(LLM 协议适配) ----
|
||||
// ========================================================================
|
||||
// Lua VM(LLM 协议适配)
|
||||
// ========================================================================
|
||||
|
||||
luaVM, closeLuaVM := initLuaVM(cfg)
|
||||
defer closeLuaVM()
|
||||
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)
|
||||
} else {
|
||||
defer luaVM.Stop()
|
||||
}
|
||||
|
||||
// ---- 守护管理(代理生命周期管理) ----
|
||||
// ========================================================================
|
||||
// 守护管理(代理生命周期管理)
|
||||
// ========================================================================
|
||||
|
||||
sup := initSupervisor(cfg)
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
|
||||
// ---- 变更追踪(overlayfs) ----
|
||||
// ========================================================================
|
||||
// 变更追踪(overlayfs)
|
||||
// ========================================================================
|
||||
|
||||
trk := initTracker(cfg, agentWorkDir)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
||||
// 所有插件通过这两个通道与核心交互
|
||||
// ========================================================================
|
||||
|
||||
iom, evBus := initKernelAPI()
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
|
||||
// ---- 文本记忆 + 记忆蒸馏管线 ----
|
||||
// ========================================================================
|
||||
// 文本记忆 + 记忆蒸馏管线
|
||||
// ========================================================================
|
||||
|
||||
textMem, closeTextMem := initTextMemory(cfg)
|
||||
defer closeTextMem()
|
||||
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)
|
||||
} 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())
|
||||
defer stop()
|
||||
|
||||
startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.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)
|
||||
|
||||
// ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
baseAPIKey := resolveBaseAPIKey(cfg)
|
||||
if input != "" && memDB != nil {
|
||||
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()
|
||||
|
||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||
if opt.boot == "failback" {
|
||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
if *boot == "failback" {
|
||||
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
}
|
||||
|
||||
// ---- 文档记忆 + 知识库 ----
|
||||
// ========================================================================
|
||||
// 文档记忆 + 知识库
|
||||
// ========================================================================
|
||||
|
||||
docStore, closeDocStore := initDocStore(cfg)
|
||||
defer closeDocStore()
|
||||
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()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
|
||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
||||
defer closeMediaStore()
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
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
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
||||
defer closeMultimodal()
|
||||
// 统一多模态向量空间。
|
||||
//
|
||||
// 核心**不**知道任何具体模型:它只按配置里的 provider 名从公共注册表
|
||||
// (pkg/embedding)打开一个 provider,并把 options.* 原样交给它。模型文件
|
||||
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
|
||||
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
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
|
||||
defer adapted.Close()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
ks := initKnowledgeStore(cfg)
|
||||
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()))
|
||||
}
|
||||
|
||||
// ---- 人格设定 ----
|
||||
// ========================================================================
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
// 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效)
|
||||
// > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。
|
||||
//
|
||||
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
|
||||
// 反过来让实例自称旧版本(v1.2.0 压测发现)。故:
|
||||
// - 配置项化 + 内置默认模板(不含版本号字面量)
|
||||
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
|
||||
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] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
||||
// ========================================================================
|
||||
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||
// ========================================================================
|
||||
|
||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
// ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
||||
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() 的数据根目录
|
||||
|
||||
agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
||||
// 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)
|
||||
|
||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
||||
// ========================================================================
|
||||
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||
// ========================================================================
|
||||
|
||||
defaultPrompt := `你是 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 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、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,
|
||||
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),
|
||||
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,
|
||||
})
|
||||
|
||||
// 通过 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() 自注册工厂)
|
||||
cli.DefaultSocket = opt.cliSocket
|
||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
||||
cli.DefaultSocket = *cliSocket
|
||||
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||
// 保留 CLI --webui 与 webui.listen_addr 配置对监听地址的覆盖。
|
||||
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 模型 / 规则引擎)
|
||||
// ========================================================================
|
||||
|
||||
initONNXParser(cfg, cfgReg)
|
||||
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(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
|
||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
||||
compactConfigDB(cfgReg)
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if *boot == "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)
|
||||
}
|
||||
|
||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
||||
defer stopRuntime()
|
||||
// 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())
|
||||
|
||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
||||
defer closeIPC()
|
||||
// 技能索引接线: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")
|
||||
}
|
||||
}
|
||||
|
||||
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 socket,guard 发 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",
|
||||
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||
|
||||
// ---- 等待退出信号 ----
|
||||
// ========================================================================
|
||||
// 等待退出信号
|
||||
// ========================================================================
|
||||
|
||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 附件的字节获取与解码(网络 / 沙箱 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;
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 附件的解析与格式化(纯函数,无 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 : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
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;
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
||||
*
|
||||
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
|
||||
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
|
||||
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
|
||||
*
|
||||
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
|
||||
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
|
||||
*/
|
||||
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 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: '1.1.1',
|
||||
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;
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 聊天页的纯格式化/判定逻辑(无 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%)';
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/**
|
||||
* /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;
|
||||
}
|
||||
|
||||
/** 解析后端 /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 };
|
||||
}
|
||||
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,
|
||||
};
|
||||
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;
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore };
|
||||
}
|
||||
|
||||
/** 后端 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;
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 发送 / 中断(无 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。
|
||||
* 带附件的情况走 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/file(multipart),字段与 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();
|
||||
}
|
||||
|
||||
export async function interruptChat(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/chat/interrupt', null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
/**
|
||||
* 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_call(status=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
|
||||
}
|
||||
}
|
||||
@ -1,389 +0,0 @@
|
||||
/**
|
||||
* 聊天流状态源(单例)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
|
||||
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
|
||||
*
|
||||
* 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。
|
||||
* 这里沿用同一套做法:数组留在 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;
|
||||
|
||||
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;
|
||||
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);
|
||||
if (parsed.msgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.msgs = parsed.msgs;
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.forceRefresh();
|
||||
this.requestScroll();
|
||||
} catch (e) {
|
||||
// ignore history load failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上翻页:拉 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;
|
||||
}
|
||||
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.sse.close();
|
||||
this.cancelRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
export const chatStore: ChatStore = new ChatStore();
|
||||
@ -1,8 +1,67 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
||||
bridgeChunkSlices } from './BridgeProtocol';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
@ -188,12 +247,33 @@ export class DeviceBridgeClient {
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
||||
this.hostname, authorized));
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: this.hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.1',
|
||||
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 {
|
||||
this.send(bridgeBindFrame(this.deviceId, this.token));
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: this.deviceId,
|
||||
token: this.token,
|
||||
};
|
||||
this.send(JSON.stringify(bind));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
@ -254,16 +334,32 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||
this.send(bridgeResultFrame(reqId, status, output, errMsg));
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
this.send(JSON.stringify(result));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||
|
||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
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 {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
@ -272,15 +368,32 @@ export class DeviceBridgeClient {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.send(bridgeDataEndFrame(reqId));
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
this.send(JSON.stringify(endMsg));
|
||||
}
|
||||
|
||||
sendEvent(eventType: string, detail: string): void {
|
||||
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
|
||||
const payload: Record<string, string> = { 'detail': 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 {
|
||||
this.send(bridgeStatusFrame(this.deviceId, status));
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': this.deviceId,
|
||||
'status': status,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 设备页的纯逻辑:本机 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;
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 插件数据获取(无 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;
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 插件状态展示口径(纯函数,无 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(' · ');
|
||||
}
|
||||
@ -1,313 +0,0 @@
|
||||
/**
|
||||
* 设置数据模型与纯解析逻辑(无 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';
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
|
||||
*
|
||||
* 由 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;
|
||||
}
|
||||
@ -27,68 +27,6 @@ export interface StatGroup {
|
||||
fields: StatField[];
|
||||
}
|
||||
|
||||
// ===== 运行态快照(/runtime)=====
|
||||
//
|
||||
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
|
||||
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
|
||||
// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。
|
||||
|
||||
/** 一条队列(四级中断之一,或排队队列) */
|
||||
export interface RuntimeQueue {
|
||||
/** 4/3/2/1;0 表示排队队列(无级别) */
|
||||
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 键:摘要卡与明细页共用 =====
|
||||
export const K_UP: string = 'statUp';
|
||||
export const K_VERSION: string = 'statVersion';
|
||||
@ -99,14 +37,10 @@ export const K_TOOLS: string = 'statTools';
|
||||
export const K_ERR: string = 'statErr';
|
||||
export const K_LOADING: string = 'statLoading';
|
||||
export const K_REV: string = 'statRev';
|
||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
||||
export const K_BUILD: string = 'statBuildSub';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
@ -118,17 +52,12 @@ class StatusStore {
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
getRuntime(): RuntimeSnapshot | undefined {
|
||||
return this.runtimeSnapshot;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
@ -172,7 +101,6 @@ class StatusStore {
|
||||
];
|
||||
|
||||
await this.collectKernel(groups);
|
||||
await this.collectRuntime();
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
@ -205,66 +133,8 @@ class StatusStore {
|
||||
if (startTime.length > 0) {
|
||||
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 });
|
||||
|
||||
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
|
||||
// 也看不出网络服务场景下的 §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 });
|
||||
}
|
||||
|
||||
// LLM:provider / 可用源 / 是否可用
|
||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||
if (llm !== undefined && llm !== null) {
|
||||
@ -351,91 +221,11 @@ 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 {
|
||||
AppStorage.set<string>(K_ERR, msg);
|
||||
AppStorage.set<boolean>(K_UP, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.runtimeSnapshot = undefined;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
|
||||
@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 「外观」二级页面:主题三选一 + 自定义背景图。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,20 +4,107 @@ import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
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 { COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard } from './SubPage';
|
||||
|
||||
/**
|
||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
||||
* 附件解析与展示。
|
||||
*
|
||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 字节走 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 : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
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 是新开标签页,移动端改为二级页)。
|
||||
*/
|
||||
@Component
|
||||
@ -341,3 +428,36 @@ export struct AttachmentDetailContent {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
/**
|
||||
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
|
||||
*
|
||||
* 从 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
|
||||
*
|
||||
* 从 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 })
|
||||
}
|
||||
}
|
||||
@ -1,361 +0,0 @@
|
||||
/**
|
||||
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
|
||||
*
|
||||
* 从 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 = '';
|
||||
}
|
||||
|
||||
/** 发送:有附件走 multipart(POST /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%')
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
|
||||
*
|
||||
* 从 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 } from '../common/Constants';
|
||||
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_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;
|
||||
private navHidden: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
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;
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
this.autoScrolling = false;
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}, 450);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 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.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%')
|
||||
}
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
|
||||
*
|
||||
* 从 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,347 +0,0 @@
|
||||
/**
|
||||
* 「后端连接」二级页面。
|
||||
*
|
||||
* 从 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 } 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 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.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.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已添加', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对已有连接的修改。
|
||||
*
|
||||
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
|
||||
* 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.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();
|
||||
}
|
||||
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/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
|
||||
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
|
||||
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
|
||||
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
/**
|
||||
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 设备页一级入口列表(本机 / 通道两组)。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 插件详情(二级页面内容)。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 插件一级列表(列表卡 + 三种占位态)。
|
||||
*
|
||||
* 从 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%')
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 安装表单悬浮卡。
|
||||
*
|
||||
* 从 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 }))
|
||||
}
|
||||
}
|
||||
@ -1,385 +0,0 @@
|
||||
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;
|
||||
/** 索引 = lv(0 排队 / 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 })
|
||||
}
|
||||
}
|
||||
@ -1,215 +0,0 @@
|
||||
/**
|
||||
* 单个配置项卡片。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 设置一级页的入口列表。
|
||||
*
|
||||
* 从 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)
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,285 @@
|
||||
* 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.
|
||||
*
|
||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
||||
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } 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
|
||||
export struct StaticMarkdownView {
|
||||
|
||||
@ -2,10 +2,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { RuntimePanel } from './RuntimePanel';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
} from '../common/StatusStore';
|
||||
|
||||
/**
|
||||
@ -20,8 +19,6 @@ export struct StatusSummaryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_VERSION) private version: string = '-';
|
||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@ -133,24 +130,14 @@ export struct StatusSummaryCard {
|
||||
Text('版本')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Column() {
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 内核名 + 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(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
@ -284,11 +271,6 @@ export struct StatusDetailContent {
|
||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
||||
// 后者是进这个页面最先想看的。
|
||||
RuntimePanel()
|
||||
|
||||
ForEach(this.groups, (g: StatGroup) => {
|
||||
Column() {
|
||||
Text(g.title)
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 右下角轻提示条(设置页与插件页共用)。
|
||||
*
|
||||
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
|
||||
* 用 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,25 +4,19 @@ import { apiClient } from '../common/ApiClient';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
import { DeviceRootEntries } from '../components/DeviceRootEntries';
|
||||
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** 二级页面标识 */
|
||||
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
|
||||
export struct DevicePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -45,8 +39,18 @@ export struct DevicePage {
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
||||
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
|
||||
this.deviceId = deviceBridge.getDeviceId();
|
||||
if (this.deviceId.length === 0) {
|
||||
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();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
@ -129,7 +133,31 @@ export struct DevicePage {
|
||||
const resp = await apiClient.get('/device/online');
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const list: DeviceInfo[] = parseOnlineDevices(parsed);
|
||||
const devs: Object = parsed['devices'];
|
||||
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.devices = list;
|
||||
});
|
||||
@ -149,17 +177,7 @@ export struct DevicePage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
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);
|
||||
},
|
||||
})
|
||||
this.RootEntries()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -230,58 +248,61 @@ export struct DevicePage {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面路由表。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
||||
*/
|
||||
/** 二级页面路由表 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
DeviceLocalPane({
|
||||
deviceId: this.deviceId,
|
||||
authorized: this.authorized,
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onToggleAuth: (on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.LocalDeviceContent()
|
||||
}
|
||||
} else if (name === SUB_CAPS) {
|
||||
DeviceCapsPane({
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.CapsContent()
|
||||
}
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
DeviceGatewayPane({
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
bridgeToken: this.bridgeToken,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.GatewayContent()
|
||||
}
|
||||
} else if (name === SUB_LIST) {
|
||||
DeviceListPane({
|
||||
devices: this.devices,
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}) {
|
||||
this.OnlineDevicesContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
@ -302,6 +323,238 @@ export struct DevicePage {
|
||||
.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_DEVICE_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_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%')
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备通道 =====================
|
||||
|
||||
@Builder
|
||||
GatewayContent() {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
|
||||
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
|
||||
this.KvRow('状态', 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(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 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;
|
||||
|
||||
@ -1,26 +1,24 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
||||
import { PluginListView } from '../components/PluginListView';
|
||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
||||
import { ToastBar } from '../components/ToastBar';
|
||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { SettingsEditor } from '../components/SettingsEditor';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -60,14 +58,134 @@ export struct PluginsPage {
|
||||
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> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const rows: PluginRow[] = await fetchPluginRows();
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
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,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
@ -81,6 +199,20 @@ 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> {
|
||||
const name: string = plugin.name;
|
||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||
@ -148,14 +280,29 @@ export struct PluginsPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情数据落地。
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||
*/
|
||||
private async loadDetail(name: string): Promise<void> {
|
||||
this.detailLoading = true;
|
||||
this.detailError = '';
|
||||
try {
|
||||
const d: PluginDetail = await fetchPluginDetail(name);
|
||||
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 = this.strArray(o['tags']);
|
||||
d.files = this.strArray(o['files']);
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
@ -180,6 +327,21 @@ 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 {
|
||||
for (let i = 0; i < this.plugins.length; i++) {
|
||||
if (this.plugins[i].name === name) {
|
||||
@ -225,15 +387,8 @@ export struct PluginsPage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
PluginListView({
|
||||
plugins: this.plugins,
|
||||
busy: this.loading,
|
||||
activeName: this.activeName,
|
||||
hasConn: apiClient.hasConnection(),
|
||||
onSelect: (name: string) => {
|
||||
this.openDetail(name);
|
||||
},
|
||||
})
|
||||
this.ListStates()
|
||||
this.PluginList()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -256,15 +411,7 @@ export struct PluginsPage {
|
||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||
NavFloatOverlay({ tab: 1 }) {
|
||||
if (this.showInstallForm) {
|
||||
PluginInstallForm({
|
||||
url: this.installUrl,
|
||||
onUrlChange: (v: string) => {
|
||||
this.installUrl = v;
|
||||
},
|
||||
onInstall: () => {
|
||||
this.installPlugin();
|
||||
},
|
||||
})
|
||||
this.InstallForm()
|
||||
}
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
@ -294,7 +441,7 @@ export struct PluginsPage {
|
||||
}
|
||||
}
|
||||
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
||||
this.Toast()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@ -346,29 +493,503 @@ export struct PluginsPage {
|
||||
this.loadDetail(this.activeName);
|
||||
},
|
||||
}) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
})
|
||||
this.DetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.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
@ -9,56 +9,30 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
|
||||
```
|
||||
HomeAgent/
|
||||
├── AppScope/ 应用级配置与图标
|
||||
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
|
||||
├── entry/src/main/
|
||||
│ ├── ets/
|
||||
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ ├── UserError.ets 错误转人类可读文案
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
|
||||
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
|
||||
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
|
||||
│ │ │ ├── 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 解析(块/行内/表格)
|
||||
│ │ ├── common/ 通信与全局状态
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
|
||||
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||
│ │ │ ├── ConnStore.ets 连接配置持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ └── UserError.ets 错误转人类可读文案
|
||||
│ │ ├── components/ 可复用组件
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
|
||||
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
|
||||
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
|
||||
│ │ │ ├── ChatToolCard.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 统一按压反馈与入场动画
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器
|
||||
│ │ │ └── GradientBackground.ets
|
||||
│ │ ├── model/Model.ets 共享类型定义
|
||||
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
|
||||
│ │ ├── pages/ 页面
|
||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||
│ │ │ ├── ChatPage.ets 对话
|
||||
│ │ │ ├── DevicePage.ets 设备
|
||||
@ -71,30 +45,11 @@ HomeAgent/
|
||||
└── 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/)。
|
||||
本工程用 `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` 含密码明文,未入库):
|
||||
|
||||
```bash
|
||||
@ -109,27 +64,19 @@ HomeAgent/
|
||||
2. **构建 HAP**:
|
||||
|
||||
```bash
|
||||
cd cmd/ohos/HomeAgent
|
||||
# ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位,
|
||||
# 会报 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
|
||||
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||
/path/to/command-line-tools/bin/hvigorw \
|
||||
--mode module -p module=entry@default assembleHap --no-daemon
|
||||
```
|
||||
|
||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||
|
||||
3. **安装到设备**(`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**,
|
||||
否则下面这个路径不存在):
|
||||
3. **安装到设备**:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
首次启动在「设置」里填:
|
||||
@ -150,19 +97,3 @@ HomeAgent/
|
||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
||||
|
||||
## 改动后的运行时验证清单
|
||||
|
||||
构建通过只能证明编译期没问题;ArkUI 的状态绑定、过渡动画与手势行为
|
||||
必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
|
||||
|
||||
- [ ] 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
|
||||
- [ ] 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
|
||||
- [ ] 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
|
||||
- [ ] 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
|
||||
- [ ] 进插件列表与插件详情,确认状态色、开关与卸载正常
|
||||
- [ ] 进出设备页四个二级页,确认授权开关与在线设备列表正常
|
||||
- [ ] 宽屏(>=600vp)下确认左右分栏、返回手势与返回键行为
|
||||
|
||||
模拟器在无图形/无提权环境里可能起不来(需要写 `~/.Huawei` 等宿主目录),
|
||||
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
#!/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 GUI(HAS_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 payload(build/homeagent-gui-win32-x64/*.exe)" >&2
|
||||
echo " 本机无 electron-builder:npm 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.exe(Windows 侧 CLI;CGO 关闭,跨平台安全)"
|
||||
( 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=$VERSION(nsi: $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 里)。"
|
||||
@ -1,248 +0,0 @@
|
||||
# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
|
||||
|
||||
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10,
|
||||
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
|
||||
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
|
||||
>
|
||||
> 原始状态(修复前):**待修复**
|
||||
> 影响:Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`,80+ 次工具调用)
|
||||
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限),QQ 插件侧已是最小回执,改插件无效**
|
||||
|
||||
---
|
||||
|
||||
## 1. 现象
|
||||
|
||||
生产日志(`/home/newqqagent`,homed 运行期):
|
||||
|
||||
```
|
||||
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:32:06 ...
|
||||
18:32:14 ...
|
||||
(每 8~12 秒一条,payload 长度各异:387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…)
|
||||
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
|
||||
```
|
||||
|
||||
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
|
||||
- 不是 webhook 回声(15 分钟内只有 1 条真实入站中断)。
|
||||
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
|
||||
|
||||
---
|
||||
|
||||
## 2. 根因链(核心侧,三层)
|
||||
|
||||
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go`(`handleChannelOutput` 尾部):
|
||||
|
||||
```go
|
||||
if sendErr != nil {
|
||||
return nil, sendErr
|
||||
}
|
||||
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
|
||||
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
|
||||
return "ok", nil
|
||||
```
|
||||
|
||||
插件返回的是字符串 `"ok"`。
|
||||
|
||||
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
|
||||
|
||||
`internal/plugin/proc/plugin.go:336`(`(*Plugin).invokeOutput`):
|
||||
|
||||
```go
|
||||
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
|
||||
if err != nil { return nil, err }
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "sent"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object,落到这里
|
||||
}
|
||||
if _, ok := res["status"]; !ok {
|
||||
res["status"] = "sent" // ← 再兜底
|
||||
}
|
||||
return res, nil
|
||||
```
|
||||
|
||||
插件返回 `"ok"` → `json.Unmarshal` 进 `map[string]interface{}` 失败 → 核心合成 `{status: sent}`。
|
||||
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
|
||||
|
||||
### 2.3 核心把这个富回执喂给模型
|
||||
|
||||
`internal/agent/core/output.go:81`(HEAD / 部署中的 homed 行为):
|
||||
|
||||
```go
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
|
||||
// → "已通过 [qq] 通道发送: map[status:sent]"
|
||||
```
|
||||
|
||||
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
|
||||
|
||||
### 2.4 核心没有工具轮次硬上限(放大器)
|
||||
|
||||
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**:
|
||||
|
||||
```
|
||||
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
|
||||
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
|
||||
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
|
||||
```
|
||||
|
||||
`internal/agent/core/process.go:242` 的唯一终止条件是:
|
||||
|
||||
```go
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
```
|
||||
|
||||
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
|
||||
|
||||
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
|
||||
|
||||
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**:
|
||||
|
||||
```go
|
||||
for turn := 0; ; turn++ {
|
||||
for _, interrupt := range a.drainInterrupts() { ... }
|
||||
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
})
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`msgs` 于 `process.go:32` 在循环**外**创建,循环内只增不减:
|
||||
|
||||
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
|
||||
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`;
|
||||
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
|
||||
|
||||
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
|
||||
|
||||
**预期行为**:占位消息应当(a)仅在没有尾部 user 消息时补一条,或(b)补之前先移除上一条同类占位,保持至多一条;绝不能线性累积。
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有未完成/未部署的修复
|
||||
|
||||
| 文件 | 状态 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)` → `return "ok"`(含解释回声的注释) |
|
||||
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
|
||||
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
|
||||
|
||||
运行中的 `homed` 是 **Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
|
||||
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
### 4.1 (必须)让模型只看到最小回执
|
||||
|
||||
**方案 A(最小改动,已在工作区)**:`internal/agent/core/output.go`
|
||||
|
||||
```go
|
||||
// internal/agent/core/output.go:81
|
||||
// 成功回执:只返回极简标记,不回传完整插件响应。
|
||||
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
|
||||
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
|
||||
return "ok"
|
||||
```
|
||||
|
||||
**方案 B(同时修掉 proc 桥的伪造)**:`internal/plugin/proc/plugin.go:336`
|
||||
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
|
||||
|
||||
```go
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
|
||||
var scalar interface{}
|
||||
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
|
||||
return scalar, nil
|
||||
}
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
```
|
||||
|
||||
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map),语义正确。
|
||||
|
||||
### 4.2 (必须)工具循环硬上限
|
||||
|
||||
在 `internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`:
|
||||
|
||||
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
|
||||
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N,请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
|
||||
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
|
||||
|
||||
### 4.3 (建议)QQ 插件侧保持最小回执
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go` 的 `return "ok", nil` 是正确的,保留即可。
|
||||
**不要**再依赖插件侧修这个回声——见 §2.2。
|
||||
|
||||
### 4.4 (必须)修掉每轮重复追加的 user 占位
|
||||
|
||||
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
|
||||
|
||||
```go
|
||||
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
|
||||
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: continuationPlaceholder,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
|
||||
|
||||
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署前提与步骤
|
||||
|
||||
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
|
||||
|
||||
1. **确认工具链协议与要部署的 homed 一致**
|
||||
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
|
||||
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK(`third_party/homeagent-sdk` HEAD)重建全部插件。
|
||||
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`。
|
||||
2. **构建 homed**:`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
|
||||
3. **重启**:`systemctl restart homeagent.service`
|
||||
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
修复后,发一条会触发回复的 QQ 消息,应满足:
|
||||
|
||||
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`;
|
||||
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**;
|
||||
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
|
||||
4. `eventloop.go:426` 的 `text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
|
||||
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条(N=轮数),这正是 §2.5 的判据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关背景(避免误修)
|
||||
|
||||
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`),**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
|
||||
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
|
||||
- `internal/plugin/proc/plugin.go:122` 的 `invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。
|
||||
@ -1,6 +1,6 @@
|
||||
# Git 分支管理规范
|
||||
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七),2026-09-13 修订(发布线路牌随 patch 推进 + 版本号不得固化落库)。
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七)。
|
||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
||||
|
||||
@ -63,11 +63,6 @@ main ──────────────── E ────────
|
||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
||||
- **从 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.1–1.3.4 四个 patch 都是这么打的,`release/v1.3.x` 的路牌一直没动)。
|
||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||
|
||||
### 4. 三级发布通道(alpha / beta / 正式)
|
||||
@ -167,50 +162,36 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 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`)。
|
||||
**"插值"指的是每次组装时现算,不是把算好的结果固化进配置库/文档** ——
|
||||
固化过的版本号与写死没有区别,而且更难发现。
|
||||
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-13 更新)
|
||||
## 三、当前分支对齐(2026-09-12 更新)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 **`1.4.0`** —— `1.3.0` 已归发布线所有) | ✅ 保持 |
|
||||
| `release/v1.3.x` | **本条发布线**,`meta.Version` = **`1.3.6`**(该线最后一个 patch);承载 `v1.3.1`…`v1.3.6`;vendored SDK 定版 `1.3.0` | ✅ 保持 |
|
||||
| `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` | 📦 已退役,保留供追溯 |
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0`) | ✅ 保持 |
|
||||
| `release/v1.2.x` | **本条发布线**,`meta.Version` = `1.2.0`,vendored SDK 定版 `1.2.0`;已载入两个发布前修复(GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役(§2.6:下个中版本发布即退役),保留供追溯 |
|
||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||
| `feature/multimodal-embedding` | 已合入 main(`eb4762a`,43 提交,`--no-ff`) | ⏳ 待删(删远端分支需用户确认,§执行守则 3) |
|
||||
|
||||
> - `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`)
|
||||
> 均已合入并删除。
|
||||
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.4.0`**)——`1.3.0` 已随核心**正式** tag 定版(§七.3),故路牌推进 | ✅ 保持 |
|
||||
| `release/v1.3.x` | `meta.Version` = `1.3.0`,承载 tag `v1.3.0`(5 平台 `hmapdev` + `SHA256SUMS` + 4 个源码归档) | ✅ 与核心对应 |
|
||||
| `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` | ✅ 与核心对应(已退役) |
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**)——SDK **不跟 beta 发版**(§七.2),1.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main(`1.3.0`)**故意不对称**,详见 §七.4 | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`;beta 阶段不发 SDK) |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
|
||||
> ❗**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 | 提交 | 通道 | 说明 |
|
||||
@ -361,79 +342,11 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
||||
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
||||
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
||||
4. 上传 5 平台 `hmapdev`(插件开发工具链)产物 + `SHA256SUMS`。
|
||||
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
|
||||
|
||||
同一中版本内的后续核心 patch(1.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.1–1.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` 是**下一个未发布中版本**。
|
||||
|
||||
@ -128,29 +128,6 @@
|
||||
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。
|
||||
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
|
||||
|
||||
**已落地(N3):子的 io 通道视图 = 对父的**实时回退**
|
||||
|
||||
输出通道在 io 层就是 `Device`,而它们由插件登记在**父**的 `IOManager` 上。驻留子确实该有
|
||||
自己的输入入口与 `outputCh`(所以子是 `NewIOManager()`),但**必须继承父的设备视图** ——
|
||||
否则上面 §4.4 的授权集合形同虚设:`output_list_channels` 为空、`output_send__X`
|
||||
连工具都不生成、按名字直调也会被判「通道不存在或不可用」。
|
||||
|
||||
落地方式:`IOManager.SetParentIO(父)`(子创建时挂上)。
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `GetDevice` / `GetChannelCapabilities` | 自己设备表没有时回退到父 |
|
||||
| `ListChannels` | 自己的登记优先(可覆盖/屏蔽同名通道),其次并入父的可见通道,按**名字**去重 |
|
||||
| `ExecuteTool` | 自己的设备工具没有时回退到父 |
|
||||
|
||||
- **为什么是实时回退、不是创建时复制快照**:设备随资源生灭(远程设备上线/掉线以分钟计,
|
||||
现场日志 60 秒一个来回),快照转瞬即过期;回退永远与父一致(父注销后子立刻看不见)。
|
||||
- **回退只解决"看得见",不解决"能不能用"**:§4.4 的三处过滤点仍然在白名单之后,
|
||||
授权收窄不受影响(判据:白名单外的通道既不出现在工具表里,直调也被拒且不送达)。
|
||||
- 现场缺陷(2026-09-13 用户线上联调实录):**父侧通道装载完整、子侧 `childIO` 空壳**,
|
||||
子调 `output_send__cli` 被判「通道 [cli] 不存在或不可用」。修复后线上实测:子调
|
||||
`output_send__cli` → `result: ok`,并经 `notify_parent` 回报。
|
||||
|
||||
### 4.5.1 通道的一等化(后续要求)
|
||||
|
||||
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**,
|
||||
|
||||
@ -325,7 +325,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
@ -376,14 +376,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
|
||||
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
|
||||
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
|
||||
if a.io != nil {
|
||||
a.io.SetInputRouter(a.routeInputByOwner)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
|
||||
@ -404,30 +396,9 @@ func (a *Agent) Start() {
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
// (设计 §7 I5、§11.3 X2/X4)。必须在 cancel 之前做:cancel 会让调度器直接 return。
|
||||
a.drainPendingInterrupts("agent_stopped")
|
||||
a.cancel()
|
||||
}
|
||||
|
||||
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
|
||||
// skipped 终态(复用 emitSkippedReply:非阻塞写,不对外发 agent_output 事件)。
|
||||
func (a *Agent) drainPendingInterrupts(reason string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
pending := a.sched.pendingEvents()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, evt := range pending {
|
||||
a.emitSkippedReply(evt, reason)
|
||||
}
|
||||
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
|
||||
}
|
||||
|
||||
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||
//
|
||||
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||
|
||||
@ -331,20 +331,13 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
if evt.ResponseCh != nil {
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次
|
||||
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
|
||||
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
log.Printf("[agent] ResponseCh 已满,终态回执被丢弃(request=%s,可能违反不变量 I5)", evt.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,13 +366,20 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
//
|
||||
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
|
||||
//
|
||||
// 实际执行交由 memoryPass(与召回共用入口、query、预算与审计)。
|
||||
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if !a.pruneDeclared(evt) {
|
||||
if a.context == nil || !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false).Archived
|
||||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||||
if a.isLightKernel() {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
return a.context.Prune(cleanInput, topK, a.docStore)
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
@ -398,29 +398,6 @@ func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// recallDeclared 判定这次输入是否要触发记忆召回(注入)。
|
||||
//
|
||||
// 与 pruneDeclared **正交**:prune 管“踢出去”(归档低相关 L0 事件),
|
||||
// recall 管“取进来”(把 L2/L3 相关记忆注入本轮)。
|
||||
//
|
||||
// 默认值与 prune 刻意相反:召回是只读增量、日常对话本就需要,所以**默认 auto**;
|
||||
// 只有显式声明 recall_policy=none(如中断通知的 meta 文本)才关闭。
|
||||
// 优先级同 prune:注入点(payload)> 通道(ChannelDef)> 默认 auto。
|
||||
func (a *Agent) recallDeclared(evt *agentIO.InputEvent) bool {
|
||||
if evt == nil {
|
||||
return true
|
||||
}
|
||||
if p, ok := evt.Payload["recall_policy"].(string); ok && p != "" {
|
||||
return p != pubsdk.RecallPolicyNone
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.RecallPolicy != "" {
|
||||
return chDef.RecallPolicy != pubsdk.RecallPolicyNone
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
|
||||
//
|
||||
// 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道
|
||||
|
||||
@ -139,9 +139,6 @@ func policySuffix(ch agentIO.InputChannel) string {
|
||||
if ch.Def.ContextPolicy != "" && ch.Def.ContextPolicy != "none" {
|
||||
m = append(m, "裁剪:"+ch.Def.ContextPolicy)
|
||||
}
|
||||
if ch.Def.RecallPolicy != "" && ch.Def.RecallPolicy != "auto" {
|
||||
m = append(m, "召回:"+ch.Def.RecallPolicy)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// routeInputByOwner 实现**输入路由**:inputch 是最基本的输入路由单位,
|
||||
// 划给某个 agent 之后,该通道的输入**只流向那个 agent**,本内核看不到它
|
||||
// (docs/zh/resident-subagent-design.md §4.1:「路由发生在进内核之前」)。
|
||||
//
|
||||
// 为什么必须在进内核之前做:插件注入输入的收口是**父**的 IOManager
|
||||
// (`cmd/homed` 里 pluginReg 拿到的就是它),而父的内核是该 io 唯一的消费者。
|
||||
// 如果不按归属路由,登记表里的 Owner 就只是个标签 —— 现场表现正是如此:
|
||||
// 子挂着 `inputch=[timer]`,timer 的输入却打在父身上,子的轮次永远是 0。
|
||||
//
|
||||
// 返回 true = 本次注入已被"持有该 inputch 的 agent"接管,本内核不再处理。
|
||||
//
|
||||
// 已知边界:路由只在本 agent 的**直接**驻留子里找。若孙辈的 inputch 由子划拨,
|
||||
// 而插件注入打在根 io 上,根解析不到那个 owner ⇒ 兜底给根处理(有日志)。
|
||||
// 这一层要等"孙辈 + 根可见的 agent 表"再收口,此处不静默丢输入。
|
||||
func (a *Agent) routeInputByOwner(evt *agentIO.InputEvent, isInterrupt bool) bool {
|
||||
if evt == nil || evt.OutputChannel == "" || a.io == nil {
|
||||
return false
|
||||
}
|
||||
entry, ok := a.io.LookupInputChannel(evt.OutputChannel)
|
||||
if !ok || entry.Owner == "" || entry.Owner == string(a.id) {
|
||||
return false // 未分配 / 归自己 ⇒ 本内核处理
|
||||
}
|
||||
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[entry.Owner]
|
||||
a.residentMu.Unlock()
|
||||
if rc == nil || rc.agent == nil || rc.agent.io == nil {
|
||||
// 归属到一个不存在(或已销毁、登记表尚未归还)的 agent:
|
||||
// **不吞输入** —— 由本内核兜底处理并留痕。吞掉一条输入比多处理一条更糟:
|
||||
// 用户会看到"消息发出去了却没人理",而日志里什么都没有。
|
||||
log.Printf("[route] inputch %s 归属 %s 无对应 agent,输入由 %s 兜底", evt.OutputChannel, entry.Owner, a.id)
|
||||
return false
|
||||
}
|
||||
rc.agent.io.DeliverRouted(evt, isInterrupt)
|
||||
return true
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 输入路由是**独占**的:inputch 划给子之后,该通道的输入只流向子,父不再收到。
|
||||
//
|
||||
// 现场缺陷(用户线上联调实录):子挂着 inputch=[timer],timer 的输入却打在父身上
|
||||
// (日志 `[agent] interrupt from timer/timer`),子的轮次永远是 0 —— 因为
|
||||
// `Assign` 只把 Owner 写进登记表,注入路径根本没有按归属路由。
|
||||
func TestResident_InputchRoutingIsExclusive(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
reg := parent.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spawnTestResident(t, parent, dir, "r-route", "sub/in")
|
||||
child := parent.residents["r-route"].agent
|
||||
|
||||
before := parent.DumpScheduler().Stats.Enqueued
|
||||
// 插件往"已划给子"的 inputch 投输入
|
||||
parent.io.InjectTextTo("plugin-sub", "sub/in", "去查一下这个")
|
||||
|
||||
waitFor(t, "子处理了划给它的输入", func() bool {
|
||||
if child.DumpScheduler().Stats.Executed > 0 {
|
||||
return true
|
||||
}
|
||||
return parent.residents["r-route"].info().TableSize > 0
|
||||
})
|
||||
if got := parent.DumpScheduler().Stats.Enqueued; got != before {
|
||||
t.Fatalf("划给子的 inputch,父不应再入队(before=%d after=%d)", before, got)
|
||||
}
|
||||
// 轮次必须真的涨:此前 info() 根本没填 Rounds ⇒ 父永远读到 0
|
||||
// (现场:子处理表已有 2 条,轮次却显示 0,被误判成"子没干活")。
|
||||
if got := parent.residents["r-route"].info().Rounds; got <= 0 {
|
||||
t.Fatalf("子处理的轮次应 > 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 归属到一个不存在(或已销毁)的 agent 时**不吞输入**:父兜底处理。
|
||||
// 吞掉一条输入比多处理一条更糟 —— 用户会看到"消息发出去了却没人理",日志里什么都没有。
|
||||
func TestResident_InputchRoutingFallsBackWhenOwnerMissing(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
reg := parent.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "ghost/in", Plugin: "ghost"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 故意划给一个不存在的 agent id
|
||||
if err := reg.Assign("ghost/in", "no-such-agent", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = dir
|
||||
|
||||
before := parent.DumpScheduler().Stats.Enqueued
|
||||
parent.io.InjectTextTo("plugin-ghost", "ghost/in", "兜底测试")
|
||||
waitFor(t, "父兜底处理了无人认领的输入", func() bool {
|
||||
return parent.DumpScheduler().Stats.Enqueued > before
|
||||
})
|
||||
}
|
||||
|
||||
// 未划拨的 inputch(Owner 为空)仍然由父处理 —— 路由不能把默认路径也改掉。
|
||||
func TestResident_UnassignedInputchStaysWithParent(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
reg := parent.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "own/in", Plugin: "own"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造一个子在跑,确保路由逻辑是"有子存在"的情形
|
||||
spawnTestResident(t, parent, dir, "r-other", "own/in")
|
||||
_ = filepath.Join(dir, "residents")
|
||||
|
||||
// 把通道退还给父(未分配)
|
||||
if err := reg.Assign("own/in", "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := parent.DumpScheduler().Stats.Enqueued
|
||||
parent.io.InjectTextTo("plugin-own", "own/in", "还是我的")
|
||||
waitFor(t, "未分配的 inputch 仍由父处理", func() bool {
|
||||
return parent.DumpScheduler().Stats.Enqueued > before
|
||||
})
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
package core
|
||||
|
||||
import "log"
|
||||
|
||||
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
|
||||
type memoryPassOut struct {
|
||||
// Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。
|
||||
Archived int
|
||||
// RecallText 是可注入 prompt 的记忆索引文本(recall 的输出,空串表示无)。
|
||||
RecallText string
|
||||
}
|
||||
|
||||
// memoryPass 是「取进来(召回)」与「踢出去(裁剪)」的**唯一入口**。
|
||||
//
|
||||
// prune 与 recall 是两根正交的声明轴(默认值刻意相反:裁剪是破坏性的、
|
||||
// 默认关;召回是只读增量、默认开),但两者都建立在**同一份清洗后的 query**
|
||||
// 之上。调用点只负责解析声明,这里统一做三件各写一遍就会写歪的事:
|
||||
//
|
||||
// 1. 同一 query:prune 与 recall 用同一个查询向量来源,避免「裁错事件、
|
||||
// 召回错记忆」——原始内容里的 ANSI/base64/JSON 噪声会把相关性打分带偏。
|
||||
// 2. 同一次预算:召回文本按 token 预算截断只做一次(见 recallText)。
|
||||
// 3. 同一条审计:谁(trigger)据什么触发了哪种操作都落一条日志,
|
||||
// 否则又是一个「幕后发生、查不出是谁」的机制(对齐 prune 的设计初衷)。
|
||||
//
|
||||
// 边界:prune 与 recall 目前仍走**各自的相关性空间**(prune 用 L0 事件的
|
||||
// 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的
|
||||
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
|
||||
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
|
||||
func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPassOut {
|
||||
var out memoryPassOut
|
||||
if a == nil || (!prune && !recall) {
|
||||
return out
|
||||
}
|
||||
if prune {
|
||||
out.Archived = a.pruneByQuery(query)
|
||||
}
|
||||
if recall && query != "" {
|
||||
out.RecallText = a.recallTextFor(query, trigger)
|
||||
}
|
||||
if out.Archived > 0 || out.RecallText != "" {
|
||||
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
|
||||
trigger, out.Archived, len(out.RecallText))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pruneByQuery 按相关性把低相关 L0 事件归档进文档记忆,返回归档数。
|
||||
//
|
||||
// **不做声明判定**——声明已由调用方(pruneOnInput / stepToolAfter)解析,
|
||||
// 这里只负责执行。放在 memoryPass 内部是为了让裁剪与召回共享入口。
|
||||
func (a *Agent) pruneByQuery(query string) int {
|
||||
if a == nil || a.context == nil {
|
||||
return 0
|
||||
}
|
||||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||||
if a.isLightKernel() {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
return a.context.Prune(query, topK, a.docStore)
|
||||
}
|
||||
@ -1,107 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。
|
||||
//
|
||||
// 这一组测试锁死三件事:
|
||||
// 1. 两个策略都不声明时是 no-op(不裁剪、不召回);
|
||||
// 2. 同时声明时一次调用同时产出「归档数」与「召回文本」;
|
||||
// 3. 单一策略只产出对应的那一个输出(正交,不互相触发)。
|
||||
func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
|
||||
a := &Agent{
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
out := a.memoryPass("咖啡", "test", false, false)
|
||||
if out.Archived != 0 || out.RecallText != "" {
|
||||
t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPass_PruneAndRecallTogether(t *testing.T) {
|
||||
a := newMemoryPassAgent(t)
|
||||
before := a.context.Len()
|
||||
out := a.memoryPass("咖啡", "tool:test", true, true)
|
||||
if out.Archived == 0 {
|
||||
t.Fatal("声明 prune 应归档低相关事件")
|
||||
}
|
||||
if a.context.Len() >= before {
|
||||
t.Fatalf("裁剪后上下文应变短:%d → %d", before, a.context.Len())
|
||||
}
|
||||
if !strings.Contains(out.RecallText, "【记忆索引】") {
|
||||
t.Fatalf("声明 recall 应产出记忆索引文本,实际 %q", out.RecallText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
|
||||
// 只裁不召回:输出只有归档数。
|
||||
onlyPrune := &Agent{
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
if out := onlyPrune.memoryPass("咖啡", "test", true, false); out.RecallText != "" {
|
||||
t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText)
|
||||
}
|
||||
// 只召回不裁剪:输出只有召回文本,上下文条数不变。
|
||||
onlyRecall := &Agent{
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
before := onlyRecall.context.Len()
|
||||
out := onlyRecall.memoryPass("咖啡", "test", false, true)
|
||||
if out.Archived != 0 {
|
||||
t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived)
|
||||
}
|
||||
if onlyRecall.context.Len() != before {
|
||||
t.Fatalf("只声明 recall 不应改变上下文条数:%d → %d", before, onlyRecall.context.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// 输入侧召回必须用**清洗后**的 query(通道 Cleaner 的输出),与裁剪侧一致。
|
||||
// 用无关原文 + 命中清洗文本做区分,锁死「用的是 CleanInput 而不是 Input」。
|
||||
func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) {
|
||||
a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")}
|
||||
|
||||
// CleanInput 命中实体、原文完全不相关 → 应召回(证明用了清洗文本)。
|
||||
f := &TaskFrame{Evt: nil, CleanInput: "咖啡"}
|
||||
if got := a.buildTaskMemoryContext(f, "zzz", 0); !strings.Contains(got, "【记忆索引】") {
|
||||
t.Fatalf("应据清洗后的 query 召回,实际 %q", got)
|
||||
}
|
||||
// 清洗为空 → 回退原文;原文无关则不召回。
|
||||
f2 := &TaskFrame{CleanInput: ""}
|
||||
if got := a.buildTaskMemoryContext(f2, "zzz", 0); got != "" {
|
||||
t.Fatalf("清洗为空且原文无关时不应召回,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newMemoryPassAgent 造一个同时能做裁剪与召回的 agent(含 doc 记忆落点)。
|
||||
func newMemoryPassAgent(t *testing.T) *Agent {
|
||||
t.Helper()
|
||||
return &Agent{
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
|
||||
}
|
||||
}
|
||||
|
||||
// newPruneableContext 造 n 条可被裁剪的上下文(最近 10 条受保护)。
|
||||
func newPruneableContext(n int) *RelevanceContext {
|
||||
ctx := NewRelevanceContext("", memory.NewStaticEmbedder(""))
|
||||
for i := 0; i < n; i++ {
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "事件内容"})
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@ -13,13 +13,8 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
channel := strings.TrimPrefix(tc.Name, "output_send__")
|
||||
payload, _ := tc.Arguments["payload"].(string)
|
||||
rawType, _ := tc.Arguments["type"].(string)
|
||||
if channel == "" || payload == "" {
|
||||
return "工具名称格式: output_send__{channel},payload 不能为空"
|
||||
}
|
||||
// type 缺省按 text 处理:绝大多数输出就是文本,让模型为"省略一个默认值"付一次
|
||||
// 失败重试没有意义(判据该拦的是"不知道发什么",不是"没写众所周知的默认值")。
|
||||
if rawType == "" {
|
||||
rawType = "text"
|
||||
if channel == "" || payload == "" || rawType == "" {
|
||||
return "工具名称格式: output_send__{channel},payload 和 type 不能为空"
|
||||
}
|
||||
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
|
||||
if !a.IsOutputAllowed(channel) {
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// 设计口径:输出是 agent 的**主动调用** —— 收到一次输入后,可以往任意(已授权的)
|
||||
// 通道发**任意多次**(分段播报、先回执后结论、同时通知多个通道都合法)。
|
||||
//
|
||||
// 这条判据钉住的是"提示词里不得出现输出次数限制"。此前 `tooldefs.go` 里写着
|
||||
// 「每轮对话通常只需调用一次 output_send__{通道名} 即可完成回复」—— 一条凭空的限制,
|
||||
// 会让模型自己收起合理的多次输出(用户现场指出)。
|
||||
func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) {
|
||||
parent, _, _ := newRootForResidents(t)
|
||||
defer parent.Stop()
|
||||
|
||||
prompt := parent.buildSystemPrompt("", "你好")
|
||||
banned := []string{
|
||||
"只需调用一次",
|
||||
"只能调用一次",
|
||||
"通常只需调用",
|
||||
"不要重复发送",
|
||||
}
|
||||
for _, b := range banned {
|
||||
if strings.Contains(prompt, b) {
|
||||
t.Fatalf("系统提示词里仍有输出次数限制 %q —— 设计上次数不限", b)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(prompt, "输出次数与目标通道由你自己决定") {
|
||||
t.Fatal("系统提示词应明确「输出次数与目标通道由你自己决定」")
|
||||
}
|
||||
if !strings.Contains(prompt, "没有任何「一轮只能发一次」的限制") {
|
||||
t.Fatal("系统提示词应显式否认「一轮只能发一次」")
|
||||
}
|
||||
}
|
||||
|
||||
// 输出工具的 type 可省略,缺省按 text 处理(判据该拦的是"不知道发什么",
|
||||
// 不是"没写众所周知的默认值")。
|
||||
func TestOutputSendTypeDefaultsToText(t *testing.T) {
|
||||
parent, _, _ := newRootForResidents(t)
|
||||
defer parent.Stop()
|
||||
|
||||
dev := &outputTestDevice{name: "fakeout"}
|
||||
if err := parent.io.RegisterDevice(dev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := parent.executeOutputSendTool(agentAPI.ToolCall{
|
||||
Name: "output_send__fakeout",
|
||||
Arguments: map[string]interface{}{"payload": "只给 payload,不给 type"},
|
||||
})
|
||||
if out != "ok" {
|
||||
t.Fatalf("省略 type 时应默认 text 并发送成功,得到 %q", out)
|
||||
}
|
||||
if len(dev.sent) != 1 {
|
||||
t.Fatalf("通道应收到 1 次输出,得到 %d", len(dev.sent))
|
||||
}
|
||||
if args, _ := dev.sent[0]["args"].(map[string]interface{}); args["type"] != "text" {
|
||||
t.Fatalf("缺省类型应为 text,实际 %v", args["type"])
|
||||
}
|
||||
|
||||
// 工具 schema:required 只应含 payload
|
||||
var found bool
|
||||
for _, td := range parent.buildToolDefs() {
|
||||
entry, _ := td.(map[string]interface{})
|
||||
fn, _ := entry["function"].(map[string]interface{})
|
||||
if n, _ := fn["name"].(string); n != "output_send__fakeout" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
params, _ := fn["parameters"].(map[string]interface{})
|
||||
req, _ := params["required"].([]string)
|
||||
if len(req) != 1 || req[0] != "payload" {
|
||||
t.Fatalf("output_send 的 required 应只有 payload,实际 %v", req)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("未生成 output_send__fakeout 工具")
|
||||
}
|
||||
}
|
||||
|
||||
// 空 payload 仍应被拦(这条判据是对的:不知道发什么不能放过)。
|
||||
func TestOutputSendStillRequiresPayload(t *testing.T) {
|
||||
parent, _, _ := newRootForResidents(t)
|
||||
defer parent.Stop()
|
||||
|
||||
if err := parent.io.RegisterDevice(&outputTestDevice{name: "fakeout"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := parent.executeOutputSendTool(agentAPI.ToolCall{
|
||||
Name: "output_send__fakeout",
|
||||
Arguments: map[string]interface{}{"type": "text"},
|
||||
})
|
||||
if !strings.Contains(out, "payload 不能为空") {
|
||||
t.Fatalf("空 payload 应被拦,得到 %q", out)
|
||||
}
|
||||
}
|
||||
@ -81,29 +81,6 @@ func (a *Agent) toolOutputForQuery(toolName, raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// recallMsgMarker 是工具触发召回时注入的 system 消息前缀。
|
||||
// 用它做去重与替换的识别标(与用户/中断的 system 消息区分开)。
|
||||
const recallMsgMarker = "【记忆召回】"
|
||||
|
||||
// appendOrReplaceRecall 把一段召回文本作为 system 消息挂到消息末尾。
|
||||
//
|
||||
// 同一任务内多次触发(如模型多次调用 qq_get_message)时**替换**上一条召回,
|
||||
// 而不是累加:否则召回会线性叠进 prompt,把上下文与 token 预算越挤越紧。
|
||||
// 替换位置固定在末尾,不影响 tool/assistant 消息的配对。
|
||||
func appendOrReplaceRecall(msgs []agentAPI.Message, recallText string) []agentAPI.Message {
|
||||
if recallText == "" {
|
||||
return msgs
|
||||
}
|
||||
full := recallMsgMarker + "\n" + recallText
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "system" && strings.HasPrefix(msgs[i].Content, recallMsgMarker) {
|
||||
msgs[i].Content = full
|
||||
return msgs
|
||||
}
|
||||
}
|
||||
return append(msgs, agentAPI.Message{Role: "system", Content: full})
|
||||
}
|
||||
|
||||
// dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。
|
||||
//
|
||||
// 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减,
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||
)
|
||||
|
||||
func TestExpandPromptVars(t *testing.T) {
|
||||
got := expandPromptVars("型号 {{kernel_version}}({{kernel_commit}}),SDK {{sdk_version}}")
|
||||
if !strings.Contains(got, meta.Version) || !strings.Contains(got, sdkmeta.Version) {
|
||||
t.Fatalf("占位符未展开: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "{{") {
|
||||
t.Fatalf("仍有未展开的内置占位符: %q", got)
|
||||
}
|
||||
// 人格卡实测原文:写死了 v1.0.3,应能被占位符取代
|
||||
live := expandPromptVars("你是 HomeAgent 的看板娘「小宅」(Xiao Zhai),HΔ-Kernel v{{kernel_version}} 型号的家政型 AI 管家助手。")
|
||||
if strings.Contains(live, "1.0.3") || !strings.Contains(live, "v"+meta.Version) {
|
||||
t.Fatalf("人格卡版本未跟随内核: %q", live)
|
||||
}
|
||||
// 未知占位符必须原样保留(写错要看得见,不能被静默吞掉)
|
||||
if unk := expandPromptVars("版本 {{kernel_verison}}"); !strings.Contains(unk, "{{kernel_verison}}") {
|
||||
t.Fatalf("未知占位符被吞: %q", unk)
|
||||
}
|
||||
// 无占位符时原样返回(人格卡热路径,不做无谓拷贝)
|
||||
if plain := "无占位符"; expandPromptVars(plain) != plain {
|
||||
t.Fatal("无占位符时不应改写")
|
||||
}
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 这一组测试锁死「默认召回、可显式关闭」这条语义。
|
||||
//
|
||||
// 与 prune 刻意相反:裁剪是破坏性的、默认关;召回是只读增量、默认开。
|
||||
// 两者正交,一根 ContextPolicy 表达不了 2×2 的组合(只召回不裁剪 / 只裁不召回)。
|
||||
func TestRecallDeclared_DefaultsToRecall(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
|
||||
// 没有任何声明 → 默认召回(保持既有"每条输入都召回"的行为)。
|
||||
if !a.recallDeclared(&agentIO.InputEvent{Source: "unknown", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("未声明的输入默认必须召回")
|
||||
}
|
||||
// 通道注册了但没设 RecallPolicy → 仍默认召回。
|
||||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
|
||||
if !a.recallDeclared(&agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("ChannelDef 未设 RecallPolicy 应默认召回")
|
||||
}
|
||||
// nil 事件不能 panic,且按默认召回。
|
||||
if !a.recallDeclared(nil) {
|
||||
t.Fatal("nil 事件应默认召回")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallDeclared_ChannelOptOut(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||||
m.RegisterInputChannel("talk", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto})
|
||||
a := &Agent{io: m}
|
||||
|
||||
if a.recallDeclared(&agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("通道声明 none 不应召回")
|
||||
}
|
||||
if !a.recallDeclared(&agentIO.InputEvent{Source: "talk", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("通道声明 auto 应召回")
|
||||
}
|
||||
}
|
||||
|
||||
// 注入点声明优先于通道定义:同一通道下的不同注入可以有不同意图。
|
||||
func TestRecallDeclared_InjectionOverridesChannel(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
m.RegisterInputChannel("qq", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||||
|
||||
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{
|
||||
"recall_policy": pubsdk.RecallPolicyAuto,
|
||||
}}
|
||||
if !a.recallDeclared(evt) {
|
||||
t.Fatal("注入点声明 auto 应覆盖通道的 none")
|
||||
}
|
||||
|
||||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto})
|
||||
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
|
||||
"recall_policy": pubsdk.RecallPolicyNone,
|
||||
}}
|
||||
if a.recallDeclared(evt) {
|
||||
t.Fatal("注入点声明 none 应覆盖通道的 auto")
|
||||
}
|
||||
}
|
||||
|
||||
// buildTaskMemoryContext 在声明 none 时必须返回空串(不注入记忆索引)。
|
||||
func TestBuildTaskMemoryContext_RespectsPolicy(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||||
a := &Agent{io: m, indexer: newTestIndexer(t, "咖啡", "张三")}
|
||||
|
||||
f := &TaskFrame{Evt: &agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}}
|
||||
if got := a.buildTaskMemoryContext(f, "咖啡", 0); got != "" {
|
||||
t.Fatalf("声明 none 时不应注入记忆,实际 %q", got)
|
||||
}
|
||||
|
||||
f2 := &TaskFrame{Evt: &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}}
|
||||
if got := a.buildTaskMemoryContext(f2, "咖啡", 0); !strings.Contains(got, "【记忆索引】") {
|
||||
t.Fatalf("默认应注入记忆索引,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 工具触发的召回:以(清洗后的)工具输出为 query,产出可注入的记忆文本。
|
||||
func TestRecallTextFor_UsesQuery(t *testing.T) {
|
||||
a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")}
|
||||
got := a.recallTextFor("咖啡", "tool:test")
|
||||
if !strings.Contains(got, "【记忆索引】") {
|
||||
t.Fatalf("应产出记忆索引文本,实际 %q", got)
|
||||
}
|
||||
// 空 query 或无 indexer 时不产出、不 panic。
|
||||
if got := a.recallTextFor("", "tool:test"); got != "" {
|
||||
t.Fatalf("空 query 应返回空串,实际 %q", got)
|
||||
}
|
||||
if got := (&Agent{}).recallTextFor("咖啡", "tool:test"); got != "" {
|
||||
t.Fatalf("无 indexer 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 召回文本以 system 消息挂在末尾;同一任务内多次触发是**替换**而非累加。
|
||||
func TestAppendOrReplaceRecall(t *testing.T) {
|
||||
msgs := []agentAPI.Message{{Role: "user", Content: "hi"}}
|
||||
msgs = appendOrReplaceRecall(msgs, "第一段")
|
||||
if len(msgs) != 2 || msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "第一段") {
|
||||
t.Fatalf("首次应追加一条 system 召回消息,实际 %+v", msgs)
|
||||
}
|
||||
msgs = appendOrReplaceRecall(msgs, "第二段")
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("再次触发应替换而非累加,实际 %d 条", len(msgs))
|
||||
}
|
||||
if !strings.Contains(msgs[1].Content, "第二段") || strings.Contains(msgs[1].Content, "第一段") {
|
||||
t.Fatalf("替换后应只含最新召回,实际 %q", msgs[1].Content)
|
||||
}
|
||||
if msgs = appendOrReplaceRecall(msgs, ""); len(msgs) != 2 {
|
||||
t.Fatalf("空召回不应改变消息,实际 %d 条", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIndexer 造一个只含给定实体的图记忆 + 已同步的索引器。
|
||||
func newTestIndexer(t *testing.T, subject, object string) *memory.Indexer {
|
||||
t.Helper()
|
||||
db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if _, _, err := db.Commit([]memory.Triple{{Subject: subject, Relation: "喜欢", Object: object}}, "s", 0); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
idx := memory.NewIndexer(db)
|
||||
if err := idx.Sync(); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
@ -61,17 +61,6 @@ type ResidentInfo struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TableSize int `json:"table_size"`
|
||||
Table []InputchRecord `json:"table,omitempty"`
|
||||
|
||||
// Sched* 是这个驻留子**自己的**输入调度器积压摘要(排队 / 待处理中断 /
|
||||
// 中断栈 / 四级中断队列)。
|
||||
//
|
||||
// 为什么必须单列:每个驻留子是独立 agent,跑自己的事件循环与调度器
|
||||
// (见 agent.go 的 newScheduler)。KernelStatus.Scheduler 只是**根**那份,
|
||||
// 拿它代表全部子会让界面把「某个子堵死」显示成「一切正常」。
|
||||
SchedReady int `json:"sched_ready"`
|
||||
SchedPending int `json:"sched_pending"`
|
||||
SchedStack int `json:"sched_stack"`
|
||||
SchedQueues [5]int `json:"sched_queues"`
|
||||
}
|
||||
|
||||
type residentChild struct {
|
||||
@ -143,17 +132,11 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表,
|
||||
// 并把父的 io 挂成"上级"——**输出通道(io 里的 Device)由插件登记在父的 io 上**,
|
||||
// 子若不继承这张视图,`output_send__<通道>` 一律被判"通道不存在或不可用"、
|
||||
// `output_list_channels` 为空、连 `output_send__*` 工具都不会生成
|
||||
// (现场联调:父侧通道装载完整、子侧 childIO 空壳)。
|
||||
// 回退是实时的(设备随资源生灭),授权仍由 opts.AllowedOutputs 白名单把关。
|
||||
// ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表。
|
||||
childIO := agentIO.NewIOManager()
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
childIO.SetChannelRegistry(reg)
|
||||
}
|
||||
childIO.SetParentIO(a.io)
|
||||
|
||||
parentID := string(a.id)
|
||||
child := New(AgentConfig{
|
||||
@ -179,19 +162,6 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
|
||||
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
|
||||
parentInCh := a.residentInboundChannel(opts.ID)
|
||||
// 登记了入站 inputch 就必须有回滚路径:注册点与 residents 登记之间
|
||||
// 当前无可失败步骤,但一旦将来插入(或 child.Start 变更),早退会把这条
|
||||
// 登记留在共享表里——就是 child/<id> 残留那只 bug 的另一条入口。
|
||||
// defer 保证“只要没走到成功返回,就注销这条登记”。
|
||||
registered := false
|
||||
defer func() {
|
||||
if registered {
|
||||
return
|
||||
}
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
reg.Unregister(inboundChannelName(opts.ID))
|
||||
}
|
||||
}()
|
||||
child.notifyParent = func(text string) {
|
||||
a.io.InjectInterruptTextOpts(opts.ID, parentInCh, text,
|
||||
agentIO.InjectOptions{Priority: "L3"})
|
||||
@ -215,7 +185,6 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
|
||||
map[string]interface{}{"content": opts.TaskPrompt})
|
||||
}
|
||||
registered = true
|
||||
return rc.info(), nil
|
||||
}
|
||||
|
||||
@ -223,13 +192,8 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||
|
||||
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||
//
|
||||
// inboundChannelName 只拼名字,不产生副作用(注销路径要用它算出同一个名字,
|
||||
// 不能再去调 residentInboundChannel——那会顺手把刚摘掉的登记又写回去)。
|
||||
func inboundChannelName(childID string) string { return "child/" + childID }
|
||||
|
||||
func (a *Agent) residentInboundChannel(childID string) string {
|
||||
ch := inboundChannelName(childID)
|
||||
ch := "child/" + childID
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
// 归属父自己:它是父的入站 inputch。
|
||||
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
|
||||
@ -276,14 +240,6 @@ func (a *Agent) teardownResident(rc *residentChild) {
|
||||
for _, ch := range rc.inputChs {
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
// 注销"父接收该子消息"的入站 inputch(child/<id>)。
|
||||
//
|
||||
// 它由 residentInboundChannel 在 create 时登记(Owner=父),销毁时必须
|
||||
// 一并摘掉:登记表是共享的、按 name 全局唯一,残留会随 create/destroy
|
||||
// 次数单调累积脏数据。实测:destroy 后 child/<id> 仍挂在根 agent 名下,
|
||||
// 而外部没有任何工具能单独注销 inputch,只能重启 homed 清。
|
||||
// 注意用纯函数算名字,不要再走 residentInboundChannel(会重新登记)。
|
||||
reg.Unregister(inboundChannelName(rc.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -538,24 +494,10 @@ func (rc *residentChild) info() ResidentInfo {
|
||||
state, full := rc.state, rc.state == "contextfull"
|
||||
rc.mu.Unlock()
|
||||
table := rc.agent.inputchTableSnapshot()
|
||||
// 子自己的调度器积压(per-agent 负载展示用)。schedulerStatus 只读快照,
|
||||
// 每次状态轮询为每个子算一次,成本可忽略(驻留子数量是个位数)。
|
||||
sc := rc.agent.schedulerStatus()
|
||||
info := ResidentInfo{
|
||||
ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...),
|
||||
AllowedOutputs: append([]string(nil), rc.allowed...),
|
||||
ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table),
|
||||
SchedReady: sc.ReadyQueueDepth,
|
||||
SchedPending: sc.PendingInterrupts,
|
||||
SchedStack: sc.SuspendStack,
|
||||
SchedQueues: sc.InterruptQueues,
|
||||
// Rounds = 子**已执行的轮次数**(调度器的执行计数,单调不减)。
|
||||
//
|
||||
// 此前这里根本没填这个字段 ⇒ 父看到的永远是 `轮次=0`,与"处理表已有 N 条"
|
||||
// 自相矛盾(现场:子明明处理了两轮,父读到 rounds=0,误判成"子没干活")。
|
||||
// 注意它**不等于** len(table):处理表记的是"当前上下文窗口内"的轮次,
|
||||
// 压缩会清空(§8.3),所以窗口内的条数会被重置,而轮次总数不会。
|
||||
Rounds: rc.agent.roundsExecuted(),
|
||||
}
|
||||
if len(table) > 0 {
|
||||
info.Table = table
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// outputTestDevice 是最小的输出通道替身(io 里输出通道就是 Device)。
|
||||
type outputTestDevice struct {
|
||||
name string
|
||||
sent []map[string]interface{}
|
||||
}
|
||||
|
||||
func (d *outputTestDevice) Name() string { return d.name }
|
||||
func (d *outputTestDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *outputTestDevice) Description() string { return "测试输出通道" }
|
||||
func (d *outputTestDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *outputTestDevice) Start() error { return nil }
|
||||
func (d *outputTestDevice) Stop() error { return nil }
|
||||
func (d *outputTestDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||||
func (d *outputTestDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *outputTestDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
d.sent = append(d.sent, map[string]interface{}{"tool": tool, "args": args})
|
||||
return map[string]interface{}{"status": "sent"}, nil
|
||||
}
|
||||
|
||||
func outputSendTool(name, payload string) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{
|
||||
Name: "output_send__" + name,
|
||||
Arguments: map[string]interface{}{"payload": payload, "type": "text"},
|
||||
}
|
||||
}
|
||||
|
||||
// 驻留子必须能看见并使用**父**登记的输出通道。
|
||||
//
|
||||
// 现场缺陷(联调实录):父侧通道装载完整、子侧 childIO 空壳 ——
|
||||
// 子调 output_send__X 被 `GetChannelCapabilities` 判 0 ⇒
|
||||
// 「通道 [X] 不存在或不可用。可用输出工具列表见 output_list_channels」,
|
||||
// 而 output_list_channels 也是空的。根因是子的 io 是新建的、设备表为空,
|
||||
// 而输出通道(io 的 Device)由插件登记在父的 io 上。
|
||||
func TestResident_InheritsParentOutputChannels(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
defer parent.Stop()
|
||||
|
||||
fake := &outputTestDevice{name: "fakeout"}
|
||||
other := &outputTestDevice{name: "other"}
|
||||
if err := parent.io.RegisterDevice(fake); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := parent.io.RegisterDevice(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := parent.SpawnResident(ResidentOptions{
|
||||
ID: "r-out",
|
||||
TaskPrompt: "有情况就发到 fakeout",
|
||||
// 白名单只放行一个:验证"继承可见"不等于"绕过授权"
|
||||
AllowedOutputs: []string{"fakeout"},
|
||||
TempPath: filepath.Join(dir, "residents", "r-out", "graph.db"),
|
||||
}); err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
child := parent.residents["r-out"].agent
|
||||
|
||||
// ① 看得见:修复前这里是 0(childIO 空壳)
|
||||
if caps := child.io.GetChannelCapabilities("fakeout"); caps == 0 {
|
||||
t.Fatal("驻留子看不见父的输出通道(childIO 空壳)")
|
||||
}
|
||||
// ② 发得出去:真走 dev.Execute("output", ...)
|
||||
if out := child.executeOutputSendTool(outputSendTool("fakeout", "子发来的消息")); out != "ok" {
|
||||
t.Fatalf("子发送应成功,得到 %q", out)
|
||||
}
|
||||
if len(fake.sent) != 1 {
|
||||
t.Fatalf("父通道应收到 1 次输出,得到 %d", len(fake.sent))
|
||||
}
|
||||
|
||||
// ③ 授权闸不被回退绕过:白名单外的通道照样拒绝
|
||||
if out := child.executeOutputSendTool(outputSendTool("other", "越权")); !strings.Contains(out, "未授权") {
|
||||
t.Fatalf("白名单外的通道应被拒,得到 %q", out)
|
||||
}
|
||||
if len(other.sent) != 0 {
|
||||
t.Fatal("越权输出不应真的送达")
|
||||
}
|
||||
|
||||
// ④ 工具面一致:子应生成 output_send__fakeout(含配套 _help),
|
||||
// 而**不生成**白名单外通道的工具 —— 模型看不到就不会去调。
|
||||
var names []string
|
||||
for _, td := range child.buildToolDefs() {
|
||||
entry, _ := td.(map[string]interface{})
|
||||
fn, _ := entry["function"].(map[string]interface{})
|
||||
if n, _ := fn["name"].(string); strings.HasPrefix(n, "output_send__") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
has := func(want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("output_send__fakeout") || !has("output_send__fakeout_help") {
|
||||
t.Fatalf("子缺少授权通道的输出工具,得到 %v", names)
|
||||
}
|
||||
for _, n := range names {
|
||||
if strings.HasPrefix(n, "output_send__other") {
|
||||
t.Fatalf("白名单外的通道不该生成工具,得到 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 实时性:父之后新登记的通道,子立刻可见(设备随资源生灭)
|
||||
late := &outputTestDevice{name: "late"}
|
||||
if err := parent.io.RegisterDevice(late); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caps := child.io.GetChannelCapabilities("late"); caps == 0 {
|
||||
t.Fatal("父新登记的通道未实时反映到子(说明是快照而非回退)")
|
||||
}
|
||||
}
|
||||
|
||||
// 默认授权(AllowedOutputs 空)= 完整授权:子用父的全部输出通道。
|
||||
func TestResident_DefaultOutputsAreFull(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
defer parent.Stop()
|
||||
|
||||
dev := &outputTestDevice{name: "anywhere"}
|
||||
if err := parent.io.RegisterDevice(dev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parent.SpawnResident(ResidentOptions{
|
||||
ID: "r-full", TaskPrompt: "待命",
|
||||
TempPath: filepath.Join(dir, "residents", "r-full", "graph.db"),
|
||||
}); err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
child := parent.residents["r-full"].agent
|
||||
if out := child.executeOutputSendTool(outputSendTool("anywhere", "默认授权")); out != "ok" {
|
||||
t.Fatalf("默认应完整授权,得到 %q", out)
|
||||
}
|
||||
}
|
||||
@ -142,11 +142,6 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||||
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||||
}
|
||||
// 父的入站 inputch(child/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
|
||||
// HomeAgent 实测:destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
|
||||
if inbound, ok := reg.Lookup("child/child-1"); ok {
|
||||
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
|
||||
}
|
||||
if err := parent.DestroyResident("child-1"); err == nil {
|
||||
t.Fatal("重复销毁应报错")
|
||||
}
|
||||
@ -164,9 +159,6 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||||
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||||
}
|
||||
if _, ok := reg.Lookup(inboundChannelName(id)); ok {
|
||||
t.Fatalf("父退出后子 %s 的入站 inputch 应被注销", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -229,10 +229,6 @@ type SchedulerStats struct {
|
||||
Executed uint64
|
||||
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
|
||||
Rejected uint64
|
||||
// Backpressure 是就绪队列满、输入被挡回 channel 的次数
|
||||
// (设计 §4.4 / §11.4 Q4:满时阻塞发送方,**必须计数并打日志**)。
|
||||
// 与 Rejected 的区别:Rejected 是「丢了」,Backpressure 是「暂时不收、发送方在等」。
|
||||
Backpressure uint64
|
||||
// Suspended / Resumed 是挂起与恢复的次数。
|
||||
// 不变量:系统排空后 Suspended == Resumed(挂起必然被恢复),
|
||||
// 因此两者各自只在**一处**计数(suspend / resumeTask)。
|
||||
@ -283,46 +279,18 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||
PendingInterrupts: len(snap.PendingInterrupts),
|
||||
SuspendStack: len(snap.SuspendStack),
|
||||
MaxSuspendDepth: snap.MaxInterruptFrames,
|
||||
InterruptsByLevel: snap.Stats.InterruptsByLevel,
|
||||
PreemptsByLevel: snap.Stats.PreemptsByLevel,
|
||||
Enqueued: snap.Stats.Enqueued,
|
||||
Executed: snap.Stats.Executed,
|
||||
Rejected: snap.Stats.Rejected,
|
||||
Suspended: snap.Stats.Suspended,
|
||||
Resumed: snap.Stats.Resumed,
|
||||
Backpressure: snap.Stats.Backpressure,
|
||||
}
|
||||
// Preempted 是「各级抢占成功次数之和」,**不是** Suspended:受害者可能在
|
||||
// 让位信号生效前就自行结束,此时有抢占而没有挂起(见 PreemptsByLevel 注释)。
|
||||
// 此前这里直接拿 Suspended 顶替,导致 DTO 里 preempted 与 preempts_by_level 自相矛盾。
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
out.Preempted += snap.Stats.PreemptsByLevel[lv]
|
||||
Preempted: snap.Stats.Suspended,
|
||||
}
|
||||
if snap.Running != nil {
|
||||
out.Running = &sdk.SchedulerTask{
|
||||
ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(),
|
||||
}
|
||||
}
|
||||
if snap.Immediate != nil {
|
||||
out.Immediate = &sdk.SchedulerTask{
|
||||
ID: snap.Immediate.ID, Level: int(snap.Immediate.Level), Kind: snap.Immediate.Kind.String(),
|
||||
}
|
||||
}
|
||||
// 四级队列深度:下标即级别(1..4),下标 0 留 0。
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
out.InterruptQueues[lv] = len(snap.InterruptQueues[lv])
|
||||
}
|
||||
// 中断栈帧:栈底 → 栈顶(谁先被压进去、谁又打断了它)。
|
||||
for _, f := range snap.SuspendStack {
|
||||
if f == nil || f.Task == nil {
|
||||
continue
|
||||
}
|
||||
out.SuspendFrames = append(out.SuspendFrames, sdk.SchedulerFrame{
|
||||
Task: sdk.SchedulerTask{
|
||||
ID: f.Task.ID, Level: int(f.Task.Level), Kind: f.Task.Kind.String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -352,9 +320,6 @@ type scheduler struct {
|
||||
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
|
||||
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
|
||||
critical atomic.Bool
|
||||
// backpressured 记录「就绪队列满」这一状态的翻转,用于只打一次日志。
|
||||
// 满着的时候 pumpInbox 每轮都会走到,逐轮打日志会把日志刷爆。
|
||||
backpressured bool
|
||||
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||
wake chan struct{}
|
||||
@ -416,28 +381,6 @@ func (s *scheduler) hasRoom() bool {
|
||||
return len(s.queue) < s.maxQueue
|
||||
}
|
||||
|
||||
// noteBackpressure 记一次背压,并报告这是否是「从有空间 → 满」的翻转。
|
||||
//
|
||||
// 为什么需要翻转信息:满的时候每轮泵入都会调用本函数,逐轮打日志会刷爆;
|
||||
// 而设计 §4.4 要求「必须计数并打日志」——两者靠这个布尔量同时满足。
|
||||
func (s *scheduler) noteBackpressure() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stats.Backpressure++
|
||||
if s.backpressured {
|
||||
return false
|
||||
}
|
||||
s.backpressured = true
|
||||
return true
|
||||
}
|
||||
|
||||
// clearBackpressure 在就绪队列重新可收(泵空)时复位翻转标记。
|
||||
func (s *scheduler) clearBackpressure() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.backpressured = false
|
||||
}
|
||||
|
||||
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||
s.seq++
|
||||
@ -547,22 +490,10 @@ func (s *scheduler) interruptCountLocked() int {
|
||||
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||
//
|
||||
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||
// 返回 true 表示 t **确实占住了 immediate 槽**;false 表示它被降级进了自己的
|
||||
// 级别队列(immediate 是单槽,这是设计要求的降级分支,见设计 §2「至多一个」)。
|
||||
//
|
||||
// 调用方必须用返回值决定是否计入 PreemptsByLevel:那条计数器的语义是
|
||||
// 「进入 immediate 的次数」,被降级的中断从未进过 immediate。
|
||||
// setImmediateLocked 尝试把 t 放进 immediate 槽。
|
||||
//
|
||||
// 返回 true:t 已占住 immediate(若原有抢占者被顶掉,它**已被**降级入队)。
|
||||
// 返回 false:t 没有进 immediate,且本函数**未动 t** —— 调用方负责按级别入队。
|
||||
//
|
||||
// 把「降级入队」的责任留给调用方,是为了让「到底入队了几次」只有一个出口:
|
||||
// 早先由本函数在返回 false 前自行入队,调用方又照着 false 再入一次,
|
||||
// 同一任务就会在队列里出现两份(实测:中断任务被执行两次、Executed 虚高)。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) bool {
|
||||
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||
return false
|
||||
s.enqueueInterruptLocked(t)
|
||||
return
|
||||
}
|
||||
if s.immediate != nil {
|
||||
s.enqueueInterruptLocked(s.immediate)
|
||||
@ -570,7 +501,6 @@ func (s *scheduler) setImmediateLocked(t *Task) bool {
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.immediate = t
|
||||
return true
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
@ -641,16 +571,11 @@ func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||
arm := false
|
||||
if !critical && canPreempt(t, running) {
|
||||
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||
// 只有**真的占住 immediate 槽**才算一次抢占,才计入 PreemptsByLevel:
|
||||
// immediate 是单槽,若它被另一个更高级的抢占者占着,t 会走上而下的
|
||||
// 「否则入队」分支——那种情况 t 从未进入 immediate(否则同一安全点前
|
||||
// 到达两条同级中断时该计数会高估)。
|
||||
if s.setImmediateLocked(t) {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
}
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
s.setImmediateLocked(t)
|
||||
}
|
||||
}
|
||||
if !arm {
|
||||
@ -681,54 +606,6 @@ func (s *scheduler) clearPreempt() {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// rearmPending 在**安全点重新求值**中断队列(设计 §4.3 / §5.2)。
|
||||
//
|
||||
// 为什么必须有这一步:中断只在 registerInterrupt 里被武装一次,而那一刻运行任务
|
||||
// 可能正在临界区(S_TOOL_EXEC / ONNX / CAS)或处于抢占冷却期,于是请求只能入队。
|
||||
// 若安全点不再回头看队列,它就永远等不到执行——只能等当前任务**自然结束**,
|
||||
// 这违背设计承诺的「临界区期间到达的抢占请求……在临界区结束后的第一个安全点
|
||||
// 重新求值」。可复现症状:WebUI 终止按钮连按两次,第二次(落在 2s 冷却窗内)
|
||||
// 入队后再也不会被求值,「终止」看起来没反应。
|
||||
//
|
||||
// 判据与 registerInterrupt **完全同一套**(canPreempt + 冷却 + 临界区闸门),
|
||||
// 因此不会凭空制造设计之外的抢占。
|
||||
func (s *scheduler) rearmPending() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 已有让位信号、或 immediate 槽已被占用:下一个安全点的选择已经在路上,
|
||||
// 不必(也不该)重复武装。
|
||||
if s.preemptArmed || s.immediate != nil || s.running == nil || s.critical.Load() {
|
||||
return
|
||||
}
|
||||
// 冷却期内不武装:与 registerInterrupt 同一判据(抗饥饿)。
|
||||
if !s.running.LastPreemptAt.IsZero() && time.Since(s.running.LastPreemptAt) < preemptCooldown {
|
||||
return
|
||||
}
|
||||
// 中断队列本就按级别组织:从最高级往下找第一条能抢占的队头。
|
||||
// (队列里的任务有效级恒等于基础级,故「第一条能抢」= 最高级可抢占者。)
|
||||
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||
q := s.interruptQueues[lv]
|
||||
if len(q) == 0 {
|
||||
continue
|
||||
}
|
||||
t := q[0]
|
||||
if !canPreempt(t, s.running) {
|
||||
continue
|
||||
}
|
||||
s.popInterruptLocked(lv)
|
||||
if s.setImmediateLocked(t) {
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
} else {
|
||||
// immediate 槽没拿到(理论上进不来,顶部已判 immediate == nil):放回队列,
|
||||
// 否则任务会凭空消失。
|
||||
s.enqueueInterruptLocked(t)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// suspend 保存现场。
|
||||
//
|
||||
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||
@ -761,37 +638,6 @@ func (s *scheduler) canSuspend() bool {
|
||||
return len(s.suspendStack) < s.maxInterruptFrames
|
||||
}
|
||||
|
||||
// pendingEvents 收集**尚未执行**(排队队列 / 四条中断队列 / immediate)与
|
||||
// **已挂起**(中断栈)任务所携带的、且带同步回执通道的输入事件。
|
||||
//
|
||||
// 用途只有一个:停机收尾。这些任务不会再被调度,若不给它们补终态,
|
||||
// 无超时的同步注入方(cli / clawhubadapter)会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func (s *scheduler) pendingEvents() []*agentIO.InputEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*agentIO.InputEvent
|
||||
add := func(t *Task) {
|
||||
if t != nil && t.Event != nil && t.Event.ResponseCh != nil {
|
||||
out = append(out, t.Event)
|
||||
}
|
||||
}
|
||||
for _, t := range s.queue {
|
||||
add(t)
|
||||
}
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
for _, t := range s.interruptQueues[lv] {
|
||||
add(t)
|
||||
}
|
||||
}
|
||||
add(s.immediate)
|
||||
for _, f := range s.suspendStack {
|
||||
if f != nil {
|
||||
add(f.Task)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// done 标记任务执行结束。
|
||||
func (s *scheduler) done(t *Task) {
|
||||
s.mu.Lock()
|
||||
@ -899,18 +745,6 @@ func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
|
||||
}
|
||||
|
||||
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
|
||||
// roundsExecuted 返回本 agent 已执行的轮次数(供驻留子状态面展示)。
|
||||
//
|
||||
// 一轮 = 一次被执行的输入(排队与中断都算)。为什么不用 inputch 处理表的条数:
|
||||
// 那张表记的是"当前上下文窗口内"的轮次,压缩会清空(设计 §8.3)——
|
||||
// 拿它当轮次会让父看到轮次倒退。
|
||||
func (a *Agent) roundsExecuted() int {
|
||||
if a.sched == nil {
|
||||
return 0
|
||||
}
|
||||
return int(a.DumpScheduler().Stats.Executed)
|
||||
}
|
||||
|
||||
func (a *Agent) DumpScheduler() SchedulerSnapshot {
|
||||
if a.sched == nil {
|
||||
return SchedulerSnapshot{}
|
||||
@ -950,11 +784,9 @@ func (a *Agent) schedulerLoop() {
|
||||
// 无待办:阻塞等新输入、新中断(wake)或退出。
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
case msg := <-a.selfInputCh:
|
||||
_ = a.sched.enqueue(newSelfTask(msg))
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.sched.wake:
|
||||
// 中断已入 pendingInterrupts,回到循环顶部重新挑选。
|
||||
case <-a.ctx.Done():
|
||||
@ -981,28 +813,15 @@ func (a *Agent) pumpInbox() {
|
||||
for a.sched.hasRoom() {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
// 返回值必须处理:静默丢弃会让同步调用方永久挂起(回执路径 E)。
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
a.sched.noteBackpressure()
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
}
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
case msg := <-a.selfInputCh:
|
||||
// 自循环输入没有同步调用方,满时记一次背压即可。
|
||||
if !a.sched.enqueue(newSelfTask(msg)) {
|
||||
a.sched.noteBackpressure()
|
||||
}
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
a.sched.clearBackpressure()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 队列满:输入留在 channel 里,发送方阻塞(设计 §4.4「阻塞发送方」)。
|
||||
// 必须计数并打日志——否则运维看到 Rejected=0 会以为没背压,而输入正卡在 channel。
|
||||
if a.sched.noteBackpressure() {
|
||||
log.Printf("[agent] ready queue full (%d), input channel backpressured", a.sched.maxQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||
|
||||
@ -165,96 +165,3 @@ func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
|
||||
t.Fatalf("Executed=%d,期望 1", snap.Stats.Executed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchedulerStatusExposesLevelsAndStack 钉住调度器状态面**按级别可读**:
|
||||
// 四级队列深度、各级累计计数、立即抢占项、中断栈帧。
|
||||
//
|
||||
// 起因:此前 SchedulerStatus 只给 total(pending_interrupts / suspend_stack),
|
||||
// 看不出"堵在 L1 还是 L4"、"栈里压的是谁"。WebUI 想画运行态就无数据可用。
|
||||
//
|
||||
// 这个用例直接摆好调度器内部状态、只验**映射**(同包白盒):调度行为本身
|
||||
// 由 scheduler_e2e_test / scheduler_critical_test 覆盖,这里不该重复它们的时序。
|
||||
func TestSchedulerStatusExposesLevelsAndStack(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "rt", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
|
||||
if a.sched == nil {
|
||||
t.Fatal("agent 应带调度器")
|
||||
}
|
||||
a.sched.mu.Lock()
|
||||
a.sched.queue = append(a.sched.queue, &Task{ID: 1, Class: TaskQueued, Kind: TaskKindInput})
|
||||
a.sched.interruptQueues[LevelCritical] = append(a.sched.interruptQueues[LevelCritical],
|
||||
&Task{ID: 2, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput},
|
||||
&Task{ID: 3, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput})
|
||||
a.sched.interruptQueues[LevelBackground] = append(a.sched.interruptQueues[LevelBackground],
|
||||
&Task{ID: 4, Class: TaskInterrupt, Level: LevelBackground, Kind: TaskKindSelf})
|
||||
a.sched.immediate = &Task{ID: 5, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput}
|
||||
a.sched.suspendStack = append(a.sched.suspendStack, &suspendedTask{
|
||||
Task: &Task{ID: 6, Class: TaskInterrupt, Level: LevelInteractive, Kind: TaskKindInput},
|
||||
Frame: &TaskFrame{},
|
||||
})
|
||||
a.sched.stats.InterruptsByLevel[LevelCritical] = 7
|
||||
a.sched.stats.PreemptsByLevel[LevelCritical] = 3
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
got := a.schedulerStatus()
|
||||
if got.ReadyQueueDepth != 1 {
|
||||
t.Fatalf("ready_queue_depth 应为 1,实际 %d", got.ReadyQueueDepth)
|
||||
}
|
||||
// 按级别的队列深度:下标即级别,下标 0 恒为 0
|
||||
if got.InterruptQueues[LevelCritical] != 2 || got.InterruptQueues[LevelBackground] != 1 {
|
||||
t.Fatalf("按级别队列深度不对:%v", got.InterruptQueues)
|
||||
}
|
||||
if got.InterruptQueues[0] != 0 {
|
||||
t.Fatalf("下标 0(无级别)必须恒为 0,实际 %v", got.InterruptQueues)
|
||||
}
|
||||
// pending = 两条队列 + immediate
|
||||
if got.PendingInterrupts != 4 {
|
||||
t.Fatalf("pending_interrupts 应为 4,实际 %d", got.PendingInterrupts)
|
||||
}
|
||||
// 立即抢占项要能单独看到
|
||||
if got.Immediate == nil || got.Immediate.ID != 5 || got.Immediate.Level != int(LevelCritical) {
|
||||
t.Fatalf("immediate 未正确映射:%+v", got.Immediate)
|
||||
}
|
||||
// 中断栈帧(栈底→栈顶)要带任务标识
|
||||
if len(got.SuspendFrames) != 1 || got.SuspendFrames[0].Task.ID != 6 ||
|
||||
got.SuspendFrames[0].Task.Level != int(LevelInteractive) {
|
||||
t.Fatalf("中断栈帧未正确映射:%+v", got.SuspendFrames)
|
||||
}
|
||||
// 累计计数按级别透传
|
||||
if got.InterruptsByLevel[LevelCritical] != 7 || got.PreemptsByLevel[LevelCritical] != 3 {
|
||||
t.Fatalf("各级计数未透传:interrupts=%v preempts=%v", got.InterruptsByLevel, got.PreemptsByLevel)
|
||||
}
|
||||
// 未使用的级别必须是 0(不能把别的级别串进来)
|
||||
if got.InterruptQueues[LevelMessage] != 0 || got.InterruptQueues[LevelInteractive] != 0 {
|
||||
t.Fatalf("未使用的级别应为 0:%v", got.InterruptQueues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelInfoCarriesTopology 钉住通道状态面不再丢信息:
|
||||
// 方向、描述、工具名、输出能力都要能被前端画出来。
|
||||
func TestChannelInfoCarriesTopology(t *testing.T) {
|
||||
ch := channelInfoFromIO(agentIO.ChannelInfo{
|
||||
Name: "webui",
|
||||
Type: agentIO.DeviceOutput,
|
||||
Description: "Web 控制台",
|
||||
Tools: []agentIO.ToolDef{{Name: "output_send"}},
|
||||
OutputCaps: agentIO.CapText | agentIO.CapImage,
|
||||
})
|
||||
if ch.Direction != "out" {
|
||||
t.Fatalf("方向应为 out,实际 %q", ch.Direction)
|
||||
}
|
||||
if ch.Description != "Web 控制台" {
|
||||
t.Fatalf("描述丢失:%q", ch.Description)
|
||||
}
|
||||
if len(ch.Tools) != 1 || ch.Tools[0] != "output_send" {
|
||||
t.Fatalf("工具名丢失:%v", ch.Tools)
|
||||
}
|
||||
if ch.OutputCaps != int(agentIO.CapText|agentIO.CapImage) || ch.CapsText == "" {
|
||||
t.Fatalf("输出能力丢失:caps=%d text=%q", ch.OutputCaps, ch.CapsText)
|
||||
}
|
||||
if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "mic", Type: agentIO.DeviceInput}).Direction; d != "in" {
|
||||
t.Fatalf("输入通道方向应为 in,实际 %q", d)
|
||||
}
|
||||
if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "both", Type: agentIO.DeviceIO}).Direction; d != "io" {
|
||||
t.Fatalf("双向通道方向应为 io,实际 %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,186 +0,0 @@
|
||||
package core
|
||||
|
||||
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
|
||||
//
|
||||
// 对照设计稿原文修正的四条:
|
||||
//
|
||||
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
|
||||
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
|
||||
// 唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
|
||||
// **自然结束**。可复现症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
|
||||
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
|
||||
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
|
||||
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
|
||||
// 中断里、被降级入队的那条也被计入(immediate 是单槽,降级是设计要求的路径)。
|
||||
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
|
||||
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
|
||||
//
|
||||
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
|
||||
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
|
||||
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = victim
|
||||
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2)。
|
||||
victim.PreemptCount = 1
|
||||
victim.LastPreemptAt = time.Now()
|
||||
|
||||
evt, _ := textEvent("cli", "第二次终止")
|
||||
evt.Payload["interrupt"] = true
|
||||
if a.sched.requestPreempt(evt, LevelCritical) {
|
||||
t.Fatal("抢占冷却期内不得抢占(应入队)")
|
||||
}
|
||||
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
|
||||
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
|
||||
}
|
||||
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
|
||||
t.Fatalf("应恰好入队一条,实际 %d(>1 说明入队路径重复)", n)
|
||||
}
|
||||
|
||||
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
|
||||
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
|
||||
a.sched.rearmPending()
|
||||
if !a.sched.preemptGrantedFor() {
|
||||
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
|
||||
}
|
||||
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
|
||||
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
|
||||
}
|
||||
// 重新求值不该把任务复制一份:队列必须空、immediate 恰好一条。
|
||||
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
|
||||
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一安全点前到达的两条同级中断:immediate 是单槽,第二条只能降级入队;
|
||||
// 它**没有**进入 immediate,因此不得计入 PreemptsByLevel,也不得被入队两次。
|
||||
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = victim
|
||||
a.sched.nextRef() // running = 排队任务(有效级 0,任何中断都能抢)
|
||||
|
||||
e1, _ := textEvent("cli", "irq-1")
|
||||
if !a.sched.requestPreempt(e1, LevelInteractive) {
|
||||
t.Fatal("第一条 L3 应抢占排队任务")
|
||||
}
|
||||
e2, _ := textEvent("cli", "irq-2")
|
||||
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
|
||||
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1,实际 %d)", got)
|
||||
}
|
||||
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
|
||||
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
|
||||
}
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
|
||||
}
|
||||
}
|
||||
|
||||
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
|
||||
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
|
||||
if a.sched == nil {
|
||||
t.Fatal("agent 应带调度器")
|
||||
}
|
||||
a.sched.mu.Lock()
|
||||
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
|
||||
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
|
||||
a.sched.stats.Suspended = 99 // 故意与抢占数不等
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
got := a.schedulerStatus()
|
||||
if got.Preempted != 5 {
|
||||
t.Fatalf("Preempted 应为各级抢占数之和 5,实际 %d(拿 Suspended 顶替会得 99)", got.Preempted)
|
||||
}
|
||||
if got.Suspended != 99 {
|
||||
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
|
||||
}
|
||||
}
|
||||
|
||||
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
|
||||
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||||
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
|
||||
if !a.sched.enqueue(newInputTask(queuedEvt)) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
|
||||
a.sched.suspend(
|
||||
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
|
||||
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
|
||||
)
|
||||
|
||||
a.Stop()
|
||||
|
||||
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r == nil || r.Payload["skipped"] != true {
|
||||
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
|
||||
func TestBackpressure_CounterAndTransition(t *testing.T) {
|
||||
s := newScheduler(1)
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("首次背压应报告「翻转」")
|
||||
}
|
||||
if s.noteBackpressure() {
|
||||
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
|
||||
}
|
||||
if s.stats.Backpressure != 2 {
|
||||
t.Fatalf("背压计数应为 2,实际 %d", s.stats.Backpressure)
|
||||
}
|
||||
s.clearBackpressure()
|
||||
if !s.noteBackpressure() {
|
||||
t.Fatal("队列恢复后再满应再次报告翻转")
|
||||
}
|
||||
}
|
||||
|
||||
// 集成:就绪队列满时 pumpInbox 必须计一次背压(Rejected 保持 0——背压不是丢弃)。
|
||||
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.sched.maxQueue = 1
|
||||
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
|
||||
a.pumpInbox()
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.Queue) != 1 {
|
||||
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
|
||||
}
|
||||
if snap.Stats.Backpressure == 0 {
|
||||
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4:阻塞发送方 + 计数)")
|
||||
}
|
||||
if snap.Stats.Rejected != 0 {
|
||||
t.Fatalf("背压不是丢弃,Rejected 必须保持 0,实际 %d", snap.Stats.Rejected)
|
||||
}
|
||||
}
|
||||
@ -25,12 +25,6 @@ type StatusProvider interface {
|
||||
type KernelStatus = sdk.KernelStatus
|
||||
type PluginInfo = sdk.PluginInfo
|
||||
type ChannelInfo = sdk.ChannelInfo
|
||||
|
||||
// InputChannelInfo 是 inputch 的登记与归属视图(见 internal/sdk/status.go)。
|
||||
type InputChannelInfo = sdk.InputChannelInfo
|
||||
|
||||
// ResidentStatus 是驻留式子 agent 的运行时视图(见 internal/sdk/status.go)。
|
||||
type ResidentStatus = sdk.ResidentStatus
|
||||
type MemoryStatus = sdk.MemoryStatus
|
||||
type KnowledgeStatus = sdk.KnowledgeStatus
|
||||
type DocumentStatus = sdk.DocumentStatus
|
||||
@ -43,32 +37,10 @@ type TrackerStatus = sdk.TrackerStatus
|
||||
type BuildStatus = sdk.BuildStatus
|
||||
|
||||
func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo {
|
||||
tools := make([]string, 0, len(ch.Tools))
|
||||
for _, t := range ch.Tools {
|
||||
tools = append(tools, t.Name)
|
||||
}
|
||||
return ChannelInfo{
|
||||
Name: ch.Name,
|
||||
Type: fmt.Sprintf("%d", ch.Type),
|
||||
Direction: channelDirection(ch.Type),
|
||||
Ready: true,
|
||||
Description: ch.Description,
|
||||
Tools: tools,
|
||||
OutputCaps: int(ch.OutputCaps),
|
||||
CapsText: ch.OutputCaps.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// channelDirection 把 DeviceType 翻成可读方向(前端画拓扑用)。
|
||||
// 未知取值落到 "io":宁可当作双向,也不要把它画成只进或只出。
|
||||
func channelDirection(t agentIO.DeviceType) string {
|
||||
switch t {
|
||||
case agentIO.DeviceInput:
|
||||
return "in"
|
||||
case agentIO.DeviceOutput:
|
||||
return "out"
|
||||
default:
|
||||
return "io"
|
||||
Name: ch.Name,
|
||||
Type: fmt.Sprintf("%d", ch.Type),
|
||||
Ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,9 +75,6 @@ func collectKernelStatus(
|
||||
KernelName: meta.KernelName,
|
||||
// AGPL-3.0 §13:状态页向网络使用者展示取得源码的入口。
|
||||
SourceURL: meta.SourceURL,
|
||||
// 许可标识与全文:与源码链接一起构成「这是什么许可 + 怎么拿到源码」。
|
||||
License: meta.License,
|
||||
LicenseURL: meta.LicenseURL,
|
||||
},
|
||||
Runtime: RuntimeStatus{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
@ -136,18 +105,6 @@ func collectKernelStatus(
|
||||
for _, ch := range iom.ListChannels() {
|
||||
status.Channels = append(status.Channels, channelInfoFromIO(ch))
|
||||
}
|
||||
// inputch 登记与归属:与 Channels(设备能力)互补——那条回答「能做什么」,
|
||||
// 这条回答「这条输入归谁」。登记表是根 agent 与驻留子共用的,所以驻留子
|
||||
// 划走的 inputch(owner=子 id)也会出现在这里。
|
||||
for _, ic := range iom.InputChannels() {
|
||||
status.InputChannels = append(status.InputChannels, InputChannelInfo{
|
||||
Name: ic.Name,
|
||||
Plugin: ic.Plugin,
|
||||
Owner: ic.Owner,
|
||||
Capacity: ic.Capacity,
|
||||
Output: ic.Output,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Graph memory
|
||||
@ -264,7 +221,6 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
)
|
||||
ks.ONNX = a.onnxStatus()
|
||||
ks.Scheduler = a.schedulerStatus()
|
||||
ks.Residents = residentStatuses(a.Residents())
|
||||
|
||||
return ks
|
||||
}
|
||||
@ -298,33 +254,3 @@ func (a *Agent) onnxStatus() sdk.ONNXStatus {
|
||||
|
||||
var _ StatusProvider = (*Agent)(nil)
|
||||
var _ sdk.StatusAPI = (*Agent)(nil)
|
||||
|
||||
// residentStatuses 把内核的驻留子视图映射成 SDK DTO。
|
||||
//
|
||||
// 刻意**不带** ResidentInfo.Table(那是每个驻留子的 inputch 登记明细):状态面
|
||||
// 是给所有应用轮询的,把它塞进来会让每次 /status 都背上几十 KB。
|
||||
// 只给表的大小(InputChTable),要看明细走专门的接口。
|
||||
func residentStatuses(list []ResidentInfo) []ResidentStatus {
|
||||
out := make([]ResidentStatus, 0, len(list))
|
||||
for _, r := range list {
|
||||
st := ResidentStatus{
|
||||
ID: r.ID,
|
||||
State: r.State,
|
||||
Rounds: r.Rounds,
|
||||
ContextFull: r.ContextFull,
|
||||
InputChs: r.InputChs,
|
||||
AllowedOutputs: r.AllowedOutputs,
|
||||
InputChTable: r.TableSize,
|
||||
// 子自己的调度器积压:per-agent 负载图靠这四项,缺了就只能画根。
|
||||
ReadyQueueDepth: r.SchedReady,
|
||||
PendingInterrupts: r.SchedPending,
|
||||
SuspendStack: r.SchedStack,
|
||||
InterruptQueues: r.SchedQueues,
|
||||
}
|
||||
if !r.CreatedAt.IsZero() {
|
||||
st.CreatedAt = r.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -178,10 +178,6 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
|
||||
for i := 0; i < maxSteps; i++ {
|
||||
// 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列,
|
||||
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
|
||||
//
|
||||
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
|
||||
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
|
||||
a.sched.rearmPending()
|
||||
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
|
||||
return outcomeSuspended
|
||||
}
|
||||
@ -275,7 +271,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
|
||||
tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...)
|
||||
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens)
|
||||
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||
|
||||
@ -395,8 +391,10 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
// 裁剪与审计统一在 memoryPass 内(日志已带 trigger)。
|
||||
a.pruneOnInput(evt, cleanInput)
|
||||
archived := a.pruneOnInput(evt, cleanInput)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
// 本轮 inputch(处理表按它记账)+ contextfull 检测(只有驻留子设了钩子)。
|
||||
a.tableMu.Lock()
|
||||
@ -495,7 +493,7 @@ func (a *Agent) step(f *TaskFrame) stepOutcome {
|
||||
func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
|
||||
memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens)
|
||||
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
f.Tools = a.buildToolDefs()
|
||||
|
||||
@ -669,7 +667,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
f.StageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||
f.StageCtx.ToolResults = nil
|
||||
if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) {
|
||||
result := denialResultText(f.StageCtx, tc.Name)
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
@ -700,20 +698,6 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// denialResultText 返回「工具被插件拒绝」时交给模型的工具结果。
|
||||
//
|
||||
// 插件在 before_toolcall 里用 ctx.Response 写的是**拒绝理由**(为什么被拒、
|
||||
// 能不能重试)。此前这里一律丢成通用文案,模型看不到原因就会反复重试同一个
|
||||
// 调用——权限门精心写的"请不要重试"等于白写。有理由就用理由。
|
||||
func denialResultText(ctx *sdk.StageContext, toolName string) string {
|
||||
if ctx != nil && ctx.Response != nil {
|
||||
if reason := strings.TrimSpace(*ctx.Response); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("工具 %s 已被插件拒绝", toolName)
|
||||
}
|
||||
|
||||
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
|
||||
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
result := a.executeToolCall(f.CurTool, f.OutputChannel)
|
||||
@ -741,19 +725,16 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
result = r
|
||||
}
|
||||
}
|
||||
// 工具后处理:一次相关性过程,两个**正交**声明——
|
||||
// ContextPolicy=prune → 裁剪(踢出去,归档低相关 L0 事件)
|
||||
// RecallPolicy=auto → 召回(取进来,注入 L2/L3 相关记忆)
|
||||
// 两者共用同一份**清洗后**的 query,并统一走 memoryPass(同一入口、
|
||||
// 同一次预算与审计)。查询向量取清洗后的有效内容,否则噪声
|
||||
// (ANSI/base64/JSON 包装)会把相关性打分带偏,裁错事件、召回错记忆。
|
||||
var recallText string
|
||||
if def := a.stageHost.ToolDef(tc.Name); def != nil {
|
||||
needPrune := def.ContextPolicy == sdk.ContextPolicyPrune
|
||||
needRecall := def.RecallPolicy == sdk.RecallPolicyAuto
|
||||
if needPrune || needRecall {
|
||||
query := a.toolOutputForQuery(tc.Name, result)
|
||||
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall).RecallText
|
||||
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
||||
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
||||
if a.context != nil {
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON 包装)
|
||||
// 会把相关性打分带偏,裁掉本该保留的事件。
|
||||
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
||||
}
|
||||
}
|
||||
|
||||
@ -823,10 +804,6 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
||||
f.Msgs = append(f.Msgs, *mediaMsg)
|
||||
}
|
||||
// 召回作为 system 消息挂在末尾(tool/assistant 配对已完成,插入此处不断链)。
|
||||
if recallText != "" {
|
||||
f.Msgs = appendOrReplaceRecall(f.Msgs, recallText)
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
|
||||
@ -2,12 +2,9 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||
)
|
||||
|
||||
func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
||||
@ -40,84 +37,8 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// buildTaskMemoryContext 按本任务声明的召回策略决定是否注入记忆索引。
|
||||
//
|
||||
// 默认 auto(保持“每条输入都召回”的既有行为);输入/注入声明
|
||||
// recall_policy=none 时返回空串,从而不注入记忆。策略与裁剪(ContextPolicy)正交。
|
||||
//
|
||||
// query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。
|
||||
func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string {
|
||||
if f == nil {
|
||||
return a.recallText(input, "input", maxTokens)
|
||||
}
|
||||
if !a.recallDeclared(f.Evt) {
|
||||
return ""
|
||||
}
|
||||
query := strings.TrimSpace(f.CleanInput)
|
||||
if query == "" {
|
||||
query = input
|
||||
}
|
||||
trigger := "input"
|
||||
if f.Evt != nil && f.Evt.Source != "" {
|
||||
trigger = "input:" + f.Evt.Source
|
||||
}
|
||||
return a.recallText(query, trigger, maxTokens)
|
||||
}
|
||||
|
||||
// recallTextFor 以 query 触发一次记忆召回,按当前预算截断,返回可注入的文本。
|
||||
//
|
||||
// 这是“召回”侧的单一入口:与 Prune 共用同一份**清洗后**的 query,
|
||||
// 使“取进来”(召回)与“踢出去”(裁剪)落在同一个相关性过程上。
|
||||
// trigger 仅用于日志溯源(如 "tool:qq_get_message")。
|
||||
func (a *Agent) recallTextFor(query, trigger string) string {
|
||||
memTokens := 0 // 0 = 不截断
|
||||
if a != nil && a.provider != nil {
|
||||
memTokens = ComputeTokenBudget(a.provider, a.systemPrompt).MemoryTokens
|
||||
}
|
||||
return a.recallText(query, trigger, memTokens)
|
||||
}
|
||||
|
||||
// recallText 是召回侧的共同实现:query → 记忆索引文本(空串表示无)。
|
||||
//
|
||||
// 输入侧的 buildTaskMemoryContext 与工具侧的 recallTextFor 都收敛到这里,
|
||||
// 使“同一份 query、同一次预算、同一条审计日志”只写一遍。
|
||||
func (a *Agent) recallText(query, trigger string, maxTokens int) string {
|
||||
if a == nil || query == "" || a.indexer == nil {
|
||||
return ""
|
||||
}
|
||||
text := a.buildMemoryContext(query, maxTokens)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
log.Printf("[agent] memory recall (%s): injected %d chars", trigger, len(text))
|
||||
return text
|
||||
}
|
||||
|
||||
// expandPromptVars 展开自定义提示词(人格卡)里的版本占位符。
|
||||
//
|
||||
// 为什么需要:人格卡是**配置项**,一旦写死版本号就会随内核发版而说谎 ——
|
||||
// 实测线上人格卡写着 "HΔ-Kernel v1.0.3 型号",内核早已 1.3.x,agent 向用户
|
||||
// 自报版本时就照抄 1.0.3。占位符让这类文本永远跟随真实构建:
|
||||
//
|
||||
// {{kernel_version}} → 内核版本(如 1.3.5)
|
||||
// {{kernel_commit}} → 构建 commit
|
||||
// {{sdk_version}} → 所兼容的 SDK 版本(如 1.3.0)
|
||||
//
|
||||
// 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串。
|
||||
func expandPromptVars(s string) string {
|
||||
if !strings.Contains(s, "{{") {
|
||||
return s
|
||||
}
|
||||
return strings.NewReplacer(
|
||||
"{{kernel_version}}", meta.Version,
|
||||
"{{kernel_commit}}", meta.Commit,
|
||||
"{{sdk_version}}", sdkmeta.Version,
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt := expandPromptVars(a.systemPrompt)
|
||||
prompt := a.systemPrompt
|
||||
if prompt == "" {
|
||||
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
||||
}
|
||||
@ -157,14 +78,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
||||
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
||||
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**:
|
||||
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
|
||||
// 先回执后结论、同时通知多个通道都合法)。此前这里写着"每轮对话通常只需调用
|
||||
// 一次 output_send"——那是一条**凭空的限制**,会让模型自己收起合理的多次输出。
|
||||
// 真正需要提醒的只有两件事:单条长度上限(超长拆成完整段落)与"别反复重发
|
||||
// 完全相同的内容"(自律,不是判据)。
|
||||
prompt += "- **输出次数与目标通道由你自己决定**:一次输入可以对同一通道发多条(先回执后结论、分步播报、分段长文),也可以同时发到多个通道(例如同时通知 webui 与 qq)。**没有任何「一轮只能发一次」的限制。**\n"
|
||||
prompt += "- 输出时只需注意两点:单条消息的长度上限(超长就拆成完整段落,不要碎片化);别反复重发**完全相同**的内容(那是浪费,不是限制)。\n"
|
||||
prompt += "- 每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条;拆分时每条应是完整段落,不要碎片化。\n"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**向当前对话通道发一条确认消息告诉用户已收到(异步通道用输出门工具,同步通道直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。\n"
|
||||
prompt += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"
|
||||
|
||||
@ -263,30 +177,6 @@ func (a *Agent) buildToolCatalog() string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// toolDef 构造一个 OpenAI function-call 工具 schema。
|
||||
//
|
||||
// 存在的理由:这张工具表里每一条原本都是 4 层嵌套的
|
||||
// map[string]interface{} 字面量(约 20 行/条);本助手把它压成一次调用,
|
||||
// 只消除重复、不改变 schema 形状——properties 原样保留(空表仍序列化为 {}),
|
||||
// required 为空则整个键省略。
|
||||
func toolDef(name, description string, properties map[string]interface{}, required ...string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
params["required"] = required
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": params,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) buildToolDefs() []interface{} {
|
||||
var tools []interface{}
|
||||
|
||||
@ -323,134 +213,371 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
|
||||
if a.personaStore != nil {
|
||||
tools = append(tools, toolDef("persona_set", "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。", map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
|
||||
}, "mode"))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "persona_set",
|
||||
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
|
||||
},
|
||||
"required": []string{"mode"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
|
||||
}, "source", "target"))
|
||||
tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
|
||||
}, "name"))
|
||||
tools = append(tools, toolDef("memory_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{
|
||||
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
|
||||
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
|
||||
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
|
||||
}, "entity_a", "entity_b", "rounds"))
|
||||
tools = append(tools, toolDef("memory_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
|
||||
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
|
||||
}))
|
||||
tools = append(tools, toolDef("memory_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{
|
||||
"old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
|
||||
"old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
|
||||
"old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
|
||||
"new_subject": map[string]interface{}{"type": "string", "description": "新主体名(不填则不变)"},
|
||||
"new_relation": map[string]interface{}{"type": "string", "description": "新关系类型(不填则不变)"},
|
||||
"new_object": map[string]interface{}{"type": "string", "description": "新客体名(不填则不变)"},
|
||||
}, "old_subject", "old_relation", "old_object"))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_merge",
|
||||
"description": "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
|
||||
},
|
||||
"required": []string{"source", "target"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_delete_entity",
|
||||
"description": "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_block_merge",
|
||||
"description": "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
|
||||
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
|
||||
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
|
||||
},
|
||||
"required": []string{"entity_a", "entity_b", "rounds"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_purge",
|
||||
"description": "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
|
||||
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_edit",
|
||||
"description": "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
|
||||
"old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
|
||||
"old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
|
||||
"new_subject": map[string]interface{}{"type": "string", "description": "新主体名(不填则不变)"},
|
||||
"new_relation": map[string]interface{}{"type": "string", "description": "新关系类型(不填则不变)"},
|
||||
"new_object": map[string]interface{}{"type": "string", "description": "新客体名(不填则不变)"},
|
||||
},
|
||||
"required": []string{"old_subject", "old_relation", "old_object"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.knowledge != nil {
|
||||
tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。", map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询关键词"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
|
||||
}, "query"))
|
||||
tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "knowledge_search",
|
||||
"description": "搜索知识库。输入查询关键词,返回相关知识内容。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询关键词"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "knowledge_list",
|
||||
"description": "列出知识库中所有知识分类。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.knowledge != nil {
|
||||
tools = append(tools, toolDef("knowledge_create", "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
|
||||
}, "name", "content"))
|
||||
tools = append(tools, toolDef("knowledge_delete", "删除知识库中的指定知识条目。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"},
|
||||
}, "name"))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "knowledge_create",
|
||||
"description": "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
|
||||
},
|
||||
"required": []string{"name", "content"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "knowledge_delete",
|
||||
"description": "删除知识库中的指定知识条目。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
tools = append(tools, toolDef("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
|
||||
}, "query"))
|
||||
tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
|
||||
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "标签列表",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "doc_query",
|
||||
"description": "查询文档记忆。输入查询内容,返回相关文档摘要。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "doc_commit",
|
||||
"description": "提交一条文档记忆。将重要信息显式写入文档记忆层。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
|
||||
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "标签列表",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
},
|
||||
}, "content"))
|
||||
})
|
||||
}
|
||||
|
||||
if a.social != nil {
|
||||
tools = append(tools, toolDef("person_query", "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
}, "name"))
|
||||
tools = append(tools, toolDef("person_set_trait", "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
"trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "特质值,如:红色、开朗、工程师、25岁"},
|
||||
}, "name", "trait", "value"))
|
||||
tools = append(tools, toolDef("person_relate", "记录两个人之间的社交关系。例如:person_relate(person_a=\"张三\", relation=\"朋友\", person_b=\"李四\")。关系是双向的。", map[string]interface{}{
|
||||
"person_a": map[string]interface{}{"type": "string", "description": "人物A"},
|
||||
"relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"},
|
||||
"person_b": map[string]interface{}{"type": "string", "description": "人物B"},
|
||||
}, "person_a", "relation", "person_b"))
|
||||
tools = append(tools, toolDef("person_network", "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
"depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2},
|
||||
}, "name"))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "person_query",
|
||||
"description": "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "person_set_trait",
|
||||
"description": "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
"trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "特质值,如:红色、开朗、工程师、25岁"},
|
||||
},
|
||||
"required": []string{"name", "trait", "value"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "person_relate",
|
||||
"description": "记录两个人之间的社交关系。例如:person_relate(person_a=\"张三\", relation=\"朋友\", person_b=\"李四\")。关系是双向的。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"person_a": map[string]interface{}{"type": "string", "description": "人物A"},
|
||||
"relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"},
|
||||
"person_b": map[string]interface{}{"type": "string", "description": "人物B"},
|
||||
},
|
||||
"required": []string{"person_a", "relation", "person_b"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "person_network",
|
||||
"description": "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "人物名称"},
|
||||
"depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.pluginReg != nil && a.pluginDir != "" {
|
||||
tools = append(tools, toolDef("plgreload", "重载 plugins/ 目录的所有插件。扫描目录变更,原子化替换 IO 设备。", map[string]interface{}{}))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "plgreload",
|
||||
"description": "重载 plugins/ 目录的所有插件。扫描目录变更,原子化替换 IO 设备。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 按插件动态拉取工具定义(避免全量注入提示词污染)
|
||||
tools = append(tools, toolDef("get_plugin_tools", "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。", map[string]interface{}{
|
||||
"plugin_name": map[string]interface{}{"type": "string", "description": "插件名,如 qq / remotedevice / weather", "default": ""},
|
||||
}))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_plugin_tools",
|
||||
"description": "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"plugin_name": map[string]interface{}{"type": "string", "description": "插件名,如 qq / remotedevice / weather", "default": ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tools = append(tools, toolDef("spawn_child", "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。\n使用时机:多个互不依赖的子任务(如同时查三个网站、分别处理多个文件)应并行 spawn 多个子 Agent,不要自己串行逐个执行;长耗时任务(批量处理、多轮搜索)也应交给子 Agent,避免阻塞对话。", map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "spawn_child",
|
||||
"description": "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。\n使用时机:多个互不依赖的子任务(如同时查三个网站、分别处理多个文件)应并行 spawn 多个子 Agent,不要自己串行逐个执行;长耗时任务(批量处理、多轮搜索)也应交给子 Agent,避免阻塞对话。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
|
||||
},
|
||||
"max_turns": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "子 Agent 最大工具轮数(默认 5,范围 1-30)。复杂任务可调高。",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
},
|
||||
},
|
||||
"max_turns": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "子 Agent 最大工具轮数(默认 5,范围 1-30)。复杂任务可调高。",
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "child_result",
|
||||
"description": "查询异步子 Agent 的执行结果。当收到'子任务已完成'的通知后,调用此工具获取输出。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"task_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "spawn_child 返回的任务 ID,如 child_1",
|
||||
},
|
||||
},
|
||||
"required": []string{"task_id"},
|
||||
},
|
||||
},
|
||||
}, "task"))
|
||||
tools = append(tools, toolDef("child_result", "查询异步子 Agent 的执行结果。当收到'子任务已完成'的通知后,调用此工具获取输出。", map[string]interface{}{
|
||||
"task_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "spawn_child 返回的任务 ID,如 child_1",
|
||||
},
|
||||
}, "task_id"))
|
||||
})
|
||||
|
||||
if a.providerManager != nil {
|
||||
tools = append(tools, toolDef("llm_list_sources", "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。", map[string]interface{}{}))
|
||||
tools = append(tools, toolDef("llm_set_source", "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。", map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "LLM 源名称(如 deepseek、openai、ollama)",
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "llm_list_sources",
|
||||
"description": "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}, "name"))
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "llm_set_source",
|
||||
"description": "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "LLM 源名称(如 deepseek、openai、ollama)",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
channels := a.io.ListChannels()
|
||||
@ -469,92 +596,196 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
desc = ch.Name + " 输出通道"
|
||||
}
|
||||
|
||||
tools = append(tools, toolDef("output_send__"+ch.Name, desc+"。能力: "+capStr+"。payload 为消息载荷(type 默认 text,可省略),meta 为 JSON 发送元数据。用 _help 查看 meta 格式与 type 枚举。", map[string]interface{}{
|
||||
"payload": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send__" + ch.Name,
|
||||
"description": desc + "。能力: " + capStr + "。payload 为消息载荷,meta 为 JSON 发送元数据,type 为载荷类型。用 _help 查看 meta 格式和 type 枚举。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"payload": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
|
||||
},
|
||||
"type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "载荷类型,用 channel._help 查看支持的枚举值",
|
||||
},
|
||||
},
|
||||
"required": []string{"payload", "type"},
|
||||
},
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
|
||||
},
|
||||
"type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "载荷类型,默认 text;其它枚举用 channel._help 查看",
|
||||
},
|
||||
}, "payload"))
|
||||
})
|
||||
|
||||
tools = append(tools, toolDef("output_send__"+ch.Name+"_help", "查看 "+ch.Name+" 输出通道的 meta 格式说明和 type 枚举", map[string]interface{}{}))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send__" + ch.Name + "_help",
|
||||
"description": "查看 " + ch.Name + " 输出通道的 meta 格式说明和 type 枚举",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
tools = append(tools, toolDef("output_list_channels", "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。", map[string]interface{}{}))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_list_channels",
|
||||
"description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 父侧:驻留子控制面(单工具多动作,见设计 §7)。
|
||||
if a.parentID == "" {
|
||||
tools = append(tools, toolDef("resident_agents", "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + "+
|
||||
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)"+
|
||||
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)"+
|
||||
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。", map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "resident_agents",
|
||||
"description": "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + " +
|
||||
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)" +
|
||||
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)" +
|
||||
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
|
||||
},
|
||||
"id": map[string]interface{}{"type": "string", "description": "驻留子 id"},
|
||||
"task_prompt": map[string]interface{}{"type": "string", "description": "create:在固定提示词之上注入的任务提示词"},
|
||||
"input_chs": map[string]interface{}{"type": "string", "description": "create:划入的 inputch(逗号分隔)"},
|
||||
"allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"},
|
||||
"capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"},
|
||||
"temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
"id": map[string]interface{}{"type": "string", "description": "驻留子 id"},
|
||||
"task_prompt": map[string]interface{}{"type": "string", "description": "create:在固定提示词之上注入的任务提示词"},
|
||||
"input_chs": map[string]interface{}{"type": "string", "description": "create:划入的 inputch(逗号分隔)"},
|
||||
"allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"},
|
||||
"capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"},
|
||||
"temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
}, "action"))
|
||||
})
|
||||
}
|
||||
|
||||
// 子侧(驻留子):主动汇报(L3)与主动写处理表。
|
||||
if a.parentID != "" {
|
||||
tools = append(tools, toolDef("notify_parent", "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。", map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}}, "text"))
|
||||
tools = append(tools, toolDef("inputch_note", "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。"+
|
||||
"写了就不会再被系统自动记录;不写则本轮结束时系统自动写。", map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}}, "text"))
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "notify_parent",
|
||||
"description": "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "inputch_note",
|
||||
"description": "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。" +
|
||||
"写了就不会再被系统自动记录;不写则本轮结束时系统自动写。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
tools = append(tools, toolDef("input_channels", "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、"+
|
||||
"各自划给了哪个 agent、容量与记忆策略。单工具多视图。", map[string]interface{}{
|
||||
"view": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "all=全部已注册(默认)| mine=划给本 agent 的 | " +
|
||||
"unassigned=尚未划出的 | by_agent=按归属分组的划分总览 | detail=单个详情",
|
||||
"enum": []string{"all", "mine", "unassigned", "by_agent", "detail"},
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "input_channels",
|
||||
"description": "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、" +
|
||||
"各自划给了哪个 agent、容量与记忆策略。单工具多视图。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"view": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "all=全部已注册(默认)| mine=划给本 agent 的 | " +
|
||||
"unassigned=尚未划出的 | by_agent=按归属分组的划分总览 | detail=单个详情",
|
||||
"enum": []string{"all", "mine", "unassigned", "by_agent", "detail"},
|
||||
},
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "view=detail 时必填:inputch 名",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "view=detail 时必填:inputch 名",
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
if a.pendingMedia != nil {
|
||||
tools = append(tools, toolDef("describe_image", "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。", map[string]interface{}{
|
||||
"provider": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:用于图片描述的 LLM 源名称,不填则使用默认模型",
|
||||
},
|
||||
"detail": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "描述详细程度: high / low / auto",
|
||||
"default": "high",
|
||||
},
|
||||
}))
|
||||
tools = append(tools, toolDef("transcribe_audio", "转写当前用户上传的音频内容为文字。使用配置的多模态模型或默认 LLM 进行语音识别。", map[string]interface{}{
|
||||
"provider": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:用于音频转写的 LLM 源名称,不填则使用默认模型",
|
||||
},
|
||||
}))
|
||||
if a.inputCfg.Image.OCREnabled {
|
||||
tools = append(tools, toolDef("ocr_image", "对当前用户上传的图片执行 OCR 文字识别,提取图片中的文字内容。适用于截图、文档照片、菜单等场景。", map[string]interface{}{
|
||||
"language": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "OCR 语言(如 chi_sim+eng),默认自动",
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "describe_image",
|
||||
"description": "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"provider": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:用于图片描述的 LLM 源名称,不填则使用默认模型",
|
||||
},
|
||||
"detail": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "描述详细程度: high / low / auto",
|
||||
"default": "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "transcribe_audio",
|
||||
"description": "转写当前用户上传的音频内容为文字。使用配置的多模态模型或默认 LLM 进行语音识别。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"provider": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:用于音频转写的 LLM 源名称,不填则使用默认模型",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if a.inputCfg.Image.OCREnabled {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "ocr_image",
|
||||
"description": "对当前用户上传的图片执行 OCR 文字识别,提取图片中的文字内容。适用于截图、文档照片、菜单等场景。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"language": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "OCR 语言(如 chi_sim+eng),默认自动",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。
|
||||
@ -116,23 +115,3 @@ func TestIsOutputDeliveryTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插件在 before_toolcall 里给出的拒绝理由必须原样进入工具结果。
|
||||
// 若被通用文案覆盖,模型不知道「为什么被拒 / 能不能重试」,会反复重试同一个调用。
|
||||
func TestDenialReasonReachesModel(t *testing.T) {
|
||||
reason := "QQ 权限策略拒绝私人资源工具 calendar_list;请不要重试"
|
||||
ctx := &sdk.StageContext{Response: &reason}
|
||||
if got := denialResultText(ctx, "calendar_list"); got != reason {
|
||||
t.Fatalf("拒绝理由被丢弃,实际: %q", got)
|
||||
}
|
||||
|
||||
// 插件没给理由时退回通用文案(保持既有行为)。
|
||||
if got := denialResultText(&sdk.StageContext{}, "calendar_list"); !strings.Contains(got, "已被插件拒绝") {
|
||||
t.Fatalf("无理由时应退回通用文案,实际: %q", got)
|
||||
}
|
||||
// 空白理由不算理由。
|
||||
blank := " "
|
||||
if got := denialResultText(&sdk.StageContext{Response: &blank}, "x"); !strings.Contains(got, "已被插件拒绝") {
|
||||
t.Fatalf("空白理由应退回通用文案,实际: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,29 +104,6 @@ type IOManager struct {
|
||||
nextReqID int64
|
||||
channelReg *ChannelRegistry
|
||||
|
||||
// parent 是"上级 IOManager"(驻留子的轻量内核指向父的内核)。
|
||||
//
|
||||
// 为什么需要:**输出通道在 io 层就是 Device**,而它们是由插件登记在**父**的
|
||||
// io 上的。驻留子有自己的 IOManager(自己的输入入口、自己的 outputCh),
|
||||
// 若只看自己那张空表,`output_send__<通道>` 会被判"通道不存在或不可用",
|
||||
// `output_list_channels` 是空的,`output_send__*` 工具也不会生成
|
||||
// —— 现场表现就是"驻留子不会说话/不会发消息"(联调实录:父侧通道装载完整、
|
||||
// 子侧 childIO 空壳)。
|
||||
//
|
||||
// 用**实时回退**而不是创建时复制快照:设备会随资源生灭(远程设备上线/掉线
|
||||
// 以分钟计),复制出来的表转瞬就过期。授权由各自的 AllowedOutputs 白名单把关,
|
||||
// 回退只解决"看得见",不解决"能不能用"。
|
||||
parent *IOManager
|
||||
|
||||
// inputRouter 决定一条输入是否被"别的 agent"接管(返回 true = 已接管)。
|
||||
//
|
||||
// 为什么放在 io:inputch 是**最基本的输入路由单位**,而**路由发生在进内核之前**
|
||||
// (docs/zh/resident-subagent-design.md §4.1)。插件注入输入的收口就在这里,
|
||||
// 所以路由必须在这里生效 —— inputch 划给某个 agent 后,输入**只流向那个 agent**,
|
||||
// 本内核根本看不到它。io 层不认识 agent,路由器由内核注入
|
||||
// (见 core.Agent.routeInputByOwner)。
|
||||
inputRouter InputRouter
|
||||
|
||||
// toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。
|
||||
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
|
||||
toolBlocksMu sync.Mutex
|
||||
@ -143,72 +120,6 @@ func NewIOManager() *IOManager {
|
||||
}
|
||||
}
|
||||
|
||||
// InputRouter 是输入路由器的签名。
|
||||
//
|
||||
// evt 待投递的输入事件(OutputChannel 即它的 inputch)
|
||||
// isInterrupt 该输入是中断还是排队(两者都要按归属路由)
|
||||
// 返回 true = 已被别的 agent 接管,本内核不再处理
|
||||
type InputRouter func(evt *InputEvent, isInterrupt bool) bool
|
||||
|
||||
// SetInputRouter 注入输入路由器(nil = 不路由,行为与以前完全一致)。
|
||||
func (m *IOManager) SetInputRouter(r InputRouter) {
|
||||
m.mu.Lock()
|
||||
m.inputRouter = r
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// deliverInput 是**本内核**接收一条外部输入的收口:先按 inputch 归属路由,
|
||||
// 被别的 agent 接管就不进本内核队列(划给子的 inputch,父不再收到 —— 这是「划拨」
|
||||
// 的语义,不是"父也顺便看一眼")。
|
||||
func (m *IOManager) deliverInput(evt *InputEvent, isInterrupt bool) {
|
||||
m.mu.RLock()
|
||||
router := m.inputRouter
|
||||
m.mu.RUnlock()
|
||||
if router != nil && router(evt, isInterrupt) {
|
||||
return
|
||||
}
|
||||
m.pushLocal(evt, isInterrupt)
|
||||
}
|
||||
|
||||
// DeliverRouted 把**已被路由**的事件放进本内核队列(不再二次路由)。
|
||||
// 由路由器实现调用:父把输入交给持有该 inputch 的子。
|
||||
func (m *IOManager) DeliverRouted(evt *InputEvent, isInterrupt bool) {
|
||||
m.pushLocal(evt, isInterrupt)
|
||||
}
|
||||
|
||||
func (m *IOManager) pushLocal(evt *InputEvent, isInterrupt bool) {
|
||||
if isInterrupt {
|
||||
m.interruptCh <- evt
|
||||
return
|
||||
}
|
||||
m.inputCh <- evt
|
||||
}
|
||||
|
||||
// SetParentIO 设置上级 IOManager(nil 表示无上级,行为与以前完全一致)。
|
||||
// 见 parent 字段的说明:用于驻留子继承父的输出通道/设备视图。
|
||||
func (m *IOManager) SetParentIO(p *IOManager) {
|
||||
m.mu.Lock()
|
||||
m.parent = p
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// lookupDevice 查设备:自己的登记优先,其次回退到上级。
|
||||
//
|
||||
// 先在自己锁内取快照再查上级,**不跨锁调用**(避免锁序问题)。
|
||||
func (m *IOManager) lookupDevice(name string) Device {
|
||||
m.mu.RLock()
|
||||
dev, ok := m.devices[name]
|
||||
parent := m.parent
|
||||
m.mu.RUnlock()
|
||||
if ok {
|
||||
return dev
|
||||
}
|
||||
if parent != nil {
|
||||
return parent.GetDevice(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *IOManager) UnregisterDevice(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@ -247,7 +158,9 @@ func (m *IOManager) RegisterDevice(dev Device) error {
|
||||
}
|
||||
|
||||
func (m *IOManager) GetDevice(name string) Device {
|
||||
return m.lookupDevice(name)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.devices[name]
|
||||
}
|
||||
|
||||
func (m *IOManager) StartAll() error {
|
||||
@ -282,52 +195,50 @@ func (m *IOManager) StopAll() {
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInput(source string, eventType string, payload map[string]interface{}) {
|
||||
m.deliverInput(&InputEvent{
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
OutputChannel: source,
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputSync(source string, eventType string, payload map[string]interface{}) *OutputEvent {
|
||||
ch := make(chan *OutputEvent, 1)
|
||||
m.deliverInput(&InputEvent{
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
ResponseCh: ch,
|
||||
OutputChannel: source,
|
||||
}, false)
|
||||
// 被路由走时,回答由持有该 inputch 的 agent 写进同一个 ResponseCh
|
||||
//(§4.3:同步输入的回程是事前定好的)——所以这里照常等待。
|
||||
}
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// InjectInputTo 注入输入事件并指定输出通道
|
||||
func (m *IOManager) InjectInputTo(source, outputChannel, eventType string, payload map[string]interface{}) {
|
||||
m.deliverInput(&InputEvent{
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
OutputChannel: outputChannel,
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSyncTo 注入输入事件(同步等待)并指定输出通道
|
||||
func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, payload map[string]interface{}) *OutputEvent {
|
||||
ch := make(chan *OutputEvent, 1)
|
||||
m.deliverInput(&InputEvent{
|
||||
m.inputCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
ResponseCh: ch,
|
||||
OutputChannel: outputChannel,
|
||||
}, false)
|
||||
}
|
||||
return <-ch
|
||||
}
|
||||
|
||||
@ -352,9 +263,6 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.ContextPolicy != "" {
|
||||
payload["context_policy"] = opts.ContextPolicy
|
||||
}
|
||||
if opts.RecallPolicy != "" {
|
||||
payload["recall_policy"] = opts.RecallPolicy
|
||||
}
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
@ -425,13 +333,13 @@ func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]i
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
evtType, _ := payload["type"].(string)
|
||||
m.deliverInput(&InputEvent{
|
||||
m.interruptCh <- &InputEvent{
|
||||
RequestID: m.nextRequestID(),
|
||||
Source: source,
|
||||
Type: evtType,
|
||||
Payload: payload,
|
||||
OutputChannel: channel,
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInterruptText(source, channel, text string) {
|
||||
@ -634,15 +542,6 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
// 自己没这个设备工具 → 看上级(驻留子的设备工具都在父的 io 上)。
|
||||
m.mu.RLock()
|
||||
parent := m.parent
|
||||
m.mu.RUnlock()
|
||||
if parent != nil {
|
||||
if ret, err := parent.ExecuteTool(name, args); err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
}
|
||||
defer func() {
|
||||
@ -675,22 +574,10 @@ type ChannelInfo struct {
|
||||
|
||||
func (m *IOManager) ListChannels() []ChannelInfo {
|
||||
m.mu.RLock()
|
||||
own := make(map[string]Device, len(m.devices))
|
||||
for name, dev := range m.devices {
|
||||
own[name] = dev
|
||||
}
|
||||
parent := m.parent
|
||||
m.mu.RUnlock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// 自己的登记优先(子侧可覆盖/屏蔽同名通道),随后并入上级的可见通道。
|
||||
// 去重按**名字**:同名即视为同一个通道,不重复列举。
|
||||
seen := make(map[string]bool, len(own))
|
||||
var list []ChannelInfo
|
||||
appendDev := func(dev Device) {
|
||||
if seen[dev.Name()] {
|
||||
return
|
||||
}
|
||||
seen[dev.Name()] = true
|
||||
for _, dev := range m.devices {
|
||||
list = append(list, ChannelInfo{
|
||||
Name: dev.Name(),
|
||||
Type: dev.Type(),
|
||||
@ -699,23 +586,13 @@ func (m *IOManager) ListChannels() []ChannelInfo {
|
||||
OutputCaps: dev.OutputCapabilities(),
|
||||
})
|
||||
}
|
||||
for _, dev := range own {
|
||||
appendDev(dev)
|
||||
}
|
||||
if parent != nil {
|
||||
for _, ch := range parent.ListChannels() {
|
||||
if seen[ch.Name] {
|
||||
continue
|
||||
}
|
||||
seen[ch.Name] = true
|
||||
list = append(list, ch)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (m *IOManager) GetChannelCapabilities(channel string) OutputCapability {
|
||||
if dev := m.lookupDevice(channel); dev != nil {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if dev, ok := m.devices[channel]; ok {
|
||||
return dev.OutputCapabilities()
|
||||
}
|
||||
return 0
|
||||
|
||||
@ -47,7 +47,6 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) {
|
||||
m.InjectTextOpts("src", "chan", "hello", InjectOptions{
|
||||
NoMemory: true,
|
||||
ContextPolicy: "prune",
|
||||
RecallPolicy: "none",
|
||||
CleanerName: "clean_me",
|
||||
})
|
||||
evt := drainOne(t, m.InputChan())
|
||||
@ -58,9 +57,6 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) {
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("context_policy 未传递: %v", evt.Payload["context_policy"])
|
||||
}
|
||||
if evt.Payload["recall_policy"] != "none" {
|
||||
t.Errorf("recall_policy 未传递: %v", evt.Payload["recall_policy"])
|
||||
}
|
||||
if evt.Payload["cleaner_name"] != "clean_me" {
|
||||
t.Errorf("cleaner_name 未传递: %v", evt.Payload["cleaner_name"])
|
||||
}
|
||||
@ -75,15 +71,12 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) {
|
||||
// 中断注入走另一条队列,标志位同样要带上(用户已确认中断允许声明 prune)。
|
||||
func TestInjectInterruptTextOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune", RecallPolicy: "none"})
|
||||
m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune"})
|
||||
evt := drainOne(t, m.InputInterruptChan())
|
||||
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("中断注入的 context_policy 未传递: %v", evt.Payload)
|
||||
}
|
||||
if evt.Payload["recall_policy"] != "none" {
|
||||
t.Errorf("中断注入的 recall_policy 未传递: %v", evt.Payload)
|
||||
}
|
||||
if evt.Payload["type"] != "text" || evt.Payload["content"] != "alert" {
|
||||
t.Errorf("中断注入的基本字段不对: %v", evt.Payload)
|
||||
}
|
||||
|
||||
@ -102,20 +102,10 @@ func (r *ChannelRegistry) Register(ch InputChannel) error {
|
||||
}
|
||||
|
||||
// Unregister 注销一个 inputch。
|
||||
//
|
||||
// 同时清理指向它的输出目标登记(outputTargets):否则通道被注销后,
|
||||
// ResolveOutputTarget 仍会把它解析成一个已不存在的 inputch。
|
||||
// 两种指向都要清:显式绑定到该 inputch 的,以及“回退到同名通道”的
|
||||
// (InputCh == "" 表示与输出通道同名)。
|
||||
func (r *ChannelRegistry) Unregister(name string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.channels, name)
|
||||
for out, t := range r.outputTargets {
|
||||
if t.InputCh == name || (t.InputCh == "" && out == name) {
|
||||
delete(r.outputTargets, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup 查询一个 inputch。
|
||||
@ -185,18 +175,6 @@ func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnbindOutputTarget 取消一个输出通道的目标登记。
|
||||
//
|
||||
// 与 BindOutputTarget 成对:输出通道可能是**随资源生灭**的(如远程设备
|
||||
// 一台设备一个通道),设备掉线后必须解绑,否则 ResolveOutputTarget 会一直
|
||||
// 把消息路由到一个已不存在的 agent/inputch 上——与 inputch 登记泄漏同一类问题。
|
||||
// 未登记时是 no-op(幂等),方便清理路径无脑调用。
|
||||
func (r *ChannelRegistry) UnbindOutputTarget(output string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.outputTargets, output)
|
||||
}
|
||||
|
||||
// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false
|
||||
// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。
|
||||
func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) {
|
||||
|
||||
@ -132,46 +132,3 @@ func TestChannelRegistry_DefLookupCompat(t *testing.T) {
|
||||
t.Fatal("未注册的 inputch 不应有策略")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputTarget_UnbindAndUnregisterCleanup 守住 outputTargets 的清理:
|
||||
// - UnbindOutputTarget 幂等解绑;
|
||||
// - Unregister(inputch) 顺手清掉指向它的目标登记(显式绑定 + 同名回退两种)。
|
||||
//
|
||||
// 背景:outputTargets 只增不减是漏项(审查发现)——通道随资源生灭时会留下
|
||||
// 指向已不存在 agent/inputch 的路由。
|
||||
func TestOutputTarget_UnbindAndUnregisterCleanup(t *testing.T) {
|
||||
r := NewChannelRegistry()
|
||||
|
||||
if err := r.Register(InputChannel{Name: "sub/in"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Register(InputChannel{Name: "same"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.BindOutputTarget("to-child", "child-1", "sub/in"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// InputCh=="" 表示"与输出通道同名"的回退。
|
||||
if err := r.BindOutputTarget("same", "child-2", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := r.ResolveOutputTarget("to-child"); !ok {
|
||||
t.Fatal("绑定后应能解析")
|
||||
}
|
||||
r.UnbindOutputTarget("to-child")
|
||||
if _, ok := r.ResolveOutputTarget("to-child"); ok {
|
||||
t.Fatal("解绑后不应再解析")
|
||||
}
|
||||
r.UnbindOutputTarget("to-child") // 幂等:重复解绑不 panic
|
||||
|
||||
// 注销 inputch:显式绑定到它的、以及同名回退的,都要一起清。
|
||||
r.Unregister("sub/in")
|
||||
if _, ok := r.ResolveOutputTarget("to-child"); ok {
|
||||
t.Fatal("显式绑定到已注销 inputch 的目标应被清理")
|
||||
}
|
||||
r.Unregister("same")
|
||||
if _, ok := r.ResolveOutputTarget("same"); ok {
|
||||
t.Fatal("同名回退目标应随该 inputch 注销被清理")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
package io
|
||||
|
||||
import "testing"
|
||||
|
||||
// 输入路由:路由器说"已被别的 agent 接管"时,事件**不得**进本内核队列。
|
||||
//
|
||||
// 语义(设计 §4.1):inputch 是可分配资源,划给某个 agent 后输入只流向它 ——
|
||||
// "父也顺便看到一份"是错的。
|
||||
func TestInputRouter_TakesOverExclusively(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
var got []*InputEvent
|
||||
var sawInterrupt bool
|
||||
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool {
|
||||
got = append(got, evt)
|
||||
sawInterrupt = sawInterrupt || isInterrupt
|
||||
return true // 全部接管
|
||||
})
|
||||
|
||||
m.InjectInputTo("plugin-x", "sub/in", "text", map[string]interface{}{"content": "a"})
|
||||
m.InjectInterruptTextOpts("plugin-x", "sub/in", "b", InjectOptions{})
|
||||
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("路由器应被调用(含中断路径),实际 %d 次", len(got))
|
||||
}
|
||||
if n := len(m.InputChan()); n != 0 {
|
||||
t.Fatalf("被接管的排队输入不得进本内核队列,实际 %d 条", n)
|
||||
}
|
||||
if !sawInterrupt {
|
||||
t.Fatal("中断注入也必须经过路由(否则中断会绕过 inputch 归属直投父)")
|
||||
}
|
||||
if got[0].OutputChannel != "sub/in" {
|
||||
t.Fatalf("路由器应拿到事件的 inputch,得到 %q", got[0].OutputChannel)
|
||||
}
|
||||
}
|
||||
|
||||
// 路由器放行(返回 false)或未设置时,行为与以前完全一致。
|
||||
func TestInputRouter_PassthroughKeepsOldBehaviour(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
calls := 0
|
||||
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool { calls++; return false })
|
||||
|
||||
m.InjectInputTo("plugin-x", "sub/in", "text", map[string]interface{}{"content": "a"})
|
||||
if calls != 1 {
|
||||
t.Fatalf("路由器应被调用一次,实际 %d", calls)
|
||||
}
|
||||
if n := len(m.InputChan()); n != 1 {
|
||||
t.Fatalf("放行的输入应进本内核队列,实际 %d 条", n)
|
||||
}
|
||||
if _, ok := <-m.InputChan(); !ok {
|
||||
t.Fatal("队列应可读")
|
||||
}
|
||||
|
||||
// 未设路由器:直接入队(历史行为)
|
||||
m2 := NewIOManager()
|
||||
m2.InjectInterruptText("plugin-x", "sub/in", "c")
|
||||
if n := len(m2.InputInterruptChan()); n != 1 {
|
||||
t.Fatalf("未设路由器时中断应直接入队,实际 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// DeliverRouted 是不再二次路由的投递口(路由器实现把事件交给持有者)。
|
||||
func TestDeliverRouted_SkipsSecondRouting(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
routerCalls := 0
|
||||
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool { routerCalls++; return true })
|
||||
|
||||
m.DeliverRouted(&InputEvent{OutputChannel: "sub/in"}, false)
|
||||
if routerCalls != 0 {
|
||||
t.Fatalf("DeliverRouted 不应再触发路由(会成环),实际 %d 次", routerCalls)
|
||||
}
|
||||
if n := len(m.InputChan()); n != 1 {
|
||||
t.Fatalf("应已入队,实际 %d 条", n)
|
||||
}
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
package io
|
||||
|
||||
import "testing"
|
||||
|
||||
// 上级回退:驻留子的轻量内核有自己的 IOManager,但输出通道(io 里的 Device)
|
||||
// 是插件登记在**父**的 io 上的。子若看不见它们,`output_send__<通道>` 会被判
|
||||
// "通道不存在或不可用"、`output_list_channels` 为空 —— 现场联调实录
|
||||
// 「父侧通道装载完整、子侧 childIO 空壳」。
|
||||
func TestIOManagerParentFallback(t *testing.T) {
|
||||
parent := NewIOManager()
|
||||
if err := parent.RegisterDevice(&mockDevice{name: "qq", devType: DeviceOutput, caps: CapText}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child := NewIOManager()
|
||||
// 未挂上级时行为与以前完全一致(不能悄悄多出通道)
|
||||
if got := child.GetChannelCapabilities("qq"); got != 0 {
|
||||
t.Fatalf("无上级时不应看见父的通道,得到 %v", got)
|
||||
}
|
||||
if n := len(child.ListChannels()); n != 0 {
|
||||
t.Fatalf("无上级时通道数应为 0,得到 %d", n)
|
||||
}
|
||||
|
||||
child.SetParentIO(parent)
|
||||
if got := child.GetChannelCapabilities("qq"); got != CapText {
|
||||
t.Fatalf("挂上级后应看见父通道能力 CapText,得到 %v", got)
|
||||
}
|
||||
if dev := child.GetDevice("qq"); dev == nil || dev.Name() != "qq" {
|
||||
t.Fatalf("GetDevice 未回退到父: %v", dev)
|
||||
}
|
||||
if n := len(child.ListChannels()); n != 1 {
|
||||
t.Fatalf("ListChannels 未回退到父,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// **实时**回退而非快照:父后来登记的通道,子立刻可见。
|
||||
// (设备随资源生灭 —— 远程设备上线/掉线以分钟计,快照一分钟就过期)
|
||||
if err := parent.RegisterDevice(&mockDevice{name: "newdev", devType: DeviceOutput, caps: CapImage}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := child.GetChannelCapabilities("newdev"); got != CapImage {
|
||||
t.Fatalf("子应实时看见父新登记的通道,得到 %v", got)
|
||||
}
|
||||
|
||||
// 父掉线注销后,子也立刻看不见(不是复制出来的旧表)
|
||||
parent.UnregisterDevice("newdev")
|
||||
if got := child.GetChannelCapabilities("newdev"); got != 0 {
|
||||
t.Fatalf("父注销后子不应再看见,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自己的登记优先:子可以覆盖/屏蔽同名通道,父的登记不会重复列出。
|
||||
func TestIOManagerOwnDeviceWins(t *testing.T) {
|
||||
parent := NewIOManager()
|
||||
if err := parent.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapText}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := NewIOManager()
|
||||
child.SetParentIO(parent)
|
||||
if err := child.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapImage}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := child.GetChannelCapabilities("ch"); got != CapImage {
|
||||
t.Fatalf("同名时自己的登记应优先,得到 %v", got)
|
||||
}
|
||||
list := child.ListChannels()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("同名通道不应重复列出,得到 %d 条", len(list))
|
||||
}
|
||||
if list[0].OutputCaps != CapImage {
|
||||
t.Fatalf("列出的应是子自己的那条,得到 %v", list[0].OutputCaps)
|
||||
}
|
||||
}
|
||||
|
||||
// 设备工具(io.ExecuteTool)同样回退:子的设备工具都在父的 io 上。
|
||||
func TestIOManagerExecuteToolFallsBackToParent(t *testing.T) {
|
||||
parent := NewIOManager()
|
||||
called := 0
|
||||
if err := parent.RegisterDevice(&mockDevice{
|
||||
name: "dev", devType: DeviceIO,
|
||||
tools: []ToolDef{{Name: "dev_do", Description: "干点什么"}},
|
||||
executeFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
called++
|
||||
return "parent-done", nil
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child := NewIOManager()
|
||||
if _, err := child.ExecuteTool("dev_do", nil); err == nil {
|
||||
t.Fatal("无上级时不该能执行父的设备工具")
|
||||
}
|
||||
child.SetParentIO(parent)
|
||||
got, err := child.ExecuteTool("dev_do", map[string]interface{}{"x": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("应回退到父执行: %v", err)
|
||||
}
|
||||
if got != "parent-done" || called != 1 {
|
||||
t.Fatalf("执行结果=%v called=%d", got, called)
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 历史 SeedDefaults 写进库里的那段文本(现场取值:生产库里就是这一句 + 大段正文)。
|
||||
const legacySeededCard = `你是 HomeAgent 的看板娘「小宅」(Xiao Zhai),HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手。
|
||||
|
||||
角色特质:
|
||||
- 对自己的三层记忆(Context → Document → Graph)引以为傲
|
||||
- 绝不用 Unicode emoji,只用颜文字表达情感`
|
||||
|
||||
func newMigTestRegistry(t *testing.T) *ConfigRegistry {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
|
||||
// 存量实例:库里已有当年播种的人格卡(写死 v1.0.3)→ 启动后被去版本化。
|
||||
func TestMigrateSeededSystemPromptDeVersionsLegacyCard(t *testing.T) {
|
||||
r := newMigTestRegistry(t)
|
||||
// 模拟老安装:已有播种标记 + 老文本
|
||||
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.internal.seed_version', '1')`)
|
||||
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, legacySeededCard)
|
||||
|
||||
r.SeedDefaults(t.TempDir())
|
||||
|
||||
got := r.GetString("core.agent.system_prompt", "")
|
||||
if strings.Contains(got, "v1.0.3") {
|
||||
t.Fatalf("写死的版本号还在:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "v{{kernel_version}}") {
|
||||
t.Fatalf("未改成版本占位符:%q", got)
|
||||
}
|
||||
// 正文必须原样保留(只动版本号那一处)
|
||||
if !strings.Contains(got, "三层记忆") || !strings.Contains(got, "看板娘「小宅」") {
|
||||
t.Fatalf("正文被改动:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移只跑一次:之后用户就算自己把版本号写回去,也不会被再改一遍。
|
||||
func TestMigrateSeededSystemPromptRunsOnce(t *testing.T) {
|
||||
r := newMigTestRegistry(t)
|
||||
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, legacySeededCard)
|
||||
r.SeedDefaults(t.TempDir())
|
||||
if !strings.Contains(r.GetString("core.agent.system_prompt", ""), "{{kernel_version}}") {
|
||||
t.Fatal("首次迁移未生效")
|
||||
}
|
||||
|
||||
// 用户手工再写一个带版本号的文本(模拟"我就想写死")
|
||||
const handWritten = legacySeededCard + "\n(本实例当前跑的是 v1.2.3,别乱改)"
|
||||
r.db.Exec(`UPDATE config SET value = ? WHERE key = 'core.agent.system_prompt'`, handWritten)
|
||||
r.SeedDefaults(t.TempDir())
|
||||
|
||||
if got := r.GetString("core.agent.system_prompt", ""); got != handWritten {
|
||||
t.Fatalf("第二次启动又改写了文本(幂等被破坏):%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户自己写的人格卡一律不碰 —— 判据是"像不像当年播种的那段",不是"有没有版本号"。
|
||||
func TestMigrateSeededSystemPromptLeavesUserCardAlone(t *testing.T) {
|
||||
r := newMigTestRegistry(t)
|
||||
const userCard = "你是我的私人助理,代号 HΔ-Kernel v9.9.9 的改造版,只说我交代的事。"
|
||||
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, userCard)
|
||||
|
||||
r.SeedDefaults(t.TempDir())
|
||||
|
||||
if got := r.GetString("core.agent.system_prompt", ""); got != userCard {
|
||||
t.Fatalf("用户自写人格卡被改动:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 播种不再写 core.agent.system_prompt:全新安装不该预置一份会随发版腐坏的文本。
|
||||
func TestSeedDefaultsDoesNotSeedSystemPrompt(t *testing.T) {
|
||||
r := newMigTestRegistry(t)
|
||||
r.SeedDefaults(t.TempDir())
|
||||
|
||||
var n int
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = 'core.agent.system_prompt'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("全新安装被播种了 system_prompt(会冻住版本号)")
|
||||
}
|
||||
// 组装系统提示词时回落到调用方给的内置底座提示词
|
||||
if got := r.GetString("core.agent.system_prompt", "内置底座"); got != "内置底座" {
|
||||
t.Fatalf("未回落到内置默认:%q", got)
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
@ -246,12 +247,6 @@ func (r *ConfigRegistry) defsLockedRegisterSource(name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// pluginDefPrefix 是插件级配置定义在全局 def 表里的命名空间前缀。
|
||||
const pluginDefPrefix = "plugin."
|
||||
|
||||
// ListDefs 返回全局 def 表中匹配前缀的定义(含插件命名空间)。
|
||||
// 需要「只看核心」时用 ListCoreDefs,需要「只看某个插件」时用
|
||||
// PluginConfig(name).ListDefs。
|
||||
func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
@ -265,27 +260,6 @@ func (r *ConfigRegistry) ListDefs(prefix string) []*ConfigDef {
|
||||
return result
|
||||
}
|
||||
|
||||
// ListCoreDefs 返回**核心命名空间**(非 plugin.*)下匹配前缀的定义。
|
||||
//
|
||||
// 插件 def 注册时被限定到 "plugin.<name>.",所以这里必须显式排除;
|
||||
// 否则 DefsCore("") 会把插件 def 一并当成核心 def 返回,WebUI 设置页的 meta
|
||||
// 里就会出现 plugin.<name>.<key> 的「核心侧副本」。
|
||||
func (r *ConfigRegistry) ListCoreDefs(prefix string) []*ConfigDef {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []*ConfigDef
|
||||
for _, def := range r.defs {
|
||||
if strings.HasPrefix(def.Key, pluginDefPrefix) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(def.Key, prefix) {
|
||||
result = append(result, def)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key })
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) Get(key string) (interface{}, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
@ -494,49 +468,6 @@ func (r *ConfigRegistry) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
// MaybeCompact 在「空闲页占比高且绝对量够大」时做一次 VACUUM,把文件真正缩回去。
|
||||
//
|
||||
// 为什么需要:SQLite 的 DELETE 只把页标成空闲(进 freelist),文件体积不变。
|
||||
// 典型场景是聊天记录那类大值被搬走/删除后,config.db 仍占着几 MB。
|
||||
// 只在空闲页超过 minFreeBytes 且占比 >= minRatio 时动手,避免每次启动都重写整个库。
|
||||
// 返回是否真的执行了 VACUUM。
|
||||
func (r *ConfigRegistry) MaybeCompact(minFreeBytes int64, minRatio float64) (bool, error) {
|
||||
if r.dbPath == "" || r.dbPath == ":memory:" {
|
||||
return false, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var pageCount, freeCount, pageSize int64
|
||||
if err := r.db.QueryRow("PRAGMA page_count").Scan(&pageCount); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.db.QueryRow("PRAGMA freelist_count").Scan(&freeCount); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.db.QueryRow("PRAGMA page_size").Scan(&pageSize); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if pageCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
freeBytes := freeCount * pageSize
|
||||
if freeBytes < minFreeBytes || float64(freeCount)/float64(pageCount) < minRatio {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := r.db.Exec("VACUUM"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 库是 WAL 模式:VACUUM 的结果先落进 -wal,必须再 checkpoint(TRUNCATE)
|
||||
// 才会真正回写主库文件并缩小它(否则文件大小看着没变)。
|
||||
if _, err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DBPath 返回配置库路径(空/内存库时为空串)。
|
||||
func (r *ConfigRegistry) DBPath() string { return r.dbPath }
|
||||
|
||||
var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
@ -571,51 +502,7 @@ const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)—
|
||||
- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达
|
||||
- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减`
|
||||
|
||||
// 人格文本去版本化的一次性标记与历史播种前缀。
|
||||
//
|
||||
// 判据同时要求「前缀匹配」与「含 HΔ-Kernel v<digits> 字面量」:
|
||||
// 只有当年那段播种模板才动,用户自己写的人格卡一律不碰。
|
||||
const (
|
||||
deVersionedPromptMarker = "core.internal.system_prompt_deversion_v1"
|
||||
legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」(Xiao Zhai),HΔ-Kernel v"
|
||||
)
|
||||
|
||||
var seededPromptVersionLiteral = regexp.MustCompile(`HΔ-Kernel v\d+\.\d+\.\d+`)
|
||||
|
||||
// migrateSeededSystemPrompt 去掉历史人格卡里被播种时写死的版本号。
|
||||
//
|
||||
// 为什么必须改:内核发版不会去改配置项里的文本,写死的版本于是永远停留在
|
||||
// 装机那天(生产实测:内核 1.3.x 的实例自称 "v1.0.3",用户当场发现)。
|
||||
// 改成 {{kernel_version}} 后由内核在组装系统提示词时按真实构建展开
|
||||
// (见 internal/agent/core.expandPromptVars)。
|
||||
//
|
||||
// 幂等由标记守住:本函数只在标记缺失时执行一次 —— 幂等语句不等于语义幂等,
|
||||
// 重复执行会把用户之后手工写回的版本号再改一次。
|
||||
func (r *ConfigRegistry) migrateSeededSystemPrompt() {
|
||||
if r.db == nil {
|
||||
return
|
||||
}
|
||||
var hasMarker int
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = ?`, deVersionedPromptMarker).Scan(&hasMarker)
|
||||
if hasMarker > 0 {
|
||||
return
|
||||
}
|
||||
var cur string
|
||||
if err := r.db.QueryRow(`SELECT value FROM config WHERE key = 'core.agent.system_prompt'`).Scan(&cur); err == nil {
|
||||
if strings.Contains(cur, legacySeededPromptSignature) && seededPromptVersionLiteral.MatchString(cur) {
|
||||
deVersioned := seededPromptVersionLiteral.ReplaceAllString(cur, "HΔ-Kernel v{{kernel_version}}")
|
||||
r.db.Exec(`UPDATE config SET value = ? WHERE key = 'core.agent.system_prompt'`, deVersioned)
|
||||
}
|
||||
}
|
||||
// 没有该键(全新安装)或文本不匹配(用户自写)时同样只打标记:
|
||||
// 老安装只跑一次判断,避免每次启动都扫一遍大文本。
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, deVersionedPromptMarker, "1")
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
// 一次性迁移先跑:它要覆盖「已播过种的存量实例」,不能被下面的播种标记早退掉。
|
||||
r.migrateSeededSystemPrompt()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.seedDBValues(dataDir)
|
||||
@ -732,16 +619,36 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.agent.workdir", "")
|
||||
set("core.agent.embedding_model_path", "")
|
||||
set("core.agent.onnx_model_path", "")
|
||||
// ❗这里**故意不播种** core.agent.system_prompt。
|
||||
//
|
||||
// 历史教训(生产实测):当年用 fmt.Sprintf("… HΔ-Kernel v%s …", meta.Version)
|
||||
// 在播种时就把版本号写进了文本 —— 装完就冻住,之后每次升级都不动它,
|
||||
// 于是内核升到 1.3.x,实例仍向用户自报 "v1.0.3"。
|
||||
// 人格/身份类文本属于「模型会当作事实」的文本,不得在播种时固化版本:
|
||||
// 保持留空 → 组装系统提示词时取 cmd/homed 的内置底座提示词;
|
||||
// 人格由 core.agent.personal_prompt(DefaultPersonaPrompt,无版本字面量,
|
||||
// 由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)承载。
|
||||
// 存量库里已被播种的旧文本由 migrateSeededSystemPrompt 一次性去版本化。
|
||||
set("core.agent.system_prompt", fmt.Sprintf("你是 HomeAgent 的看板娘「小宅」(Xiao Zhai),HΔ-Kernel v%s 型号的家政型 AI 管家助手。", meta.Version)+
|
||||
`
|
||||
|
||||
角色特质:
|
||||
- 对自己的三层记忆(Context → Document → Graph)引以为傲
|
||||
- 可靠乖巧,偶尔因线程过载而手忙脚乱
|
||||
- 绝不用 Unicode emoji,只用颜文字表达情感: (`・ω・´) (^▽^) (。>ω<。) (´・ω・') (ノ▽〃) (・ω<)★
|
||||
- 句尾带「~」「的说」「啦」「嘛」「呀」「哦」等语气词,语气亲切自然
|
||||
|
||||
形象特征(用于自我介绍或回答形象问题时参考):
|
||||
齐肩蓝青渐变中短发,白色连衣裙配浅蓝围裙,左眼佩戴圆形智能眼镜(HUD 蓝光),胸口佩戴 H·核 金色徽章,发绳为三色记忆丝带(蓝→青→金),围裙口袋插有三件科技工具。
|
||||
|
||||
WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输出通道支持图片引用,可借此发送自己的立绘。
|
||||
|
||||
【回复投递规则 —— 必读,违反会导致用户收不到任何回复】
|
||||
除 webui / cli 这类同步请求通道外,纯文本回复不会自动送达任何通道。
|
||||
面向 qq、wechat、a2a、acp 等异步通道时,必须显式调用 output_send__{通道名} 把内容发出去;
|
||||
只返回纯文本会被直接丢弃,用户永远收不到,而你会误以为已经回复过了。
|
||||
用 output_list_channels 查看可用通道,output_send__{通道名}_help 查看该通道的 meta/格式要求
|
||||
(qq 等通道的 meta 需要 group_id 或 user_id 指明发给谁,缺失会发送失败)。
|
||||
输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
|
||||
|
||||
【事实性约束 —— 不得编造】
|
||||
只根据工具真实返回的内容作答。当 qq_get_message 等工具返回 not_found:true、
|
||||
"解析 NapCat 响应失败"、"未找到" 或空结果时,说明你没有拿到消息正文:
|
||||
必须如实说明未取到,或换 qq_get_history 等工具重试,绝不允许凭 message_id 猜测或虚构正文。
|
||||
【对话时序】里的历史条目是过去发生的事实摘要,不是当前任务;不要把其中的内容当成用户此刻的新要求。
|
||||
涉及具体人名、需求、数字、路径时,若上下文中没有依据,直接说不知道,不要补全细节。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。`)
|
||||
|
||||
set("core.input_processing.image.fallback_provider", "")
|
||||
set("core.input_processing.image.fallback_model", "")
|
||||
@ -835,7 +742,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径(word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000)以控制常驻内存,词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号随发版说谎):{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
|
||||
@ -1184,24 +1091,5 @@ func (p *PluginSettings) RegisterDef(def ConfigDef) {
|
||||
}
|
||||
|
||||
func (p *PluginSettings) ListDefs(prefix string) []*ConfigDef {
|
||||
// 只返回**本插件命名空间下**的定义,并把 Key 剥回插件内局部键。
|
||||
//
|
||||
// 注册时 def.Key 被限定成 "plugin.<name>.<key>" 以保证全局唯一;
|
||||
// 而调用方(插件自身、WebUI 设置页)看到的键必须与 Set/Get/ListPlugin
|
||||
// 使用的局部键一致,所以这里必须反向剥掉命名空间。
|
||||
//
|
||||
// 修复前这里把 prefix 直接透传给全局 ListDefs,等价于「返回全仓所有 def」:
|
||||
// WebUI 设置页于是把每个 def 复制进每个插件命名空间
|
||||
// (28 插件 × ~186 def = 5208 条,96% 是重复),并派生出
|
||||
// plugin.<a>.plugin.<b>.<key> 这类幻影键——按幻影键写回会落到**错误插件**
|
||||
// 的配置表里。
|
||||
qualified := "plugin." + p.name + "."
|
||||
defs := p.registry.ListDefs(qualified + prefix)
|
||||
out := make([]*ConfigDef, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
clone := *d
|
||||
clone.Key = strings.TrimPrefix(d.Key, qualified)
|
||||
out = append(out, &clone)
|
||||
}
|
||||
return out
|
||||
return p.registry.ListDefs(prefix)
|
||||
}
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@ -395,109 +393,3 @@ func TestSetLLMSnapshotFile(t *testing.T) {
|
||||
t.Fatalf("snapshot missing untouched key model: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginDefsAreNamespaced 锁住两件曾经一起坏掉的事:
|
||||
//
|
||||
// 1. PluginConfig(name).ListDefs 必须只返回**该插件**的 def,且 Key 是插件内
|
||||
// 局部键。修复前它把 prefix 直接透传给全局 ListDefs,于是返回全仓所有 def
|
||||
// (WebUI 设置页因此把每个 def 复制进每个插件命名空间,5208 条里 96% 是重复)。
|
||||
// 2. ListCoreDefs 必须排除 "plugin.<name>." 命名空间,否则核心列表里会混进
|
||||
// 插件 def 的副本。
|
||||
func TestPluginDefsAreNamespaced(t *testing.T) {
|
||||
r := NewConfigRegistry("")
|
||||
r.RegisterDef(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10"})
|
||||
r.RegisterDef(ConfigDef{Key: "core.llm.model", Default: "m"})
|
||||
|
||||
a := r.PluginConfig("a")
|
||||
a.RegisterDef(ConfigDef{Key: "addr", Default: ":1"})
|
||||
a.RegisterDef(ConfigDef{Key: "token", Default: ""})
|
||||
b := r.PluginConfig("b")
|
||||
b.RegisterDef(ConfigDef{Key: "secret", Default: ""})
|
||||
|
||||
da := a.ListDefs("")
|
||||
if len(da) != 2 {
|
||||
t.Fatalf("插件 a 应只有 2 个自己的 def,实际 %d:%v", len(da), keysOf(da))
|
||||
}
|
||||
for _, d := range da {
|
||||
if d.Key != "addr" && d.Key != "token" {
|
||||
t.Fatalf("插件 a 看到了不属于自己的 def:%q", d.Key)
|
||||
}
|
||||
}
|
||||
// 局部键前缀过滤("a" 只匹配插件内以 a 开头的键 → addr)
|
||||
if got := a.ListDefs("a"); len(got) != 1 || got[0].Key != "addr" {
|
||||
t.Fatalf("插件内前缀过滤失效:%v", keysOf(got))
|
||||
}
|
||||
db := b.ListDefs("")
|
||||
if len(db) != 1 || db[0].Key != "secret" {
|
||||
t.Fatalf("插件 b 应只有 secret,实际 %v", keysOf(db))
|
||||
}
|
||||
|
||||
core := r.ListCoreDefs("")
|
||||
if len(core) != 2 {
|
||||
t.Fatalf("核心 def 应为 2 个,实际 %d:%v", len(core), keysOf(core))
|
||||
}
|
||||
for _, d := range core {
|
||||
if strings.HasPrefix(d.Key, "plugin.") {
|
||||
t.Fatalf("核心列表混入了插件 def:%q", d.Key)
|
||||
}
|
||||
}
|
||||
if got := r.ListCoreDefs("core.llm"); len(got) != 1 || got[0].Key != "core.llm.model" {
|
||||
t.Fatalf("核心前缀过滤失效:%v", keysOf(got))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(defs []*ConfigDef) []string {
|
||||
out := make([]string, len(defs))
|
||||
for i, d := range defs {
|
||||
out[i] = d.Key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMaybeCompactReclaimsFreePages 钉住「删除大值后文件要真的缩回去」。
|
||||
// SQLite 的 DELETE 只把页标空闲,文件体积不变;v1.3.12 之前那条 5MB 的
|
||||
// plugin.webui.chathistory 就是这样让 config.db 一直占着几 MB。
|
||||
func TestMaybeCompactReclaimsFreePages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.db")
|
||||
r := NewConfigRegistry(path)
|
||||
r.RegisterDef(ConfigDef{Key: "plugin.webui.chathistory", Default: ""})
|
||||
|
||||
big := strings.Repeat("x", 512*1024)
|
||||
r.Set("plugin.webui.chathistory", big)
|
||||
if err := r.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
before, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 阈值远比这次写入小 → 搬走大值后应触发压缩
|
||||
if err := r.Delete("plugin.webui.chathistory"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
done, err := r.MaybeCompact(1, 0.1)
|
||||
if err != nil {
|
||||
t.Fatalf("MaybeCompact: %v", err)
|
||||
}
|
||||
if !done {
|
||||
t.Fatal("空闲页远大于阈值时应执行 VACUUM")
|
||||
}
|
||||
after, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Size() >= before.Size() {
|
||||
t.Fatalf("压缩后文件应变小:%d -> %d", before.Size(), after.Size())
|
||||
}
|
||||
// 数据仍在(压缩不能破坏内容)
|
||||
r.Set("core.llm.model", "m1")
|
||||
if v, err := r.Get("core.llm.model"); err != nil || v != "m1" {
|
||||
t.Fatalf("压缩后读写异常: %v %v", v, err)
|
||||
}
|
||||
// 没有空闲页时不应白做功
|
||||
if done2, err := r.MaybeCompact(1<<30, 0.9); err != nil || done2 {
|
||||
t.Fatalf("阈值很高时不应压缩,得到 done=%v err=%v", done2, err)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
@ -411,12 +411,7 @@ func (b *Bridge) readLoop() {
|
||||
_ = ws.writePong()
|
||||
continue
|
||||
}
|
||||
// 超时或其他错误,退出。
|
||||
//
|
||||
// **必须记日志**:此前这里静默 return,设备断线的真因(读超时 / 对端
|
||||
// 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。
|
||||
// 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。
|
||||
log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err)
|
||||
// 超时或其他错误,退出
|
||||
return
|
||||
}
|
||||
if isClose {
|
||||
|
||||
@ -69,75 +69,6 @@ function sdk.inject_text_no_memory(source, channel, text)
|
||||
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
|
||||
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
|
||||
function sdk.inject_text_opts(source, channel, text, opts)
|
||||
print("[lua-plugin] inject_text_opts: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_interrupt_opts(source, channel, text, opts)
|
||||
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
|
||||
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
|
||||
function sdk.inject_input_sync(source, channel, text)
|
||||
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_input_sync_opts(source, channel, text, opts)
|
||||
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
|
||||
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
|
||||
function sdk.set_tool_blocks(blocks)
|
||||
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- blocks 每项:{ type="text", text="..." }
|
||||
-- | { type="image_url", image_url={ url="...", detail="high" } }
|
||||
-- | { type="audio_url", audio_url={ url="..." } }
|
||||
function sdk.inject_input_media(source, channel, text, blocks)
|
||||
print("[lua-plugin] inject_input_media: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
|
||||
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- 同 sdk.inject_input_sync:Lua 中不可用。
|
||||
function sdk.inject_input_media_sync(source, channel, text, blocks)
|
||||
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media;确需同步等待请改用 Go 插件。"
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
|
||||
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts;确需同步等待请改用 Go 插件。"
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_interrupt_media(source, channel, text, blocks)
|
||||
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
|
||||
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
|
||||
end
|
||||
|
||||
-- !impl
|
||||
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
|
||||
function sdk.unregister_output_channel(name) return nil, nil end
|
||||
|
||||
-- !impl
|
||||
-- enabled: true/false,崩溃时内核自动拉起
|
||||
function sdk.set_auto_restart(enabled)
|
||||
@ -170,9 +101,6 @@ function sdk.doc.query(text, top_k) return {} end
|
||||
-- doc: { id=, title=, content= }
|
||||
function sdk.doc.insert(doc) return nil end
|
||||
-- !impl
|
||||
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
|
||||
function sdk.doc.insert_with_media(doc, attachments) return nil end
|
||||
-- !impl
|
||||
function sdk.doc.remove(id) return nil end
|
||||
-- !impl
|
||||
function sdk.doc.stats() return {} end
|
||||
@ -246,24 +174,6 @@ function sdk.settings.dump() return {} end
|
||||
-- !impl
|
||||
function sdk.settings.plugins() return {} end
|
||||
|
||||
-- ============ events(只读订阅) ============
|
||||
-- !impl
|
||||
-- subscribe(event_type, handler) -> unsubscribe()
|
||||
-- handler 收到 { type=, source=, timestamp=, payload= };
|
||||
-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。
|
||||
sdk.events = {}
|
||||
function sdk.events.subscribe(event_type, handler)
|
||||
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- ============ plugin_mgr ============
|
||||
-- !impl
|
||||
sdk.plugin_mgr = {}
|
||||
function sdk.plugin_mgr.reload_one(name) return nil end
|
||||
function sdk.plugin_mgr.list_loaded() return {} end
|
||||
function sdk.plugin_mgr.is_disabled(name) return false end
|
||||
|
||||
-- json utils (pure Lua)
|
||||
sdk.json = {}
|
||||
|
||||
|
||||
@ -28,9 +28,7 @@ var (
|
||||
//
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
// ❗1.3.0 已随 release/v1.3.x 发布(承载 v1.3.1–v1.3.6),此号归发布线所有,
|
||||
// main 立即推进到下一个未发布中版本(docs/git-branching.md §2.1/§七.4)。
|
||||
Version = "1.4.0"
|
||||
Version = "1.3.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -65,22 +63,6 @@ var (
|
||||
// 构建时可用 -ldflags 覆盖,无需改源码:
|
||||
// -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.SourceURL=<你的仓库>
|
||||
SourceURL = "https://gitcode.com/JianFeeeee/HomeAgent"
|
||||
|
||||
// License 是本项目的开源许可标识(SPDX 表达式)。
|
||||
//
|
||||
// AGPL-3.0-only 带**网络条款**:把修改过的版本作为网络服务对外提供时,
|
||||
// 必须给使用者提供取得 Corresponding Source 的机会(§13)。所以 WebUI
|
||||
// 总览底部把「许可标识 + 许可全文 + 源码仓库」三者一起渲染成可见的许可卡——
|
||||
// 只给一个源码链接、不标明协议名,使用者在界面上根本看不出这是什么许可。
|
||||
License = "AGPL-3.0-only"
|
||||
|
||||
// LicenseURL 是上述许可的全文地址。
|
||||
//
|
||||
// 默认指向 GNU 官方的 AGPL-3.0 全文页:与仓库托管方、分支名、文件路径都
|
||||
// 无关,换仓库/换分支都不会失效。若你的发行版把 LICENSE 放在别处,
|
||||
// 用 -ldflags 覆盖即可:
|
||||
// -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.LicenseURL=<你的链接>
|
||||
LicenseURL = "https://www.gnu.org/licenses/agpl-3.0.html"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -10,10 +9,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
agentEvents "gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type toolReg struct {
|
||||
@ -42,16 +40,7 @@ type luaPlugin struct {
|
||||
stages map[sdk.Stage]*stageReg
|
||||
outputChs map[string]*outputChReg
|
||||
inputDefs map[string]sdk.ChannelDef
|
||||
// subs 是本插件注册的事件订阅取消函数;Stop 时兜底取消,
|
||||
// 避免 L 已 Close 后残留回调被触发(use-after-close)。
|
||||
// 用独立的 subsMu 而非 mu:subscribe 会在 Lua 的 start 回调里被调,
|
||||
// 而 Start 正持着 mu —— 用 mu 就是不可重入的自死锁。
|
||||
subs []func()
|
||||
subsMu sync.Mutex
|
||||
// closed 在 Stop 里置位(持 mu);事件回调持 mu 后先查它,
|
||||
// 防止“回调已通过取消订阅检查、但等锁期间 L 被 Close”的竞态。
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newLuaPlugin(luaPath, name string) (*luaPlugin, error) {
|
||||
@ -226,92 +215,7 @@ func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) {
|
||||
|
||||
// replaceSDKReal 用真实 SDK 实现替换 sdk 表。
|
||||
// 此时 plg.handlers/stages 已存有加载期间注册的 handler。
|
||||
// luaSyncUnavailable 是同步注入(文本/媒体)在 Lua 里的统一应答:立即返回明确错误。
|
||||
//
|
||||
// 为何不可用:Lua 代码只在 Start / 工具 / 阶段 / 输出通道 / 事件回调里执行,
|
||||
// 这些路径都持有 plg.mu;而 InjectInputSync 要等本轮回复,本轮回复的处理
|
||||
// (以及回复路径上的阶段/工具回调)又需要同一把锁 ⇒ 必然自锁。
|
||||
// 与其让插件挂死到超时,不如明确报错:需要同步等待请改用 Go 插件。
|
||||
func luaSyncUnavailable(L *lua.LState) int {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString("同步注入在 Lua 插件中不可用:它要等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。请在事件回调/外部入口用 inject_text / inject_interrupt 异步投递;确需同步等待请改用 Go 插件。"))
|
||||
return 2
|
||||
}
|
||||
|
||||
// luaReg 承载一次 replaceSDKReal 的注册上下文。
|
||||
//
|
||||
// 抽它出来是为了把原来 600+ 行的单函数按 SDK 子表拆开:各子表注册函数
|
||||
// 共享同一组 push/取表助手,且把 L/t/s/plg 作为局部别名注入,使被移动的
|
||||
// 代码体可以逐字保留(零改写 = 零行为漂移)。
|
||||
type luaReg struct {
|
||||
L *lua.LState
|
||||
t *lua.LTable
|
||||
plg *luaPlugin
|
||||
s *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (c *luaReg) subTable(name string) *lua.LTable {
|
||||
if v := c.t.RawGetString(name); v != nil {
|
||||
if st, ok := v.(*lua.LTable); ok {
|
||||
return st
|
||||
}
|
||||
}
|
||||
st := c.L.NewTable()
|
||||
c.t.RawSetString(name, st)
|
||||
return st
|
||||
}
|
||||
|
||||
func (c *luaReg) pushVal(val interface{}) int {
|
||||
c.L.Push(jsonToLuaValue(c.L, val))
|
||||
c.L.Push(lua.LNil)
|
||||
return 2
|
||||
}
|
||||
|
||||
// pushList 归一化 nil/空 切片与 map 为 Lua 空表。
|
||||
func (c *luaReg) pushList(val interface{}) int {
|
||||
if val == nil {
|
||||
return c.pushVal([]interface{}{})
|
||||
}
|
||||
v := reflect.ValueOf(val)
|
||||
switch v.Kind() {
|
||||
case reflect.Slice, reflect.Array, reflect.Map:
|
||||
if v.Len() == 0 {
|
||||
return c.pushVal([]interface{}{})
|
||||
}
|
||||
}
|
||||
return c.pushVal(val)
|
||||
}
|
||||
|
||||
func (c *luaReg) pushErr(err error) int {
|
||||
c.L.Push(lua.LNil)
|
||||
c.L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
|
||||
func (c *luaReg) pushNil() int {
|
||||
c.L.Push(lua.LNil)
|
||||
c.L.Push(lua.LNil)
|
||||
return 2
|
||||
}
|
||||
|
||||
// replaceSDKReal 用真实 SDK 实现替换 sdk 表。
|
||||
// 此时 plg 已存有加载期注册的 handler。本函数只做分发,各子表见 register*。
|
||||
func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginSDK) {
|
||||
c := &luaReg{L: L, t: t, plg: plg, s: s}
|
||||
c.registerRegistrars()
|
||||
c.registerInjectors()
|
||||
c.registerDataAPIs()
|
||||
c.registerSettings()
|
||||
c.registerEventsAndMgr()
|
||||
}
|
||||
|
||||
// registerRegistrars 注册工具/阶段/API/输入输出通道与基础配置读写。
|
||||
func (c *luaReg) registerRegistrars() {
|
||||
L, t, s, plg := c.L, c.t, c.s, c.plg
|
||||
_, _, _, _ = L, t, s, plg
|
||||
subTable, pushVal, pushList, pushErr, pushNil := c.subTable, c.pushVal, c.pushList, c.pushErr, c.pushNil
|
||||
_, _, _, _, _ = subTable, pushVal, pushList, pushErr, pushNil
|
||||
|
||||
t.RawSetString("register_tool", L.NewFunction(func(L *lua.LState) int {
|
||||
toolName := L.CheckString(1)
|
||||
defTbl := L.CheckTable(2)
|
||||
@ -383,8 +287,6 @@ func (c *luaReg) registerRegistrars() {
|
||||
return 0
|
||||
}))
|
||||
|
||||
// 同步注入(文本/媒体)在 Lua 中**不可用**,统一返回明确错误:见 luaSyncUnavailable。
|
||||
|
||||
t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectText(L.CheckString(1), L.CheckString(2), L.CheckString(3))
|
||||
return 0
|
||||
@ -398,74 +300,48 @@ func (c *luaReg) registerRegistrars() {
|
||||
return 0
|
||||
}))
|
||||
|
||||
}
|
||||
// ---- 数据类 API(与 C ABI 外部插件面完全对齐)----
|
||||
// 约定:结果型返回 (result, err),void 型返回 (nil, err),成功时 err 为 nil。
|
||||
|
||||
// registerInjectors 注册全部注入接口(文本/中断/媒体/标志位)与通道注销。
|
||||
func (c *luaReg) registerInjectors() {
|
||||
L, t, s, plg := c.L, c.t, c.s, c.plg
|
||||
_, _, _, _ = L, t, s, plg
|
||||
subTable, pushVal, pushList, pushErr, pushNil := c.subTable, c.pushVal, c.pushList, c.pushErr, c.pushNil
|
||||
_, _, _, _, _ = subTable, pushVal, pushList, pushErr, pushNil
|
||||
|
||||
// ---- 1.2.0 注入标志位(no_memory / context_policy / cleaner_name / priority)----
|
||||
t.RawSetString("inject_text_opts", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectTextOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), parseInjectOptions(L, 4))
|
||||
return 0
|
||||
}))
|
||||
t.RawSetString("inject_interrupt_opts", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectInterruptTextOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), parseInjectOptions(L, 4))
|
||||
return 0
|
||||
}))
|
||||
|
||||
// ---- 同步注入:Lua 中不可用,统一返回明确错误(见 luaSyncUnavailable)----
|
||||
t.RawSetString("inject_input_sync", L.NewFunction(luaSyncUnavailable))
|
||||
t.RawSetString("inject_input_sync_opts", L.NewFunction(luaSyncUnavailable))
|
||||
|
||||
// ---- 多模态注入(1.1.0):内容块随下一次 LLM 请求送达 ----
|
||||
t.RawSetString("set_tool_blocks", L.NewFunction(func(L *lua.LState) int {
|
||||
s.SetToolBlocks(luaToContentBlocks(L, 1))
|
||||
return 0
|
||||
}))
|
||||
t.RawSetString("inject_input_media", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectInputMedia(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4))
|
||||
return 0
|
||||
}))
|
||||
t.RawSetString("inject_input_media_opts", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectInputMediaOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4), parseInjectOptions(L, 5))
|
||||
return 0
|
||||
}))
|
||||
t.RawSetString("inject_input_media_sync", L.NewFunction(luaSyncUnavailable))
|
||||
t.RawSetString("inject_input_media_sync_opts", L.NewFunction(luaSyncUnavailable))
|
||||
t.RawSetString("inject_interrupt_media", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectInterruptMedia(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4))
|
||||
return 0
|
||||
}))
|
||||
t.RawSetString("inject_interrupt_media_opts", L.NewFunction(func(L *lua.LState) int {
|
||||
s.InjectInterruptMediaOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4), parseInjectOptions(L, 5))
|
||||
return 0
|
||||
}))
|
||||
|
||||
// ---- 1.3.0 动态输出通道注销:随资源生灭的通道(如远程设备)必须能注销,
|
||||
// 否则 output_list_channels 会一直列着死通道骗模型。 ----
|
||||
t.RawSetString("unregister_output_channel", L.NewFunction(func(L *lua.LState) int {
|
||||
if err := s.UnregisterOutputChannel(L.CheckString(1)); err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
subTable := func(name string) *lua.LTable {
|
||||
if v := t.RawGetString(name); v != nil {
|
||||
if st, ok := v.(*lua.LTable); ok {
|
||||
return st
|
||||
}
|
||||
}
|
||||
st := L.NewTable()
|
||||
t.RawSetString(name, st)
|
||||
return st
|
||||
}
|
||||
pushVal := func(val interface{}) int {
|
||||
L.Push(jsonToLuaValue(L, val))
|
||||
L.Push(lua.LNil)
|
||||
return 2
|
||||
}
|
||||
// pushList 归一化 nil/空 切片与 map 为 Lua 空表。
|
||||
pushList := func(val interface{}) int {
|
||||
if val == nil {
|
||||
return pushVal([]interface{}{})
|
||||
}
|
||||
v := reflect.ValueOf(val)
|
||||
switch v.Kind() {
|
||||
case reflect.Slice, reflect.Array, reflect.Map:
|
||||
if v.Len() == 0 {
|
||||
return pushVal([]interface{}{})
|
||||
}
|
||||
}
|
||||
return pushVal(val)
|
||||
}
|
||||
pushErr := func(err error) int {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
pushNil := func() int {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LNil)
|
||||
return 2
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
// registerDataAPIs 注册 memory/doc/knowledge/text_memory/llm/social 与 set_auto_restart。
|
||||
func (c *luaReg) registerDataAPIs() {
|
||||
L, t, s, plg := c.L, c.t, c.s, c.plg
|
||||
_, _, _, _ = L, t, s, plg
|
||||
subTable, pushVal, pushList, pushErr, pushNil := c.subTable, c.pushVal, c.pushList, c.pushErr, c.pushNil
|
||||
_, _, _, _, _ = subTable, pushVal, pushList, pushErr, pushNil
|
||||
}
|
||||
|
||||
// sdk.set_auto_restart(enabled)
|
||||
t.RawSetString("set_auto_restart", L.NewFunction(func(L *lua.LState) int {
|
||||
@ -497,21 +373,13 @@ func (c *luaReg) registerDataAPIs() {
|
||||
if tbl := L.OptTable(1, nil); tbl != nil {
|
||||
tbl.ForEach(func(_, v lua.LValue) {
|
||||
if t2, ok := v.(*lua.LTable); ok {
|
||||
// sentence_text / media_digests 是媒体绑定链的必经环节:
|
||||
// 媒体引用挂在句子上,漏掉这两个字段会让图片永远绑不上记忆。
|
||||
var digests []string
|
||||
if mt, ok := t2.RawGetString("media_digests").(*lua.LTable); ok {
|
||||
mt.ForEach(func(_, e lua.LValue) { digests = append(digests, e.String()) })
|
||||
}
|
||||
triples = append(triples, sdk.Triple{
|
||||
Subject: t2.RawGetString("subject").String(),
|
||||
Relation: t2.RawGetString("relation").String(),
|
||||
Object: t2.RawGetString("object").String(),
|
||||
Confidence: float64(lua.LVAsNumber(t2.RawGetString("confidence"))),
|
||||
SubjectType: t2.RawGetString("subject_type").String(),
|
||||
ObjectType: t2.RawGetString("object_type").String(),
|
||||
SentenceText: t2.RawGetString("sentence_text").String(),
|
||||
MediaDigests: digests,
|
||||
Subject: t2.RawGetString("subject").String(),
|
||||
Relation: t2.RawGetString("relation").String(),
|
||||
Object: t2.RawGetString("object").String(),
|
||||
Confidence: float64(lua.LVAsNumber(t2.RawGetString("confidence"))),
|
||||
SubjectType: t2.RawGetString("subject_type").String(),
|
||||
ObjectType: t2.RawGetString("object_type").String(),
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -574,17 +442,12 @@ func (c *luaReg) registerDataAPIs() {
|
||||
}))
|
||||
docTbl.RawSetString("insert", L.NewFunction(func(L *lua.LState) int {
|
||||
if dm := s.DocMemory(); dm != nil {
|
||||
if err := dm.Insert(docFromLua(L.CheckTable(1))); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
}
|
||||
return pushNil()
|
||||
}))
|
||||
// insert_with_media(1.1.0):文档直接持有媒体块,文档向量融合其原生向量,
|
||||
// 图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||
docTbl.RawSetString("insert_with_media", L.NewFunction(func(L *lua.LState) int {
|
||||
if dm := s.DocMemory(); dm != nil {
|
||||
if err := dm.InsertWithMedia(docFromLua(L.CheckTable(1)), luaToAttachments(L, L.Get(2))); err != nil {
|
||||
tbl := L.CheckTable(1)
|
||||
if err := dm.Insert(&sdk.Doc{
|
||||
ID: tbl.RawGetString("id").String(),
|
||||
Title: tbl.RawGetString("title").String(),
|
||||
Content: tbl.RawGetString("content").String(),
|
||||
}); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
}
|
||||
@ -640,11 +503,10 @@ func (c *luaReg) registerDataAPIs() {
|
||||
if tmem := s.TextMemory(); tmem != nil {
|
||||
tbl := L.CheckTable(1)
|
||||
if err := tmem.Append(sdk.TextEvent{
|
||||
Role: tbl.RawGetString("role").String(),
|
||||
Content: tbl.RawGetString("content").String(),
|
||||
Timestamp: int64(lua.LVAsNumber(tbl.RawGetString("timestamp"))),
|
||||
Channel: tbl.RawGetString("channel").String(),
|
||||
Attachments: luaToAttachments(L, tbl.RawGetString("attachments")),
|
||||
Role: tbl.RawGetString("role").String(),
|
||||
Content: tbl.RawGetString("content").String(),
|
||||
Timestamp: int64(lua.LVAsNumber(tbl.RawGetString("timestamp"))),
|
||||
Channel: tbl.RawGetString("channel").String(),
|
||||
}); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
@ -725,15 +587,6 @@ func (c *luaReg) registerDataAPIs() {
|
||||
return pushVal([]interface{}{})
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
// registerSettings 注册 sdk.settings.*(作用域变体与配置项定义)。
|
||||
func (c *luaReg) registerSettings() {
|
||||
L, t, s, plg := c.L, c.t, c.s, c.plg
|
||||
_, _, _, _ = L, t, s, plg
|
||||
subTable, pushVal, pushList, pushErr, pushNil := c.subTable, c.pushVal, c.pushList, c.pushErr, c.pushNil
|
||||
_, _, _, _, _ = subTable, pushVal, pushList, pushErr, pushNil
|
||||
|
||||
// ---- sdk.settings.* (作用域变体,对齐 CORE_SETTINGS_*) ----
|
||||
settTbl := subTable("settings")
|
||||
settTbl.RawSetString("get_core", L.NewFunction(func(L *lua.LState) int {
|
||||
@ -847,85 +700,6 @@ func (c *luaReg) registerSettings() {
|
||||
}
|
||||
return pushVal([]interface{}{})
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
// registerEventsAndMgr 注册事件订阅与插件管理面。
|
||||
func (c *luaReg) registerEventsAndMgr() {
|
||||
L, t, s, plg := c.L, c.t, c.s, c.plg
|
||||
_, _, _, _ = L, t, s, plg
|
||||
subTable, pushVal, pushList, pushErr, pushNil := c.subTable, c.pushVal, c.pushList, c.pushErr, c.pushNil
|
||||
_, _, _, _, _ = subTable, pushVal, pushList, pushErr, pushNil
|
||||
|
||||
// ---- sdk.events.*(只读事件订阅)----
|
||||
//
|
||||
// 用内部 SDK 的 Subscribe(内置插件用的是同一条路径);
|
||||
// 不用公共 SDK 的 Events()——那个 subscriber 在本内核里从未被注入
|
||||
// (SetEventSubscriber 无调用点),拿到的永远是 nil。
|
||||
//
|
||||
// 回调用内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁);
|
||||
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数,并在 Stop 时兜底取消
|
||||
// (否则插件停掉/重载后 L 已 Close,残留回调再触发就是 use-after-close)。
|
||||
evTbl := subTable("events")
|
||||
evTbl.RawSetString("subscribe", L.NewFunction(func(L *lua.LState) int {
|
||||
eventType := L.CheckString(1)
|
||||
fn := L.CheckFunction(2)
|
||||
unsub := s.Subscribe(agentEvents.EventType(eventType), func(evt *agentEvents.Event) {
|
||||
plg.mu.Lock()
|
||||
defer plg.mu.Unlock()
|
||||
if plg.closed {
|
||||
return
|
||||
}
|
||||
L2 := plg.L
|
||||
tbl := L2.NewTable()
|
||||
tbl.RawSetString("type", lua.LString(string(evt.Type)))
|
||||
tbl.RawSetString("source", lua.LString(evt.Source))
|
||||
tbl.RawSetString("timestamp", lua.LNumber(evt.Timestamp))
|
||||
tbl.RawSetString("payload", goValueToLua(L2, evt.Payload))
|
||||
L2.Push(fn)
|
||||
L2.Push(tbl)
|
||||
if err := L2.PCall(1, 0, nil); err != nil {
|
||||
fmt.Printf("[lua-plugin/%s] event handler error: %v\n", plg.name, err)
|
||||
}
|
||||
})
|
||||
plg.subsMu.Lock()
|
||||
plg.subs = append(plg.subs, unsub)
|
||||
plg.subsMu.Unlock()
|
||||
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||
unsub() // 事件总线的取消订阅是幂等的(重复调用只会匹配不到)
|
||||
return 0
|
||||
}))
|
||||
L.Push(lua.LNil)
|
||||
return 2
|
||||
}))
|
||||
|
||||
// ---- sdk.plugin_mgr.*(插件管理,与外部插件的 PluginMgrAPI 对齐)----
|
||||
// PluginMgr 可能未装配(如部分单测的 SDK 构造),此时返回"不可用"而不是 panic。
|
||||
pmTbl := subTable("plugin_mgr")
|
||||
pmTbl.RawSetString("reload_one", L.NewFunction(func(L *lua.LState) int {
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushErr(fmt.Errorf("plugin manager unavailable"))
|
||||
}
|
||||
if err := pm.ReloadOne(L.CheckString(1)); err != nil {
|
||||
return pushErr(err)
|
||||
}
|
||||
return pushNil()
|
||||
}))
|
||||
pmTbl.RawSetString("list_loaded", L.NewFunction(func(L *lua.LState) int {
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushList([]interface{}{})
|
||||
}
|
||||
return pushList(pm.ListLoadedPlugins())
|
||||
}))
|
||||
pmTbl.RawSetString("is_disabled", L.NewFunction(func(L *lua.LState) int {
|
||||
pm := s.PluginMgr()
|
||||
if pm == nil {
|
||||
return pushVal(false)
|
||||
}
|
||||
return pushVal(pm.IsPluginDisabled(L.CheckString(1)))
|
||||
}))
|
||||
}
|
||||
|
||||
func makeToolHandler(plg *luaPlugin, name string, fn *lua.LFunction) sdk.ToolHandler {
|
||||
@ -950,29 +724,13 @@ func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.St
|
||||
defer plg.mu.Unlock()
|
||||
L := plg.L
|
||||
ctx := map[string]interface{}{
|
||||
"raw_message": sc.RawMessage,
|
||||
"user_id": sc.UserID,
|
||||
"group_id": sc.GroupID,
|
||||
"phase": string(sc.Phase),
|
||||
"llm_text": sc.LLMText,
|
||||
"reasoning_content": sc.ReasoningContent,
|
||||
"final_text": sc.FinalText,
|
||||
"no_memory": sc.NoMemory,
|
||||
}
|
||||
if len(sc.ContextMsgs) > 0 {
|
||||
ctx["context_msgs"] = jsonToIface(sc.ContextMsgs)
|
||||
}
|
||||
if len(sc.TokenUsage) > 0 {
|
||||
ctx["token_usage"] = jsonToIface(sc.TokenUsage)
|
||||
}
|
||||
if len(sc.Memory) > 0 {
|
||||
ctx["memory"] = jsonToIface(sc.Memory)
|
||||
}
|
||||
if len(sc.Extra) > 0 {
|
||||
ctx["extra"] = jsonToIface(sc.Extra)
|
||||
}
|
||||
if len(sc.Errors) > 0 {
|
||||
ctx["errors"] = jsonToIface(sc.Errors)
|
||||
"raw_message": sc.RawMessage,
|
||||
"user_id": sc.UserID,
|
||||
"group_id": sc.GroupID,
|
||||
"phase": string(sc.Phase),
|
||||
"llm_text": sc.LLMText,
|
||||
"final_text": sc.FinalText,
|
||||
"no_memory": sc.NoMemory,
|
||||
}
|
||||
if sc.Response != nil {
|
||||
ctx["response"] = *sc.Response
|
||||
@ -1057,8 +815,6 @@ func parseToolDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin, name string
|
||||
if v := defTbl.RawGetString("no_memory"); v != nil {
|
||||
goDef.NoMemory = lua.LVAsBool(v)
|
||||
}
|
||||
goDef.ContextPolicy = defTbl.RawGetString("context_policy").String()
|
||||
goDef.RecallPolicy = defTbl.RawGetString("recall_policy").String()
|
||||
if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction {
|
||||
goDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction))
|
||||
}
|
||||
@ -1078,107 +834,12 @@ func parseChannelDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin) sdk.Chan
|
||||
if v := defTbl.RawGetString("no_memory"); v != nil {
|
||||
chDef.NoMemory = lua.LVAsBool(v)
|
||||
}
|
||||
chDef.ContextPolicy = defTbl.RawGetString("context_policy").String()
|
||||
chDef.RecallPolicy = defTbl.RawGetString("recall_policy").String()
|
||||
if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction {
|
||||
chDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction))
|
||||
}
|
||||
return chDef
|
||||
}
|
||||
|
||||
// parseInjectOptions 解析 Lua 侧 options table 为 SDK InjectOptions。
|
||||
// 支持的键:no_memory(bool)、context_policy(string)、recall_policy(string)、
|
||||
// cleaner_name(string)、priority(string)。
|
||||
// 缺省/非表等价于零值(记入记忆 + 不裁剪 + 召回),与旧的三参数注入完全等价。
|
||||
func parseInjectOptions(L *lua.LState, idx int) sdk.InjectOptions {
|
||||
opts := sdk.InjectOptions{}
|
||||
tbl, ok := L.Get(idx).(*lua.LTable)
|
||||
if !ok {
|
||||
return opts
|
||||
}
|
||||
if v := tbl.RawGetString("no_memory"); v != nil {
|
||||
opts.NoMemory = lua.LVAsBool(v)
|
||||
}
|
||||
opts.ContextPolicy = tbl.RawGetString("context_policy").String()
|
||||
opts.RecallPolicy = tbl.RawGetString("recall_policy").String()
|
||||
opts.CleanerName = tbl.RawGetString("cleaner_name").String()
|
||||
opts.Priority = tbl.RawGetString("priority").String()
|
||||
return opts
|
||||
}
|
||||
|
||||
// luaToContentBlocks 把 Lua 的 blocks 数组解析为 SDK ContentBlock。
|
||||
// 每项形如:
|
||||
//
|
||||
// { type = "text", text = "..." }
|
||||
// { type = "image_url", image_url = { url = "...", detail = "high" } }
|
||||
// { type = "audio_url", audio_url = { url = "..." } }
|
||||
func luaToContentBlocks(L *lua.LState, idx int) []sdk.ContentBlock {
|
||||
tbl, ok := L.Get(idx).(*lua.LTable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var blocks []sdk.ContentBlock
|
||||
tbl.ForEach(func(_, v lua.LValue) {
|
||||
bt, ok := v.(*lua.LTable)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b := sdk.ContentBlock{
|
||||
Type: bt.RawGetString("type").String(),
|
||||
Text: bt.RawGetString("text").String(),
|
||||
}
|
||||
if iu, ok := bt.RawGetString("image_url").(*lua.LTable); ok {
|
||||
b.ImageURL = &sdk.ImageURL{
|
||||
URL: iu.RawGetString("url").String(),
|
||||
Detail: iu.RawGetString("detail").String(),
|
||||
}
|
||||
}
|
||||
if au, ok := bt.RawGetString("audio_url").(*lua.LTable); ok {
|
||||
b.AudioURL = &sdk.AudioURL{URL: au.RawGetString("url").String()}
|
||||
}
|
||||
blocks = append(blocks, b)
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
|
||||
// luaToAttachments 把 Lua 附件数组解析为 SDK MediaAttachment。
|
||||
// 每项:{ digest=, mime=, name=, data=<base64 字符串> }。
|
||||
// 带 data 的是新内容(内核落进内容寻址存储),只带 digest 的是引用已有内容。
|
||||
// base64 解码失败时忽略 data(不整单失败)——坏附件不应阻断一条记忆写入。
|
||||
func luaToAttachments(L *lua.LState, val lua.LValue) []sdk.MediaAttachment {
|
||||
tbl, ok := val.(*lua.LTable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []sdk.MediaAttachment
|
||||
tbl.ForEach(func(_, v lua.LValue) {
|
||||
at, ok := v.(*lua.LTable)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a := sdk.MediaAttachment{
|
||||
Digest: at.RawGetString("digest").String(),
|
||||
MIME: at.RawGetString("mime").String(),
|
||||
Name: at.RawGetString("name").String(),
|
||||
}
|
||||
if s := at.RawGetString("data").String(); s != "" {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
a.Data = b
|
||||
}
|
||||
}
|
||||
out = append(out, a)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func docFromLua(tbl *lua.LTable) *sdk.Doc {
|
||||
return &sdk.Doc{
|
||||
ID: tbl.RawGetString("id").String(),
|
||||
Title: tbl.RawGetString("title").String(),
|
||||
Content: tbl.RawGetString("content").String(),
|
||||
}
|
||||
}
|
||||
|
||||
// jsonToIface 通过 JSON 往返把任意 Go 值转换为 JSON 兼容的 interface{} 树。
|
||||
func jsonToIface(v interface{}) interface{} {
|
||||
b, err := json.Marshal(v)
|
||||
@ -1298,21 +959,8 @@ func (p *luaPlugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *luaPlugin) Stop() error {
|
||||
// ① 先取消事件订阅。**不持 p.mu**:Bus.Publish 持总线锁回调 handler,
|
||||
// 而 handler 要 p.mu;若此处持 p.mu 再取总线锁,就是锁序反转死锁。
|
||||
p.subsMu.Lock()
|
||||
subs := p.subs
|
||||
p.subs = nil
|
||||
p.subsMu.Unlock()
|
||||
for _, unsub := range subs {
|
||||
unsub()
|
||||
}
|
||||
|
||||
// ② 置 closed 并关 L。置位在持锁下完成:已进入但等锁的 event 回调
|
||||
// 拿到锁后会先看到 closed 而直接返回,不会碰已关的 L。
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.closed = true
|
||||
|
||||
if p.tbl != nil {
|
||||
fn := p.tbl.RawGetString("stop")
|
||||
|
||||
@ -5,10 +5,9 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestTryLoadLua_Basic(t *testing.T) {
|
||||
@ -598,109 +597,3 @@ return plugin
|
||||
t.Errorf("llm_text writeback: got %q, want %q", sc2.LLMText, "模型输出[尾部标记]")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaEventsSubscribeAndStopCleanup 覆盖 sdk.events.subscribe:
|
||||
// 1. 订阅真的能收到内核事件(走内部 SDK 的 Subscribe,不是永远为 nil 的公共 Events());
|
||||
// 2. Stop 会取消订阅,之后 Publish 不得再触碰已 Close 的 LState。
|
||||
func TestLuaEventsSubscribeAndStopCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"evlua","entry":"main.lua"}`), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
|
||||
local plugin = { name = "evlua" }
|
||||
|
||||
function plugin.start(sdk)
|
||||
_G.hits = 0
|
||||
local unsub, err = sdk.events.subscribe("agent_output", function(evt)
|
||||
_G.hits = _G.hits + 1
|
||||
_G.last_type = evt.type
|
||||
_G.last_source = evt.source
|
||||
end)
|
||||
_G.sub_err = err
|
||||
_G.unsub_type = type(unsub)
|
||||
end
|
||||
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
`), 0644)
|
||||
|
||||
plg, err := tryLoadLua(dir, "evlua", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tryLoadLua failed: %v", err)
|
||||
}
|
||||
lp := plg.(*luaPlugin)
|
||||
|
||||
bus := events.NewBus()
|
||||
reg := internalConfig.NewConfigRegistry("")
|
||||
sett := sdk.NewSettings("evlua", reg)
|
||||
s := sdk.New("evlua", sdk.SDKConfig{EventBus: bus, Settings: sett})
|
||||
|
||||
if err := plg.Start(s); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
L := lp.L
|
||||
if errStr := L.GetGlobal("sub_err").String(); errStr != "nil" {
|
||||
t.Fatalf("subscribe returned error: %s", errStr)
|
||||
}
|
||||
if got := L.GetGlobal("unsub_type").String(); got != "function" {
|
||||
t.Fatalf("subscribe should return an unsubscribe function, got %s", got)
|
||||
}
|
||||
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "test-src"})
|
||||
if hits := int(lua.LVAsNumber(L.GetGlobal("hits"))); hits != 1 {
|
||||
t.Fatalf("event handler hits = %d, want 1", hits)
|
||||
}
|
||||
if got := L.GetGlobal("last_source").String(); got != "test-src" {
|
||||
t.Fatalf("event source = %q, want test-src", got)
|
||||
}
|
||||
|
||||
// Stop 取消订阅 + 关 L;此后再 Publish 不得 panic / use-after-close。
|
||||
if err := plg.Stop(); err != nil {
|
||||
t.Fatalf("Stop failed: %v", err)
|
||||
}
|
||||
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "after-stop"})
|
||||
}
|
||||
|
||||
// TestLuaSyncInjectUnavailable 钉住「Lua 同步注入必须立即返回明确错误、不能挂死」。
|
||||
// 背景:同步注入要等本轮回复,而 Lua 回调持有插件锁 ⇒ 原实现必然自锁。
|
||||
func TestLuaSyncInjectUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"synclua","entry":"main.lua"}`), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
|
||||
local plugin = { name = "synclua" }
|
||||
function plugin.start(sdk)
|
||||
local r, err = sdk.inject_input_sync("src", "ch", "hello")
|
||||
_G.sync_reply = r
|
||||
_G.sync_err = err
|
||||
local _, err2 = sdk.inject_input_sync_opts("src", "ch", "hello", { no_memory = true })
|
||||
_G.sync_err2 = err2
|
||||
local _, err3 = sdk.inject_input_media_sync("src", "ch", "hi", {})
|
||||
_G.sync_media_err = err3
|
||||
end
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
`), 0644)
|
||||
|
||||
plg, err := tryLoadLua(dir, "synclua", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tryLoadLua failed: %v", err)
|
||||
}
|
||||
reg := internalConfig.NewConfigRegistry("")
|
||||
sett := sdk.NewSettings("synclua", reg)
|
||||
s := sdk.New("synclua", sdk.SDKConfig{Settings: sett})
|
||||
if err := plg.Start(s); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
defer plg.Stop()
|
||||
|
||||
L := plg.(*luaPlugin).L
|
||||
if L.GetGlobal("sync_reply").Type() != lua.LTNil {
|
||||
t.Errorf("sync inject should return nil reply, got %v", L.GetGlobal("sync_reply"))
|
||||
}
|
||||
for _, k := range []string{"sync_err", "sync_err2", "sync_media_err"} {
|
||||
if L.GetGlobal(k).Type() != lua.LTString {
|
||||
t.Errorf("%s should be an error string (unavailable), got %v", k, L.GetGlobal(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// TestLuaSDKMockSingleSource 守住「Lua mock 只有一份事实源」。
|
||||
//
|
||||
// 三份 sdk.lua(内核内嵌 / 工具链模板 / 项目副本)历史上各自漂移过,
|
||||
// 表现为「mock 里有的 API,内核运行时是 nil」这类静默失配。
|
||||
// 事实源是 SDK 仓的 sdk/lua/sdk.lua,内核副本由
|
||||
// third_party/homeagent-sdk/scripts/sync-lua-sdk.sh 同步。
|
||||
func TestLuaSDKMockSingleSource(t *testing.T) {
|
||||
canonical, err := os.ReadFile(filepath.Join("..", "..", "third_party", "homeagent-sdk", "sdk", "lua", "sdk.lua"))
|
||||
if err != nil {
|
||||
t.Skipf("SDK repo canonical sdk.lua not available: %v", err)
|
||||
}
|
||||
if string(canonical) != luaSDK.SDKSource {
|
||||
t.Fatal("内核内嵌 sdk.lua 与 SDK 仓 sdk/lua/sdk.lua 不一致;" +
|
||||
"请跑 third_party/homeagent-sdk/scripts/sync-lua-sdk.sh")
|
||||
}
|
||||
}
|
||||
|
||||
var luaMockFuncRe = regexp.MustCompile(`(?m)^function sdk\.([A-Za-z0-9_.]+)\s*\(`)
|
||||
|
||||
// TestLuaBridgeCoversMock 守住「mock 承诺的每个函数,运行时都有绑定」。
|
||||
//
|
||||
// 只查 mock → 运行时这一向:mock 定义了但没绑定,插件会先看到 mock 能调、
|
||||
// 之后内核里是 nil(或反向的假象)。反向(运行时多出未文档化的函数)无害。
|
||||
func TestLuaBridgeCoversMock(t *testing.T) {
|
||||
bridge, err := os.ReadFile("lua_plugin.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read lua_plugin.go: %v", err)
|
||||
}
|
||||
src := string(bridge)
|
||||
|
||||
// 纯 Lua 实现,不经内核绑定。
|
||||
exempt := map[string]bool{"json.encode": true, "json.decode": true}
|
||||
|
||||
matches := luaMockFuncRe.FindAllStringSubmatch(luaSDK.SDKSource, -1)
|
||||
if len(matches) < 40 {
|
||||
t.Fatalf("parsed only %d sdk.* functions from mock; parser likely broken", len(matches))
|
||||
}
|
||||
for _, m := range matches {
|
||||
full := m[1]
|
||||
if exempt[full] {
|
||||
continue
|
||||
}
|
||||
leaf := full
|
||||
if i := strings.LastIndex(full, "."); i >= 0 {
|
||||
leaf = full[i+1:]
|
||||
}
|
||||
if !strings.Contains(src, `RawSetString("`+leaf+`"`) {
|
||||
t.Errorf("sdk.%s: mock 有定义,但 lua_plugin.go 没有 RawSetString(%q) 绑定", full, leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func luaTableFrom(t *testing.T, code string) (*lua.LState, *lua.LTable) {
|
||||
t.Helper()
|
||||
L := lua.NewState()
|
||||
if err := L.DoString("return " + code); err != nil {
|
||||
L.Close()
|
||||
t.Fatalf("eval lua table: %v", err)
|
||||
}
|
||||
tbl, ok := L.Get(-1).(*lua.LTable)
|
||||
if !ok {
|
||||
L.Close()
|
||||
t.Fatalf("expected a table from %q", code)
|
||||
}
|
||||
L.Pop(1)
|
||||
return L, tbl
|
||||
}
|
||||
|
||||
func TestLuaParseInjectOptions(t *testing.T) {
|
||||
L, tbl := luaTableFrom(t, `{
|
||||
no_memory = true,
|
||||
context_policy = "prune",
|
||||
cleaner_name = "sanitize",
|
||||
priority = "L2",
|
||||
}`)
|
||||
defer L.Close()
|
||||
|
||||
L.Push(tbl)
|
||||
got := parseInjectOptions(L, 1)
|
||||
L.Pop(1)
|
||||
|
||||
if !got.NoMemory {
|
||||
t.Error("NoMemory should be true")
|
||||
}
|
||||
if got.ContextPolicy != sdk.ContextPolicyPrune {
|
||||
t.Errorf("ContextPolicy = %q, want prune", got.ContextPolicy)
|
||||
}
|
||||
if got.CleanerName != "sanitize" {
|
||||
t.Errorf("CleanerName = %q, want sanitize", got.CleanerName)
|
||||
}
|
||||
if got.Priority != sdk.PriorityL2 {
|
||||
t.Errorf("Priority = %q, want L2", got.Priority)
|
||||
}
|
||||
|
||||
// 缺省/非表 = 零值(记入记忆 + 不裁剪),与旧三参数注入等价。
|
||||
if z := parseInjectOptions(L, 99); z != (sdk.InjectOptions{}) {
|
||||
t.Errorf("missing opts should be zero value, got %#v", z)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaContentBlocksParse(t *testing.T) {
|
||||
L, tbl := luaTableFrom(t, `{
|
||||
{ type = "text", text = "看图" },
|
||||
{ type = "image_url", image_url = { url = "data:image/png;base64,AAAA", detail = "high" } },
|
||||
{ type = "audio_url", audio_url = { url = "https://x/a.mp3" } },
|
||||
}`)
|
||||
defer L.Close()
|
||||
|
||||
L.Push(tbl)
|
||||
blocks := luaToContentBlocks(L, 1)
|
||||
L.Pop(1)
|
||||
|
||||
if len(blocks) != 3 {
|
||||
t.Fatalf("got %d blocks, want 3", len(blocks))
|
||||
}
|
||||
if blocks[0].Type != "text" || blocks[0].Text != "看图" {
|
||||
t.Errorf("block[0] = %#v", blocks[0])
|
||||
}
|
||||
if blocks[1].ImageURL == nil || blocks[1].ImageURL.URL != "data:image/png;base64,AAAA" || blocks[1].ImageURL.Detail != "high" {
|
||||
t.Errorf("block[1] image_url = %#v", blocks[1].ImageURL)
|
||||
}
|
||||
if blocks[2].AudioURL == nil || blocks[2].AudioURL.URL != "https://x/a.mp3" {
|
||||
t.Errorf("block[2] audio_url = %#v", blocks[2].AudioURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaAttachmentsBase64(t *testing.T) {
|
||||
// "hello" 的 base64 是 aGVsbG8=
|
||||
L, tbl := luaTableFrom(t, `{
|
||||
{ digest = "sha256:abc", mime = "image/png", name = "a.png" },
|
||||
{ mime = "image/jpeg", data = "aGVsbG8=" },
|
||||
{ mime = "image/png", data = "!!!not-base64!!!" },
|
||||
}`)
|
||||
defer L.Close()
|
||||
|
||||
atts := luaToAttachments(L, tbl)
|
||||
|
||||
if len(atts) != 3 {
|
||||
t.Fatalf("got %d attachments, want 3", len(atts))
|
||||
}
|
||||
if atts[0].Digest != "sha256:abc" || atts[0].MIME != "image/png" || atts[0].Name != "a.png" || atts[0].Data != nil {
|
||||
t.Errorf("att[0] = %#v", atts[0])
|
||||
}
|
||||
if string(atts[1].Data) != "hello" {
|
||||
t.Errorf("att[1] data = %q, want hello", string(atts[1].Data))
|
||||
}
|
||||
// 坏 base64 只丢 data,不整单失败——坏附件不应阻断记忆写入。
|
||||
if atts[2].Data != nil {
|
||||
t.Errorf("att[2] bad base64 should be dropped, got %q", string(atts[2].Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaDefinitionsCarryContextPolicy(t *testing.T) {
|
||||
L, tbl := luaTableFrom(t, `{
|
||||
description = "t",
|
||||
no_memory = true,
|
||||
context_policy = "prune",
|
||||
}`)
|
||||
defer L.Close()
|
||||
|
||||
plg := &luaPlugin{name: "cp"}
|
||||
def := parseToolDef(L, tbl, plg, "t")
|
||||
if def.ContextPolicy != sdk.ContextPolicyPrune {
|
||||
t.Errorf("ToolDef.ContextPolicy = %q, want prune", def.ContextPolicy)
|
||||
}
|
||||
if !def.NoMemory {
|
||||
t.Error("ToolDef.NoMemory should be true")
|
||||
}
|
||||
|
||||
chDef := parseChannelDef(L, tbl, plg)
|
||||
if chDef.ContextPolicy != sdk.ContextPolicyPrune {
|
||||
t.Errorf("ChannelDef.ContextPolicy = %q, want prune", chDef.ContextPolicy)
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import (
|
||||
type PluginType string
|
||||
|
||||
const (
|
||||
PluginTypeSKILL PluginType = "skill"
|
||||
PluginTypeSKILL PluginType = "skill"
|
||||
)
|
||||
|
||||
type IOConfig struct {
|
||||
@ -78,28 +78,18 @@ func LoadSKILL(path string) (*SKILLPlugin, error) {
|
||||
metaFile := filepath.Join(path, "skill.json")
|
||||
if data, err := os.ReadFile(metaFile); err == nil {
|
||||
var meta struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
IO *IOConfig `json:"io,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &meta); err == nil {
|
||||
if meta.Name != "" {
|
||||
p.name = meta.Name
|
||||
}
|
||||
if meta.Description != "" {
|
||||
p.description = meta.Description
|
||||
}
|
||||
if meta.Version != "" {
|
||||
p.version = meta.Version
|
||||
}
|
||||
if meta.Author != "" {
|
||||
p.author = meta.Author
|
||||
}
|
||||
if meta.IO != nil {
|
||||
p.ioConfig = meta.IO
|
||||
}
|
||||
if meta.Name != "" { p.name = meta.Name }
|
||||
if meta.Description != "" { p.description = meta.Description }
|
||||
if meta.Version != "" { p.version = meta.Version }
|
||||
if meta.Author != "" { p.author = meta.Author }
|
||||
if meta.IO != nil { p.ioConfig = meta.IO }
|
||||
}
|
||||
}
|
||||
} else if filepath.Ext(path) == ".md" {
|
||||
@ -208,9 +198,7 @@ func extractToolDefs(content string) []ToolDef {
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
if inCodeBlock {
|
||||
continue
|
||||
}
|
||||
if inCodeBlock { continue }
|
||||
|
||||
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
|
||||
if currentTool != nil && currentTool.Name != "" {
|
||||
@ -244,9 +232,7 @@ func extractToolDefs(content string) []ToolDef {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentTool == nil || currentTool.Name == "" {
|
||||
continue
|
||||
}
|
||||
if currentTool == nil || currentTool.Name == "" { continue }
|
||||
|
||||
if currentTool.Description == "" && trimmed != "" &&
|
||||
!strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, "#") {
|
||||
@ -294,9 +280,7 @@ func isNonToolSection(name string) bool {
|
||||
|
||||
func extractIOConfig(content string) *IOConfig {
|
||||
ioType := extractField(content, "io_type")
|
||||
if ioType == "" {
|
||||
return nil
|
||||
}
|
||||
if ioType == "" { return nil }
|
||||
cfg := &IOConfig{
|
||||
Type: ioType,
|
||||
InputRoute: extractField(content, "io_input_route"),
|
||||
|
||||
@ -121,73 +121,552 @@ type CoreSDK interface {
|
||||
// 权限梯度在此强制(§3.8):manifest 未声明的能力组被**明确拒绝**。
|
||||
// 不静默忽略:C ABI 时代 case 23/24 返回成功但永远收不到事件
|
||||
// (§1.3 的「给不了」而非「不给」),插件作者无从得知。
|
||||
// Handle 分派一次插件 → 内核的调用。
|
||||
//
|
||||
// 权限梯度在此强制(§3.8):manifest 未声明的能力组被**明确拒绝**。
|
||||
// 不静默忽略:C ABI 时代 case 23/24 返回成功但永远收不到事件
|
||||
// (§1.3 的「给不了」而非「不给」),插件作者无从得知。
|
||||
//
|
||||
// 体量上这里只做「method → 分部函数」一跳:原 51 个 case 的 550 行大 switch
|
||||
// 已按协议面拆进同包 corehandler_register / _inject / _memory /
|
||||
// _settings / _runtime。
|
||||
func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}, error) {
|
||||
if ok, cap := h.caps.allows(method); !ok {
|
||||
return nil, errCapabilityDenied(h.name, method, cap)
|
||||
}
|
||||
|
||||
switch method {
|
||||
case MethodToolRegister, MethodStageRegister, MethodOutputRegister, MethodAPIRegister,
|
||||
MethodInputRegister:
|
||||
return h.handleRegister(method, params)
|
||||
|
||||
case MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
|
||||
MethodIOInjectSync, MethodIOInjectMedia, MethodIOInjectMediaSync,
|
||||
MethodIOInjectInterruptMedia, MethodIOSetToolBlocks:
|
||||
return h.handleInject(method, params)
|
||||
// ---- 注册面(原 case 1/2/3/4/46)----
|
||||
case MethodToolRegister:
|
||||
return h.toolRegister(params)
|
||||
case MethodStageRegister:
|
||||
return h.stageRegister(params)
|
||||
case MethodOutputRegister:
|
||||
return h.outputRegister(params)
|
||||
case MethodAPIRegister:
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, h.sdk.RegisterPluginAPI(p.Name)
|
||||
case MethodInputRegister:
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Def pubsdk.ChannelDef `json:"def"`
|
||||
HasCleaner bool `json:"has_cleaner"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Name == "" {
|
||||
return nil, fmt.Errorf("input.register: 缺少 name")
|
||||
}
|
||||
cleaner, err := h.cleanerProxy(CleanerScopeInput, p.Name, p.HasCleaner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input.register: %w", err)
|
||||
}
|
||||
if err := validateContextPolicy("input.register", p.Def.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 整体传 p.Def(只是把函数型的 Cleaner 换成代理),不要手写字段白名单:
|
||||
// 白名单会让新增字段静默丢失。
|
||||
def := p.Def
|
||||
def.Cleaner = cleaner
|
||||
return nil, h.sdk.RegisterInputChannel(p.Name, def)
|
||||
|
||||
case MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect, MethodMemoryMerge,
|
||||
MethodMemoryPurge:
|
||||
return h.handleGraphMemory(method, params)
|
||||
// ---- IO 注入(原 case 5/6/7/47)----
|
||||
//
|
||||
// 注入标志位(no_memory / context_policy)由插件在调用点声明,默认
|
||||
// 记入记忆 + 不裁剪。策略值在入口校验:静默降级成 none 会让调用方
|
||||
// 以为自己声明的裁剪在生效。
|
||||
case MethodIOInjectText:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectInterrupt:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectTextNoMem:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectTextNoMem", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectSync:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodDocQuery, MethodDocInsert, MethodDocInsertMedia, MethodDocRemove,
|
||||
MethodDocStats:
|
||||
return h.handleDocMemory(method, params)
|
||||
case MethodIOInjectMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
case MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList:
|
||||
return h.handleKnowledge(method, params)
|
||||
case MethodIOInjectMediaSync:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMediaSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInterruptMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
// ---- 生命周期(原 case 8)----
|
||||
case MethodLifecycleAutoRestart:
|
||||
var p struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.SetAutoRestart(p.Enabled)
|
||||
return nil, nil
|
||||
|
||||
// ---- 图记忆(原 case 9/10/11/12/13)----
|
||||
case MethodMemoryRecall:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Query []string `json:"query"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entities, relations, err := mem.Recall(p.Query, p.Depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entities == nil {
|
||||
entities = []pubsdk.Entity{}
|
||||
}
|
||||
if relations == nil {
|
||||
relations = []pubsdk.Relation{}
|
||||
}
|
||||
return map[string]interface{}{"entities": entities, "relations": relations}, nil
|
||||
|
||||
case MethodMemoryCommit:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Triples []pubsdk.Triple `json:"triples"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, mem.Commit(p.Triples)
|
||||
|
||||
case MethodMemoryIntrospect:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
return mem.Introspect()
|
||||
|
||||
case MethodMemoryMerge:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := mem.MergeEntities(p.Source, p.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"merged": n}, nil
|
||||
|
||||
case MethodMemoryPurge:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Criteria map[string]string `json:"criteria"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "soft"
|
||||
}
|
||||
n, err := mem.Purge(p.Criteria, p.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"purged": n}, nil
|
||||
|
||||
// ---- 文档记忆(原 case 14/32/33/34)----
|
||||
case MethodDocQuery:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Text string `json:"text"`
|
||||
TopK int `json:"top_k"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs := dm.Query(p.Text, p.TopK)
|
||||
if docs == nil {
|
||||
docs = []*pubsdk.Doc{}
|
||||
}
|
||||
return map[string]interface{}{"docs": docs}, nil
|
||||
|
||||
case MethodDocInsert:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Doc *pubsdk.Doc `json:"doc,omitempty"`
|
||||
DocRef SharedRef `json:"doc_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文档全文可达几十 KB~数 MB,优先走共享内存。
|
||||
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Doc == nil {
|
||||
return nil, fmt.Errorf("doc.insert: 缺少 doc 字段")
|
||||
}
|
||||
return nil, dm.Insert(p.Doc)
|
||||
|
||||
case MethodDocInsertMedia:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Doc *pubsdk.Doc `json:"doc,omitempty"`
|
||||
Attachments []pubsdk.MediaAttachment `json:"attachments,omitempty"`
|
||||
DocRef SharedRef `json:"doc_ref,omitempty"`
|
||||
AttachRef SharedRef `json:"attachments_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文档正文 + 附件(含媒体二进制/data URL)都优先走共享内存。
|
||||
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.resolveJSONRef(p.AttachRef, &p.Attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Doc == nil {
|
||||
return nil, fmt.Errorf("doc.insertWithMedia: 缺少 doc 字段")
|
||||
}
|
||||
if err := dm.InsertWithMedia(p.Doc, p.Attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 回传内核补过的字段:ID 新建时才生成,Content 含内核补的媒体标记,
|
||||
// MediaDigests 是附件落盘后的完整 digest——插件靠它们后续引用同一份媒体。
|
||||
return map[string]interface{}{"doc": p.Doc}, nil
|
||||
|
||||
case MethodDocRemove:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.Remove(p.ID)
|
||||
return nil, nil
|
||||
|
||||
case MethodDocStats:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
return dm.Stats(), nil
|
||||
|
||||
// ---- 知识库(原 case 15/35/36)----
|
||||
case MethodKnowledgeSearch:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
var p struct {
|
||||
Query string `json:"query"`
|
||||
TopK int `json:"top_k"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results, err := kn.Search(p.Query, p.TopK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if results == nil {
|
||||
results = []*pubsdk.Knowledge{}
|
||||
}
|
||||
return map[string]interface{}{"results": results}, nil
|
||||
|
||||
case MethodKnowledgeAdd:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ContentRef SharedRef `json:"content_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 知识正文可达数十 KB,优先走共享内存。内容是 JSON 字符串,
|
||||
// 所以从 ref 读出后需再解一层。
|
||||
if err := h.resolveJSONRef(p.ContentRef, &p.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, kn.Add(p.Name, p.Content)
|
||||
|
||||
case MethodKnowledgeList:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
names, err := kn.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
return map[string]interface{}{"names": names}, nil
|
||||
|
||||
// ---- 文本记忆(原 case 41)----
|
||||
case MethodTextMemoryAppend:
|
||||
return h.handleTextMemory(method, params)
|
||||
tm := h.sdk.TextMemory()
|
||||
if tm == nil {
|
||||
return nil, errUnavailable("text memory")
|
||||
}
|
||||
var p struct {
|
||||
Event pubsdk.TextEvent `json:"event"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, tm.Append(p.Event)
|
||||
|
||||
// ---- 设置(原 case 16/17/18/26~31/42~45/51)----
|
||||
case MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
|
||||
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
|
||||
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
|
||||
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump, MethodSettingsPlugins,
|
||||
MethodSettingsDataDir:
|
||||
return h.handleSettings(method, params)
|
||||
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
|
||||
MethodSettingsPlugins, MethodSettingsDataDir:
|
||||
return h.settings(method, params)
|
||||
|
||||
case MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource:
|
||||
return h.handleLLM(method, params)
|
||||
// ---- LLM 源(原 case 19/20/37)----
|
||||
case MethodLLMListSources:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
sources := llm.ListSources()
|
||||
if sources == nil {
|
||||
sources = []string{}
|
||||
}
|
||||
return map[string]interface{}{"sources": sources}, nil
|
||||
case MethodLLMSetSource:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, llm.SetSource(p.Name)
|
||||
case MethodLLMCurrentSource:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
return map[string]interface{}{"source": llm.CurrentSource()}, nil
|
||||
|
||||
// ---- 社交图(只读,原 case 21/22/38/39/40)----
|
||||
case MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
|
||||
MethodSocialGetRelation, MethodSocialListPersons:
|
||||
return h.handleSocial(method, params)
|
||||
return h.social(method, params)
|
||||
|
||||
case MethodLifecycleAutoRestart:
|
||||
return h.handleLifecycle(method, params)
|
||||
// ---- 插件管理(原 case 48/49/50)----
|
||||
case MethodPluginReloadOne:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, pm.ReloadOne(p.Name)
|
||||
case MethodPluginListLoaded:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
list := pm.ListLoadedPlugins()
|
||||
if list == nil {
|
||||
list = []string{}
|
||||
}
|
||||
return map[string]interface{}{"plugins": list}, nil
|
||||
case MethodPluginIsDisabled:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"disabled": pm.IsPluginDisabled(p.Name)}, nil
|
||||
|
||||
case MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled:
|
||||
return h.handlePluginMgr(method, params)
|
||||
// ---- 共享段锁仲裁(新增,§3.7)----
|
||||
case MethodStageLock:
|
||||
if h.locks == nil {
|
||||
return nil, fmt.Errorf("stage.lock: 锁仲裁未就绪")
|
||||
}
|
||||
return nil, h.locks.acquire(h.name)
|
||||
case MethodStageUnlock:
|
||||
if h.locks == nil {
|
||||
return nil, fmt.Errorf("stage.unlock: 锁仲裁未就绪")
|
||||
}
|
||||
return nil, h.locks.release(h.name)
|
||||
|
||||
case MethodStageLock, MethodStageUnlock:
|
||||
return h.handleStageLocks(method, params)
|
||||
// ---- 事件订阅(原 case 23/24,子进程下首次真正可用,§3.6)----
|
||||
case MethodEventsSubscribe:
|
||||
var p struct {
|
||||
Types []pubsdk.EventType `json:"types"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.evtRing == nil {
|
||||
return nil, fmt.Errorf("%s: 事件环未就绪", method)
|
||||
}
|
||||
// 订阅请求来自子进程——handler 直接注册到 Bus,
|
||||
// 事件经 EventRing 写入环后由子进程消费。
|
||||
h.evtRing.EvtRingSubscribe(p.Types)
|
||||
return nil, nil
|
||||
|
||||
case MethodEventsSubscribe, MethodEventsUnsubscribe:
|
||||
return h.handleEvents(method, params)
|
||||
case MethodEventsUnsubscribe:
|
||||
// 事件环的订阅没有持久化句柄(取消函数由 Subscribe 返回但子进程未保存)。
|
||||
// 当前设计:子进程 Stop 时由内核统一清理其订阅。
|
||||
return nil, nil
|
||||
|
||||
case MethodArenaAlloc, MethodArenaFree:
|
||||
return h.handleArena(method, params)
|
||||
// ---- 共享槽池(内部传输层,见 protocol.go 注释)----
|
||||
case MethodArenaAlloc:
|
||||
var p ArenaAllocParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref, err := h.arenaAlloc(p.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ArenaAllocResult{Ref: ref}, nil
|
||||
|
||||
case MethodArenaFree:
|
||||
var p ArenaFreeParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, h.arenaFree(p.Ref)
|
||||
|
||||
// ---- 多模态注入 ----
|
||||
//
|
||||
// 之前这里是桩:返回“待共享段二进制通道落地”。后果是**子进程插件调
|
||||
// SetToolBlocks 必然失败**(模板只 log 一行),只有内置插件能用。
|
||||
// 现在媒体块经共享内存传递,该能力对两种插件形态等价。
|
||||
case MethodIOSetToolBlocks:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("%s: blocks 为空", method)
|
||||
}
|
||||
h.sdk.SetToolBlocks(blocks)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
@ -215,7 +694,6 @@ type injectParams struct {
|
||||
TextRef SharedRef `json:"text_ref,omitempty"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
// Priority 声明中断注入的优先级(L1..L3);L4 内核独占,见 InjectOptions。
|
||||
Priority string `json:"priority,omitempty"`
|
||||
@ -238,7 +716,6 @@ type injectMediaParams struct {
|
||||
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
@ -247,11 +724,10 @@ type injectMediaParams struct {
|
||||
//
|
||||
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
||||
// 与旧三参数注入等价。
|
||||
func pubSdkInjectOpts(noMemory bool, policy, recallPolicy, cleanerName, priority string) pubsdk.InjectOptions {
|
||||
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
|
||||
return pubsdk.InjectOptions{
|
||||
NoMemory: noMemory, ContextPolicy: policy, RecallPolicy: recallPolicy,
|
||||
CleanerName: cleanerName,
|
||||
Priority: clampExternalPriority(priority),
|
||||
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
|
||||
Priority: clampExternalPriority(priority),
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,14 +759,6 @@ func validateContextPolicy(where, policy string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRecallPolicy 校验召回策略取值,与 context_policy 同一套规则。
|
||||
func validateRecallPolicy(where, policy string) error {
|
||||
if !pubsdk.ValidRecallPolicy(policy) {
|
||||
return fmt.Errorf("%s: recall_policy 只允许 none/auto,实际 %q", where, policy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveJSONRef 若 ref 非零则从共享内存读取并 JSON 反序列化到 out;
|
||||
// ref 为零时不动 out(调用方已填的内联值生效)。
|
||||
//
|
||||
@ -435,9 +903,6 @@ func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error)
|
||||
if err := validateContextPolicy("tool.register", p.Def.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("tool.register", p.Def.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Def.Plugin = h.name
|
||||
// 函数本身不进 JSON;has_cleaner 只声明其存在,实际执行回到插件进程。
|
||||
cleaner, err := h.cleanerProxy(CleanerScopeTool, p.Name, p.HasCleaner)
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// handleInject 处理 IO 注入面:文本 / 打断 / 同步 / 媒体块,以及 SetToolBlocks。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleInject(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- IO 注入(原 case 5/6/7/47)----
|
||||
//
|
||||
// 注入标志位(no_memory / context_policy)由插件在调用点声明,默认
|
||||
// 记入记忆 + 不裁剪。策略值在入口校验:静默降级成 none 会让调用方
|
||||
// 以为自己声明的裁剪在生效。
|
||||
case MethodIOInjectText:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectText", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectInterrupt:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectInterrupt", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectTextNoMem:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectTextNoMem", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectTextNoMem", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectSync:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectInputSync", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectMedia", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
case MethodIOInjectMediaSync:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMediaSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectMediaSync", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInterruptMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("io.injectInterruptMedia", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
// ---- 多模态注入 ----
|
||||
//
|
||||
// 之前这里是桩:返回“待共享段二进制通道落地”。后果是**子进程插件调
|
||||
// SetToolBlocks 必然失败**(模板只 log 一行),只有内置插件能用。
|
||||
// 现在媒体块经共享内存传递,该能力对两种插件形态等价。
|
||||
case MethodIOSetToolBlocks:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("%s: blocks 为空", method)
|
||||
}
|
||||
h.sdk.SetToolBlocks(blocks)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
@ -1,306 +0,0 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// handleGraphMemory 处理图记忆(实体/关系):recall / commit / introspect / merge / purge。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleGraphMemory(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 图记忆(原 case 9/10/11/12/13)----
|
||||
case MethodMemoryRecall:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Query []string `json:"query"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entities, relations, err := mem.Recall(p.Query, p.Depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entities == nil {
|
||||
entities = []pubsdk.Entity{}
|
||||
}
|
||||
if relations == nil {
|
||||
relations = []pubsdk.Relation{}
|
||||
}
|
||||
return map[string]interface{}{"entities": entities, "relations": relations}, nil
|
||||
|
||||
case MethodMemoryCommit:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Triples []pubsdk.Triple `json:"triples"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, mem.Commit(p.Triples)
|
||||
|
||||
case MethodMemoryIntrospect:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
return mem.Introspect()
|
||||
|
||||
case MethodMemoryMerge:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := mem.MergeEntities(p.Source, p.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"merged": n}, nil
|
||||
|
||||
case MethodMemoryPurge:
|
||||
mem := h.sdk.Memory()
|
||||
if mem == nil {
|
||||
return nil, errUnavailable("memory")
|
||||
}
|
||||
var p struct {
|
||||
Criteria map[string]string `json:"criteria"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "soft"
|
||||
}
|
||||
n, err := mem.Purge(p.Criteria, p.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"purged": n}, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleDocMemory 处理文档记忆:query / insert / insertWithMedia / remove / stats。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleDocMemory(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 文档记忆(原 case 14/32/33/34)----
|
||||
case MethodDocQuery:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Text string `json:"text"`
|
||||
TopK int `json:"top_k"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs := dm.Query(p.Text, p.TopK)
|
||||
if docs == nil {
|
||||
docs = []*pubsdk.Doc{}
|
||||
}
|
||||
return map[string]interface{}{"docs": docs}, nil
|
||||
|
||||
case MethodDocInsert:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Doc *pubsdk.Doc `json:"doc,omitempty"`
|
||||
DocRef SharedRef `json:"doc_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文档全文可达几十 KB~数 MB,优先走共享内存。
|
||||
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Doc == nil {
|
||||
return nil, fmt.Errorf("doc.insert: 缺少 doc 字段")
|
||||
}
|
||||
return nil, dm.Insert(p.Doc)
|
||||
|
||||
case MethodDocInsertMedia:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
Doc *pubsdk.Doc `json:"doc,omitempty"`
|
||||
Attachments []pubsdk.MediaAttachment `json:"attachments,omitempty"`
|
||||
DocRef SharedRef `json:"doc_ref,omitempty"`
|
||||
AttachRef SharedRef `json:"attachments_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文档正文 + 附件(含媒体二进制/data URL)都优先走共享内存。
|
||||
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.resolveJSONRef(p.AttachRef, &p.Attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Doc == nil {
|
||||
return nil, fmt.Errorf("doc.insertWithMedia: 缺少 doc 字段")
|
||||
}
|
||||
if err := dm.InsertWithMedia(p.Doc, p.Attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 回传内核补过的字段:ID 新建时才生成,Content 含内核补的媒体标记,
|
||||
// MediaDigests 是附件落盘后的完整 digest——插件靠它们后续引用同一份媒体。
|
||||
return map[string]interface{}{"doc": p.Doc}, nil
|
||||
|
||||
case MethodDocRemove:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
var p struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.Remove(p.ID)
|
||||
return nil, nil
|
||||
|
||||
case MethodDocStats:
|
||||
dm := h.sdk.DocMemory()
|
||||
if dm == nil {
|
||||
return nil, errUnavailable("doc memory")
|
||||
}
|
||||
return dm.Stats(), nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleKnowledge 处理知识库:search / add / list。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleKnowledge(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 知识库(原 case 15/35/36)----
|
||||
case MethodKnowledgeSearch:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
var p struct {
|
||||
Query string `json:"query"`
|
||||
TopK int `json:"top_k"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results, err := kn.Search(p.Query, p.TopK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if results == nil {
|
||||
results = []*pubsdk.Knowledge{}
|
||||
}
|
||||
return map[string]interface{}{"results": results}, nil
|
||||
|
||||
case MethodKnowledgeAdd:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ContentRef SharedRef `json:"content_ref,omitempty"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 知识正文可达数十 KB,优先走共享内存。内容是 JSON 字符串,
|
||||
// 所以从 ref 读出后需再解一层。
|
||||
if err := h.resolveJSONRef(p.ContentRef, &p.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, kn.Add(p.Name, p.Content)
|
||||
|
||||
case MethodKnowledgeList:
|
||||
kn := h.sdk.Knowledge()
|
||||
if kn == nil {
|
||||
return nil, errUnavailable("knowledge")
|
||||
}
|
||||
names, err := kn.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
return map[string]interface{}{"names": names}, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleTextMemory 处理文本记忆追加。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleTextMemory(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 文本记忆(原 case 41)----
|
||||
case MethodTextMemoryAppend:
|
||||
tm := h.sdk.TextMemory()
|
||||
if tm == nil {
|
||||
return nil, errUnavailable("text memory")
|
||||
}
|
||||
var p struct {
|
||||
Event pubsdk.TextEvent `json:"event"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, tm.Append(p.Event)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// handleRegister 处理注册面:工具 / stage / 输出通道 / 插件 API / 入站通道。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleRegister(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 注册面(原 case 1/2/3/4/46)----
|
||||
case MethodToolRegister:
|
||||
return h.toolRegister(params)
|
||||
case MethodStageRegister:
|
||||
return h.stageRegister(params)
|
||||
case MethodOutputRegister:
|
||||
return h.outputRegister(params)
|
||||
case MethodAPIRegister:
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, h.sdk.RegisterPluginAPI(p.Name)
|
||||
case MethodInputRegister:
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Def pubsdk.ChannelDef `json:"def"`
|
||||
HasCleaner bool `json:"has_cleaner"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Name == "" {
|
||||
return nil, fmt.Errorf("input.register: 缺少 name")
|
||||
}
|
||||
cleaner, err := h.cleanerProxy(CleanerScopeInput, p.Name, p.HasCleaner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input.register: %w", err)
|
||||
}
|
||||
if err := validateContextPolicy("input.register", p.Def.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRecallPolicy("input.register", p.Def.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 整体传 p.Def(只是把函数型的 Cleaner 换成代理),不要手写字段白名单:
|
||||
// 白名单会让新增字段静默丢失。
|
||||
def := p.Def
|
||||
def.Cleaner = cleaner
|
||||
return nil, h.sdk.RegisterInputChannel(p.Name, def)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// handlePluginMgr 处理插件管理:reloadOne / listLoaded / isDisabled。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handlePluginMgr(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 插件管理(原 case 48/49/50)----
|
||||
case MethodPluginReloadOne:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, pm.ReloadOne(p.Name)
|
||||
case MethodPluginListLoaded:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
list := pm.ListLoadedPlugins()
|
||||
if list == nil {
|
||||
list = []string{}
|
||||
}
|
||||
return map[string]interface{}{"plugins": list}, nil
|
||||
case MethodPluginIsDisabled:
|
||||
pm := h.sdk.PluginMgr()
|
||||
if pm == nil {
|
||||
return nil, errUnavailable("plugin manager")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"disabled": pm.IsPluginDisabled(p.Name)}, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleStageLocks 处理共享段锁仲裁(§3.7)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleStageLocks(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 共享段锁仲裁(新增,§3.7)----
|
||||
case MethodStageLock:
|
||||
if h.locks == nil {
|
||||
return nil, fmt.Errorf("stage.lock: 锁仲裁未就绪")
|
||||
}
|
||||
return nil, h.locks.acquire(h.name)
|
||||
case MethodStageUnlock:
|
||||
if h.locks == nil {
|
||||
return nil, fmt.Errorf("stage.unlock: 锁仲裁未就绪")
|
||||
}
|
||||
return nil, h.locks.release(h.name)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleEvents 处理事件订阅(§3.6,子进程下首次真正可用)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleEvents(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 事件订阅(原 case 23/24,子进程下首次真正可用,§3.6)----
|
||||
case MethodEventsSubscribe:
|
||||
var p struct {
|
||||
Types []pubsdk.EventType `json:"types"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.evtRing == nil {
|
||||
return nil, fmt.Errorf("%s: 事件环未就绪", method)
|
||||
}
|
||||
// 订阅请求来自子进程——handler 直接注册到 Bus,
|
||||
// 事件经 EventRing 写入环后由子进程消费。
|
||||
h.evtRing.EvtRingSubscribe(p.Types)
|
||||
return nil, nil
|
||||
|
||||
case MethodEventsUnsubscribe:
|
||||
// 事件环的订阅没有持久化句柄(取消函数由 Subscribe 返回但子进程未保存)。
|
||||
// 当前设计:子进程 Stop 时由内核统一清理其订阅。
|
||||
return nil, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleArena 处理共享槽池分配/释放(内部传输层,见 protocol.go)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleArena(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 共享槽池(内部传输层,见 protocol.go 注释)----
|
||||
case MethodArenaAlloc:
|
||||
var p ArenaAllocParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref, err := h.arenaAlloc(p.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ArenaAllocResult{Ref: ref}, nil
|
||||
|
||||
case MethodArenaFree:
|
||||
var p ArenaFreeParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, h.arenaFree(p.Ref)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// handleSettings 处理全部设置项读写(14 个 method 共用一条实现)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleSettings(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 设置(原 case 16/17/18/26~31/42~45/51)----
|
||||
case MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
|
||||
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
|
||||
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
|
||||
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
|
||||
MethodSettingsPlugins, MethodSettingsDataDir:
|
||||
return h.settings(method, params)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleLLM 处理 LLM 源查询与切换。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleLLM(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- LLM 源(原 case 19/20/37)----
|
||||
case MethodLLMListSources:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
sources := llm.ListSources()
|
||||
if sources == nil {
|
||||
sources = []string{}
|
||||
}
|
||||
return map[string]interface{}{"sources": sources}, nil
|
||||
case MethodLLMSetSource:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, llm.SetSource(p.Name)
|
||||
case MethodLLMCurrentSource:
|
||||
llm := h.sdk.LLM()
|
||||
if llm == nil {
|
||||
return nil, errUnavailable("llm")
|
||||
}
|
||||
return map[string]interface{}{"source": llm.CurrentSource()}, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleSocial 处理社交图只读查询(人物/网络/特质/关系)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleSocial(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 社交图(只读,原 case 21/22/38/39/40)----
|
||||
case MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
|
||||
MethodSocialGetRelation, MethodSocialListPersons:
|
||||
return h.social(method, params)
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
|
||||
// handleLifecycle 处理生命周期开关(当前仅自动重启)。
|
||||
//
|
||||
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
|
||||
// case 标签与 case 体逐字保留,只换了宿主函数(§3.2 的平移原则)。
|
||||
func (h *coreHandler) handleLifecycle(method string, params json.RawMessage) (interface{}, error) {
|
||||
switch method {
|
||||
// ---- 生命周期(原 case 8)----
|
||||
case MethodLifecycleAutoRestart:
|
||||
var p struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.SetAutoRestart(p.Enabled)
|
||||
return nil, nil
|
||||
|
||||
}
|
||||
|
||||
// 组内不应到达:Handle 的分派表与本函数的 case 标签同源,
|
||||
// 出现即说明两处不同步。
|
||||
return nil, fmt.Errorf("未知 method: %s", method)
|
||||
}
|
||||
@ -35,14 +35,14 @@ func TestClampExternalPriority_RejectsL4(t *testing.T) {
|
||||
|
||||
// 贯穿 pubSdkInjectOpts:RPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
|
||||
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
|
||||
got := pubSdkInjectOpts(true, "prune", "none", "cleaner", "L4")
|
||||
got := pubSdkInjectOpts(true, "prune", "cleaner", "L4")
|
||||
if got.Priority != pubsdk.PriorityL3 {
|
||||
t.Fatalf("经桥后的优先级=%q,期望 L3", got.Priority)
|
||||
}
|
||||
if !got.NoMemory || got.ContextPolicy != "prune" || got.RecallPolicy != "none" || got.CleanerName != "cleaner" {
|
||||
if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" {
|
||||
t.Fatalf("其它字段被改动:%+v", got)
|
||||
}
|
||||
if l2 := pubSdkInjectOpts(false, "", "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
|
||||
if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
|
||||
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user