Files
AskaEth c907a34510 feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)
前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
  v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
2026-10-06 09:03:55 +08:00

351 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"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/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// 本文件声明网关所消费的依赖契约。每一个都是窄接口,
// 由组合根注入的具体后端满足(见 deps.go 中的
// Deps),因此网关绝不直接取用具体实现,
// 测试也能替换成桩。
// Authenticator 抽象凭据 + RBAC 服务。*auth.JWTService 是
// 生产实现;它是接口,因此测试可替换为 mock。
type Authenticator interface {
// Sign 为 sub 签发一个带给定角色与生命周期的 token。
Sign(sub string, role auth.Role, ttl time.Duration) (string, error)
// Verify 校验一个 token 并返回其 claims。
Verify(token string) (*auth.Claims, error)
// Authorize 返回一个 HTTP 中间件,强制至少一个所需角色。
Authorize(required ...auth.Role) func(http.Handler) http.Handler
}
// CapabilityStore 抽象管理 API 中用于枚举插件与渠道的全局
// 能力注册表。*capability.Registry 是生产
// 实现。
type CapabilityStore interface {
// List 返回 Kind 匹配的每一个能力,kind 为空时
// 返回全部。
List(kind capreg.Kind) []capreg.Capability
// SetPolicy 替换指定能力的可达性标记。
SetPolicy(name string, p capreg.ToolPolicy) error
}
// Chatter 运行单次 agent 回合。*loop.Loop 是生产实现。
type Chatter interface {
// Run 为 sessionID 驱动一个回合并返回最终答案。
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// SessionStore 列出已知的渠道会话。*session.SessionManager 是
// 生产实现。
type SessionStore interface {
List(ctx context.Context) ([]session.Session, error)
}
// PersonaStore 列出已注册角色(每个与一个人格一一绑定)。
// *persona/registry.Registry 是生产实现。网关把
// 每个 Character 映射为其线上摘要;该接口刻意返回
// 领域类型,使线上结构不进入存储契约。
type PersonaStore interface {
List() []personareg.Character
// Persona 返回给定 id 的人格(用于读取其视觉资产)。
// 生产注册表实现了它;线上摘要从它读取 Avatar /
// ChatBackground,使每个角色的视觉保持角色作用域。
Persona(id string) (*loader.Persona, bool)
}
// PersonaMoodStore 是人格存储的*可选*能力,该存储同时持有
// 每人格的情绪状态(design/01 §2:情绪跟随人格)。
// 生产实现 *personareg.Registry 实现了它;网关通过对这个窄
// 接口做断言来读取某角色的 mood,因此只列出人格的存储
// (测试替身、未来的只读目录)无需持有情绪机
// 就能继续满足 PersonaStore。当存储未实现它时,
// 某角色的 mood 只读作 "",而不是被凭空造出。
type PersonaMoodStore interface {
Emotions() *emotion.Manager
}
// MemoryStore 列出记忆条目。*runtime/memory.Store 是生产
// 实现。网关把每个 MemoryItem 映射为其线上摘要。
type MemoryStore interface {
List(ctx context.Context) ([]memory.MemoryItem, error)
}
// HistoryStore 是持久化聊天历史存储(design/16)。*runtime/history.Store
// 是生产实现。为 nil 时会话/消息端点退化:列出
// 会话回退到渠道会话视图,而创建/取消息/删除
// 端点返回明确的 501。
type HistoryStore interface {
Append(ctx context.Context, msg history.Message) error
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息
// (含分配的 id/时间戳),使编排器能广播它以进行
// 跨端实时同步(design/16 §4.2)。
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
// ListAfter 按最旧优先返回至多 limit 条严格新于 after 的消息,
// 用于重连后的前向增量追赶("before" 游标只能
// 向后走)。platform 的过滤同 List。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]history.Message, error)
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
DeleteSession(ctx context.Context, sessionID string) error
// Search 为 ?q= 消息搜索路径返回会话作用域的内容匹配
// (最新优先)。空查询得到空切片。
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error)
// UpdateMessage 编辑单条消息的内容并返回更新后的
// 消息。未知 id 返回 history.ErrMessageNotFound。
UpdateMessage(ctx context.Context, id, content string) (history.Message, error)
// DeleteMessage 移除单条消息。未知 id 返回
// history.ErrMessageNotFound。
DeleteMessage(ctx context.Context, id string) error
// RenameSession 设置会话的显示标题(main 或 sub)。未知 id
// 返回 history.ErrSessionNotFound。
RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned 置顶/取消置顶一个会话(main 或 sub)。未知 id 返回
// history.ErrSessionNotFound。
SetPinned(ctx context.Context, sessionID string, pinned bool) error
NewSubSession(ctx context.Context, ownerID string) (string, error)
// MarkSessionRead 把会话的读游标(D4)推进到 before 并返回
// 结果游标作为 Unix 毫秒时间戳;before 为零意味着
// 「现在」。游标只向前移动。未知 id 返回
// history.ErrSessionNotFound。
MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
}
// IoTStore 列出 IoT 设备。*iot.Manager 是生产实现。
type IoTStore interface {
List(ctx context.Context) ([]iot.Device, error)
}
// SubagentAuditor 为 subagent_ask WS 路由审计子代理的对话
// 上下文。*orchestration.Scheduler 是生产实现。
type SubagentAuditor interface {
AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error)
}
// TaskOutputStore 为 task_output_query WS 路由查询任务输出。
// *task.Manager 是生产实现。
type TaskOutputStore interface {
TaskOutput(ctx context.Context, id string) (task.OutputEvent, error)
}
// AutomationStore 为管理列表枚举自动化与提醒。
// *automations.Service 是生产实现。网关把每条
// 记录映射为其线上结构。
type AutomationStore interface {
List() []automations.AutomationRecord
}
// UserStore 枚举管理面板可见的账号与 API key。
// 生产实现读取已配置的管理账号加上
// auth.APIKey 表。网关把每个摘要映射为其线上结构。
type UserStore interface {
List() []auth.UserSummary
}
// RefreshGrantStore 管理非透明 refresh token(auth.RefreshStore 是
// 生产实现)。它是登录与 POST /auth/refresh、/auth/logout 端点
// 所返回 refreshToken 背后的凭据。只持久化哈希;
// 明文 token 每次签发/轮换恰好穿过该接口一次。
// 可选:为 nil 时登录省略 refreshToken,
// refresh 与 logout 端点返回明确的 not-configured 错误。
type RefreshGrantStore interface {
// Issue 为 userID/role 铸造一个 refresh token,返回其明文
// (仅可见一次)及其过期时间。
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
// Rotate 校验 plaintext,并在 opts.Rotate 设置时吊销它并
// 返回替代品。对未知、已吊销或已过期的 token 返回
// auth.ErrInvalidGrant 错误。
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
// Revoke 把匹配 plaintext 的 token 标记为已吊销,并返回是否
// 找到了存活的 token。它是幂等的。
Revoke(ctx context.Context, plaintext string) (bool, error)
// RevokeAll 删除每一个 refresh token(秘密轮换时的全局
// 失效)并返回移除了多少个。
RevokeAll(ctx context.Context) (int64, error)
// RevokeByUser 删除 userID 的每一个 refresh token(按访问
// 令牌登出,此时不知道具体是哪一份授权)并返回
// 移除了多少个。
RevokeByUser(ctx context.Context, userID string) (int64, error)
}
// BackthinkStore 为 M9/M10 监控面板枚举持久化的后台思考
// 记录。*backthink.Backthink 是生产实现。
type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// ChannelConfigStore 是运行时可编辑的渠道适配器配置
// (channels.json,design/04 §3.1)。它由组合根的渠道存储
// 实现:Load 返回按平台生效的账号(文件 → 环境变量
// 回退),Path 报告 channels.json 路径,Reload 在面板保存后
// 热替换某平台运行中的适配器(Bridge.Reload)。Load 返回
// 带真实 token 的账号配置,使 owner 写路径能持久化它们;
// 管理读路径在返回客户端前对它们脱敏。
type ChannelConfigStore interface {
Path() string
// Load 返回按平台生效的账号(文件 → 环境变量回退),
// 含持久化它们所需的秘密 token。
Load() map[string][]config.ChannelAccount
// Reload 重建并热替换该平台运行中的适配器。
Reload(platform string) error
}
// OwnerRegistrar 把一个渠道账号 id 登记进主人白名单
// (configs/users/owner.yaml)。它是结构化的身份红线:扫码登录
// 确认会把扫码者绑定的 ilink_user_id 盖进白名单,使
// 渠道 bridge 经 actor 元数据认出它是主人,绝不是提示词。
// 组合根实现了它(cmd/cyrene 的 ownerRegistrar)以持久化
// 白名单并热更新活的 owner profile。登记是尽力而为:
// 失败由调用方记日志,但不会让已经施加的
// channels.json 持久化失败。
type OwnerRegistrar interface {
RegisterChannelOwner(ctx context.Context, platform, accountID string) error
}
// RagStore 支撑管理侧知识库 RAG 模块(design/10 §3.12 module 8)。
// *runtime/knowledge.Store 是生产实现:它列出/搜索
// 项目索引、导入文档(切分 + 索引)、删除文档
// (项目条目 + 级联章节)。不能写入的实现
// 会报告明确的错误(knowledge.ErrWriteUnsupported),而不是编造
// 成功;handler 把文档缺失映射为 404。
type RagStore interface {
// ListDocuments 枚举知识库文档(项目索引
// 条目)。
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
// Search 应答一次知识库查询(行级层级 + 可选
// 语义/向量排序)。
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
// IndexDocument 把一份文档上传/切分/嵌入进项目索引。
IndexDocument(ctx context.Context, doc knowledge.Doc) error
// DeleteDocument 按 id 移除一份文档(及其章节),在
// 不存在该文档时返回 knowledge.ErrDocumentNotFound。
DeleteDocument(ctx context.Context, id string) error
}
// LlmUsageStore 是 LLM 用量汇总的**只读**出口(切片 U1,
// GET /api/v1/admin/llm/usage)。*runtime/model.UsageReader 是生产
// 实现:它在一块时间窗内聚合 `llm_usage` 表的 token 计数与缓存两档,
// 按用途与模型分解。
//
// 汇总里**没有任何凭据、也没有消息正文** —— 那些东西根本没进
// `llm_usage` 表(写入侧 model.UsageWriter 的样本里就没有这些字段)。
// 为 nil 时端点诚实作答 500 usage_unavailable,绝不回一份全 0
// 的假汇总。
type LlmUsageStore interface {
// Summary 聚合 q 描述的时间窗与用途过滤。q 的时间窗由
// handler 校验并补齐(缺省近 24h、上限 90 天)。
Summary(ctx context.Context, q model.UsageQuery) (model.UsageSummary, error)
}
// ChannelEventStore 是渠道事件留存的**只读查询出口**(design/26 §4.5 / §6.5;
// 切片 E6)。生产实现是 `internal/runtime/events` 的 `*SQLStore`,组合根把
// 同一个句柄既交给桥接层(写入面,E3)也交给网关(本查询面)。
//
// 它刻意只声明三个**读**方法:写入、计数落盘与保留期清扫是桥接层与组合根的
// 职责,管理查询面拿不到、也不该拿到它们(最小权限)。
//
// 查询结果里**没有任何平台原文 / 媒体 URL / 凭据 / OneBot `flag`** —— 事件表
// 本身就没有这些列(§4.2 红线),因此这里不存在「回显敏感字段」的代码路径。
//
// 为 nil 时两个端点诚实作答 500 `channel_events_unavailable`,绝不回一份全 0
// 的假汇总(与 `usage_unavailable` / `storage_unavailable` 同一口径)。
type ChannelEventStore interface {
// List 按时间倒序返回符合条件的事件行(Limit/Offset 见 events.Filter)。
List(ctx context.Context, f events.Filter) ([]events.Event, error)
// Count 统计符合条件的事件行数(List 的分页配套)。
Count(ctx context.Context, f events.Filter) (int64, error)
// Counters 返回逐类计数汇总;platform 为空表示全部平台。它读的是**已落盘**
// 的表,不含内存里待 flush 的部分(即 §4.3 的「近似值」)。
Counters(ctx context.Context, platform string) ([]events.Counter, error)
}
// FlowGraphSource 是**最近一轮链路图**的只读出口(契约 v0.71 的
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3)。生产实现就是
// `*loop.FlowPublisher`(`internal/runtime/loop/flow.go` 的 `LastGraph`),
// 组合根把**同一个**发布器既交给 loop/orchestration(写入面:每帧 span
// 发布 `ws.graph_update`)也交给网关(本查询面)。
//
// 它刻意只声明**两个只读方法**(v0.77 起是两个:`LastGraph` + `LatestLanes`):
// 发布、建图、轮次计数与发布会话白名单都留在发布器自己手里,管理查询面拿不到、
// 也不该拿到它们(最小权限)——因此只读查询**不可能**改变任何会话的实时图,
// 也不可能把面板看不见的会话变成可查的(白名单外的会话从未建图,两者自然回空)。
//
// 为 nil 时端点诚实作答 500 `flow_unavailable`,绝不回 `graph:null`
// (那会把「图源没接线」伪装成「这一轮确实没有图」——
// 与 `usage_unavailable` / `channel_events_unavailable` 同一口径)。
type FlowGraphSource interface {
// LastGraph 返回 sessionID 最近一轮图的一份只读快照;该会话还没有图
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
LastGraph(sessionID string) *trace.FlowGraph
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(契约 v0.77 的
// `lanes[]`),按路的开始时间升序;没有图时返回空切片。
//
// 真并行时同一会话同一轮有多条路(design/15 §2.5.5),`LastGraph` 只给出
// 其中最新的一条 —— 管理面板要靠本方法才看得全「两路各自的图」,否则
// 后起的那一路会把前一路从面板上顶掉(design/15 §2.5.10-1)。它同样是
// **只读**的:不发布、不改图、不动轮次计数。
LatestLanes(sessionID string) []*trace.FlowGraph
}
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
// `GET /api/v1/admin/flow/graph` 的 `?turnId=` 与 `turns[]`;design/19 §4 的 P3
// 「多轮历史回放」;**v0.76 ① 登记的工具回看面已由本包的 `GraphsByTurnID` 接上**
// (版本号由主会话在 `Docs/api-client.md` 统一登记))。生产实现是
// `internal/runtime/flowgraph` 的 `*SQLStore`,组合根把**同一个**句柄交给
// 发布器(写入面:EndRound 每路落一条)与本查询面(管理面板 + 消息读取面)。
//
// 它刻意只声明四个**读**方法:写入(RecordRound)与保留期清扫(Sweep)是发布器与
// 组合根的职责,查询面拿不到、也不该拿到它们(最小权限)。
//
// 为 nil 时管理端点回退到**只有内存**的行为(v0.71 口径):`turns[]` 为空、`?turnId=`
// 只能命中内存里那一轮;消息读取面则整体失去工具回看面(`toolCalls` 缺席,正文不受
// 影响)。内存与落盘**都没接线**时才回 500 `flow_unavailable`。
type FlowGraphStore interface {
// Graph 按 (sessionID, turnID) 取回一轮的图;该轮不存在时返回 (nil, nil)。
Graph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, error)
// Latest 取该会话最近一轮已落盘的图;没有已落盘的轮次时返回 (nil, nil)。
Latest(ctx context.Context, sessionID string) (*trace.FlowGraph, error)
// Turns 返回该会话已落盘轮次的摘要(最新在前),供面板选历史轮。
Turns(ctx context.Context, sessionID string, limit int) ([]flowgraph.Turn, error)
// GraphsByTurnID 按**路标识**(`chat_messages.turn_id`,迁移 0018)批量取图:
// 返回 `路标识 → 该路的图` 的映射,**取不到的标识不出现在映射里**。
//
// 它是消息读取面「工具回看面」(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)
// 的读侧入口,补的正是 v0.76 登记的那块缺口:`Graph` 按**图 id**取(`turn_id`
// 列),而消息面手里只有**路标识**(`lane_turn_id` 列,迁移 0023)—— 两者不同源,
// 因此必须有这个按路查的入口。一页消息的标识集合**一次查完**,调用方绝不按消息
// 逐条查(禁 N+1)。
//
// 查询失败如实上抛(调用方据此让回看面缺席并记 WARN);**取不到不是错误**,
// 缺席天然落在「映射里没有这个键」上。
GraphsByTurnID(ctx context.Context, sessionID string, laneTurnIDs []string) (map[string]*trace.FlowGraph, error)
}