diff --git a/PROGRESS.md b/PROGRESS.md index e8a39c5..1b2bbd1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-05-20 +- **fs tool 输出渲染为 user_root-relative 路径(根因消 chip 404 + 防 uuid/部署根泄漏) + dev SPA chip 工作目录锚点修正 + assistant 正文也挂 chip**:用户报对话内 chip 点击 404,根因不在 chip 抽取本身 —— `task.working_dir` DB 形态是 `workspace/users//`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users///foo.md`,backend `_safe_join` 拼出来不存在 → 404。两层修:① **tool 侧根治**:`tools/base.py::Tool` 加 `user_root` kwarg + `_display(p)` helper(p 在 user_root 内 → POSIX 相对串,外 → 原绝对),`tools/fs.py` 五个 tool(Read/Write/Edit/Glob/Grep)所有结果串里 `{p}` 替成 `{self._display(p)}` — 现在 `[wrote N chars to wd/foo.md]` 而不再 `[wrote N chars to /home/lighthouse/...//wd/foo.md]`。`core/agent_builder.py::build_agent` 加 `ur_path = user_root(workspace_dir, uid)` 并透传给所有 tool 构造(含 LoadSkillTool / RunPythonTool / ShellTool — base 默认接 None 不影响);`tools/skill_tool.py::LoadSkillTool.__init__` 加 `user_root` 转传 super。**附带收益**:截图分享对话不再泄 user_id + 服务器路径根;chip rel 直接就是 user_root-relative,与 `/v1/files/download` 边界吻合。② **前端 chip 锚点修正**:`web/static/dev.html` 加 `_workingDirName(workingDir)` helper —— `\` 归 `/` 后,绝对路径(`/...` 或 `C:/...`)返空(外部 --working-dir 文件不在 user_root,backend 也拒,挂 chip 无意义),否则取最后非空段。5 个 chip 抽取调用点(`renderMessages` 的 tool / assistant tool_calls + assistant 正文 + `handleSseEvent` 的 tool_call / tool_result)统一用这个 helper 代替原 `state.taskMeta.working_dir` 直取。③ **assistant 正文也挂 chip**:`renderMessages` 里 assistant `
` 渲完后 `extractArtifactRels(p.content, wd)` 抽出助手 echo 的路径同样挂 chip 条(user 输入不抽,避免他打字过程中误触发)。流式途中不实时挂 — `fetchSse` 收尾自动 `loadMessages()` 重渲染,chip 顺势出现,降低实现复杂度。**没动**:`/v1/files/download` 后端(本来就接 user_root-relative)、ShellTool / RunPythonTool 的 stdout/stderr(subprocess 自己 print 的绝对路径无法干预,且不是 agent 工具直接吐的"系统消息")、DESIGN(无架构/schema 变化)、RUN(无对外命令变化)。**Tradeoff**:旧消息(本次改动前历史 tool result)里仍有绝对路径,但 chip 抽取以 wdName 末段为锚 → 旧路径里的 `//...` 子串也能匹配出正确 rel,**新旧消息 chip 都可点**(回测验证:`extractArtifactRels("/home/.../uuid/wd/foo.md", "wd")` 返 `["wd/foo.md"]`)。 - **`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA「清空对话」按钮**:用户要在同一 task 内重新开始对话。后端新路由:同事务 `SELECT … FOR UPDATE` 锁 + `run_status in (running, cancelling)` → 409(先 cancel)+ `DELETE FROM messages WHERE task_id=tid` + reset `tasks.tokens_prompt/completion/cost_usd=0` + `run_status='idle'` + `run_error=None`,返回新 task dict(`n_messages=0`)。**`usage_events` 表完全不动** — 那是用户级账户账单的 source of truth,清空对话不该影响计费;`usage_events.message_id` FK 是 `ondelete=SET NULL`(models.py:128),message_id 列变 NULL,但 task_id/model_profile/units(tokens_in/out)/cost_usd 全保留,按 task_id 聚合可重建历史累计。**reset task 三列累计 vs 保留累计**:选 reset,因为顶栏「N 条 · M tok」显示"0 条 vs 50k tok"会视觉矛盾;真正账单数据在 usage_events 完整无损。dev SPA 顶栏在「导出对话记录」后插「清空对话」按钮(紫色 hover #8e44ad,区别于完成绿/废弃橙/删除红),`renderChatMeta` 里 `running||n_messages==0 → disabled`,confirm 二次确认(显示任务名 + 消息数),clear 后 `renderMessages([])` + `renderChatMeta()` + `loadTaskList()` 同步列表。**没动**:DESIGN(无架构/schema 字段语义变化)、其他 task 写路径、FS 文件(沿用 task delete 的"FS 视图可重生"心智 — 中间产物保留,模型重起对话可继续基于已有素材推进)、SSE 协议。 - **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `/...`(lead 边界字符类 `[\s"'\`/=:,()<>\[\]{}|]` 避免 `multi_proj_x` 误匹配,末段必须含 `.` 把目录滤掉),Set 去重;`renderArtifactBarHtml(rels)` 渲一行 `.art-chip` 小药丸(`📄 文件名`,前缀 emoji + hover 翻品牌红)。四个渲染点都插入 chip 条:① `renderMessages` 的 `role==="tool"` 历史卡;② `renderMessages` 的 assistant `tool_calls` 历史;③ `handleSseEvent` 的 `tool_call` 流式;④ `handleSseEvent` 的 `tool_result` 流式。`chat-stream` 上加点击委托 → `openFilePreview(rel)`,modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 `working_dir/` 前缀(skill 脚本 `cd` 后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无 `.`)直接跳过。**没动**:右栏文件面板、`openFilePreview` / `downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。 - **task 级「宪法」文件 (spec) 命名约定 + `spec_lock` → `spec` 简化**:同 working_dir 多 task 共享中间产物(`source/` / `sections/` / `figures/` 跨本子复用)是设计意图,但 spec 这种 task 1:1 宪法文件必须隔离 — 两本子 spec 直接撞。文件名约定 `--.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*--*.spec.md` 字典序最大 = current;`` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`` 写入作建时元数据,改 task.name 不 cascade(由 short_id 兜底定位)。`core/agent_builder.py::_build_system_prompt` 加 `task_id` / `today` 注入 + 命名约定段 — 所有 skill 共享一份约定文本,SKILL.md 不再重复;proposal / ppt SKILL.md 阶段一加"先 glob 检测已有 spec → 询问沿用/重定调"分支。`_lock` 后缀无信息量去掉(`templates/spec_lock.md` → `templates/spec.md` git mv 保历史)。**没动**:DB schema(无新字段)、`PATCH /v1/tasks/{id}` 改 name 入口(免 cascade)、其他中间产物扁平共享、quality_check.py(`--spec` 接路径,SKILL.md 拼对参数即可)。**反方案**(cascade rename / spec 入 PG / 物理 task 子目录)及"何时升级到 DB 化"信号见 DESIGN §7.9 取舍说明。 diff --git a/core/agent_builder.py b/core/agent_builder.py index d31011c..2539b29 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -324,17 +324,21 @@ def build_agent( else: session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta) + # user_root 传给 tool 让 fs 输出渲染成相对路径(不泄漏 user_id / 部署根, + # 同时让 web SPA artifact chip 抽取稳定锚定 / 前缀) + ur_path = user_root(workspace_dir, uid) + tools = {} for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): - t = cls(base_dir=tool_base) + t = cls(base_dir=tool_base, user_root=ur_path) tools[t.name] = t if skills.skills: - ls = LoadSkillTool(registry=skills, base_dir=tool_base) + ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path) tools[ls.name] = ls if caps.enable_run_python: - rp = RunPythonTool(base_dir=tool_base) + rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) tools[rp.name] = rp sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None diff --git a/tools/base.py b/tools/base.py index 09c2558..4d327b2 100644 --- a/tools/base.py +++ b/tools/base.py @@ -11,8 +11,13 @@ class Tool(ABC): description: str = "" parameters: dict = {} - def __init__(self, base_dir: Optional[Path] = None) -> None: + def __init__(self, base_dir: Optional[Path] = None, user_root: Optional[Path] = None) -> None: self.base_dir: Path = Path(base_dir) if base_dir else Path.cwd() + # tool 输出渲染路径用:user_root 内的 path 渲成相对 POSIX 串,user_root 外 + # (用户 --working-dir 指向外部目录)保持绝对。None → 全部按绝对渲染。 + # 目的:不让 tool result 文本里出现 user_id / 部署绝对路径,SPA 截图分享更安全; + # 顺便让 web SPA 的 artifact chip 抽取(限定 / 前缀)更稳。 + self.user_root: Optional[Path] = Path(user_root) if user_root else None @abstractmethod def execute(self, **kwargs) -> str: @@ -32,3 +37,12 @@ class Tool(ABC): def _resolve(self, path: str) -> Path: p = Path(path) return p if p.is_absolute() else (self.base_dir / p) + + def _display(self, p: Path) -> str: + """对外渲染路径:在 user_root 内 → POSIX 相对串;否则原绝对。""" + if self.user_root is not None: + try: + return p.resolve().relative_to(self.user_root.resolve()).as_posix() + except (ValueError, OSError): + pass + return str(p) diff --git a/tools/fs.py b/tools/fs.py index 565de5b..918708e 100644 --- a/tools/fs.py +++ b/tools/fs.py @@ -29,20 +29,21 @@ class ReadTool(Tool): def execute(self, path: str, offset: int = 1, limit: int = 2000) -> str: p = self._resolve(path) + disp = self._display(p) if not p.exists(): - return f"[Error] file not found: {p}" + return f"[Error] file not found: {disp}" if not p.is_file(): - return f"[Error] not a file: {p}" + return f"[Error] not a file: {disp}" try: text = p.read_text(encoding="utf-8") except UnicodeDecodeError: - return f"[Error] not a UTF-8 text file: {p}" + return f"[Error] not a UTF-8 text file: {disp}" lines = text.split("\n") start = max(1, offset) end = min(len(lines), start + limit - 1) out = [f"{i+1:6d}\t{lines[i]}" for i in range(start - 1, end)] - header = f"[{p}] lines {start}-{end} of {len(lines)}\n" + header = f"[{disp}] lines {start}-{end} of {len(lines)}\n" return header + "\n".join(out) @@ -65,7 +66,7 @@ class WriteTool(Tool): p = self._resolve(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") - return f"[wrote {len(content)} chars to {p}]" + return f"[wrote {len(content)} chars to {self._display(p)}]" class EditTool(Tool): @@ -86,16 +87,17 @@ class EditTool(Tool): def execute(self, path: str, old_str: str, new_str: str) -> str: p = self._resolve(path) + disp = self._display(p) if not p.exists(): - return f"[Error] file not found: {p}" + return f"[Error] file not found: {disp}" content = p.read_text(encoding="utf-8") count = content.count(old_str) if count == 0: - return f"[Error] old_str not found in {p}" + return f"[Error] old_str not found in {disp}" if count > 1: - return f"[Error] old_str appears {count} times in {p}, must be unique — add more context" + return f"[Error] old_str appears {count} times in {disp}, must be unique — add more context" p.write_text(content.replace(old_str, new_str), encoding="utf-8") - return f"[edited {p}: 1 replacement]" + return f"[edited {disp}: 1 replacement]" class GlobTool(Tool): @@ -113,14 +115,14 @@ class GlobTool(Tool): def execute(self, pattern: str, path: str = ".") -> str: base = self._resolve(path) if not base.exists(): - return f"[Error] base path not found: {base}" + return f"[Error] base path not found: {self._display(base)}" # 把 '**/' 前缀的递归交给 rglob,其他用 glob if "**" in pattern: - matches = sorted(str(p) for p in base.glob(pattern)) + matches = sorted(self._display(p) for p in base.glob(pattern)) else: - matches = sorted(str(p) for p in base.glob(pattern)) + matches = sorted(self._display(p) for p in base.glob(pattern)) if not matches: - return f"[no matches for '{pattern}' under {base}]" + return f"[no matches for '{pattern}' under {self._display(base)}]" return "\n".join(matches[:200]) @@ -147,7 +149,7 @@ class GrepTool(Tool): def execute(self, pattern: str, path: str = ".", glob: str = "", ignore_case: bool = False) -> str: base = self._resolve(path) if not base.exists(): - return f"[Error] base path not found: {base}" + return f"[Error] base path not found: {self._display(base)}" flags = re.IGNORECASE if ignore_case else 0 try: regex = re.compile(pattern, flags) @@ -169,14 +171,15 @@ class GrepTool(Tool): text = f.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): continue + disp = self._display(f) for i, line in enumerate(text.split("\n"), 1): if regex.search(line): - matches.append(f"{f}:{i}:{line}") + matches.append(f"{disp}:{i}:{line}") if len(matches) >= 200: break if len(matches) >= 200: break if not matches: - return f"[no matches for /{pattern}/ in {base}]" + return f"[no matches for /{pattern}/ in {self._display(base)}]" return "\n".join(matches) diff --git a/tools/skill_tool.py b/tools/skill_tool.py index 0c6a67e..0356f1d 100644 --- a/tools/skill_tool.py +++ b/tools/skill_tool.py @@ -31,8 +31,13 @@ class LoadSkillTool(Tool): "required": ["name"], } - def __init__(self, registry: SkillRegistry, base_dir: Optional[Path] = None) -> None: - super().__init__(base_dir) + def __init__( + self, + registry: SkillRegistry, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + ) -> None: + super().__init__(base_dir, user_root=user_root) self.registry = registry def execute(self, name: str) -> str: diff --git a/web/static/dev.html b/web/static/dev.html index c2bda8e..ce2840b 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1304,7 +1304,7 @@ function renderMessages(msgs) { const card = document.createElement("div"); card.className = "msg tool"; const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); - const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); card.innerHTML = `
工具调用 · ${escapeHtml(p.name || "")}
结果(${(txt || "").length} 字符)
${escapeHtml(txt || "")}
@@ -1321,12 +1321,12 @@ function renderMessages(msgs) { html += `
${renderMd(p.content)}
`; // assistant 正文里 echo 的 /... 路径同样挂 chip 条(只对 assistant,user 输入不抽) if (role === "assistant") { - const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); html += renderArtifactBarHtml(extractArtifactRels(p.content, wd)); } } if (Array.isArray(p.tool_calls) && p.tool_calls.length) { - const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); for (const tc of p.tool_calls) { const fn = (tc.function && tc.function.name) || "?"; let args = ""; @@ -1504,7 +1504,7 @@ function handleSseEvent(ev, asstCard, ctx) { det.className = "tool-call"; det.innerHTML = `工具调用:${escapeHtml(fn)}
${escapeHtml(argsStr)}
`; asstCard.appendChild(det); - const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const barHtml = renderArtifactBarHtml(extractArtifactRels(argsStr, wd)); if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml); } else if (t === "tool_result") { @@ -1514,7 +1514,7 @@ function handleSseEvent(ev, asstCard, ctx) { det.className = "tool-call"; det.innerHTML = `工具结果
${escapeHtml(txtStr)}
`; asstCard.appendChild(det); - const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd)); if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml); scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 @@ -1928,8 +1928,20 @@ async function renameFile(rel, name, isDir) { } // ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ───── -// 从 tool args / result 文本里抓 working_dir 下的文件路径,归一为 user_root 相对路径。 -// 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 +// task.working_dir 在 DB 是 `workspace/users//` 形态(to_db_path), +// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下 +// 一级子目录名(同 filesPath 的 wdName 语义)。外部 --working-dir 是绝对路径, +// 文件不在 user_root,backend files API 拒访问 → 不挂 chip。 +function _workingDirName(workingDir) { + if (!workingDir) return ""; + const wd = String(workingDir).replace(/\\+/g, "/"); + if (wd.startsWith("/") || /^[A-Za-z]:/.test(wd)) return ""; // 绝对 = 外部目录,跳过 + const segs = wd.split("/").filter(Boolean); + return segs[segs.length - 1] || ""; +} + +// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。 +// 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 function extractArtifactRels(text, workingDir) { if (!text || !workingDir) return []; const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");