mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 06:07:13 +08:00
feat(web): support folder upload
This commit is contained in:
@@ -401,9 +401,16 @@
|
|||||||
onclick="document.getElementById('file-input').click()">
|
onclick="document.getElementById('file-input').click()">
|
||||||
<i class="fas fa-paperclip text-base"></i>
|
<i class="fas fa-paperclip text-base"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="attach-folder-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||||
|
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
onclick="document.getElementById('folder-input').click()">
|
||||||
|
<i class="fas fa-folder-plus text-base"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="file-input" class="hidden" multiple
|
<input type="file" id="file-input" class="hidden" multiple
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
|
||||||
|
<input type="file" id="folder-input" class="hidden" multiple webkitdirectory directory>
|
||||||
<div id="slash-menu" class="slash-menu hidden"></div>
|
<div id="slash-menu" class="slash-menu hidden"></div>
|
||||||
<textarea id="chat-input"
|
<textarea id="chat-input"
|
||||||
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const I18N = {
|
|||||||
tip_new_chat: '新建对话',
|
tip_new_chat: '新建对话',
|
||||||
tip_clear_context: '清除上下文',
|
tip_clear_context: '清除上下文',
|
||||||
tip_attach_file: '上传附件',
|
tip_attach_file: '上传附件',
|
||||||
|
tip_attach_folder: '上传目录',
|
||||||
confirm_yes: '确认',
|
confirm_yes: '确认',
|
||||||
confirm_cancel: '取消',
|
confirm_cancel: '取消',
|
||||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||||
@@ -204,6 +205,7 @@ const I18N = {
|
|||||||
tip_new_chat: 'New Chat',
|
tip_new_chat: 'New Chat',
|
||||||
tip_clear_context: 'Clear Context',
|
tip_clear_context: 'Clear Context',
|
||||||
tip_attach_file: 'Attach File',
|
tip_attach_file: 'Attach File',
|
||||||
|
tip_attach_folder: 'Attach Folder',
|
||||||
confirm_yes: 'Confirm',
|
confirm_yes: 'Confirm',
|
||||||
confirm_cancel: 'Cancel',
|
confirm_cancel: 'Cancel',
|
||||||
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||||
@@ -578,6 +580,15 @@ const chatInput = document.getElementById('chat-input');
|
|||||||
const sendBtn = document.getElementById('send-btn');
|
const sendBtn = document.getElementById('send-btn');
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const folderInput = document.getElementById('folder-input');
|
||||||
|
const attachFolderBtn = document.getElementById('attach-folder-btn');
|
||||||
|
|
||||||
|
if (folderInput && attachFolderBtn) {
|
||||||
|
const supportsDirectoryUpload = 'webkitdirectory' in folderInput;
|
||||||
|
if (!supportsDirectoryUpload) {
|
||||||
|
attachFolderBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Smart auto-scroll: pause when user scrolls up, resume when near bottom
|
// Smart auto-scroll: pause when user scrolls up, resume when near bottom
|
||||||
let _autoScrollEnabled = true;
|
let _autoScrollEnabled = true;
|
||||||
@@ -644,9 +655,12 @@ function renderAttachmentPreview() {
|
|||||||
attachmentPreview.classList.remove('hidden');
|
attachmentPreview.classList.remove('hidden');
|
||||||
attachmentPreview.innerHTML = pendingAttachments.map((att, idx) => {
|
attachmentPreview.innerHTML = pendingAttachments.map((att, idx) => {
|
||||||
if (att._uploading) {
|
if (att._uploading) {
|
||||||
|
const suffix = att.file_type === 'directory' && att.file_count
|
||||||
|
? ` (${att.file_count})`
|
||||||
|
: '';
|
||||||
return `<div class="att-chip att-uploading" data-idx="${idx}">
|
return `<div class="att-chip att-uploading" data-idx="${idx}">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
<span class="att-name">${escapeHtml(att.file_name)}</span>
|
<span class="att-name">${escapeHtml(att.file_name)}${suffix}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (att.file_type === 'image') {
|
if (att.file_type === 'image') {
|
||||||
@@ -655,10 +669,15 @@ function renderAttachmentPreview() {
|
|||||||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
const icon = att.file_type === 'video' ? 'fa-film' : 'fa-file-alt';
|
const icon = att.file_type === 'video'
|
||||||
|
? 'fa-film'
|
||||||
|
: (att.file_type === 'directory' ? 'fa-folder-tree' : 'fa-file-alt');
|
||||||
|
const suffix = att.file_type === 'directory' && att.file_count
|
||||||
|
? ` (${att.file_count})`
|
||||||
|
: '';
|
||||||
return `<div class="att-chip" data-idx="${idx}">
|
return `<div class="att-chip" data-idx="${idx}">
|
||||||
<i class="fas ${icon}"></i>
|
<i class="fas ${icon}"></i>
|
||||||
<span class="att-name">${escapeHtml(att.file_name)}</span>
|
<span class="att-name">${escapeHtml(att.file_name)}${suffix}</span>
|
||||||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -709,11 +728,92 @@ async function handleFileSelect(files) {
|
|||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _makeUploadId() {
|
||||||
|
return `dir_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _groupDirectoryFiles(files) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const file of Array.from(files || [])) {
|
||||||
|
const relPath = file.webkitRelativePath || file.name;
|
||||||
|
const parts = relPath.split('/').filter(Boolean);
|
||||||
|
const rootName = parts[0] || file.name;
|
||||||
|
if (!groups.has(rootName)) groups.set(rootName, []);
|
||||||
|
groups.get(rootName).push({ file, relPath });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderSelect(files) {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const groups = _groupDirectoryFiles(files);
|
||||||
|
const groupTasks = [];
|
||||||
|
|
||||||
|
for (const [rootName, entries] of groups.entries()) {
|
||||||
|
const placeholder = {
|
||||||
|
file_name: rootName,
|
||||||
|
file_type: 'directory',
|
||||||
|
file_count: entries.length,
|
||||||
|
_uploading: true,
|
||||||
|
};
|
||||||
|
pendingAttachments.push(placeholder);
|
||||||
|
renderAttachmentPreview();
|
||||||
|
|
||||||
|
const uploadId = _makeUploadId();
|
||||||
|
const tasks = entries.map(async ({ file, relPath }) => {
|
||||||
|
uploadingCount++;
|
||||||
|
renderAttachmentPreview();
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('session_id', sessionId);
|
||||||
|
formData.append('upload_id', uploadId);
|
||||||
|
formData.append('relative_path', relPath);
|
||||||
|
|
||||||
|
const resp = await fetch('/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Upload failed');
|
||||||
|
}
|
||||||
|
if (!placeholder.file_path && data.root_path) {
|
||||||
|
placeholder.file_path = data.root_path;
|
||||||
|
placeholder.file_name = data.root_name || rootName;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadingCount--;
|
||||||
|
renderAttachmentPreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
groupTasks.push((async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all(tasks);
|
||||||
|
if (!placeholder.file_path) {
|
||||||
|
throw new Error('Directory root path missing');
|
||||||
|
}
|
||||||
|
delete placeholder._uploading;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Directory upload failed:', e);
|
||||||
|
const i = pendingAttachments.indexOf(placeholder);
|
||||||
|
if (i !== -1) pendingAttachments.splice(i, 1);
|
||||||
|
}
|
||||||
|
renderAttachmentPreview();
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(groupTasks);
|
||||||
|
}
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
fileInput.addEventListener('change', function() {
|
||||||
handleFileSelect(this.files);
|
handleFileSelect(this.files);
|
||||||
this.value = '';
|
this.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
folderInput.addEventListener('change', function() {
|
||||||
|
handleFolderSelect(this.files);
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
// Drag-and-drop support on chat input area
|
// Drag-and-drop support on chat input area
|
||||||
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
||||||
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
|
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
|
||||||
@@ -1037,6 +1137,7 @@ function sendMessage() {
|
|||||||
file_path: a.file_path,
|
file_path: a.file_path,
|
||||||
file_name: a.file_name,
|
file_name: a.file_name,
|
||||||
file_type: a.file_type,
|
file_type: a.file_type,
|
||||||
|
file_count: a.file_count,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,8 +1549,13 @@ function createUserMessageEl(content, timestamp, attachments) {
|
|||||||
if (a.file_type === 'image') {
|
if (a.file_type === 'image') {
|
||||||
return `<img src="${a.preview_url}" alt="${escapeHtml(a.file_name)}" class="user-msg-image">`;
|
return `<img src="${a.preview_url}" alt="${escapeHtml(a.file_name)}" class="user-msg-image">`;
|
||||||
}
|
}
|
||||||
const icon = a.file_type === 'video' ? 'fa-film' : 'fa-file-alt';
|
const icon = a.file_type === 'video'
|
||||||
return `<div class="user-msg-file"><i class="fas ${icon}"></i> ${escapeHtml(a.file_name)}</div>`;
|
? 'fa-film'
|
||||||
|
: (a.file_type === 'directory' ? 'fa-folder-tree' : 'fa-file-alt');
|
||||||
|
const suffix = a.file_type === 'directory' && a.file_count
|
||||||
|
? ` (${a.file_count})`
|
||||||
|
: '';
|
||||||
|
return `<div class="user-msg-file"><i class="fas ${icon}"></i> ${escapeHtml(a.file_name)}${suffix}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
attachHtml = `<div class="user-msg-attachments">${items}</div>`;
|
attachHtml = `<div class="user-msg-attachments">${items}</div>`;
|
||||||
}
|
}
|
||||||
@@ -1980,6 +2086,7 @@ function _applyInputTooltips() {
|
|||||||
set('new-chat-btn', 'tip_new_chat');
|
set('new-chat-btn', 'tip_new_chat');
|
||||||
set('clear-context-btn', 'tip_clear_context');
|
set('clear-context-btn', 'tip_clear_context');
|
||||||
set('attach-btn', 'tip_attach_file');
|
set('attach-btn', 'tip_attach_file');
|
||||||
|
set('attach-folder-btn', 'tip_attach_folder');
|
||||||
set('session-toggle-btn', 'session_history', 'bottom');
|
set('session-toggle-btn', 'session_history', 'bottom');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,45 @@ def _get_upload_dir() -> str:
|
|||||||
return tmp_dir
|
return tmp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_upload_relative_path(relative_path: str) -> str:
|
||||||
|
"""Normalize relative upload path and reject escapes / absolute paths."""
|
||||||
|
relative_path = (relative_path or "").replace("\\", "/").strip("/")
|
||||||
|
if not relative_path:
|
||||||
|
raise ValueError("Empty relative path")
|
||||||
|
norm_path = os.path.normpath(relative_path)
|
||||||
|
if norm_path in (".", "") or norm_path.startswith("..") or os.path.isabs(norm_path):
|
||||||
|
raise ValueError("Invalid relative path")
|
||||||
|
return norm_path
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_upload_id(upload_id: str) -> str:
|
||||||
|
"""Allow only simple batch ids for directory uploads."""
|
||||||
|
sanitized = "".join(ch for ch in (upload_id or "") if ch.isalnum() or ch in ("-", "_"))
|
||||||
|
if not sanitized:
|
||||||
|
raise ValueError("Invalid upload id")
|
||||||
|
return sanitized[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_uploaded_file_bytes(file_obj) -> bytes:
|
||||||
|
"""Return uploaded content as bytes across web.py upload object variants."""
|
||||||
|
content = None
|
||||||
|
|
||||||
|
if hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
|
||||||
|
content = file_obj.file.read()
|
||||||
|
elif hasattr(file_obj, "read"):
|
||||||
|
content = file_obj.read()
|
||||||
|
elif hasattr(file_obj, "value"):
|
||||||
|
content = file_obj.value
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
raise ValueError("Unable to read uploaded file content")
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
return content
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.encode("utf-8")
|
||||||
|
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
||||||
|
|
||||||
|
|
||||||
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
||||||
"""Delegate to the shared SessionService implementation."""
|
"""Delegate to the shared SessionService implementation."""
|
||||||
from agent.chat.session_service import generate_session_title
|
from agent.chat.session_service import generate_session_title
|
||||||
@@ -343,24 +382,47 @@ class WebChannel(ChatChannel):
|
|||||||
return on_event
|
return on_event
|
||||||
|
|
||||||
def upload_file(self):
|
def upload_file(self):
|
||||||
"""Handle file upload via multipart/form-data. Save to workspace/tmp/ and return metadata."""
|
"""Handle file or directory upload via multipart/form-data."""
|
||||||
try:
|
try:
|
||||||
params = web.input(file={}, session_id="")
|
params = web.input(file={}, session_id="", relative_path="", upload_id="")
|
||||||
file_obj = params.get("file")
|
file_obj = params.get("file")
|
||||||
session_id = params.get("session_id", "")
|
session_id = params.get("session_id", "")
|
||||||
|
relative_path = params.get("relative_path", "")
|
||||||
|
upload_id = params.get("upload_id", "")
|
||||||
if file_obj is None or not hasattr(file_obj, "filename") or not file_obj.filename:
|
if file_obj is None or not hasattr(file_obj, "filename") or not file_obj.filename:
|
||||||
return json.dumps({"status": "error", "message": "No file uploaded"})
|
return json.dumps({"status": "error", "message": "No file uploaded"})
|
||||||
|
|
||||||
upload_dir = _get_upload_dir()
|
upload_dir = _get_upload_dir()
|
||||||
|
|
||||||
original_name = file_obj.filename
|
original_name = file_obj.filename
|
||||||
ext = os.path.splitext(original_name)[1].lower()
|
root_path = ""
|
||||||
safe_name = f"web_{uuid.uuid4().hex[:8]}{ext}"
|
|
||||||
save_path = os.path.join(upload_dir, safe_name)
|
|
||||||
|
|
||||||
|
if relative_path:
|
||||||
|
if not upload_id:
|
||||||
|
return json.dumps({"status": "error", "message": "Missing upload_id for directory upload"})
|
||||||
|
safe_upload_id = _sanitize_upload_id(upload_id)
|
||||||
|
safe_rel_path = _sanitize_upload_relative_path(relative_path)
|
||||||
|
upload_root = os.path.join(upload_dir, f"webdir_{safe_upload_id}")
|
||||||
|
save_path = os.path.normpath(os.path.join(upload_root, safe_rel_path))
|
||||||
|
if not os.path.abspath(save_path).startswith(os.path.abspath(upload_root) + os.sep):
|
||||||
|
return json.dumps({"status": "error", "message": "Invalid directory upload path"})
|
||||||
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
|
root_name = safe_rel_path.split(os.sep, 1)[0]
|
||||||
|
root_path = os.path.join(upload_root, root_name)
|
||||||
|
public_path = os.path.relpath(save_path, upload_dir).replace(os.sep, "/")
|
||||||
|
display_name = root_name
|
||||||
|
else:
|
||||||
|
ext = os.path.splitext(original_name)[1].lower()
|
||||||
|
safe_name = f"web_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
save_path = os.path.join(upload_dir, safe_name)
|
||||||
|
public_path = safe_name
|
||||||
|
display_name = original_name
|
||||||
|
|
||||||
|
content_bytes = _read_uploaded_file_bytes(file_obj)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
f.write(file_obj.read() if hasattr(file_obj, "read") else file_obj.value)
|
f.write(content_bytes)
|
||||||
|
|
||||||
|
ext = os.path.splitext(original_name)[1].lower()
|
||||||
if ext in IMAGE_EXTENSIONS:
|
if ext in IMAGE_EXTENSIONS:
|
||||||
file_type = "image"
|
file_type = "image"
|
||||||
elif ext in VIDEO_EXTENSIONS:
|
elif ext in VIDEO_EXTENSIONS:
|
||||||
@@ -368,17 +430,26 @@ class WebChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
file_type = "file"
|
file_type = "file"
|
||||||
|
|
||||||
preview_url = f"/uploads/{safe_name}"
|
from urllib.parse import quote
|
||||||
|
preview_url = f"/uploads/{quote(public_path, safe='/')}"
|
||||||
|
|
||||||
logger.info(f"[WebChannel] File uploaded: {original_name} -> {save_path} ({file_type})")
|
logger.info(f"[WebChannel] File uploaded: {original_name} -> {save_path} ({file_type})")
|
||||||
|
|
||||||
return json.dumps({
|
resp = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"file_path": save_path,
|
"file_path": save_path,
|
||||||
"file_name": original_name,
|
"file_name": display_name,
|
||||||
"file_type": file_type,
|
"file_type": file_type,
|
||||||
"preview_url": preview_url,
|
"preview_url": preview_url,
|
||||||
}, ensure_ascii=False)
|
}
|
||||||
|
if root_path:
|
||||||
|
resp.update({
|
||||||
|
"root_path": root_path,
|
||||||
|
"root_name": display_name,
|
||||||
|
"relative_path": relative_path,
|
||||||
|
"upload_type": "directory",
|
||||||
|
})
|
||||||
|
return json.dumps(resp, ensure_ascii=False)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WebChannel] File upload error: {e}", exc_info=True)
|
logger.error(f"[WebChannel] File upload error: {e}", exc_info=True)
|
||||||
@@ -410,6 +481,8 @@ class WebChannel(ChatChannel):
|
|||||||
file_refs.append(f"[图片: {fpath}]")
|
file_refs.append(f"[图片: {fpath}]")
|
||||||
elif ftype == "video":
|
elif ftype == "video":
|
||||||
file_refs.append(f"[视频: {fpath}]")
|
file_refs.append(f"[视频: {fpath}]")
|
||||||
|
elif ftype == "directory":
|
||||||
|
file_refs.append(f"[目录: {fpath}]")
|
||||||
else:
|
else:
|
||||||
file_refs.append(f"[文件: {fpath}]")
|
file_refs.append(f"[文件: {fpath}]")
|
||||||
if file_refs:
|
if file_refs:
|
||||||
|
|||||||
Reference in New Issue
Block a user