package gateway import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) // LLM provider 与渠道生命周期的管理写操作(仅 owner,带审计)。LLM // 测试会发起真实模型调用;provider「add」记录一条 // 进程生命周期内的条目。渠道开/关驱动真实适配器生命周期;身份 // 测试走结构化的 actor × 白名单规则,绝不是提示词文本。 const ( // ownerProfilePath 是为结构化身份测试加载的主人白名单。 // 它与 wire.go 用于主人识别的路径一致。 ownerProfilePath = "configs/users/owner.yaml" // llmTestTimeout 限定单次 LLM 探测请求。 llmTestTimeout = 30 * time.Second // llmProbePrompt 是用于验证模型能应答的最小提示词。 llmProbePrompt = "Reply with the single word: OK" ) // llmReasoningRequest 是请求体里的推理开关形状块。整块缺省 = 保留已存形状; // 整块给出(哪怕只有 `{}`)= 整块替换,因此面板能把形状改回「未声明」。 type llmReasoningRequest struct { Kind *string `json:"kind"` } // llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新 // provider 分组。每个字段都是指针(映射类型用 nil 判定),使 handler 能区分 // 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部 // 编辑绝不清除它未触碰的字段。Name 是客户端侧 // 显示标签(不持久化 —— models.json 结构含 id/provider/ // baseUrl/apiKey/model/fallback/multimodal/maxTokens/contextWindow/reasoning/ // personaModels/personaProviders)。 type llmProviderRequest struct { ID *string `json:"id"` Name *string `json:"name"` Provider *string `json:"provider"` BaseURL *string `json:"baseUrl"` APIKey *string `json:"apiKey"` Model *string `json:"model"` Multimodal *bool `json:"multimodal"` Fallback *string `json:"fallback"` // MaxTokens 是该分组自身的 token 预算。缺失则保留已存 // 预算(新分组 0 = 未设置)。负值被拒绝。 MaxTokens *int `json:"maxTokens"` // ContextWindow 是该分组声明的模型上下文窗口(token)。缺失则保留已存窗口 // (新分组 0 = 未声明);显式 0 与缺失同义(都按回落窗口推导);负值被拒绝。 ContextWindow *int `json:"contextWindow"` // Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖 // (kind 缺失/空串 = 回到「未声明」,等同 auto)。 Reasoning *llmReasoningRequest `json:"reasoning"` // PersonaModels / PersonaProviders 是 persona 路由表,缺失 = 保留已存表, // 给出(含 `{}`)= 整表替换。用整表替换而非合并:否则面板无法删除一条 // 已存路由,只能覆盖它的值。 PersonaModels map[string]string `json:"personaModels"` PersonaProviders map[string]string `json:"personaProviders"` } // adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write, // 并将其提升为活动分组),再热重载运行中的 LLM 栈,使 // 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的 // 字段保持其已存值,因此局部编辑绝不复位预算、窗口或 // key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。 func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { var req llmProviderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if req.MaxTokens != nil && *req.MaxTokens < 0 { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0")) return } // 窗口没有负数语义(与 runtimeconfig.Group.Validate 同一口径:只拒绝负数, // 0 与缺失都是「未声明」)。这里提前报错是为了给出面板能直接显示的字段名, // 规则本身不在网关另立一套。 if req.ContextWindow != nil && *req.ContextWindow < 0 { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "contextWindow must be >= 0")) return } if s.llm == nil { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入")) return } group, err := mergeLlmProvider(s.llm.Groups(), req) if err != nil { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err)) return } // 把已保存的分组提升到活动(下标 0)槽位,然后持久化并 // 热重载,使 Current() 立即反映新的 provider/model。 groups := runtimeconfig.Activate(s.llm.Groups(), group) if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil { s.auditClaims(r, "llm.addProvider", "error") s.log.Error("llm save models", "path", s.llm.Path(), "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "failed to save models.json")) return } if err := s.llm.Reload(); err != nil { s.auditClaims(r, "llm.addProvider", "error") s.log.Error("llm reload", "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload")) return } saved := adminLlmProviderDTO(group, providerDisplayName(group), group.ID == s.llm.Group().ID) s.auditClaims(r, "llm.addProvider", "ok") writeJSON(w, http.StatusCreated, map[string]any{ "provider": saved, "persisted": true, "path": s.llm.Path(), }) } // mergeLlmProvider 把局部 provider 补丁施加到已存分组之上: // 请求中出现的字段胜出,缺失字段保留已存值(新分组 // 则用其默认值)。它返回经过校验的分组——校验走配置层同一入口 // (runtimeconfig.Group.Validate),因此非法的 reasoning.kind / // 非法的 personaProviders 协议名都在这里以错误浮出(写端点回 400), // 绝不静默丢弃或悄悄改成别的值。 func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) { id := "" if req.ID != nil { id = strings.TrimSpace(*req.ID) } g, found := runtimeconfig.Find(groups, id) if !found { g = runtimeconfig.Group{} } g.ID = id if g.ID == "" { g.ID = runtimeconfig.NewID() } if req.Provider != nil { g.Provider = strings.TrimSpace(*req.Provider) } if g.Provider == "" { g.Provider = model.ProviderOpenAI } if g.Provider != model.ProviderOpenAI && g.Provider != model.ProviderAnthropic { return runtimeconfig.Group{}, fmt.Errorf("provider must be openai or anthropic") } if req.BaseURL != nil { g.BaseURL = strings.TrimSpace(*req.BaseURL) } if req.APIKey != nil { g.APIKey = *req.APIKey } if req.Model != nil { g.Model = strings.TrimSpace(*req.Model) } if req.Fallback != nil { g.Fallback = strings.TrimSpace(*req.Fallback) } if req.Multimodal != nil { g.Multimodal = *req.Multimodal } if req.MaxTokens != nil { g.MaxTokens = *req.MaxTokens } if req.ContextWindow != nil { g.ContextWindow = *req.ContextWindow } if req.Reasoning != nil { g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)} } if req.PersonaModels != nil { models, err := normalizePersonaMap("personaModels", req.PersonaModels) if err != nil { return runtimeconfig.Group{}, err } g.PersonaModels = models } if req.PersonaProviders != nil { providers, err := normalizePersonaMap("personaProviders", req.PersonaProviders) if err != nil { return runtimeconfig.Group{}, err } g.PersonaProviders = providers } if g.BaseURL == "" || g.Model == "" { return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required") } if err := g.Validate(); err != nil { return runtimeconfig.Group{}, err } return g, nil } // reasoningKindOf 取出请求里的形状名:kind 缺失/空串 = 未声明形状(等同 auto, // 等同不写这把键),因此面板能显式「改回未声明」而不是被局部编辑语义锁死在 // 已存形状上。 func reasoningKindOf(req *llmReasoningRequest) string { if req == nil || req.Kind == nil { return "" } return strings.TrimSpace(*req.Kind) } // llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。 type llmTestRequest struct { Provider string `json:"provider"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` Model string `json:"model"` Prompt string `json:"prompt"` } // adminTestLlm 针对提供的 provider/model 端点执行一次真实补全 // 并报告结果 —— 真实的往返,而不是占位。 func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) { var req llmTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" { s.auditClaims(r, "llm.test", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required")) return } provider := req.Provider if provider == "" { provider = model.ProviderOpenAI } adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model}) if err != nil { s.auditClaims(r, "llm.test", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider")) return } prompt := strings.TrimSpace(req.Prompt) if prompt == "" { prompt = llmProbePrompt } ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout) defer cancel() resp, err := adapter.Chat(ctx, model.Request{ Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)}, }, model.Options{Timeout: llmTestTimeout}) if err != nil { s.auditClaims(r, "llm.test", "error") s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error())) return } s.auditClaims(r, "llm.test", "ok") writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens}) } // adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。请求可选的 // accountId 决定多账号平台上**连的是哪一条**(见 channelForAction);缺省时走平台级 // 旧语义(精确同名适配器 → 该平台配置顺序第一条),因此老客户端/旧面板不受影响。 func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { target, ok := s.channelAction(r, w, "channel.open") if !ok { return } ch, ok := s.channelForAction(w, r, target, "channel.open") if !ok { return } if err := ch.Connect(r.Context()); err != nil { s.auditClaimsAccount(r, "channel.open", "error", target.AccountID) // 单纯重连修不好的状态不得读作通用失败: // weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此 // 主人需要一次新的扫码登录。用该指引返回 409, // 而不是假装渠道已打开(面板会把它标记为已连接)。 if errors.Is(err, weixin_ilink.ErrSessionRejected) { writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected", "该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接")) return } // 尚无 token 的 weixin-iLink 既不能挂起,也不能读作凭据 // 错误:面板会把主人导向带外的扫码登录流程。 if errors.Is(err, weixin_ilink.ErrNeedsLogin) { writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接")) return } s.log.Error("channel open", "name", ch.Name(), "accountId", target.AccountID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error())) return } s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID) // `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意 // 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里 // **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含 // 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。 connected, outcome := channelConnectStateOf(ch) writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome)) } // adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId // 的语义与 open 完全一致(给出时只断该账号,缺省时同 open 的平台级解析)。 func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) { target, ok := s.channelAction(r, w, "channel.close") if !ok { return } ch, ok := s.channelForAction(w, r, target, "channel.close") if !ok { return } if err := ch.Disconnect(r.Context()); err != nil { s.auditClaimsAccount(r, "channel.close", "error", target.AccountID) s.log.Error("channel close", "name", ch.Name(), "accountId", target.AccountID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error())) return } s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID) // 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。 // 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是 // 「会话到底还在不在」——那正是 IsConnected() 的语义。 writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{})) } // channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道 // 账号 id 加上对话形态,由结构化 actor 规则解析。 type channelTestRequest struct { Platform string `json:"platform"` AccountID string `json:"accountId"` IsGroup bool `json:"isGroup"` } // adminChannelTest 运行结构化身份规则(actor 元数据 × 主人 // 白名单),绝不是提示词文本。它从磁盘加载主人白名单, // 因此不匹配时退化为保守默认(member/stranger)。 func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) { var req channelTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.AccountID) == "" { s.auditClaims(r, "channel.identity-test", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required")) return } msg := adapter.UnifiedMessage{ Platform: req.Platform, ChannelID: req.AccountID, SenderID: req.AccountID, Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup}, } if req.IsGroup { msg.ChannelType = adapter.ChannelGroup } else { msg.ChannelType = adapter.ChannelDirect } owner := loadOwnerProfile() actor := session.ResolveActor(msg, owner) env := session.EnvFor(actor) s.auditClaims(r, "channel.identity-test", "ok") writeJSON(w, http.StatusOK, map[string]any{ "role": string(actor.Role), "owner": actor.IsOwner(), "isGroup": actor.IsGroupChat(), "env": string(env), "verdict": describeIdentity(actor, owner), }) } // findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的 // 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook") // 仍会解析到该平台首个已注册账号,因此面板/CLI 的 // "connect kook" 能抵达携带 token 的账号,即使它名为 // "kook:conf0"(空 token 的裸占位符从不注册)。 // // 这就是 open/close 在**没有** accountId 时的旧语义;多账号平台上要指名某条 // 账号请走 findChannelByAccount(见 admin_channel_target.go),那条路径没有回落。 func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) { if caps == nil { return nil, false } var first sdk.Channel for _, cap := range caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } if ch.Name() == name { return ch, true } if first == nil && channelBasePlatform(ch.Name()) == name { first = ch } } if first != nil { return first, true } return nil, false } // loadOwnerProfile 从磁盘加载主人白名单,任何失败都返回 // nil,因此身份测试退化为保守的拒绝。 func loadOwnerProfile() *user.OwnerProfile { p, err := user.LoadOwnerProfile(ownerProfilePath) if err != nil { return nil } return p } // describeIdentity 为身份测试渲染结构化判定结果。 func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string { switch { case a.IsGroupChat(): return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具" case a.IsOwner(): return "匹配主人白名单 → owner(全量访问,记忆可读写)" default: return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)" } }