diff --git a/web/static/css/style.css b/web/static/css/style.css index f79dd1d7..89597e71 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1803,6 +1803,16 @@ header { color: var(--text-primary); } +.chat-file-chip--uploading { + opacity: 0.92; + border-style: dashed; +} + +.chat-file-chip--error { + border-color: rgba(220, 38, 38, 0.45); + background: rgba(220, 38, 38, 0.06); +} + .chat-file-input-hidden { position: absolute; width: 0; @@ -14622,3 +14632,47 @@ button.chat-files-dropdown-item:hover:not(:disabled) { transform: translateX(-50%) translateY(0); } +/* 对话附件读取 / 文件管理上传 进度条 */ +/* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */ +.chat-upload-progress-row[hidden] { + display: none !important; +} + +.chat-upload-progress-row { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0 4px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08)); +} + +.chat-upload-progress-row--files { + margin-top: 12px; + margin-bottom: 0; +} + +.chat-upload-progress-track { + height: 6px; + border-radius: 4px; + background: var(--border-color, rgba(0, 0, 0, 0.1)); + overflow: hidden; +} + +.chat-upload-progress-fill { + height: 100%; + width: 0%; + border-radius: 4px; + background: var(--accent-primary, #2563eb); + transition: width 0.12s ease-out; +} + +.chat-upload-progress-label { + font-size: 0.8rem; + color: var(--text-secondary, #666); + line-height: 1.35; + word-break: break-all; +} + diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 1d76291c..a6ce1f09 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -123,6 +123,13 @@ "inputPlaceholder": "Enter target or command... (type @ to select tools | Shift+Enter newline, Enter send)", "selectFile": "Select file", "uploadFile": "Upload file (multi-select or drag & drop)", + "readingAttachmentsDetail": "Reading attachment {{current}}/{{total}} · {{name}} · {{percent}}%", + "uploadingAttachmentsDetail": "Uploading attachments · {{done}}/{{total}} done · {{percent}}% overall", + "waitingAttachmentsUpload": "Waiting for attachments to finish uploading…", + "attachmentsUploadIncomplete": "Some attachments failed to upload. Remove the failed items or pick files again before sending.", + "attachmentUploading": "Uploading…", + "attachmentUploadFailed": "Failed", + "attachmentUploadAlert": "Upload failed: {{name}}", "send": "Send", "searchInGroup": "Search in group...", "loadingTools": "Loading tools...", @@ -1124,6 +1131,7 @@ "copyPathTitle": "Copy the absolute path on the server; paste into chat to reference this file", "pathCopied": "Path copied — paste it into chat", "uploadOkHint": "Uploaded. Use “Copy path” to copy the absolute path.", + "uploadingFile": "Uploading {{name}} · {{percent}}%", "moreActions": "More: open chat, edit, rename, delete", "download": "Download", "edit": "Edit", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c1625e5c..2f1b584c 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -123,6 +123,13 @@ "inputPlaceholder": "输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)", "selectFile": "选择文件", "uploadFile": "上传文件(可多选或拖拽到此处)", + "readingAttachmentsDetail": "读取附件 {{current}}/{{total}} · {{name}} · {{percent}}%", + "uploadingAttachmentsDetail": "上传附件 · {{done}}/{{total}} 已完成 · 总进度 {{percent}}%", + "waitingAttachmentsUpload": "正在等待附件上传完成…", + "attachmentsUploadIncomplete": "部分附件未上传成功,请移除失败项或重新选择文件后再发送。", + "attachmentUploading": "上传中…", + "attachmentUploadFailed": "失败", + "attachmentUploadAlert": "上传失败:{{name}}", "send": "发送", "searchInGroup": "搜索分组中的对话...", "loadingTools": "正在加载工具...", @@ -1124,6 +1131,7 @@ "copyPathTitle": "复制服务器上的绝对路径,可粘贴到对话中让模型引用该文件", "pathCopied": "路径已复制,可到对话中粘贴使用", "uploadOkHint": "上传成功。点击「复制路径」可复制绝对路径到剪贴板。", + "uploadingFile": "正在上传 {{name}} · {{percent}}%", "moreActions": "更多:打开对话、编辑、重命名、删除", "download": "下载", "edit": "编辑", diff --git a/web/static/js/auth.js b/web/static/js/auth.js index d57a95f3..3b9c3579 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -163,6 +163,54 @@ async function apiFetch(url, options = {}) { return response; } +/** + * multipart POST with XMLHttpRequest so upload progress is available (fetch 无法可靠上报进度). + * 返回与 fetch 类似的对象:ok、status、json()、text() + */ +async function apiUploadWithProgress(url, formData, options = {}) { + await ensureAuthenticated(); + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + if (authToken) { + xhr.setRequestHeader('Authorization', `Bearer ${authToken}`); + } + xhr.upload.onprogress = (e) => { + if (!onProgress || !e.lengthComputable) return; + const percent = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0; + onProgress({ loaded: e.loaded, total: e.total, percent }); + }; + xhr.onerror = () => { + reject(new Error('Network error')); + }; + xhr.onload = () => { + if (xhr.status === 401) { + handleUnauthorized(); + const msg = (typeof window !== 'undefined' && typeof window.t === 'function') + ? window.t('auth.unauthorized') + : '未授权访问'; + reject(new Error(msg)); + return; + } + const responseText = xhr.responseText || ''; + resolve({ + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + text: async () => responseText, + json: async () => { + try { + return responseText ? JSON.parse(responseText) : {}; + } catch (err) { + throw err; + } + }, + }); + }; + xhr.send(formData); + }); +} + async function submitLogin(event) { event.preventDefault(); const passwordInput = document.getElementById('login-password'); diff --git a/web/static/js/chat-files.js b/web/static/js/chat-files.js index 0b6979e2..2cca02f6 100644 --- a/web/static/js/chat-files.js +++ b/web/static/js/chat-files.js @@ -12,6 +12,8 @@ const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path'; let chatFilesBrowsePath = []; /** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */ let chatFilesPendingUploadDir = ''; +/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */ +let chatFilesXHRUploadBusy = false; /** 仅前端记录的「空目录」键 parentPath('' 表示 chat_uploads 根)-> 子目录名列表,与树合并以便 mkdir 后可见 */ const CHAT_FILES_SYNTHETIC_DIRS_KEY = 'csai_chat_files_synthetic_dirs'; @@ -301,7 +303,7 @@ function chatFilesNameFilter(files) { /** 仅前端按文件名筛选,不重新请求 */ function chatFilesFilterNameOnInput() { - if (!chatFilesCache.length) return; + if (!chatFilesCache.length && chatFilesGetGroupByMode() !== 'folder') return; renderChatFilesTable(); } @@ -554,8 +556,10 @@ function renderChatFilesTable() { if (!wrap) return; chatFilesDisplayed = chatFilesNameFilter(chatFilesCache); + const groupMode = chatFilesGetGroupByMode(); const emptyMsg = (typeof window.t === 'function') ? window.t('chatFilesPage.empty') : '暂无文件'; - if (!chatFilesDisplayed.length) { + // 「按文件夹」模式下即使尚无文件,也要显示 chat_uploads 路径栏与「新建文件夹」,否则无法先建目录 + if (!chatFilesDisplayed.length && groupMode !== 'folder') { wrap.classList.remove('chat-files-table-wrap--grouped'); wrap.classList.remove('chat-files-table-wrap--tree'); wrap.innerHTML = '
' + escapeHtml(emptyMsg) + '
'; @@ -665,7 +669,6 @@ function renderChatFilesTable() { ${escapeHtml(thActions)} `; - const groupMode = chatFilesGetGroupByMode(); let innerHtml; if (groupMode === 'folder') { @@ -1197,7 +1200,36 @@ async function submitChatFilesMkdir() { } } +function chatFilesSetUploadProgressUI(visible, percent, fileName) { + const wrap = document.getElementById('chat-files-upload-progress'); + const fill = document.getElementById('chat-files-upload-progress-fill'); + const label = document.getElementById('chat-files-upload-progress-label'); + if (!wrap || !fill || !label) return; + if (!visible) { + wrap.hidden = true; + fill.style.width = '0%'; + label.textContent = ''; + return; + } + wrap.hidden = false; + const p = Math.min(100, Math.max(0, Math.round(percent))); + fill.style.width = p + '%'; + const name = fileName || ''; + label.textContent = (typeof window.t === 'function') + ? window.t('chatFilesPage.uploadingFile', { name: name, percent: p }) + : ('正在上传 ' + name + ' · ' + p + '%'); +} + +function chatFilesSetUploadBusy(busy) { + chatFilesXHRUploadBusy = !!busy; + ['chat-files-header-upload-btn', 'chat-files-refresh-btn'].forEach(function (id) { + const el = document.getElementById(id); + if (el) el.disabled = chatFilesXHRUploadBusy; + }); +} + function chatFilesOpenUploadPicker() { + if (chatFilesXHRUploadBusy) return; if (chatFilesGetGroupByMode() === 'folder') { chatFilesPendingUploadDir = chatFilesBrowsePath.join('/'); } else { @@ -1209,6 +1241,7 @@ function chatFilesOpenUploadPicker() { function chatFilesUploadToFolderClick(ev, btn) { if (ev) ev.stopPropagation(); + if (chatFilesXHRUploadBusy) return; const raw = btn.getAttribute('data-upload-dir'); if (!raw) return; try { @@ -1237,12 +1270,22 @@ async function onChatFilesUploadPick(ev) { form.append('conversationId', conv.value.trim()); } } + chatFilesSetUploadBusy(true); + chatFilesSetUploadProgressUI(true, 0, file.name); try { - const res = await apiFetch('/api/chat-uploads', { method: 'POST', body: form }); + const doXhr = typeof apiUploadWithProgress === 'function'; + const res = doXhr + ? await apiUploadWithProgress('/api/chat-uploads', form, { + onProgress: function (p) { + chatFilesSetUploadProgressUI(true, p.percent, file.name); + } + }) + : await apiFetch('/api/chat-uploads', { method: 'POST', body: form }); if (!res.ok) { throw new Error(await res.text()); } const data = await res.json().catch(() => ({})); + chatFilesSetUploadProgressUI(true, 100, file.name); loadChatFilesPage(); if (data && data.ok) { const msg = (typeof window.t === 'function') @@ -1253,6 +1296,8 @@ async function onChatFilesUploadPick(ev) { } catch (e) { alert((e && e.message) ? e.message : String(e)); } finally { + chatFilesSetUploadBusy(false); + chatFilesSetUploadProgressUI(false); input.value = ''; } } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 44046180..aef7609e 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -25,8 +25,12 @@ const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟 // 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表) const MAX_CHAT_FILES = 10; const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。'; -/** @type {{ fileName: string, content: string, mimeType: string }[]} */ +/** + * 对话附件:选文件后异步 POST /api/chat-uploads,发送时只传 serverPath(绝对路径),请求体不再内联大文件内容。 + * @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise|null, uploadError: string|null }[]} + */ let chatAttachments = []; +let chatAttachmentSeq = 0; // 多代理(Eino):需后端 multi_agent.enabled,与单代理 /agent-loop 并存 const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode'; @@ -236,6 +240,30 @@ async function sendMessage() { if (!message && !hasAttachments) { return; } + + if (hasAttachments) { + const needWait = chatAttachments.some((a) => a.uploading); + if (needWait) { + const waitLabel = (typeof window.t === 'function') + ? window.t('chat.waitingAttachmentsUpload') + : '正在等待附件上传完成…'; + chatAttachmentProgressSet(true, 0, waitLabel); + } + try { + await Promise.all(chatAttachments.map((a) => (a.uploadPromise ? a.uploadPromise : Promise.resolve()))); + } finally { + refreshChatAttachmentUploadProgress(); + } + const bad = chatAttachments.filter((a) => !a.serverPath); + if (bad.length) { + const hint = (typeof window.t === 'function') + ? window.t('chat.attachmentsUploadIncomplete') + : '部分附件未上传成功,请移除失败项或重新选择文件后再发送。'; + alert(hint); + return; + } + } + // 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型) if (hasAttachments && !message) { message = CHAT_FILE_DEFAULT_PROMPT; @@ -274,10 +302,10 @@ async function sendMessage() { role: typeof getCurrentRole === 'function' ? getCurrentRole() : '' }; if (hasAttachments) { - body.attachments = chatAttachments.map(a => ({ + body.attachments = chatAttachments.map((a) => ({ fileName: a.fileName, - content: a.content, - mimeType: a.mimeType || '' + mimeType: a.mimeType || '', + serverPath: a.serverPath })); } // 发送后清空附件列表 @@ -386,11 +414,19 @@ function renderChatFileChips() { chatAttachments.forEach((a, i) => { const chip = document.createElement('div'); chip.className = 'chat-file-chip'; + if (a.uploading) chip.classList.add('chat-file-chip--uploading'); + if (a.uploadError) chip.classList.add('chat-file-chip--error'); chip.setAttribute('role', 'listitem'); const name = document.createElement('span'); name.className = 'chat-file-chip-name'; name.title = a.fileName; - name.textContent = a.fileName; + let label = a.fileName; + if (a.uploading) { + label += ' · ' + ((typeof window.t === 'function') ? window.t('chat.attachmentUploading') : '上传中…'); + } else if (a.uploadError) { + label += ' · ' + ((typeof window.t === 'function') ? window.t('chat.attachmentUploadFailed') : '失败'); + } + name.textContent = label; const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'chat-file-chip-remove'; @@ -407,6 +443,7 @@ function renderChatFileChips() { function removeChatAttachment(index) { chatAttachments.splice(index, 1); renderChatFileChips(); + refreshChatAttachmentUploadProgress(); } // 有附件且输入框为空时,填入一句默认提示(可编辑);后端会单独拼接路径与内容给大模型 @@ -419,46 +456,122 @@ function appendChatFilePrompt() { } } -function readFileAsAttachment(file) { - return new Promise((resolve, reject) => { - const mimeType = file.type || ''; - const isTextLike = /^text\//i.test(mimeType) || /^(application\/(json|xml|javascript)|image\/svg\+xml)/i.test(mimeType); - const reader = new FileReader(); - reader.onload = () => { - let content = reader.result; - if (typeof content === 'string' && content.startsWith('data:')) { - content = content.replace(/^data:[^;]+;base64,/, ''); - } - resolve({ fileName: file.name, content: content, mimeType: mimeType }); - }; - reader.onerror = () => reject(reader.error); - if (isTextLike) { - reader.readAsText(file, 'UTF-8'); - } else { - reader.readAsDataURL(file); - } - }); +function chatAttachmentProgressSet(visible, percent, detailText) { + const wrap = document.getElementById('chat-attachment-progress'); + const fill = document.getElementById('chat-attachment-progress-fill'); + const label = document.getElementById('chat-attachment-progress-label'); + if (!wrap || !fill || !label) return; + if (!visible) { + wrap.hidden = true; + fill.style.width = '0%'; + label.textContent = ''; + return; + } + wrap.hidden = false; + const p = Math.min(100, Math.max(0, Math.round(percent))); + fill.style.width = p + '%'; + label.textContent = detailText || ''; } -function addFilesToChat(files) { +function refreshChatAttachmentUploadProgress() { + if (!chatAttachments.length) { + chatAttachmentProgressSet(false); + return; + } + const uploading = chatAttachments.filter((a) => a.uploading); + if (!uploading.length) { + chatAttachmentProgressSet(false); + return; + } + let sum = 0; + chatAttachments.forEach((a) => { + sum += a.uploading ? (a.uploadPercent || 0) : 100; + }); + const overall = Math.round(sum / chatAttachments.length); + const line = (typeof window.t === 'function') + ? window.t('chat.uploadingAttachmentsDetail', { + done: chatAttachments.length - uploading.length, + total: chatAttachments.length, + percent: overall + }) + : ('上传附件 ' + (chatAttachments.length - uploading.length) + '/' + chatAttachments.length + ' · ' + overall + '%'); + chatAttachmentProgressSet(true, overall, line); +} + +async function uploadOneChatAttachment(entry, file) { + const form = new FormData(); + form.append('file', file); + const conv = currentConversationId; + if (conv && String(conv).trim()) { + form.append('conversationId', String(conv).trim()); + } + const entryId = entry.id; + try { + const res = typeof apiUploadWithProgress === 'function' + ? await apiUploadWithProgress('/api/chat-uploads', form, { + onProgress: function (p) { + const cur = chatAttachments.find((x) => x.id === entryId); + if (cur) { + cur.uploadPercent = p.percent; + refreshChatAttachmentUploadProgress(); + } + } + }) + : await apiFetch('/api/chat-uploads', { method: 'POST', body: form }); + if (!res.ok) { + throw new Error(await res.text()); + } + const data = await res.json().catch(() => ({})); + const abs = data.absolutePath ? String(data.absolutePath).trim() : ''; + if (!abs) { + throw new Error('no absolutePath in response'); + } + const cur = chatAttachments.find((x) => x.id === entryId); + if (cur) { + cur.serverPath = abs; + cur.uploading = false; + cur.uploadPercent = 100; + cur.uploadError = null; + } + } catch (e) { + const msg = (e && e.message) ? e.message : String(e); + const cur = chatAttachments.find((x) => x.id === entryId); + if (cur) { + cur.uploading = false; + cur.uploadError = msg; + cur.serverPath = null; + } + alert(((typeof window.t === 'function') ? window.t('chat.attachmentUploadAlert', { name: file.name }) : ('上传失败:' + file.name)) + '\n' + msg); + } + renderChatFileChips(); + refreshChatAttachmentUploadProgress(); +} + +async function addFilesToChat(files) { if (!files || !files.length) return; const next = Array.from(files); if (chatAttachments.length + next.length > MAX_CHAT_FILES) { alert('最多同时上传 ' + MAX_CHAT_FILES + ' 个文件,当前已选 ' + chatAttachments.length + ' 个。'); return; } - const addOne = (file) => { - return readFileAsAttachment(file).then((a) => { - chatAttachments.push(a); - renderChatFileChips(); - appendChatFilePrompt(); - }).catch(() => { - alert('读取文件失败:' + file.name); - }); - }; - let p = Promise.resolve(); - next.forEach((file) => { p = p.then(() => addOne(file)); }); - p.then(() => {}); + next.forEach((file) => { + const id = ++chatAttachmentSeq; + const entry = { + id: id, + fileName: file.name, + mimeType: file.type || '', + serverPath: null, + uploading: true, + uploadPercent: 0, + uploadPromise: null, + uploadError: null + }; + entry.uploadPromise = uploadOneChatAttachment(entry, file); + chatAttachments.push(entry); + }); + renderChatFileChips(); + refreshChatAttachmentUploadProgress(); + appendChatFilePrompt(); } function setupChatFileUpload() { @@ -469,7 +582,7 @@ function setupChatFileUpload() { inputEl.addEventListener('change', function () { const files = this.files; if (files && files.length) { - addFilesToChat(files); + addFilesToChat(files).catch(function () { /* addFilesToChat 已提示 */ }); } this.value = ''; }); @@ -491,7 +604,7 @@ function setupChatFileUpload() { e.stopPropagation(); this.classList.remove('drag-over'); const files = e.dataTransfer && e.dataTransfer.files; - if (files && files.length) addFilesToChat(files); + if (files && files.length) addFilesToChat(files).catch(function () { /* addFilesToChat 已提示 */ }); }); } diff --git a/web/templates/index.html b/web/templates/index.html index 51bf545b..26dc42b5 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -626,6 +626,10 @@
+
@@ -637,7 +641,7 @@ - + - +
@@ -1101,6 +1105,10 @@
+
加载中…