fs tools 输出 user_root-relative 路径 + dev SPA chip 锚点修正 + assistant 正文也挂 chip
- tools/base.Tool: 加 user_root kwarg + _display(p) helper(p 在 user_root 内 → POSIX 相对,外 → 原绝对) - tools/fs.py: Read/Write/Edit/Glob/Grep 所有结果串里路径都过 _display,不再泄 user_id / 部署根 - core/agent_builder: build_agent 把 user_root 透传给所有 tool(含 ShellTool / RunPythonTool / LoadSkillTool — base 默认 None 不影响) - tools/skill_tool: __init__ 加 user_root 转传 super - web/static/dev.html: 新加 _workingDirName helper(从 db 形 working_dir 取末段 + 跳过外部绝对路径);5 个 chip 抽取点统一用它代替原 working_dir 直取 → 根治 chip 点击 404;assistant 正文也接 chip 抽取 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1a2961bf4
commit
5ff09b9aca
|
|
@ -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/<uuid>/<name>`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users/<uuid>/<wd>/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/.../<uuid>/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 `<div class="body">` 渲完后 `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 末段为锚 → 旧路径里的 `/<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)` 把文本里 `\` 一律归 `/`,正则锚定 `<working_dir>/...`(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 直接撞。文件名约定 `<YYYY-MM-DD>-<task_short_id>-<task_name>.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*-<short_id>-*.spec.md` 字典序最大 = current;`<YYYY-MM-DD>` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`<task_name>` 写入作建时元数据,改 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 取舍说明。
|
||||
|
|
|
|||
|
|
@ -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 抽取稳定锚定 <wd>/ 前缀)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 抽取(限定 <wd>/ 前缀)更稳。
|
||||
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)
|
||||
|
|
|
|||
35
tools/fs.py
35
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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
||||
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||||
|
|
@ -1321,12 +1321,12 @@ function renderMessages(msgs) {
|
|||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||||
// assistant 正文里 echo 的 <wd>/... 路径同样挂 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 = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||
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 = `<summary>工具结果</summary><pre>${escapeHtml(txtStr)}</pre>`;
|
||||
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 相对路径。
|
||||
// 启发式:把 \ 一律归 /,然后找以 `<working_dir>/` 打头的串,要求最后一段含 . (像文件)。
|
||||
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(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 相对。
|
||||
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
||||
function extractArtifactRels(text, workingDir) {
|
||||
if (!text || !workingDir) return [];
|
||||
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
|
|
|
|||
Loading…
Reference in New Issue