From e0c9a3bd8eafd466ddf3f1e6906af4a1a7d6cf49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Sun, 29 Mar 2026 03:24:22 +0800
Subject: [PATCH] Add files via upload
---
web/static/css/style.css | 54 ++++++++++
web/static/i18n/en-US.json | 8 ++
web/static/i18n/zh-CN.json | 8 ++
web/static/js/auth.js | 48 +++++++++
web/static/js/chat-files.js | 53 +++++++++-
web/static/js/chat.js | 191 ++++++++++++++++++++++++++++--------
web/templates/index.html | 14 ++-
7 files changed, 330 insertions(+), 46 deletions(-)
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 @@