缺口①:「连接」是按平台连、且恒打第一条账号(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 行按普通行渲染)。
428 lines
17 KiB
Go
428 lines
17 KiB
Go
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,记忆只读不写)"
|
||
}
|
||
}
|