feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

A1 情绪接线:
- loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步
- persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700)
- 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发
- cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd
- 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟
- Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照)
- 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环

拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批

回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
This commit is contained in:
AskaEth committed 2026-09-30 08:51:38 +08:00
1 parent 610b330025
commit 1a85de555d
27 files changed
+1946 -594

No files matched your search

+11 -1
View File
@@ -129,7 +129,7 @@
## 3. WebSocket `/ws/chat`(实时) ## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 - **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", 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`(定向回请求连接,非广播)。 - **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", 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`(情绪变化,见 §3.7) / `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` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)。 > **`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,主人私聊为空)。
@@ -187,6 +187,15 @@
- **红线**:owner-only;payload 已脱敏;backthink 每次主动推送**只发一个决策平台**(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。 - **红线**:owner-only;payload 已脱敏;backthink 每次主动推送**只发一个决策平台**(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
- **推送出口(D1,对客户端无新端点)**:在 WS/IM 投递之外,后端会把主动消息**异步镜像**到「推送出口」——主通道是**主人自建统一推送服务**(`PUSH_EXPORTER_URL` webhook,HTTP POST JSON `{source,title,content,platform,timestamp}`,可带 `Authorization: Bearer <PUSH_EXPORTER_TOKEN>`),回退是**个推 Getui**(字段预留、SDK 未接入)。该出口只影响**出站投递**,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),**不影响**上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。 - **推送出口(D1,对客户端无新端点)**:在 WS/IM 投递之外,后端会把主动消息**异步镜像**到「推送出口」——主通道是**主人自建统一推送服务**(`PUSH_EXPORTER_URL` webhook,HTTP POST JSON `{source,title,content,platform,timestamp}`,可带 `Authorization: Bearer <PUSH_EXPORTER_TOKEN>`),回退是**个推 Getui**(字段预留、SDK 未接入)。该出口只影响**出站投递**,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),**不影响**上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。
### 3.7 情绪事件(`mood`,design/01 §2 情绪状态机 + 好感度)
- **触发**:每轮对话结束(loop 回合边界,每轮一次、**非**每步)把该轮用户消息的情绪信号应用到**该角色自己的**情绪状态机(按 persona 隔离),且**状态真的变化时**才广播一次 `mood`;状态不变(含「一直 calm」)**不发**,避免每轮噪声。
- **载荷**:`{type:"mood", data:{mood, personaId, affection}}`——`mood` ∈ `calm|happy|excited|sad|angry`(情绪状态机 state id,与消息体 `mood` 同源同值);`personaId` 为该角色 id(情绪/好感度跟随角色,不跟随行为模式);`affection` 为该角色当前好感度(受角色配置 `affection.min/max` 约束)。角色级数值,**不带** `sessionId`。
- **与消息体 `mood` 的关系**:assistant 消息体的 `mood` 是该轮结束后的**当前情绪**(每轮都写,见 §3.3),而 `mood` 事件只在**变化时**发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。
- **群聊/陌生人(`MemWritable=false`)**:情绪**只读不写**——第三方消息**不**改变角色情绪与好感度、**不**发 `mood` 事件、该轮 assistant 消息 `mood` 为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。
- **状态转移与衰减**:按角色配置的转移表(缺省内置表)与 `emotion.decayRate`——有情绪信号的轮次**沿合法转移走一步**指向对应状态;无信号轮次**衰减一步**趋向 `calm`;`decayRate ≤ 0` 关闭衰减。好感度按角色 `affection.delta` 随情绪轮次增减(warm `+delta` / cold `−delta`,clamped 到 `[min,max]`)。
- **客户端处理**:`mood` → `setMood(data.mood)`(`src/lib/ws.ts`),顶栏/侧栏显示「心情 · <mood>」;消息体内 `mood` 由聊天气泡显示(`chat.tsx`)。
- **状态持久性**:情绪与好感度为**进程内、按角色**状态,**重启回到角色配置的 `initialState`**(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。
## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用) ## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
### 4.1 一次性接入流程(三步走) ### 4.1 一次性接入流程(三步走)
@@ -222,6 +231,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录 ## 5. 变更记录
- `v0.32`(**A1 情绪系统接线:情绪/好感度真跑起来**):后端把既有的 `persona/emotion` 状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动**该角色自己**的情绪状态机(沿配置转移表走一步)并调整好感度(按角色 `affection.delta`);无信号轮次衰减一步趋向 `calm`。① **消息 `mood` 不再恒空**:assistant 消息落库带该轮情绪,`message_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 的 `mood` 为真实值(user 消息不带)。② **新增下行 `mood` 事件**(`{data:{mood,personaId,affection}}`,**仅情绪变化时**广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ **隔离**:群聊/陌生人(`MemWritable=false`)情绪只读不写——不改情绪/好感度、不发事件、该轮 `mood` 空。④ **持久性**:情绪/好感度为进程内按角色状态,重启回 `initialState`(未落库;design/01 未要求)。**触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认**。**契约新增(向后兼容):`mood` 事件 + 消息 `mood` 变真实值。**
- `v0.31`(**Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子**):① **B5-知识库删除由 501 改为真实实现**:`POST /api/v1/admin/rag/delete` body `{"id"}` → `200 {data:{id,deleted:true}}`——从项目索引删除该文档条目并**级联删除全部章节**(单事务);不存在回 `404 not_found`,`id` 空回 `400`,未注入 store 回 `501`;路由由 admin 收紧为 **owner-only**(与其它 admin 写一致)+ 审计 `rag.delete`。② **B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501**:无用户表(登录仅 `.env` 单 admin)、IoT 无场景/规则模型与存储(`iot.Provider` 只有设备列表/控制/状态),错误消息明确缺什么,**不伪造 store/假成功**。③ **D1 推送出口钩子**:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,**自建统一推送服务优先 → 个推(Getui)兜底**(个推 SDK 未接入,仅预留 `GETUI_*` 字段,不可达时日志注明待接入);`.env` 新增 `PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC`,token 只走 `Authorization` 头、不落日志;**对客户端无新端点**(详见 §3.6)。 - `v0.31`(**Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子**):① **B5-知识库删除由 501 改为真实实现**:`POST /api/v1/admin/rag/delete` body `{"id"}` → `200 {data:{id,deleted:true}}`——从项目索引删除该文档条目并**级联删除全部章节**(单事务);不存在回 `404 not_found`,`id` 空回 `400`,未注入 store 回 `501`;路由由 admin 收紧为 **owner-only**(与其它 admin 写一致)+ 审计 `rag.delete`。② **B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501**:无用户表(登录仅 `.env` 单 admin)、IoT 无场景/规则模型与存储(`iot.Provider` 只有设备列表/控制/状态),错误消息明确缺什么,**不伪造 store/假成功**。③ **D1 推送出口钩子**:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,**自建统一推送服务优先 → 个推(Getui)兜底**(个推 SDK 未接入,仅预留 `GETUI_*` 字段,不可达时日志注明待接入);`.env` 新增 `PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC`,token 只走 `Authorization` 头、不落日志;**对客户端无新端点**(详见 §3.6)。
- `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。 - `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。 - `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// This file wires the persona emotion state machine (design/01 §2: 情绪状态机 +
// 好感度曲线 follow the persona/character, never a behavior mode) into the agent
// loop's turn boundary. The loop stays emotion-agnostic: it reports each settled
// turn and carries the returned mood label onto Result.Mood, which the
// persistence layer writes onto the assistant message (design/16 §3.3).
//
// Scope of this milestone (A1) — what is wired end to end:
// - every settled owner turn advances the bound persona's emotion machine and
// affection (in-memory, persona-keyed by the registry's emotion Manager);
// - the resulting mood rides the assistant message into chat_messages and the
// `message_persisted` frame, and each *change* is broadcast as a `mood` frame.
//
// Not wired (design/01 does not specify it): the mood is not yet injected back
// into the system prompt, so it does not steer the reply text, and the emotion
// state is not persisted across restarts (design/01 requires neither; the state
// resets to the persona's configured initialState on boot).
// moodEmitter is the gateway-facing sink for `mood` broadcasts. *gateway.Server
// implements it (BroadcastMood); held as an interface so the composition root
// stays unit-testable with a fake sink, mirroring capacityEmitter.
type moodEmitter interface {
BroadcastMood(personaID, mood string, affection int)
}
// buildEmotionObserver assembles the persona emotion observer: the registry's
// emotion Manager supplies each persona's machine and affection, the registry
// supplies the persona's configured affection step (好感度曲线), and every change
// is pushed to the Web UI through publishMood.
func (a *app) buildEmotionObserver() *emotion.Observer {
return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood)
}
// onTurnEnd is the loop's turn-boundary hook (design/01 §2): a settled turn is
// applied to the persona the loop is bound to, and the mood after the turn is
// returned for the assistant message. An absent persona id falls back to the
// default persona, matching how the loop is bound.
//
// Two gates keep this to real conversation turns:
// - only the user-facing conversation lane (the Web main session and its
// sub-sessions; IM owner-private chat rides the main session, design/16 §4.4)
// moves the mood. Autonomous and delegated loop calls — background thinking
// (sess-backthink), sub-agent work, isolated IM sessions — are not turns of
// the owner's conversation, so they must not rewrite her mood;
// - a restricted (group/stranger) conversation is read-only: the observer
// refuses the write, so a third party can never move her mood or affection
// (design/02 §3, design/11 §2).
func (a *app) onTurnEnd(end loop.TurnEnd) string {
if a.emotion == nil || !isConversationLane(end.SessionID) {
return ""
}
personaID := end.PersonaID
if personaID == "" {
personaID = a.defaultPersonaID()
}
return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted()))
}
// isConversationLane reports whether a session belongs to the owner's live
// conversation: the fixed main session "web" and its "web:<uuid>" sub-sessions
// (design/16 §2.1 — IM owner-private chat is routed into the main session).
func isConversationLane(sessionID string) bool {
return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":")
}
// publishMood broadcasts one emotion change as a `mood` frame (design/07 §3,
// design/01 §2) so the Web 心情 badge follows the persona without a refresh. It is
// called only when the mood actually changed, so the frame stays news rather than
// per-turn noise. The sink is read at call time because the gateway is built after
// the loop; before it exists the change is logged and dropped.
func (a *app) publishMood(update emotion.Update) {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.moodSink == nil {
log.Debug("mood change dropped: gateway not ready",
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
"affection", update.Affection, "signal", update.Signal)
return
}
log.Info("persona mood changed",
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
"affection", update.Affection, "signal", update.Signal)
a.moodSink.BroadcastMood(update.PersonaID, string(update.After), update.Affection)
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fakeMoodEmitter captures the `mood` broadcasts the composition root forwards,
// so tests can assert an emotion change actually reaches the Web UI.
type fakeMoodEmitter struct {
calls int
personaID string
mood string
affection int
}
func (f *fakeMoodEmitter) BroadcastMood(personaID, mood string, affection int) {
f.calls++
f.personaID = personaID
f.mood = mood
f.affection = affection
}
// moodAdapter is a model.Adapter returning fixed text, so a test can drive a real
// loop turn without a provider.
type moodAdapter struct{ reply string }
func (m moodAdapter) Name() string { return "mood-test" }
func (m moodAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: m.reply}, nil
}
func (m moodAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return m.Chat(ctx, req, model.Options{})
}
// testEmotionApp builds an app with one registered persona (emotion calm, decay
// enabled, affection 50/700 step 10) and the emotion observer wired exactly as
// the composition root wires it, plus a capturing mood sink.
func testEmotionApp(t *testing.T) (*app, *fakeMoodEmitter) {
t.Helper()
reg := personareg.New()
persona := &loader.Persona{
ID: "cyrene",
Name: "昔涟",
Emotion: loader.EmotionParams{
InitialState: emotion.Calm,
DecayRate: 0.5,
},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
}
if err := reg.RegisterPersona(persona); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
sink := &fakeMoodEmitter{}
a := &app{log: testLog(), personas: reg, moodSink: sink}
a.emotion = a.buildEmotionObserver()
return a, sink
}
// testMoodLoop builds a real loop whose turn boundary is the app's hook.
func testMoodLoop(t *testing.T, a *app, reply string) *loop.Loop {
t.Helper()
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(moodAdapter{reply: reply}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
return loop.New(loop.Options{Agent: ag, PersonaID: "cyrene", OnTurnEnd: a.onTurnEnd})
}
// TestOnTurnEndDrivesEmotionThroughLoop is the A1 end-to-end lock: a real loop
// turn is applied to the bound persona's emotion machine, the mood lands on
// Result.Mood (which the persistence layer writes onto the assistant message),
// affection steps by the persona's configured magnitude, and the change is
// broadcast once as a `mood` frame.
func TestOnTurnEndDrivesEmotionThroughLoop(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "人家也开心♪")
res, err := l.Run(loop.WithSessionEnv(context.Background(), loop.OwnerEnv()), "web",
[]model.Message{model.TextMessage(model.RoleUser, "今天谢谢你呀,好开心")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("Result.Mood = %q, want happy", res.Mood)
}
machine, affection, ok := a.personas.Emotions().Get("cyrene")
if !ok {
t.Fatal("persona cyrene not registered in the emotion manager")
}
if machine.Current() != emotion.Happy {
t.Fatalf("machine state = %s, want happy", machine.Current())
}
if affection.Value() != 60 {
t.Fatalf("affection = %d, want 60 (50 + the persona's step 10)", affection.Value())
}
if sink.calls != 1 || sink.mood != string(emotion.Happy) || sink.personaID != "cyrene" || sink.affection != 60 {
t.Fatalf("mood broadcast = %+v, want one happy/cyrene/60 frame", sink)
}
}
// TestOnTurnEndEvolvesAndDecaysAcrossTurns locks how the emotion walks over a
// conversation: each emotional turn moves one legal edge toward the state the cue
// aims at (so a strong warm cue reaches excited on the second turn, not the
// first), a cue-free turn lets it decay one step toward calm (design/01 §2
// decayRate), and each real move is broadcast once while an unchanged mood
// broadcasts nothing.
func TestOnTurnEndEvolvesAndDecaysAcrossTurns(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "嗯")
ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv())
res, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "我太开心了,最喜欢你了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("first strong warm turn mood = %q, want the first legal step happy", res.Mood)
}
// A second strong warm turn escalates to excited.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "最喜欢你了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Excited) {
t.Fatalf("second strong warm turn mood = %q, want excited", res.Mood)
}
if sink.calls != 2 || sink.mood != string(emotion.Excited) {
t.Fatalf("broadcasts = %d/%s, want 2 with excited", sink.calls, sink.mood)
}
// Same mood again: no change, so no third frame.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "还是最喜欢你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Excited) {
t.Fatalf("third warm turn mood = %q, want excited unchanged", res.Mood)
}
if sink.calls != 2 {
t.Fatalf("broadcasts = %d, want 2 (unchanged mood is not news)", sink.calls)
}
// A cue-free turn carries no signal, so the emotion decays excited → happy.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "帮我看下这个报错")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("neutral turn mood = %q, want decayed happy", res.Mood)
}
if sink.calls != 3 || sink.mood != string(emotion.Happy) {
t.Fatalf("broadcasts = %d/%s, want 3 with happy", sink.calls, sink.mood)
}
}
// TestOnTurnEndRestrictedConversationIsReadOnly locks the isolation boundary at
// the wiring level: a group/stranger turn never moves the persona's mood or
// affection and never broadcasts, matching the read-only memory lane (design/02
// §3, design/11 §2).
func TestOnTurnEndRestrictedConversationIsReadOnly(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "你好呀")
restricted := loop.SessionEnv{MemWritable: false}
ctx := loop.WithSessionEnv(context.Background(), restricted)
res, err := l.Run(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "我好喜欢你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != "" {
t.Fatalf("Result.Mood = %q, want empty for a read-only conversation", res.Mood)
}
machine, affection, _ := a.personas.Emotions().Get("cyrene")
if machine.Current() != emotion.Calm || affection.Value() != 50 {
t.Fatalf("restricted turn moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if sink.calls != 0 {
t.Fatalf("broadcasts = %d, want 0 for a restricted conversation", sink.calls)
}
}
// TestOnTurnEndFallsBackToDefaultPersona locks the persona binding: a loop bound
// without an explicit persona id advances the library's default persona instead
// of silently tracking nothing.
func TestOnTurnEndFallsBackToDefaultPersona(t *testing.T) {
a, sink := testEmotionApp(t)
if got := a.onTurnEnd(loop.TurnEnd{SessionID: "web", UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
t.Fatalf("mood = %q, want happy on the default persona", got)
}
if sink.personaID != "cyrene" {
t.Fatalf("broadcast persona = %q, want cyrene", sink.personaID)
}
}
// TestOnTurnEndSkipsNonConversationLanes locks the lane gate: only the owner's
// conversation (main session and its sub-sessions) moves the persona's mood.
// Autonomous / delegated loop calls — background thinking, sub-agent work,
// isolated IM sessions — are not conversation turns and must leave her state
// alone.
func TestOnTurnEndSkipsNonConversationLanes(t *testing.T) {
a, sink := testEmotionApp(t)
for _, sessionID := range []string{"sess-backthink", "sess-group-abc", "task-42", ""} {
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "我好喜欢你", Env: loop.OwnerEnv()}); got != "" {
t.Fatalf("mood for session %q = %q, want empty", sessionID, got)
}
}
machine, affection, _ := a.personas.Emotions().Get("cyrene")
if machine.Current() != emotion.Calm || affection.Value() != 50 {
t.Fatalf("non-conversation lane moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if sink.calls != 0 {
t.Fatalf("broadcasts = %d, want 0", sink.calls)
}
// The main session and its sub-sessions are the conversation lane.
for _, sessionID := range []string{history.MainSessionID, history.MainSessionID + ":abc"} {
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
t.Fatalf("mood for session %q = %q, want happy", sessionID, got)
}
}
}
// TestOnTurnEndNilDependenciesNoPanic locks the degraded boots: no observer and
// no gateway sink must not panic (the gateway is built after the loop).
func TestOnTurnEndNilDependenciesNoPanic(t *testing.T) {
empty := &app{log: testLog()}
if got := empty.onTurnEnd(loop.TurnEnd{UserText: "我爱你", Env: loop.OwnerEnv()}); got != "" {
t.Fatalf("mood without an observer = %q, want empty", got)
}
empty.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
a, _ := testEmotionApp(t)
a.moodSink = nil
a.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
}
+16
View File
@@ -23,6 +23,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" 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/platform"
@@ -108,6 +109,14 @@ type app struct {
// §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an // §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an
// interface so the composition root is unit-testable with a fake sink. // interface so the composition root is unit-testable with a fake sink.
capacitySink capacityEmitter capacitySink capacityEmitter
// emotion applies each settled turn to the bound persona's emotion machine and
// affection (design/01 §2) and hands the changes to moodSink. Nil when no
// persona was registered, in which case no mood is tracked or persisted.
emotion *emotion.Observer
// moodSink broadcasts a mood change to the Web UI (design/07 §3). It is
// satisfied by *gateway.Server (BroadcastMood); held as an interface so the
// composition root is unit-testable with a fake sink.
moodSink moodEmitter
} }
// capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts. // capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts.
@@ -389,6 +398,11 @@ func (a *app) wireRuntime() error {
) )
capDecider := a.buildCapacity() capDecider := a.buildCapacity()
// The persona emotion observer (design/01 §2) is built here because it is the
// loop's turn-boundary dependency: after every settled turn the bound persona's
// emotion machine and affection move, and the returned mood label is written
// onto Result.Mood and the assistant message (design/16 §3.3).
a.emotion = a.buildEmotionObserver()
a.loop = loop.New(loop.Options{ a.loop = loop.New(loop.Options{
Agent: ag, Agent: ag,
@@ -396,6 +410,7 @@ func (a *app) wireRuntime() error {
Capacity: capDecider, Capacity: capDecider,
OnCapacity: a.onCapacity, OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit, OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store, Memory: store,
Retriever: a.retriever, Retriever: a.retriever,
Extractor: memory.NewManualExtractor(personaID, "owner"), Extractor: memory.NewManualExtractor(personaID, "owner"),
@@ -736,6 +751,7 @@ func (a *app) wireFrontend() error {
}) })
} }
a.capacitySink = gw a.capacitySink = gw
a.moodSink = gw
// Wire the backthink proactive sinks now that the gateway hub (active-client // Wire the backthink proactive sinks now that the gateway hub (active-client
// route) and the channel bridge (IM fallback) both exist. They were absent when // route) and the channel bridge (IM fallback) both exist. They were absent when
+98
View File
@@ -0,0 +1,98 @@
package emotion
import "sync"
// Update reports the effect of one observed turn on a persona's emotion and
// affection. It is handed to the Observer's emit sink so a caller can surface
// the change (the `mood` WS event) and log it.
type Update struct {
// PersonaID is the persona the turn was applied to.
PersonaID string
// Before is the emotion state before the turn.
Before EmotionState
// After is the emotion state after the turn.
After EmotionState
// Affection is the affection value after the turn.
Affection int
// Signal reports whether the turn carried an emotional cue.
Signal bool
}
// Moved reports whether the emotion state actually changed this turn. It is the
// gate for broadcasting: a turn that leaves the mood where it was is not news.
func (u Update) Moved() bool { return u.Before != u.After }
// StepFunc returns the affection magnitude configured for a persona (its 好感度
// 曲线 delta, design/01 §2). Returning 0 leaves affection untouched.
type StepFunc func(personaID string) int
// Observer applies settled conversation turns to the per-persona emotion machine
// and affection. It owns no state of its own beyond the write lock: the machines
// and affection trackers live in the Manager, which is what keeps emotion keyed
// by persona (design/01 §2: emotion/affection follow the persona/character, never
// a behavior mode). All methods are safe for concurrent use.
type Observer struct {
mu sync.Mutex
mgr *Manager
step StepFunc
emit func(Update)
}
// NewObserver builds an Observer over mgr. step resolves the per-persona
// affection magnitude (a nil step means affection never moves); emit, when
// non-nil, is called once per mood change so a caller can broadcast it.
func NewObserver(mgr *Manager, step StepFunc, emit func(Update)) *Observer {
if step == nil {
step = func(string) int { return 0 }
}
return &Observer{mgr: mgr, step: step, emit: emit}
}
// Observe applies one settled turn's user text to personaID's emotion machine
// and affection, then returns the persona's mood after the turn ("" when the
// persona is unknown or the conversation is not writable).
//
// writable is the conversation's write gate: a restricted (group/stranger)
// conversation is read-only for emotion exactly as it is for memory, so a third
// party can never move the persona's mood or affection (design/02 §3, design/11
// §2 — memory/emotion isolation; identity stays structural, never prompt-based).
//
// A turn with an emotional cue moves the machine one legal edge toward the state
// the cue aims at (Machine.Move walks the persona's own transition table, and the
// step size is one edge per turn — the ladder is the machine's, not a new
// intensity scale) and steps the affection curve; a turn with no cue instead lets
// the emotion decay one step toward calm (Machine.Decay, gated by the persona's
// decayRate). One Run = one turn = one application, so nothing is applied twice.
func (o *Observer) Observe(personaID, userText string, writable bool) EmotionState {
if !writable || personaID == "" {
return ""
}
machine, affection, ok := o.mgr.Get(personaID)
if !ok {
return ""
}
o.mu.Lock()
defer o.mu.Unlock()
before := machine.Current()
reaction := React(userText)
if reaction.Signal {
machine.Move(reaction.State)
if affection != nil && reaction.AffectionDelta != 0 {
affection.Adjust(reaction.AffectionDelta * o.step(personaID))
}
} else {
machine.Decay()
}
after := machine.Current()
value := 0
if affection != nil {
value = affection.Value()
}
update := Update{PersonaID: personaID, Before: before, After: after, Affection: value, Signal: reaction.Signal}
if o.emit != nil && update.Moved() {
o.emit(update)
}
return after
}
+165
View File
@@ -0,0 +1,165 @@
package emotion
import "testing"
// newTestObserver builds an observer over one persona whose affection step is 10,
// capturing every emitted update.
func newTestObserver(t *testing.T, id string, decay float64, initial EmotionState) (*Observer, *Machine, *Affection, *[]Update) {
t.Helper()
machine, err := NewMachine(Params{InitialState: initial, DecayRate: decay})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
affection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
mgr := NewManager()
mgr.Set(id, machine, affection)
emitted := &[]Update{}
obs := NewObserver(mgr, func(string) int { return 10 }, func(u Update) {
*emitted = append(*emitted, u)
})
return obs, machine, affection, emitted
}
// TestObserveMovesEmotionAndAffection locks the turn rule: a warm turn raises the
// mood toward happy/excited and steps affection up by the persona's configured
// magnitude, a cold turn lowers both, and the change is reported for broadcast.
func TestObserveMovesEmotionAndAffection(t *testing.T) {
t.Parallel()
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("cyrene", "今天谢谢你呀", true); got != Happy {
t.Fatalf("warm turn mood = %s, want happy", got)
}
if affection.Value() != 60 {
t.Fatalf("affection after warm turn = %d, want 60 (50+10)", affection.Value())
}
if got := obs.Observe("cyrene", "我今天好累,有点难过", true); got != Sad {
t.Fatalf("down turn mood = %s, want sad", got)
}
if affection.Value() != 50 {
t.Fatalf("affection after down turn = %d, want 50 (60-10)", affection.Value())
}
// A cue-free turn carries no signal: the emotion decays toward calm instead of
// jumping, so sad → calm.
if got := obs.Observe("cyrene", "帮我看下这个报错", true); got != Calm {
t.Fatalf("neutral turn mood = %s, want decayed calm", got)
}
if len(*emitted) != 3 {
t.Fatalf("emitted = %d, want 3 (one per real mood change)", len(*emitted))
}
if machine.Current() != Calm {
t.Fatalf("machine current = %s, want calm", machine.Current())
}
}
// TestObserveEmitsOnlyOnChange locks the noise guard: a turn that leaves the mood
// where it already was emits nothing, while a turn that moves it emits once.
func TestObserveEmitsOnlyOnChange(t *testing.T) {
t.Parallel()
obs, _, _, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
// calm + neutral → calm: no change, no event.
if got := obs.Observe("cyrene", "把这段代码格式化一下", true); got != Calm {
t.Fatalf("neutral turn mood = %s, want calm", got)
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0 for an unchanged mood", len(*emitted))
}
// calm + warm → happy: one event.
obs.Observe("cyrene", "谢谢你", true)
// happy + warm → happy: unchanged, no second event.
obs.Observe("cyrene", "辛苦啦", true)
if len(*emitted) != 1 {
t.Fatalf("emitted = %d, want 1 (only the real change)", len(*emitted))
}
}
// TestObserveDecayDisabledByRate locks the persona's decayRate knob: with decay
// off, a cue-free turn leaves the mood untouched.
func TestObserveDecayDisabledByRate(t *testing.T) {
t.Parallel()
obs, machine, _, emitted := newTestObserver(t, "cyrene", 0, Happy)
if got := obs.Observe("cyrene", "帮我把日志改成 debug", true); got != Happy {
t.Fatalf("mood with decay disabled = %s, want happy unchanged", got)
}
if machine.Current() != Happy {
t.Fatalf("machine current = %s, want happy", machine.Current())
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0", len(*emitted))
}
}
// TestObserveRestrictedConversationIsReadOnly locks the isolation boundary: a
// group-chat/stranger turn (writable=false) never moves the persona's mood or
// affection, mirroring the read-only memory lane (design/02 §3, design/11 §2).
func TestObserveRestrictedConversationIsReadOnly(t *testing.T) {
t.Parallel()
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("cyrene", "我很喜欢你", false); got != "" {
t.Fatalf("restricted turn mood = %q, want empty", got)
}
if machine.Current() != Calm || affection.Value() != 50 {
t.Fatalf("restricted turn changed state to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0 for a restricted turn", len(*emitted))
}
}
// TestObserveUnknownPersonaIsNoop locks the persona-scoping guard: an unregistered
// persona id yields no mood and no panic.
func TestObserveUnknownPersonaIsNoop(t *testing.T) {
t.Parallel()
obs, machine, _, _ := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("someone-else", "我爱你", true); got != "" {
t.Fatalf("unknown persona mood = %q, want empty", got)
}
if machine.Current() != Calm {
t.Fatalf("unknown persona id moved the registered machine to %s", machine.Current())
}
if got := obs.Observe("", "我爱你", true); got != "" {
t.Fatalf("empty persona mood = %q, want empty", got)
}
}
// TestObserveKeepsPersonasSeparate locks the per-persona isolation the whole
// mechanism rests on (design/01 §2: emotion follows the persona): a turn applied
// to one persona leaves every other persona untouched.
func TestObserveKeepsPersonasSeparate(t *testing.T) {
t.Parallel()
aMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
aAffection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
bMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
bAffection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
mgr := NewManager()
mgr.Set("a", aMachine, aAffection)
mgr.Set("b", bMachine, bAffection)
obs := NewObserver(mgr, func(string) int { return 10 }, nil)
if got := obs.Observe("a", "我爱你", true); got != Happy {
t.Fatalf("persona a mood = %s, want happy", got)
}
if bMachine.Current() != Calm || bAffection.Value() != 50 {
t.Fatalf("persona b = %s/%d, want calm/50 untouched", bMachine.Current(), bAffection.Value())
}
}
+104
View File
@@ -0,0 +1,104 @@
package emotion
import "strings"
// Reaction is one turn's emotional reading of the user's message (design/01 §2:
// the persona config carries the emotion state-machine parameters and the
// affection curve; the machine itself only knows states and legal moves).
//
// design/01 fixes the model (states + transitions + decay + affection curve) but
// does not enumerate the trigger vocabulary, so the minimal reading implemented
// here is: a clearly angry turn, a clearly down turn, a strongly positive turn
// and a mildly positive turn, each moving the matching state and stepping the
// affection curve once. A turn with no cue carries no signal, which leaves the
// emotion to decay toward calm instead of jumping (see Observer.Observe).
type Reaction struct {
// State is the emotion state this turn moves the machine toward. It is a
// target, not a forced value: the machine walks its configured transition
// table toward it (Machine.Move), so a persona's own table always wins.
State EmotionState
// AffectionDelta is the signed number of affection *steps* for this turn
// (+1 warm, -1 cold, 0 neutral). The concrete magnitude is the persona's
// configured affection curve step, applied by the caller.
AffectionDelta int
// Signal reports whether the turn carried any emotional cue at all.
Signal bool
}
// React classifies one user turn into the persona's emotional response. Anger
// and negative cues win over positive ones, so a mixed message ("不开心" also
// contains "开心") never reads as positive.
func React(text string) Reaction {
t := strings.ToLower(text)
switch {
case t == "":
return Reaction{}
case containsCue(t, angerCues()):
return Reaction{State: Angry, AffectionDelta: affectionDown, Signal: true}
case containsCue(t, negativeCues()):
return Reaction{State: Sad, AffectionDelta: affectionDown, Signal: true}
case containsCue(t, strongPositiveCues()):
return Reaction{State: Excited, AffectionDelta: affectionUp, Signal: true}
case containsCue(t, positiveCues()):
return Reaction{State: Happy, AffectionDelta: affectionUp, Signal: true}
default:
return Reaction{}
}
}
// Affection step signs. The magnitude comes from the persona's own curve.
const (
affectionUp = 1
affectionDown = -1
)
// containsCue reports whether text contains any of the cues.
func containsCue(text string, cues []string) bool {
for _, c := range cues {
if strings.Contains(text, c) {
return true
}
}
return false
}
// angerCues are the protest/hostility cues that move the persona to angry. They
// are checked first so an angry turn is never read as merely sad or happy.
func angerCues() []string {
return []string{
"讨厌你", "烦你", "恨你", "别烦", "好烦", "烦死", "烦人", "生气", "气死", "气愤",
"滚开", "走开", "闭嘴", "不理你", "分手", "凶我", "骂你",
"angry", "annoying", "annoyed", "shut up", "hate you", "mad at",
}
}
// negativeCues are the down/low cues that move the persona to sad — including
// tiredness, which in the five built-in states reads as the gentle/caring lane.
func negativeCues() []string {
return []string{
"不开心", "很难过", "难过", "伤心", "委屈", "失望", "沮丧", "郁闷", "崩溃", "心累",
"累了", "好累", "疲惫", "困了", "想哭", "呜呜", "555", "糟糕", "焦虑", "孤独",
"孤单", "寂寞", "失眠", "睡不着", "被冷落",
"sad", "tired", "upset", "exhausted", "lonely", "depressed", "feel bad",
}
}
// strongPositiveCues are the high-arousal warm cues that move the persona to
// excited (a stronger state than happy).
func strongPositiveCues() []string {
return []string{
"太开心", "好开心", "最喜欢", "太喜欢", "爱死", "太棒", "好耶", "好激动",
"兴奋", "幸福死了", "amazing", "awesome", "so happy", "love you",
}
}
// positiveCues are the everyday warm cues that move the persona to happy.
func positiveCues() []string {
return []string{
"开心", "高兴", "喜欢", "爱你", "想你", "抱抱", "么么", "亲亲", "摸摸", "谢谢",
"辛苦", "哈哈", "嘿嘿", "嘻嘻", "真好", "厉害", "好棒", "期待", "满足", "幸福",
"好呀", "好哒", "早安", "早上好", "中午好", "下午好", "晚上好", "晚安",
"nice", "great", "thanks", "thank you", "haha", "hug", "miss you",
"good morning", "good night", "good evening",
}
}
+105
View File
@@ -0,0 +1,105 @@
package emotion
import "testing"
// TestReactClassifiesTurns locks the turn reading the observer applies: anger and
// negative cues outrank positive ones, strong warmth outranks mild warmth, and a
// cue-free turn carries no signal (so the machine is left to decay, not jump).
func TestReactClassifiesTurns(t *testing.T) {
t.Parallel()
cases := []struct {
name string
text string
state EmotionState
delta int
sig bool
}{
{name: "empty", text: "", sig: false},
{name: "plain task", text: "帮我把这个文件重命名一下", sig: false},
{name: "mild warm", text: "今天谢谢你呀", state: Happy, delta: affectionUp, sig: true},
{name: "greeting", text: "早上好", state: Happy, delta: affectionUp, sig: true},
{name: "strong warm", text: "我太开心了,最喜欢你了", state: Excited, delta: affectionUp, sig: true},
{name: "down", text: "今天有点难过", state: Sad, delta: affectionDown, sig: true},
{name: "tired", text: "我好累", state: Sad, delta: affectionDown, sig: true},
{name: "angry", text: "别烦我了", state: Angry, delta: affectionDown, sig: true},
// "不开心" contains "开心": the negative reading must win, otherwise a
// complaint would raise the mood.
{name: "negated positive", text: "我不开心", state: Sad, delta: affectionDown, sig: true},
{name: "ascii warm", text: "thanks, miss you", state: Happy, delta: affectionUp, sig: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := React(tc.text)
if got.Signal != tc.sig {
t.Fatalf("React(%q).Signal = %v, want %v", tc.text, got.Signal, tc.sig)
}
if !tc.sig {
return
}
if got.State != tc.state || got.AffectionDelta != tc.delta {
t.Fatalf("React(%q) = {%s, %d}, want {%s, %d}", tc.text, got.State, got.AffectionDelta, tc.state, tc.delta)
}
})
}
}
// TestMachineMoveWalksTransitions verifies Move honors the installed transition
// table instead of forcing a state: an installed edge is taken directly, and an
// emotion two edges away is reached through the legal intermediate (the default
// table has no calm→angry or calm→excited edge).
func TestMachineMoveWalksTransitions(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{InitialState: Calm})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
if got := m.Move(Happy); got != Happy {
t.Fatalf("calm → happy = %s, want direct happy", got)
}
m.Set(Calm)
if got := m.Move(Angry); got != Sad {
t.Fatalf("calm → angry = %s, want the legal intermediate sad", got)
}
if got := m.Move(Angry); got != Angry {
t.Fatalf("sad → angry = %s, want direct angry", got)
}
m.Set(Calm)
if got := m.Move(Excited); got != Happy {
t.Fatalf("calm → excited = %s, want the legal intermediate happy", got)
}
// A target with no legal path leaves the machine where it was.
p, err := NewMachine(Params{
InitialState: Calm,
Transitions: map[EmotionState][]EmotionState{Calm: {Happy}},
})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
if got := p.Move(Sad); got != Calm {
t.Fatalf("unreachable move = %s, want calm unchanged", got)
}
if got := m.Move(""); got != m.Current() {
t.Fatalf("empty target moved to %s, want unchanged", got)
}
}
// TestMachineMoveHonorsCustomTable verifies a persona's own transition table wins:
// a custom edge that skips the built-in intermediate is taken directly.
func TestMachineMoveHonorsCustomTable(t *testing.T) {
t.Parallel()
m, err := NewMachine(Params{
InitialState: Calm,
Transitions: map[EmotionState][]EmotionState{
Calm: {Excited},
Excited: {Calm},
},
})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
if got := m.Move(Excited); got != Excited {
t.Fatalf("custom calm → excited = %s, want direct excited", got)
}
}
+57
View File
@@ -155,6 +155,63 @@ func (m *Machine) ensure(from EmotionState) map[EmotionState]struct{} {
return m.transitions[from] return m.transitions[from]
} }
// Move aims the machine at target, walking the persona's installed transition
// table. A direct edge is taken when present; otherwise the first hop of a
// shortest legal path is taken, so a caller can aim at an emotion without
// knowing the intermediate states. A target that is unreachable from the current
// state, an empty target, or the current state itself leaves the machine
// unchanged rather than forcing an illegal move. The resulting state is
// returned.
func (m *Machine) Move(target EmotionState) EmotionState {
m.mu.Lock()
defer m.mu.Unlock()
if target == "" || target == m.current {
return m.current
}
if m.allowed(m.current, target) {
m.current = target
return m.current
}
if hop, ok := m.firstHopTo(target); ok {
m.current = hop
}
return m.current
}
// firstHopTo returns the first state on a shortest transition path from the
// current state to target, and whether such a path exists. Ties between equally
// short paths are broken arbitrarily, which is invisible to callers: any first
// hop on a shortest legal path moves the machine in the wanted direction.
func (m *Machine) firstHopTo(target EmotionState) (EmotionState, bool) {
type node struct {
state EmotionState
first EmotionState
}
queue := make([]node, 0, len(m.transitions[m.current]))
for next := range m.transitions[m.current] {
if next == target {
return next, true
}
queue = append(queue, node{state: next, first: next})
}
seen := map[EmotionState]struct{}{m.current: {}}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if _, ok := seen[cur.state]; ok {
continue
}
seen[cur.state] = struct{}{}
for next := range m.transitions[cur.state] {
if next == target {
return cur.first, true
}
queue = append(queue, node{state: next, first: cur.first})
}
}
return "", false
}
// Decay drifts the emotion one position toward calm whenever DecayRate is // Decay drifts the emotion one position toward calm whenever DecayRate is
// positive; otherwise it leaves the current state unchanged. The new state is // positive; otherwise it leaves the current state unchanged. The new state is
// returned. // returned.
+11
View File
@@ -132,3 +132,14 @@ func (p *Persona) NewAffection() (*emotion.Affection, error) {
} }
return emotion.NewAffection(params.Initial, params.Min, params.Max) return emotion.NewAffection(params.Initial, params.Min, params.Max)
} }
// AffectionStep returns the per-turn affection magnitude of this persona's
// 好感度曲线 (design/01 §2) — the configured `affection.delta`, or the loader
// default when the persona declares no curve. It is the magnitude applied once
// per emotional turn; the sign comes from the turn's signal.
func (p *Persona) AffectionStep() int {
if p.Affection.Delta != 0 {
return p.Affection.Delta
}
return defaultAffectionDelta
}
+10
View File
@@ -234,6 +234,16 @@ func (r *Registry) Emotions() *emotion.Manager {
return r.emotions return r.emotions
} }
// AffectionStep returns the per-turn affection magnitude configured for the
// persona under personaID (its 好感度曲线 delta, design/01 §2). It returns 0 for
// an unknown persona, so a caller skips the adjustment instead of inventing one.
func (r *Registry) AffectionStep(personaID string) int {
if p, ok := r.Persona(personaID); ok {
return p.AffectionStep()
}
return 0
}
// CountPersonas returns the number of registered personas. // CountPersonas returns the number of registered personas.
func (r *Registry) CountPersonas() int { func (r *Registry) CountPersonas() int {
r.mu.RLock() r.mu.RLock()
+32 -26
View File
@@ -73,6 +73,10 @@ type Result struct {
// ToolCallsExecuted is the number of tool calls the loop invoked, including // ToolCallsExecuted is the number of tool calls the loop invoked, including
// those that failed. // those that failed.
ToolCallsExecuted int ToolCallsExecuted int
// Mood is the persona's mood label for this turn, as returned by the
// OnTurnEnd hook ("" when no hook is wired). The persistence layer carries it
// onto the assistant message (design/16 §3.3).
Mood string
} }
// Loop is the default agent execution engine. It is configured once via New and // Loop is the default agent execution engine. It is configured once via New and
@@ -80,32 +84,33 @@ type Result struct {
// dispatcher, stores, recorder, tracer) are themselves concurrency-safe, which // dispatcher, stores, recorder, tracer) are themselves concurrency-safe, which
// they are by contract. Run keeps all loop state on the stack. // they are by contract. Run keeps all loop state on the stack.
type Loop struct { type Loop struct {
agent *agent.Agent agent *agent.Agent
tools *tool.Dispatcher tools *tool.Dispatcher
capacity *capacity.Decider capacity *capacity.Decider
onCapacity func(capacity.Event) onCapacity func(capacity.Event)
memory *memory.Store memory *memory.Store
retriever *memory.Retriever retriever *memory.Retriever
working *memory.WorkingMemory working *memory.WorkingMemory
extractor memory.Extractor extractor memory.Extractor
checkpoint checkpointStore checkpoint checkpointStore
tracer *trace.Tracer tracer *trace.Tracer
flow *FlowPublisher flow *FlowPublisher
guards *guardrail.Pipeline guards *guardrail.Pipeline
personaID string personaID string
timeLocation *time.Location timeLocation *time.Location
timeZoneName string timeZoneName string
maxRounds int maxRounds int
toolExec string toolExec string
toolParallelism int toolParallelism int
hooks ToolLoopHooks hooks ToolLoopHooks
onStream func(model.StreamEvent) onStream func(model.StreamEvent)
onAudit func(AuditEvent) onAudit func(AuditEvent)
maxContextTokens int onTurnEnd TurnEndHook
compactor Compactor maxContextTokens int
transformContext func(context.Context, []model.Message, int) []model.Message compactor Compactor
imageResolver ImageResolver transformContext func(context.Context, []model.Message, int) []model.Message
log *slog.Logger imageResolver ImageResolver
log *slog.Logger
} }
// New builds a Loop from opts, applying defaults. A non-positive MaxRounds // New builds a Loop from opts, applying defaults. A non-positive MaxRounds
@@ -153,6 +158,7 @@ func New(opts Options) *Loop {
hooks: opts.Hooks, hooks: opts.Hooks,
onStream: opts.OnStream, onStream: opts.OnStream,
onAudit: opts.OnAudit, onAudit: opts.OnAudit,
onTurnEnd: opts.OnTurnEnd,
maxContextTokens: opts.MaxContextTokens, maxContextTokens: opts.MaxContextTokens,
compactor: compactor, compactor: compactor,
transformContext: opts.TransformContext, transformContext: opts.TransformContext,
+7
View File
@@ -43,6 +43,13 @@ type Options struct {
// small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the // small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the
// model as a soft not_allowed result, just not recorded to an external sink. // model as a soft not_allowed result, just not recorded to an external sink.
OnAudit func(AuditEvent) OnAudit func(AuditEvent)
// OnTurnEnd, when non-nil, is called once per settled turn (a Run that
// returned a Result) with the turn's user/assistant text and the persona the
// loop is bound to, so a caller can advance state that follows the persona —
// the emotion machine and affection curve of design/01 §2. Its return value
// is recorded on Result.Mood and carried to the persisted assistant message.
// Nil = the loop reports nothing and Result.Mood stays empty.
OnTurnEnd TurnEndHook
// Memory is the long-term memory store used to persist extracted items. // Memory is the long-term memory store used to persist extracted items.
Memory *memory.Store Memory *memory.Store
// Retriever injects relevant memories into the context, re-evaluated every // Retriever injects relevant memories into the context, re-evaluated every
+7
View File
@@ -21,6 +21,10 @@ import (
// invocation), and after each step the memory Extractor runs with errors logged // invocation), and after each step the memory Extractor runs with errors logged
// rather than fatal. // rather than fatal.
// //
// When a turn settles with a Result, the OnTurnEnd hook is called once for that
// turn (never per step) and its mood label is recorded on Result.Mood, so the
// layers around the loop can advance persona state and tag the persisted reply.
//
// It returns the final answer, or a partial Result with StopReasonMaxRounds when // It returns the final answer, or a partial Result with StopReasonMaxRounds when
// the budget is exhausted without a final answer. Step errors are wrapped with %w // the budget is exhausted without a final answer. Step errors are wrapped with %w
// so callers can classify them with errors.Is. // so callers can classify them with errors.Is.
@@ -81,6 +85,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
res.StopReason = outcome.stopReason res.StopReason = outcome.stopReason
l.saveCheckpoint(ctx, sessionID, step, msgs, outcome.stopReason) l.saveCheckpoint(ctx, sessionID, step, msgs, outcome.stopReason)
l.extractMemory(ctx, msgs, step) l.extractMemory(ctx, msgs, step)
l.settleTurn(ctx, sessionID, msgs, res)
return res, nil return res, nil
} }
@@ -100,6 +105,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
res.StopReason = StopReasonToolLoopTerminated res.StopReason = StopReasonToolLoopTerminated
l.saveCheckpoint(ctx, sessionID, step, msgs, StopReasonToolLoopTerminated) l.saveCheckpoint(ctx, sessionID, step, msgs, StopReasonToolLoopTerminated)
l.extractMemory(ctx, msgs, step) l.extractMemory(ctx, msgs, step)
l.settleTurn(ctx, sessionID, msgs, res)
return res, nil return res, nil
} }
} }
@@ -109,5 +115,6 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
res.StopReason = StopReasonMaxRounds res.StopReason = StopReasonMaxRounds
l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds) l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds)
l.extractMemory(ctx, msgs, l.maxRounds) l.extractMemory(ctx, msgs, l.maxRounds)
l.settleTurn(ctx, sessionID, msgs, res)
return res, nil return res, nil
} }
+62
View File
@@ -0,0 +1,62 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file owns the loop's turn boundary: the point where one user turn has
// settled (a Run produced a Result) and the state that follows the persona —
// emotion and affection, design/01 §2 — is advanced outside the loop. The loop
// itself stays persona- and emotion-agnostic (design/16 §3.3: the engine
// generates text; state and persistence belong to the layers around it), so it
// only reports the turn and records the mood label the hook hands back.
// TurnEnd describes one settled turn to the loop's OnTurnEnd hook. It carries
// everything an observer needs without the loop knowing what the observer does.
type TurnEnd struct {
// SessionID is the conversation the turn belonged to.
SessionID string
// PersonaID is the persona the loop is bound to (Options.PersonaID), so the
// observer stays persona-scoped (design/01 §2).
PersonaID string
// UserText is the latest user message of the turn ("" when the turn carried
// no user text, e.g. a resumed tool round).
UserText string
// AssistantText is the turn's final assistant text.
AssistantText string
// StopReason classifies how the turn ended.
StopReason string
// Steps is the number of model steps the turn took.
Steps int
// Env is the conversation's resolved isolation environment (owner / group /
// stranger), so a hook can keep a restricted conversation read-only.
Env SessionEnv
}
// TurnEndHook observes a settled turn and returns the persona's mood label for
// it ("" when the caller tracks no emotion). It runs once per settled turn — not
// once per model step — so a multi-step tool turn applies its effect exactly
// once. It must not block: it runs on the Run path after the answer is complete,
// and its return value is carried on Result.Mood to the persistence layer.
type TurnEndHook func(TurnEnd) string
// settleTurn reports a settled turn to the hook (when wired) and records the
// returned mood label on the result, so the persisted assistant message carries
// the turn's mood (design/16 §3.3). The conversation environment travels on ctx,
// so a group/stranger turn reaches the hook as restricted.
func (l *Loop) settleTurn(ctx context.Context, sessionID string, msgs []model.Message, res *Result) {
if l.onTurnEnd == nil {
return
}
res.Mood = l.onTurnEnd(TurnEnd{
SessionID: sessionID,
PersonaID: l.personaID,
UserText: lastUserText(msgs),
AssistantText: res.Text,
StopReason: res.StopReason,
Steps: res.Steps,
Env: sessionEnvFrom(ctx),
})
}
+160
View File
@@ -0,0 +1,160 @@
package loop
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestRunCallsOnTurnEndOnce locks the turn boundary: a settled turn is reported
// exactly once — with the turn's own user text, the persona the loop is bound to
// and the answer — and the label the hook returns is recorded on Result.Mood so
// the persistence layer can tag the assistant message.
func TestRunCallsOnTurnEndOnce(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在的呀♪"}}}}
a := newTestAgent(t, ad, nil)
var ends []TurnEnd
l := New(Options{
Agent: a,
PersonaID: "cyrene",
OnTurnEnd: func(end TurnEnd) string {
ends = append(ends, end)
return "happy"
},
})
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "今天谢谢你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(ends) != 1 {
t.Fatalf("OnTurnEnd calls = %d, want 1", len(ends))
}
got := ends[0]
if got.SessionID != "web" || got.PersonaID != "cyrene" {
t.Fatalf("TurnEnd = %+v, want session web / persona cyrene", got)
}
if got.UserText != "今天谢谢你" {
t.Fatalf("TurnEnd.UserText = %q, want the user turn text", got.UserText)
}
if got.AssistantText != "在的呀♪" {
t.Fatalf("TurnEnd.AssistantText = %q, want the final answer", got.AssistantText)
}
if got.StopReason != agent.StopReasonStop || got.Steps != 1 {
t.Fatalf("TurnEnd stop/steps = %q/%d, want stop/1", got.StopReason, got.Steps)
}
if !got.Env.MemWritable {
t.Fatalf("TurnEnd.Env = %+v, want the owner lane by default", got.Env)
}
if res.Mood != "happy" {
t.Fatalf("Result.Mood = %q, want the hook's label", res.Mood)
}
}
// TestRunPassesRestrictedEnvToTurnEnd locks that the conversation's isolation
// environment reaches the hook, so a group/stranger turn can be kept read-only.
func TestRunPassesRestrictedEnvToTurnEnd(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, nil)
var got TurnEnd
l := New(Options{
Agent: a,
OnTurnEnd: func(end TurnEnd) string {
got = end
return ""
},
})
env := SessionEnv{ActorEnv: "group_member", MemWritable: false}
ctx := WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "sess-group", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil {
t.Fatalf("Run: %v", err)
}
if !got.Env.Restricted() || got.Env != env {
t.Fatalf("TurnEnd.Env = %+v, want the restricted env", got.Env)
}
}
// TestRunTurnEndOnceForMultiStepTurn locks "once per turn, not per step": a
// tool-loop turn (model → tool call → final answer) reports its boundary once,
// and the reported user text stays the turn's user message rather than a
// fed-back tool result.
func TestRunTurnEndOnceForMultiStepTurn(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "Echoed!"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
a := newTestAgent(t, ad, td)
var ends []TurnEnd
l := New(Options{
Agent: a,
PersonaID: "cyrene",
OnTurnEnd: func(end TurnEnd) string {
ends = append(ends, end)
return "calm"
},
})
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(ends) != 1 {
t.Fatalf("OnTurnEnd calls = %d, want 1 for a 2-step turn", len(ends))
}
if ends[0].UserText != "echo this" {
t.Fatalf("TurnEnd.UserText = %q, want the turn's user text", ends[0].UserText)
}
if ends[0].AssistantText != "Echoed!" || ends[0].Steps != 2 {
t.Fatalf("TurnEnd = %+v, want the final answer at 2 steps", ends[0])
}
if res.Mood != "calm" {
t.Fatalf("Result.Mood = %q, want calm", res.Mood)
}
}
// TestRunNoTurnEndOnError locks that a failed turn is not reported: nothing
// settled, so persona state must not advance.
func TestRunNoTurnEndOnError(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{err: errors.New("model down")}}}
a := newTestAgent(t, ad, nil)
calls := 0
l := New(Options{
Agent: a,
OnTurnEnd: func(TurnEnd) string { calls++; return "happy" },
})
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "我爱你")}); err == nil {
t.Fatal("Run: want an error")
}
if calls != 0 {
t.Fatalf("OnTurnEnd calls = %d, want 0 on a failed turn", calls)
}
}
// TestRunWithoutTurnEndHook locks the nil-tolerance: an unwired hook leaves the
// mood empty and Run behaves exactly as before.
func TestRunWithoutTurnEndHook(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "hi"}}}}
l := New(Options{Agent: newTestAgent(t, ad, nil)})
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != "" {
t.Fatalf("Result.Mood = %q, want empty without a hook", res.Mood)
}
}
@@ -0,0 +1,72 @@
package channelbridge
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the per-account allow/deny black-or-white lists (design/16 §4.6
// §6) enforced at the head of the inbound pipeline, plus the config readers the
// list matching depends on.
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
// against allowGroups/denyGroups; direct messages are matched by the sender's user
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
// deny list applies); a non-empty allow list becomes a white list. The owner is
// never blacklisted (the caller skips this for owner messages).
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
if a == nil {
return false, ""
}
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
if msg.IsGroup() {
if sliceContains(denyGroups, msg.GroupID) {
return true, "group_denied"
}
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
return true, "group_not_allowed"
}
return false, ""
}
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
if sliceContains(denyUsers, uid) {
return true, "user_denied"
}
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
return true, "user_not_allowed"
}
return false, ""
}
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
// The lists are stored as plain []string values (set by the multi-account wiring);
// an absent key yields an empty list.
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
if cfg == nil {
return nil, nil, nil, nil
}
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
}
// cfgStrings reads a []string field from an adapter config map, returning nil when
// absent or of a different type.
func cfgStrings(m map[string]any, key string) []string {
if m == nil {
return nil
}
switch v := m[key].(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
@@ -14,11 +14,9 @@ package channelbridge
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math/rand" "math/rand"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -257,560 +255,3 @@ func New(opts Options) (*Bridge, error) {
sem: make(chan struct{}, maxConcurrent), sem: make(chan struct{}, maxConcurrent),
}, nil }, nil
} }
// Add registers an adapter by its Name. It is safe to call before Start; adding
// after Start has no effect on already-running adapters.
func (b *Bridge) Add(a adapter.PlatformAdapter) {
if a == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.adapters[a.Name()] = a
}
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
// path from the adapter's config. The gateway mounts these to receive external
// webhook callbacks into the runtime. A nil map is returned when no adapter
// provides an inbound handler.
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]http.Handler)
for _, a := range b.adapters {
wh, ok := a.(webhookAdapter)
if !ok {
continue
}
path := wh.WebhookPath()
if path == "" {
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
continue
}
h := wh.InboundHandler()
if h == nil {
continue
}
out[path] = h
}
if len(out) == 0 {
return nil
}
return out
}
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
}
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
// message processing to finish. It is idempotent.
func (b *Bridge) Stop(ctx context.Context) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.closed = true
cancel := b.cancel
b.mu.Unlock()
cancel()
for name, a := range b.snapshot() {
if err := a.Disconnect(ctx); err != nil {
b.log.Warn("channel disconnect", "channel", name, "error", err)
}
}
b.wg.Wait()
b.log.Info("channel bridge stopped")
}
// IsConnected reports whether the named adapter is currently connected.
func (b *Bridge) IsConnected(name string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.connected[name]
}
// ConnectedCount reports how many adapters are currently connected.
func (b *Bridge) ConnectedCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
n := 0
for _, ok := range b.connected {
if ok {
n++
}
}
return n
}
// snapshot returns a copy of the registered adapters for a lock-free iteration.
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
for _, a := range b.adapters {
out = append(out, a)
}
return out
}
// configured reports whether the adapter carries enough configuration to attempt
// a Connect. It inspects the adapter's own Config() snapshot for the credential
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
// when no bot token is present.
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
cfg := a.Config()
switch basePlatform(a.Name()) {
case "telegram", "weixin-ilink", "kook":
return cfgStr(cfg, "bot_token") != ""
case "qq-obv11":
if cfgStr(cfg, "mode") == "client" {
return cfgStr(cfg, "remoteURL") != ""
}
return cfgStr(cfg, "listenAddr") != ""
case "webhook":
// Inbound-only: no external credential is required to hold the session.
return true
default:
return true
}
}
// basePlatform returns the bare platform name of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
// reply-routing fallback treat every account of a platform identically.
func basePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
// for the adapter that received a message, or "" when a is nil. The single-account
// convenience path (process) and a platform with no registered adapter can pass a nil
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
// dereferencing a nil adapter.
func adapterName(a adapter.PlatformAdapter) string {
if a == nil {
return ""
}
return a.Name()
}
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
// The adapter is carried explicitly so the pipeline knows which account received
// the message (multi-account reply routing + per-account allow/deny, design/16
// §4.6 §5).
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.mu.Unlock()
if err := msg.Validate(); err != nil {
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
return
}
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
// messages bypass it entirely (the owner is never throttled or pruned); every
// other message passes a sliding-window rate limit (策略三) and the bounded
// pending set whose saturation triggers the review model to prune noise (策略四).
// Dropped messages are audited through the gate's OnPruned hook (wired to
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() {
verdict := b.gateFor(a).Admit(ctx, msg)
if !verdict.Proceed {
b.log.Warn("inbound message dropped by flow gate",
"platform", msg.Platform, "channel", msg.ChannelID,
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
return
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// Add the worker under the same lock that Stop uses to set closed, so a
// concurrent Stop either sees our Add before it waits, or we observe closed
// and bail without leaking a goroutine.
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
}
// process runs the inbound pipeline using the adapter whose platform matches the
// message (single-account convenience path; the adapter may be a bare
// platform-named registration).
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom runs the full inbound pipeline against the named account adapter:
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
// agent loop with the chat text, and send the loop's reply back through the same
// adapter that received the message. Identity is resolved structurally by the
// session layer; the bridge never reads message content to decide who the sender
// is (a project red line).
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
text := strings.TrimSpace(msg.Content)
if text == "" {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
// the pipeline, before a session is resolved or the loop is driven, so a
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
// owner is always allowed (structural, via the resolved actor).
actor := session.ResolveActor(msg, b.owner)
if actor.IsOwner() {
// fall through: owner messages are never blacklisted
} else if denied, reason := b.blacklisted(msg, a); denied {
b.log.Warn("inbound message blacklisted, dropped",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
if b.onRejected != nil {
b.onRejected(msg, reason)
}
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
// non-owner group/stranger message that the cheap light judgement deems not
// worth a response is registered to its isolated session log and silenced — it
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "reason", reason)
return
}
}
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
// main web session (shared across platforms) and persisted to chat_messages with
// the channel platform, so the Web client mirrors it; group chats and strangers
// keep their isolated sess-xxx context and their own platform log.
sessID := sess.SessionID
isOwnerPrivate := !sess.IsGroup && sess.Owner
platform := ""
if isOwnerPrivate {
sessID = history.MainSessionID
platform = msg.Platform
}
// The third-party boundary only applies in a restricted (group/stranger)
// context: memory is read-only (MemWritable=false), and the sender name is
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
// resolved actor decides who is the owner vs a third party structurally — never
// from prompt text.
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// Persist the inbound user message before the turn so it is durable even if the
// channel reply fails. Owner-private messages land in the main web session and
// are broadcast to the Web client; restricted messages land in their isolated
// session (the adapter log page) and are broadcast as a `channel_persisted` event
// (never into the main/`web` session or the web chat).
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", sessID, "error", err)
return
}
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
return
}
// Persist the assistant reply (owner-private → main session + message_persisted
// broadcast; restricted → isolated log + channel_persisted broadcast) then
// deliver it back to the channel.
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", sessID)
return
}
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
}
}
// buildLoopMessages builds the model-visible message list for one inbound turn. It
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
// the model can see which platform and conversation the message came from (design/16
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
// a restricted (group/stranger) context it additionally prepends a single system
// message that states the third-party identity boundary — "every IM non-admin
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
// untouched (a copy is built with the prefix), so persisted content stays consistent
// with what the source platform delivered.
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
msg := model.TextMessage(model.RoleUser, text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
// The speaker is carried as structured metadata, NOT woven into the user text
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
msg.SenderName = senderName
}
// origin system line: conversation origin + identity-trust rule, one per turn.
origin := originSystemText(src, actor)
if !env.Restricted() {
if origin != "" {
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
}
return []model.Message{msg}
}
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
var sys string
switch {
case origin != "" && thirdPartyBoundaryPrompt != "":
sys = origin + "\n" + thirdPartyBoundaryPrompt
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
}
// sendReply delivers the loop's text back to the channel's source conversation
// through the account adapter that received the message (multi-account safe,
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
// the adapter resolves the same peer the message came from, and it exits through the
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
if a == nil {
a = b.adapter(in.Platform)
}
if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
}
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text)
}
// adapter returns the registered adapter for a platform name, or nil.
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
return b.adapters[platform]
}
// cfgStr reads a string field from an adapter config map, returning "" when
// absent.
func cfgStr(m map[string]any, key string) string {
if m == nil {
return ""
}
if s, ok := m[key].(string); ok {
return s
}
return ""
}
// deviceIDFor builds the client deviceID tag for an inbound IM message
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
// explicit deviceId field yet, so the local config id and the user platform uid are
// taken from the normalized channel binding: ChannelID stands in for the local
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
//
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
// bridge gains a local config id), replace the ChannelID stand-in here and return
// that value directly.
func deviceIDFor(msg adapter.UnifiedMessage) string {
if msg.SenderID != "" {
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
}
return msg.Platform + ":" + msg.ChannelID
}
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
// (group-chat / stranger) context so the model knows the identity boundary. It is
// only injected when the session is structurally restricted (design/16 §4.6 §1);
// the LLM never decides who is a third party — that was already resolved from the
// actor metadata before this message exists.
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
// senderDisplayName returns the human-readable sender name used to label a
// third-party message. It prefers the resolved actor's display name (which the
// channel layer derived from channel metadata + the raw SenderName), falling back
// to the message's own SenderName, so an empty SenderName always means "the
// owner/unlabeled" rather than a nickname that resolved to nothing.
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if actor.DisplayName != "" {
return actor.DisplayName
}
return msg.SenderName
}
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
// against allowGroups/denyGroups; direct messages are matched by the sender's user
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
// deny list applies); a non-empty allow list becomes a white list. The owner is
// never blacklisted (the caller skips this for owner messages).
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
if a == nil {
return false, ""
}
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
if msg.IsGroup() {
if sliceContains(denyGroups, msg.GroupID) {
return true, "group_denied"
}
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
return true, "group_not_allowed"
}
return false, ""
}
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
if sliceContains(denyUsers, uid) {
return true, "user_denied"
}
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
return true, "user_not_allowed"
}
return false, ""
}
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
// The lists are stored as plain []string values (set by the multi-account wiring);
// an absent key yields an empty list.
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
if cfg == nil {
return nil, nil, nil, nil
}
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
}
// cfgStrings reads a []string field from an adapter config map, returning nil when
// absent or of a different type.
func cfgStrings(m map[string]any, key string) []string {
if m == nil {
return nil
}
switch v := m[key].(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
// sliceContains reports whether s is present in list.
func sliceContains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// firstNonEmptyStr returns a when non-empty, otherwise b.
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
+173
View File
@@ -0,0 +1,173 @@
package channelbridge
import (
"context"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the adapter registry and the connect lifecycle of the channel
// bridge (design/08 §0): adapters are registered through Add, dialed by Start
// (each within connectTimeout), torn down by Stop, and skipped by the credential
// gate when they carry no usable configuration.
// Add registers an adapter by its Name. It is safe to call before Start; adding
// after Start has no effect on already-running adapters.
func (b *Bridge) Add(a adapter.PlatformAdapter) {
if a == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.adapters[a.Name()] = a
}
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
// path from the adapter's config. The gateway mounts these to receive external
// webhook callbacks into the runtime. A nil map is returned when no adapter
// provides an inbound handler.
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]http.Handler)
for _, a := range b.adapters {
wh, ok := a.(webhookAdapter)
if !ok {
continue
}
path := wh.WebhookPath()
if path == "" {
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
continue
}
h := wh.InboundHandler()
if h == nil {
continue
}
out[path] = h
}
if len(out) == 0 {
return nil
}
return out
}
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
}
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
// message processing to finish. It is idempotent.
func (b *Bridge) Stop(ctx context.Context) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.closed = true
cancel := b.cancel
b.mu.Unlock()
cancel()
for name, a := range b.snapshot() {
if err := a.Disconnect(ctx); err != nil {
b.log.Warn("channel disconnect", "channel", name, "error", err)
}
}
b.wg.Wait()
b.log.Info("channel bridge stopped")
}
// IsConnected reports whether the named adapter is currently connected.
func (b *Bridge) IsConnected(name string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.connected[name]
}
// ConnectedCount reports how many adapters are currently connected.
func (b *Bridge) ConnectedCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
n := 0
for _, ok := range b.connected {
if ok {
n++
}
}
return n
}
// snapshot returns a copy of the registered adapters for a lock-free iteration.
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
for _, a := range b.adapters {
out = append(out, a)
}
return out
}
// configured reports whether the adapter carries enough configuration to attempt
// a Connect. It inspects the adapter's own Config() snapshot for the credential
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
// when no bot token is present.
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
cfg := a.Config()
switch basePlatform(a.Name()) {
case "telegram", "weixin-ilink", "kook":
return cfgStr(cfg, "bot_token") != ""
case "qq-obv11":
if cfgStr(cfg, "mode") == "client" {
return cfgStr(cfg, "remoteURL") != ""
}
return cfgStr(cfg, "listenAddr") != ""
case "webhook":
// Inbound-only: no external credential is required to hold the session.
return true
default:
return true
}
}
// cfgStr reads a string field from an adapter config map, returning "" when
// absent.
func cfgStr(m map[string]any, key string) string {
if m == nil {
return ""
}
if s, ok := m[key].(string); ok {
return s
}
return ""
}
// adapter returns the registered adapter for a platform name, or nil.
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
return b.adapters[platform]
}
@@ -0,0 +1,62 @@
package channelbridge
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the small package-level helpers shared across the bridge files:
// platform/account naming, the sender label, and generic slice readers.
// basePlatform returns the bare platform name of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
// reply-routing fallback treat every account of a platform identically.
func basePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
// for the adapter that received a message, or "" when a is nil. The single-account
// convenience path (process) and a platform with no registered adapter can pass a nil
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
// dereferencing a nil adapter.
func adapterName(a adapter.PlatformAdapter) string {
if a == nil {
return ""
}
return a.Name()
}
// senderDisplayName returns the human-readable sender name used to label a
// third-party message. It prefers the resolved actor's display name (which the
// channel layer derived from channel metadata + the raw SenderName), falling back
// to the message's own SenderName, so an empty SenderName always means "the
// owner/unlabeled" rather than a nickname that resolved to nothing.
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if actor.DisplayName != "" {
return actor.DisplayName
}
return msg.SenderName
}
// sliceContains reports whether s is present in list.
func sliceContains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// firstNonEmptyStr returns a when non-empty, otherwise b.
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
+282
View File
@@ -0,0 +1,282 @@
package channelbridge
import (
"context"
"errors"
"strings"
"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/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file holds the inbound path of the channel bridge (design/08 §1-§2): the
// per-adapter read loop, the bounded inbound callback, and the full
// session→actor→loop→reply pipeline including the structural identity boundary.
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
// The adapter is carried explicitly so the pipeline knows which account received
// the message (multi-account reply routing + per-account allow/deny, design/16
// §4.6 §5).
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.mu.Unlock()
if err := msg.Validate(); err != nil {
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
return
}
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
// messages bypass it entirely (the owner is never throttled or pruned); every
// other message passes a sliding-window rate limit (策略三) and the bounded
// pending set whose saturation triggers the review model to prune noise (策略四).
// Dropped messages are audited through the gate's OnPruned hook (wired to
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() {
verdict := b.gateFor(a).Admit(ctx, msg)
if !verdict.Proceed {
b.log.Warn("inbound message dropped by flow gate",
"platform", msg.Platform, "channel", msg.ChannelID,
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
return
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// Add the worker under the same lock that Stop uses to set closed, so a
// concurrent Stop either sees our Add before it waits, or we observe closed
// and bail without leaking a goroutine.
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
}
// process runs the inbound pipeline using the adapter whose platform matches the
// message (single-account convenience path; the adapter may be a bare
// platform-named registration).
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom runs the full inbound pipeline against the named account adapter:
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
// agent loop with the chat text, and send the loop's reply back through the same
// adapter that received the message. Identity is resolved structurally by the
// session layer; the bridge never reads message content to decide who the sender
// is (a project red line).
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
text := strings.TrimSpace(msg.Content)
if text == "" {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
// the pipeline, before a session is resolved or the loop is driven, so a
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
// owner is always allowed (structural, via the resolved actor).
actor := session.ResolveActor(msg, b.owner)
if actor.IsOwner() {
// fall through: owner messages are never blacklisted
} else if denied, reason := b.blacklisted(msg, a); denied {
b.log.Warn("inbound message blacklisted, dropped",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
if b.onRejected != nil {
b.onRejected(msg, reason)
}
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
// non-owner group/stranger message that the cheap light judgement deems not
// worth a response is registered to its isolated session log and silenced — it
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "reason", reason)
return
}
}
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
// main web session (shared across platforms) and persisted to chat_messages with
// the channel platform, so the Web client mirrors it; group chats and strangers
// keep their isolated sess-xxx context and their own platform log.
sessID := sess.SessionID
isOwnerPrivate := !sess.IsGroup && sess.Owner
platform := ""
if isOwnerPrivate {
sessID = history.MainSessionID
platform = msg.Platform
}
// The third-party boundary only applies in a restricted (group/stranger)
// context: memory is read-only (MemWritable=false), and the sender name is
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
// resolved actor decides who is the owner vs a third party structurally — never
// from prompt text.
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// Persist the inbound user message before the turn so it is durable even if the
// channel reply fails. Owner-private messages land in the main web session and
// are broadcast to the Web client; restricted messages land in their isolated
// session (the adapter log page) and are broadcast as a `channel_persisted` event
// (never into the main/`web` session or the web chat).
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleUser, text, "", platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", sessID, "error", err)
return
}
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
return
}
// Persist the assistant reply (owner-private → main session + message_persisted
// broadcast; restricted → isolated log + channel_persisted broadcast) then
// deliver it back to the channel.
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", sessID)
return
}
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
}
}
// deviceIDFor builds the client deviceID tag for an inbound IM message
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
// explicit deviceId field yet, so the local config id and the user platform uid are
// taken from the normalized channel binding: ChannelID stands in for the local
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
//
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
// bridge gains a local config id), replace the ChannelID stand-in here and return
// that value directly.
func deviceIDFor(msg adapter.UnifiedMessage) string {
if msg.SenderID != "" {
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
}
return msg.Platform + ":" + msg.ChannelID
}
// buildLoopMessages builds the model-visible message list for one inbound turn. It
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
// the model can see which platform and conversation the message came from (design/16
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
// a restricted (group/stranger) context it additionally prepends a single system
// message that states the third-party identity boundary — "every IM non-admin
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
// untouched (a copy is built with the prefix), so persisted content stays consistent
// with what the source platform delivered.
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
msg := model.TextMessage(model.RoleUser, text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
// The speaker is carried as structured metadata, NOT woven into the user text
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
msg.SenderName = senderName
}
// origin system line: conversation origin + identity-trust rule, one per turn.
origin := originSystemText(src, actor)
if !env.Restricted() {
if origin != "" {
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
}
return []model.Message{msg}
}
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
var sys string
switch {
case origin != "" && thirdPartyBoundaryPrompt != "":
sys = origin + "\n" + thirdPartyBoundaryPrompt
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
}
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
// (group-chat / stranger) context so the model knows the identity boundary. It is
// only injected when the session is structurally restricted (design/16 §4.6 §1);
// the LLM never decides who is a third party — that was already resolved from the
// actor metadata before this message exists.
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
@@ -0,0 +1,29 @@
package channelbridge
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the outbound reply path (design/16 §4.6 §5): the loop's text is
// delivered back to the source conversation through the same account adapter that
// received the message.
// sendReply delivers the loop's text back to the channel's source conversation
// through the account adapter that received the message (multi-account safe,
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
// the adapter resolves the same peer the message came from, and it exits through the
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
if a == nil {
a = b.adapter(in.Platform)
}
if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
}
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text)
}
+7 -4
View File
@@ -57,14 +57,17 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid" // concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
// for IM. adapterID is the account adapter registration name that received the message // for IM. adapterID is the account adapter registration name that received the message
// (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream; // (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream;
// it is empty for Web-originated messages. It is a no-op when no history store is wired, // it is empty for Web-originated messages. mood is the persona's mood for that turn
// so the bridge runs unchanged without storage. // (design/01 §2 情绪状态机), carried on the assistant reply only (design/16 §3.3: the
func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID, adapterID string) { // assistant message carries mode/mood); a user message and an untracked mood pass "".
// It is a no-op when no history store is wired, so the bridge runs unchanged without
// storage.
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, platform, deviceID, adapterID string) {
if b.history == nil { if b.history == nil {
return return
} }
full, err := b.history.AppendFull(ctx, history.Message{ full, err := b.history.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat, SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
}) })
if err != nil { if err != nil {
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID) b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID)
+1 -1
View File
@@ -73,7 +73,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// §4.4 + §4.6.1). Without this the model would not see what it proactively said, so // §4.4 + §4.6.1). Without this the model would not see what it proactively said, so
// a follow-up owner reply would feel incoherent (the message "loses" its history). // a follow-up owner reply would feel incoherent (the message "loses" its history).
// It is the same mirror path as owner-private IM replies, scoped to MainSessionID. // It is the same mirror path as owner-private IM replies, scoped to MainSessionID.
b.persistToMain(ctx, history.RoleAssistant, msg.Content, target.platform, b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", target.platform,
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID), fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
target.platform) target.platform)
return nil return nil
+103
View File
@@ -0,0 +1,103 @@
package gateway
import (
"context"
"io"
"log/slog"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
// test can assert the mood the loop reported is what gets persisted.
type moodRunner struct {
mood string
}
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
}
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
// path: the mood the loop's turn-boundary hook reported lands on the persisted
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
// `message_persisted` and history replay carry real data instead of an always
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
// mood to the assistant reply).
func TestOrchestratorPersistsTurnMood(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: "happy"}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
if !sr.Accepted || res == nil {
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
}
wire := toHistoryMessage(recs[1])
if wire.Mood != "happy" {
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
}
if recs[0].Mood != "" {
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
}
}
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
// empty rather than being fabricated.
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: ""}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2", len(recs))
}
if recs[1].Mood != "" {
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
}
}
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
// affection ride along as extra detail.
func TestServerBroadcastMood(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
c := newFakeConn(env.srv.hub, 32)
env.srv.hub.register(c)
env.srv.BroadcastMood("cyrene", "happy", 60)
ev := readFakeFrame(t, c)
if ev.Type != "mood" {
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
}
if got, ok := data["affection"].(float64); !ok || got != 60 {
t.Fatalf("frame affection = %v, want 60", data["affection"])
}
}
+24
View File
@@ -681,6 +681,30 @@ func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool str
}) })
} }
// wsMoodEvent is the WS event type carrying the persona's mood after a turn
// (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing
// `mood` handler (ws.ts) reads data.mood and shows the 心情 badge.
const wsMoodEvent = "mood"
// BroadcastMood fans one emotion change to every Web client as a `mood` frame
// (design/07 §3). It is called only when the mood actually changed — the
// machinery in the composition root is the gate — so the badge does not flicker
// with an unchanged value on every turn. data matches what the frontend `mood`
// handler reads ({mood}); affection/personaId ride along as the honest extra
// detail (affection is the persona's current 好感度, personaId the persona whose
// mood moved) and are ignored by clients that do not render them. The mood is a
// persona-level value, not a per-session one, so no sessionId is attached.
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
s.hub.broadcast(wsEvent{
Type: wsMoodEvent,
Data: map[string]any{
"mood": mood,
"personaId": personaID,
"affection": affection,
},
})
}
// sourcedLog returns a logger tagged with source "gateway", substituting a // sourcedLog returns a logger tagged with source "gateway", substituting a
// discard logger for nil so Server can always log safely. // discard logger for nil so Server can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger { func sourcedLog(l *slog.Logger) *slog.Logger {
+6 -3
View File
@@ -78,14 +78,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags // history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
// the reply with the client that sent the turn (design/16 §4.5) and mode is the // the reply with the client that sent the turn (design/16 §4.5) and mode is the
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when // behavior mode of that turn (design/16 §5.1), falling back to modeChat when
// empty. It is a no-op when no history store is wired or the turn produced no text // empty. Mood is the persona's mood for that turn (design/01 §2 情绪状态机), taken
// (interrupted/errored turns leave res nil). The write runs under a fresh // off the loop result, so the stored/DTO/`message_persisted` mood is real data
// rather than a permanently empty column; a turn with no mood tracking stores an
// empty mood. It is a no-op when no history store is wired or the turn produced no
// text (interrupted/errored turns leave res nil). The write runs under a fresh
// background context so a canceled turn context never aborts it. // background context so a canceled turn context never aborts it.
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) { func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) {
if o.history == nil || res == nil || res.Text == "" { if o.history == nil || res == nil || res.Text == "" {
return return
} }
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, DeviceID: deviceID, Mode: modeOrDefault(mode)}) full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode)})
if err != nil { if err != nil {
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID) o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
return return