fix: Phase 6联调 — 插件管理器端口修正 + 多模型配置系统整合 + 历史消息刷新修复

## 调试日志

### 1. 插件管理器启动失败
- **症状**: DevTools 显示插件管理器一直"已停止",手动启动正常
- **排查**: 对比 process-manager.js 传入的环境变量 vs plugin-manager config.go 读取的变量
- **根因**: config.js 传入 PLUGIN_MANAGER_PORT=8094,但 config.go 读取 os.Getenv("PORT"),env 名不匹配。且 process.env 中 PORT 泄露时被误读为 9090,与 DevTools 端口冲突
- **修复**: config.js 将 PLUGIN_MANAGER_PORT → PORT,使 env 名与代码一致 (c3055f4)

### 2. 历史消息刷新后消失
- **症状**: 浏览器刷新后聊天历史清空
- **排查**: WebSocket history_response handler 中 if (msg.messages) 对空数组 [] 为 truthy
- **根因**: 后端返回空的 history_response (缓存为空) 时,空数组覆盖了 HTTP 已加载的消息
- **修复**: useWebSocket.ts 改为 if (msg.messages && msg.messages.length > 0),空数组走 else-if 分支仅打日志,不覆盖已有消息

### 3. Phase 6 多模型配置系统
- Gateway: ModelsConfigStore (JSON文件持久化) + Admin CRUD API (providers/models/routing)
- ai-core: ModelSelector 支持按 purpose 选择 + fallback_chain,无配置时回退 .env
- DevTools: 模型配置管理面板 (Providers/Models/Routing 三Tab)、在线模型查询代理、路由表单 checkbox 多选、关键词搜索过滤
- .gitignore: models.json + platform_configs.json

### 4. 多端客户端追踪
- Hub 新增 knownClients 映射 (clientID → KnownClient),在线/离线状态追踪
- 客户端备注持久化到 PostgreSQL
- DevTools 客户端管理面板

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:23:10 +08:00
parent 965cce7192
commit 0717928496
29 changed files with 3177 additions and 137 deletions
@@ -0,0 +1,297 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/config"
)
// ModelConfigHandler exposes admin CRUD endpoints for model configuration.
type ModelConfigHandler struct {
store *config.ModelsConfigStore
}
func NewModelConfigHandler(store *config.ModelsConfigStore) *ModelConfigHandler {
return &ModelConfigHandler{store: store}
}
// ---- Providers ----
func (h *ModelConfigHandler) ListProviders(c *gin.Context) {
providers := h.store.ListProviders()
if providers == nil {
providers = []*config.ProviderConfig{}
}
c.JSON(http.StatusOK, gin.H{"providers": providers, "total": len(providers)})
}
func (h *ModelConfigHandler) GetProvider(c *gin.Context) {
name := c.Param("name")
p, err := h.store.GetProvider(name)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
func (h *ModelConfigHandler) SetProvider(c *gin.Context) {
name := c.Param("name")
var body config.ProviderConfig
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
body.Name = name
if err := h.store.SetProvider(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "name": name})
}
func (h *ModelConfigHandler) DeleteProvider(c *gin.Context) {
name := c.Param("name")
if err := h.store.DeleteProvider(name); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "name": name})
}
// ---- Models ----
func (h *ModelConfigHandler) ListModels(c *gin.Context) {
models := h.store.ListModels()
if models == nil {
models = []*config.ModelConfig{}
}
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
}
func (h *ModelConfigHandler) GetModel(c *gin.Context) {
id := c.Param("id")
m, err := h.store.GetModel(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
func (h *ModelConfigHandler) SetModel(c *gin.Context) {
id := c.Param("id")
var body config.ModelConfig
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
body.ID = id
if err := h.store.SetModel(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "id": id})
}
func (h *ModelConfigHandler) DeleteModel(c *gin.Context) {
id := c.Param("id")
if err := h.store.DeleteModel(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "id": id})
}
// ---- Routing ----
func (h *ModelConfigHandler) ListRouting(c *gin.Context) {
routing := h.store.ListRouting()
if routing == nil {
routing = []*config.RoutingRule{}
}
c.JSON(http.StatusOK, gin.H{"routing": routing, "total": len(routing)})
}
func (h *ModelConfigHandler) GetRouting(c *gin.Context) {
purpose := c.Param("purpose")
r, err := h.store.GetRouting(purpose)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, r)
}
func (h *ModelConfigHandler) SetRouting(c *gin.Context) {
purpose := c.Param("purpose")
var body config.RoutingRule
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
body.Purpose = purpose
if err := h.store.SetRouting(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "purpose": purpose})
}
func (h *ModelConfigHandler) DeleteRouting(c *gin.Context) {
purpose := c.Param("purpose")
if err := h.store.DeleteRouting(purpose); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "purpose": purpose})
}
// ---- Health Check ----
func (h *ModelConfigHandler) TestProvider(c *gin.Context) {
var body struct {
Provider string `json:"provider"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
p, err := h.store.GetProvider(body.Provider)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"provider": p.Name,
"base_url": p.BaseURL,
"message": "Provider 配置已保存,连接测试请通过实际 LLM 调用验证",
})
}
// ---- Remote Model List Proxy ----
// ProxyListModels forwards a request to the provider's models endpoint using the stored API key.
func (h *ModelConfigHandler) ProxyListModels(c *gin.Context) {
providerName := c.Param("name")
modelsURL := c.Query("url")
if modelsURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'url' query parameter"})
return
}
p, err := h.store.GetProvider(providerName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if p.APIKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider 未配置 API Key"})
return
}
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", modelsURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()})
return
}
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "请求模型列表失败: " + err.Error()})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 2 MB limit
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "读取响应失败: " + err.Error()})
return
}
if resp.StatusCode >= 400 {
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("Provider API 返回错误 (HTTP %d)", resp.StatusCode),
"body": string(body),
"models_url": modelsURL,
})
return
}
// Parse the response body which may use different formats:
// OpenAI: {"object":"list","data":[{"id":"...","object":"model",...}]}
// DashScope: {"request_id":"...","data":{"models":[{"model_id":"..."}]}}
// Generic: {"data":[{"id":"..."}]} or {"data":[{"model_id":"..."}]}
ids := parseModelListResponse(body)
if len(ids) == 0 {
c.JSON(http.StatusBadGateway, gin.H{
"error": "无法从 Provider 响应中解析模型列表 (不支持的格式)",
"raw": string(body),
})
return
}
c.JSON(http.StatusOK, gin.H{
"provider": providerName,
"url": modelsURL,
"models": ids,
"total": len(ids),
})
}
// parseModelListResponse attempts to extract model IDs from various provider response formats.
// Supported formats:
// - OpenAI-compatible: {"object":"list","data":[{"id":"gpt-4o",...}]}
// - DashScope: {"data":{"models":[{"model_id":"qwen-turbo",...}]}}
// - Generic: {"data":[{"id":"..."}]} or {"data":[{"model_id":"..."}]}
func parseModelListResponse(body []byte) []string {
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil
}
// Strategy 1: data is an array of objects — try "id" then "model_id"
if dataArr, ok := raw["data"].([]interface{}); ok {
ids := extractIDs(dataArr, "id")
if len(ids) > 0 {
return ids
}
return extractIDs(dataArr, "model_id")
}
// Strategy 2: data is an object with a "models" array (DashScope format)
if dataObj, ok := raw["data"].(map[string]interface{}); ok {
if modelsArr, ok := dataObj["models"].([]interface{}); ok {
ids := extractIDs(modelsArr, "model_id")
if len(ids) > 0 {
return ids
}
return extractIDs(modelsArr, "id")
}
}
return nil
}
func extractIDs(items []interface{}, key string) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
if obj, ok := item.(map[string]interface{}); ok {
if v, ok := obj[key]; ok {
if s, ok := v.(string); ok && s != "" {
ids = append(ids, s)
}
}
}
}
return ids
}