Selaa lähdekoodia

feat(dashboard): 改造为今日工作台 — AI 助手入口 + 意图路由

原 Dashboard 是平台运维视角(统计卡+运行状态表), 对 desktop 单用户
无用。重写为任务导向工作台, 信息架构按使用频率排:

1. AI 助手入口(主角): 自然语言描述任务 → POST /assistant/route
   轻量 LLM 意图分类(dashscope qwen-turbo > 本地 ollama > 不可用)
   → 命中则直达该智能体对话并预填输入(/chat/{id}?q=...);
   路由不可用/拿不准 → 回退手动选择器 — 路由是加速器不是单点
2. 继续上次的工作: GET /status/recent-runs 跨智能体最近运行
   (运行中排最前, 15s 轮询), 一键回到对话
3. 常用场景卡片: 审论文/出试卷/写推荐信/设计课程 → 已有实例直达,
   没有则从内置包一键创建(包未安装自动隐藏)
4. 统计降级为页脚一行(累计运行/token, 链到模型页)

后端: status.py recent-runs(租户隔离/输入截断/running 优先);
assistant.py route(候选≤12/输入截断500字/单智能体免LLM直返/
choice 越界与异常全部降级 matched=false 而非 500/usage 可审计/
分类函数可注入)。Chat.tsx 支持 ?q= 深链预填。

测试 +6(recent-runs 排序截断租户隔离; route 直返/命中/拿不准/
越界/异常降级/无模型), 全量 296 passed。live 验证: 真实分类器
「出期中测验」→试卷助手、「评审投稿论文」→审稿助手, 双双命中

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 2 kuukautta sitten
vanhempi
commit
a9866418db

+ 2 - 1
agentpaas/src/agentpaas/api/app.py

@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks, assistant
 from agentpaas.observability.logging import logger
 
 # 改进⑦ (AUDIT_2026-06-11): on_event("startup") 在 FastAPI 中已弃用,
@@ -219,6 +219,7 @@ app.include_router(providers_api.router, prefix="/api/v1")
 app.include_router(templates_api.router, prefix="/api/v1")
 app.include_router(knowledge_router.router, prefix="/api/v1")
 app.include_router(agentpacks.router, prefix="/api/v1")
+app.include_router(assistant.router, prefix="/api/v1")
 
 
 async def _on_startup():

+ 130 - 0
agentpaas/src/agentpaas/api/v1/assistant.py

@@ -0,0 +1,130 @@
+"""
+api.v1.assistant — 工作台 AI 助手入口的意图路由(方案 B)。
+
+POST /api/v1/assistant/route  {input}
+  → 用一次轻量 LLM 分类把用户的自然语言请求分发给最合适的智能体。
+    返回 {matched, agent_id, agent_name, reason};分类不可用(没配
+    云端 key 且本地 ollama 不在)或拿不准时 matched=false,前端回退
+    到手动选择器(方案 A 动线)— 路由永远只是加速器,不是单点。
+
+分类模型选择:dashscope qwen-turbo(几乎免费)> 本地 ollama > 不可用。
+分类调用走 lambdagent provider(与 pipeline default_judge 同一模式),
+usage 随响应返回,成本可审计。
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+from typing import Callable, Optional
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+
+from agentpaas.api.deps import get_tenant, get_database
+from agentpaas.api.middleware.auth import TenantContext
+from agentpaas.db.models import Database
+
+router = APIRouter(prefix="/assistant", tags=["assistant"])
+
+_MAX_INPUT_CHARS = 500
+_MAX_CANDIDATES = 12
+
+_CLASSIFY_SYS = (
+    "你是智能体调度员。根据用户请求,从候选智能体列表中选出最合适的一个。\n"
+    "只输出一个 JSON:{\"choice\": <编号>, \"reason\": \"一句话理由\"}。\n"
+    "都不合适或拿不准时输出 {\"choice\": 0}。不要输出其他内容。"
+)
+
+
+def _pick_classifier_model() -> Optional[dict]:
+    """选分类模型:dashscope qwen-turbo > 本地 ollama > None(不可用)。"""
+    if os.environ.get("DASHSCOPE_API_KEY"):
+        return {"provider": "dashscope", "name": "qwen-turbo"}
+    try:
+        import urllib.request
+        with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=2) as r:
+            models = [m["name"] for m in json.loads(r.read()).get("models", [])]
+        if models:
+            name = next((m for m in models if m.startswith("qwen2.5:")), models[0])
+            return {"provider": "ollama", "name": name}
+    except Exception:
+        pass
+    return None
+
+
+def _llm_classify(user_input: str, candidates: list, model: dict) -> tuple:
+    """返回 (choice_index_1based_or_0, reason, usage)。异常向上抛。"""
+    from lambdagent.providers import create_provider, ChatMessage
+
+    listing = "\n".join(
+        f"{i}. {c['name']} — {c['description'][:80]}"
+        for i, c in enumerate(candidates, 1))
+    user = (f"用户请求:{user_input[:_MAX_INPUT_CHARS]}\n\n"
+            f"候选智能体:\n{listing}")
+    kwargs: dict = {"timeout": 30}
+    if model.get("name"):
+        kwargs["model"] = model["name"]
+    p = create_provider(model["provider"], **kwargs)
+    resp = p.chat_typed(
+        [ChatMessage(role="system", content=_CLASSIFY_SYS),
+         ChatMessage(role="user", content=user)],
+        temperature=0.0, max_tokens=128)
+    usage = {"provider": model["provider"],
+             "input_tokens": getattr(resp, "input_tokens", 0),
+             "output_tokens": getattr(resp, "output_tokens", 0)}
+
+    text = resp.text.strip()
+    m = re.search(r'\{[^{}]*"choice"[^{}]*\}', text)
+    if m:
+        try:
+            d = json.loads(m.group(0))
+            return int(d.get("choice", 0)), str(d.get("reason", ""))[:200], usage
+        except Exception:
+            pass
+    m = re.search(r"\d+", text)  # 退化:模型只回了个数字
+    return (int(m.group(0)) if m else 0), "", usage
+
+
+# 可注入的分类函数(测试用):(input, candidates, model) -> (choice, reason, usage)
+_classify_fn: Callable = _llm_classify
+
+
+class RouteRequest(BaseModel):
+    input: str = Field(..., min_length=1, description="用户的自然语言请求")
+
+
+@router.post("/route")
+async def route_request(
+    req: RouteRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """把自然语言请求路由到最合适的智能体(拿不准则 matched=false)。"""
+    agents = db.fetchall(
+        "SELECT id, name, description FROM agents "
+        "WHERE tenant_id = ? AND status = 'active' "
+        "ORDER BY updated_at DESC LIMIT ?",
+        (tenant.tenant_id, _MAX_CANDIDATES))
+    if not agents:
+        return {"matched": False, "reason": "no agents"}
+    if len(agents) == 1:
+        return {"matched": True, "agent_id": agents[0]["id"],
+                "agent_name": agents[0]["name"], "reason": "唯一智能体", "usage": None}
+
+    model = _pick_classifier_model()
+    if model is None:
+        return {"matched": False, "reason": "no classifier model available"}
+
+    candidates = [{"name": a["name"], "description": a.get("description") or ""}
+                  for a in agents]
+    try:
+        choice, reason, usage = _classify_fn(req.input, candidates, model)
+    except Exception as e:
+        return {"matched": False, "reason": f"classify failed: {e}"}
+
+    if not (1 <= choice <= len(agents)):
+        return {"matched": False, "reason": reason or "分类器拿不准", "usage": usage}
+    picked = agents[choice - 1]
+    return {"matched": True, "agent_id": picked["id"],
+            "agent_name": picked["name"], "reason": reason, "usage": usage}

+ 33 - 0
agentpaas/src/agentpaas/api/v1/status.py

@@ -16,6 +16,39 @@ from agentpaas.db.models import Database
 router = APIRouter(prefix="/status", tags=["status"])
 
 
+@router.get("/recent-runs")
+async def recent_runs(
+    limit: int = Query(default=5, le=20),
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """跨智能体的最近运行(工作台「继续上次的工作」数据源)。
+
+    带智能体名与 workspace,输入截断到 120 字。运行中的排最前。
+    """
+    rows = db.fetchall(
+        "SELECT r.id, r.agent_id, a.name AS agent_name, r.status, r.input, "
+        "r.workspace_path, r.created_at, r.completed_at, r.duration_ms "
+        "FROM runs r JOIN agents a ON a.id = r.agent_id "
+        "WHERE r.tenant_id = ? AND a.status = 'active' "
+        "ORDER BY (r.status = 'running') DESC, r.created_at DESC LIMIT ?",
+        (tenant.tenant_id, limit),
+    )
+    items = []
+    for r in rows:
+        items.append({
+            "run_id": r["id"],
+            "agent_id": r["agent_id"],
+            "agent_name": r["agent_name"],
+            "status": r["status"],
+            "input_preview": (r.get("input") or "")[:120],
+            "workspace_path": r.get("workspace_path") or "",
+            "created_at": r.get("created_at"),
+            "completed_at": r.get("completed_at"),
+        })
+    return {"items": items}
+
+
 @router.get("")
 async def platform_status(
     tenant: TenantContext = Depends(get_tenant),

+ 176 - 0
tests/test_assistant.py

@@ -0,0 +1,176 @@
+"""
+tests/test_assistant.py — 工作台 AI 助手入口(方案 A+B 后端)。
+
+覆盖:
+- GET /status/recent-runs: 跨智能体最近运行、运行中排最前、租户隔离。
+- POST /assistant/route: 唯一智能体直返、分类命中、拿不准回退、
+  分类器异常降级、choice 越界保护(注入 fake 分类器,零 LLM 调用)。
+"""
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("AGENTPAAS_TESTING", "1")
+
+
+@pytest.fixture()
+def api_client(tmp_path, monkeypatch):
+    import secrets
+    from fastapi.testclient import TestClient
+    from agentpaas.api.app import app
+    from agentpaas.api.middleware.auth import hash_key
+    from agentpaas.config import settings
+    from agentpaas.db.models import Database, gen_id, now_utc
+    import agentpaas.db.session as _session_mod
+
+    monkeypatch.setattr(settings, "workspace_base", str(tmp_path / "Workspace"))
+    prev = _session_mod._db
+    _session_mod._db = Database("sqlite:///:memory:")
+    db = _session_mod._db
+    tid, uid = gen_id("tn_"), gen_id("usr_")
+    raw_key = f"ap_{secrets.token_hex(16)}"
+    now = now_utc()
+    db.execute("INSERT INTO tenants (id, name, plan, status, created_at) "
+               "VALUES (?, 'test', 'free', 'active', ?)", (tid, now))
+    db.execute("INSERT INTO users (id, tenant_id, email, role, created_at) "
+               "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now))
+    db.execute(
+        "INSERT INTO api_keys (id, tenant_id, user_id, key_hash, key_prefix, name, "
+        "scopes, rate_limit, status, created_at) "
+        "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
+        (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
+         json.dumps(["agents:*"]), now))
+    db.commit()
+    with TestClient(app) as client:
+        yield client, raw_key, tid, db
+    _session_mod._db = prev
+
+
+def _auth(k):
+    return {"Authorization": f"Bearer {k}"}
+
+
+def _mk_agent(client, key, name, desc=""):
+    cfg = {"name": name, "type": "simple",
+           "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "t"}
+    r = client.post("/api/v1/agents", headers=_auth(key),
+                    json={"name": name, "description": desc, "config": cfg})
+    assert r.status_code in (200, 201), r.text[:200]
+    return r.json()["agent_id"]
+
+
+# ── recent-runs ──────────────────────────────────────────────────────────────
+
+def test_recent_runs_running_first_and_truncated(api_client):
+    from agentpaas.db.models import gen_id, now_utc
+    client, key, tid, db = api_client
+    aid = _mk_agent(client, key, "甲")
+    now = now_utc()
+    for i, status in enumerate(["completed", "running", "failed"]):
+        db.execute(
+            "INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, "
+            "status, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)",
+            (gen_id("run_"), aid, tid, f"输入{i}" + "长" * 200, status, now))
+    db.commit()
+
+    r = client.get("/api/v1/status/recent-runs?limit=10", headers=_auth(key))
+    assert r.status_code == 200
+    items = r.json()["items"]
+    assert len(items) == 3
+    assert items[0]["status"] == "running"          # 运行中排最前
+    assert items[0]["agent_name"] == "甲"
+    assert len(items[0]["input_preview"]) <= 120    # 截断
+
+
+def test_recent_runs_tenant_scoped(api_client):
+    from agentpaas.db.models import gen_id, now_utc
+    client, key, tid, db = api_client
+    # 别的租户的 run 不可见
+    other_t = gen_id("tn_")
+    db.execute("INSERT INTO tenants (id, name, plan, status, created_at) "
+               "VALUES (?, 'o', 'free', 'active', ?)", (other_t, now_utc()))
+    other_a = gen_id("ag_")
+    db.execute("INSERT INTO agents (id, tenant_id, name, current_version, status, "
+               "created_at, updated_at) VALUES (?, ?, 'x', 1, 'active', ?, ?)",
+               (other_a, other_t, now_utc(), now_utc()))
+    db.execute("INSERT INTO runs (id, agent_id, agent_version, tenant_id, input, "
+               "status, created_at) VALUES (?, ?, 1, ?, 'i', 'completed', ?)",
+               (gen_id("run_"), other_a, other_t, now_utc()))
+    db.commit()
+    r = client.get("/api/v1/status/recent-runs", headers=_auth(key))
+    assert r.json()["items"] == []
+
+
+# ── assistant/route ──────────────────────────────────────────────────────────
+
+def test_route_single_agent_no_llm(api_client):
+    client, key, *_ = api_client
+    aid = _mk_agent(client, key, "唯一助手")
+    r = client.post("/api/v1/assistant/route", headers=_auth(key),
+                    json={"input": "随便什么任务"})
+    assert r.status_code == 200
+    d = r.json()
+    assert d["matched"] is True and d["agent_id"] == aid
+
+
+def test_route_classifier_picks(api_client, monkeypatch):
+    from agentpaas.api.v1 import assistant as mod
+    client, key, *_ = api_client
+    _mk_agent(client, key, "审稿助手", "论文评审")
+    aid_exam = _mk_agent(client, key, "试卷助手", "出试卷与题库")
+
+    monkeypatch.setattr(mod, "_pick_classifier_model",
+                        lambda: {"provider": "fake", "name": "f"})
+    seen = {}
+
+    def fake_classify(user_input, candidates, model):
+        seen["input"] = user_input
+        seen["names"] = [c["name"] for c in candidates]
+        # 选「试卷助手」
+        idx = next(i for i, c in enumerate(candidates, 1) if c["name"] == "试卷助手")
+        return idx, "出题类请求", {"input_tokens": 50, "output_tokens": 10}
+
+    monkeypatch.setattr(mod, "_classify_fn", fake_classify)
+    r = client.post("/api/v1/assistant/route", headers=_auth(key),
+                    json={"input": "给数据结构出一份期末卷"})
+    d = r.json()
+    assert d["matched"] is True and d["agent_id"] == aid_exam
+    assert d["reason"] == "出题类请求"
+    assert "试卷助手" in seen["names"] and "期末卷" in seen["input"]
+
+
+def test_route_unsure_and_out_of_range(api_client, monkeypatch):
+    from agentpaas.api.v1 import assistant as mod
+    client, key, *_ = api_client
+    _mk_agent(client, key, "甲")
+    _mk_agent(client, key, "乙")
+    monkeypatch.setattr(mod, "_pick_classifier_model",
+                        lambda: {"provider": "fake", "name": "f"})
+    # choice=0 拿不准
+    monkeypatch.setattr(mod, "_classify_fn", lambda *a: (0, "都不像", None))
+    assert client.post("/api/v1/assistant/route", headers=_auth(key),
+                       json={"input": "x"}).json()["matched"] is False
+    # choice 越界
+    monkeypatch.setattr(mod, "_classify_fn", lambda *a: (99, "", None))
+    assert client.post("/api/v1/assistant/route", headers=_auth(key),
+                       json={"input": "x"}).json()["matched"] is False
+    # 分类器抛异常 → 降级 matched=false 而非 500
+    def boom(*a):
+        raise RuntimeError("provider down")
+    monkeypatch.setattr(mod, "_classify_fn", boom)
+    r = client.post("/api/v1/assistant/route", headers=_auth(key), json={"input": "x"})
+    assert r.status_code == 200 and r.json()["matched"] is False
+
+
+def test_route_no_classifier_model(api_client, monkeypatch):
+    from agentpaas.api.v1 import assistant as mod
+    client, key, *_ = api_client
+    _mk_agent(client, key, "甲")
+    _mk_agent(client, key, "乙")
+    monkeypatch.setattr(mod, "_pick_classifier_model", lambda: None)
+    r = client.post("/api/v1/assistant/route", headers=_auth(key), json={"input": "x"})
+    assert r.json()["matched"] is False

+ 2 - 0
webui/src/api/agents.ts

@@ -14,6 +14,8 @@ export interface Agent {
   run_dir: string
   kb_ids: string[]
   kb_search_mode: string
+  /** 创建来源的内置包 id(如 teaching.exam-builder),手建智能体为空 */
+  agent_template?: string
   created_at: string
   updated_at: string
 }

+ 17 - 0
webui/src/api/status.ts

@@ -22,7 +22,24 @@ export interface AgentStatus {
   last_run: string | null
 }
 
+export interface RecentRun {
+  run_id: string
+  agent_id: string
+  agent_name: string
+  status: string
+  input_preview: string
+  workspace_path: string
+  created_at: string | null
+  completed_at: string | null
+}
+
 export const statusApi = {
   platform: () => api.get<PlatformStatus>('/status'),
   agents: () => api.get<{ agents: AgentStatus[]; count: number }>('/status/agents'),
+  /** 跨智能体最近运行(工作台「继续上次的工作」) */
+  recentRuns: (limit = 5) => api.get<{ items: RecentRun[] }>(`/status/recent-runs?limit=${limit}`),
+  /** AI 助手入口:把自然语言请求路由到最合适的智能体(方案 B) */
+  assistantRoute: (input: string) =>
+    api.post<{ matched: boolean; agent_id?: string; agent_name?: string; reason?: string }>(
+      '/assistant/route', { input }),
 }

+ 5 - 1
webui/src/pages/Chat.tsx

@@ -1038,7 +1038,11 @@ export default function Chat() {
 
   const [messages, setMessages]       = useState<Message[]>([])
   const [historyLoaded, setHistoryLoaded] = useState(false)
-  const [input, setInput]             = useState('')
+  // 工作台 AI 入口深链:/agents/{id}/chat?q=... 把首页输入的请求带过来预填
+  const [input, setInput]             = useState(() => {
+    const q = new URLSearchParams(window.location.search).get('q')
+    return q ?? ''
+  })
   const [streaming, setStreaming]     = useState(false)
   const [memoryOpen, setMemoryOpen]   = useState(false)
   const abortRef  = useRef<(() => void) | null>(null)

+ 238 - 199
webui/src/pages/Dashboard.tsx

@@ -1,224 +1,263 @@
-import { useQuery } from '@tanstack/react-query'
+/**
+ * Dashboard — 今日工作台(方案 A+B 改造版)。
+ *
+ * 原版是平台运维视角(统计卡 + 运行状态表),对 desktop 单用户无用。
+ * 现在的信息架构按使用频率排:
+ *   1. AI 助手入口(主角):自然语言输入 → /assistant/route 意图路由
+ *      (方案 B,轻量 LLM 分类)→ 命中则直达该智能体对话并预填输入;
+ *      路由不可用/拿不准 → 回退手动选择器(方案 A 动线,永远可用)。
+ *   2. 继续上次的工作:跨智能体最近运行(运行中的排最前)。
+ *   3. 常用场景卡片:内置包典型任务,点击直达(无实例则先创建)。
+ *   4. 统计降级为页脚一行字。
+ */
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
 import { useNavigate } from 'react-router-dom'
-import { Bot, Play, CheckCircle2, AlertCircle, Zap, MessageSquare } from 'lucide-react'
+import {
+  ArrowRight, Bot, FileSearch, ClipboardList, Mail, NotebookPen,
+  Loader2, MessageSquare, Route, X,
+} from 'lucide-react'
+import toast from 'react-hot-toast'
 import { statusApi } from '../api/status'
-import { agentsApi } from '../api/agents'
-import { Card, StatCard, PageHeader, Badge, HealthDot, Spinner, EmptyState } from '../components/ui'
-
-function relativeTime(iso: string | null) {
-  if (!iso) return '—'
-  const diff = Date.now() - new Date(iso).getTime()
-  const m = Math.floor(diff / 60_000)
-  if (m < 1) return '刚刚'
-  if (m < 60) return `${m} 分钟前`
-  const h = Math.floor(m / 60)
-  if (h < 24) return `${h} 小时前`
-  return `${Math.floor(h / 24)} 天前`
+import { agentsApi, type Agent } from '../api/agents'
+import { agentpacksApi } from '../api/agentpacks'
+import { Card, Spinner, Badge } from '../components/ui'
+
+// 常用场景 → 内置包映射(包未安装时卡片自动隐藏)
+const SCENARIOS = [
+  { packId: 'research.top-journal-reviewer', icon: FileSearch,
+    title: '审一篇论文', subtitle: '顶刊水准评审报告' },
+  { packId: 'teaching.exam-builder', icon: ClipboardList,
+    title: '出一份试卷', subtitle: '细目表 + A/B 卷' },
+  { packId: 'service.academic-letters', icon: Mail,
+    title: '写推荐信', subtitle: '中英文、事例驱动' },
+  { packId: 'teaching.course-designer', icon: NotebookPen,
+    title: '设计一门课', subtitle: '大纲 + 日历 + 教案' },
+]
+
+function greeting(): string {
+  const h = new Date().getHours()
+  if (h < 6) return '夜深了'
+  if (h < 12) return '早上好'
+  if (h < 18) return '下午好'
+  return '晚上好'
 }
 
-function healthBadge(level: string) {
-  const map: Record<string, 'green' | 'yellow' | 'red' | 'default'> = {
-    healthy: 'green',
-    degraded: 'yellow',
-    warning: 'yellow',
-    critical: 'red',
-    no_data: 'default',
-  }
-  const labels: Record<string, string> = {
-    healthy: '健康',
-    degraded: '轻微异常',
-    warning: '警告',
-    critical: '严重',
-    no_data: '无数据',
-  }
-  return <Badge variant={map[level] ?? 'default'}>{labels[level] ?? level}</Badge>
+function runStatusBadge(status: string) {
+  if (status === 'running') return <Badge variant="yellow">运行中</Badge>
+  if (status === 'completed') return <Badge variant="green">已完成</Badge>
+  if (status === 'cancelled') return <Badge variant="default">已取消</Badge>
+  return <Badge variant="red">失败</Badge>
 }
 
 export default function Dashboard() {
   const navigate = useNavigate()
+  const qc = useQueryClient()
+  const [input, setInput] = useState('')
+  const [routing, setRouting] = useState(false)
+  const [pickerOpen, setPickerOpen] = useState(false)
+  const [creatingPack, setCreatingPack] = useState<string | null>(null)
 
-  const { data: status, isLoading: loadingStatus, isError } = useQuery({
-    queryKey: ['status'],
-    queryFn: statusApi.platform,
-    refetchInterval: 15_000,
-    retry: false,
+  const { data: agentData } = useQuery({
+    queryKey: ['agents'], queryFn: agentsApi.list, retry: false,
   })
-
-  const { data: agentStatus, isLoading: loadingAgents } = useQuery({
-    queryKey: ['status-agents'],
-    queryFn: statusApi.agents,
-    refetchInterval: 15_000,
-    retry: false,
+  const { data: recent } = useQuery({
+    queryKey: ['recent-runs'], queryFn: () => statusApi.recentRuns(5),
+    retry: false, refetchInterval: 15000,
   })
-
-  const { data: agentList } = useQuery({
-    queryKey: ['agents'],
-    queryFn: agentsApi.list,
-    retry: false,
+  const { data: platform } = useQuery({
+    queryKey: ['platform-status'], queryFn: statusApi.platform, retry: false,
+  })
+  const { data: packData } = useQuery({
+    queryKey: ['agentpacks'], queryFn: agentpacksApi.list, retry: false,
   })
 
-  const loading = loadingStatus || loadingAgents
+  const agents = agentData?.agents ?? []
+  const installedPacks = new Set((packData?.agentpacks ?? []).map(p => p.id))
+
+  const gotoChat = (agentId: string, q?: string) =>
+    navigate(`/chat/${agentId}${q ? `?q=${encodeURIComponent(q)}` : ''}`)
+
+  // ── AI 助手入口提交:先试意图路由(B),失败回退手动选择(A)──
+  async function submit() {
+    const q = input.trim()
+    if (!q) return
+    if (agents.length === 0) {
+      toast('先创建一个智能体(试试下方场景卡片)')
+      return
+    }
+    setRouting(true)
+    try {
+      const r = await statusApi.assistantRoute(q)
+      if (r.matched && r.agent_id) {
+        toast.success(`交给「${r.agent_name}」${r.reason ? ` — ${r.reason}` : ''}`,
+          { duration: 3500 })
+        gotoChat(r.agent_id, q)
+        return
+      }
+    } catch { /* 路由不可用 → 手动选择 */ }
+    finally { setRouting(false) }
+    setPickerOpen(true)
+  }
+
+  // ── 场景卡片点击:已有该包实例 → 直达;没有 → 创建后直达 ──
+  async function openScenario(packId: string, title: string) {
+    const existing = agents
+      .filter(a => a.agent_template === packId)
+      .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))[0]
+    if (existing) { gotoChat(existing.id); return }
+    setCreatingPack(packId)
+    try {
+      const pack = (packData?.agentpacks ?? []).find(p => p.id === packId)
+      const res = await agentpacksApi.createAgent(packId, { name: pack?.name || title })
+      toast.success(`已创建「${res.name}」`)
+      qc.invalidateQueries({ queryKey: ['agents'] })
+      gotoChat(res.agent_id)
+    } catch (e) {
+      toast.error(e instanceof Error ? e.message : '创建失败')
+    } finally {
+      setCreatingPack(null)
+    }
+  }
+
+  const scenarios = SCENARIOS.filter(s => installedPacks.has(s.packId))
 
   return (
-    <div className="p-8 max-w-6xl mx-auto">
-      <PageHeader
-        title="仪表盘"
-        description="平台运行概览"
-        action={
-          <button
-            onClick={() => navigate('/agents/new')}
-            className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors"
-          >
-            <Bot size={16} />
-            新建智能体
-          </button>
-        }
-      />
-
-      {isError ? (
-        <div className="rounded-xl border border-yellow-200 bg-yellow-50 p-6 text-sm text-yellow-800">
-          <p className="font-medium mb-1">无法连接到后端服务</p>
-          <p className="text-yellow-700">请先启动:
-            <code className="ml-1 bg-yellow-100 px-1.5 py-0.5 rounded font-mono text-xs">agentpaas serve --dev</code>
-          </p>
-        </div>
-      ) : loading ? (
-        <div className="flex justify-center py-16"><Spinner size={32} /></div>
-      ) : (
-        <>
-          {/* Stat cards */}
-          <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
-            <StatCard
-              label="活跃智能体"
-              value={status?.agents_active ?? 0}
-              icon={<Bot size={22} />}
-            />
-            <StatCard
-              label="总运行次数"
-              value={status?.runs_total ?? 0}
-              sub={`成功率 ${status?.success_rate ?? '—'}`}
-              icon={<Play size={22} />}
-            />
-            <StatCard
-              label="平均延迟"
-              value={status?.avg_latency_ms ? `${status.avg_latency_ms} ms` : '—'}
-              icon={<Zap size={22} />}
-            />
-            <StatCard
-              label="累计 Token"
-              value={
-                status?.total_tokens
-                  ? status.total_tokens > 1_000_000
-                    ? `${(status.total_tokens / 1_000_000).toFixed(1)}M`
-                    : `${Math.round(status.total_tokens / 1000)}K`
-                  : '0'
-              }
-              icon={<MessageSquare size={22} />}
-            />
+    <div className="p-8 max-w-3xl mx-auto">
+      {/* ── 1. AI 助手入口(主角)── */}
+      <div className="mb-8">
+        <h1 className="text-xl font-semibold text-gray-900 mb-1">{greeting()}</h1>
+        <p className="text-sm text-gray-500 mb-4">今天想做什么?描述你的任务,我来找合适的智能体。</p>
+        <div className="bg-white border border-gray-200 rounded-2xl shadow-sm focus-within:border-indigo-300 focus-within:ring-2 focus-within:ring-indigo-100 transition-all">
+          <textarea
+            value={input}
+            onChange={e => setInput(e.target.value)}
+            onKeyDown={e => {
+              if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() }
+            }}
+            placeholder="例如:帮我审这篇论文 ~/Papers/draft.pdf,目标期刊 TSE…"
+            rows={2}
+            className="w-full px-4 pt-3 pb-1 text-sm resize-none focus:outline-none rounded-2xl"
+          />
+          <div className="flex items-center justify-between px-4 pb-3">
+            <span className="text-[11px] text-gray-400 flex items-center gap-1">
+              <Route size={12} /> 自动分发给合适的智能体(也可手动选择)
+            </span>
+            <button
+              onClick={submit}
+              disabled={!input.trim() || routing}
+              className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-200 disabled:text-gray-400 text-white text-sm px-4 py-1.5 rounded-xl transition-colors"
+            >
+              {routing ? <Loader2 size={14} className="animate-spin" /> : <ArrowRight size={14} />}
+              {routing ? '分析中' : '开始'}
+            </button>
           </div>
+        </div>
+      </div>
 
-          {/* Agent status table */}
-          <Card padding={false} className="overflow-hidden">
-            <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
-              <h2 className="text-sm font-semibold text-gray-900">智能体运行状态</h2>
-              <span className="text-xs text-gray-400">每 15 秒自动刷新</span>
-            </div>
-
-            {!agentStatus?.agents?.length ? (
-              <EmptyState
-                icon={<Bot size={40} />}
-                title="还没有智能体"
-                description="创建第一个智能体开始体验"
-                action={
-                  <button
-                    onClick={() => navigate('/agents/new')}
-                    className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
-                  >
-                    新建智能体
-                  </button>
-                }
-              />
-            ) : (
-              <div className="overflow-x-auto">
-                <table className="w-full text-sm">
-                  <thead className="bg-gray-50 border-b border-gray-100">
-                    <tr>
-                      {['名称', '状态', '运行次数', '成功率', '平均延迟', '最近运行', '操作'].map(h => (
-                        <th
-                          key={h}
-                          className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide"
-                        >
-                          {h}
-                        </th>
-                      ))}
-                    </tr>
-                  </thead>
-                  <tbody className="divide-y divide-gray-50">
-                    {agentStatus.agents.map(a => (
-                      <tr key={a.agent_id} className="hover:bg-gray-50 transition-colors">
-                        <td className="px-6 py-4 font-medium text-gray-900">{a.name}</td>
-                        <td className="px-6 py-4">
-                          <div className="flex items-center gap-1.5">
-                            <HealthDot level={a.health.level} />
-                            {healthBadge(a.health.level)}
-                          </div>
-                        </td>
-                        <td className="px-6 py-4 text-gray-600">{a.runs_total}</td>
-                        <td className="px-6 py-4 text-gray-600">{a.success_rate}</td>
-                        <td className="px-6 py-4 text-gray-600">
-                          {a.avg_latency_ms ? `${a.avg_latency_ms} ms` : '—'}
-                        </td>
-                        <td className="px-6 py-4 text-gray-500 text-xs">{relativeTime(a.last_run)}</td>
-                        <td className="px-6 py-4">
-                          <button
-                            onClick={() => navigate(`/chat/${a.agent_id}`)}
-                            className="text-indigo-600 hover:text-indigo-800 text-xs font-medium"
-                          >
-                            对话
-                          </button>
-                        </td>
-                      </tr>
-                    ))}
-                  </tbody>
-                </table>
-              </div>
-            )}
+      {/* ── 2. 继续上次的工作 ── */}
+      {(recent?.items?.length ?? 0) > 0 && (
+        <section className="mb-8">
+          <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
+            继续上次的工作
+          </h2>
+          <Card className="divide-y divide-gray-100 p-0 overflow-hidden">
+            {recent!.items.map(r => (
+              <button
+                key={r.run_id}
+                onClick={() => gotoChat(r.agent_id)}
+                className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors text-left"
+              >
+                <MessageSquare size={15} className="text-gray-400 shrink-0" />
+                <span className="text-sm font-medium text-gray-900 shrink-0">{r.agent_name}</span>
+                <span className="text-xs text-gray-400 truncate flex-1">{r.input_preview}</span>
+                {runStatusBadge(r.status)}
+                <ArrowRight size={13} className="text-gray-300 shrink-0" />
+              </button>
+            ))}
           </Card>
+        </section>
+      )}
 
-          {/* Quick actions */}
-          {!!agentList?.agents?.length && (
-            <div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
-              <Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => navigate('/agents')}>
-                <div className="flex items-center gap-4">
-                  <div className="p-2 bg-indigo-50 rounded-lg">
-                    <Bot size={20} className="text-indigo-600" />
-                  </div>
-                  <div>
-                    <p className="text-sm font-medium text-gray-900">管理智能体</p>
-                    <p className="text-xs text-gray-500">创建、编辑、删除智能体</p>
-                  </div>
-                </div>
-              </Card>
+      {/* ── 3. 常用场景 ── */}
+      {scenarios.length > 0 && (
+        <section className="mb-8">
+          <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
+            常用场景
+          </h2>
+          <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
+            {scenarios.map(s => {
+              const Icon = s.icon
+              const busy = creatingPack === s.packId
+              return (
+                <button
+                  key={s.packId}
+                  onClick={() => openScenario(s.packId, s.title)}
+                  disabled={busy}
+                  className="bg-white border border-gray-200 rounded-xl p-3.5 text-left hover:border-indigo-300 hover:shadow-sm transition-all disabled:opacity-60"
+                >
+                  {busy
+                    ? <Loader2 size={18} className="text-indigo-500 animate-spin" />
+                    : <Icon size={18} className="text-indigo-500" />}
+                  <p className="text-sm font-medium text-gray-900 mt-2">{s.title}</p>
+                  <p className="text-xs text-gray-400 mt-0.5">{s.subtitle}</p>
+                </button>
+              )
+            })}
+          </div>
+        </section>
+      )}
 
-              <Card
-                className="hover:shadow-md transition-shadow cursor-pointer"
-                onClick={() => navigate(`/chat/${agentList.agents[0].id}`)}
-              >
-                <div className="flex items-center gap-4">
-                  <div className="p-2 bg-green-50 rounded-lg">
-                    <MessageSquare size={20} className="text-green-600" />
-                  </div>
-                  <div>
-                    <p className="text-sm font-medium text-gray-900">开始对话</p>
-                    <p className="text-xs text-gray-500">
-                      与「{agentList.agents[0].name}」对话
-                    </p>
+      {/* 全空态:没有任何智能体也没有场景包 */}
+      {agents.length === 0 && scenarios.length === 0 && (
+        <Card className="text-center py-10 mb-8">
+          <Bot size={36} className="mx-auto text-gray-300 mb-3" />
+          <p className="text-sm text-gray-500">还没有智能体 —— 到「智能体包」页用内置包一键创建</p>
+        </Card>
+      )}
+
+      {/* ── 4. 统计页脚 ── */}
+      {platform && (
+        <p className="text-xs text-gray-400 text-right">
+          累计 {platform.runs_total} 次运行 · {(platform.total_tokens / 10000).toFixed(1)} 万 token
+          <button onClick={() => navigate('/providers')} className="ml-2 text-indigo-400 hover:text-indigo-600">
+            模型与用量 →
+          </button>
+        </p>
+      )}
+
+      {/* ── 手动选择器(路由不可用/拿不准时的回退)── */}
+      {pickerOpen && (
+        <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setPickerOpen(false)}>
+          <div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-5" onClick={e => e.stopPropagation()}>
+            <div className="flex items-center justify-between mb-3">
+              <h3 className="text-sm font-semibold text-gray-900">交给哪个智能体?</h3>
+              <button onClick={() => setPickerOpen(false)} className="text-gray-300 hover:text-gray-500">
+                <X size={16} />
+              </button>
+            </div>
+            <div className="max-h-80 overflow-y-auto divide-y divide-gray-100">
+              {agents.map((a: Agent) => (
+                <button
+                  key={a.id}
+                  onClick={() => { setPickerOpen(false); gotoChat(a.id, input.trim()) }}
+                  className="w-full flex items-center gap-3 px-2 py-2.5 hover:bg-gray-50 rounded-lg text-left"
+                >
+                  <Bot size={15} className="text-indigo-400 shrink-0" />
+                  <div className="min-w-0">
+                    <p className="text-sm font-medium text-gray-900 truncate">{a.name}</p>
+                    {a.description && (
+                      <p className="text-xs text-gray-400 truncate">{a.description}</p>
+                    )}
                   </div>
-                </div>
-              </Card>
+                </button>
+              ))}
+              {agents.length === 0 && (
+                <div className="py-6 text-center"><Spinner /></div>
+              )}
             </div>
-          )}
-        </>
+          </div>
+        </div>
       )}
     </div>
   )