feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 前端: 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:
AskaEth committed 2026-09-08 23:47:09 +08:00
1 parent 70c9fba529
commit 18b2040c95
10 files changed
+812 -220

No files matched your search

@@ -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"`