Files
Cyrene_v2/internal/services/gateway/admin_write_llm_channels.go
T
AskaEth 18b2040c95
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
- 前端: api.ts(adminPluginInstall带config/uninstall/listInstalledPlugins) + types + plugins/InstalledPlugins.tsx(已装列表+安装表单, Miuix) + llm/LlmProviderForm.tsx(maxTokens输入) ; Plugins.tsx去假安装按钮
- 后端 admin_write_llm_channels.go: llmProviderRequest字段改指针, mergeLlmProvider部分合并(省略保留原值), runtimeconfig/models.go Find
- 新增测试 admin_write_test TestAdminAddLlmProviderPartialEditPreservesFields
- 前端 build + 后端 build/vet/test 全绿; Docs/api-client v0.29
- (面板无法实机登录, 用 react-dom/server 冒烟渲染 LlmModule/PluginsModule)
2026-09-08 23:47:09 +08:00

386 lines
14 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 and channel-lifecycle admin writes (owner-only, audited). The LLM
// test performs a real model call; provider "add" records a process-lifetime
// entry. Channel open/close drive the real adapter lifecycle; the identity test
// is the structural actor × whitelist rule, never prompt text.
const (
// ownerProfilePath is the owner whitelist loaded for the structural identity
// test. It mirrors the path wire.go uses for owner recognition.
ownerProfilePath = "configs/users/owner.yaml"
// llmTestTimeout bounds a single LLM probe request.
llmTestTimeout = 30 * time.Second
// llmProbePrompt is the minimal prompt used to verify a model responds.
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
// provider group. Every field is a pointer so the handler can tell "absent"
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
// from the panel must never wipe a field it did not touch. Name is a client-side
// display label (not persisted — the models.json schema carries id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens).
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 is the group's own token budget. Absent preserves the stored
// budget (0 = unset for a new group). Negative is rejected.
MaxTokens *int `json:"maxTokens"`
}
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
// promoting it to the active group) and hot-reloads the running LLM stack so the
// new provider/model takes effect without a restart (design/04 §3.1). A field the
// client omits keeps its stored value, so a partial edit never resets a budget or
// key. The API key is persisted to the gitignored file but never returned/logged.
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
}
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
}
// Promote the saved group to the active (index 0) slot, then persist and
// hot-reload so Current() reflects the new provider/model immediately.
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 := AdminLlmProvider{
ID: group.ID,
Name: providerDisplayName(group),
Provider: group.Provider,
BaseURL: group.BaseURL,
Model: group.Model,
Multimodal: group.Multimodal,
Enabled: group.ID == s.llm.Group().ID,
Fallback: group.Fallback,
MaxTokens: group.MaxTokens,
}
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
"persisted": true,
"path": s.llm.Path(),
})
}
// mergeLlmProvider applies a partial provider patch over the stored groups: a
// field present in the request wins, an absent field keeps the stored value (or
// its default for a new group). It returns a validated group.
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 g.BaseURL == "" || g.Model == "" {
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
}
return g, nil
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
type llmTestRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Prompt string `json:"prompt"`
}
// adminTestLlm performs a real completion against the supplied provider/model
// endpoint and reports the result — a genuine round-trip, not a placeholder.
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})
}
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
type channelActionRequest struct {
Name string `json:"name"`
}
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.open")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.open", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Connect(r.Context()); err != nil {
s.auditClaims(r, "channel.open", "error")
// weixin-iLink with no token yet must not hang nor read as a credential
// error: the panel routes the owner to the out-of-band 扫码登录 flow.
if errors.Is(err, weixin_ilink.ErrNeedsLogin) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接"))
return
}
s.log.Error("channel open", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
return
}
s.auditClaims(r, "channel.open", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
}
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.close")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.close", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Disconnect(r.Context()); err != nil {
s.auditClaims(r, "channel.close", "error")
s.log.Error("channel close", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
return
}
s.auditClaims(r, "channel.close", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
}
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
// account id plus the conversation shape, resolved by the structural actor rule.
type channelTestRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
IsGroup bool `json:"isGroup"`
}
// adminChannelTest runs the structural identity rule (actor metadata × owner
// whitelist), never prompt text. It loads the owner whitelist from disk so a
// mismatch degrades to the conservative default (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),
})
}
// channelName decodes the {name} body for open/close routes, writing a 400 on
// failure. It reports uniqueness of the decode via ok.
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return "", false
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return "", false
}
return req.Name, true
}
// findChannel returns the registered channel adapter named name, if any. An exact
// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an
// exact name still resolves to that platform's first registered account, so the
// panel/CLI "connect kook" reaches the token-bearing account even when it is named
// "kook:conf0" (the empty-token bare placeholder is never registered).
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 loads the owner whitelist from disk, returning nil on any
// failure so the identity test degrades to the conservative deny.
func loadOwnerProfile() *user.OwnerProfile {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
return nil
}
return p
}
// describeIdentity renders the structural verdict for the identity test.
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
switch {
case a.IsGroupChat():
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
case a.IsOwner():
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
default:
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}