Show pasted upload chips

This commit is contained in:
caoqianming 2026-05-25 08:43:07 +08:00
parent f157a4e050
commit 6e33f07bfb
2 changed files with 179 additions and 16 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-25(dev SPA 三栏支持右栏折叠 + 拖拽调宽)
最后更新:2026-05-25(dev SPA 粘贴上传文件显示可预览 chip)
---
@ -23,6 +23,7 @@
### 2026-05-25
- **dev SPA Ctrl+V 粘贴上传反馈改成可预览 chip**:`web/static/dev.html` `uploadFiles()` 成功时返回 `/v1/files/upload``saved[]` 元数据,粘贴文件后 `#chat-hint` 显示"已粘贴" + `.art-chip` 文件 chip + "可在右侧文件处查看",不再 4s 自动消失,下一次发送时由原有"发送中…"状态覆盖。chip 点击复用 `openFilePreview`;若主文件预览框已打开,改开新增的 `#mini-preview-modal` 小预览窗(支持 image/video/pdf/text/md,其它格式给下载兜底),避免覆盖用户当前正在看的主预览。`DESIGN.md` 不动(纯 dev SPA 交互);`RUN.md` 不动(运行方式无变化)。
- **dev SPA 三栏支持右文件栏折叠 + 左右分隔线拖拽调宽**:`web/static/dev.html` 主布局从 3 列 grid 改为 5 列 grid(任务栏 / 左 splitter / 对话栏 / 右 splitter / 文件栏),新增 `#split-left` / `#split-right` 两条 6px 拖拽分隔线,拖动时分别调整 `--left-pane-width` / `--right-pane-width` 并持久化到 localStorage(`zcbot.left-width` / `zcbot.right-width`)。右侧文件栏新增 `#pane-toggle-right`,折叠态复用左栏 rail 范式:列宽 40px,只保留展开按钮,状态持久化到 `zcbot.right-collapsed`;手机端继续走三 tab 单列,隐藏折叠按钮和 splitter,避免与移动端导航冲突。`DESIGN.md` 不动(纯 dev SPA 布局交互);`RUN.md` 不动(运行方式无变化)。
- **dev SPA 右侧文件列表长名称 hover 显示全路径**:`web/static/dev.html` 在右 pane 文件行 `.file-row .name` 和"选入…"源文件列表 `.sp-row .sp-name` 上补 `title`,内容取 `e.rel || e.name`,保留现有 ellipsis 截断视觉,鼠标悬停可看完整相对路径/名称。`DESIGN.md` 不动(无架构/心智模型变化);`RUN.md` 不动(运行方式无变化)。
- **dev SPA 左侧滚动条只覆盖 task 列表**:`web/static/dev.html` 左 pane 改成 flex column,顶部 4 行 pane-head(任务标题/新建/搜索筛选/排序)固定不参与滚动;`#task-list` 与 `#task-sentinel` 包进 `#task-scroll`,并把 IntersectionObserver root 从 `#pane-left` 改到 `#task-scroll`,保证无限滚动仍按列表区域触发。`DESIGN.md` 不动(无架构/心智模型变化);`RUN.md` 不动(运行方式无变化)。

View File

@ -421,6 +421,7 @@
}
.art-chip::before { content: "📄"; font-size: 11px; }
.art-chip:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
#chat-hint .art-chip { margin: 0 2px; vertical-align: middle; font-family: var(--mono); }
/* 内联图片/视频:产物 chip 替代,fetch 完直接展示 */
.art-media {
border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden;
@ -572,6 +573,33 @@
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
}
#mini-preview-modal { background: rgba(0,0,0,0.18); z-index: 96; align-items: flex-start; justify-content: flex-end; padding: 56px 18px 0 0; }
#mini-preview-modal .card {
width: min(520px, 92vw); height: min(420px, 72vh);
display: flex; flex-direction: column;
box-shadow: var(--shadow-card);
}
#mini-preview-modal .hdr {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-bottom: 1px solid var(--border);
}
#mini-preview-modal .hdr .name {
flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#mini-preview-modal .body { flex: 1; overflow: auto; padding: 10px; }
#mini-preview-modal .body.center { display: flex; align-items: center; justify-content: center; }
#mini-preview-modal .body .ph { color: var(--muted); font-size: 12px; text-align: center; }
#mini-preview-modal .body img.preview-img,
#mini-preview-modal .body video.preview-video {
max-width: 100%; max-height: 100%; display: block; margin: 0 auto;
}
#mini-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; }
#mini-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg);
border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word;
font-family: var(--mono); font-size: 12px; line-height: 1.5;
}
.small { font-size: 12px; }
.muted { color: var(--muted); }
@ -943,6 +971,19 @@
</div>
</div>
<!-- ───── compact secondary preview (for pasted chips while main preview is open) ───── -->
<div id="mini-preview-modal" class="modal">
<div class="card">
<div class="hdr">
<span class="name" id="mp-name"></span>
<span class="small muted" id="mp-meta"></span>
<button class="small" id="mp-download" title="下载原文件">下载</button>
<button class="small" id="mp-close" title="关闭 (Esc)">×</button>
</div>
<div class="body" id="mp-body"></div>
</div>
</div>
<script>
const LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id";
@ -1920,29 +1961,37 @@ $("chat-input").addEventListener("keydown", (e) => {
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 N 个 → 4s 回原 hint(同 optimizePrompt 救回范式,
// 不破坏 streaming/optimizing 期间的状态广播)
let _pasteHintTimer = null;
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。
$("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);
const saved = await uploadFiles(files);
if (saved && saved.length) {
hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} <span class="muted">可在右侧文件处查看</span>`;
} else {
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
}
});
function renderPasteFileChips(saved) {
return (saved || []).map((f) => {
const rel = f.rel || f.name || "";
const name = f.name || (rel.split("/").pop() || rel);
return `<button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button>`;
}).join("");
}
$("chat-hint").addEventListener("click", (e) => {
const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
if (!chip) return;
const rel = chip.dataset.rel;
if (rel) openPasteFilePreview(rel);
});
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
// 不与主对话 run 互斥,各跑各的 LLM)。
@ -3109,20 +3158,132 @@ function closeFilePreview() {
_fpCurrentRel = null;
}
let _mpCurrentRel = null;
const _miniPreviewBlobUrls = new Set();
function _trackMiniBlobUrl(blob, mime) {
const b = mime ? new Blob([blob], { type: mime }) : blob;
const url = URL.createObjectURL(b);
_miniPreviewBlobUrls.add(url);
return url;
}
function _flushMiniBlobUrls() {
for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u);
_miniPreviewBlobUrls.clear();
}
function openPasteFilePreview(rel) {
if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel);
else openFilePreview(rel);
}
async function openMiniFilePreview(rel) {
_mpCurrentRel = rel;
const name = rel.split("/").pop() || rel;
$("mp-name").textContent = name;
$("mp-meta").textContent = "";
const body = $("mp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`;
_flushMiniBlobUrls();
$("mini-preview-modal").classList.add("show");
const cat = _categorize(rel);
try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
});
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
$("mp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
body.className = "body";
if (cat === "md") {
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
} else {
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
return;
}
if (blob.size > PREVIEW_BIN_MAX) {
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
body.innerHTML = "";
if (cat === "image") {
body.className = "body center";
const img = document.createElement("img");
img.className = "preview-img";
img.src = _trackMiniBlobUrl(blob);
body.appendChild(img);
} else if (cat === "video") {
body.className = "body center";
const v = document.createElement("video");
v.className = "preview-video";
v.src = _trackMiniBlobUrl(blob);
v.controls = true;
body.appendChild(v);
} else if (cat === "pdf") {
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
} else {
_showMiniFallback("暂不支持小窗预览此格式,请下载查看");
}
} catch (e) {
if (e.status === 401) { closeMiniPreview(); logout(); return; }
_showMiniFallback("加载失败:" + e.message);
}
}
function _showMiniFallback(msg) {
const body = $("mp-body");
body.className = "body center";
body.innerHTML = "";
const ph = document.createElement("div");
ph.className = "ph";
ph.textContent = msg;
const dl = document.createElement("button");
dl.className = "primary";
dl.textContent = "下载原文件";
dl.style.marginTop = "12px";
dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
ph.appendChild(document.createElement("br"));
ph.appendChild(dl);
body.appendChild(ph);
}
function closeMiniPreview() {
$("mini-preview-modal").classList.remove("show");
$("mp-body").innerHTML = "";
_flushMiniBlobUrls();
_mpCurrentRel = null;
}
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview();
});
$("mp-close").onclick = closeMiniPreview;
$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
$("mini-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "mini-preview-modal") closeMiniPreview();
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// 多模态共存:优先关靠前栈顶 — 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
});
async function uploadFiles(files) {
if (!files || !files.length) return false;
if (!files || !files.length) return null;
const fd = new FormData();
fd.append("path", state.filesPath || "");
for (const f of files) fd.append("files", f);
@ -3136,11 +3297,12 @@ async function uploadFiles(files) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " 上传失败"));
}
const data = await r.json();
await loadFiles();
return true;
return data.saved || [];
} catch (e) {
alert("上传失败:" + e.message);
return false;
return null;
}
}