From 6dbb4a2e1899b159c718fb2e288141439d7df809 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 21:54:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=94=A4=E9=86=92=E5=BC=8F=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=89=B9=E6=AC=A1=E2=80=94=E2=80=94capacity/L4/?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BC=96=E6=8E=92/=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=86=99CRUD/=E5=89=8D=E7=AB=AF=E9=9B=86?= =?UTF-8?q?=E6=88=90=EF=BC=8877-81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。 【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。 【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。 【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。 【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。 【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。 实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。 --- Docs/api-client.md | 41 +- cmd/cyrene/wire.go | 53 ++ frontend/web/src/admin/components.tsx | 8 +- .../web/src/admin/modules/Automations.tsx | 121 ++++- frontend/web/src/admin/modules/Channels.tsx | 132 +++-- frontend/web/src/admin/modules/IoT.tsx | 113 ++++- frontend/web/src/admin/modules/Llm.tsx | 123 +++-- frontend/web/src/admin/modules/Memories.tsx | 101 +++- frontend/web/src/admin/modules/Personas.tsx | 188 +++++-- frontend/web/src/admin/modules/Plugins.tsx | 57 ++- frontend/web/src/admin/modules/Sessions.tsx | 84 ++- frontend/web/src/admin/modules/Users.tsx | 136 +++-- frontend/web/src/components/TopBar.tsx | 3 + frontend/web/src/components/chat.tsx | 22 +- frontend/web/src/components/status.tsx | 125 +++++ frontend/web/src/lib/api.ts | 141 +++++ frontend/web/src/lib/types.ts | 129 ++++- frontend/web/src/lib/ws.ts | 36 +- frontend/web/src/modes/ChatShell.tsx | 26 +- frontend/web/src/store/useAppStore.ts | 35 ++ frontend/web/src/styles/index.css | 18 + internal/auth/apikey.go | 26 + internal/config/config.go | 21 + internal/config/config_test.go | 32 ++ internal/config/decode.go | 15 + internal/persona/registry/registry.go | 62 +++ internal/runtime/capacity/capacity.go | 328 ++++++++++++ internal/runtime/capacity/capacity_test.go | 234 +++++++++ internal/runtime/capacity/recall.go | 325 ++++++++++++ internal/runtime/capacity/recall_test.go | 177 +++++++ internal/runtime/capacity/signals.go | 300 +++++++++++ internal/runtime/loop/capacity_ctx.go | 32 ++ .../runtime/loop/capacity_l4_loop_test.go | 97 ++++ internal/runtime/loop/capacity_loop_test.go | 158 ++++++ internal/runtime/loop/loop.go | 5 + internal/runtime/loop/options.go | 12 + internal/runtime/loop/request.go | 107 +++- internal/runtime/loop/run.go | 6 + internal/runtime/loop/stream.go | 2 +- internal/services/automations/service.go | 54 ++ internal/services/gateway/admin.go | 11 +- .../gateway/admin_write_iot_automations.go | 175 +++++++ .../gateway/admin_write_llm_channels.go | 278 ++++++++++ .../gateway/admin_write_personas_users.go | 275 ++++++++++ .../admin_write_plugins_memory_sessions.go | 156 ++++++ internal/services/gateway/rest.go | 29 +- internal/services/gateway/server.go | 58 ++- .../services/gateway/session_orchestrator.go | 443 ++++++++++++++++ .../gateway/session_orchestrator_test.go | 480 ++++++++++++++++++ internal/services/gateway/ws_hub.go | 88 +++- internal/services/gateway/ws_route.go | 4 + 51 files changed, 5395 insertions(+), 287 deletions(-) create mode 100644 frontend/web/src/components/status.tsx create mode 100644 internal/runtime/capacity/capacity.go create mode 100644 internal/runtime/capacity/capacity_test.go create mode 100644 internal/runtime/capacity/recall.go create mode 100644 internal/runtime/capacity/recall_test.go create mode 100644 internal/runtime/capacity/signals.go create mode 100644 internal/runtime/loop/capacity_ctx.go create mode 100644 internal/runtime/loop/capacity_l4_loop_test.go create mode 100644 internal/runtime/loop/capacity_loop_test.go create mode 100644 internal/services/gateway/admin_write_iot_automations.go create mode 100644 internal/services/gateway/admin_write_llm_channels.go create mode 100644 internal/services/gateway/admin_write_personas_users.go create mode 100644 internal/services/gateway/admin_write_plugins_memory_sessions.go create mode 100644 internal/services/gateway/session_orchestrator.go create mode 100644 internal/services/gateway/session_orchestrator_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 878dff0..654b427 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -21,7 +21,7 @@ |---|------|------|------|:---:|------| | 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,jwt}}`(24h,owner) | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | -| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06) | +| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}` | | 5 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | @@ -49,7 +49,27 @@ | 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条) | | 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) | -> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。写操作(策略/档案/密钥/系统设置/用户与自动化创建、知识库上传/删除)经前端审计登记,待后端 M10+ 落地真实持久化与热重载。 +> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**系统设置**、**知识库上传/删除**——后端如实返回 `501 not_implemented`,不伪造成功。 + +**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`): +- `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?}` → `201 {data:{id,name,character,isDefault,mode,memoryNamespace}}`(注册进 persona registry + 持久化到 `configs/personas/.yaml`);id 已存在回 `409 duplicate`。 +- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`;不存在回 `404`。 +- `POST /admin/users` → `501 not_implemented`(无持久用户表,当前为单 admin + APIKey 现状)。 +- `POST /admin/users/apikey` body `{role:"owner|admin|viewer"}` → `201 {data:{id:<掩码>, key:<明文·仅此一次>, role}}`。`key` 仅此响应出现一次。 +- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。 +- `POST /admin/iot/{id}/control` body `{action,params?}` → `200 {data:{id,name,type,state,online}}`(真实下发到 IoT provider);`404` 设备不存在 / `400` 动作不支持或参数错 / `409` 设备离线。 +- `POST /admin/iot/scenarios`、`POST /admin/iot/rules` → `501 not_implemented`(IoT 无场景/规则存储)。 +- `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。 +- `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。 +- `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。 +- `POST /admin/llm` body `{name,model,baseUrl?,multimodal?,fallback?}` → `201 {data:{provider,temporary:true}}`。**临时**,进程生命周期,不入 config、不绑定模型路由。 +- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 +- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。 +- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 +- `POST /admin/plugins/install` → `501 not_implemented`(需插件源码清单)。 +- `POST /admin/plugins/uninstall` body `{name}` → `200 {data:{name,uninstalled:true}}`(真实从 registry 移除);`404`。 +- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。 +- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。 **知识库 RAG 契约**: - `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。 @@ -64,7 +84,7 @@ ## 3. WebSocket `/ws/chat`(实时) - **握手**: `ws://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 -- **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。语音回复走 `voice_output`(定向回请求连接,非广播)。 +- **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。 ### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`) - **事件壳**:`{type, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。 @@ -73,19 +93,32 @@ - **`tool_call.start`**(`{id, index}`) / **`tool_call.delta`**(`{id, index, delta}`) / **`tool_call.end`**(`{id, index}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。 - **`tool_execution.update`**(`{id, index, delta}`):工具执行进度/局部输出(保留,工具循环 §1.2 后置实现)。 - **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。 -- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query`。 +- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。 +- **`interrupt`**(上行,客户端→服务端):`{sessionId}` → 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)、清空其待处理队列、回 `session_interrupted`;用户说「停下/算了/换个话题」时发。命中空闲会话为 no-op。 - **`voice_input`**(上行,客户端→服务端):`_{audio?, format?, text?, sessionId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。无注入语音服务时回 `error` 帧(`voice unavailable`)。 - **`voice_output`**(下行,服务端→客户端):`{audio, format, text, reply}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本。 - **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。 --- +### 3.2 会话编排状态事件(design/15) +- **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。 +- **`session_busy`**:会话进入忙碌态(`{sessionId, mode}`,`mode` ∈ `idle|thinking|executing|subagent`)。 +- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth}`)——「收到,稍后回」。 +- **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth}`)。 +- **`session_idle`**:会话回到空闲、队列清空(`{sessionId, mode}`)。 +- **`session_interrupted`**:用户 `interrupt` 中断,当前任务取消、pending 清空(`{sessionId, mode, dropped}`)。 +- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。 +- **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{mode, queueDepth}` 之外的续接由服务端完成),非清空重来(design/15 §2.2)。 + ## 4. 客户端接入指引 - **认证**: 先 `POST /api/v1/auth/login` 取 JWT → WS 握手(`?token=`)注入。 - **版本**: 客户端请求头声明 `X-API-Version: 1`。 - **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。 ## 5. 变更记录 +- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(临时记录)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。 +- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。 - `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。 - `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。 - `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。 diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index f3155ef..040fc15 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -3,6 +3,7 @@ package main import ( "context" "database/sql" + "encoding/json" "fmt" "log/slog" "os" @@ -23,6 +24,7 @@ import ( personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" @@ -314,9 +316,13 @@ func (a *app) wireRuntime() error { a.dispatcher.Invoke, ) + capDecider := a.buildCapacity() + a.loop = loop.New(loop.Options{ Agent: ag, Tools: a.dispatcher, + Capacity: capDecider, + OnCapacity: a.onCapacity, Memory: store, Retriever: a.retriever, Extractor: memory.NewManualExtractor(personaID, "owner"), @@ -344,6 +350,53 @@ func (a *app) wireRuntime() error { return nil } +// buildCapacity constructs the awakened-capacity decider when enabled, or nil +// (which keeps the loop's default all-tools behavior). It is opt-in via +// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and +// falls back to built-in defaults on a parse error. +func (a *app) buildCapacity() *capacity.Decider { + if !a.cfg.Capacity.Enabled { + return nil + } + sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON) + if err != nil { + a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err) + sets = nil + } + return capacity.New(capacity.Config{ + Enabled: true, + MinConfidence: a.cfg.Capacity.MinConfidence, + ToolSets: sets, + }, capacity.WithLogger(a.log)) +} + +// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken +// transition for observability. A future frontend wire (design/14 §5) will render +// the "能力点亮" progression from these events. +func (a *app) onCapacity(ev capacity.Event) { + a.log.Debug("capacity event", + "type", ev.Type, "from", ev.FromState, "to", ev.ToState, + "tool", ev.Tool, "at", ev.At) +} + +// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of +// state→allowed tool name list. A blank string yields nil (built-in defaults); a +// null value for a state means unrestricted (all tools). +func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var m map[string][]string + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, fmt.Errorf("capacity tool sets: %w", err) + } + out := make(map[capacity.State][]string, len(m)) + for k, v := range m { + out[capacity.State(k)] = v + } + return out, nil +} + // wireAuxiliary builds the execution provider, task manager, IoT manager, // channel session manager, automations and background thinking services. func (a *app) wireAuxiliary() error { diff --git a/frontend/web/src/admin/components.tsx b/frontend/web/src/admin/components.tsx index ed6dca5..6eb96d3 100644 --- a/frontend/web/src/admin/components.tsx +++ b/frontend/web/src/admin/components.tsx @@ -75,6 +75,7 @@ export interface AdminTableProps { error: string | null; emptyTitle?: string; rowKey: (row: T) => string; + onRowClick?: (row: T) => void; } /** A strict, generic data table. Loading/error/empty states are handled here so @@ -86,6 +87,7 @@ export function AdminTable({ error, emptyTitle = "暂无数据", rowKey, + onRowClick, }: AdminTableProps): ReactNode { if (loading) { return
加载中…
; @@ -113,7 +115,11 @@ export function AdminTable({ {rows.map((row) => ( - + onRowClick(row) : undefined} + className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`} + > {columns.map((c) => ( {c.render(row)} diff --git a/frontend/web/src/admin/modules/Automations.tsx b/frontend/web/src/admin/modules/Automations.tsx index 787ba0d..bd67a0c 100644 --- a/frontend/web/src/admin/modules/Automations.tsx +++ b/frontend/web/src/admin/modules/Automations.tsx @@ -1,30 +1,84 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListAutomations, isNotImplemented } from "../../lib/api"; +import { + adminListAutomations, + adminCreateAutomation, + adminRunAutomation, + adminDeleteAutomation, + isNotImplemented, +} from "../../lib/api"; +import type { AdminAutomationRecord } from "../../lib/types"; import { useAuditStore } from "../audit"; import { Notice, PageHeader } from "../components"; import { Button, Surface, Switch, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; -interface ReminderRow { - id: string; - name: string; - kind: string; - cron: string; - enabled: boolean; -} - -const sample: ReminderRow[] = [ +const sample: AdminAutomationRecord[] = [ { id: "r1", name: "睡前总结", kind: "reminder", cron: "0 22 * * *", enabled: true }, { id: "r2", name: "晨间问候", kind: "cron", cron: "0 8 * * *", enabled: true }, { id: "r3", name: "设备待机提醒", kind: "queue", cron: "—", enabled: false }, ]; export function AutomationsModule(): React.ReactNode { - const { data, error } = useAsyncData(adminListAutomations, []); + const { data, error, reload } = useAsyncData(adminListAutomations, []); const log = useAuditStore((s) => s.log); - const [rows, setRows] = useState(sample); const backend = !isNotImplemented(error); + const rows = backend && data && data.length > 0 ? data : sample; + + const [name, setName] = useState(""); + const [kind, setKind] = useState("cron"); + const [expr, setExpr] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const create = () => { + setBusy(true); + setMsg(null); + adminCreateAutomation({ name, kind, expression: expr }) + .then((r) => { + log("automation.create", "ok"); + setMsg(`已创建 ${r.name}(${r.kind})`); + setName(""); + setExpr(""); + reload(); + }) + .catch((e: unknown) => { + log("automation.create", "error"); + setMsg(e instanceof Error ? e.message : "创建失败"); + }) + .finally(() => setBusy(false)); + }; + + const run = (id: string, label: string) => { + setBusy(true); + setMsg(null); + adminRunAutomation(id) + .then((r) => { + log("automation.run", "ok"); + setMsg(`已触发 ${label}:${JSON.stringify(r.output)}`); + }) + .catch((e: unknown) => { + log("automation.run", "error"); + setMsg(e instanceof Error ? e.message : "触发失败"); + }) + .finally(() => setBusy(false)); + }; + + const del = (id: string, label: string) => { + setBusy(true); + setMsg(null); + adminDeleteAutomation(id) + .then(() => { + log("automation.delete", "ok"); + setMsg(`已删除 ${label}`); + reload(); + }) + .catch((e: unknown) => { + log("automation.delete", "error"); + setMsg(e instanceof Error ? e.message : "删除失败"); + }) + .finally(() => setBusy(false)); + }; return (
@@ -32,33 +86,51 @@ export function AutomationsModule(): React.ReactNode { title="自动化与提醒" subtitle="提醒 + cron + 消息队列事件 + 后台任务(含终端任务) + 运行日志" actions={ - } /> + {msg ? {msg} : null} {backend && !error ? 后端已返回自动化配置(数据以接口为准)。 : null} {error && !backend ? 后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。 : null} {error && backend ? {error} : null}

提醒 / CRON / 队列事件

+
+ + + +
+ {rows.map((r) => (
{r.name} {r.kind} - {r.cron} - - setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" /> + {r.cron || "—"} + + {}} label="启用" /> + + -
))}
@@ -77,3 +149,6 @@ export function AutomationsModule(): React.ReactNode {
); } + +const inputCls = + "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index c9383a3..a38e14f 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -1,52 +1,101 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListChannels } from "../../lib/api"; -import type { AdminChannelRecord } from "../../lib/types"; +import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest } from "../../lib/api"; +import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { Button, Surface, Tag } from "../../components/primitives"; -const columns: AdminColumn[] = [ - { key: "name", title: "渠道", render: (c) => {c.name} }, - { - key: "connected", - title: "状态", - render: (c) => ( - - - {c.connected ? "已连接" : "未连接"} - - ), - }, -]; - export function ChannelsModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); const log = useAuditStore((s) => s.log); + const [busy, setBusy] = useState(null); + const [err, setErr] = useState(null); + const [ownerId, setOwnerId] = useState(""); + const [platform, setPlatform] = useState(""); const [group, setGroup] = useState(false); - const [verdict, setVerdict] = useState(null); + const [verdict, setVerdict] = useState(null); + + const toggle = (name: string, connect: boolean) => { + setBusy(name); + setErr(null); + const call = connect ? adminChannelOpen(name) : adminChannelClose(name); + call + .then(() => { + log(connect ? "channel.open" : "channel.close", "ok"); + reload(); + }) + .catch((e: unknown) => { + log(connect ? "channel.open" : "channel.close", "error"); + setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`); + }) + .finally(() => setBusy(null)); + }; + + const test = () => { + if (!ownerId.trim()) { + setErr("邮箱/账号 id 不能为空"); + return; + } + setBusy("__test__"); + setErr(null); + adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group }) + .then((v) => { + log("channel.identity-test", "ok"); + setVerdict(v); + }) + .catch((e: unknown) => { + log("channel.identity-test", "error"); + setErr(e instanceof Error ? e.message : "身份测试失败"); + }) + .finally(() => setBusy(null)); + }; + + const columns: AdminColumn[] = [ + { key: "name", title: "渠道", render: (c) => {c.name} }, + { + key: "connected", + title: "状态", + render: (c) => ( + + + {c.connected ? "已连接" : "未连接"} + + ), + }, + { + key: "actions", + title: "操作", + render: (c) => ( + + + + + ), + }, + ]; return (
{ - log("channel.reconnect", "ok"); - alert("热重载/重连(占位):后端 M10+ 提供。"); - }} - > - 热重载 + } /> + {err ? {err} : null} + -

身份识别测试(占位 · 策略引擎 M10 接入)

+

身份识别测试(结构性判定 · 绝非提示词)

- 输入渠道确认的发送者账号 id,模拟 channel 层 actor 元数据 → 策略引擎判定;判定绝不依赖提示词 / LLM 自我判断。 + 输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。

+ setPlatform(e.target.value)} + placeholder="platform(如 weixin-ilink,可留空)" + className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" + /> setOwnerId(e.target.value)} @@ -74,24 +129,19 @@ export function ChannelsModule(): React.ReactNode { setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" /> 群聊 -
- {verdict ?
判定:{verdict}
: null} + {verdict ? ( +
+
角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}
+
判定:{verdict.verdict}
+
+ ) : null}
- 账号配置(扫码/token)、消息流预览、推送测试为后端 M10+ 提供;此页当前展示渠道注册与连接健康。 + 账号配置(扫码/token)、消息流预览、推送测试由后端 M10+ 提供;此页当前为注册渠道 + 连接健康 + 结构性身份识别。
); } diff --git a/frontend/web/src/admin/modules/IoT.tsx b/frontend/web/src/admin/modules/IoT.tsx index 5b1eb76..bdad27e 100644 --- a/frontend/web/src/admin/modules/IoT.tsx +++ b/frontend/web/src/admin/modules/IoT.tsx @@ -1,5 +1,6 @@ +import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListIoT } from "../../lib/api"; +import { adminListIoT, adminIoTControl, adminIoTScenario, adminIoTRule } from "../../lib/api"; import type { AdminIoTDevice } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; @@ -31,42 +32,118 @@ const columns: AdminColumn[] = [ ), }, + { + key: "control", + title: "控制", + render: (d) => ( + + ), + }, ]; +function DeviceControl({ device, disabled }: { device: AdminIoTDevice; disabled: boolean }): React.ReactNode { + const [action, setAction] = useState("on"); + const [params, setParams] = useState(""); + const [res, setRes] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const log = useAuditStore((s) => s.log); + + const send = () => { + setBusy(true); + setErr(null); + let parsed: Record | undefined; + if (params.trim()) { + try { + parsed = JSON.parse(params) as Record; + } catch { + setErr("参数需为合法 JSON"); + setBusy(false); + return; + } + } + adminIoTControl(device.id, action.trim() || "on", parsed) + .then((d) => { + log("iot.control", "ok"); + setRes(`${d.name} → ${action}:${JSON.stringify(d.state)}`); + }) + .catch((e: unknown) => { + log("iot.control", "error"); + setErr(e instanceof Error ? e.message : "控制失败"); + }) + .finally(() => setBusy(false)); + }; + + return ( +
+ setAction(e.target.value)} + placeholder="on / off / set_temperature" + className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" + /> + setParams(e.target.value)} + placeholder='{"temp":24}' + className="w-28 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" + /> + + {res ? {res} : null} + {err ? {err} : null} +
+ ); +} + export function IoTModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListIoT, []); useAutoReload(reload, 8000); const log = useAuditStore((s) => s.log); + const [msg, setMsg] = useState(null); + + const scenario = () => + adminIoTScenario({ name: "场景-夜灯" }) + .then(() => { + log("iot.scene", "ok"); + setMsg("场景保存成功"); + }) + .catch((e: unknown) => { + log("iot.scene", "error"); + setMsg(e instanceof Error ? e.message : "场景保存失败"); + }); + + const rule = () => + adminIoTRule({ name: "规则-定时", expression: "0 22 * * *" }) + .then(() => { + log("iot.rule", "ok"); + setMsg("规则保存成功"); + }) + .catch((e: unknown) => { + log("iot.rule", "error"); + setMsg(e instanceof Error ? e.message : "规则保存失败"); + }); return (
- - } /> + {msg ? {msg} : null} + - 实时上/下线经 WS device_update 事件或轮询刷新;控制/场景/规则写入需后端 M10+ 提供。 + 设备控制真实下发到 IoT provider(在线设备可操作);场景/规则存储由后端 M10 提供,当前返回明确待实现。
); } diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index d537944..1bee5a8 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -1,43 +1,77 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminGetLlm, isNotImplemented } from "../../lib/api"; +import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api"; +import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types"; import { useAuditStore } from "../audit"; import { Notice, PageHeader } from "../components"; import { Button, Surface, Switch, Tag } from "../../components/primitives"; -interface ProviderRow { - id: string; - name: string; - baseUrl: string; - model: string; - multimodal: boolean; - enabled: boolean; -} - -const sample: ProviderRow[] = [ - { id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true }, - { id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true }, -]; +const sample: AdminLlmConfig = { + providers: [ + { id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true }, + { id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true }, + ], +}; export function LlmModule(): React.ReactNode { - const { data, error } = useAsyncData(adminGetLlm, []); + const { data, error, reload } = useAsyncData(adminGetLlm, []); const log = useAuditStore((s) => s.log); - const [rows, setRows] = useState(sample); const backend = !isNotImplemented(error); + const providers = backend && data ? data.providers : sample.providers; + + const [name, setName] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [multimodal, setMultimodal] = useState(false); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(null); + const [result, setResult] = useState(null); + + const addProvider = () => { + setBusy(true); + setFail(null); + setResult(null); + adminAddLlmProvider({ name, model, baseUrl, multimodal }) + .then(({ provider }) => { + log("llm.addProvider", "ok"); + setResult(`已记录 ${provider.name}(进程生命周期,未绑定模型路由)`); + setName(""); + setModel(""); + setBaseUrl(""); + setMultimodal(false); + reload(); + }) + .catch((e: unknown) => { + log("llm.addProvider", "error"); + setFail(e instanceof Error ? e.message : "新增模型失败"); + }) + .finally(() => setBusy(false)); + }; + + const test = (p: AdminLlmProvider) => { + setBusy(true); + setFail(null); + setResult(null); + const providerId = p.id === "anthropic" ? "anthropic" : "openai"; + adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model }) + .then((r) => { + log("llm.test", "ok"); + setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`); + }) + .catch((e: unknown) => { + log("llm.test", "error"); + setFail(e instanceof Error ? e.message : "模型测试失败"); + }) + .finally(() => setBusy(false)); + }; return (
{ - log("llm.addProvider", "ok"); - alert("新增模型(占位):后端 M10+ 提供真实持久化。"); - }} - > + } @@ -46,23 +80,49 @@ export function LlmModule(): React.ReactNode { {backend && !error ? 后端已返回 LLM 配置(数据以接口为准)。 : null} {error && !backend ? 后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。 : null} {error && backend ? {error} : null} + {fail ? {fail} : null} + {result ? {result} : null}

供应商 / 模型

- {rows.map((r) => ( + {providers.map((r) => (
{r.name} {r.model} - {r.baseUrl} + {r.baseUrl || "—"} - setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} label="启用" /> - setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, multimodal: v } : x)))} label="多模态" /> - + {r.enabled ? "启用" : "停用"} + {r.multimodal ? "多模态" : "文本"} +
))}
+ +

新增模型(进程生命周期,未绑定路由)

+
+ + + +
+
+ + API key 不入库、不进 wire、不泄露;测试端点每次用请求内的 key 直接调用。 +
+
+

按角色路由 + 降级链

{["owner", "admin", "viewer"].map((role) => ( @@ -74,7 +134,10 @@ export function LlmModule(): React.ReactNode { ))}
- 配置写入 / 多模态检测 / 用量费用统计为后端 M10+ 提供;per-role 路由 + 降级逻辑由后端 loop 实现。 + per-role 路由 + 降级逻辑由后端 loop 实现;新增模型记录为进程生命周期临时(不持久化/config 不变)。
); } + +const inputCls = + "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; diff --git a/frontend/web/src/admin/modules/Memories.tsx b/frontend/web/src/admin/modules/Memories.tsx index eada8e4..aef8ddc 100644 --- a/frontend/web/src/admin/modules/Memories.tsx +++ b/frontend/web/src/admin/modules/Memories.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListMemories } from "../../lib/api"; -import type { MemoryRecord } from "../../lib/types"; +import { adminListMemories, adminMemorySearch } from "../../lib/api"; +import type { MemoryRecord, MemorySearchHit } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; @@ -13,16 +13,26 @@ const scopeTone: Record = { project: "warn", }; -const columns: AdminColumn[] = [ - { key: "scope", title: "作用域", render: (m) => {m.scope} }, - { key: "text", title: "内容", render: (m) => {m.text} }, - { key: "id", title: "ID", render: (m) => {m.id} }, -]; +function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; loading: boolean; error: string | null }): React.ReactNode { + const columns: AdminColumn[] = [ + { key: "scope", title: "作用域", render: (m) => {m.scope} }, + { key: "text", title: "内容", render: (m) => {m.text} }, + { key: "id", title: "ID", render: (m) => {m.id} }, + ]; + return ( + m.id} /> + ); +} export function MemoriesModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListMemories, []); useAutoReload(reload, 15000); const [query, setQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [personaId, setPersonaId] = useState("cyrene"); + const [hits, setHits] = useState(null); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(null); const log = useAuditStore((s) => s.log); const filtered = (data ?? []).filter((m) => { @@ -31,42 +41,83 @@ export function MemoriesModule(): React.ReactNode { return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q); }); + const search = () => { + if (!searchQuery.trim()) { + setFail("测试查询不能为空"); + return; + } + setBusy(true); + setFail(null); + adminMemorySearch({ query: searchQuery.trim(), personaId: personaId.trim() || "cyrene" }) + .then((r) => { + log("memory.vectorTest", "ok"); + setHits(r.hits); + }) + .catch((e: unknown) => { + log("memory.vectorTest", "error"); + setFail(e instanceof Error ? e.message : "向量测试失败"); + }) + .finally(() => setBusy(false)); + }; + return (
{ - log("memory.vectorTest", "ok"); - alert("向量检索测试(占位):后端 M10+ 提供语义/向量路径。"); - }} - > + } /> + {fail ? {fail} : null} + + +

语义/关键词检索测试(真实 retrieval)

+
+ setSearchQuery(e.target.value)} + placeholder="检索查询…" + className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" + /> + setPersonaId(e.target.value)} + placeholder="personaId" + className="w-32 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" + /> +
+ {hits ? ( +
+ {hits.length === 0 ? ( + 无命中 + ) : ( + hits.map((h) => ( +
+ {h.scope} {h.score.toFixed(3)} +
{h.text}
+
+ )) + )} + 当前为 keyword×decay 路径;语义/向量路径需主会话注入 vector index(wire.go 装配)。 +
+ ) : null} +
+ setQuery(e.target.value)} - placeholder="按内容 / 作用域 / ID 检索…" + placeholder="按内容 / 作用域 / ID 过滤…" className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" /> - m.id} - /> + - 编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。 + 编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
); } diff --git a/frontend/web/src/admin/modules/Personas.tsx b/frontend/web/src/admin/modules/Personas.tsx index 65e4f89..6eaa94e 100644 --- a/frontend/web/src/admin/modules/Personas.tsx +++ b/frontend/web/src/admin/modules/Personas.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListPersonas } from "../../lib/api"; +import { adminListPersonas, adminCreatePersona, adminUpdatePersona } from "../../lib/api"; import type { PersonaRecord } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; @@ -14,30 +14,103 @@ const columns: AdminColumn[] = [ title: "默认", render: (p) => (p.isDefault ? 默认 : —), }, - { key: "mode", title: "模式", render: (p) => {p.mode} }, - { key: "memory", title: "记忆域", render: (p) => {p.memoryNamespace} }, + { key: "mode", title: "模式", render: (p) => {p.mode || "—"} }, + { key: "memory", title: "记忆域", render: (p) => {p.memoryNamespace || "—"} }, { key: "id", title: "ID", render: (p) => {p.id} }, ]; +interface Draft { + id: string; + name: string; + role: string; + systemPrompt: string; + mode: string; + memoryNamespace: string; +} + +const emptyDraft: Draft = { id: "", name: "", role: "", systemPrompt: "", mode: "", memoryNamespace: "" }; + export function PersonasModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListPersonas, []); useAutoReload(reload, 10000); const [selected, setSelected] = useState(null); + const [creating, setCreating] = useState(false); + const [draft, setDraft] = useState(emptyDraft); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(null); const log = useAuditStore((s) => s.log); + const startCreate = () => { + setDraft(emptyDraft); + setCreating(true); + setSelected(null); + setFail(null); + }; + + const editDraftFrom = (p: PersonaRecord): Draft => ({ + id: p.id, + name: p.name, + role: p.character || "", + systemPrompt: "", + mode: p.mode || "", + memoryNamespace: p.memoryNamespace || "", + }); + + const create = () => { + setBusy(true); + setFail(null); + adminCreatePersona({ + id: draft.id, + name: draft.name, + role: draft.role, + systemPrompt: draft.systemPrompt, + mode: draft.mode, + memoryNamespace: draft.memoryNamespace, + }) + .then((p) => { + log("persona.create", "ok"); + setCreating(false); + setDraft(emptyDraft); + setSelected(p); + reload(); + }) + .catch((err: unknown) => { + log("persona.create", "error"); + setFail(err instanceof Error ? err.message : "创建人格失败"); + }) + .finally(() => setBusy(false)); + }; + + const save = () => { + if (!selected) return; + setBusy(true); + setFail(null); + adminUpdatePersona(selected.id, { + name: draft.name, + role: draft.role, + systemPrompt: draft.systemPrompt, + mode: draft.mode, + memoryNamespace: draft.memoryNamespace, + }) + .then((p) => { + log("persona.edit", "ok"); + setSelected(p); + reload(); + }) + .catch((err: unknown) => { + log("persona.edit", "error"); + setFail(err instanceof Error ? err.message : "保存人格失败"); + }) + .finally(() => setBusy(false)); + }; + return (
{ - log("persona.create", "ok"); - alert("新建人格:后端 M10+ 提供,当前为占位操作。"); - }} - > + } @@ -49,35 +122,77 @@ export function PersonasModule(): React.ReactNode { rows={loading ? null : data} loading={loading} error={error} - emptyTitle="无人格数据(后端未接线)" + emptyTitle="无人格数据" rowKey={(p) => p.id} + onRowClick={(p) => { + setSelected(p); + setDraft(editDraftFrom(p)); + setCreating(false); + setFail(null); + }} /> - 点击表格行查看/编辑该人格(编辑为本地占位,写入需后端 M10+ 提供)。 + {fail ? {fail} : null} - {selected ? ( + {creating ? ( + +

新建人格

+
+ setDraft({ ...draft, id: v })} /> + setDraft({ ...draft, name: v })} /> + setDraft({ ...draft, role: v })} /> + setDraft({ ...draft, mode: v })} /> + setDraft({ ...draft, memoryNamespace: v })} /> +
+
+ 身份说systemPrompt(可留空,自动生成) +