前提由 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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
351 lines
19 KiB
Go
351 lines
19 KiB
Go
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)
|
||
}
|