diff --git a/.env.example b/.env.example index 0fa0841..c526b84 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,13 @@ SESSION_IDLE_TIMEOUT_MIN=30 # ---- 跨域白名单 ---- ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ---- +# 单账号仍走上面 CHANNEL__* 环境变量;这里提供独立的 JSON 注册表, +# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截, +# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如: +# CHANNELS_JSON={"telegram":[{"id":"conf0","config":{"bot_token":"..."},"denyUsers":["12345"],"denyGroups":["-100"]}]} +# 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、 +# allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、 +# allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。 +# CHANNELS_JSON= diff --git a/Docs/api-client.md b/Docs/api-client.md index f0429e1..52a1c95 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -24,7 +24,7 @@ | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06);`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt}]}`(主会话 `id="web"` 恒在首位,次级 `web:`);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 HistoryStore 回 `501 not_implemented` | -| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,createdAt}]}`;`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` | +| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,createdAt}]}`;`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人私聊消息带,主人私聊为空,`omitempty`);非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | | 8 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | @@ -35,8 +35,8 @@ | # | 端点 | 方法 | 状态 | 说明 | |---|------|------|:---:|------| | 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表 | -| 2 | `/api/v1/admin/sessions` | GET | ✅ | 会话列表 | -| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(检索/编辑随 M10 扩展) | +| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图 | +| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) | | 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) | | 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) | | 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 | @@ -51,8 +51,10 @@ | 15 | `/api/v1/admin/observability/metrics` | GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) | | 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条) | | 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) | +| 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` | +| 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` | -> **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`,不伪造成功。 +> **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`。 @@ -69,15 +71,18 @@ - `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` 白名单,绝不靠提示词。 +- `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。 - `POST /admin/plugins/install` → `501 not_implemented`(需插件源码清单)。 - `POST /admin/plugins/uninstall` body `{name}` → `200 {data:{name,uninstalled:true}}`(真实从 registry 移除);`404`。 +- `POST /admin/memories` body `{personaId?,content,scope?}` → `201 {data:{id,scope,text}}`。**手动添加记忆**(owner 私聊直写);`scope` ∈ `user|persona|project`,缺省 `persona`(与「记住…」同路径);无效非空 `scope` 回 `400`;未注入 `memory.Store` 回 `501`。 - `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` 反映部署是否配置向量增强(未配置为待嵌入)。 +- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。 - `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。 -- `POST /admin/rag/delete` body `{"id":"..."}` → 写操作 M10,返回 `501` `{"error":{"code":"not_implemented",...}}`。 +- `POST /admin/rag/delete` body `{"id":"..."}` → 删除为后置 M10,返回 `501` `{"error":{"code":"not_implemented",...}}`。 **可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin,`?limit=N` 缺省 50、上限 500): - `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本后端不统计、不返回**(不伪造指标)。 @@ -89,7 +94,7 @@ - **握手**: `ws://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 - **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤)——`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` / **`message_persisted`**(会话持久化增量,见 §3.3)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。 - > **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。 + > **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)。 ### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`) - **事件壳**:`{type, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。 @@ -118,7 +123,8 @@ ### 3.3 消息持久化增量事件(`message_persisted`,design/16) - **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。 -- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`thinking` 已剥离(design/16 §6)。 +- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);`thinking` 已剥离(design/16 §6)。 +- **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取。 - **客户端处理**:仅当 `data.message.sessionId === activeSessionId`(激活会话)时处理;按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。 ## 4. 客户端接入指引 @@ -127,6 +133,7 @@ - **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。 ## 5. 变更记录 +- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。 - `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web::<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `::` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。 - `v0.12`(IM 渠道并入主会话 + 平台标记,design/16 §4.4):**`chat_messages` 增 `platform` 列**(迁移 0004,默认 `'web'`);`GET /api/v1/sessions/{id}/messages` 支持可选 **`?platform=`** 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS `message_persisted`)均带 `platform`。**IM 渠道主人私聊**(`!IsGroup && Owner`)经 channelbridge 落**主会话 `web`**(`platform=<渠道>`)并广播 `message_persisted` → Web 端免刷新镜像;**群聊/陌生人**保持独立隔离上下文、不落主会话。Web 端聊天(`platform=web`)不回流 IM。 - `v0.11`(跨端实时同步,design/16 §4.2):WS 下行新增 **`message_persisted`** 事件——每条 user/assistant 消息落库后广播 `{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}`(`id` 为后端生成,`thinking` 已剥离)。客户端按 `sessionId === activeSessionId` 过滤 + 按 `id` upsert(含本端乐观气泡 `uid`→后端 `id` 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 `history.Store` 新增 `AppendFull` 供编排层取含 `id` 的落库消息。 diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index f1347f8..7396143 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -8,10 +8,12 @@ package main // the channel bridge. import ( + "fmt" "log/slog" "os" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" @@ -68,9 +70,119 @@ func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter { if weixin != nil { out = append(out, weixin) } + + // design/16 §4.6 §5: a platform may carry multiple access configs (multiple + // accounts) via CHANNELS_JSON. Each account becomes its own adapter instance + // (named platform:confID) with independent session/account and its own allow/deny + // black-or-white lists. An empty CHANNELS_JSON adds nothing, so the single-account + // env path above is unchanged. + if accounts, err := config.ParseChannelAccounts(os.Getenv("CHANNELS_JSON")); err == nil { + for platform, accts := range accounts { + for _, acct := range accts { + built, berr := buildAccountAdapter(platform, acct, log) + if berr != nil { + log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr) + continue + } + out = append(out, built) + } + } + } else { + log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err) + } return out } +// namedAdapter wraps a platform adapter so a multi-account instance gets a unique +// name (platform:confID) and surfaces its allow/deny lists through Config(), which +// the channel bridge reads to enforce the platform's black-or-white lists per +// account (design/16 §4.6 §5-6). Everything else delegates to the inner adapter. +type namedAdapter struct { + adapter.PlatformAdapter + name string + allowGroups []string + denyGroups []string + allowUsers []string + denyUsers []string +} + +func (n *namedAdapter) Name() string { return n.name } + +func (n *namedAdapter) Config() map[string]any { + cfg := n.PlatformAdapter.Config() + out := make(map[string]any, len(cfg)+4) + for k, v := range cfg { + out[k] = v + } + out["allowGroups"] = n.allowGroups + out["denyGroups"] = n.denyGroups + out["allowUsers"] = n.allowUsers + out["denyUsers"] = n.denyUsers + return out +} + +// buildAccountAdapter builds one account's adapter for a platform, decoding the +// account's generic Config map into the platform's typed config and wrapping it so +// the bridge sees a distinct account name and its allow/deny lists. +func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) { + name := platform + if acct.ID != "" { + name = platform + ":" + acct.ID + } + + var inner adapter.PlatformAdapter + switch barePlatform(platform) { + case "telegram": + var cfg telegram.Config + if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { + return nil, fmt.Errorf("decode telegram config: %w", err) + } + inner = telegram.New(cfg, telegram.WithLogger(log)) + case "weixin-ilink": + var cfg weixin_ilink.Config + if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { + return nil, fmt.Errorf("decode weixin-ilink config: %w", err) + } + w, err := weixin_ilink.New(cfg, nil, log) + if err != nil { + return nil, fmt.Errorf("build weixin-ilink: %w", err) + } + inner = w + case "qq-obv11": + var cfg qq_obv11.Config + if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { + return nil, fmt.Errorf("decode qq-obv11 config: %w", err) + } + inner = qq_obv11.NewAdapter(cfg) + case "webhook": + var cfg webhook.Config + if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { + return nil, fmt.Errorf("decode webhook config: %w", err) + } + inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook"))) + default: + return nil, fmt.Errorf("unknown platform %q", platform) + } + return &namedAdapter{ + PlatformAdapter: inner, + name: name, + allowGroups: acct.AllowGroups, + denyGroups: acct.DenyGroups, + allowUsers: acct.AllowUsers, + denyUsers: acct.DenyUsers, + }, nil +} + +// barePlatform returns the platform name for a possibly-suffixed "platform:confID". +func barePlatform(name string) string { + for i := 0; i < len(name); i++ { + if name[i] == ':' { + return name[:i] + } + } + return name +} + // envString returns the value of key, or fallback when the variable is unset or // blank. func envString(key, fallback string) string { diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 3844288..46cf8fb 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -15,6 +15,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" @@ -553,6 +554,12 @@ func (a *app) wireFrontend() error { // `message_persisted` broadcast the orchestrator uses, so the Web client mirrors // IM messages with one, uniform frame (design/16 §4.4). a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted) + // design/16 §4.6 §6: a message dropped by the platform's allow/deny + // black-or-white lists is recorded in the gateway audit trail so the + // rejection is "audit 可查" even though it never reaches the loop. + a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) { + a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason) + }) } a.gw = gw return nil diff --git a/frontend/web/src/admin/modules/Memories.tsx b/frontend/web/src/admin/modules/Memories.tsx index aef8ddc..2cba554 100644 --- a/frontend/web/src/admin/modules/Memories.tsx +++ b/frontend/web/src/admin/modules/Memories.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListMemories, adminMemorySearch } from "../../lib/api"; +import { adminAddMemory, adminListMemories, adminMemorySearch } from "../../lib/api"; import type { MemoryRecord, MemorySearchHit } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; @@ -9,10 +9,12 @@ import { Button, Surface, Tag } from "../../components/primitives"; const scopeTone: Record = { user: "accent", - habitual: "ok", + persona: "ok", project: "warn", }; +const SCOPES = ["user", "persona", "project"] as const; + 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} }, @@ -20,7 +22,7 @@ function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; lo { key: "id", title: "ID", render: (m) => {m.id} }, ]; return ( - m.id} /> + m.id} /> ); } @@ -33,6 +35,10 @@ export function MemoriesModule(): React.ReactNode { const [hits, setHits] = useState(null); const [busy, setBusy] = useState(false); const [fail, setFail] = useState(null); + // Manual "添加记忆" form fields. + const [addPersona, setAddPersona] = useState("cyrene"); + const [addScope, setAddScope] = useState<(typeof SCOPES)[number]>("persona"); + const [addContent, setAddContent] = useState(""); const log = useAuditStore((s) => s.log); const filtered = (data ?? []).filter((m) => { @@ -60,11 +66,36 @@ export function MemoriesModule(): React.ReactNode { .finally(() => setBusy(false)); }; + const addMemory = () => { + if (!addContent.trim()) { + setFail("记忆内容不能为空"); + return; + } + setBusy(true); + setFail(null); + adminAddMemory({ + personaId: addPersona.trim() || "cyrene", + scope: addScope, + content: addContent.trim(), + }) + .then(() => { + log("memory.add", "ok"); + setAddContent(""); + // Reflect the new record immediately (also refreshes the table). + reload(); + }) + .catch((e: unknown) => { + log("memory.add", "error"); + setFail(e instanceof Error ? e.message : "添加记忆失败"); + }) + .finally(() => setBusy(false)); + }; + return (
向量测试 @@ -74,6 +105,42 @@ export function MemoriesModule(): React.ReactNode { {fail ? {fail} : null} + +

添加记忆(写入后端持久化)

+
+ setAddPersona(e.target.value)} + placeholder="personaId" + className="w-40 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)]" + /> + + setAddContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && addContent.trim()) void addMemory(); + }} + 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)]" + /> + +
+ 作用域为 persona 时与「记住…」同路径;owner 私聊写入安全(群聊/陌生人隔离在 loop 层)。 +
+

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

@@ -117,7 +184,9 @@ export function MemoriesModule(): React.ReactNode { - 编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。 + + 编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。 +
); } diff --git a/frontend/web/src/admin/modules/Rag.tsx b/frontend/web/src/admin/modules/Rag.tsx index d108dd6..3274727 100644 --- a/frontend/web/src/admin/modules/Rag.tsx +++ b/frontend/web/src/admin/modules/Rag.tsx @@ -1,13 +1,11 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminDeleteRag, adminListRag, adminSearchRag } from "../../lib/api"; +import { adminDeleteRag, adminIndexRag, adminListRag, adminSearchRag } from "../../lib/api"; import type { AdminRagDoc, AdminRagHit } from "../../lib/types"; import { useAuditStore } from "../audit"; import { Notice, PageHeader } from "../components"; import { Button, Surface, Tag } from "../../components/primitives"; -const WRITE_M10 = "上传/分块/嵌入为后端 M10+ 提供;当前只读列文档与检索,删除接口在 M10 落地。"; - export function RagModule(): React.ReactNode { const log = useAuditStore((s) => s.log); const { data, loading, error, reload } = useAsyncData(() => adminListRag(), []); @@ -15,6 +13,11 @@ export function RagModule(): React.ReactNode { const [hits, setHits] = useState([]); const [searching, setSearching] = useState(false); const [notice, setNotice] = useState(null); + // Manual "导入文档" form fields. + const [docName, setDocName] = useState(""); + const [docDesc, setDocDesc] = useState(""); + const [docContent, setDocContent] = useState(""); + const [importing, setImporting] = useState(false); const docs: AdminRagDoc[] = data?.documents ?? []; @@ -40,7 +43,34 @@ export function RagModule(): React.ReactNode { try { await adminDeleteRag(d.id); } catch (err: unknown) { - setNotice(err instanceof Error ? err.message : WRITE_M10); + setNotice(err instanceof Error ? err.message : "文档删除为后端 M10 写操作"); + } + }; + + const importDoc = async () => { + if (!docName.trim() || !docContent.trim()) { + setNotice("文档名称与内容不能为空"); + return; + } + setImporting(true); + setNotice(null); + try { + await adminIndexRag({ + name: docName.trim(), + desc: docDesc.trim() || undefined, + content: docContent.trim(), + }); + log("rag.index", "ok"); + setDocName(""); + setDocDesc(""); + setDocContent(""); + reload(); + setNotice("文档已导入,可按知识库检索。"); + } catch (err: unknown) { + log("rag.index", "error"); + setNotice(err instanceof Error ? err.message : "导入文档失败"); + } finally { + setImporting(false); } }; @@ -48,21 +78,43 @@ export function RagModule(): React.ReactNode {
{ - log("rag.upload", "ok"); - setNotice(WRITE_M10); - }} - > - 上传文档 + } /> - {notice ? {notice} : null} + {notice ? {notice} : null} + + +

导入文档(写入项目索引,可分块、可检索)

+ setDocName(e.target.value)} + placeholder="文档名称(如:内部知识库.md)…" + 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)]" + /> + setDocDesc(e.target.value)} + placeholder="描述(可选,作为项目描述)…" + 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)]" + /> +