mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-09 22:08:52 +02:00
Add files via upload
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 = '<div class="empty-state" data-i18n="chatFilesPage.empty">' + escapeHtml(emptyMsg) + '</div>';
|
||||
@@ -665,7 +669,6 @@ function renderChatFilesTable() {
|
||||
<th>${escapeHtml(thActions)}</th>
|
||||
</tr></thead>`;
|
||||
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
+152
-39
@@ -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<void>|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 已提示 */ });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user