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 是个空转依赖,因此把它的创建顺序提前不会 // 影响任何下游。 // // 记忆整理「试算」出口(契约 v0.75;切片 MT1):注入的正是低峰心跳跑的那个 // *memory.TidyService(同一套规则、同一份配置)。**必须**在它为 nil 时留成 // **真 nil 接口** —— 带类型的 nil 指针会让网关的 `== nil` 判断失效,端点就会 // 去调一个空接收者。 var tidyDryRunner gateway.MemoryTidyDryRunner if a.memoryTidy != nil { tidyDryRunner = a.memoryTidy } 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(), // MemoryTidy 是记忆整理「试算」的只读出口(契约 v0.75;切片 MT1):上面按 // **真 nil** 取舍过的接口值。 MemoryTidy: tidyDryRunner, }) 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) // IM 路由的**主动推送**另走一条通知通道(切片 P0b):它落的也是主会话,但帧顶 // 带结构化判据 `proactive:true`,客户端据此在非前台补一发浏览器通知。此前 IM // 路由只有上面那条普通帧 ⇒ 走 IM 的主动消息一条都不弹(而 `auto` 提示在主 // 人有已连接 IM 渠道时优先走 IM,恰恰是最常见的路径)。 a.channelBridge.SetOnProactivePersisted(a.gw.BroadcastProactivePersisted) // 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, } }