ui: chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 13:59:53 +08:00
parent 0c732590a4
commit d6cbe8194b
2 changed files with 28 additions and 2 deletions

View File

@ -23,6 +23,7 @@
### 2026-05-22 ### 2026-05-22
- **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input``paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)``/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:<name>…" → "已粘贴:<name>"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。
- **dev SPA 文件预览弹框让出 chat-form 高度(打开期间输入区仍可点可打字)**:`web/static/dev.html` 给 `#file-preview-modal``bottom: var(--preview-bottom-inset, 0)` —— 默认 0 行为不变,`openFilePreview` 时 JS 量 `#chat-form.offsetHeight`(隐藏走 `offsetParent` 判空 → 0,无活动任务恢复全屏)写到弹框元素 inline style 上;`.card` 加 `max-height: calc(100vh - var(...) - 32px)` 让卡片随容器收缩不溢出,手机段同理用 `100dvh`。`closeFilePreview` `removeProperty` 清掉避免下次冗余。弹框遮罩本身物理上不覆盖底部输入区 → chat-form 自然可点击/打字,Esc 与点遮罩关闭逻辑不动。否决:(a) 整窗 `pointer-events: none` + card 收回 —— 遮罩物理还在覆盖,视觉仍遮挡;(b) 弹框抽进 `#pane-mid` 内 absolute —— 弹框来源含 `#pane-right` 文件列表和聊天 chip,挂 mid 内会限制弹框只能在 mid 列,且 `#pane-mid` 多层 flex 嵌套要重排;(c) 硬编一个常量 `bottom: 140px` —— chat-form 高度依据 textarea 用户拖拽变化(min 60 但可拉高),JS 量一次足够准。 - **dev SPA 文件预览弹框让出 chat-form 高度(打开期间输入区仍可点可打字)**:`web/static/dev.html` 给 `#file-preview-modal``bottom: var(--preview-bottom-inset, 0)` —— 默认 0 行为不变,`openFilePreview` 时 JS 量 `#chat-form.offsetHeight`(隐藏走 `offsetParent` 判空 → 0,无活动任务恢复全屏)写到弹框元素 inline style 上;`.card` 加 `max-height: calc(100vh - var(...) - 32px)` 让卡片随容器收缩不溢出,手机段同理用 `100dvh`。`closeFilePreview` `removeProperty` 清掉避免下次冗余。弹框遮罩本身物理上不覆盖底部输入区 → chat-form 自然可点击/打字,Esc 与点遮罩关闭逻辑不动。否决:(a) 整窗 `pointer-events: none` + card 收回 —— 遮罩物理还在覆盖,视觉仍遮挡;(b) 弹框抽进 `#pane-mid` 内 absolute —— 弹框来源含 `#pane-right` 文件列表和聊天 chip,挂 mid 内会限制弹框只能在 mid 列,且 `#pane-mid` 多层 flex 嵌套要重排;(c) 硬编一个常量 `bottom: 140px` —— chat-form 高度依据 textarea 用户拖拽变化(min 60 但可拉高),JS 量一次足够准。
- **对外路径协议刚性化(system 强约束 + SKILL 简化 + UI 一次性兼容)**:`prompts/system/general_v1.md`「路径」段加规则 —— 助手对外 echo 产物路径必须用 user_root 相对全形式 `<wd_name>/<rel>`(`<wd_name>` = task_dir 末段,如 `生图测试/videos/xxx.mp4` / `基金申报/sections/01-绪论.md` / `公司汇报/slides/deck.pptx`),不简写为 `videos/xxx.mp4` 这种 task 内裸形式(Web UI 按 `<wd_name>/` 前缀挂 chip,简写 → chip 失效用户点不开)。媒体 tool(`seedream` / `seedance`)的 `saved:` 行已是规范全形式可直接照抄,其他场景(ppt / proposal / coding 等 run_python/write 写文件)自己拼。**跨所有产物 skill 统一生效**(不止 imagegen/videogen)。`skills/imagegen/SKILL.md` + `skills/videogen/SKILL.md` 把原有"把 `saved: xxx` 告诉用户"重复教学改成"照抄 saved 行,详见 system「路径」段"(消除 skill 内具体写法 → 协议归一到 system,新产物 skill 不用重复教育)。ppt/proposal/coding 等 SKILL **不动** —— 它们只泛说"告诉用户文件路径"没教错,system 协议升级后助手自然按全形式 echo,加 skill 提醒反而是协议漂移源。`web/static/dev.html::extractArtifactRels` 加一次性兼容兜底:产物目录裸路径 `videos/xxx.<ext>` / `figures/xxx.<ext>`(协议刚性前历史简写)prepend `<wdName>/` 拼成 user_root rel —— 白名单显式枚举两项不扩展、长期老消息归档后整段可删。**术语校准**:前缀叫 `<wd_name>`(working_dir 最后一段)而非 `<task_name>` —— 用户允许 `wd_name ≠ task_name`(`build_agent` wd_raw 走 working_dir 字段独立可指定),`_display` 锚 user_root 出来的是 `<wd_name>`,SKILL/system 早期写 `task_name` 在分叉场景下会误导助手拼错前缀。否决:(a) 后端 `_display` 改 task-relative 让 tool 输出本身就裸 —— `Tool` 基类 + fs/skill_tool/seedream/seedance/agent_builder/smoke 改 8 个文件,且 fs 跨 task 时要分层 fallback(working_dir → user_root → 绝对),复杂度超过收益;(b) 后端补 HEAD 探针让前端验文件存在再挂 chip —— 工程量与开发期需求不匹配;(c) 白名单常驻服务所有简写形式 —— 维护负担+清单可能膨胀,改成"一次性兼容历史消息"角色后边界清晰;(d) 每个写产物的 SKILL 各加一句"按 system 协议" —— 协议漂移源,违反"system 谈通用、SKILL 谈领域"边界。 - **对外路径协议刚性化(system 强约束 + SKILL 简化 + UI 一次性兼容)**:`prompts/system/general_v1.md`「路径」段加规则 —— 助手对外 echo 产物路径必须用 user_root 相对全形式 `<wd_name>/<rel>`(`<wd_name>` = task_dir 末段,如 `生图测试/videos/xxx.mp4` / `基金申报/sections/01-绪论.md` / `公司汇报/slides/deck.pptx`),不简写为 `videos/xxx.mp4` 这种 task 内裸形式(Web UI 按 `<wd_name>/` 前缀挂 chip,简写 → chip 失效用户点不开)。媒体 tool(`seedream` / `seedance`)的 `saved:` 行已是规范全形式可直接照抄,其他场景(ppt / proposal / coding 等 run_python/write 写文件)自己拼。**跨所有产物 skill 统一生效**(不止 imagegen/videogen)。`skills/imagegen/SKILL.md` + `skills/videogen/SKILL.md` 把原有"把 `saved: xxx` 告诉用户"重复教学改成"照抄 saved 行,详见 system「路径」段"(消除 skill 内具体写法 → 协议归一到 system,新产物 skill 不用重复教育)。ppt/proposal/coding 等 SKILL **不动** —— 它们只泛说"告诉用户文件路径"没教错,system 协议升级后助手自然按全形式 echo,加 skill 提醒反而是协议漂移源。`web/static/dev.html::extractArtifactRels` 加一次性兼容兜底:产物目录裸路径 `videos/xxx.<ext>` / `figures/xxx.<ext>`(协议刚性前历史简写)prepend `<wdName>/` 拼成 user_root rel —— 白名单显式枚举两项不扩展、长期老消息归档后整段可删。**术语校准**:前缀叫 `<wd_name>`(working_dir 最后一段)而非 `<task_name>` —— 用户允许 `wd_name ≠ task_name`(`build_agent` wd_raw 走 working_dir 字段独立可指定),`_display` 锚 user_root 出来的是 `<wd_name>`,SKILL/system 早期写 `task_name` 在分叉场景下会误导助手拼错前缀。否决:(a) 后端 `_display` 改 task-relative 让 tool 输出本身就裸 —— `Tool` 基类 + fs/skill_tool/seedream/seedance/agent_builder/smoke 改 8 个文件,且 fs 跨 task 时要分层 fallback(working_dir → user_root → 绝对),复杂度超过收益;(b) 后端补 HEAD 探针让前端验文件存在再挂 chip —— 工程量与开发期需求不匹配;(c) 白名单常驻服务所有简写形式 —— 维护负担+清单可能膨胀,改成"一次性兼容历史消息"角色后边界清晰;(d) 每个写产物的 SKILL 各加一句"按 system 协议" —— 协议漂移源,违反"system 谈通用、SKILL 谈领域"边界。
- **dev SPA 手机自适应:两档断点 + tab 单列**:`web/static/dev.html` 加 `@media`,**平板段(641-1024px)**纯 CSS 强制 rail(grid `40px 1fr 260px` + 左 pane 子项 `display:none` + 留 toggle 按钮),不写 localStorage —— 回桌面用户原偏好仍生效。**手机段(≤640px)**单列布局 grid `1fr` + `grid-template-areas:"head" "main"`,三 pane 都 `grid-area: main` 且默认 `display:none`,新加 `body.mv-{left,mid,right}` 控制当前可见 pane;header 加 `.mobile-tabs`(任务/对话/文件)桌面 `display:none`,手机段 `order:99 + flex-basis:100%` 换行铺底;`selectTask` 末加 `if (mqPhone.matches) setMobileView("mv-mid")` 选中任务自动切对话。`applyMobileMode()` 监听 `matchMedia("(max-width: 640px)")`,进手机时清 DOM 上的 `left-collapsed` class(localStorage 不动),回桌面再 `applyLeftCollapsed(读 localStorage)` 恢复。`100vh → 100dvh` 解决 iOS Safari 工具栏挤压;`textarea / input` 强制 ≥16px 防 focus 双指缩放;4 个 modal 卡片宽度从固定 px 改 `min(92vw, …)`,file-preview 改 `100vw × 100dvh` 全屏化。`#pane-toggle-left { display:none !important; }` 手机不允许折叠避免与 tab 切换语义冲突。否决:(a) 抽屉式(要写遮罩+手势 JS,自用工具不划算);(b) 只缩字号不改布局(三列在 360px 屏字段严重截断)。 - **dev SPA 手机自适应:两档断点 + tab 单列**:`web/static/dev.html` 加 `@media`,**平板段(641-1024px)**纯 CSS 强制 rail(grid `40px 1fr 260px` + 左 pane 子项 `display:none` + 留 toggle 按钮),不写 localStorage —— 回桌面用户原偏好仍生效。**手机段(≤640px)**单列布局 grid `1fr` + `grid-template-areas:"head" "main"`,三 pane 都 `grid-area: main` 且默认 `display:none`,新加 `body.mv-{left,mid,right}` 控制当前可见 pane;header 加 `.mobile-tabs`(任务/对话/文件)桌面 `display:none`,手机段 `order:99 + flex-basis:100%` 换行铺底;`selectTask` 末加 `if (mqPhone.matches) setMobileView("mv-mid")` 选中任务自动切对话。`applyMobileMode()` 监听 `matchMedia("(max-width: 640px)")`,进手机时清 DOM 上的 `left-collapsed` class(localStorage 不动),回桌面再 `applyLeftCollapsed(读 localStorage)` 恢复。`100vh → 100dvh` 解决 iOS Safari 工具栏挤压;`textarea / input` 强制 ≥16px 防 focus 双指缩放;4 个 modal 卡片宽度从固定 px 改 `min(92vw, …)`,file-preview 改 `100vw × 100dvh` 全屏化。`#pane-toggle-left { display:none !important; }` 手机不允许折叠避免与 tab 切换语义冲突。否决:(a) 抽屉式(要写遮罩+手势 JS,自用工具不划算);(b) 只缩字号不改布局(三列在 360px 屏字段严重截断)。

View File

@ -758,7 +758,7 @@
<div id="wd-concurrent-warn" style="display:none;"></div> <div id="wd-concurrent-warn" style="display:none;"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div> <div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<form id="chat-form" style="display:none;"> <form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea> <textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
<div class="row"> <div class="row">
<span class="hint" id="chat-hint">就绪</span> <span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
@ -1737,6 +1737,29 @@ $("chat-input").addEventListener("keydown", (e) => {
} }
}); });
$("chat-input").addEventListener("input", syncOptimizeBtn); $("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 N 个 → 4s 回原 hint(同 optimizePrompt 救回范式,
// 不破坏 streaming/optimizing 期间的状态广播)
let _pasteHintTimer = null;
$("chat-input").addEventListener("paste", async (e) => {
const files = Array.from(e.clipboardData?.files || []);
if (!files.length) return;
e.preventDefault();
const hint = $("chat-hint");
const prevHint = hint.textContent;
clearTimeout(_pasteHintTimer);
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
const ok = await uploadFiles(files);
if (ok) {
const names = files.map(f => f.name).join(", ");
hint.textContent = files.length === 1 ? `已粘贴:${names}` : `已粘贴 ${files.length} 个:${names}`;
_pasteHintTimer = setTimeout(() => {
if (hint.textContent.startsWith("已粘贴")) hint.textContent = prevHint;
}, 4000);
} else {
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
}
});
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText') // 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 // 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
@ -2915,7 +2938,7 @@ document.addEventListener("keydown", (e) => {
}); });
async function uploadFiles(files) { async function uploadFiles(files) {
if (!files || !files.length) return; if (!files || !files.length) return false;
const fd = new FormData(); const fd = new FormData();
fd.append("path", state.filesPath || ""); fd.append("path", state.filesPath || "");
for (const f of files) fd.append("files", f); for (const f of files) fd.append("files", f);
@ -2930,8 +2953,10 @@ async function uploadFiles(files) {
throw new Error(d.detail || (r.status + " 上传失败")); throw new Error(d.detail || (r.status + " 上传失败"));
} }
await loadFiles(); await loadFiles();
return true;
} catch (e) { } catch (e) {
alert("上传失败:" + e.message); alert("上传失败:" + e.message);
return false;
} }
} }