Files
Cyrene_v2/cmd/cyrene/wire_frontend.go
T
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00

184 lines
8.5 KiB
Go
Raw 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 main
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// capacityEmitter 是面向网关的 capacity_awaken 广播出口。
// *gateway.Server 实现它;测试用假实现替换以捕获入参。
type capacityEmitter interface {
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
}
// wireFrontend 用内嵌/外部 SPA 文件系统与 JWT/RBAC 认证器构建网关。
// Session/Persona/Memories 接到具体的管理器上,管理端/用户端的列表接口才会
// 返回真实数据。
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
apiKeys := a.wireAPIKeys()
ragStore := knowledge.New(knowledge.Deps{
Projects: a.projStore,
Retriever: a.retriever,
DefaultPersona: a.defaultPersonaID(),
VectorOn: a.vindex != nil,
Log: a.log,
})
auditStore, aerr := gateway.NewSQLiteAuditStore(context.Background(), a.db, a.log)
if aerr != nil {
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
auditStore = nil
}
// 不透明刷新令牌(B2)与主 SQLite 句柄共用同一连接。这里失败要优雅降级:
// 登录只是不下发 refreshToken,/auth/refresh 报「未配置」,而不是让整个
// 启动失败。store 以接口形式持有,构造失败就保持 nil(若存成带类型的 nil
// 指针,接口值非 nil,首次调用会 panic)。
var refreshStore gateway.RefreshGrantStore
if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil {
a.log.Warn("refresh token store unavailable", "error", rerr)
} else {
refreshStore = store
}
// a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
// 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
// 影响任何下游。
gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
Bus: a.bus,
Registry: a.caps,
Loop: a.loop,
Session: a.sessions,
Personas: a.personas,
Memories: a.memStore,
History: a.history,
IoT: a.iot,
Scheduler: a.scheduler,
Tasks: a.tasks,
Automations: a.automation,
Users: apiKeys,
Refresh: refreshStore,
Backthink: a.backthink,
RAG: ragStore,
LLM: a.llm,
// EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板
// 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明,
// 网关自己不 fopen `.env`。
EnvPath: envPath,
Channels: a.channels,
Plugins: a.plugins,
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
AuditStore: auditStore,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
Webhooks: a.channelBridge.WebhookHandlers(),
DatabasePath: a.dbPath,
DataDir: resolveDataDir(a.cfg),
Database: a.db,
Vector: a.vectorState(),
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
Flow: a.flow,
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
FlowGraphs: a.flowGraphStore(),
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
// usage_unavailable,不回假汇总)。
Usage: model.NewUsageReader(a.db),
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6):
// 复用组合根已按 CHANNEL_EVENT_* 建好的那份 store。未接线时为**真 nil**
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
// 绝不回空 200 假装「没有事件」。
EventStore: a.channelEventStore(),
})
if err != nil {
return fmt.Errorf("gateway: %w", err)
}
gw.SetVoice(a.voice)
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
wireGatewayTraceSink(a, gw)
if a.channelBridge != nil {
// 把渠道桥中「主人私聊」持久化的消息接入编排器所用的同一个
// `message_persisted` 广播,让 Web 客户端用同一套统一帧来镜像 IM 消息
// (design/16 §4.4)。
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
// design/16 §4.6 §6:被平台白/黑名单(allow/deny)丢弃的消息会记入网关审计
// 轨迹,这样即使它从未到达循环,这次拒绝也「audit 可查」。
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
})
}
a.capacitySink = gw
a.moodSink = gw
// 网关 hub(活跃客户端通路)与渠道桥(IM 回落)都已在位,现在接线 backthink 的
// 主动推送出口。backthink.New 执行时它们都还不存在(hub 需要循环,桥需要循环与
// 主人档案,这些都更晚才组装),若不在此处补接线,推送器会解析出 RouteNone。
// 每个 setter 都在服务的生命周期锁下重建推送器。
if a.backthink != nil {
a.backthink.SetActiveSink(gw)
if a.channelBridge != nil {
a.backthink.SetIMSink(a.channelBridge)
}
}
return nil
}
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
// 长凭据的语义与「重启即作废」自相矛盾)。
//
// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发
// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败
// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次
// 「面板报成功、重启全 401」。
func (a *app) wireAPIKeys() *auth.APIKey {
keys := auth.NewAPIKey(a.cfg.Auth)
if a.db == nil {
return keys
}
store, err := auth.NewSQLiteKeyStore(a.db, a.log)
if err != nil {
a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err)
return keys
}
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err)
}
return keys
}
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
//
// 真源就在本组合根,且只有两项事实,因此这里**复用**运行时状态而不新造探测:
//
// - Configured = `DB_PG_URL` 是否配置(非空即算)—— 开关在不在;
// - Live = 活着的向量索引确实落在 pgvector 后端上 —— 真的在不在跑。
//
// 两者会不一致:配置了 PG 但连接/schema 准备失败时,wireMemory 把 vindex 留成
// nil(语义检索降级),此时 Configured=true、Live=false,面板据此显示「已配置但
// 未就绪」,而不是把「配了」当成「生效了」。这里绝不新开 PG 连接去探测:那既多
// 一次网络往返,也可能与运行中的索引给出不同答案。
func (a *app) vectorState() gateway.VectorState {
_, live := a.vindex.(*vector.PGVectorIndex)
return gateway.VectorState{
Configured: strings.TrimSpace(a.cfg.DB.PGURL) != "",
Live: live,
}
}