refactor(dev): 前端模块化 Step 2 — 抽出 media.js + 收敛 downloadFile 反向依赖
对话内工具活动标签 + artifact(产物)抽取/渲染:toolActivityLabel /
extractArtifactRels / extractMediaBanner / renderArtifactBarHtml /
upgradeMediaArtifacts / downloadFile → media.js(237 行,原 main 1134–1359)。
收敛点:downloadFile 移入 media 后,preview.js / files.js 对它的 import
从 ./main.js 改指 ./media.js —— 把这条反向依赖从 main 挪开。media 导入极少
(escapeHtml / _categorize(preview)/ state / logout),与 preview 成
media↔preview 环(均运行时调用,安全)。
两次险漏靠校验抓回:
- 共享 const ARTIFACT_PRODUCING_TOOLS(main renderMessages/SSE 用 4 处,
.has() 访问非函数调用,"被调标识符"法漏掉)
- 内部函数 _flushMediaArtifactCache(selectTask 切任务清缓存用)
两者经残留符号检查发现后补 export。
新增全模块 import/export 一致性校验脚本(每个 import{X} 必在目标 export),
11 模块全过。main.js 删至 1393 行。node --check 11 模块全过、静态测试 2 过。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
71bac870ed
commit
e5940266ca
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-06-06(前端模块化 Step 2:抽出 layout / auth / preview / files.js)
|
最后更新:2026-06-07(前端模块化 Step 2:抽出 layout / auth / preview / files / media.js)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-06-06
|
### 2026-06-06
|
||||||
|
|
||||||
|
- **前端模块化 Step 2:抽出 `media.js`(工具活动标签 + artifact 抽取/渲染)+ 收敛 downloadFile 反向依赖**:对话内 `toolActivityLabel`(工具调用→中文活动名)、`extractArtifactRels`(从结果文本/working_dir 提产物路径)、`extractMediaBanner`(seedream/seedance 横幅)、`renderArtifactBarHtml`(产物 chip 条 + 图/视频内联占位)、`upgradeMediaArtifacts`(占位异步 fetch blob 填 `<img>`/`<video>` 带缓存)、`downloadFile`(blob 下载)→ `media.js`(237 行,原 main.js 1134–1359)。**收敛点**:downloadFile 移入 media 后,`preview.js`/`files.js` 对它的 import 从 `./main.js` 改指 `./media.js` —— 把这条反向依赖从 main 挪开。media 导入极少(`escapeHtml`/`_categorize`(preview)/`state`/`logout`),与 preview 成 media↔preview 环(均运行时调用,安全)。**两次险漏靠校验抓回**:① 共享 const `ARTIFACT_PRODUCING_TOOLS`(main renderMessages/SSE 用 4 处,`.has()` 访问非函数调用,"被调标识符"法漏掉)② 内部函数 `_flushMediaArtifactCache`(selectTask 切任务清缓存用)—— 残留符号检查发现后补 export。新增**全模块 import/export 一致性校验脚本**(每个 `import{X}` 必在目标 `export`),11 模块全过。main.js 删至 1393 行。`node --check` 11 模块全过、静态测试 2 过。
|
||||||
- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 − 段内定义 − 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。
|
- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 − 段内定义 − 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。
|
||||||
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。
|
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。
|
||||||
- **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。
|
- **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import { api } from "./api.js";
|
||||||
import { escapeHtml, humanSize } from "./format.js";
|
import { escapeHtml, humanSize } from "./format.js";
|
||||||
import { openFilePreview } from "./preview.js";
|
import { openFilePreview } from "./preview.js";
|
||||||
import { logout } from "./auth.js";
|
import { logout } from "./auth.js";
|
||||||
import { downloadFile, selectTask, loadTaskList, loadFolderSuggestions } from "./main.js";
|
import { downloadFile } from "./media.js";
|
||||||
|
import { selectTask, loadTaskList, loadFolderSuggestions } from "./main.js";
|
||||||
|
|
||||||
// ───── files(user-rooted,不绑 task) ─────
|
// ───── files(user-rooted,不绑 task) ─────
|
||||||
$("btn-refresh-files").onclick = () => loadFiles();
|
$("btn-refresh-files").onclick = () => loadFiles();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { mqPhone, setMobileView } from "./layout.js";
|
||||||
import { logout, closeChpwModal } from "./auth.js";
|
import { logout, closeChpwModal } from "./auth.js";
|
||||||
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
|
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
|
||||||
import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js";
|
import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js";
|
||||||
|
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
|
||||||
|
|
||||||
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
||||||
let _embedInitialTaskHandled = false;
|
let _embedInitialTaskHandled = false;
|
||||||
|
|
@ -1131,232 +1132,6 @@ function exportTask(tid) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
|
|
||||||
// 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。
|
|
||||||
// 把一次 tool_call 翻成一句中文活动描述(展示在折叠标题行,不展开就能看懂在干啥)。
|
|
||||||
// args 是后端 _execute_tool_call 解析后的参数 dict;取每个工具最能代表"在干啥"的字段。
|
|
||||||
function toolActivityLabel(name, args) {
|
|
||||||
const a = (args && typeof args === "object") ? args : {};
|
|
||||||
const clip = (v, n) => {
|
|
||||||
const s = String(v == null ? "" : v).replace(/\s+/g, " ").trim();
|
|
||||||
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
||||||
};
|
|
||||||
switch (name) {
|
|
||||||
case "read": return `读取文件: ${clip(a.path, 80)}`;
|
|
||||||
case "write": return `写入文件: ${clip(a.path, 80)}`;
|
|
||||||
case "edit": return `编辑文件: ${clip(a.path, 80)}`;
|
|
||||||
case "glob": return `查找文件: ${clip(a.pattern, 60)}`;
|
|
||||||
case "grep": return `搜索内容: ${clip(a.pattern, 60)}`;
|
|
||||||
case "shell": return `执行命令: ${clip(a.command, 80)}`;
|
|
||||||
case "run_python": return `运行 Python: ${clip(a.code, 80)}`;
|
|
||||||
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
|
|
||||||
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
|
|
||||||
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
|
|
||||||
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
|
|
||||||
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
|
|
||||||
default: {
|
|
||||||
const p = clip(JSON.stringify(a), 80);
|
|
||||||
return p && p !== "{}" ? `${name} ${p}` : `工具调用: ${name}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 产物工具白名单:**工具 I/O** 维度,只有这些工具的 tool_call args / tool_result
|
|
||||||
// 里 echo 的路径才挂 chip 条 + 图片/视频 inline 大图;通用工具(grep/read/glob/
|
|
||||||
// shell)echo 的路径是"引用"不是"产物",完全不挂(避免把 grep 命中的老 figures/
|
|
||||||
// foo.png 当新产物展示)。**assistant 正文不受此限** —— 助手回复里任何 echo 的
|
|
||||||
// 路径无条件挂 chip(`allowInlineMedia=false`,只 chip 不 inline,跟上面 tool 结果
|
|
||||||
// 可能已 inline 的同图不冲突);用户视角"助手提到的文件理应能点开",chip 是
|
|
||||||
// 可发现性入口,小图标无视觉污染。
|
|
||||||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
|
||||||
// "生成 docx 的工具",入这里但不入 banner 白名单。
|
|
||||||
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
|
||||||
|
|
||||||
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
|
||||||
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
|
||||||
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
|
|
||||||
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
|
|
||||||
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
|
|
||||||
function extractMediaBanner(toolName, resultText) {
|
|
||||||
if (!resultText) return "";
|
|
||||||
if (toolName !== "seedream" && toolName !== "seedance") return "";
|
|
||||||
const firstLine = String(resultText).split("\n", 1)[0] || "";
|
|
||||||
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
|
|
||||||
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
|
|
||||||
const kvs = {};
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(firstLine)) !== null) {
|
|
||||||
kvs[m[1]] = m[2].trim();
|
|
||||||
}
|
|
||||||
if (!kvs.model && !kvs.cost) return "";
|
|
||||||
// model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式
|
|
||||||
const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, "");
|
|
||||||
const parts = [];
|
|
||||||
if (model) parts.push(`<span class="kv model">${escapeHtml(model)}</span>`);
|
|
||||||
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
|
|
||||||
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
|
|
||||||
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
|
|
||||||
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractArtifactRels(text, workingDir) {
|
|
||||||
if (!text || !workingDir) return [];
|
|
||||||
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
|
||||||
if (!wd) return [];
|
|
||||||
const norm = String(text).replace(/\\+/g, "/");
|
|
||||||
const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
|
|
||||||
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
|
|
||||||
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
|
|
||||||
{
|
|
||||||
const re = new RegExp(
|
|
||||||
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
|
||||||
"g"
|
|
||||||
);
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(norm)) !== null) {
|
|
||||||
let rel = m[1];
|
|
||||||
rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英)
|
|
||||||
const tail = rel.slice(wd.length + 1);
|
|
||||||
if (!tail) continue;
|
|
||||||
const last = tail.split("/").pop() || "";
|
|
||||||
if (!last.includes(".")) continue; // 看着像目录的不挂 chip
|
|
||||||
if (seen.has(rel)) continue;
|
|
||||||
seen.add(rel);
|
|
||||||
out.push(rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───── 一次性兼容:协议刚性化前的历史简写消息 ─────
|
|
||||||
// system prompt 改硬约束之前,助手按 SKILL 旧文案 echo 过 `videos/xxx.mp4` /
|
|
||||||
// `figures/xxx.png` 这种裸形式 —— 那些消息已存 DB 改不动,前端这里 prepend
|
|
||||||
// <wdName> 把它们拼成 user_root rel 才能挂 chip。**白名单显式枚举不扩展**:
|
|
||||||
// 新产物 skill 走 system 协议必出全形式,这一层只服务**历史消息**渲染。
|
|
||||||
// 长期(老消息归档/不再回看)整段可删。
|
|
||||||
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
|
|
||||||
for (const dir of LEGACY_PRODUCT_DIRS) {
|
|
||||||
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
// lead 边界跟主规则一致但去掉 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
|
|
||||||
// 匹配(虽然 seen 去重,但浪费 cycles)
|
|
||||||
const re = new RegExp(
|
|
||||||
"(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
|
||||||
"g"
|
|
||||||
);
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(norm)) !== null) {
|
|
||||||
let tail = m[1];
|
|
||||||
tail = tail.replace(/[.,;:!?)\]}>。,;:!?)]+$/, "");
|
|
||||||
const last = tail.split("/").pop() || "";
|
|
||||||
if (!last.includes(".")) continue;
|
|
||||||
const rel = wd + "/" + tail;
|
|
||||||
if (seen.has(rel)) continue;
|
|
||||||
seen.add(rel);
|
|
||||||
out.push(rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具
|
|
||||||
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob)
|
|
||||||
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal),
|
|
||||||
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。
|
|
||||||
function renderArtifactBarHtml(rels, allowInlineMedia = true) {
|
|
||||||
if (!rels || !rels.length) return "";
|
|
||||||
const items = rels.map((rel) => {
|
|
||||||
const name = rel.split("/").pop() || rel;
|
|
||||||
const cat = _categorize(rel);
|
|
||||||
if (allowInlineMedia && (cat === "image" || cat === "video")) {
|
|
||||||
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
|
||||||
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
|
||||||
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
|
||||||
}
|
|
||||||
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
|
|
||||||
}).join("");
|
|
||||||
return `<div class="artifact-bar">${items}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
|
|
||||||
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
|
|
||||||
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
|
|
||||||
const _mediaArtifactCache = new Map();
|
|
||||||
|
|
||||||
function _fetchMediaBlobUrl(rel) {
|
|
||||||
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
|
|
||||||
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
|
||||||
headers: { "Authorization": "Bearer " + state.token },
|
|
||||||
}).then(async (r) => {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
const blob = await r.blob();
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
});
|
|
||||||
_mediaArtifactCache.set(rel, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _flushMediaArtifactCache() {
|
|
||||||
for (const p of _mediaArtifactCache.values()) {
|
|
||||||
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
|
|
||||||
}
|
|
||||||
_mediaArtifactCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
|
|
||||||
// renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。
|
|
||||||
function upgradeMediaArtifacts(root) {
|
|
||||||
const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])");
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
node.dataset.upgraded = "1";
|
|
||||||
const rel = node.dataset.rel;
|
|
||||||
const cat = node.dataset.cat;
|
|
||||||
_fetchMediaBlobUrl(rel).then((url) => {
|
|
||||||
node.innerHTML = "";
|
|
||||||
if (cat === "image") {
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = url;
|
|
||||||
img.alt = rel.split("/").pop() || rel;
|
|
||||||
img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解)
|
|
||||||
node.appendChild(img);
|
|
||||||
} else if (cat === "video") {
|
|
||||||
const v = document.createElement("video");
|
|
||||||
v.src = url;
|
|
||||||
v.controls = true;
|
|
||||||
v.preload = "metadata";
|
|
||||||
node.appendChild(v);
|
|
||||||
}
|
|
||||||
}).catch((e) => {
|
|
||||||
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadFile(rel) {
|
|
||||||
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
|
||||||
headers: { "Authorization": "Bearer " + state.token },
|
|
||||||
}).then(async (r) => {
|
|
||||||
if (!r.ok) { alert("下载失败:" + r.status); return; }
|
|
||||||
const blob = await r.blob();
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = rel.split("/").pop() || "file";
|
|
||||||
document.body.appendChild(a); a.click();
|
|
||||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)─────
|
// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)─────
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
// 对话内的工具活动标签 + artifact(产物)抽取与渲染:
|
||||||
|
// toolActivityLabel(工具调用→中文活动名)、extractArtifactRels(从结果文本/working_dir 提产物相对路径)、
|
||||||
|
// extractMediaBanner(seedream/seedance 媒体横幅)、renderArtifactBarHtml(产物 chip 条,图/视频内联占位)、
|
||||||
|
// upgradeMediaArtifacts(占位异步 fetch blob 填 <img>/<video>,带缓存)、downloadFile(blob 下载)。
|
||||||
|
// 供 main 的 renderMessages 及 preview/files(downloadFile)使用。
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { escapeHtml } from "./format.js";
|
||||||
|
import { _categorize } from "./preview.js";
|
||||||
|
import { logout } from "./auth.js";
|
||||||
|
|
||||||
|
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
|
||||||
|
// 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。
|
||||||
|
// 把一次 tool_call 翻成一句中文活动描述(展示在折叠标题行,不展开就能看懂在干啥)。
|
||||||
|
// args 是后端 _execute_tool_call 解析后的参数 dict;取每个工具最能代表"在干啥"的字段。
|
||||||
|
export function toolActivityLabel(name, args) {
|
||||||
|
const a = (args && typeof args === "object") ? args : {};
|
||||||
|
const clip = (v, n) => {
|
||||||
|
const s = String(v == null ? "" : v).replace(/\s+/g, " ").trim();
|
||||||
|
return s.length > n ? s.slice(0, n) + "…" : s;
|
||||||
|
};
|
||||||
|
switch (name) {
|
||||||
|
case "read": return `读取文件: ${clip(a.path, 80)}`;
|
||||||
|
case "write": return `写入文件: ${clip(a.path, 80)}`;
|
||||||
|
case "edit": return `编辑文件: ${clip(a.path, 80)}`;
|
||||||
|
case "glob": return `查找文件: ${clip(a.pattern, 60)}`;
|
||||||
|
case "grep": return `搜索内容: ${clip(a.pattern, 60)}`;
|
||||||
|
case "shell": return `执行命令: ${clip(a.command, 80)}`;
|
||||||
|
case "run_python": return `运行 Python: ${clip(a.code, 80)}`;
|
||||||
|
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
|
||||||
|
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
|
||||||
|
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
|
||||||
|
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
|
||||||
|
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
|
||||||
|
default: {
|
||||||
|
const p = clip(JSON.stringify(a), 80);
|
||||||
|
return p && p !== "{}" ? `${name} ${p}` : `工具调用: ${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 产物工具白名单:**工具 I/O** 维度,只有这些工具的 tool_call args / tool_result
|
||||||
|
// 里 echo 的路径才挂 chip 条 + 图片/视频 inline 大图;通用工具(grep/read/glob/
|
||||||
|
// shell)echo 的路径是"引用"不是"产物",完全不挂(避免把 grep 命中的老 figures/
|
||||||
|
// foo.png 当新产物展示)。**assistant 正文不受此限** —— 助手回复里任何 echo 的
|
||||||
|
// 路径无条件挂 chip(`allowInlineMedia=false`,只 chip 不 inline,跟上面 tool 结果
|
||||||
|
// 可能已 inline 的同图不冲突);用户视角"助手提到的文件理应能点开",chip 是
|
||||||
|
// 可发现性入口,小图标无视觉污染。
|
||||||
|
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
||||||
|
// "生成 docx 的工具",入这里但不入 banner 白名单。
|
||||||
|
export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
||||||
|
|
||||||
|
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
||||||
|
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
||||||
|
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
|
||||||
|
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
|
||||||
|
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
|
||||||
|
export function extractMediaBanner(toolName, resultText) {
|
||||||
|
if (!resultText) return "";
|
||||||
|
if (toolName !== "seedream" && toolName !== "seedance") return "";
|
||||||
|
const firstLine = String(resultText).split("\n", 1)[0] || "";
|
||||||
|
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
|
||||||
|
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
|
||||||
|
const kvs = {};
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(firstLine)) !== null) {
|
||||||
|
kvs[m[1]] = m[2].trim();
|
||||||
|
}
|
||||||
|
if (!kvs.model && !kvs.cost) return "";
|
||||||
|
// model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式
|
||||||
|
const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, "");
|
||||||
|
const parts = [];
|
||||||
|
if (model) parts.push(`<span class="kv model">${escapeHtml(model)}</span>`);
|
||||||
|
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
|
||||||
|
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
|
||||||
|
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
|
||||||
|
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractArtifactRels(text, workingDir) {
|
||||||
|
if (!text || !workingDir) return [];
|
||||||
|
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!wd) return [];
|
||||||
|
const norm = String(text).replace(/\\+/g, "/");
|
||||||
|
const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
|
||||||
|
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
|
||||||
|
{
|
||||||
|
const re = new RegExp(
|
||||||
|
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(norm)) !== null) {
|
||||||
|
let rel = m[1];
|
||||||
|
rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英)
|
||||||
|
const tail = rel.slice(wd.length + 1);
|
||||||
|
if (!tail) continue;
|
||||||
|
const last = tail.split("/").pop() || "";
|
||||||
|
if (!last.includes(".")) continue; // 看着像目录的不挂 chip
|
||||||
|
if (seen.has(rel)) continue;
|
||||||
|
seen.add(rel);
|
||||||
|
out.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── 一次性兼容:协议刚性化前的历史简写消息 ─────
|
||||||
|
// system prompt 改硬约束之前,助手按 SKILL 旧文案 echo 过 `videos/xxx.mp4` /
|
||||||
|
// `figures/xxx.png` 这种裸形式 —— 那些消息已存 DB 改不动,前端这里 prepend
|
||||||
|
// <wdName> 把它们拼成 user_root rel 才能挂 chip。**白名单显式枚举不扩展**:
|
||||||
|
// 新产物 skill 走 system 协议必出全形式,这一层只服务**历史消息**渲染。
|
||||||
|
// 长期(老消息归档/不再回看)整段可删。
|
||||||
|
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
|
||||||
|
for (const dir of LEGACY_PRODUCT_DIRS) {
|
||||||
|
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// lead 边界跟主规则一致但去掉 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
|
||||||
|
// 匹配(虽然 seen 去重,但浪费 cycles)
|
||||||
|
const re = new RegExp(
|
||||||
|
"(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(norm)) !== null) {
|
||||||
|
let tail = m[1];
|
||||||
|
tail = tail.replace(/[.,;:!?)\]}>。,;:!?)]+$/, "");
|
||||||
|
const last = tail.split("/").pop() || "";
|
||||||
|
if (!last.includes(".")) continue;
|
||||||
|
const rel = wd + "/" + tail;
|
||||||
|
if (seen.has(rel)) continue;
|
||||||
|
seen.add(rel);
|
||||||
|
out.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具
|
||||||
|
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob)
|
||||||
|
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal),
|
||||||
|
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。
|
||||||
|
export function renderArtifactBarHtml(rels, allowInlineMedia = true) {
|
||||||
|
if (!rels || !rels.length) return "";
|
||||||
|
const items = rels.map((rel) => {
|
||||||
|
const name = rel.split("/").pop() || rel;
|
||||||
|
const cat = _categorize(rel);
|
||||||
|
if (allowInlineMedia && (cat === "image" || cat === "video")) {
|
||||||
|
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
||||||
|
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
||||||
|
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
||||||
|
}
|
||||||
|
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="artifact-bar">${items}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
|
||||||
|
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
|
||||||
|
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
|
||||||
|
const _mediaArtifactCache = new Map();
|
||||||
|
|
||||||
|
function _fetchMediaBlobUrl(rel) {
|
||||||
|
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
|
||||||
|
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||||||
|
headers: { "Authorization": "Bearer " + state.token },
|
||||||
|
}).then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
const blob = await r.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
});
|
||||||
|
_mediaArtifactCache.set(rel, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _flushMediaArtifactCache() {
|
||||||
|
for (const p of _mediaArtifactCache.values()) {
|
||||||
|
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
|
||||||
|
}
|
||||||
|
_mediaArtifactCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
|
||||||
|
// renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。
|
||||||
|
export function upgradeMediaArtifacts(root) {
|
||||||
|
const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])");
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.dataset.upgraded = "1";
|
||||||
|
const rel = node.dataset.rel;
|
||||||
|
const cat = node.dataset.cat;
|
||||||
|
_fetchMediaBlobUrl(rel).then((url) => {
|
||||||
|
node.innerHTML = "";
|
||||||
|
if (cat === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = rel.split("/").pop() || rel;
|
||||||
|
img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解)
|
||||||
|
node.appendChild(img);
|
||||||
|
} else if (cat === "video") {
|
||||||
|
const v = document.createElement("video");
|
||||||
|
v.src = url;
|
||||||
|
v.controls = true;
|
||||||
|
v.preload = "metadata";
|
||||||
|
node.appendChild(v);
|
||||||
|
}
|
||||||
|
}).catch((e) => {
|
||||||
|
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFile(rel) {
|
||||||
|
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||||||
|
headers: { "Authorization": "Bearer " + state.token },
|
||||||
|
}).then(async (r) => {
|
||||||
|
if (!r.ok) { alert("下载失败:" + r.status); return; }
|
||||||
|
const blob = await r.blob();
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = rel.split("/").pop() || "file";
|
||||||
|
document.body.appendChild(a); a.click();
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+
|
// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+
|
||||||
// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。
|
// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。
|
||||||
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
|
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
|
||||||
// _categorize 也供媒体段判图/视频。反向依赖 downloadFile(main 媒体段)、logout(auth)。
|
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { $ } from "./dom.js";
|
import { $ } from "./dom.js";
|
||||||
import { humanSize, escapeHtml } from "./format.js";
|
import { humanSize, escapeHtml } from "./format.js";
|
||||||
import { renderMd, highlightIn } from "./markdown.js";
|
import { renderMd, highlightIn } from "./markdown.js";
|
||||||
import { logout } from "./auth.js";
|
import { logout } from "./auth.js";
|
||||||
import { downloadFile } from "./main.js";
|
import { downloadFile } from "./media.js";
|
||||||
|
|
||||||
// ───── file preview ─────
|
// ───── file preview ─────
|
||||||
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
|
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue