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)
This commit is contained in:
1 parent
70c9fba529
commit
18b2040c95
10 files changed
+812
-220
No files matched your search
@@ -184,6 +184,21 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
||||
}}
|
||||
}
|
||||
|
||||
// Find returns the group with the given id, if present. It is the read half of
|
||||
// the admin read-modify-write merge, so a partial edit starts from the stored
|
||||
// group instead of zero values.
|
||||
func Find(groups []Group, id string) (Group, bool) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return Group{}, false
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g.ID == id {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return Group{}, false
|
||||
}
|
||||
|
||||
// Upsert returns a copy of groups with g inserted or replaced in place by ID,
|
||||
// preserving order. A blank ID is assigned a generated id so a panel save
|
||||
// without an explicit id still produces a stable, addressable group.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,74 +37,55 @@ const (
|
||||
)
|
||||
|
||||
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
|
||||
// provider group. Name is a client-side display label (not persisted — the
|
||||
// models.json schema carries id/provider/baseUrl/apiKey/model/fallback/
|
||||
// multimodal/maxTokens).
|
||||
// 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. Zero means unset (the runtime
|
||||
// falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the
|
||||
// adapter default). Negative is rejected.
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
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). The API key
|
||||
// is persisted to the gitignored file but never returned or logged.
|
||||
// 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 strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
|
||||
return
|
||||
}
|
||||
if req.MaxTokens < 0 {
|
||||
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
|
||||
}
|
||||
provider := req.Provider
|
||||
if provider == "" {
|
||||
provider = model.ProviderOpenAI
|
||||
}
|
||||
if provider != model.ProviderOpenAI && provider != model.ProviderAnthropic {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
|
||||
return
|
||||
}
|
||||
if s.llm == nil {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
||||
return
|
||||
}
|
||||
id := req.ID
|
||||
if id == "" {
|
||||
id = runtimeconfig.NewID()
|
||||
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(), runtimeconfig.Group{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
BaseURL: req.BaseURL,
|
||||
APIKey: req.APIKey,
|
||||
Model: req.Model,
|
||||
Fallback: req.Fallback,
|
||||
Multimodal: req.Multimodal,
|
||||
MaxTokens: req.MaxTokens,
|
||||
})
|
||||
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)
|
||||
@@ -117,15 +99,15 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
saved := AdminLlmProvider{
|
||||
ID: id,
|
||||
Name: providerDisplayName(runtimeconfig.Group{Provider: provider}),
|
||||
Provider: provider,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
Multimodal: req.Multimodal,
|
||||
Enabled: id == s.llm.Group().ID,
|
||||
Fallback: req.Fallback,
|
||||
MaxTokens: req.MaxTokens,
|
||||
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{
|
||||
@@ -135,6 +117,55 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -301,6 +301,50 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Seed a fully-specified group.
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true, "fallback": "deepseek-lite", "maxTokens": 1234}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Partial edit: only the model is given. Every omitted field must keep its
|
||||
// stored value — in particular maxTokens (a full replace reset it to 0) and
|
||||
// apiKey (a full replace wiped it).
|
||||
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"id": "deepseek", "model": "deepseek-chat-v2"}, tok)
|
||||
if resp2.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("edit status = %d, want 201 (body=%s)", resp2.StatusCode, body2)
|
||||
}
|
||||
var res struct {
|
||||
Provider AdminLlmProvider `json:"provider"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body2)["data"], &res); err != nil {
|
||||
t.Fatalf("decode edit: %v", err)
|
||||
}
|
||||
p := res.Provider
|
||||
if p.Model != "deepseek-chat-v2" || p.MaxTokens != 1234 || p.Fallback != "deepseek-lite" ||
|
||||
!p.Multimodal || p.Provider != "openai" || p.BaseURL != "https://api.deepseek.com/v1" {
|
||||
t.Fatalf("edit provider = %+v, want model updated and every omitted field preserved", p)
|
||||
}
|
||||
|
||||
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("load models warn = %v", warn)
|
||||
}
|
||||
if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-chat-v2" {
|
||||
t.Fatalf("file groups = %+v, want apiKey/maxTokens preserved and model updated", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
|
||||
Reference in new issue
Block a user