ui(dev SPA): 任务/文件行 ⋯ 下拉菜单 + 顶栏长名截断 + 聊天上传按钮 + 工具调用刷新右侧

- 单例浮层菜单 (position: fixed) 避开 pane overflow 裁剪
- 任务行 ⋯:完成/废弃/导出 docx/删除 (4 色, 按 status/消息数 disabled)
- 文件行 ⋯:重命名/下载(仅文件)/删除, 替代原内联按钮
- pane-head .label 加 nowrap+flex-shrink:0;files-proj 长项目名 11 字截断+title 全名
- chat-upload 复用同一 upload-input, 上传到右侧当前目录
- tool_result 触发 scheduleFilesRefresh (debounce 500ms)
- 重构 setTaskStatus/deleteTask/exportTask 接 tid 参数, 中间 pane 按钮共用同组函数

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 10:50:45 +08:00
parent fafcb14d86
commit f61503fbdb
2 changed files with 198 additions and 56 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(proposal skill 加 mermaid 管线:`render_diagrams.py` 预渲染 + `render_docx.py` 图片插入 + 图题自动编号;`key_rd.md` 占位 `[图 N-N ...]` 换成真 mermaid 例子) 最后更新:2026-05-19(dev SPA 任务/文件行加 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧文件)
--- ---
@ -21,6 +21,7 @@
## 已完成关键能力 ## 已完成关键能力
- **05-19 / dev SPA 任务/文件 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧**:用户提"左侧任务行加下拉菜单(删除/完成/废弃/导出 docx,不同颜色)、右侧文件同理、文件顶栏长项目名压'文件'换行不要、聊天框加跟文件 panel 一样的上传按钮;另:上传后右侧刷新、工具调用返回时右侧也刷新"。**做法**:① **单例浮层菜单**(`#floating-menu`,`position: fixed`)避开 pane `overflow:auto` 裁剪 — `showMenu(triggerEl, items)` 算 trigger 右下展开,空间不足翻上;点 trigger 外 / resize / 任何 scroll 关菜单。② **任务行**(`renderTaskList`):右侧加 `⋯` trigger,菜单 4 项 `complete/abandon/export/delete`,颜色 `act-complete #2e7d32` / `act-abandon #c77800` / `act-export #1565c0` / `act-delete var(--accent)`;`complete/abandon` 在非 active 任务上 disabled,`export` 在 0 消息时 disabled;点击 trigger `stopPropagation` 不触发 row 选中;`state.tasksById` 缓存避免 menu 里再查。③ **文件行**(`renderFiles` + `fileMenuItems`):删除原内联 `改名 / ×` 两个按钮,统一改 `⋯` 菜单 — `重命名` / `下载`(目录不出现) / `删除`,同套颜色;`state.entriesByRel` 缓存 entry。④ **中间 pane-head 已有的 完成/废弃/导出/删除 4 个按钮保留**(操作当前打开任务还是顺手),重构 `setTaskStatus(tid, status, name)` / `deleteTask(tid, name, nMsg)` / `exportTask(tid)` 接受 tid 参数,中间按钮与左侧菜单共用同一组函数。⑤ **"文件"二字换行**:`.pane-head .label` 加 `white-space: nowrap; flex-shrink: 0`;同时 `#files-proj``flex: 0 1 auto` + `min-width: 0` + ellipsis + JS 端 `projName.slice(0, 11) + "…"` 截短(完整名留 title) — 双保险防长项目名挤爆顶栏。⑥ **聊天框上传**(`#chat-upload`):与右侧 `#btn-upload` 都触发同一 `<input type="file" id="upload-input">`,`uploadSelected` 不变(上传到 `state.filesPath` 当前右侧目录),末尾 `await loadFiles()` 已有刷新。⑦ **工具调用刷新文件**:`handleSseEvent` 的 `tool_result` 分支加 `scheduleFilesRefresh()`,debounce 500ms 避免每次 tool_result 都 hit `/v1/files`(SSE 一轮回复里 tool_call 经常一连串)。**没动**:后端(纯前端 UX 调整);DESIGN(不动 — 非架构);RUN(不动 — 无 CLI / env / 文件布局变化);中间 pane 已有按钮文案与 disabled 规则保持不变。**文档**:只动 PROGRESS(按 CLAUDE.md 三文档边界)。**改文件**:仅 `web/static/dev.html`(+~110 行 JS/CSS,-~10 行旧内联按钮代码)。
- **05-19 / dev SPA `/v1/files/download` 加 `Cache-Control: no-cache` + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%**:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(`┌─┐│`)+ 裸 ```...``` 围栏,从未用 mermaid + `![]()`,matplotlib 生成的 `figures/fig*.png` 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch `/v1/files/download` 命中浏览器**启发式缓存**(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。**修法**:① `web/app.py::download_file``headers={"Cache-Control": "no-cache"}` —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② `skills/proposal/scripts/quality_check.py::check_figures` 新加(共 4 条):**1) `figures/` 有 png 但 sections 0 个 `![](...)` 引用 → 图全没挂上**,2) 任何 fenced 代码块里出现 box-drawing 字符(`┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶`)→ ASCII 字符画当图,3) mermaid 块必须有首行 `%% caption: <题>`,4) 同 task 内 mermaid caption 不能撞名;③ **hash → caption 命名重构**(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):`render_diagrams.py` 删 `mermaid_hash()` + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 `caption_to_stem()` 清洗(保留 CJK/字母/数字,其它折 `_`,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);`render_docx.py` 删 `mermaid_hash()` + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ **SKILL.md 精简**(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(`quality_check` 会拦)")。**为什么这一波改这么散**:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。**端到端 smoke**(`/tmp/zcbot_repro` 临时 task):mermaid 块 `%% caption: 总体架构``figures/fig_总体架构.png` 落盘 → docx `figures: 1` 报告对、`word/media/image1.png` 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:`figures/ 有 N 张 png 0 个 ![]()` / `[md:L] ASCII 字符画 ┌─┐│└─┘` / `[md:L] mermaid 缺首行 %% caption` / `mermaid caption 撞名 X 出现在 md1:L1, md2:L2`。**没动**:`render_docx.py` 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(`fig1_xxx.png` 风格留着,反正不冲突,`figures/` 同时存在 mermaid 的 `fig_<caption>.png` 与 matplotlib 的 `fig<N>_<desc>.png` 两种风格)/ `templates/*.md` 里 mermaid 示例首行 `%% caption:` 本来就有(只是历史可选,现在强制约定到位)。**hash → caption 兼容性**:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 `fig_<caption>.png` 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。**文档**:**只动 PROGRESS + skills/proposal/SKILL.md**(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 `Cache-Control` 改动是 `/v1/files/download` 行为微调,客户端无感、文档化为后续 follow-up 可选)。 - **05-19 / dev SPA `/v1/files/download` 加 `Cache-Control: no-cache` + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%**:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(`┌─┐│`)+ 裸 ```...``` 围栏,从未用 mermaid + `![]()`,matplotlib 生成的 `figures/fig*.png` 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch `/v1/files/download` 命中浏览器**启发式缓存**(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。**修法**:① `web/app.py::download_file``headers={"Cache-Control": "no-cache"}` —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② `skills/proposal/scripts/quality_check.py::check_figures` 新加(共 4 条):**1) `figures/` 有 png 但 sections 0 个 `![](...)` 引用 → 图全没挂上**,2) 任何 fenced 代码块里出现 box-drawing 字符(`┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶`)→ ASCII 字符画当图,3) mermaid 块必须有首行 `%% caption: <题>`,4) 同 task 内 mermaid caption 不能撞名;③ **hash → caption 命名重构**(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):`render_diagrams.py` 删 `mermaid_hash()` + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 `caption_to_stem()` 清洗(保留 CJK/字母/数字,其它折 `_`,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);`render_docx.py` 删 `mermaid_hash()` + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ **SKILL.md 精简**(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(`quality_check` 会拦)")。**为什么这一波改这么散**:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。**端到端 smoke**(`/tmp/zcbot_repro` 临时 task):mermaid 块 `%% caption: 总体架构``figures/fig_总体架构.png` 落盘 → docx `figures: 1` 报告对、`word/media/image1.png` 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:`figures/ 有 N 张 png 0 个 ![]()` / `[md:L] ASCII 字符画 ┌─┐│└─┘` / `[md:L] mermaid 缺首行 %% caption` / `mermaid caption 撞名 X 出现在 md1:L1, md2:L2`。**没动**:`render_docx.py` 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(`fig1_xxx.png` 风格留着,反正不冲突,`figures/` 同时存在 mermaid 的 `fig_<caption>.png` 与 matplotlib 的 `fig<N>_<desc>.png` 两种风格)/ `templates/*.md` 里 mermaid 示例首行 `%% caption:` 本来就有(只是历史可选,现在强制约定到位)。**hash → caption 兼容性**:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 `fig_<caption>.png` 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。**文档**:**只动 PROGRESS + skills/proposal/SKILL.md**(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 `Cache-Control` 改动是 `/v1/files/download` 行为微调,客户端无感、文档化为后续 follow-up 可选)。
- **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `<img>` blob URL;② pdf → `<iframe>` blob URL 强制 `application/pdf` mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ `<pre>` textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 `renderMd`(marked + DOMPurify + hljs);⑤ docx → 懒加载 `/static/vendor/jszip.min.js` + `docx-preview.min.js``window.docx.renderAsync(blob, host)` 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 `xlsx.full.min.js`(SheetJS 社区版),多 sheet 出 tab 切换,`sheet_to_html` 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。**机制**:`loadScript()` 懒加载只在首次访问 office 文件才拉 1MB vendor;`_trackBlobUrl` + `_flushBlobUrls` 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。**库选型**:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,`sheet_to_html` 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。**新增 `web/static/vendor/`**(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。**改 `web/static/dev.html`**(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。**没动**:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。**文档**:**只动 PROGRESS + 文件清单加 vendor/ 目录**(按 CLAUDE.md 三文档边界)。 - **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `<img>` blob URL;② pdf → `<iframe>` blob URL 强制 `application/pdf` mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ `<pre>` textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 `renderMd`(marked + DOMPurify + hljs);⑤ docx → 懒加载 `/static/vendor/jszip.min.js` + `docx-preview.min.js``window.docx.renderAsync(blob, host)` 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 `xlsx.full.min.js`(SheetJS 社区版),多 sheet 出 tab 切换,`sheet_to_html` 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。**机制**:`loadScript()` 懒加载只在首次访问 office 文件才拉 1MB vendor;`_trackBlobUrl` + `_flushBlobUrls` 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。**库选型**:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,`sheet_to_html` 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。**新增 `web/static/vendor/`**(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。**改 `web/static/dev.html`**(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。**没动**:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。**文档**:**只动 PROGRESS + 文件清单加 vendor/ 目录**(按 CLAUDE.md 三文档边界)。
- **05-18 / proposal skill 流程图/结构图管线**:用户反馈"申报 skill 关于流程图、结构图等的生成有些问题,包括渲染到 docx 里"。诊断结果:① `render_docx.py` 整个脚本没有 `add_picture` / 没引 `Inches`,所谓"画流程图"只能走 `add_code_block` 的 ASCII 字符画(`新宋体` + Consolas + box drawing),Word 里 CJK 与 `─ │ ┌ ┐` 不真等宽,中文标签一长就错位,评审看到字符画扣印象分;② 模板里写满 `[图 2-2 关键技术关系架构]` 裸占位,但 SKILL.md 零提及 mermaid / graphviz / matplotlib,模型只能瞎编 ASCII;③ 评审红线"图编号连续无遗漏"(`references/review_redlines.md:96`)无机制保证。**方案**:Mermaid 管线 + matplotlib 兜底 + 图编号自增。**新增 `scripts/render_diagrams.py`**(143 行):扫 sections/*.md 的 ```mermaid``` 块 → 算 sha1 前 10 位作稳定 id → 落到 `<task_dir>/figures/fig_<hash>.png`;两阶 backend:① 本地 `mmdc`(npm i -g @mermaid-js/mermaid-cli;最高质量、离线)② `mermaid.ink` 公网 API(`https://mermaid.ink/img/<url-safe-b64>`,urlsafe_b64encode rstrip '=';不装东西、要联网);两个都失败留 WARN 退出 0(不阻塞流水线);`%% caption: <图题>` 行注释抽题文,mermaid 本身当注释跳过、render_docx 当题用;不改动 .md 文件(源是真相);幂等(png 存在跳过)。**改 `render_docx.py`**(+~70 行):① 加 `![caption](path)` 单行识别 → `add_picture(width=Cm(15))` 居中 + 五号宋体居中图题段落"图 N <caption>",N 通过 `ctx` 字典(`{sections_dir, figures_dir, fig_no}`)在 `render_md_block` 调用链里递增,relative 路径以 .md 所在目录为锚;图片源缺失 → 留 `[图片缺失: <src>]` 占位段防 silent miss、文档不崩;② 围栏 lang == "mermaid" 特判:算同源 sha1 查 `<sections_dir>/../figures/fig_<hash>.png`,命中走插图 + 题(同样自增编号、复用 `extract_mermaid_caption`),未命中**继续走原 `add_code_block` ASCII fallback 路径**(mmdc 没装也能交差,只是不漂亮);③ A4 减页边距得正文宽 16cm,图宽 cap `Cm(15)` 留 1cm 安全垫;④ `add_picture` 失败 try/except 不让整 doc 崩,改占位文字。**改 SKILL.md**:`资源` 段加 `render_diagrams.py` 行;阶段三命令链插入 `render_diagrams.py` 前置(可选,无 mermaid 块直接跳过);新增"插图"段(类型选择表 / mermaid `%% caption` 约定 + 完整 flowchart 例子 / matplotlib `figsize=(10,4)` `dpi=150` 中文字体 SimHei 配色规范 / 不要手写"图 2-2"章节-序号);反模式加 3 条(ASCII 字符画当真图 / 手写图编号 / 裸 `[图 N-N ...]` 占位)。**改 `templates/key_rd.md`**:① §04_content (一) 主要研究内容里 `[图 2-2 关键技术关系架构]` 占位换成完整 ```mermaid flowchart LR``` 块(关键问题 Q1/Q2 → 技术 T1/T2/T3 → 平台,带 `%% caption:`);② §04 (三) 技术路线加"项目总体技术路线" mermaid `flowchart TB` 例子(需求→设计→突破→集成→示范 5 阶段 + 双向反馈虚线);③ §09_schedule 甘特图改"两种画法 A. mermaid `gantt` B. matplotlib `barh`"并给完整 mermaid gantt 示例。**没动**:`major_project.md` / `nsfc_joint_fund.md` 只是"配图"提示,不是裸占位,通过 SKILL.md 横向覆盖;`scripts/word_count.py` / `quality_check.py`(图不计字数,质量检查暂不涉及图占位)。**Smoke 4 case 全绿**(`scripts/_smoke_proposal_diagrams.py`,留作回归):① cached mermaid + direct image + ASCII fallback 混排(`figures: 2` 报告对、`inline_shapes == 2`、缓存命中走"图 1/图 2"、缺缓存 mermaid 走 ASCII 源保留 + 不申请图号"图 3");② 无插图回归(`figures: 0` + table 完好);③ `render_diagrams.py` API 调用(`find_mermaid_blocks` 抽 2 块 / `extract_caption` 命中/未命中 / 预填 cache png 全走 `cache` backend 不走网络);④ 图片源缺失走占位文字,后续段落不丢。**Windows GBK 子进程坑**:smoke 跑 subprocess 拿不到 UTF-8 stdout(`UnicodeDecodeError 0xd6`),给子进程 env 加 `PYTHONIOENCODING=utf-8` 修;同 memory 里 emoji 编码教训同源。**文档**:**只动 PROGRESS**(skill 内部能力增强 ≠ 架构变化,不动 DESIGN;skill CLI 不是 zcbot 对外行为,不动 RUN —— 按 CLAUDE.md 三文档边界)。**净增量**:~213 行代码新增,5 行文档示例改写,sections/*.md 不动(源永远是 mermaid 真相)。**留给真用户的体验**:模型不需要再瞎编 ASCII,直接写 mermaid 块就行;mmdc/网络都没的极端环境下 docx 仍能产(ASCII 退化,文字不丢);图编号永远连续不重不漏(自动),手工占位的旧坑彻底关上。 - **05-18 / proposal skill 流程图/结构图管线**:用户反馈"申报 skill 关于流程图、结构图等的生成有些问题,包括渲染到 docx 里"。诊断结果:① `render_docx.py` 整个脚本没有 `add_picture` / 没引 `Inches`,所谓"画流程图"只能走 `add_code_block` 的 ASCII 字符画(`新宋体` + Consolas + box drawing),Word 里 CJK 与 `─ │ ┌ ┐` 不真等宽,中文标签一长就错位,评审看到字符画扣印象分;② 模板里写满 `[图 2-2 关键技术关系架构]` 裸占位,但 SKILL.md 零提及 mermaid / graphviz / matplotlib,模型只能瞎编 ASCII;③ 评审红线"图编号连续无遗漏"(`references/review_redlines.md:96`)无机制保证。**方案**:Mermaid 管线 + matplotlib 兜底 + 图编号自增。**新增 `scripts/render_diagrams.py`**(143 行):扫 sections/*.md 的 ```mermaid``` 块 → 算 sha1 前 10 位作稳定 id → 落到 `<task_dir>/figures/fig_<hash>.png`;两阶 backend:① 本地 `mmdc`(npm i -g @mermaid-js/mermaid-cli;最高质量、离线)② `mermaid.ink` 公网 API(`https://mermaid.ink/img/<url-safe-b64>`,urlsafe_b64encode rstrip '=';不装东西、要联网);两个都失败留 WARN 退出 0(不阻塞流水线);`%% caption: <图题>` 行注释抽题文,mermaid 本身当注释跳过、render_docx 当题用;不改动 .md 文件(源是真相);幂等(png 存在跳过)。**改 `render_docx.py`**(+~70 行):① 加 `![caption](path)` 单行识别 → `add_picture(width=Cm(15))` 居中 + 五号宋体居中图题段落"图 N <caption>",N 通过 `ctx` 字典(`{sections_dir, figures_dir, fig_no}`)在 `render_md_block` 调用链里递增,relative 路径以 .md 所在目录为锚;图片源缺失 → 留 `[图片缺失: <src>]` 占位段防 silent miss、文档不崩;② 围栏 lang == "mermaid" 特判:算同源 sha1 查 `<sections_dir>/../figures/fig_<hash>.png`,命中走插图 + 题(同样自增编号、复用 `extract_mermaid_caption`),未命中**继续走原 `add_code_block` ASCII fallback 路径**(mmdc 没装也能交差,只是不漂亮);③ A4 减页边距得正文宽 16cm,图宽 cap `Cm(15)` 留 1cm 安全垫;④ `add_picture` 失败 try/except 不让整 doc 崩,改占位文字。**改 SKILL.md**:`资源` 段加 `render_diagrams.py` 行;阶段三命令链插入 `render_diagrams.py` 前置(可选,无 mermaid 块直接跳过);新增"插图"段(类型选择表 / mermaid `%% caption` 约定 + 完整 flowchart 例子 / matplotlib `figsize=(10,4)` `dpi=150` 中文字体 SimHei 配色规范 / 不要手写"图 2-2"章节-序号);反模式加 3 条(ASCII 字符画当真图 / 手写图编号 / 裸 `[图 N-N ...]` 占位)。**改 `templates/key_rd.md`**:① §04_content (一) 主要研究内容里 `[图 2-2 关键技术关系架构]` 占位换成完整 ```mermaid flowchart LR``` 块(关键问题 Q1/Q2 → 技术 T1/T2/T3 → 平台,带 `%% caption:`);② §04 (三) 技术路线加"项目总体技术路线" mermaid `flowchart TB` 例子(需求→设计→突破→集成→示范 5 阶段 + 双向反馈虚线);③ §09_schedule 甘特图改"两种画法 A. mermaid `gantt` B. matplotlib `barh`"并给完整 mermaid gantt 示例。**没动**:`major_project.md` / `nsfc_joint_fund.md` 只是"配图"提示,不是裸占位,通过 SKILL.md 横向覆盖;`scripts/word_count.py` / `quality_check.py`(图不计字数,质量检查暂不涉及图占位)。**Smoke 4 case 全绿**(`scripts/_smoke_proposal_diagrams.py`,留作回归):① cached mermaid + direct image + ASCII fallback 混排(`figures: 2` 报告对、`inline_shapes == 2`、缓存命中走"图 1/图 2"、缺缓存 mermaid 走 ASCII 源保留 + 不申请图号"图 3");② 无插图回归(`figures: 0` + table 完好);③ `render_diagrams.py` API 调用(`find_mermaid_blocks` 抽 2 块 / `extract_caption` 命中/未命中 / 预填 cache png 全走 `cache` backend 不走网络);④ 图片源缺失走占位文字,后续段落不丢。**Windows GBK 子进程坑**:smoke 跑 subprocess 拿不到 UTF-8 stdout(`UnicodeDecodeError 0xd6`),给子进程 env 加 `PYTHONIOENCODING=utf-8` 修;同 memory 里 emoji 编码教训同源。**文档**:**只动 PROGRESS**(skill 内部能力增强 ≠ 架构变化,不动 DESIGN;skill CLI 不是 zcbot 对外行为,不动 RUN —— 按 CLAUDE.md 三文档边界)。**净增量**:~213 行代码新增,5 行文档示例改写,sections/*.md 不动(源永远是 mermaid 真相)。**留给真用户的体验**:模型不需要再瞎编 ASCII,直接写 mermaid 块就行;mmdc/网络都没的极端环境下 docx 仍能产(ASCII 退化,文字不丢);图编号永远连续不重不漏(自动),手工占位的旧坑彻底关上。

View File

@ -88,9 +88,41 @@
display: flex; gap: 8px; align-items: center; background: #fafafa; display: flex; gap: 8px; align-items: center; background: #fafafa;
position: sticky; top: 0; position: sticky; top: 0;
} }
.pane-head .label { font-weight: 600; font-size: 13px; } .pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.pane-head .spacer { flex: 1; } .pane-head .spacer { flex: 1; }
/* ───── floating dropdown menu ───── */
/* 单例:position: fixed 逃出 pane overflow 裁剪;右上角触发,向下展开 */
.dd-toggle {
padding: 2px 6px; font-size: 14px; line-height: 1;
background: transparent; border: 1px solid transparent;
color: var(--muted); border-radius: 3px; cursor: pointer;
}
.dd-toggle:hover { background: var(--hover); color: var(--text); border-color: var(--border); }
#floating-menu {
display: none; position: fixed;
min-width: 132px; background: #fff;
border: 1px solid var(--border); border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
z-index: 60; padding: 4px 0;
}
#floating-menu.show { display: block; }
.dd-item {
display: block; width: 100%; text-align: left;
padding: 6px 14px; font-size: 13px; line-height: 1.4;
background: transparent; border: 0; border-radius: 0;
cursor: pointer; color: var(--text);
}
.dd-item:hover { background: var(--hover); }
.dd-item:disabled { color: var(--muted); cursor: not-allowed; opacity: 0.55; }
.dd-item:disabled:hover { background: transparent; }
.dd-item.act-complete { color: #2e7d32; }
.dd-item.act-abandon { color: #c77800; }
.dd-item.act-export { color: #1565c0; }
.dd-item.act-rename { color: #1565c0; }
.dd-item.act-download { color: #2e7d32; }
.dd-item.act-delete { color: var(--accent); }
/* ───── task list ───── */ /* ───── task list ───── */
.task-row { .task-row {
padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer; padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer;
@ -381,6 +413,7 @@
<span class="hint" id="chat-hint">就绪</span> <span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button> <button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button>
<button type="button" class="small" id="chat-upload" title="上传文件到右侧当前文件目录">⬆ 上传</button>
<button type="submit" class="primary" id="chat-send">发送</button> <button type="submit" class="primary" id="chat-send">发送</button>
</div> </div>
</form> </form>
@ -390,7 +423,7 @@
<div id="pane-right"> <div id="pane-right">
<div class="pane-head"> <div class="pane-head">
<span class="label">文件</span> <span class="label">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span> <span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="btn-upload" class="small" title="上传文件到当前目录"></button> <button id="btn-upload" class="small" title="上传文件到当前目录"></button>
<button id="btn-refresh-files" class="small"></button> <button id="btn-refresh-files" class="small"></button>
@ -401,6 +434,9 @@
</div> </div>
</div> </div>
<!-- ───── floating dropdown menu (single instance) ───── -->
<div id="floating-menu"></div>
<!-- ───── new task modal ───── --> <!-- ───── new task modal ───── -->
<div id="new-task-modal"> <div id="new-task-modal">
<div class="card"> <div class="card">
@ -460,6 +496,53 @@ const state = {
// ───── helpers ───── // ───── helpers ─────
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
// ───── floating dropdown menu (single instance) ─────
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
let _menuItems = null;
function showMenu(triggerEl, items) {
_menuItems = items;
const menu = $("floating-menu");
menu.innerHTML = items.map((it) => {
const cls = "dd-item " + (it.cls || "");
const dis = it.disabled ? " disabled" : "";
return `<button class="${cls}" data-act="${escapeHtml(it.act)}"${dis}>${escapeHtml(it.label)}</button>`;
}).join("");
menu.querySelectorAll(".dd-item").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const act = btn.dataset.act;
const item = _menuItems && _menuItems.find((i) => i.act === act);
hideMenu();
if (item && item.onclick) item.onclick();
};
});
// 默认右下展开;若空间不足则改向上
const rect = triggerEl.getBoundingClientRect();
menu.style.visibility = "hidden";
menu.classList.add("show");
const mh = menu.offsetHeight || 120;
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
menu.style.left = "auto";
if (rect.bottom + mh + 8 > window.innerHeight) {
menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
} else {
menu.style.top = (rect.bottom + 4) + "px";
}
menu.style.visibility = "";
}
function hideMenu() {
_menuItems = null;
$("floating-menu").classList.remove("show");
}
document.addEventListener("click", (e) => {
if (e.target.closest(".dd-toggle")) return;
if (e.target.closest("#floating-menu")) return;
hideMenu();
}, true);
window.addEventListener("resize", hideMenu);
// 滚动 pane 时菜单位置失效,直接关
document.addEventListener("scroll", hideMenu, true);
async function api(method, path, body) { async function api(method, path, body) {
const opts = { method, headers: {} }; const opts = { method, headers: {} };
if (state.token) opts.headers["Authorization"] = "Bearer " + state.token; if (state.token) opts.headers["Authorization"] = "Bearer " + state.token;
@ -629,6 +712,8 @@ function resetPageAndReload() {
} }
function renderTaskList(tasks) { function renderTaskList(tasks) {
state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t;
if (!tasks.length) { if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`; $("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return; return;
@ -642,24 +727,53 @@ function renderTaskList(tasks) {
const desc = t.description || ""; const desc = t.description || "";
const statusLabel = statusLabels[t.status] || t.status; const statusLabel = statusLabels[t.status] || t.status;
return ` return `
<div class="task-row${active}" data-tid="${t.task_id}"> <div class="task-row${active}" data-tid="${t.task_id}" style="display:flex;align-items:flex-start;gap:6px;">
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div> <div style="flex:1;min-width:0;">
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""} <div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""} ${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
<div class="meta"> ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
<span class="badge ${t.status}">${statusLabel}</span> <div class="meta">
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""} <span class="badge ${t.status}">${statusLabel}</span>
<span>${t.n_messages || 0} 条</span> ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.tokens || 0} tok</span> <span>${t.n_messages || 0} 条</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span> <span>${t.tokens || 0} tok</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
</div>
</div> </div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button>
</div> </div>
`; `;
}).join(""); }).join("");
$("task-list").innerHTML = html; $("task-list").innerHTML = html;
$("task-list").querySelectorAll(".task-row").forEach((el) => { $("task-list").querySelectorAll(".task-row").forEach((el) => {
el.onclick = () => selectTask(el.dataset.tid); el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
selectTask(el.dataset.tid);
};
}); });
$("task-list").querySelectorAll(".task-menu").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const t = state.tasksById[btn.dataset.tid];
if (!t) return;
showMenu(btn, taskMenuItems(t));
};
});
}
function taskMenuItems(t) {
const isActive = t.status === "active";
const hasMsg = (t.n_messages || 0) > 0;
return [
{ act: "complete", label: "完成", cls: "act-complete", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
{ act: "abandon", label: "废弃", cls: "act-abandon", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
{ act: "export", label: "导出 docx", cls: "act-export", disabled: !hasMsg,
onclick: () => exportTask(t.task_id) },
{ act: "delete", label: "删除", cls: "act-delete",
onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
];
} }
// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page // 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
@ -942,6 +1056,7 @@ function handleSseEvent(ev, asstCard, ctx) {
det.className = "tool-call"; det.className = "tool-call";
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`; det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det); asstCard.appendChild(det);
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
} else if (t === "cancelled") { } else if (t === "cancelled") {
const badge = document.createElement("div"); const badge = document.createElement("div");
badge.className = "cancelled-badge"; badge.className = "cancelled-badge";
@ -963,17 +1078,21 @@ function appendErrorCard(msg) {
} }
// ───── done / abandon / delete / export ───── // ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => patchStatus("completed"); $("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || "");
$("btn-abandon").onclick = () => patchStatus("abandoned"); $("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || "");
$("btn-delete-task").onclick = deleteCurrentTask; $("btn-delete-task").onclick = () => {
async function patchStatus(status) {
if (!state.taskId) return; if (!state.taskId) return;
const t = state.taskMeta || {};
deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0);
};
$("btn-export").onclick = () => state.taskId && exportTask(state.taskId);
async function setTaskStatus(tid, status, name) {
const labels = { completed: "已完成", abandoned: "已废弃" }; const labels = { completed: "已完成", abandoned: "已废弃" };
if (!confirm(`确认置为「${labels[status] || status}」?`)) return; if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return;
try { try {
await api("PATCH", "/v1/tasks/" + state.taskId, { status }); await api("PATCH", "/v1/tasks/" + tid, { status });
await selectTask(state.taskId); if (state.taskId === tid) await selectTask(tid);
loadTaskList(); loadTaskList();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -981,54 +1100,57 @@ async function patchStatus(status) {
} }
} }
async function deleteCurrentTask() { async function deleteTask(tid, name, nMsg) {
if (!state.taskId) return; if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
const t = state.taskMeta;
const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8);
const nMsg = (t && t.n_messages) || 0;
if (!confirm(`确认硬删除任务「${projName}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try { try {
await api("DELETE", "/v1/tasks/" + state.taskId); await api("DELETE", "/v1/tasks/" + tid);
// 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在) if (state.taskId === tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null; state.taskId = null;
state.taskMeta = null; state.taskMeta = null;
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`; $("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
$("chat-form").style.display = "none"; $("chat-form").style.display = "none";
$("btn-done").disabled = true; $("btn-done").disabled = true;
$("btn-abandon").disabled = true; $("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true; $("btn-delete-task").disabled = true;
$("btn-export").disabled = true; $("btn-export").disabled = true;
}
loadTaskList(); loadTaskList();
loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见) loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("删除失败:" + e.message); alert("删除失败:" + e.message);
} }
} }
$("btn-export").onclick = () => { function exportTask(tid) {
if (!state.taskId) return; fetch("/v1/tasks/" + tid + "/export", {
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
fetch("/v1/tasks/" + state.taskId + "/export", {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) { alert("导出失败:" + r.status); return; } if (!r.ok) { alert("导出失败:" + r.status); return; }
const blob = await r.blob(); const blob = await r.blob();
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = "chat_" + state.taskId.slice(0, 8) + ".docx"; a.download = "chat_" + tid.slice(0, 8) + ".docx";
document.body.appendChild(a); a.click(); document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}); });
}; }
// ───── files(user-rooted,不绑 task) ───── // ───── files(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles(); $("btn-refresh-files").onclick = () => loadFiles();
$("btn-upload").onclick = () => $("upload-input").click(); $("btn-upload").onclick = () => $("upload-input").click();
$("chat-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected); $("upload-input").addEventListener("change", uploadSelected);
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
let _filesRefreshTimer = null;
function scheduleFilesRefresh() {
clearTimeout(_filesRefreshTimer);
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
}
async function loadFiles() { async function loadFiles() {
try { try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
@ -1045,8 +1167,10 @@ function renderFiles(data) {
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean); const segs = (data.current || "").split("/").filter(Boolean);
const projName = segs[0] || ""; const projName = segs[0] || "";
$("files-proj").textContent = projName ? "· " + projName : "· (根目录)"; // 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行)
$("files-proj").title = data.root || ""; const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
$("files-proj").title = projName || data.root || "";
// crumbs root 标"我的"(user_root),更直观;其余原样 // crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => { const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : c.label; const label = i === 0 ? "我的" : c.label;
@ -1066,6 +1190,8 @@ function renderFiles(data) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`; $("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
return; return;
} }
state.entriesByRel = {};
for (const e of data.entries) state.entriesByRel[e.rel] = e;
$("file-list").innerHTML = data.entries.map((e) => { $("file-list").innerHTML = data.entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file"; const cls = e.is_dir ? "ico-dir" : "ico-file";
return ` return `
@ -1074,8 +1200,7 @@ function renderFiles(data) {
${escapeHtml(e.name)} ${escapeHtml(e.name)}
</span> </span>
<span class="size">${humanSize(e.size)}</span> <span class="size">${humanSize(e.size)}</span>
<button class="small mv-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="重命名">改名</button> <button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作"></button>
<button class="small danger del-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="删(非空目录 / 仍被 task 引用会失败)">×</button>
</div> </div>
`; `;
}).join(""); }).join("");
@ -1087,14 +1212,30 @@ function renderFiles(data) {
else { openFilePreview(rel); } else { openFilePreview(rel); }
}; };
}); });
$("file-list").querySelectorAll(".del-file").forEach((btn) => { $("file-list").querySelectorAll(".file-menu").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); deleteFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); }; btn.onclick = (ev) => {
}); ev.stopPropagation();
$("file-list").querySelectorAll(".mv-file").forEach((btn) => { const e = state.entriesByRel[btn.dataset.rel];
btn.onclick = (ev) => { ev.stopPropagation(); renameFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); }; if (!e) return;
showMenu(btn, fileMenuItems(e));
};
}); });
} }
function fileMenuItems(e) {
const items = [
{ act: "rename", label: "重命名", cls: "act-rename",
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
];
if (!e.is_dir) {
items.push({ act: "download", label: "下载", cls: "act-download",
onclick: () => downloadFile(e.rel) });
}
items.push({ act: "delete", label: "删除", cls: "act-delete",
onclick: () => deleteFile(e.rel, e.name, e.is_dir) });
return items;
}
async function deleteFile(rel, name, isDir) { async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件"; const what = isDir ? "目录" : "文件";
const tip = isDir const tip = isDir