feat(web): 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)
附件 chip 拆出独立托盘 #chat-attach,与状态文字解耦:append+按 rel 去重, 上传进度只写 #chat-hint,不再互相覆盖。整个 #chat-form 加 dragenter/over/ leave/drop(计数防闪烁,只认文件拖拽,微信镜像只读不接收),复用 uploadFiles。 takePastedRels/删除/预览改查托盘;切 task 清残留未发送 chip。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d235cb7564
commit
e49ff641f9
|
|
@ -21,6 +21,12 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-06-26 / 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)
|
||||
|
||||
- 现象:① 消息框只能粘贴文件不能拖拽;② 连粘多个文件,后一个把前一个的 chip 顶掉,只剩一个。
|
||||
- 根因:粘贴附件 chip 和状态文字共用 `#chat-hint`,每次粘贴用 `innerHTML =` 整体重建只塞最新一批,且上传进度回调写 `hint.textContent` 也会清掉已有 chip——附件与状态文字抢同一个容器。
|
||||
- 修复(`web/static/dev.html` + `web/static/js/chat.js`):① 新增独立 chip 托盘 `#chat-attach`(textarea 与按钮行之间),chip 累积靠 append + 按 `rel` 去重,状态进度只写 `#chat-hint`,从根上解耦;② 给整个 `#chat-form` 加 `dragenter/over/leave/drop`(enter/leave 计数防闪烁,`_dragHasFiles` 只认文件拖拽,微信镜像只读时不接收),复用 `uploadFiles` + 同一托盘;`takePastedRels` / 删除 / 预览三处改查托盘。
|
||||
|
||||
### 2026-06-26 / 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2)
|
||||
|
||||
- 现象(0.20.4 后仍残留):① 点圆点,被点的圆点不变红、活跃态跑到途经轮次(尤其点 #1 跳到 #2);② 点最后一个 / 滚到底,倒数第二个变红。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.31.2"
|
||||
__version__ = "0.31.3"
|
||||
|
|
|
|||
|
|
@ -855,6 +855,12 @@
|
|||
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
|
||||
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
|
||||
#chat-form .hint { font-size: 11px; color: var(--muted); }
|
||||
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
|
||||
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
|
||||
#chat-attach.show { display: flex; }
|
||||
/* 拖拽文件悬停整个输入区:虚线高亮提示可落点 */
|
||||
#chat-form.drag-over { outline: 2px dashed var(--accent); outline-offset: -4px; background: var(--accent-soft); }
|
||||
#chat-form.drag-over * { pointer-events: none; }
|
||||
|
||||
/* ───── files ───── */
|
||||
.crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
|
||||
|
|
@ -1505,7 +1511,8 @@
|
|||
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
|
||||
<div id="msg-outline-rail" style="display:none;"></div>
|
||||
<form id="chat-form" style="display:none;">
|
||||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
||||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea>
|
||||
<span id="chat-attach"></span>
|
||||
<div class="row">
|
||||
<span class="hint" id="chat-hint">就绪</span>
|
||||
<span style="flex:1;"></span>
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export async function selectTask(tid) {
|
|||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||||
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
|
||||
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
|
||||
if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache();
|
||||
if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
|
||||
state.taskId = tid;
|
||||
document.querySelectorAll(".task-row").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tid === tid);
|
||||
|
|
@ -1034,14 +1034,55 @@ $("chat-input").addEventListener("keydown", (e) => {
|
|||
}
|
||||
});
|
||||
$("chat-input").addEventListener("input", syncOptimizeBtn);
|
||||
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
|
||||
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。
|
||||
$("chat-input").addEventListener("paste", async (e) => {
|
||||
// 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦,
|
||||
// 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认。
|
||||
$("chat-input").addEventListener("paste", (e) => {
|
||||
const files = Array.from(e.clipboardData?.files || []);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
uploadAttachFiles(files);
|
||||
});
|
||||
|
||||
// 拖拽落点 = 整个 #chat-form(命中面积大);用 enter/leave 计数防子元素冒泡时高亮闪烁。
|
||||
// 只认文件拖拽(_hasFiles),只读镜像(微信渠道)不接收。
|
||||
let _composerDragDepth = 0;
|
||||
function _dragHasFiles(ev) {
|
||||
const t = ev.dataTransfer;
|
||||
return !!(t && t.types && [...t.types].includes("Files"));
|
||||
}
|
||||
function _composerLocked() {
|
||||
const input = $("chat-input");
|
||||
return !!(input && input.readOnly);
|
||||
}
|
||||
$("chat-form").addEventListener("dragenter", (e) => {
|
||||
if (_composerLocked() || !_dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
_composerDragDepth++;
|
||||
$("chat-form").classList.add("drag-over");
|
||||
});
|
||||
$("chat-form").addEventListener("dragover", (e) => {
|
||||
if (_composerLocked() || !_dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
$("chat-form").addEventListener("dragleave", (e) => {
|
||||
if (!_dragHasFiles(e)) return;
|
||||
_composerDragDepth = Math.max(0, _composerDragDepth - 1);
|
||||
if (_composerDragDepth === 0) $("chat-form").classList.remove("drag-over");
|
||||
});
|
||||
$("chat-form").addEventListener("drop", (e) => {
|
||||
if (_composerLocked() || !_dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
_composerDragDepth = 0;
|
||||
$("chat-form").classList.remove("drag-over");
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length) uploadAttachFiles(files);
|
||||
});
|
||||
|
||||
// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。
|
||||
async function uploadAttachFiles(files) {
|
||||
if (!files || !files.length) return;
|
||||
const hint = $("chat-hint");
|
||||
const prevHint = hint.textContent;
|
||||
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
|
||||
const saved = await uploadFiles(files, {
|
||||
onProgress: (loaded, total) => {
|
||||
|
|
@ -1049,21 +1090,45 @@ $("chat-input").addEventListener("paste", async (e) => {
|
|||
},
|
||||
});
|
||||
if (saved && saved.length) {
|
||||
hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} <span class="muted">可在右侧文件处查看</span>`;
|
||||
} else {
|
||||
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
|
||||
addAttachChips(saved);
|
||||
const n = attachCount();
|
||||
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
function renderPasteFileChips(saved) {
|
||||
return (saved || []).map((f) => {
|
||||
const rel = f.rel || f.name || "";
|
||||
const name = f.name || (rel.split("/").pop() || rel);
|
||||
return `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该粘贴文件">×</button></span>`;
|
||||
}).join("");
|
||||
// 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖)
|
||||
}
|
||||
|
||||
$("chat-hint").addEventListener("click", (e) => {
|
||||
function attachTray() { return $("chat-attach"); }
|
||||
function attachWraps() {
|
||||
const tray = attachTray();
|
||||
return tray ? Array.from(tray.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
|
||||
}
|
||||
function attachCount() { return attachWraps().length; }
|
||||
// 切 task 时清掉上个 task 残留的未发送 chip(它们指向上个 task_dir,新 task 用不上)。
|
||||
// 只清 DOM,不删已上传的文件(用户可能切回去发,文件还在原目录)。
|
||||
function clearAttachTray() {
|
||||
const tray = attachTray();
|
||||
if (!tray) return;
|
||||
tray.innerHTML = "";
|
||||
tray.classList.remove("show");
|
||||
}
|
||||
|
||||
// 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。
|
||||
function addAttachChips(saved) {
|
||||
const tray = attachTray();
|
||||
if (!tray) return;
|
||||
const existing = new Set(attachWraps().map((w) => w.dataset.rel));
|
||||
for (const f of saved || []) {
|
||||
const rel = f.rel || f.name || "";
|
||||
if (!rel || existing.has(rel)) continue;
|
||||
existing.add(rel);
|
||||
const name = f.name || (rel.split("/").pop() || rel);
|
||||
tray.insertAdjacentHTML("beforeend",
|
||||
`<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
|
||||
}
|
||||
tray.classList.toggle("show", attachCount() > 0);
|
||||
}
|
||||
|
||||
attachTray().addEventListener("click", (e) => {
|
||||
const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
|
||||
if (del) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1085,9 +1150,10 @@ async function deletePastedFile(rel, wrap) {
|
|||
closePreviewIfShowing(rel);
|
||||
wrap.remove();
|
||||
await loadFiles();
|
||||
const hint = $("chat-hint");
|
||||
if (!hint.querySelector(".paste-chip-wrap")) {
|
||||
hint.innerHTML = `<span class="muted">已删除粘贴文件</span>`;
|
||||
const tray = attachTray();
|
||||
if (tray) tray.classList.toggle("show", attachCount() > 0);
|
||||
if (attachCount() === 0) {
|
||||
$("chat-hint").innerHTML = `<span class="muted">已删除文件</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) btn.disabled = false;
|
||||
|
|
@ -1109,7 +1175,7 @@ function applyChannelComposerLock(meta) {
|
|||
input.classList.toggle("readonly-locked", !!cfg);
|
||||
input.placeholder = cfg
|
||||
? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`
|
||||
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
|
||||
: "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)";
|
||||
if (cfg) {
|
||||
const opt = $("chat-optimize");
|
||||
if (opt) opt.disabled = true;
|
||||
|
|
@ -1227,10 +1293,11 @@ async function postMessageWithRetry(taskId, body) {
|
|||
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
|
||||
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
|
||||
function takePastedRels() {
|
||||
const hint = $("chat-hint");
|
||||
const wraps = hint ? Array.from(hint.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
|
||||
const wraps = attachWraps();
|
||||
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
|
||||
wraps.forEach((w) => w.remove());
|
||||
const tray = attachTray();
|
||||
if (tray) tray.classList.remove("show");
|
||||
return rels;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue