Files
Cyrene_v2/internal/services/gateway/admin_write_llm_channels.go
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

428 lines
17 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 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)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true))
}
// 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)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false))
}
// 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,记忆只读不写)"
}
}