Files
Cyrene_v2/cmd/cyrene/wire_frontend.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00

166 lines
7.2 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/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(),
})
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,
}
}