Merge pull request #2815 from TryToMakeUsBetter/master

feat(web): support folder upload
This commit is contained in:
zhayujie
2026-05-15 18:57:15 +08:00
committed by GitHub
4 changed files with 432 additions and 25 deletions

View File

@@ -398,12 +398,24 @@
<button id="attach-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('file-input').click()">
type="button"
onclick="toggleAttachMenu(event)">
<i class="fas fa-paperclip text-base"></i>
</button>
</div>
<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">
<input type="file" id="folder-input" class="hidden" multiple webkitdirectory directory>
<div id="attach-menu" class="attach-menu hidden">
<button id="attach-file-option" type="button" class="attach-menu-item" onclick="triggerFileUpload()">
<i class="fas fa-file-arrow-up"></i>
<span data-i18n="attach_menu_file">上传文件</span>
</button>
<button id="attach-folder-option" type="button" class="attach-menu-item" onclick="triggerFolderUpload()">
<i class="fas fa-folder-plus"></i>
<span data-i18n="attach_menu_folder">上传文件夹</span>
</button>
</div>
<div id="slash-menu" class="slash-menu hidden"></div>
<textarea id="chat-input"
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
@@ -972,7 +984,6 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="assets/js/console.js"></script>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -748,6 +748,46 @@
}
.attachment-preview.hidden { display: none; }
.attach-menu {
position: absolute;
left: 72px;
bottom: calc(100% + 6px);
min-width: 148px;
padding: 6px;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
z-index: 55;
animation: slashMenuIn 0.15s ease-out;
}
.attach-menu.hidden { display: none; }
.attach-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 13px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
text-align: left;
}
.attach-menu-item:hover {
background: #EDFDF3;
color: #228547;
}
.attach-menu-item i {
width: 14px;
text-align: center;
color: #64748b;
}
.attach-menu-item:hover i { color: inherit; }
.att-thumb {
position: relative;
width: 64px; height: 64px;
@@ -926,6 +966,22 @@
color: #64748b;
}
.dark .attach-menu {
background: #1A1A1A;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15);
}
.dark .attach-menu-item {
color: #e2e8f0;
}
.dark .attach-menu-item i {
color: #94a3b8;
}
.dark .attach-menu-item:hover {
background: rgba(74, 190, 110, 0.1);
color: #4ABE6E;
}
/* ============================================================
Knowledge View
============================================================ */

View File

@@ -104,7 +104,9 @@ const I18N = {
context_cleared: '— 以上内容已从上下文中移除 —',
tip_new_chat: '新建对话',
tip_clear_context: '清除上下文',
tip_attach_file: '上传附件',
tip_attach: '添加附件',
attach_menu_file: '上传文件',
attach_menu_folder: '上传文件夹',
confirm_yes: '确认',
confirm_cancel: '取消',
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
@@ -203,7 +205,9 @@ const I18N = {
context_cleared: '— Context above has been cleared —',
tip_new_chat: 'New Chat',
tip_clear_context: 'Clear Context',
tip_attach_file: 'Attach File',
tip_attach: 'Add Attachment',
attach_menu_file: 'Upload File',
attach_menu_folder: 'Upload Folder',
confirm_yes: 'Confirm',
confirm_cancel: 'Cancel',
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
@@ -390,14 +394,34 @@ window.addEventListener('resize', () => {
// =====================================================================
// Markdown Renderer
// =====================================================================
const FALLBACK_HLJS = {
getLanguage() { return false; },
highlight(str) { return { value: escapeHtml(str) }; },
highlightAuto(str) { return { value: escapeHtml(str) }; },
highlightElement() {},
};
function getHljs() {
return window.hljs || FALLBACK_HLJS;
}
function createMd() {
const md = window.markdownit({
const hljsLib = getHljs();
const mdFactory = window.markdownit;
if (typeof mdFactory !== 'function') {
return {
render(text) {
return `<p>${escapeHtml(text || '')}</p>`;
}
};
}
const md = mdFactory({
html: false, breaks: true, linkify: true, typographer: true,
highlight: function(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try { return hljs.highlight(str, { language: lang }).value; } catch (_) {}
if (lang && hljsLib.getLanguage(lang)) {
try { return hljsLib.highlight(str, { language: lang }).value; } catch (_) {}
}
return hljs.highlightAuto(str).value;
return hljsLib.highlightAuto(str).value;
}
});
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
@@ -578,6 +602,15 @@ const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const messagesDiv = document.getElementById('chat-messages');
const fileInput = document.getElementById('file-input');
const folderInput = document.getElementById('folder-input');
const attachBtn = document.getElementById('attach-btn');
const attachMenu = document.getElementById('attach-menu');
const attachFolderOption = document.getElementById('attach-folder-option');
const supportsDirectoryUpload = !!folderInput && 'webkitdirectory' in folderInput;
if (!supportsDirectoryUpload && attachFolderOption) {
attachFolderOption.classList.add('hidden');
}
// Smart auto-scroll: pause when user scrolls up, resume when near bottom
let _autoScrollEnabled = true;
@@ -644,9 +677,12 @@ function renderAttachmentPreview() {
attachmentPreview.classList.remove('hidden');
attachmentPreview.innerHTML = pendingAttachments.map((att, idx) => {
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}">
<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>`;
}
if (att.file_type === 'image') {
@@ -655,10 +691,15 @@ function renderAttachmentPreview() {
<button class="att-remove" onclick="removeAttachment(${idx})">&times;</button>
</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}">
<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})">&times;</button>
</div>`;
}).join('');
@@ -671,6 +712,34 @@ function removeAttachment(idx) {
renderAttachmentPreview();
}
function isAttachMenuVisible() {
return attachMenu && !attachMenu.classList.contains('hidden');
}
function hideAttachMenu() {
if (attachMenu) attachMenu.classList.add('hidden');
}
function toggleAttachMenu(event) {
if (!attachMenu) return;
if (event) {
event.preventDefault();
event.stopPropagation();
}
attachMenu.classList.toggle('hidden');
}
function triggerFileUpload() {
hideAttachMenu();
fileInput?.click();
}
function triggerFolderUpload() {
if (!supportsDirectoryUpload) return;
hideAttachMenu();
folderInput?.click();
}
async function handleFileSelect(files) {
if (!files || files.length === 0) return;
const tasks = [];
@@ -709,11 +778,90 @@ async function handleFileSelect(files) {
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);
uploadingCount++;
renderAttachmentPreview();
const uploadId = _makeUploadId();
groupTasks.push((async () => {
try {
const formData = new FormData();
formData.append('session_id', sessionId);
formData.append('upload_id', uploadId);
for (const { file, relPath } of entries) {
formData.append('files', file);
formData.append('relative_paths', 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 (!data.root_path) {
throw new Error('Directory root path missing');
}
placeholder.file_path = data.root_path;
placeholder.file_name = data.root_name || rootName;
delete placeholder._uploading;
} catch (e) {
console.error('Directory upload failed:', e);
const i = pendingAttachments.indexOf(placeholder);
if (i !== -1) pendingAttachments.splice(i, 1);
} finally {
uploadingCount--;
}
renderAttachmentPreview();
})());
}
await Promise.all(groupTasks);
}
fileInput.addEventListener('change', function() {
handleFileSelect(this.files);
this.value = '';
});
folderInput.addEventListener('change', function() {
handleFolderSelect(this.files);
this.value = '';
});
document.addEventListener('click', (e) => {
if (!isAttachMenuVisible()) return;
if (attachMenu.contains(e.target) || attachBtn.contains(e.target)) return;
hideAttachMenu();
});
// Drag-and-drop support on chat input area
const chatInputArea = chatInput.closest('.flex-shrink-0');
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
@@ -894,6 +1042,11 @@ chatInput.addEventListener('input', function() {
chatInput.addEventListener('keydown', function(e) {
if (e.keyCode === 229 || e.isComposing || isComposing) return;
if (e.key === 'Escape' && isAttachMenuVisible()) {
hideAttachMenu();
return;
}
if (isSlashMenuVisible()) {
if (e.key === 'ArrowDown') {
e.preventDefault();
@@ -1037,6 +1190,7 @@ function sendMessage() {
file_path: a.file_path,
file_name: a.file_name,
file_type: a.file_type,
file_count: a.file_count,
}));
}
@@ -1448,8 +1602,13 @@ function createUserMessageEl(content, timestamp, attachments) {
if (a.file_type === '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';
return `<div class="user-msg-file"><i class="fas ${icon}"></i> ${escapeHtml(a.file_name)}</div>`;
const icon = a.file_type === 'video'
? '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('');
attachHtml = `<div class="user-msg-attachments">${items}</div>`;
}
@@ -1979,7 +2138,7 @@ function _applyInputTooltips() {
};
set('new-chat-btn', 'tip_new_chat');
set('clear-context-btn', 'tip_clear_context');
set('attach-btn', 'tip_attach_file');
set('attach-btn', 'tip_attach');
set('session-toggle-btn', 'session_history', 'bottom');
}
@@ -2295,9 +2454,10 @@ function _updateScrollToBottomBtn() {
function applyHighlighting(container) {
const root = container || document;
setTimeout(() => {
const hljsLib = getHljs();
root.querySelectorAll('pre code').forEach(block => {
if (!block.classList.contains('hljs')) {
hljs.highlightElement(block);
hljsLib.highlightElement(block);
}
});
}, 0);
@@ -4421,18 +4581,38 @@ function switchKnowledgeTab(tab) {
}
}
let _d3LoadPromise = null;
function ensureD3Loaded() {
if (window.d3) return Promise.resolve(window.d3);
if (_d3LoadPromise) return _d3LoadPromise;
_d3LoadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
script.async = true;
script.onload = () => resolve(window.d3);
script.onerror = () => reject(new Error('Failed to load d3'));
document.head.appendChild(script);
});
return _d3LoadPromise;
}
function loadKnowledgeGraph() {
_knowledgeGraphLoaded = true;
const container = document.getElementById('knowledge-graph-container');
container.innerHTML = '';
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm"><i class="fas fa-spinner fa-spin mr-2"></i>Loading graph...</div>';
fetch('/api/knowledge/graph').then(r => r.json()).then(data => {
Promise.all([
ensureD3Loaded(),
fetch('/api/knowledge/graph').then(r => r.json()),
]).then(([, data]) => {
const nodes = data.nodes || [];
const links = data.links || [];
if (nodes.length === 0) {
container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-slate-400"><i class="fas fa-diagram-project text-3xl mb-3 opacity-40"></i><p class="text-sm">${t('knowledge_empty_hint')}</p></div>`;
return;
}
container.innerHTML = '';
renderKnowledgeGraph(container, nodes, links);
}).catch(() => {
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm">Failed to load graph</div>';

View File

@@ -9,6 +9,7 @@ import threading
import time
import uuid
from queue import Queue, Empty
from typing import Tuple
import web
@@ -90,6 +91,95 @@ def _get_upload_dir() -> str:
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")
parts = []
for part in relative_path.split("/"):
if part in ("", "."):
continue
if part == "..":
raise ValueError("Invalid relative path")
parts.append(part)
if not parts:
raise ValueError("Invalid relative path")
norm_path = "/".join(parts)
if 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 _is_within_directory(root_path: str, target_path: str) -> bool:
try:
return os.path.commonpath([root_path, target_path]) == root_path
except ValueError:
return False
def _resolve_upload_path(upload_root: str, relative_path: str) -> Tuple[str, str]:
"""Resolve a relative upload path under upload_root and reject escapes."""
safe_rel_path = _sanitize_upload_relative_path(relative_path)
upload_root_real = os.path.realpath(upload_root)
save_path = os.path.realpath(os.path.join(upload_root_real, *safe_rel_path.split("/")))
if not _is_within_directory(upload_root_real, save_path):
raise ValueError("Invalid directory upload path")
return safe_rel_path, save_path
def _read_uploaded_file_bytes(file_obj) -> bytes:
"""Return uploaded content as bytes across web.py upload object variants."""
if isinstance(file_obj, bytes):
return file_obj
if isinstance(file_obj, str):
return file_obj.encode("utf-8")
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 _raw_web_input():
"""Return unprocessed multipart form data when web.py exposes rawinput."""
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
if not callable(rawinput):
raise RuntimeError("web.py rawinput is not available")
try:
return rawinput(method="post")
except TypeError:
return rawinput()
def _ensure_list(value):
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
"""Delegate to the shared SessionService implementation."""
from agent.chat.session_service import generate_session_title
@@ -343,23 +433,90 @@ class WebChannel(ChatChannel):
return on_event
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:
params = web.input(file={}, session_id="")
params = _raw_web_input()
file_obj = params.get("file")
file_objs = params.get("files")
session_id = params.get("session_id", "")
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"})
relative_path = params.get("relative_path", "")
relative_paths = params.get("relative_paths")
upload_id = params.get("upload_id", "")
directory_files = _ensure_list(file_objs)
if not directory_files and file_obj and relative_path:
directory_files = [file_obj]
directory_rel_paths = _ensure_list(relative_paths)
if not directory_rel_paths and relative_path:
directory_rel_paths = [relative_path]
is_directory_upload = bool(directory_files or directory_rel_paths or relative_path or upload_id)
upload_dir = _get_upload_dir()
if is_directory_upload:
if not upload_id:
return json.dumps({"status": "error", "message": "Missing upload_id for directory upload"})
if not directory_files:
return json.dumps({"status": "error", "message": "No files uploaded"})
if len(directory_files) != len(directory_rel_paths):
return json.dumps({"status": "error", "message": "Directory upload payload mismatch"})
safe_upload_id = _sanitize_upload_id(upload_id)
upload_root = os.path.join(upload_dir, f"webdir_{safe_upload_id}")
upload_root_real = os.path.realpath(upload_root)
root_name = None
saved_files = 0
for file_obj, rel_path in zip(directory_files, directory_rel_paths):
if file_obj is None:
raise ValueError("Invalid uploaded file")
safe_rel_path, save_path = _resolve_upload_path(upload_root_real, rel_path)
current_root_name = safe_rel_path.split("/", 1)[0]
if root_name is None:
root_name = current_root_name
elif root_name != current_root_name:
raise ValueError("Directory upload must use a single root folder")
os.makedirs(os.path.dirname(save_path), exist_ok=True)
content_bytes = _read_uploaded_file_bytes(file_obj)
with open(save_path, "wb") as f:
f.write(content_bytes)
saved_files += 1
if not root_name:
raise ValueError("Directory root path missing")
root_path = os.path.realpath(os.path.join(upload_root_real, root_name))
if not _is_within_directory(upload_root_real, root_path):
raise ValueError("Invalid directory upload path")
logger.info(f"[WebChannel] Directory uploaded: {root_name} -> {root_path} ({saved_files} files)")
return json.dumps({
"status": "success",
"file_path": root_path,
"file_name": root_name,
"file_type": "directory",
"file_count": saved_files,
"root_path": root_path,
"root_name": root_name,
"upload_type": "directory",
}, ensure_ascii=False)
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"})
original_name = file_obj.filename
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:
f.write(file_obj.read() if hasattr(file_obj, "read") else file_obj.value)
f.write(content_bytes)
if ext in IMAGE_EXTENSIONS:
file_type = "image"
@@ -368,14 +525,15 @@ class WebChannel(ChatChannel):
else:
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})")
return json.dumps({
"status": "success",
"file_path": save_path,
"file_name": original_name,
"file_name": display_name,
"file_type": file_type,
"preview_url": preview_url,
}, ensure_ascii=False)
@@ -410,6 +568,8 @@ class WebChannel(ChatChannel):
file_refs.append(f"[图片: {fpath}]")
elif ftype == "video":
file_refs.append(f"[视频: {fpath}]")
elif ftype == "directory":
file_refs.append(f"[目录: {fpath}]")
else:
file_refs.append(f"[文件: {fpath}]")
if file_refs: