mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 06:07:13 +08:00
fix(web): remove upload dir button, one-time upload all files,path check adapt windows
This commit is contained in:
@@ -398,19 +398,24 @@
|
|||||||
<button id="attach-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
<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
|
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||||
cursor-pointer transition-colors duration-150"
|
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>
|
<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>
|
<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>
|
<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
|
||||||
@@ -979,7 +984,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
<script defer src="assets/js/console.js"></script>
|
||||||
<script src="assets/js/console.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -748,6 +748,46 @@
|
|||||||
}
|
}
|
||||||
.attachment-preview.hidden { display: none; }
|
.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 {
|
.att-thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 64px; height: 64px;
|
width: 64px; height: 64px;
|
||||||
@@ -926,6 +966,22 @@
|
|||||||
color: #64748b;
|
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
|
Knowledge View
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ const I18N = {
|
|||||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
tip_new_chat: '新建对话',
|
tip_new_chat: '新建对话',
|
||||||
tip_clear_context: '清除上下文',
|
tip_clear_context: '清除上下文',
|
||||||
tip_attach_file: '上传附件',
|
tip_attach: '添加附件',
|
||||||
tip_attach_folder: '上传目录',
|
attach_menu_file: '上传文件',
|
||||||
|
attach_menu_folder: '上传文件夹',
|
||||||
confirm_yes: '确认',
|
confirm_yes: '确认',
|
||||||
confirm_cancel: '取消',
|
confirm_cancel: '取消',
|
||||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||||
@@ -204,8 +205,9 @@ const I18N = {
|
|||||||
context_cleared: '— Context above has been cleared —',
|
context_cleared: '— Context above has been cleared —',
|
||||||
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: 'Add Attachment',
|
||||||
tip_attach_folder: 'Attach Folder',
|
attach_menu_file: 'Upload File',
|
||||||
|
attach_menu_folder: 'Upload 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.',
|
||||||
@@ -392,14 +394,34 @@ window.addEventListener('resize', () => {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Markdown Renderer
|
// 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() {
|
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,
|
html: false, breaks: true, linkify: true, typographer: true,
|
||||||
highlight: function(str, lang) {
|
highlight: function(str, lang) {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljsLib.getLanguage(lang)) {
|
||||||
try { return hljs.highlight(str, { language: lang }).value; } catch (_) {}
|
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) {
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||||
@@ -581,13 +603,13 @@ 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 folderInput = document.getElementById('folder-input');
|
||||||
const attachFolderBtn = document.getElementById('attach-folder-btn');
|
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 (folderInput && attachFolderBtn) {
|
if (!supportsDirectoryUpload && attachFolderOption) {
|
||||||
const supportsDirectoryUpload = 'webkitdirectory' in folderInput;
|
attachFolderOption.classList.add('hidden');
|
||||||
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
|
||||||
@@ -690,6 +712,34 @@ function removeAttachment(idx) {
|
|||||||
renderAttachmentPreview();
|
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) {
|
async function handleFileSelect(files) {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
@@ -757,45 +807,37 @@ async function handleFolderSelect(files) {
|
|||||||
_uploading: true,
|
_uploading: true,
|
||||||
};
|
};
|
||||||
pendingAttachments.push(placeholder);
|
pendingAttachments.push(placeholder);
|
||||||
|
uploadingCount++;
|
||||||
renderAttachmentPreview();
|
renderAttachmentPreview();
|
||||||
|
|
||||||
const uploadId = _makeUploadId();
|
const uploadId = _makeUploadId();
|
||||||
const tasks = entries.map(async ({ file, relPath }) => {
|
groupTasks.push((async () => {
|
||||||
uploadingCount++;
|
|
||||||
renderAttachmentPreview();
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('session_id', sessionId);
|
formData.append('session_id', sessionId);
|
||||||
formData.append('upload_id', uploadId);
|
formData.append('upload_id', uploadId);
|
||||||
formData.append('relative_path', relPath);
|
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 resp = await fetch('/upload', { method: 'POST', body: formData });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.status !== 'success') {
|
if (data.status !== 'success') {
|
||||||
throw new Error(data.message || 'Upload failed');
|
throw new Error(data.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
if (!placeholder.file_path && data.root_path) {
|
if (!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');
|
throw new Error('Directory root path missing');
|
||||||
}
|
}
|
||||||
|
placeholder.file_path = data.root_path;
|
||||||
|
placeholder.file_name = data.root_name || rootName;
|
||||||
delete placeholder._uploading;
|
delete placeholder._uploading;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Directory upload failed:', e);
|
console.error('Directory upload failed:', e);
|
||||||
const i = pendingAttachments.indexOf(placeholder);
|
const i = pendingAttachments.indexOf(placeholder);
|
||||||
if (i !== -1) pendingAttachments.splice(i, 1);
|
if (i !== -1) pendingAttachments.splice(i, 1);
|
||||||
|
} finally {
|
||||||
|
uploadingCount--;
|
||||||
}
|
}
|
||||||
renderAttachmentPreview();
|
renderAttachmentPreview();
|
||||||
})());
|
})());
|
||||||
@@ -814,6 +856,12 @@ folderInput.addEventListener('change', function() {
|
|||||||
this.value = '';
|
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
|
// 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'); });
|
||||||
@@ -994,6 +1042,11 @@ chatInput.addEventListener('input', function() {
|
|||||||
chatInput.addEventListener('keydown', function(e) {
|
chatInput.addEventListener('keydown', function(e) {
|
||||||
if (e.keyCode === 229 || e.isComposing || isComposing) return;
|
if (e.keyCode === 229 || e.isComposing || isComposing) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && isAttachMenuVisible()) {
|
||||||
|
hideAttachMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSlashMenuVisible()) {
|
if (isSlashMenuVisible()) {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2085,8 +2138,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');
|
||||||
set('attach-folder-btn', 'tip_attach_folder');
|
|
||||||
set('session-toggle-btn', 'session_history', 'bottom');
|
set('session-toggle-btn', 'session_history', 'bottom');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2402,9 +2454,10 @@ function _updateScrollToBottomBtn() {
|
|||||||
function applyHighlighting(container) {
|
function applyHighlighting(container) {
|
||||||
const root = container || document;
|
const root = container || document;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const hljsLib = getHljs();
|
||||||
root.querySelectorAll('pre code').forEach(block => {
|
root.querySelectorAll('pre code').forEach(block => {
|
||||||
if (!block.classList.contains('hljs')) {
|
if (!block.classList.contains('hljs')) {
|
||||||
hljs.highlightElement(block);
|
hljsLib.highlightElement(block);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -4528,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() {
|
function loadKnowledgeGraph() {
|
||||||
_knowledgeGraphLoaded = true;
|
_knowledgeGraphLoaded = true;
|
||||||
const container = document.getElementById('knowledge-graph-container');
|
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 nodes = data.nodes || [];
|
||||||
const links = data.links || [];
|
const links = data.links || [];
|
||||||
if (nodes.length === 0) {
|
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>`;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
container.innerHTML = '';
|
||||||
renderKnowledgeGraph(container, nodes, links);
|
renderKnowledgeGraph(container, nodes, links);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm">Failed to load graph</div>';
|
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm">Failed to load graph</div>';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
import web
|
import web
|
||||||
|
|
||||||
@@ -95,8 +96,17 @@ def _sanitize_upload_relative_path(relative_path: str) -> str:
|
|||||||
relative_path = (relative_path or "").replace("\\", "/").strip("/")
|
relative_path = (relative_path or "").replace("\\", "/").strip("/")
|
||||||
if not relative_path:
|
if not relative_path:
|
||||||
raise ValueError("Empty relative path")
|
raise ValueError("Empty relative path")
|
||||||
norm_path = os.path.normpath(relative_path)
|
parts = []
|
||||||
if norm_path in (".", "") or norm_path.startswith("..") or os.path.isabs(norm_path):
|
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")
|
raise ValueError("Invalid relative path")
|
||||||
return norm_path
|
return norm_path
|
||||||
|
|
||||||
@@ -109,8 +119,30 @@ def _sanitize_upload_id(upload_id: str) -> str:
|
|||||||
return sanitized[:80]
|
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:
|
def _read_uploaded_file_bytes(file_obj) -> bytes:
|
||||||
"""Return uploaded content as bytes across web.py upload object variants."""
|
"""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
|
content = None
|
||||||
|
|
||||||
if hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
|
if hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
|
||||||
@@ -129,6 +161,25 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
|
|||||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
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:
|
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
|
||||||
@@ -384,45 +435,89 @@ class WebChannel(ChatChannel):
|
|||||||
def upload_file(self):
|
def upload_file(self):
|
||||||
"""Handle file or directory upload via multipart/form-data."""
|
"""Handle file or directory upload via multipart/form-data."""
|
||||||
try:
|
try:
|
||||||
params = web.input(file={}, session_id="", relative_path="", upload_id="")
|
params = _raw_web_input()
|
||||||
file_obj = params.get("file")
|
file_obj = params.get("file")
|
||||||
|
file_objs = params.get("files")
|
||||||
session_id = params.get("session_id", "")
|
session_id = params.get("session_id", "")
|
||||||
relative_path = params.get("relative_path", "")
|
relative_path = params.get("relative_path", "")
|
||||||
|
relative_paths = params.get("relative_paths")
|
||||||
upload_id = params.get("upload_id", "")
|
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:
|
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()
|
|
||||||
|
|
||||||
original_name = file_obj.filename
|
original_name = file_obj.filename
|
||||||
root_path = ""
|
ext = os.path.splitext(original_name)[1].lower()
|
||||||
|
safe_name = f"web_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
if relative_path:
|
save_path = os.path.join(upload_dir, safe_name)
|
||||||
if not upload_id:
|
public_path = safe_name
|
||||||
return json.dumps({"status": "error", "message": "Missing upload_id for directory upload"})
|
display_name = original_name
|
||||||
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)
|
content_bytes = _read_uploaded_file_bytes(file_obj)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
f.write(content_bytes)
|
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:
|
||||||
@@ -435,21 +530,13 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
logger.info(f"[WebChannel] File uploaded: {original_name} -> {save_path} ({file_type})")
|
logger.info(f"[WebChannel] File uploaded: {original_name} -> {save_path} ({file_type})")
|
||||||
|
|
||||||
resp = {
|
return json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"file_path": save_path,
|
"file_path": save_path,
|
||||||
"file_name": display_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user