diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index 91f1aef9..e2d7da01 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -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) ? `
${msg.images.map(im => ``).join('')}
` : ''; + const filesHtml = (msg.files && msg.files.length) + ? `
${msg.files.map(f => { + const name = f.name || 'file'; + const sub = fileSubLabel(name); + return `
${escapeHtml(name)}${escapeHtml(sub)}
`; + }).join('')}
` + : ''; const cleanText = stripAttachPlaceholders(shown); const textHtml = cleanText ? `
${escapeHtml(cleanText)}
` : ''; - el.innerHTML = `
${imgsHtml}${textHtml}
`; + el.innerHTML = `
${filesHtml}${imgsHtml}${textHtml}
`; } 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 `
`; } - const label = (f.name || 'file').replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c])); - return `
${label}
`; + const name = f.name || 'file'; + const label = name.replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c])); + const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c])); + const path = (f.path || '').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); + return `
${label}${sub}
`; }).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 = ''; const CHAN_FILE_LABELS = { diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html index caed7caa..0420d330 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -3,7 +3,7 @@ GenericAgent - + @@ -451,7 +451,7 @@ - + diff --git a/frontends/desktop/static/styles.css b/frontends/desktop/static/styles.css index 7734f14b..b6378804 100644 --- a/frontends/desktop/static/styles.css +++ b/frontends/desktop/static/styles.css @@ -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) ─── */ diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index a8cba032..a83b35d3 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -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()