Forráskód Böngészése

feat(webui): AgentEdit Guard tab + 包详情预填 + Chat 孤儿 run 兜底

- AgentEdit.tsx: 新增「验收 & 安全」tab (validator/retry/fallback +
  dangerousCommandBlock/highRiskConfirmation/maxOutputLength)
- agentpacks.py API: 包详情返回 entrypoint_config, 前端高级定制表单
  免二次请求; setup.py: data-dir 配置端点
- Chat.tsx (审计⑥): SSE 流无终态事件结束/网络异常时, 拉
  GET /traces/{run_id} 真实终态收尾气泡 — 后端重启留下的孤儿 run
  不再永久转圈; 配合后端启动清扫
- AgentImport/AgentPacks/Agents/Sidebar/SetupWizard: 模板导入与包管理
  页面改进

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 3 hónapja
szülő
commit
02e55b7e63

+ 48 - 8
agentpaas/src/agentpaas/api/v1/agentpacks.py

@@ -14,8 +14,9 @@ concern, same posture as /setup/data-dir); auth-gated in lab/paas.
 """
 from __future__ import annotations
 
-from typing import Optional
+from typing import Any, Dict, Optional
 
+import yaml
 from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
 from pydantic import BaseModel, Field
 
@@ -34,9 +35,9 @@ from lambdagent.agentpack import AgentPackError
 router = APIRouter(prefix="/agentpacks", tags=["agentpacks"])
 
 
-def _pack_json(p) -> dict:
+def _pack_json(p, include_config: bool = False) -> dict:
     m = p.manifest
-    return {
+    result: dict = {
         "id": p.id,
         "version": p.version,
         "name": p.name,
@@ -49,6 +50,13 @@ def _pack_json(p) -> dict:
         "model_recommended": m.model_recommended,
         "entrypoint": m.entrypoint,
     }
+    if include_config:
+        try:
+            with open(m.entrypoint_path(), encoding="utf-8") as _f:
+                result["entrypoint_config"] = yaml.safe_load(_f) or {}
+        except Exception:
+            result["entrypoint_config"] = {}
+    return result
 
 
 class InstallRequest(BaseModel):
@@ -73,11 +81,16 @@ async def get_agentpack(
     version: Optional[str] = None,
     tenant: TenantContext = Depends(get_tenant),
 ):
-    """Detail for one installed pack (newest version unless ?version=)."""
+    """Detail for one installed pack (newest version unless ?version=).
+
+    Includes ``entrypoint_config`` — the parsed entrypoint YAML — so the
+    webui can pre-fill the advanced-customisation form without a second
+    round-trip.
+    """
     p = get_installed(settings.agentpacks_dir, pack_id, version)
     if not p:
         raise HTTPException(status_code=404, detail="AgentPack not found")
-    return _pack_json(p)
+    return _pack_json(p, include_config=True)
 
 
 def _loopback_only(request: Request) -> None:
@@ -184,6 +197,22 @@ class CreateAgentFromPackRequest(BaseModel):
         description="Knowledge bases to link to this agent")
     kb_search_mode: str = Field(default="bm25")
     tags: list = Field(default_factory=list)
+    # ── Advanced customisation ─────────────────────────────────────────
+    # These fields let the caller override specific parts of the pack's
+    # entrypoint config without forking the whole pack.
+    model_override: Optional[Dict[str, Any]] = Field(
+        default=None,
+        description='Override model fields, e.g. {"provider":"anthropic","name":"claude-3-5-sonnet"}',
+    )
+    system_prompt_override: Optional[str] = Field(
+        default=None,
+        max_length=32768,
+        description="Replace the pack systemPrompt entirely. None = use pack default.",
+    )
+    # 3-directory config (same semantics as CreateAgentRequest)
+    work_dir: str = Field(default="", description="工作产物目录: Bash tool CWD")
+    source_dir: str = Field(default="", description="数据来源目录: injected into system prompt preamble")
+    run_dir: str = Field(default="", description="运行日志目录: run workspace base")
 
 
 @router.post("/{pack_id}/create-agent", status_code=201)
@@ -223,6 +252,16 @@ async def create_agent_from_pack(
     # fix at compiler.py:219; explicit cfg._config_dir trumps inference).
     config.setdefault("_config_dir", pack.path)
 
+    # ── Apply caller-supplied overrides (advanced customisation) ───────
+    if req.model_override:
+        # Merge into existing model dict so fields not in the override
+        # (e.g. temperature, maxTokens) are preserved from the pack.
+        existing_model = config.get("model") or {}
+        existing_model.update(req.model_override)
+        config["model"] = existing_model
+    if req.system_prompt_override is not None:
+        config["systemPrompt"] = req.system_prompt_override
+
     config_json = json.dumps(config, ensure_ascii=False)
     config_hash = hashlib.sha256(config_json.encode()).hexdigest()
 
@@ -232,13 +271,14 @@ async def create_agent_from_pack(
     db.execute(
         "INSERT INTO agents (id, tenant_id, name, description, current_version, "
         "tags, environment, agent_dir, agent_template, instance_dir, kb_ids, "
-        "kb_search_mode, status, created_at, updated_at) "
-        "VALUES (?, ?, ?, ?, 1, ?, 'production', ?, ?, '', ?, ?, 'active', ?, ?)",
+        "kb_search_mode, work_dir, source_dir, run_dir, status, created_at, updated_at) "
+        "VALUES (?, ?, ?, ?, 1, ?, 'production', ?, ?, '', ?, ?, ?, ?, ?, 'active', ?, ?)",
         (agent_id, tenant.tenant_id, req.name,
          req.description or pack.manifest.description,
          json.dumps(req.tags),
          pack.path, pack.id,
-         json.dumps(req.kb_ids), req.kb_search_mode, now, now),
+         json.dumps(req.kb_ids), req.kb_search_mode,
+         req.work_dir, req.source_dir, req.run_dir, now, now),
     )
     db.execute(
         "INSERT INTO agent_versions "

+ 53 - 0
agentpaas/src/agentpaas/api/v1/setup.py

@@ -404,3 +404,56 @@ def auto_bootstrap_desktop() -> Optional[str]:
     cfg["tenant_id"] = tid
     _save_config(cfg)
     return raw_key
+
+
+# ── Directory browser (loopback-only) ─────────────────────────────────────────
+
+@router.get("/browse-dir")
+async def browse_dir(request: Request, path: str = ""):
+    """List immediate sub-directories of *path* for the directory-picker UI.
+
+    Loopback-only — exposes the server filesystem to the local browser; never
+    callable from a remote host (same oracle defense as /data-dir and /install).
+
+    Query params:
+        path: absolute path to browse (empty → user home directory)
+
+    Response:
+        current:  resolved absolute path being listed
+        parent:   parent directory path (null when at filesystem root)
+        home:     user home directory (always returned for quick-nav button)
+        entries:  list of {name, path, hidden} — immediate subdirs only
+    """
+    _require_loopback(request)
+
+    from pathlib import Path
+
+    # Default to home if path is empty or "~"
+    resolved = Path(os.path.expanduser(path or "~")).resolve()
+
+    if not resolved.exists():
+        # Gracefully fall back to home if path doesn't exist yet
+        resolved = Path.home()
+    if not resolved.is_dir():
+        raise HTTPException(status_code=400, detail=f"Not a directory: {resolved}")
+
+    entries = []
+    try:
+        for entry in sorted(resolved.iterdir(), key=lambda e: e.name.lower()):
+            if not entry.is_dir():
+                continue
+            entries.append({
+                "name": entry.name,
+                "path": str(entry),
+                "hidden": entry.name.startswith("."),
+            })
+    except PermissionError:
+        pass  # return empty entries for unreadable dirs
+
+    parent = resolved.parent
+    return {
+        "current": str(resolved),
+        "parent": str(parent) if parent != resolved else None,
+        "home": str(Path.home()),
+        "entries": entries,
+    }

+ 10 - 0
webui/src/api/agentpacks.ts

@@ -30,6 +30,8 @@ export interface AgentPack {
   permission_summary: string
   model_recommended: string[]
   entrypoint: string
+  /** Only present on GET /agentpacks/{id} (detail), not in list. */
+  entrypoint_config?: Record<string, unknown>
 }
 
 export interface ListPacksResponse {
@@ -94,6 +96,14 @@ export const agentpacksApi = {
       version?: string
       kb_ids?: string[]
       tags?: string[]
+      /** Override the pack's model (provider + name). */
+      model_override?: { provider?: string; name?: string }
+      /** Replace the pack's systemPrompt entirely. Empty string = use pack default. */
+      system_prompt_override?: string
+      /** 3-directory config */
+      work_dir?: string
+      source_dir?: string
+      run_dir?: string
     },
   ): Promise<CreateAgentResponse> =>
     api.post(`/agentpacks/${packId}/create-agent`, payload),

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

@@ -104,6 +104,17 @@ export const agentsApi = {
   runs: (id: string, limit = 50) =>
     api.get<{ runs: Run[] }>(`/agents/${id}/runs?limit=${limit}`),
 
+  // Sub-agent (multi-agent orchestrator) config file
+  subAgentConfig: (agentId: string, subName: string) =>
+    api.get<{
+      sub_name: string
+      tool?: string
+      path: string | null
+      rel_path?: string
+      inline: boolean
+      content: string
+    }>(`/agents/${agentId}/subagents/${encodeURIComponent(subName)}/config`),
+
   // Workspace file browsing
   workspaceFiles: (agentId: string, runId: string) =>
     api.get<{ files: WorkspaceFile[]; workspace_path: string }>(

+ 5 - 1
webui/src/components/Sidebar.tsx

@@ -89,7 +89,11 @@ export default function Sidebar() {
         <button
           onClick={() => {
             clearApiKey()
-            navigate('/setup')
+            // ?reconfigure=1 tells SetupWizard to skip the desktop
+            // auto-login shortcut — without it the wizard immediately
+            // re-fetches the key and bounces back to /dashboard, making
+            // this button a no-op.
+            navigate('/setup?reconfigure=1')
           }}
           className="flex items-center gap-3 px-3 py-2 w-full rounded-lg text-sm text-gray-500 hover:bg-gray-800 hover:text-gray-300 transition-colors"
         >

+ 334 - 6
webui/src/pages/AgentEdit.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect } from 'react'
 import { useNavigate, useParams } from 'react-router-dom'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { ArrowLeft, Save, X, Database, CheckSquare, Square } from 'lucide-react'
+import { ArrowLeft, Save, X, Database, CheckSquare, Square, FolderOpen } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { agentsApi } from '../api/agents'
 import { knowledgeApi } from '../api/knowledge'
 import { Button, Input, Textarea, Card, PageHeader, Spinner, Tabs } from '../components/ui'
 import { clsx } from '../lib/clsx'
+import DirPickerModal from '../components/DirPickerModal'
 
 // ── Tool tag editor (same as AgentImport) ────────────────────────
 
@@ -62,6 +63,7 @@ const TABS = [
   { id: 'model',    label: '模型配置' },
   { id: 'behavior', label: '行为配置' },
   { id: 'memory',   label: '记忆 & 运行时' },
+  { id: 'guard',    label: '验收 & 安全' },
   { id: 'tools',    label: '工具列表' },
   { id: 'prompt',   label: '系统提示词' },
   { id: 'kb',       label: '知识库' },
@@ -81,6 +83,11 @@ export default function AgentEdit() {
   const [description, setDescription] = useState('')
   const [agentType, setAgentType]     = useState('react')
   const [agentDir, setAgentDir]       = useState('')
+  const [workDir, setWorkDir]         = useState('')
+  const [sourceDir, setSourceDir]     = useState('')
+  const [runDir, setRunDir]           = useState('')
+  // which dir picker is open: 'work' | 'source' | 'run' | null
+  const [dirPickerFor, setDirPickerFor] = useState<'work' | 'source' | 'run' | null>(null)
 
   // model
   const [provider, setProvider]       = useState('')
@@ -101,10 +108,42 @@ export default function AgentEdit() {
   const [runtimeEngine, setRuntimeEngine] = useState('cek')
   const [costBudget, setCostBudget]       = useState('0.5')
 
+  // guard (验收 & 安全) — agent-level output validation + tool safety
+  const [guardEnabled, setGuardEnabled]             = useState(false)
+  const [guardValidator, setGuardValidator]         = useState('')
+  const [guardRetry, setGuardRetry]                 = useState('0')
+  const [guardFallback, setGuardFallback]           = useState('error')
+  const [guardBlockDangerous, setGuardBlockDangerous] = useState(true)
+  const [guardHighRiskConfirm, setGuardHighRiskConfirm] = useState(false)
+  const [guardMaxOutput, setGuardMaxOutput]         = useState('0')
+
   // tools & prompt
   const [localTools, setLocalTools]     = useState<string[]>([])
   const [systemPrompt, setSystemPrompt] = useState('')
 
+  // sub-agents (multi-agent orchestrator). Read-only view: each entry maps a
+  // sub-agent name to { tool: call_*, config: ./path.yml } or an inline def.
+  const [subAgents, setSubAgents] = useState<Record<string, unknown>>({})
+  // Clicking a sub-agent loads & shows its config file. Cache keyed by name.
+  const [openSub, setOpenSub] = useState<string | null>(null)
+  const [subConfigs, setSubConfigs] = useState<Record<string, {
+    loading: boolean; content?: string; path?: string | null; error?: string
+  }>>({})
+
+  const toggleSub = async (name: string) => {
+    if (openSub === name) { setOpenSub(null); return }
+    setOpenSub(name)
+    if (subConfigs[name]?.content !== undefined || subConfigs[name]?.error) return
+    setSubConfigs(p => ({ ...p, [name]: { loading: true } }))
+    try {
+      const r = await agentsApi.subAgentConfig(agentId!, name)
+      setSubConfigs(p => ({ ...p, [name]: { loading: false, content: r.content, path: r.path } }))
+    } catch (e) {
+      const msg = (e as Error)?.message || String(e)
+      setSubConfigs(p => ({ ...p, [name]: { loading: false, error: msg } }))
+    }
+  }
+
   // knowledge bases
   const [linkedKBs, setLinkedKBs]         = useState<string[]>([])
   const [kbSearchMode, setKbSearchMode]   = useState('bm25')
@@ -129,6 +168,9 @@ export default function AgentEdit() {
     setDescription(agent.description ?? '')
     setAgentType((cfg.type as string) ?? 'react')
     setAgentDir(agent.agent_dir ?? '')
+    setWorkDir(agent.work_dir ?? '')
+    setSourceDir(agent.source_dir ?? '')
+    setRunDir(agent.run_dir ?? '')
 
     setProvider((m.provider as string) ?? '')
     setModelName((m.name as string) ?? '')
@@ -147,8 +189,23 @@ export default function AgentEdit() {
     setRuntimeEngine((rt.engine as string) ?? 'cek')
     setCostBudget(String(rt.costBudget ?? 0.5))
 
+    const g = (cfg.guard as Record<string, unknown>) ?? {}
+    setGuardEnabled(Object.keys(g).length > 0)
+    setGuardValidator((g.validator as string) ?? '')
+    setGuardRetry(String(g.retry ?? 0))
+    setGuardFallback((g.fallback as string) ?? 'error')
+    setGuardBlockDangerous(g.dangerousCommandBlock !== false)   // default true
+    setGuardHighRiskConfirm(Boolean(g.highRiskConfirmation))
+    setGuardMaxOutput(String(g.maxOutputLength ?? 0))
+
     setLocalTools((mcp.localTools as string[]) ?? [])
     setSystemPrompt((cfg.systemPrompt as string) ?? '')
+    // Compiler accepts both `subAgents` (camelCase) and `sub_agents`.
+    setSubAgents(
+      (cfg.subAgents as Record<string, unknown>) ??
+      (cfg.sub_agents as Record<string, unknown>) ??
+      {},
+    )
     setLinkedKBs(agent.kb_ids ?? [])
     setKbSearchMode(agent.kb_search_mode ?? 'bm25')
     setReady(true)
@@ -187,9 +244,26 @@ export default function AgentEdit() {
       }
       cfg.mcp = { ...((cfg.mcp as object) ?? {}), localTools }
 
+      if (guardEnabled) {
+        cfg.guard = {
+          ...((cfg.guard as object) ?? {}),
+          ...(guardValidator.trim() ? { validator: guardValidator.trim() } : {}),
+          retry:                 parseInt(guardRetry) || 0,
+          fallback:              guardFallback,
+          dangerousCommandBlock: guardBlockDangerous,
+          highRiskConfirmation:  guardHighRiskConfirm,
+          maxOutputLength:       parseInt(guardMaxOutput) || 0,
+        }
+      } else {
+        delete cfg.guard
+      }
+
       return agentsApi.update(agentId!, {
-        config:    cfg,
-        agent_dir: agentDir,
+        config:     cfg,
+        agent_dir:  agentDir,
+        work_dir:   workDir,
+        source_dir: sourceDir,
+        run_dir:    runDir,
         changelog: `Web UI 编辑 — ${new Date().toLocaleString('zh-CN')}`,
       })
     },
@@ -240,7 +314,19 @@ export default function AgentEdit() {
       </div>
 
       <Card>
-        <Tabs tabs={TABS} active={tab} onChange={setTab} />
+        <Tabs
+          tabs={(() => {
+            if (Object.keys(subAgents).length === 0) return TABS
+            // Insert the read-only 子智能体 tab right AFTER 工具列表 (sub-agents
+            // are compiled into call_* tools, so they belong next to tools).
+            // Fall back to before 知识库 if the tools tab is ever renamed.
+            const after = TABS.findIndex(t => t.id === 'tools')
+            const i = after >= 0 ? after + 1 : TABS.findIndex(t => t.id === 'kb')
+            return [...TABS.slice(0, i), { id: 'subagents', label: '子智能体' }, ...TABS.slice(i)]
+          })()}
+          active={tab}
+          onChange={setTab}
+        />
 
         {/* ── 基本设置 ── */}
         {tab === 'basic' && (
@@ -257,12 +343,49 @@ export default function AgentEdit() {
                 <option value="groupchat">多智能体群聊</option>
               </select>
             </div>
+            {/* ── 三目录配置 ── */}
+            <div className="space-y-3 rounded-xl border border-indigo-100 bg-indigo-50/40 p-4">
+              <p className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">目录配置</p>
+
+              {/* source_dir */}
+              <DirField
+                label="① 数据来源目录"
+                hint="只读输入数据所在位置;智能体启动时自动提示从此处读取文件"
+                value={sourceDir}
+                onChange={setSourceDir}
+                onBrowse={() => setDirPickerFor('source')}
+                placeholder="/Users/me/papers"
+                color="blue"
+              />
+
+              {/* work_dir */}
+              <DirField
+                label="② 工作产物目录"
+                hint="Bash 工具的初始路径;智能体在此写入最终交付成果"
+                value={workDir}
+                onChange={setWorkDir}
+                onBrowse={() => setDirPickerFor('work')}
+                placeholder="/Users/me/Documents/my-project"
+                color="amber"
+              />
+
+              {/* run_dir */}
+              <DirField
+                label="③ 运行日志目录"
+                hint="每次对话的 run_*/traces/中间产物落在这里;留空则自动使用【工作产物目录】"
+                value={runDir}
+                onChange={setRunDir}
+                onBrowse={() => setDirPickerFor('run')}
+                placeholder="留空 = 自动(工作产物目录 + /workspace)"
+                color="green"
+              />
+            </div>
             <Input
-              label="工作目录(agent_dir)"
+              label="智能体数据目录(高级)"
               value={agentDir}
               onChange={e => setAgentDir(e.target.value)}
               placeholder="如 agentexample/research67"
-              hint="绑定后执行时自动创建 workspace/run_* 目录,工具生成的文件可在对话页下载"
+              hint="Pack 或模板的系统目录,用于加载子智能体配置。普通用户无需填写。"
             />
           </div>
         )}
@@ -347,6 +470,69 @@ export default function AgentEdit() {
           </div>
         )}
 
+        {/* ── 验收 & 安全(Guard) ── */}
+        {tab === 'guard' && (
+          <div className="space-y-6 max-w-lg">
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input type="checkbox" checked={guardEnabled} onChange={e => setGuardEnabled(e.target.checked)}
+                className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
+              <span className="text-sm font-medium text-gray-800">启用验收 Guard(对智能体输出做校验 + 工具安全护栏)</span>
+            </label>
+
+            {guardEnabled && (
+              <>
+                <div>
+                  <h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3">输出验收</h3>
+                  <div className="space-y-3">
+                    <Textarea
+                      label="验收表达式(validator)"
+                      rows={2}
+                      value={guardValidator}
+                      onChange={e => setGuardValidator(e.target.value)}
+                      placeholder={'例:\'评审结论\' in x  或  \'acceptance_probability\' in x'}
+                      hint="对输出 x 求值的安全表达式(AST 白名单),返回真才算通过;留空 = 不校验内容"
+                    />
+                    <div className="grid grid-cols-2 gap-4">
+                      <Input label="失败重试次数" type="number" value={guardRetry}
+                        onChange={e => setGuardRetry(e.target.value)} hint="校验不过时重跑的次数" />
+                      <div>
+                        <label className="block text-xs font-medium text-gray-700 mb-1">重试耗尽后</label>
+                        <select value={guardFallback} onChange={e => setGuardFallback(e.target.value)}
+                          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
+                          <option value="error">报错中止(error)</option>
+                          <option value="last">返回最后一次输出(last)</option>
+                        </select>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3">工具安全护栏</h3>
+                  <div className="space-y-3">
+                    <label className="flex items-center gap-3 cursor-pointer">
+                      <input type="checkbox" checked={guardBlockDangerous} onChange={e => setGuardBlockDangerous(e.target.checked)}
+                        className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
+                      <span className="text-sm text-gray-700">拦截危险命令(rm -rf、sudo、curl|sh 等)</span>
+                    </label>
+                    <label className="flex items-center gap-3 cursor-pointer">
+                      <input type="checkbox" checked={guardHighRiskConfirm} onChange={e => setGuardHighRiskConfirm(e.target.checked)}
+                        className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
+                      <span className="text-sm text-gray-700">高危操作需人工确认</span>
+                    </label>
+                    <Input label="工具输出最大长度(字节,0 = 不限)" type="number" value={guardMaxOutput}
+                      onChange={e => setGuardMaxOutput(e.target.value)} hint="超长则截断,防止上下文爆掉" />
+                  </div>
+                </div>
+
+                <p className="text-xs text-gray-400 bg-gray-50 rounded-lg px-3 py-2">
+                  验收 Guard 在编译期生效(lambdagent Guard 构造)。审稿场景常用:让结论必须含「评审结论」或「接受概率」,否则自动重审。
+                </p>
+              </>
+            )}
+          </div>
+        )}
+
         {/* ── 工具列表 ── */}
         {tab === 'tools' && (
           <div className="max-w-2xl space-y-3">
@@ -360,6 +546,84 @@ export default function AgentEdit() {
           </div>
         )}
 
+        {/* ── 子智能体 ── */}
+        {tab === 'subagents' && (
+          <div className="max-w-2xl space-y-3">
+            <p className="text-xs text-gray-500">
+              该智能体是<strong>多智能体协调者</strong>。下列子智能体在运行时被编译为
+              <code className="mx-1 px-1 bg-gray-100 rounded">call_*</code>工具,由协调者按需调度。
+              子智能体配置在 Pack 的 YAML 中定义,此处为只读视图。
+            </p>
+            <div className="space-y-2">
+              {Object.entries(subAgents).map(([key, raw]) => {
+                const v = (raw ?? {}) as Record<string, unknown>
+                const tool   = (v.tool as string) || `call_${key}`
+                const config = (v.config as string) || ''
+                const model  = (v.model as Record<string, unknown>) || undefined
+                const desc   = (v.description as string) || ''
+                const open   = openSub === key
+                const sc     = subConfigs[key]
+                return (
+                  <div key={key} className="rounded-lg border border-gray-200 bg-white overflow-hidden">
+                    <button
+                      type="button"
+                      onClick={() => toggleSub(key)}
+                      className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors"
+                    >
+                      <div className="flex items-center justify-between gap-2">
+                        <span className="flex items-center gap-1.5 text-sm font-semibold text-gray-900">
+                          <span className={clsx('text-gray-400 transition-transform', open && 'rotate-90')}>▶</span>
+                          {key}
+                        </span>
+                        <span className="inline-flex items-center text-xs font-mono bg-indigo-50 text-indigo-600 border border-indigo-200 px-1.5 py-0.5 rounded">
+                          {tool}
+                        </span>
+                      </div>
+                      {desc && <p className="text-xs text-gray-500 mt-1 ml-5">{desc}</p>}
+                      {config && (
+                        <p className="text-xs text-gray-400 mt-1 ml-5 font-mono break-all">📄 {config}</p>
+                      )}
+                      {model && (
+                        <p className="text-xs text-gray-400 mt-1 ml-5">
+                          模型:{(model.provider as string) ?? '—'} · {(model.name as string) ?? '—'}
+                        </p>
+                      )}
+                    </button>
+
+                    {open && (
+                      <div className="border-t border-gray-100 bg-gray-50">
+                        {sc?.loading && (
+                          <p className="text-xs text-gray-400 px-4 py-3">加载配置文件中…</p>
+                        )}
+                        {sc?.error && (
+                          <p className="text-xs text-red-600 px-4 py-3">
+                            无法读取配置文件:{sc.error}
+                          </p>
+                        )}
+                        {sc?.content !== undefined && (
+                          <div>
+                            {sc.path && (
+                              <p className="text-[11px] text-gray-400 font-mono break-all px-4 pt-2">
+                                {sc.path}
+                              </p>
+                            )}
+                            <pre className="text-xs leading-relaxed text-gray-800 font-mono whitespace-pre-wrap px-4 py-3 max-h-[480px] overflow-auto">
+                              {sc.content}
+                            </pre>
+                          </div>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                )
+              })}
+            </div>
+            <p className="text-xs text-gray-400">
+              共 {Object.keys(subAgents).length} 个子智能体。如需修改,请编辑对应 Pack 的 YAML 后重新安装。
+            </p>
+          </div>
+        )}
+
         {/* ── 系统提示词 ── */}
         {tab === 'prompt' && (
           <div className="max-w-2xl">
@@ -468,6 +732,70 @@ export default function AgentEdit() {
           </Button>
         </div>
       </Card>
+
+      {dirPickerFor === 'work' && (
+        <DirPickerModal
+          initial={workDir || undefined}
+          onSelect={(path) => setWorkDir(path)}
+          onClose={() => setDirPickerFor(null)}
+        />
+      )}
+      {dirPickerFor === 'source' && (
+        <DirPickerModal
+          initial={sourceDir || undefined}
+          onSelect={(path) => setSourceDir(path)}
+          onClose={() => setDirPickerFor(null)}
+        />
+      )}
+      {dirPickerFor === 'run' && (
+        <DirPickerModal
+          initial={runDir || undefined}
+          onSelect={(path) => setRunDir(path)}
+          onClose={() => setDirPickerFor(null)}
+        />
+      )}
+    </div>
+  )
+}
+
+// ── Reusable dir input with picker button ─────────────────────────
+
+function DirField({
+  label, hint, value, onChange, onBrowse, placeholder,
+  color = 'amber',
+}: {
+  label: string
+  hint: string
+  value: string
+  onChange: (v: string) => void
+  onBrowse: () => void
+  placeholder: string
+  color?: 'amber' | 'blue' | 'green'
+}) {
+  const iconColor = color === 'amber' ? 'text-amber-500'
+    : color === 'blue' ? 'text-blue-500'
+    : 'text-emerald-500'
+  return (
+    <div>
+      <label className="block text-xs font-medium text-gray-700 mb-1">{label}</label>
+      <div className="flex gap-2">
+        <input
+          value={value}
+          onChange={e => onChange(e.target.value)}
+          placeholder={placeholder}
+          className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
+        />
+        <button
+          type="button"
+          onClick={onBrowse}
+          className="flex items-center gap-1.5 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50 hover:border-indigo-400 transition-colors shrink-0"
+          title="浏览文件夹"
+        >
+          <FolderOpen size={15} className={iconColor} />
+          浏览
+        </button>
+      </div>
+      <p className="text-xs text-gray-400 mt-1">{hint}</p>
     </div>
   )
 }

+ 88 - 1
webui/src/pages/AgentImport.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect, useRef } from 'react'
 import { useNavigate } from 'react-router-dom'
 import { useQuery, useMutation } from '@tanstack/react-query'
-import { ArrowLeft, Bot, Zap, Database, CheckCircle2, X } from 'lucide-react'
+import { ArrowLeft, Bot, Zap, Database, CheckCircle2, X, FolderOpen } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { templatesApi, type TemplateSummary } from '../api/templates'
 import { agentsApi } from '../api/agents'
 import { Button, Input, Textarea, Card, PageHeader, Spinner, Tabs } from '../components/ui'
 import { clsx } from '../lib/clsx'
+import DirPickerModal from '../components/DirPickerModal'
 
 // ── Provider badge ────────────────────────────────────────
 
@@ -207,6 +208,12 @@ export default function AgentImport() {
   // prompt
   const [systemPrompt, setSystemPrompt]     = useState('')
 
+  // 3-directory config
+  const [sourceDir, setSourceDir]   = useState('')
+  const [workDir, setWorkDir]       = useState('')
+  const [runDir, setRunDir]         = useState('')
+  const [dirPickerFor, setDirPickerFor] = useState<'work' | 'source' | 'run' | null>(null)
+
   const { data: listData, isLoading } = useQuery({
     queryKey: ['templates'],
     queryFn: templatesApi.list,
@@ -299,6 +306,9 @@ export default function AgentImport() {
           },
         },
         tags: [],
+        work_dir:   workDir,
+        source_dir: sourceDir,
+        run_dir:    runDir,
       }),
     onSuccess: (data) => {
       toast.success('智能体创建成功!')
@@ -395,6 +405,14 @@ export default function AgentImport() {
                   <option value="groupchat">多智能体群聊</option>
                 </select>
               </div>
+
+              {/* ── 三目录配置 ── */}
+              <DirSection
+                sourceDir={sourceDir} onSourceDir={setSourceDir}
+                workDir={workDir}     onWorkDir={setWorkDir}
+                runDir={runDir}       onRunDir={setRunDir}
+                onBrowse={setDirPickerFor}
+              />
             </div>
           )}
 
@@ -589,6 +607,75 @@ export default function AgentImport() {
           </div>
         </Card>
       )}
+
+      {dirPickerFor === 'work' && (
+        <DirPickerModal initial={workDir || undefined}
+          onSelect={setWorkDir} onClose={() => setDirPickerFor(null)} />
+      )}
+      {dirPickerFor === 'source' && (
+        <DirPickerModal initial={sourceDir || undefined}
+          onSelect={setSourceDir} onClose={() => setDirPickerFor(null)} />
+      )}
+      {dirPickerFor === 'run' && (
+        <DirPickerModal initial={runDir || undefined}
+          onSelect={setRunDir} onClose={() => setDirPickerFor(null)} />
+      )}
+    </div>
+  )
+}
+
+// ── Shared 3-dir section ──────────────────────────────────────────
+
+function DirSection({
+  sourceDir, onSourceDir, workDir, onWorkDir, runDir, onRunDir, onBrowse,
+}: {
+  sourceDir: string; onSourceDir: (v: string) => void
+  workDir: string;   onWorkDir: (v: string) => void
+  runDir: string;    onRunDir: (v: string) => void
+  onBrowse: (which: 'work' | 'source' | 'run') => void
+}) {
+  return (
+    <div className="space-y-3 rounded-xl border border-indigo-100 bg-indigo-50/40 p-4">
+      <p className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">目录配置</p>
+      <DirRow label="① 数据来源目录" value={sourceDir} onChange={onSourceDir}
+        onBrowse={() => onBrowse('source')}
+        placeholder="/Users/me/papers"
+        hint="只读输入数据;智能体启动时提示从此处读取" color="blue" />
+      <DirRow label="② 工作产物目录" value={workDir} onChange={onWorkDir}
+        onBrowse={() => onBrowse('work')}
+        placeholder="/Users/me/Documents/my-project"
+        hint="Bash 工具初始路径;最终交付成果写这里" color="amber" />
+      <DirRow label="③ 运行日志目录" value={runDir} onChange={onRunDir}
+        onBrowse={() => onBrowse('run')}
+        placeholder="留空 = 自动(工作产物目录 + /workspace)"
+        hint="run_*/traces/中间产物;留空自动用工作产物目录" color="green" />
+    </div>
+  )
+}
+
+function DirRow({
+  label, hint, value, onChange, onBrowse, placeholder, color,
+}: {
+  label: string; hint: string; value: string; onChange: (v: string) => void
+  onBrowse: () => void; placeholder: string; color: 'amber' | 'blue' | 'green'
+}) {
+  const iconColor = color === 'amber' ? 'text-amber-500'
+    : color === 'blue' ? 'text-blue-500' : 'text-emerald-500'
+  return (
+    <div>
+      <label className="block text-xs font-medium text-gray-700 mb-1">{label}</label>
+      <div className="flex gap-2">
+        <input value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder}
+          className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono
+            focus:outline-none focus:ring-2 focus:ring-indigo-500" />
+        <button type="button" onClick={onBrowse}
+          className="flex items-center gap-1.5 px-3 py-2 border border-gray-300 rounded-lg
+            text-sm text-gray-600 hover:bg-gray-50 hover:border-indigo-400 transition-colors shrink-0"
+          title="浏览文件夹">
+          <FolderOpen size={15} className={iconColor} />浏览
+        </button>
+      </div>
+      <p className="text-xs text-gray-400 mt-1">{hint}</p>
     </div>
   )
 }

+ 174 - 4
webui/src/pages/AgentPacks.tsx

@@ -7,7 +7,7 @@
  *   • 卸载 pack(DELETE)
  *   • 一键"从此包创建智能体"(POST /{id}/create-agent → 跳转 /agents)
  */
-import { useRef, useState } from 'react'
+import { useRef, useState, useEffect } from 'react'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { useNavigate } from 'react-router-dom'
 import {
@@ -25,6 +25,7 @@ import {
 import toast from 'react-hot-toast'
 import { agentpacksApi, type AgentPack } from '../api/agentpacks'
 import { PageHeader, Spinner, EmptyState, Button, Badge, Card } from '../components/ui'
+import DirPickerModal from '../components/DirPickerModal'
 
 // ── Permission chips ─────────────────────────────────────────────
 
@@ -221,9 +222,61 @@ function CreateAgentModal({
   const [name, setName] = useState(`${pack.name} (副本)`)
   const [desc, setDesc] = useState(pack.description ?? '')
 
+  // 3-directory config
+  const [sourceDir, setSourceDir]   = useState('')
+  const [workDir, setWorkDir]       = useState('')
+  const [runDir, setRunDir]         = useState('')
+  const [dirPickerFor, setDirPickerFor] = useState<'work' | 'source' | 'run' | null>(null)
+
+  // Advanced customisation state
+  const [showAdvanced, setShowAdvanced] = useState(false)
+  const [modelProvider, setModelProvider] = useState('')
+  const [modelName, setModelName] = useState('')
+  const [systemPrompt, setSystemPrompt] = useState('')
+  const [advancedInited, setAdvancedInited] = useState(false)
+
+  // Fetch pack detail (includes entrypoint_config) when advanced section opens
+  const detailQuery = useQuery({
+    queryKey: ['agentpack-detail', pack.id],
+    queryFn: () => agentpacksApi.get(pack.id),
+    enabled: showAdvanced,
+    staleTime: 60_000,
+  })
+
+  // Pre-fill advanced fields from entrypoint_config on first load
+  useEffect(() => {
+    if (detailQuery.data?.entrypoint_config && !advancedInited) {
+      const cfg = detailQuery.data.entrypoint_config as Record<string, any>
+      const model = (cfg.model ?? {}) as Record<string, string>
+      setModelProvider(model.provider ?? '')
+      setModelName(model.name ?? '')
+      setSystemPrompt((cfg.systemPrompt as string) ?? '')
+      setAdvancedInited(true)
+    }
+  }, [detailQuery.data, advancedInited])
+
   const mut = useMutation({
-    mutationFn: () =>
-      agentpacksApi.createAgent(pack.id, { name: name.trim(), description: desc.trim() }),
+    mutationFn: () => {
+      const payload: Parameters<typeof agentpacksApi.createAgent>[1] = {
+        name: name.trim(),
+        description: desc.trim(),
+      }
+      if (showAdvanced && advancedInited) {
+        if (modelProvider.trim() || modelName.trim()) {
+          payload.model_override = {}
+          if (modelProvider.trim()) payload.model_override.provider = modelProvider.trim()
+          if (modelName.trim()) payload.model_override.name = modelName.trim()
+        }
+        // Only send system_prompt_override if user actually edited it
+        if (systemPrompt !== ((detailQuery.data?.entrypoint_config as any)?.systemPrompt ?? '')) {
+          payload.system_prompt_override = systemPrompt
+        }
+      }
+      if (workDir.trim())   payload.work_dir   = workDir.trim()
+      if (sourceDir.trim()) payload.source_dir = sourceDir.trim()
+      if (runDir.trim())    payload.run_dir    = runDir.trim()
+      return agentpacksApi.createAgent(pack.id, payload)
+    },
     onSuccess: (res) => {
       toast.success(`智能体「${res.name}」已创建`)
       onCreated(res.agent_id)
@@ -233,11 +286,12 @@ function CreateAgentModal({
 
   return (
     <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
-      <div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
+      <div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
         <h3 className="text-base font-semibold mb-1">从 Pack 创建智能体</h3>
         <p className="text-xs text-gray-500 mb-4">
           基于 <strong>{pack.name}</strong> v{pack.version}
         </p>
+
         <div className="space-y-3">
           <div>
             <label className="block text-xs font-medium text-gray-700 mb-1">名称 *</label>
@@ -257,6 +311,110 @@ function CreateAgentModal({
             />
           </div>
         </div>
+
+        {/* ── 三目录配置 ── */}
+        <div className="mt-4 space-y-3 rounded-xl border border-indigo-100 bg-indigo-50/40 p-4">
+          <p className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">目录配置</p>
+          {[
+            { label: '① 数据来源目录', value: sourceDir, setter: setSourceDir,
+              key: 'source' as const, placeholder: '/Users/me/papers',
+              hint: '只读输入数据;智能体启动时提示从此读取', color: 'blue' },
+            { label: '② 工作产物目录', value: workDir, setter: setWorkDir,
+              key: 'work' as const, placeholder: '/Users/me/Documents/project',
+              hint: 'Bash 工具初始路径;最终交付成果写这里', color: 'amber' },
+            { label: '③ 运行日志目录', value: runDir, setter: setRunDir,
+              key: 'run' as const, placeholder: '留空 = 自动(工作产物目录 + /workspace)',
+              hint: 'run_*/traces/中间产物;留空则自动', color: 'green' },
+          ].map(({ label, value, setter, key, placeholder, hint, color }) => (
+            <div key={key}>
+              <label className="block text-xs font-medium text-gray-700 mb-1">{label}</label>
+              <div className="flex gap-2">
+                <input value={value} onChange={e => setter(e.target.value)} placeholder={placeholder}
+                  className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono
+                    focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white" />
+                <button type="button" onClick={() => setDirPickerFor(key)}
+                  className="flex items-center gap-1 px-3 py-2 border border-gray-300 rounded-lg
+                    text-xs text-gray-600 hover:bg-gray-50 hover:border-indigo-400 transition-colors shrink-0">
+                  <FolderOpen size={13}
+                    className={color === 'amber' ? 'text-amber-500' : color === 'blue' ? 'text-blue-500' : 'text-emerald-500'} />
+                  浏览
+                </button>
+              </div>
+              <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p>
+            </div>
+          ))}
+        </div>
+
+        {/* Advanced customisation toggle */}
+        <button
+          onClick={() => setShowAdvanced(v => !v)}
+          className="mt-4 text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 block"
+        >
+          {showAdvanced ? '▲ 收起高级配置' : '▼ 高级配置(可选:覆盖模型 / 提示词)'}
+        </button>
+
+        {showAdvanced && (
+          <div className="mt-3 border border-gray-100 rounded-xl p-4 bg-gray-50 space-y-3">
+            {detailQuery.isLoading ? (
+              <div className="flex justify-center py-4"><Spinner size={16} /></div>
+            ) : (
+              <>
+                <p className="text-[11px] text-gray-400 leading-relaxed">
+                  留空则使用 Pack 内置配置。仅影响此次创建的智能体实例,不修改 Pack 本身。
+                </p>
+
+                {/* Model override */}
+                <div className="grid grid-cols-2 gap-2">
+                  <div>
+                    <label className="block text-xs font-medium text-gray-700 mb-1">模型提供商</label>
+                    <input
+                      value={modelProvider}
+                      onChange={(e) => setModelProvider(e.target.value)}
+                      placeholder="claude-code"
+                      className="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-xs font-medium text-gray-700 mb-1">模型名称</label>
+                    <input
+                      value={modelName}
+                      onChange={(e) => setModelName(e.target.value)}
+                      placeholder="sonnet"
+                      list={`models-${pack.id}`}
+                      className="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white"
+                    />
+                    <datalist id={`models-${pack.id}`}>
+                      {pack.model_recommended.map(m => (
+                        <option key={m} value={m.split('/').pop() ?? m} />
+                      ))}
+                    </datalist>
+                    {pack.model_recommended.length > 0 && (
+                      <p className="text-[10px] text-gray-400 mt-1 truncate">
+                        推荐:{pack.model_recommended.join(' / ')}
+                      </p>
+                    )}
+                  </div>
+                </div>
+
+                {/* System prompt override */}
+                <div>
+                  <label className="block text-xs font-medium text-gray-700 mb-1">
+                    系统提示词
+                    <span className="text-gray-400 font-normal ml-1">(留空使用 Pack 默认)</span>
+                  </label>
+                  <textarea
+                    value={systemPrompt}
+                    onChange={(e) => setSystemPrompt(e.target.value)}
+                    rows={7}
+                    placeholder="留空则使用 Pack 内置系统提示词..."
+                    className="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono resize-y focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white"
+                  />
+                </div>
+              </>
+            )}
+          </div>
+        )}
+
         <div className="flex justify-end gap-2 mt-5">
           <button
             onClick={onClose}
@@ -274,6 +432,18 @@ function CreateAgentModal({
           </button>
         </div>
       </div>
+      {dirPickerFor === 'work' && (
+        <DirPickerModal initial={workDir || undefined}
+          onSelect={setWorkDir} onClose={() => setDirPickerFor(null)} />
+      )}
+      {dirPickerFor === 'source' && (
+        <DirPickerModal initial={sourceDir || undefined}
+          onSelect={setSourceDir} onClose={() => setDirPickerFor(null)} />
+      )}
+      {dirPickerFor === 'run' && (
+        <DirPickerModal initial={runDir || undefined}
+          onSelect={setRunDir} onClose={() => setDirPickerFor(null)} />
+      )}
     </div>
   )
 }

+ 7 - 3
webui/src/pages/Agents.tsx

@@ -1,6 +1,6 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { useNavigate } from 'react-router-dom'
-import { Bot, MessageSquare, Trash2, Plus, Settings2, Download } from 'lucide-react'
+import { Bot, MessageSquare, Trash2, Plus, Settings2, FileCode2, Package } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { agentsApi, type Agent } from '../api/agents'
 import { Card, PageHeader, Badge, EmptyState, Spinner, Button } from '../components/ui'
@@ -96,8 +96,12 @@ export default function Agents() {
         description={data ? `共 ${data.agents.length} 个智能体` : undefined}
         action={
           <div className="flex gap-2">
-            <Button variant="secondary" onClick={() => navigate('/agents/import')} icon={<Download size={16} />}>
-              从模版导入
+            <Button variant="secondary" onClick={() => navigate('/agents/import')} icon={<FileCode2 size={16} />}>
+              开发者模板
+              <span className="ml-1.5 text-[9px] bg-gray-200 text-gray-500 px-1.5 py-0.5 rounded font-mono leading-none">高级</span>
+            </Button>
+            <Button variant="secondary" onClick={() => navigate('/agentpacks')} icon={<Package size={16} />}>
+              从智能体包创建
             </Button>
             <Button onClick={() => navigate('/agents/new')} icon={<Plus size={16} />}>
               新建智能体

+ 54 - 7
webui/src/pages/Chat.tsx

@@ -665,6 +665,14 @@ function MessageBubble({
       </div>
 
       <div className={clsx('max-w-[80%] space-y-2', isUser ? 'items-end' : 'items-start')}>
+        {/* Waiting indicator — shown when run is streaming but no step has arrived yet.
+            Qianwen / non-streaming providers take 10-15s before the first step. */}
+        {!isUser && isStreaming && msg.turns.length === 0 && (
+          <div className="flex items-center gap-2 text-xs text-gray-400 px-1 py-0.5">
+            <span className="animate-spin inline-block w-3 h-3 border border-gray-300 border-t-gray-500 rounded-full" />
+            <span>模型思考中,请稍候…</span>
+          </div>
+        )}
         {/* Lambda Trace — live turns (streaming or completed with turns) */}
         {!isUser && msg.turns.length > 0 && (
           <LambdaTraceView
@@ -1227,6 +1235,23 @@ export default function Chat() {
     let currentRunId: string | null = null   // captured from the SSE 'started' event
     const abortCtrl = new AbortController()
 
+    // AUDIT_2026-06-11 ⑥: SSE 静默断开(后端重启/网络中断)时拉一次
+    // run 的最终状态 —— 后端启动清扫会把孤儿 run 收尾成 failed/cancelled,
+    // 前端据此结束气泡,而不是永远转圈或只显示笼统的网络错误。
+    const fetchFinalStatus = async (rid: string): Promise<string | null> => {
+      try {
+        const r = await fetch(`/api/v1/traces/${rid}`, {
+          headers: { Authorization: `Bearer ${apiKey}` },
+        })
+        if (!r.ok) return null
+        const j = await r.json() as { status?: string }
+        return j.status ?? null
+      } catch { return null }
+    }
+    const statusLabel = (s: string | null) =>
+      s === 'completed' ? '已完成' : s === 'cancelled' ? '已取消'
+        : s === 'failed' ? '已失败' : '状态未知 — 请查看运行历史'
+
     try {
       const res = await fetch(`/api/v1/agents/${agentId}/run/stream`, {
         method: 'POST',
@@ -1483,19 +1508,41 @@ export default function Chat() {
         setMessages(prev => prev.map(m =>
           m.id === assistantId ? { ...m, status: 'done', text: m.text || '(已停止)' } : m,
         ))
+      } else if (!doneReceived) {
+        // 流在没有 done/cancelled 终态事件的情况下结束 —— 孤儿 run 场景。
+        // 此前这里什么都不做,气泡永远停在 streaming。
+        const final = currentRunId ? await fetchFinalStatus(currentRunId) : null
+        setMessages(prev => prev.map(m =>
+          m.id === assistantId
+            ? { ...m, status: final === 'completed' ? 'done' : 'error',
+                text: m.text || `(连接中断,运行${statusLabel(final)})` }
+            : m,
+        ))
+        qc.invalidateQueries({ queryKey: ['runs', agentId] })
       }
     } catch (e) {
       // User-initiated stop / abort throws AbortError — treat as graceful
       // stop, not as an error red bubble.
       const err = e as Error
       const isAbort = err.name === 'AbortError' || cancelled
-      setMessages(prev => prev.map(m =>
-        m.id === assistantId
-          ? isAbort
-            ? { ...m, status: 'done', text: m.text || '(已停止)' }
-            : { ...m, status: 'error', text: `错误:${err.message}` }
-          : m,
-      ))
+      if (isAbort) {
+        setMessages(prev => prev.map(m =>
+          m.id === assistantId ? { ...m, status: 'done', text: m.text || '(已停止)' } : m,
+        ))
+      } else {
+        // 网络层异常(如后端重启杀掉了连接):同样优先报告 run 的
+        // 真实终态,拉不到再显示原始错误。
+        const final = currentRunId ? await fetchFinalStatus(currentRunId) : null
+        setMessages(prev => prev.map(m =>
+          m.id === assistantId
+            ? final
+              ? { ...m, status: final === 'completed' ? 'done' : 'error',
+                  text: m.text || `(连接中断,运行${statusLabel(final)})` }
+              : { ...m, status: 'error', text: `错误:${err.message}` }
+            : m,
+        ))
+        if (final) qc.invalidateQueries({ queryKey: ['runs', agentId] })
+      }
     } finally {
       if (idleTimer) clearInterval(idleTimer)
       setStreaming(false)

+ 11 - 2
webui/src/pages/SetupWizard.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
 import { CheckCircle2, XCircle, Loader2, Zap, ChevronRight, HardDrive } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { useAppStore } from '../store/app'
@@ -385,10 +385,16 @@ export default function SetupWizard() {
   const [providerChoice, setProviderChoice] = useState<ProviderChoice>({ id: '', apiKey: '' })
   const { setApiKey } = useAppStore()
   const navigate = useNavigate()
+  const [searchParams] = useSearchParams()
   const { data: modeData } = useMode()
   const mode = (modeData?.mode ?? 'desktop') as DeploymentMode
   const brand = MODE_META[mode].brand
 
+  // ?reconfigure=1 means the user explicitly clicked "切换数据 / 重新配置".
+  // In that case we must show the full wizard; auto-login would immediately
+  // bounce them back to /dashboard, making the button a no-op.
+  const isReconfigure = searchParams.get('reconfigure') === '1'
+
   function handleDone(key: string) {
     setApiKey(key)
     toast.success('配置完成,欢迎使用!')
@@ -400,7 +406,10 @@ export default function SetupWizard() {
   // loopback-only /setup/desktop-key endpoint and skip straight into the
   // app. A professor double-clicking the app should NOT have to paste a
   // key. Dogfooding found the wizard otherwise dead-ends here.
+  // EXCEPTION: skip auto-login when the user arrived via "切换数据 / 重新配置"
+  // (?reconfigure=1) — they want to see the wizard, not bounce back.
   useEffect(() => {
+    if (isReconfigure) return          // user wants to reconfigure — show wizard
     if (mode !== 'desktop' || !modeData?.bootstrapped) return
     let cancelled = false
     ;(async () => {
@@ -419,7 +428,7 @@ export default function SetupWizard() {
     return () => {
       cancelled = true
     }
-  }, [mode, modeData?.bootstrapped, setApiKey, navigate])
+  }, [isReconfigure, mode, modeData?.bootstrapped, setApiKey, navigate])
 
   const steps = [
     { n: 1, label: '环境检测' },