Files
Cyrene_v2/cmd/cyrene/wire_frontend.go
T
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

171 lines
7.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(),
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
// usage_unavailable,不回假汇总)。
Usage: model.NewUsageReader(a.db),
})
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,
}
}