feat(chat): file attachment cards in user message + click-to-open (#10)

Non-image attachments now render as Gemini/ChatGPT-style file cards above
the user text bubble (icon disc + bold filename + kind subtitle). Click on
a file card opens it via the system default app — bridge `/path/open` gains
a `kind=upload` mode that whitelists \`_WEB_UPLOAD_DIR\` to prevent the
frontend from opening arbitrary local paths.

Composer thumb-strip uses the same card style for non-image pending files,
also click-to-open. Image thumbs (48×48) unchanged.

Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>
This commit is contained in:
顾祎炜andYiwei Gu authored and GitHub committed 2026-05-23 19:42:58 +08:00
1 parent 26872bd39a
commit 8783914077
4 files changed
+186 -6

No files matched your search

+80 -4
View File
@@ -274,6 +274,15 @@ const I18N = {
'upload.button': '上传文件',
'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件',
'upload.failed': '上传失败',
'file.openFailed': '无法打开文件',
'file.kindGeneric': '文件',
'file.kindDoc': '文档',
'file.kindSheet': '表格',
'file.kindSlide': '幻灯片',
'file.kindCode': '代码',
'file.kindArchive': '压缩包',
'file.kindAudio': '音频',
'file.kindVideo': '视频',
'upload.removeTitle': '移除',
'upload.dropHint': '松开以上传文件',
'lightbox.closeTitle': '关闭',
@@ -370,6 +379,15 @@ const I18N = {
'upload.button': 'Upload file',
'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file',
'upload.failed': 'Upload failed',
'file.openFailed': 'Cannot open file',
'file.kindGeneric': 'File',
'file.kindDoc': 'Document',
'file.kindSheet': 'Spreadsheet',
'file.kindSlide': 'Slides',
'file.kindCode': 'Code',
'file.kindArchive': 'Archive',
'file.kindAudio': 'Audio',
'file.kindVideo': 'Video',
'upload.removeTitle': 'Remove',
'upload.dropHint': 'Drop to upload files',
'lightbox.closeTitle': 'Close',
@@ -917,6 +935,26 @@ function refreshEmptyState(sess) {
function stripAttachPlaceholders(text) {
return String(text || '').replace(/\[(Image|File)\s+#\d+\]\s*/g, '').trim();
}
function fileSubLabel(name) {
const m = String(name || '').match(/\.([^.]+)$/);
if (!m) return t('file.kindGeneric');
const ext = m[1].toLowerCase();
const docExts = ['pdf', 'doc', 'docx', 'rtf', 'odt', 'pages', 'tex'];
const sheetExts = ['xls', 'xlsx', 'csv', 'tsv', 'numbers', 'ods'];
const slideExts = ['ppt', 'pptx', 'key', 'odp'];
const codeExts = ['py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'xml', 'sql', 'md'];
const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z', 'bz2'];
const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'];
const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv'];
if (docExts.includes(ext)) return t('file.kindDoc');
if (sheetExts.includes(ext)) return t('file.kindSheet');
if (slideExts.includes(ext)) return t('file.kindSlide');
if (codeExts.includes(ext)) return t('file.kindCode') + ' · ' + ext.toUpperCase();
if (archiveExts.includes(ext)) return t('file.kindArchive');
if (audioExts.includes(ext)) return t('file.kindAudio');
if (videoExts.includes(ext)) return t('file.kindVideo');
return ext.toUpperCase();
}
function msgNode(msg) {
const el = document.createElement('div');
el.className = 'msg ' + (msg.role || 'system');
@@ -925,9 +963,16 @@ function msgNode(msg) {
const imgsHtml = (msg.images && msg.images.length)
? `<div class="user-imgs">${msg.images.map(im => `<img src="${im.dataUrl}" alt="">`).join('')}</div>`
: '';
const filesHtml = (msg.files && msg.files.length)
? `<div class="user-files">${msg.files.map(f => {
const name = f.name || 'file';
const sub = fileSubLabel(name);
return `<div class="file-chip" data-path="${escapeHtml(f.path || '')}" data-name="${escapeHtml(name)}"><span class="fc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span><span class="fc-meta"><span class="fc-name">${escapeHtml(name)}</span><span class="fc-sub">${escapeHtml(sub)}</span></span></div>`;
}).join('')}</div>`
: '';
const cleanText = stripAttachPlaceholders(shown);
const textHtml = cleanText ? `<div class="bubble">${escapeHtml(cleanText)}</div>` : '';
el.innerHTML = `<div class="user-stack">${imgsHtml}${textHtml}</div>`;
el.innerHTML = `<div class="user-stack">${filesHtml}${imgsHtml}${textHtml}</div>`;
}
else if (msg.role === 'assistant') {
const body = msg.stopped ? (msg.content + '\n\n_[' + t('status.stopped') + ']_') : msg.content;
@@ -1161,6 +1206,7 @@ function normalize(m) {
if (typeof m.display === 'string' && m.display.length) o.display = m.display;
if (m.stopped) o.stopped = true;
if (m.images) o.images = m.images;
if (m.files) o.files = m.files;
return o;
}
function upsert(sess, raw, partial) {
@@ -1290,6 +1336,8 @@ async function sendPrompt(text) {
const userMsg = { role: 'user', content: text };
const previewImgs = usedFiles.filter(f => f.isImage && f.dataUrl).map(f => ({ id: 'f-' + f.sid, dataUrl: f.dataUrl }));
if (previewImgs.length) userMsg.images = previewImgs;
const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path }));
if (previewFiles.length) userMsg.files = previewFiles;
sess.messages.push(userMsg); appendMessage(sess, userMsg);
sess.lastActiveTs = Date.now();
if (sess.untitled || isUntitled(sess.title)) {
@@ -1736,8 +1784,11 @@ function renderThumbStrip() {
if (f.isImage && f.dataUrl) {
return `<div class="thumb" data-sid="${f.sid}"><img src="${f.dataUrl}"><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}
const label = (f.name || 'file').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
return `<div class="file-chip" data-sid="${f.sid}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span class="fc-name">${label}</span><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
const name = f.name || 'file';
const label = name.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
const path = (f.path || '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
return `<div class="file-chip pending" data-sid="${f.sid}" data-path="${path}"><span class="fc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span><span class="fc-meta"><span class="fc-name">${label}</span><span class="fc-sub">${sub}</span></span><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}).join('');
thumbStrip.hidden = false;
applyI18n();
@@ -1867,6 +1918,12 @@ if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
}
return;
}
const fileChip = e.target.closest('.file-chip.pending');
if (fileChip) {
const path = fileChip.getAttribute('data-path');
if (path) openUploadFile(path);
return;
}
const img = e.target.closest('img');
if (img && img.src) openLightbox(img.src);
});
@@ -2285,10 +2342,29 @@ document.addEventListener('keydown', (e) => {
if (msgArea) {
msgArea.addEventListener('click', (e) => {
const img = e.target.closest('.user-imgs img');
if (img && img.src) openLightbox(img.src);
if (img && img.src) { openLightbox(img.src); return; }
const fileChip = e.target.closest('.user-files .file-chip');
if (fileChip) {
const path = fileChip.getAttribute('data-path');
if (path) openUploadFile(path);
}
});
}
async function openUploadFile(path) {
try {
const res = await fetch(`http://${location.hostname}:14168/path/open`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'upload', path }),
});
const j = await res.json();
if (!j.ok) throw new Error(j.error || 'open failed');
} catch (e) {
showChanToast(t('file.openFailed'), e.message || String(e), 'err');
}
}
/* ═══════════════ 消息通道(复用 gaServiceStore + WS 同步) ═══════════════ */
const CHAN_ICON = '<svg class="lr-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const CHAN_FILE_LABELS = {
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<link rel="stylesheet" href="styles.css?v=18">
<link rel="stylesheet" href="styles.css?v=19">
<!-- KaTeX: LaTeX 公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<!-- highlight.js: 代码高亮 -->
@@ -451,7 +451,7 @@
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<!-- highlight.js -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="app.js?v=24"></script>
<script src="app.js?v=25"></script>
<script src="annot.js"></script><!-- DEV 脚手架,可删 -->
</body>
</html>
+95
View File
@@ -1034,6 +1034,52 @@ html[data-boot-has-sessions] .chat-start{ display:none !important; }
}
.file-chip .x:hover{ color:var(--danger); }
/* ─── thumb-strip 里的文件 chip:升级成卡片样式(与消息中一致) ─── */
.thumb-strip .file-chip.pending{
height:auto;
min-height:52px;
padding:8px 32px 8px 8px;
gap:9px;
max-width:240px;
border-radius:10px;
background:var(--panel);
cursor:pointer;
transition:background .15s, border-color .15s, box-shadow .15s;
}
.thumb-strip .file-chip.pending:hover{
background:var(--hover-2);
border-color:var(--line-strong);
box-shadow:var(--shadow);
}
.thumb-strip .file-chip.pending .fc-icon{
width:32px; height:32px;
flex:0 0 auto;
border-radius:50%;
background:var(--blue-bg);
color:var(--blue);
display:flex; align-items:center; justify-content:center;
}
.thumb-strip .file-chip.pending .fc-icon svg{
width:16px; height:16px;
color:inherit;
}
.thumb-strip .file-chip.pending .fc-meta{
display:flex; flex-direction:column;
min-width:0;
line-height:1.25;
}
.thumb-strip .file-chip.pending .fc-name{
font-size:12px;
font-weight:600;
color:var(--txt-strong);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.thumb-strip .file-chip.pending .fc-sub{
font-size:11px;
color:var(--muted);
margin-top:1px;
}
/* ─── chat panel: drag-over overlay ─── */
.main.dragover{ position:relative; }
.main.dragover::after{
@@ -1066,6 +1112,55 @@ html[data-boot-has-sessions] .chat-start{ display:none !important; }
cursor:zoom-in;
display:block;
}
.user-files{
display:flex; flex-direction:column; gap:6px;
align-items:flex-end;
}
.user-files .file-chip{
height:auto;
min-height:56px;
padding:10px 14px 10px 10px;
gap:10px;
max-width:280px;
border-radius:12px;
background:var(--panel);
cursor:pointer;
transition:background .15s, border-color .15s, box-shadow .15s, transform .15s;
}
.user-files .file-chip:hover{
background:var(--hover-2);
border-color:var(--line-strong);
box-shadow:var(--shadow);
transform:translateY(-1px);
}
.user-files .file-chip .fc-icon{
width:36px; height:36px;
flex:0 0 auto;
border-radius:50%;
background:var(--blue-bg);
color:var(--blue);
display:flex; align-items:center; justify-content:center;
}
.user-files .file-chip .fc-icon svg{
width:18px; height:18px;
color:inherit;
}
.user-files .file-chip .fc-meta{
display:flex; flex-direction:column;
min-width:0;
line-height:1.25;
}
.user-files .file-chip .fc-name{
font-size:13px;
font-weight:600;
color:var(--txt-strong);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.user-files .file-chip .fc-sub{
font-size:11px;
color:var(--muted);
margin-top:2px;
}
.user-stack .bubble{ max-width:100%; }
/* ─── lightbox (image preview) ─── */
+9
View File
@@ -1083,6 +1083,15 @@ async def path_open_handler(request):
target = template if template.exists() else target
elif kind == "mykeyTemplate":
target = Path(manager.ga_root) / "mykey_template.py"
elif kind == "upload":
raw = Path(data.get("path") or "")
try:
resolved = raw.resolve()
upload_root = _WEB_UPLOAD_DIR.resolve()
resolved.relative_to(upload_root)
except (ValueError, OSError):
return json_ok({"ok": False, "error": "path not in upload dir"}, status=403)
target = resolved
else:
target = Path(data.get("path") or data.get("target") or manager.ga_root)
target = target.resolve()