- 语义:只把 .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 生效。
166 lines
7.2 KiB
Go
166 lines
7.2 KiB
Go
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,
|
||
}
|
||
}
|