Browse Source

feat(workbench): 工作区对话目录一致性 — F 成唯一事实来源

修"选的工作目录 vs 智能体默认目录不一致、对话跑偏":工作区对话模式下让所选
文件夹 F 在 UI/prompt/运行三处统一,且不受旧续跑上下文与 agent 默认目录干扰。

后端(#4 语义解耦):
- _execute_agent 加 inplace_dir 参:有值时把给模型看的 [工作目录]
  (overrides.workspace_path)设为 F,而 config.yml/trace 仍写 agent run_dir
  (本地 workspace_path 变量不变)→ 模型就地在 F 读写改,日志/产物不污染 F
- run_agent + run_agent_stream:context.work_dir=F → 算 _inplace_dir 传入

前端(Chat):
- workDir 存在 → 隐藏 来源/产物/日志 DirInfoBar(那是专项 agent 的目录,
  工作区模式只认 F),只显示 📁F 徽章
- workDir 存在 → run 不挂续跑上下文 + 隐藏续跑 banner(干净会话,避免旧
  run_xxx/cycle 劫持 [工作目录]);连续性靠 thread 记忆 + F 里真实文件
- 📁F 徽章可点 → 复用 DirPickerModal 换文件夹 → setWorkDir + 同步 URL + 新会话

验证:e2e+run_workspace 41 passed(回归断言 inplace_dir 传入=F)、tsc+webui 构建过、
服务重启 1.3.1。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju 3 tháng trước cách đây
mục cha
commit
fa4f4ad703

+ 14 - 5
agentpaas/src/agentpaas/api/v1/agents.py

@@ -584,8 +584,9 @@ async def run_agent(
     # [数据来源目录])。run_dir 保持 agent 自己的,trace/日志不污染用户项目文件夹。
     import os as _os
     _ctx_wd = (req.context or {}).get("work_dir") if isinstance(req.context, dict) else ""
-    if _ctx_wd and _os.path.isdir(_ctx_wd):
-        work_dir = source_dir = _ctx_wd
+    _inplace_dir = _ctx_wd if (_ctx_wd and _os.path.isdir(_ctx_wd)) else ""
+    if _inplace_dir:
+        work_dir = source_dir = _inplace_dir
 
     # 注入顺序(MEMORY_DESIGN §2.2):本会话前情 → 相关历史(L3) → KB → 用户问题
     enriched_input = req.input
@@ -629,6 +630,7 @@ async def run_agent(
                 work_dir=work_dir, source_dir=source_dir, run_dir=run_dir,
                 kb_tools=_make_platform_kb_tools(
                     db, kb_ids, tenant.tenant_id, kb_search_mode),
+                inplace_dir=_inplace_dir,
             ),
         )
         duration_ms = int((time.time() - t0) * 1000)
@@ -741,8 +743,9 @@ async def run_agent_stream(
     # 工作区对话模式:本次 run 经 context.work_dir 覆盖工作文件夹(见 run_agent 同段注释)。
     import os as _os
     _ctx_wd = (req.context or {}).get("work_dir") if isinstance(req.context, dict) else ""
-    if _ctx_wd and _os.path.isdir(_ctx_wd):
-        work_dir = source_dir = _ctx_wd
+    _inplace_dir = _ctx_wd if (_ctx_wd and _os.path.isdir(_ctx_wd)) else ""
+    if _inplace_dir:
+        work_dir = source_dir = _inplace_dir
 
     # 会话记忆 P1:thread 解析(run 记录在下方流式分支创建,这里先拿 id)
     from agentpaas.engine import thread_memory as _tm
@@ -960,6 +963,7 @@ async def run_agent_stream(
                     work_dir=work_dir, source_dir=source_dir, run_dir=run_dir,
                     kb_tools=_make_platform_kb_tools(
                         db, kb_ids, tenant.tenant_id, kb_search_mode),
+                    inplace_dir=_inplace_dir,
                 )
                 duration_ms = int((time.time() - t0) * 1000)
                 workspace_path = trace_info.get("workspace_path", "")
@@ -1931,7 +1935,7 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
                    agent_dir: str = "", run_id: str = "",
                    continue_workspace: str = "", work_dir: str = "",
                    source_dir: str = "", run_dir: str = "",
-                   kb_tools: dict = None):
+                   kb_tools: dict = None, inplace_dir: str = ""):
     """Execute agent via lambdagent. Returns (result, trace_info).
 
     3-directory system
@@ -2071,6 +2075,11 @@ def _execute_agent(config: dict, input_text: str, on_step=None,
         overrides = {}
         if workspace_path:
             overrides["workspace_path"] = workspace_path
+        # 工作区对话模式:给模型看的 [工作目录] = 用户选的文件夹 F(就地读写改),
+        # 而 config.yml / trace 仍写到上面的 workspace_path(agent run_dir,不污染 F)。
+        # 即把"模型操作目录"与"日志/产物目录"解耦——仅工作区模式(inplace_dir 有值)生效。
+        if inplace_dir and os.path.isdir(inplace_dir):
+            overrides["workspace_path"] = os.path.abspath(inplace_dir)
         if on_step:
             def _step_adapter(step_event):
                 """Convert StepEvent dataclass to SSE dict for the queue."""

+ 3 - 0
tests/test_agentpack_e2e.py

@@ -505,6 +505,7 @@ def test_run_with_context_work_dir_override(tmp_path, http_client, monkeypatch):
     def _stub_exec(config, input_text, **kw):
         captured["work_dir"] = kw.get("work_dir")
         captured["source_dir"] = kw.get("source_dir")
+        captured["inplace_dir"] = kw.get("inplace_dir")
         return ("摘要:ok", {"workspace_path": "", "input_tokens": 1,
                              "output_tokens": 1, "steps": 1, "cost_usd": 0.0,
                              "cache_read_tokens": 0, "cache_creation_tokens": 0})
@@ -523,3 +524,5 @@ def test_run_with_context_work_dir_override(tmp_path, http_client, monkeypatch):
     # override 生效:_execute_agent 收到的 work_dir/source_dir 都是所选文件夹
     assert captured.get("work_dir") == folder
     assert captured.get("source_dir") == folder
+    # 工作区模式:inplace_dir 传入 → 模型 [工作目录] 会被设为 F(就地操作)
+    assert captured.get("inplace_dir") == folder

+ 37 - 13
webui/src/pages/Chat.tsx

@@ -12,6 +12,7 @@ import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type
 import { knowledgeApi } from '../api/knowledge'
 import { useAppStore } from '../store/app'
 import { Spinner } from '../components/ui'
+import DirPickerModal from '../components/DirPickerModal'
 import { clsx } from '../lib/clsx'
 import { useMode } from '../api/mode'
 
@@ -1112,7 +1113,8 @@ export default function Chat() {
   })
   // 工作区对话模式:/chat/{id}?dir=/path → 每次 run 把工作文件夹带进
   // context.work_dir,让助手就地在该文件夹读写改跑(Claude Code 式)。
-  const [workDir] = useState(() => new URLSearchParams(window.location.search).get('dir') ?? '')
+  const [workDir, setWorkDir] = useState(() => new URLSearchParams(window.location.search).get('dir') ?? '')
+  const [dirPickerOpen, setDirPickerOpen] = useState(false)
   const [streaming, setStreaming]     = useState(false)
   const [memoryOpen, setMemoryOpen]   = useState(false)
   const abortRef  = useRef<(() => void) | null>(null)
@@ -1379,12 +1381,14 @@ export default function Chat() {
           input: userText,
           parameters: {},
           context: {
-            ...(ctxRunId ? { run_id: ctxRunId, workspace_path: ctxPath } : {}),
+            // 工作区对话模式(workDir)不挂续跑上下文——干净会话,避免旧 run
+            // 工作区劫持 [工作目录];连续性靠 thread 记忆 + 文件夹里的真实文件。
+            ...((ctxRunId && !workDir) ? { run_id: ctxRunId, workspace_path: ctxPath } : {}),
             ...(workDir ? { work_dir: workDir } : {}),
           },
-          // Only meaningful when continuing — fresh runs always run as iterate
-          mode: ctxRunId ? ctxMode : 'iterate',
-          target_subagent: ctxRunId && ctxMode === 'edit' ? ctxTarget : '',
+          // Only meaningful when continuing — fresh / workspace runs run as iterate
+          mode: (ctxRunId && !workDir) ? ctxMode : 'iterate',
+          target_subagent: (ctxRunId && !workDir && ctxMode === 'edit') ? ctxTarget : '',
           // 会话记忆:带上当前 thread(首条消息为空 → 后端新建,started 回填)
           thread_id: threadIdRef.current ?? '',
         }),
@@ -1715,15 +1719,17 @@ export default function Chat() {
               {' · '}
               {(agent?.config?.model as Record<string, unknown>)?.name as string ?? ''}
             </p>
-            {/* 工作区对话:当前工作文件夹 */}
+            {/* 工作区对话:当前工作文件夹(点击换文件夹) */}
             {workDir && (
-              <span
-                className="inline-flex items-center gap-1 text-xs bg-emerald-50 text-emerald-700 border border-emerald-200 px-1.5 py-0.5 rounded-full max-w-[280px]"
-                title={`工作文件夹(就地读写):${workDir}`}
+              <button
+                onClick={() => setDirPickerOpen(true)}
+                className="inline-flex items-center gap-1 text-xs bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 px-1.5 py-0.5 rounded-full max-w-[320px] transition-colors"
+                title={`工作文件夹(就地读写):${workDir}\n点击切换文件夹`}
               >
                 <Folder size={9} className="shrink-0" />
                 <span className="truncate font-mono">{workDir.split('/').slice(-2).join('/')}</span>
-              </span>
+                <span className="text-emerald-400 shrink-0">·换</span>
+              </button>
             )}
             {/* KB badges */}
             {(agentKBData?.knowledge_bases ?? []).map(kb => (
@@ -1806,8 +1812,9 @@ export default function Chat() {
         </button>
       </div>
 
-      {/* Directory info bar — shown only when any dir is configured */}
-      {(agent?.source_dir || agent?.work_dir || agent?.run_dir) && (
+      {/* Directory info bar — 专项模式才显示 agent 的来源/产物/日志;
+          工作区对话模式(workDir)只认所选文件夹 F,隐藏这条避免目录打架 */}
+      {!workDir && (agent?.source_dir || agent?.work_dir || agent?.run_dir) && (
         <DirInfoBar
           sourceDir={agent.source_dir}
           workDir={agent.work_dir}
@@ -1888,12 +1895,29 @@ export default function Chat() {
         {memoryOpen && agentId && (
           <MemoryPanel agentId={agentId} onClose={() => setMemoryOpen(false)} />
         )}
+
+        {/* 工作区对话:换文件夹 → 换工作目录 + 开新会话 */}
+        {dirPickerOpen && (
+          <DirPickerModal
+            initial={workDir}
+            onClose={() => setDirPickerOpen(false)}
+            onSelect={(folder) => {
+              setDirPickerOpen(false)
+              if (!folder || folder === workDir) return
+              setWorkDir(folder)
+              const u = new URL(window.location.href)
+              u.searchParams.set('dir', folder)
+              window.history.replaceState({}, '', u.toString())
+              startNewThread()  // 换文件夹 = 干净新会话
+            }}
+          />
+        )}
       </div>
 
       {/* Continue-from-run banner — pinned above input so it's always visible
           right before the user sends. Distinguishes "continue this run"
           from "start a fresh conversation". */}
-      {continueFromRunId && (() => {
+      {continueFromRunId && !workDir && (() => {
         const prevRun = runsData?.runs?.find(r => r.id === continueFromRunId)
         const prevInputPreview = prevRun?.input?.slice(0, 120) ?? ''
         return (