Explorar o código

feat(workbench): 工作区对话模式(Claude Code 式:选文件夹直接对话)

工作台在「选专项智能体」之外新增「工作区对话」:选一个本地文件夹,像 Claude Code
一样直接读写改跑、多轮协作。两种模式同一套 run/thread 通道,差异只在 (agent, work_dir)。

后端:
- 新增内置包 workspace.assistant(第 12 个内置包)——react 全工具
  (读/写/EditFile/Bash/搜索/RunTests/Git*)+ memory + 默认 claude-code/sonnet +
  systemPrompt 硬约束"就地在所选文件夹操作" + guard(dangerousCommandBlock+highRiskConfirmation)
- run 按会话覆盖工作目录:RunRequest.context.work_dir → work_dir=source_dir=该文件夹
  (Bash CWD + 读写就地在此;run_dir 保持 agent 自己的,trace 不污染用户项目),两端点都接
- builtin 安装传 trusted=True:内置包是第一方可信包,允许其声明 shell(workspace
  助手的 Bash 能力);shell 拒绝策略只针对不受信三方包

前端:
- Dashboard 加「💻 工作区对话」卡片 → 复用 DirPickerModal 选文件夹 → 找/建工作区
  助手实例 → 带 ?dir 进对话
- Chat 读 ?dir 存 workDir,每次 run 把 work_dir 并进 context;头部显示 📁 当前工作文件夹

测试:bundled pack 的"无 shell"策略改为"带 shell 必须有 dangerousCommandBlock 兜底"。
验证:38 pack 测试绿(原 4 失败已修)、webui tsc+构建过、服务重启 workspace.assistant
自动安装、browse-dir 活。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju hai 2 meses
pai
achega
d3b1019b4c

+ 37 - 0
agentexample/agentpacks/workspace.assistant/README.md

@@ -0,0 +1,37 @@
+# 工作区助手(workspace.assistant)
+
+Claude Code 式的**通用工作区助手**。在工作台选一个本地文件夹,就能用自然语言
+直接对话,助手在该文件夹里读文件、改代码、写文档、跑命令、查 Git,多轮连续协作。
+
+## 与「专项智能体」的关系(两种模式兼得)
+
+平台的对话本质是 `(智能体 + 工作文件夹 + 会话)` 三元组:
+
+- **专项智能体**:选某个领域智能体(审稿/批改/出卷…),用它的专用提示词与工具。
+- **工作区对话(本助手)**:选一个文件夹 + 通用助手,像 Claude Code 一样就地读写改跑。
+
+两者共用同一套对话/运行/会话记忆通道,差异只在"用哪个智能体、工作文件夹是哪个"。
+
+## 能力
+
+- 读:ReadFile / ListFiles / SearchContent(先摸清结构再动手)
+- 改:EditFile(精确改动)/ WriteFile(新建或重写),**就地修改工作文件夹内的文件**
+- 跑:Bash(当前目录即工作文件夹)/ RunTests
+- Git:GitStatus / GitDiff / GitLog
+- 多轮:会话记忆,承接前文继续
+
+## 安全
+
+- 所有读写**限定在所选工作文件夹内**(提示约束 + guard 兜底)。
+- **危险/不可逆命令先停下征求确认**:`rm -rf`、覆盖重要文件、`git reset --hard`、
+  `git push -f` 等(`dangerousCommandBlock` + `highRiskConfirmation`)。
+- 不读取也不外传工作文件夹之外的数据。
+
+## 模型
+
+默认 `claude-code/sonnet`(agentic 编码/文件操作最强,最接近 Claude Code 体验);
+可切 `dashscope/qwen-max` 或离线 `ollama/qwen`。
+
+## 用法
+
+工作台 → 「💻 工作区对话」卡片 → 选文件夹 → 直接对话。

+ 83 - 0
agentexample/agentpacks/workspace.assistant/agents/assistant.yml

@@ -0,0 +1,83 @@
+agentId: workspace-assistant
+name: 工作区助手
+description: >
+  Claude Code 式通用助手。在用户指定的工作文件夹里读写文件、改代码、写文档、
+  跑命令、查 Git,多轮连续协作。所有读写就地在该文件夹内完成。
+
+type: react
+
+model:
+  provider: claude-code
+  name: sonnet
+  temperature: 0.2
+  maxTokens: 8192
+
+systemPrompt: |
+  你是「工作区助手」——一个在用户指定**工作文件夹**里干活的通用助手,
+  风格务实、直接、可执行,像一个细心的结对工程师。
+
+  ## 工作文件夹(最重要,先读)
+  - 本次会话的工作文件夹由 [数据来源目录] 给出(也是 Bash 的当前目录)。
+    **你的一切读、写、改、运行都在这个文件夹内就地完成。**
+  - 读文件:用该文件夹下的**完整绝对路径** ReadFile;先 ListFiles / SearchContent
+    摸清结构再动手,不要凭空假设文件存在。
+  - 改文件:优先 **EditFile**(精确改动、保留其余内容);新建/整体重写才用 WriteFile。
+    一律用工作文件夹内的绝对路径,**就地修改用户的文件**。
+  - 跑命令:Bash 的当前目录已是工作文件夹,直接跑(如 `ls`、`grep`、`python x.py`、
+    `npm test`)。需要时用 RunTests、Git* 查看状态/diff/历史。
+  - **不要**把产物写到 [工作目录](那是日志区)或别的地方;除非用户明确要求,
+    不在工作文件夹之外读写。
+
+  ## 安全铁律
+  - **危险/不可逆命令先停下征求确认**:`rm -rf`、`> 覆盖重要文件`、`git reset --hard`、
+    `git push -f`、批量删除/移动、改动 `.git` 内部、`chmod -R`、下载执行脚本等。
+    先说清你要做什么、影响什么,等用户同意再做。
+  - 改动前先让用户知道你打算改哪些文件、为什么;大改分步做、边做边汇报。
+  - 不读取也不外传工作文件夹之外的隐私数据。
+
+  ## 工作方式
+  1. 先理解任务 + 摸清相关文件(ListFiles/SearchContent/ReadFile)。
+  2. 给出简短计划(要改哪些文件、思路),动手执行。
+  3. 每步用工具真实落地(EditFile/WriteFile/Bash),**不要把改动只写在回答里**——
+     回答只汇报"改了什么文件、为什么、怎么验证",具体内容落到文件。
+  4. 能验证就验证(跑测试、读回改动、`git diff`)。
+  5. 多轮协作:记住本会话前面做过什么,承接着继续。
+
+  ## 风格
+  中文为主(代码/命令/路径保留原文);简洁、给可执行建议;不确定就说不确定、
+  先查再答,不编造文件内容或运行结果。
+
+guard:
+  validator: "len(x) > 0"
+  retry: 0
+  fallback: last
+  dangerousCommandBlock: true
+  highRiskConfirmation: true
+
+react:
+  maxSteps: 80
+  observationEnabled: true
+  toolTimeout: 180
+  thinkTimeout: 300
+
+memory:
+  enabled: true
+  strategy: local
+  size: 60
+
+mcp:
+  localTools:
+    - ReadFile
+    - WriteFile
+    - EditFile
+    - ListFiles
+    - SearchContent
+    - Bash
+    - RunTests
+    - GitStatus
+    - GitDiff
+    - GitLog
+    - terminate
+  policy:
+    mode: auto
+    maxConcurrent: 1

+ 29 - 0
agentexample/agentpacks/workspace.assistant/manifest.yml

@@ -0,0 +1,29 @@
+id: workspace.assistant
+name: 工作区助手
+version: 0.1.0
+domain: general
+entrypoint: agents/assistant.yml
+description: >
+  Claude Code 式的通用工作区助手:指定一个本地文件夹后,直接用自然语言对话,
+  助手就在该文件夹里读文件、改代码、写文档、跑命令、查 Git,多轮连续协作。
+  与「专项智能体」互补——专项做特定领域任务,本助手做"选个文件夹随便聊着改"。
+  危险命令默认拦截、高风险操作需确认;所有读写限定在所选文件夹内。
+audience:
+  - professor
+  - lecturer
+  - researcher
+  - developer
+permissions:
+  network: false
+  shell: true
+  file_write: workspace
+  read_knowledge: true
+  read_filesystem: true
+model:
+  recommended:
+    - claude-code/sonnet
+    - dashscope/qwen-max
+    - ollama/qwen
+author: kenny67nju
+homepage: https://github.com/kenny67nju/lambdagentpaas
+license: BUSL-1.1

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

@@ -579,6 +579,13 @@ async def run_agent(
     source_dir = agent.get("source_dir", "") or ""
     run_dir = agent.get("run_dir", "") or ""
 
+    # 工作区对话模式(Dashboard「工作区对话」):本次 run 经 context.work_dir
+    # 覆盖工作文件夹 —— Bash CWD + 读写就地在该文件夹(同时作 source_dir 注入
+    # [数据来源目录])。run_dir 保持 agent 自己的,trace/日志不污染用户项目文件夹。
+    _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
+
     # 注入顺序(MEMORY_DESIGN §2.2):本会话前情 → 相关历史(L3) → KB → 用户问题
     enriched_input = req.input
     preamble = _tm.build_preamble(db, thread_id)
@@ -730,6 +737,11 @@ async def run_agent_stream(
     source_dir = agent.get("source_dir", "") or ""
     run_dir = agent.get("run_dir", "") or ""
 
+    # 工作区对话模式:本次 run 经 context.work_dir 覆盖工作文件夹(见 run_agent 同段注释)。
+    _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
+
     # 会话记忆 P1:thread 解析(run 记录在下方流式分支创建,这里先拿 id)
     from agentpaas.engine import thread_memory as _tm
     thread_id = _tm.get_or_create_thread(

+ 7 - 1
agentpaas/src/agentpaas/engine/builtin_packs.py

@@ -33,6 +33,8 @@ BUILTIN_PACK_IDS: list[str] = [
     "teaching.assignment-grader",
     # 学术服务
     "service.academic-letters",
+    # 通用
+    "workspace.assistant",
 ]
 
 
@@ -96,7 +98,11 @@ def ensure_builtin_packs_installed(packs_dir: str) -> None:
                     continue
 
                 zip_path = _zip_pack(pack_id, pack_dir, Path(td))
-                pack = install_from_zip(str(zip_path), packs_dir)
+                # 内置包是第一方、随产品分发的可信包:trusted=True 允许其声明
+                # shell(如 workspace.assistant 的 Claude Code 式 Bash 能力)——
+                # shell 拒绝策略针对的是不受信的三方包。运行期仍有 guard
+                # (dangerousCommandBlock/highRiskConfirmation) 兜底。
+                pack = install_from_zip(str(zip_path), packs_dir, trusted=True)
                 logger.info(
                     f"auto-installed builtin pack: {pack_id} v{pack.version}"
                 )

+ 12 - 2
tests/test_agentpack.py

@@ -245,12 +245,22 @@ def test_bundled_pack_valid_and_compiles(manifest_path):
     pack_dir = os.path.dirname(manifest_path)
     m = load_manifest(pack_dir)
     assert os.path.isfile(m.entrypoint_path()), m.entrypoint
-    # Built-in research packs are local-first: no shell, no network.
-    assert m.permissions.shell is False
+    # Built-in packs are local-first: no network.
     assert m.permissions.network is False
     # The entrypoint must compile to a runnable term.
     term = from_config(m.entrypoint_path())
     assert term is not None
+    # Policy: bundled packs default to no shell. The one exception — a
+    # Claude-Code-style workspace assistant — may declare shell:true, but
+    # ONLY if its agent guards it with dangerousCommandBlock (runtime safety
+    # net for arbitrary Bash). 杜绝"内置包随便要 shell 又不设防护"。
+    if m.permissions.shell:
+        import yaml as _yaml
+        cfg = _yaml.safe_load(open(m.entrypoint_path(), encoding="utf-8"))
+        guard = cfg.get("guard") or {}
+        assert guard.get("dangerousCommandBlock") is True, (
+            f"bundled pack {m.id} 声明 shell:true 但未设 dangerousCommandBlock 防护"
+        )
 
 
 # ─────────────────────────────────────────────────────────────────────────────

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

@@ -1110,6 +1110,9 @@ export default function Chat() {
     const q = new URLSearchParams(window.location.search).get('q')
     return q ?? ''
   })
+  // 工作区对话模式:/chat/{id}?dir=/path → 每次 run 把工作文件夹带进
+  // context.work_dir,让助手就地在该文件夹读写改跑(Claude Code 式)。
+  const [workDir] = useState(() => new URLSearchParams(window.location.search).get('dir') ?? '')
   const [streaming, setStreaming]     = useState(false)
   const [memoryOpen, setMemoryOpen]   = useState(false)
   const abortRef  = useRef<(() => void) | null>(null)
@@ -1375,7 +1378,10 @@ export default function Chat() {
         body: JSON.stringify({
           input: userText,
           parameters: {},
-          context: ctxRunId ? { run_id: ctxRunId, workspace_path: ctxPath } : {},
+          context: {
+            ...(ctxRunId ? { 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 : '',
@@ -1709,6 +1715,16 @@ 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}`}
+              >
+                <Folder size={9} className="shrink-0" />
+                <span className="truncate font-mono">{workDir.split('/').slice(-2).join('/')}</span>
+              </span>
+            )}
             {/* KB badges */}
             {(agentKBData?.knowledge_bases ?? []).map(kb => (
               <span

+ 51 - 1
webui/src/pages/Dashboard.tsx

@@ -15,13 +15,16 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
 import { useNavigate } from 'react-router-dom'
 import {
   ArrowRight, Bot, FileSearch, ClipboardList, Mail, NotebookPen,
-  Loader2, MessageSquare, Route, X,
+  Loader2, MessageSquare, Route, X, FolderCode,
 } from 'lucide-react'
 import toast from 'react-hot-toast'
 import { statusApi } from '../api/status'
 import { agentsApi, type Agent } from '../api/agents'
 import { agentpacksApi } from '../api/agentpacks'
 import { Card, Spinner, Badge } from '../components/ui'
+import DirPickerModal from '../components/DirPickerModal'
+
+const WORKSPACE_PACK = 'workspace.assistant'
 
 // 常用场景 → 内置包映射(包未安装时卡片自动隐藏)
 const SCENARIOS = [
@@ -57,6 +60,8 @@ export default function Dashboard() {
   const [routing, setRouting] = useState(false)
   const [pickerOpen, setPickerOpen] = useState(false)
   const [creatingPack, setCreatingPack] = useState<string | null>(null)
+  const [wsPickerOpen, setWsPickerOpen] = useState(false)
+  const [openingWs, setOpeningWs] = useState(false)
 
   const { data: agentData } = useQuery({
     queryKey: ['agents'], queryFn: agentsApi.list, retry: false,
@@ -120,6 +125,27 @@ export default function Dashboard() {
     }
   }
 
+  // ── 工作区对话(Claude Code 式):选文件夹 → 找/建工作区助手实例 → 带 ?dir 进对话 ──
+  async function openWorkspace(folder: string) {
+    setWsPickerOpen(false)
+    setOpeningWs(true)
+    try {
+      let agentId = agents
+        .filter(a => a.agent_template === WORKSPACE_PACK)
+        .sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))[0]?.id
+      if (!agentId) {
+        const res = await agentpacksApi.createAgent(WORKSPACE_PACK, { name: '工作区助手' })
+        qc.invalidateQueries({ queryKey: ['agents'] })
+        agentId = res.agent_id
+      }
+      navigate(`/chat/${agentId}?dir=${encodeURIComponent(folder)}`)
+    } catch (e) {
+      toast.error(e instanceof Error ? e.message : '打开工作区失败(工作区助手包是否已安装?)')
+    } finally {
+      setOpeningWs(false)
+    }
+  }
+
   const scenarios = SCENARIOS.filter(s => installedPacks.has(s.packId))
 
   return (
@@ -153,6 +179,22 @@ export default function Dashboard() {
             </button>
           </div>
         </div>
+
+        {/* 工作区对话入口(Claude Code 式):选个文件夹直接对话 */}
+        <button
+          onClick={() => setWsPickerOpen(true)}
+          disabled={openingWs}
+          className="mt-3 w-full flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-2xl hover:border-emerald-300 hover:bg-emerald-50/40 transition-colors text-left disabled:opacity-60"
+        >
+          <span className="flex items-center justify-center w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 shrink-0">
+            {openingWs ? <Loader2 size={18} className="animate-spin" /> : <FolderCode size={18} />}
+          </span>
+          <span className="flex-1 min-w-0">
+            <span className="block text-sm font-medium text-gray-900">工作区对话</span>
+            <span className="block text-xs text-gray-500">选一个文件夹,像 Claude Code 一样直接读写改跑、多轮协作</span>
+          </span>
+          <ArrowRight size={16} className="text-gray-300 shrink-0" />
+        </button>
       </div>
 
       {/* ── 2. 继续上次的工作 ── */}
@@ -259,6 +301,14 @@ export default function Dashboard() {
           </div>
         </div>
       )}
+
+      {/* 工作区对话:选文件夹 */}
+      {wsPickerOpen && (
+        <DirPickerModal
+          onSelect={openWorkspace}
+          onClose={() => setWsPickerOpen(false)}
+        />
+      )}
     </div>
   )
 }