Browse Source

feat(providers): 模型页显示各智能体当前模型 + 一键切换

回答"目前用的是哪个大模型": 每个智能体有自己的模型(config.model),
现在模型提供商页新增「使用中的模型」区块集中显示与切换:

- GET /providers/models-in-use: 各活跃智能体当前版本的
  provider/model 一览(租户隔离; 容错 model 为字符串的旧配置)
- PUT /agents/{id}/model {provider,name}: 轻量切换 — 只改模型字段,
  temperature/maxTokens 等保留; 生成新 agent_version(changelog
  记录 旧→新, 版本历史可回滚)
- GET /providers 的 ollama 模型列表改为实时探测本机已装模型
  (/api/tags, 2s 超时回退静态列表)
- Providers.tsx: 智能体 → 当前模型徽章 → 下拉(已配置 provider
  优先, 未配置标注) → 切换按钮; toast 报告版本变化

测试 +2(一览/切换/字段保留/回滚、404), 全量 282 passed;
真实服务已重启验证 models-in-use 返回正确

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 4 months ago
parent
commit
81d2588660

+ 61 - 0
agentpaas/src/agentpaas/api/v1/agents.py

@@ -384,6 +384,67 @@ async def delete_agent(
     return {"message": "Agent deleted", "agent_id": agent_id}
 
 
+class SwitchModelRequest(BaseModel):
+    provider: str = Field(..., description="Provider id, e.g. dashscope / ollama / claude-code")
+    name: str = Field(..., description="Model name, e.g. qwen-max / qwen2.5:7b / sonnet")
+
+
+@router.put("/{agent_id}/model")
+async def switch_agent_model(
+    agent_id: str,
+    req: SwitchModelRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """轻量模型切换(模型页「显示与切换」功能)。
+
+    只改当前配置的 model.provider / model.name,temperature、maxTokens
+    等其余字段原样保留;生成新 agent_version(可在版本历史回滚)。
+    """
+    require_permission(tenant, "agents:write")
+    agent = db.fetchone(
+        "SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
+        (agent_id, tenant.tenant_id))
+    if not agent:
+        api_error(404, "AGENT_NOT_FOUND", f"Agent {agent_id} not found")
+
+    ver = db.fetchone(
+        "SELECT config FROM agent_versions WHERE agent_id = ? AND version = ?",
+        (agent_id, agent["current_version"]))
+    if not ver:
+        api_error(404, "VERSION_NOT_FOUND", "current version config missing")
+
+    import hashlib
+    config = json.loads(ver["config"] or "{}")
+    old_model = config.get("model") or {}
+    if isinstance(old_model, str):
+        old_model = {"name": old_model}
+    old_desc = f"{old_model.get('provider', '?')}/{old_model.get('name', '?')}"
+
+    new_model = dict(old_model)
+    new_model["provider"] = req.provider
+    new_model["name"] = req.name
+    config["model"] = new_model
+
+    config_json = json.dumps(config, ensure_ascii=False)
+    config_hash = hashlib.sha256(config_json.encode()).hexdigest()
+    new_version = int(agent["current_version"]) + 1
+    now = now_utc()
+    db.execute(
+        "INSERT INTO agent_versions (agent_id, version, config, config_hash, "
+        "changelog, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+        (agent_id, new_version, config_json, config_hash,
+         f"模型切换: {old_desc} → {req.provider}/{req.name}",
+         tenant.user_id, now))
+    db.execute(
+        "UPDATE agents SET current_version = ?, updated_at = ? WHERE id = ?",
+        (new_version, now, agent_id))
+    db.commit()
+    return {"agent_id": agent_id, "version": new_version,
+            "provider": req.provider, "model": req.name,
+            "previous": old_desc}
+
+
 @router.post("/{agent_id}/rollback")
 async def rollback_agent(
     agent_id: str,

+ 55 - 1
agentpaas/src/agentpaas/api/v1/providers_api.py

@@ -106,18 +106,35 @@ def _is_configured(pid: str, saved: dict) -> bool:
     return bool(entry.get("api_key") or (env_key and os.getenv(env_key)))
 
 
+def _live_ollama_models(base_url: str = "http://127.0.0.1:11434") -> list:
+    """探测本地 Ollama 实际安装的模型(2s 超时;失败返回 [])。"""
+    try:
+        import urllib.request
+        with urllib.request.urlopen(f"{base_url}/api/tags", timeout=2) as r:
+            data = json.loads(r.read())
+        return [m["name"] for m in data.get("models", [])]
+    except Exception:
+        return []
+
+
 @router.get("")
 async def list_providers(_tenant: TenantContext = Depends(get_tenant)):
     saved = _load_providers()
     providers = []
     for pid, p in DEFAULT_PROVIDERS.items():
         entry = saved.get(pid, {})
+        models = p["models"]
+        if pid == "ollama":
+            # 模型切换下拉要列真实可用的本地模型,而非静态示例
+            live = _live_ollama_models()
+            if live:
+                models = live
         providers.append({
             "id": pid,
             "name": p["name"],
             "type": p["type"],
             "base_url": entry.get("base_url") or p["base_url"],
-            "models": p["models"],
+            "models": models,
             "env_key": p.get("env_key", ""),
             "no_key_required": p.get("no_key_required", False),
             "configured": _is_configured(pid, saved),
@@ -125,6 +142,43 @@ async def list_providers(_tenant: TenantContext = Depends(get_tenant)):
     return {"providers": providers}
 
 
+@router.get("/models-in-use")
+async def models_in_use(tenant: TenantContext = Depends(get_tenant)):
+    """各智能体当前使用的大模型一览(模型页「显示与切换」的数据源)。
+
+    从每个活跃智能体当前版本的 config.model 解析 provider/name。
+    """
+    from agentpaas.db.session import get_db
+    db = get_db()
+    rows = db.fetchall(
+        "SELECT a.id, a.name, a.current_version, v.config "
+        "FROM agents a JOIN agent_versions v "
+        "  ON v.agent_id = a.id AND v.version = a.current_version "
+        "WHERE a.tenant_id = ? AND a.status = 'active' "
+        "ORDER BY a.updated_at DESC",
+        (tenant.tenant_id,),
+    )
+    items = []
+    for r in rows:
+        try:
+            cfg = json.loads(r["config"] or "{}")
+        except Exception:
+            cfg = {}
+        model = cfg.get("model") or {}
+        if isinstance(model, str):  # 容错: model 可能是 "provider/name" 字符串
+            parts = model.split("/", 1)
+            model = {"provider": parts[0] if len(parts) > 1 else "",
+                     "name": parts[-1]}
+        items.append({
+            "agent_id": r["id"],
+            "agent_name": r["name"],
+            "version": r["current_version"],
+            "provider": model.get("provider", "") or "",
+            "model": model.get("name", "") or "",
+        })
+    return {"items": items, "count": len(items)}
+
+
 class SaveProviderRequest(BaseModel):
     api_key: Optional[str] = None
     base_url: Optional[str] = None

+ 47 - 0
tests/test_workspace_provision.py

@@ -136,3 +136,50 @@ def test_create_agent_respects_explicit_dirs(api_client, tmp_path):
     assert body["run_dir"] == ""  # 显式给了任一目录 → 其余不自动补
     # 没有为它建 Workspace/<名> 目录
     assert not os.path.exists(os.path.join(ws_base, "显式目录"))
+
+
+# ── 模型显示与切换(模型页功能)─────────────────────────────────────────────
+
+def test_models_in_use_and_switch(api_client):
+    client, key, ws_base, db = api_client
+    # 建两个不同模型的智能体
+    for name, model in [("甲", {"provider": "ollama", "name": "qwen2.5:7b"}),
+                        ("乙", {"provider": "dashscope", "name": "qwen-max"})]:
+        cfg = {"name": name, "type": "simple", "model": model, "systemPrompt": "t"}
+        r = client.post("/api/v1/agents", headers=_auth(key),
+                        json={"name": name, "config": cfg})
+        assert r.status_code in (200, 201), r.text[:200]
+
+    # 一览
+    r = client.get("/api/v1/providers/models-in-use", headers=_auth(key))
+    assert r.status_code == 200, r.text[:200]
+    items = {i["agent_name"]: i for i in r.json()["items"]}
+    assert items["甲"]["provider"] == "ollama" and items["甲"]["model"] == "qwen2.5:7b"
+    assert items["乙"]["provider"] == "dashscope" and items["乙"]["model"] == "qwen-max"
+
+    # 切换甲 → dashscope/qwen-plus
+    aid = items["甲"]["agent_id"]
+    r = client.put(f"/api/v1/agents/{aid}/model", headers=_auth(key),
+                   json={"provider": "dashscope", "name": "qwen-plus"})
+    assert r.status_code == 200, r.text[:300]
+    body = r.json()
+    assert body["version"] == 2 and body["previous"] == "ollama/qwen2.5:7b"
+
+    # 配置生效且其余字段保留;版本历史可回滚
+    g = client.get(f"/api/v1/agents/{aid}", headers=_auth(key)).json()
+    assert g["config"]["model"]["provider"] == "dashscope"
+    assert g["config"]["model"]["name"] == "qwen-plus"
+    assert g["config"]["systemPrompt"] == "t"
+    assert g["current_version"] == 2
+    rb = client.post(f"/api/v1/agents/{aid}/rollback", headers=_auth(key),
+                     json={"target_version": 1})
+    assert rb.status_code == 200
+    g2 = client.get(f"/api/v1/agents/{aid}", headers=_auth(key)).json()
+    assert g2["config"]["model"]["name"] == "qwen2.5:7b"
+
+
+def test_switch_model_unknown_agent_404(api_client):
+    client, key, *_ = api_client
+    r = client.put("/api/v1/agents/ag_nonexistent/model", headers=_auth(key),
+                   json={"provider": "ollama", "name": "x"})
+    assert r.status_code == 404

+ 14 - 0
webui/src/api/providers.ts

@@ -11,10 +11,24 @@ export interface Provider {
   configured: boolean
 }
 
+export interface ModelInUse {
+  agent_id: string
+  agent_name: string
+  version: number
+  provider: string
+  model: string
+}
+
 export const providersApi = {
   list: () => api.get<{ providers: Provider[] }>('/providers'),
   save: (id: string, body: { api_key?: string; base_url?: string }) =>
     api.post<{ ok: boolean }>(`/providers/${id}`, body),
   remove: (id: string) => api.delete<{ ok: boolean }>(`/providers/${id}`),
   test: (id: string) => api.post<{ ok: boolean; message: string }>(`/providers/${id}/test`),
+  /** 各智能体当前使用的模型一览 */
+  modelsInUse: () => api.get<{ items: ModelInUse[]; count: number }>('/providers/models-in-use'),
+  /** 切换某个智能体的模型(生成新版本,可回滚) */
+  switchModel: (agentId: string, provider: string, name: string) =>
+    api.put<{ agent_id: string; version: number; previous: string }>(
+      `/agents/${agentId}/model`, { provider, name }),
 }

+ 100 - 2
webui/src/pages/Providers.tsx

@@ -1,8 +1,8 @@
 import { useState } from 'react'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { CheckCircle2, XCircle, Eye, EyeOff, Loader2, Trash2, Plus } from 'lucide-react'
+import { CheckCircle2, XCircle, Eye, EyeOff, Loader2, Trash2, Plus, Bot, ArrowRight } from 'lucide-react'
 import toast from 'react-hot-toast'
-import { providersApi, type Provider } from '../api/providers'
+import { providersApi, type Provider, type ModelInUse } from '../api/providers'
 import { Card, PageHeader, Badge, Button, Input, Spinner } from '../components/ui'
 import { clsx } from '../lib/clsx'
 
@@ -146,6 +146,102 @@ function ProviderCard({ provider, onSave, onRemove, onTest, testing, testResult,
   )
 }
 
+// ── 使用中的模型:各智能体当前模型一览 + 切换 ──────────────────────────────
+
+function ModelsInUseSection({ providers }: { providers: Provider[] }) {
+  const qc = useQueryClient()
+  // 切换草稿: agent_id -> "provider/model"
+  const [draft, setDraft] = useState<Record<string, string>>({})
+
+  const { data, isLoading } = useQuery({
+    queryKey: ['models-in-use'],
+    queryFn: providersApi.modelsInUse,
+    retry: false,
+  })
+
+  const switchMut = useMutation({
+    mutationFn: ({ agentId, provider, name }: { agentId: string; provider: string; name: string }) =>
+      providersApi.switchModel(agentId, provider, name),
+    onSuccess: (res) => {
+      toast.success(`已切换:${res.previous} → 新模型(v${res.version},可在版本历史回滚)`, { duration: 5000 })
+      qc.invalidateQueries({ queryKey: ['models-in-use'] })
+      qc.invalidateQueries({ queryKey: ['agents'] })
+    },
+    onError: (e: any) => toast.error(e.message ?? '切换失败'),
+  })
+
+  // 下拉选项:已配置的 provider 优先,其余置后并标注
+  const options: { value: string; label: string; configured: boolean }[] = []
+  for (const p of providers) {
+    for (const m of p.models) {
+      options.push({
+        value: `${p.id}/${m}`,
+        label: `${p.id} / ${m}${p.configured ? '' : '(未配置 Key)'}`,
+        configured: p.configured,
+      })
+    }
+  }
+  options.sort((a, b) => Number(b.configured) - Number(a.configured))
+
+  if (isLoading) return null
+  const items = data?.items ?? []
+  if (items.length === 0) return null
+
+  return (
+    <section>
+      <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
+        使用中的模型({items.length} 个智能体)
+      </h2>
+      <Card className="divide-y divide-gray-100 p-0 overflow-hidden">
+        {items.map((it: ModelInUse) => {
+          const current = `${it.provider || '?'}/${it.model || '?'}`
+          const selected = draft[it.agent_id] ?? ''
+          const changed = selected && selected !== current
+          return (
+            <div key={it.agent_id} className="flex items-center gap-3 px-4 py-3 flex-wrap">
+              <div className="flex items-center gap-2 min-w-0 flex-1">
+                <Bot size={14} className="text-indigo-500 shrink-0" />
+                <span className="text-sm font-medium text-gray-900 truncate">{it.agent_name}</span>
+                <span className="text-[10px] text-gray-400 font-mono shrink-0">v{it.version}</span>
+              </div>
+              <Badge variant={it.provider ? 'green' : 'default'}>
+                {current}
+              </Badge>
+              <ArrowRight size={12} className="text-gray-300 shrink-0" />
+              <select
+                value={selected || current}
+                onChange={e => setDraft(prev => ({ ...prev, [it.agent_id]: e.target.value }))}
+                className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white text-gray-700 max-w-[220px]"
+              >
+                {!options.some(o => o.value === current) && (
+                  <option value={current}>{current}(当前)</option>
+                )}
+                {options.map(o => (
+                  <option key={o.value} value={o.value}>{o.label}</option>
+                ))}
+              </select>
+              <Button
+                size="sm"
+                disabled={!changed || switchMut.isPending}
+                onClick={() => {
+                  const [provider, ...rest] = selected.split('/')
+                  switchMut.mutate({ agentId: it.agent_id, provider, name: rest.join('/') })
+                }}
+              >
+                {switchMut.isPending ? '切换中…' : '切换'}
+              </Button>
+            </div>
+          )
+        })}
+      </Card>
+      <p className="text-[11px] text-gray-400 mt-2">
+        切换会生成新版本(旧配置保留在「智能体 → 编辑 → 版本历史」中可回滚);
+        ollama 选项列出的是本机实际安装的模型。
+      </p>
+    </section>
+  )
+}
+
 export default function Providers() {
   const qc = useQueryClient()
   const [testingId, setTestingId] = useState<string | null>(null)
@@ -219,6 +315,8 @@ export default function Providers() {
         </div>
       ) : (
         <div className="space-y-8">
+          <ModelsInUseSection providers={data?.providers ?? []} />
+
           {configured.length > 0 && (
             <section>
               <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">