diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 2d621bcc..0e20180b 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -107,6 +107,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "default_hide_workspace": False, # 默认隐藏工作区 "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) "quick_dock_auto_expand": True, # 快捷窗口自动展开:True-有内容时自动展开 / False-只能手动点击按钮展开 + "file_preview_auto_wrap": False, # 文件预览窗口自动换行:True-按面板宽度换行显示 / False-长行横向滚动 "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 @@ -543,6 +544,12 @@ def sanitize_personalization_payload( else: base["quick_dock_auto_expand"] = bool(base.get("quick_dock_auto_expand", True)) + # 文件预览窗口自动换行 + if "file_preview_auto_wrap" in data: + base["file_preview_auto_wrap"] = bool(data.get("file_preview_auto_wrap")) + else: + base["file_preview_auto_wrap"] = bool(base.get("file_preview_auto_wrap", False)) + # 侧边栏按工作区/项目分组显示对话 if "group_sidebar_by_workspace" in data: base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) diff --git a/server/chat_flow.py b/server/chat_flow.py index 378a1c41..6734048c 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -126,9 +126,10 @@ def detect_malformed_tool_call(text): return _detect_malformed_tool_call(text) -def process_message_task(terminal: WebTerminal, message: str, images, sender, client_sid, workspace: UserWorkspace, username: str, videos=None): +def process_message_task(terminal: WebTerminal, message: str, images, sender, client_sid, workspace: UserWorkspace, username: str, videos=None, files=None): """在后台处理消息任务""" videos = videos or [] + files = files or [] auto_user_message_event = bool(getattr(terminal, "_auto_user_message_event", False)) try: loop = asyncio.new_event_loop() @@ -146,6 +147,7 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl username, videos, auto_user_message_event=auto_user_message_event, + files=files, ) ) @@ -253,6 +255,6 @@ def start_chat_task(terminal, message: str, images: Any, sender, client_sid: str ) -def run_chat_task_sync(terminal, message: str, images: Any, sender, client_sid: str, workspace, username: str, videos: Any = None): +def run_chat_task_sync(terminal, message: str, images: Any, sender, client_sid: str, workspace, username: str, videos: Any = None, files: Any = None): """同步执行(测试/CLI 使用)。""" - return process_message_task(terminal, message, images, sender, client_sid, workspace, username, videos) + return process_message_task(terminal, message, images, sender, client_sid, workspace, username, videos, files) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 9d28a660..a87d01ce 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -200,6 +200,32 @@ def _build_media_paths_message(images: Optional[List[Any]], videos: Optional[Lis return "\n".join(lines) +def _normalize_attached_files(files: Optional[List[Any]]) -> List[str]: + """归一化消息附加文件:仅保留字符串路径,去重,最多 9 个。""" + normalized: List[str] = [] + for item in files or []: + if not isinstance(item, str): + continue + path = item.strip() + if not path or path in normalized: + continue + normalized.append(path) + if len(normalized) >= 9: + break + return normalized + + +def _build_file_paths_message(files: Optional[List[Any]]) -> Optional[str]: + """为本次消息附带的文件生成一条 hidden user 消息文本(供模型感知文件位置,界面不渲染)。""" + paths = _normalize_attached_files(files) + if not paths: + return None + lines = ["[系统通知|file_paths]", "本次消息附带的文件路径:"] + for path in paths: + lines.append(f"- `{path}`") + return "\n".join(lines) + + def _should_skip_versioning_for_message( *, message: str, @@ -1325,6 +1351,7 @@ async def handle_task_with_sender( username: str, videos=None, auto_user_message_event: bool = False, + files=None, ): """处理任务并发送消息 - 集成token统计版本""" from .extensions import socketio @@ -1349,6 +1376,7 @@ async def handle_task_with_sender( pending_master_messages_count=pending_count, ) videos = videos or [] + files = _normalize_attached_files(files) raw_sender = sender def sender(event_type, data): @@ -1443,6 +1471,9 @@ async def handle_task_with_sender( user_message_metadata["starts_work"] = False else: user_message_metadata["auto_message_type"] = "completion_notice" + # 附加文件路径记录在主消息 metadata 中:前端据此在气泡内渲染文件块,历史恢复同理 + if files: + user_message_metadata["files"] = list(files) saved_user_message = web_terminal.context_manager.add_conversation( "user", message, @@ -1499,6 +1530,19 @@ async def handle_task_with_sender( "hidden": True, } ) + file_paths_message = _build_file_paths_message(files) + if file_paths_message: + web_terminal.context_manager.add_conversation( + "user", + file_paths_message, + metadata={ + "source": "file_paths", + "message_source": "file_paths", + "visibility": "hidden", + "starts_work": False, + "hidden": True, + } + ) try: sender( 'user_message', diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 419d56d6..80409e91 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -27,13 +27,14 @@ _VALID_SOURCES = { "permission_change", "execution_change", "network_change", + "file_paths", } def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str, Any]: """Return UI metadata for model-facing user messages injected by the runtime.""" normalized = str(src or "").strip().lower() - if normalized in {"skill", "goal_prompt", "permission", "sandbox"}: + if normalized in {"skill", "goal_prompt", "permission", "sandbox", "file_paths"}: return {"visibility": "hidden", "starts_work": False} if normalized in {"guidance", "notify"}: return {"visibility": "compact", "starts_work": False} diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index c683e713..c5857f58 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -1388,12 +1388,20 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie injected_count = 0 for raw_item in runtime_guidance_items: runtime_guidance_source = "guidance" + runtime_guidance_files: List[str] = [] if isinstance(raw_item, dict): runtime_guidance_text = str(raw_item.get("text") or "").strip() runtime_guidance_source = ( str(raw_item.get("source") or "guidance").strip().lower() or "guidance" ) + raw_files = raw_item.get("files") + if isinstance(raw_files, list): + runtime_guidance_files = [ + str(p).strip() + for p in raw_files + if isinstance(p, str) and str(p).strip() + ][:9] else: runtime_guidance_text = str(raw_item or "").strip() if not runtime_guidance_text: @@ -1406,7 +1414,22 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie sender=sender, conversation_id=conversation_id, inline=True, + extra_metadata={"files": runtime_guidance_files} if runtime_guidance_files else None, ) + # 携带文件的引导:同步注入一条 hidden 文件路径通知,让模型感知文件位置 + if runtime_guidance_files: + file_notice_lines = ["本次消息附带的文件路径:"] + for _path in runtime_guidance_files: + file_notice_lines.append(f"- `{_path}`") + inject_runtime_user_message( + web_terminal=web_terminal, + messages=messages, + text="\n".join(file_notice_lines), + source="file_paths", + sender=sender, + conversation_id=conversation_id, + inline=True, + ) injected_count += 1 if injected_count: debug_log( diff --git a/server/tasks/api.py b/server/tasks/api.py index ec66b601..9411a78e 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -25,7 +25,7 @@ from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL from server.tasks import task_manager from server.tasks.skills import _build_skill_context_messages from server.tasks.helpers import _task_public_payload -from server.tasks.media import _normalize_media_payload +from server.tasks.media import _normalize_media_payload, _normalize_files_payload @@ -114,6 +114,7 @@ def create_task_api(): payload = request.get_json() or {} message = (payload.get("message") or "").strip() images, videos = _normalize_media_payload(payload.get("images") or [], payload.get("videos") or []) + files = _normalize_files_payload(payload.get("files")) conversation_id = payload.get("conversation_id") if not message and not images and not videos: return jsonify({"success": False, "error": "消息不能为空"}), 400 @@ -204,6 +205,7 @@ def create_task_api(): message_source=message_source, goal_mode=goal_mode, skill_context_messages=skill_context_messages, + files=files, ) except RuntimeError as exc: return jsonify({"success": False, "error": str(exc)}), 409 @@ -330,7 +332,8 @@ def enqueue_runtime_queue_message_api(task_id: str): message = (payload.get("message") or "").strip() if not message: return jsonify({"success": False, "error": "消息不能为空"}), 400 - result = task_manager.enqueue_runtime_pending_message(username, task_id, message) + files = _normalize_files_payload(payload.get("files")) + result = task_manager.enqueue_runtime_pending_message(username, task_id, message, files=files) if not result.get("success"): code = result.get("code") or "runtime_queue_enqueue_failed" if code == "task_not_found": diff --git a/server/tasks/media.py b/server/tasks/media.py index 860ddf18..8b106c7a 100644 --- a/server/tasks/media.py +++ b/server/tasks/media.py @@ -48,6 +48,25 @@ def _is_image_item(item: Any) -> bool: mime, _ = mimetypes.guess_type(path) return bool(mime and mime.startswith("image/")) +def _normalize_files_payload(raw: Any) -> List[str]: + """归一化附加文件列表:仅接受字符串形式的工作区相对路径,去重,最多 9 个。""" + if not isinstance(raw, list): + return [] + files: List[str] = [] + for item in raw: + if not isinstance(item, str): + continue + path = item.strip() + if not path or len(path) > 500: + continue + if path in files: + continue + files.append(path) + if len(files) >= 9: + break + return files + + def _normalize_media_payload(images: List[Any], videos: List[Any]) -> tuple[List[Any], List[Any]]: """纠偏媒体字段:把误传到 images 的视频项自动归入 videos。""" fixed_images: List[Any] = [] diff --git a/server/tasks/models.py b/server/tasks/models.py index 9896311f..46ebe937 100644 --- a/server/tasks/models.py +++ b/server/tasks/models.py @@ -136,6 +136,7 @@ class TaskManager: message_source: Optional[str] = None, goal_mode: bool = False, skill_context_messages: Optional[List[Dict[str, str]]] = None, + files: Optional[List[str]] = None, task_type: str = "chat", ) -> TaskRecord: if run_mode: @@ -199,7 +200,7 @@ class TaskManager: record.session_data = {} with self._lock: self._tasks[task_id] = record - thread = threading.Thread(target=self._run_chat_task, args=(record, images, videos or []), daemon=True) + thread = threading.Thread(target=self._run_chat_task, args=(record, images, videos or [], files or []), daemon=True) record.thread = thread record.status = "running" record.updated_at = time.time() @@ -322,10 +323,12 @@ class TaskManager: item_id = str(raw_item.get("id") or "").strip() text = str(raw_item.get("text") or "").strip() created_at = raw_item.get("created_at") + raw_files = raw_item.get("files") else: item_id = "" text = str(raw_item or "").strip() created_at = None + raw_files = None if not text: continue if not item_id: @@ -334,13 +337,20 @@ class TaskManager: created_at_float = float(created_at) except Exception: created_at_float = now_ts - normalized.append( - { - "id": item_id, - "text": text, - "created_at": created_at_float, - } - ) + entry = { + "id": item_id, + "text": text, + "created_at": created_at_float, + } + if isinstance(raw_files, list): + files = [ + str(p).strip() + for p in raw_files + if isinstance(p, str) and str(p).strip() + ][:9] + if files: + entry["files"] = files + normalized.append(entry) return normalized @staticmethod @@ -353,17 +363,23 @@ class TaskManager: item_id = str(item.get("id") or "").strip() if not text or not item_id: continue - result.append( - { - "id": item_id, - "text": text, - "created_at": item.get("created_at"), - } - ) + entry = { + "id": item_id, + "text": text, + "created_at": item.get("created_at"), + } + if isinstance(item.get("files"), list) and item["files"]: + entry["files"] = list(item["files"]) + result.append(entry) return result def enqueue_runtime_pending_message( - self, username: str, task_id: str, message: str, max_queue_size: int = 5 + self, + username: str, + task_id: str, + message: str, + max_queue_size: int = 5, + files: Optional[List[str]] = None, ) -> Dict[str, Any]: text = str(message or "").strip() if not text: @@ -387,6 +403,12 @@ class TaskManager: "text": text, "created_at": time.time(), } + if isinstance(files, list): + normalized_files = [ + str(p).strip() for p in files if isinstance(p, str) and str(p).strip() + ][:9] + if normalized_files: + item["files"] = normalized_files queue.append(item) rec.runtime_pending_queue = queue rec.updated_at = time.time() @@ -463,7 +485,11 @@ class TaskManager: selected_text = str(selected.get("text") or "").strip() if not selected_text: return {"success": False, "code": "empty_message", "error": "消息内容为空"} - guidance_queue.append(selected_text) + selected_files = selected.get("files") + if isinstance(selected_files, list) and selected_files: + guidance_queue.append({"text": selected_text, "files": list(selected_files)[:9]}) + else: + guidance_queue.append(selected_text) rec.runtime_guidance_queue = guidance_queue rec.runtime_pending_queue = remain_queue rec.updated_at = time.time() @@ -577,7 +603,15 @@ class TaskManager: if not text: continue src = str(item.get("source") or "").strip().lower() - items.append({"text": text, "source": src} if src else {"text": text}) + entry = {"text": text, "source": src} if src else {"text": text} + raw_files = item.get("files") + if isinstance(raw_files, list) and raw_files: + entry["files"] = [ + str(p).strip() + for p in raw_files + if isinstance(p, str) and str(p).strip() + ][:9] + items.append(entry) else: text = str(item or "").strip() if text: @@ -739,7 +773,7 @@ class TaskManager: }) rec.updated_at = time.time() - def _run_chat_task(self, rec: TaskRecord, images: List[Any], videos: List[Any]): + def _run_chat_task(self, rec: TaskRecord, images: List[Any], videos: List[Any], files: Optional[List[str]] = None): username = rec.username workspace_id = rec.workspace_id terminal = None @@ -948,6 +982,7 @@ class TaskManager: workspace=workspace, username=username, videos=videos, + files=files or [], ) finally: try: diff --git a/static/src/App.vue b/static/src/App.vue index dfef6d4a..18d48f68 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -275,6 +275,7 @@ :tool-category-icon="toolCategoryIcon" :selected-images="selectedImages" :selected-videos="selectedVideos" + :selected-files="selectedFiles" :block-upload="policyUiBlocks.block_upload" :block-tool-toggle="policyUiBlocks.block_tool_toggle" :block-realtime-terminal="policyUiBlocks.block_realtime_terminal" @@ -333,10 +334,12 @@ @compress-conversation="handleCompressConversationClick" @toggle-approval-panel="handleApprovalPanelToggleClick" @file-selected="handleFileSelected" + @paste-files="handlePastedFiles" @pick-images="openImagePicker" @pick-video="openVideoPicker" @remove-image="handleRemoveImage" @remove-video="handleRemoveVideo" + @remove-file="handleRemoveFile" @open-review="openReviewDialog" @toggle-permission-menu="togglePermissionMenu" @change-permission-mode="changePermissionMode" @@ -484,6 +487,7 @@ @local-files="handleLocalVideoFiles" /> + 0); const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; - return this.quickMenuOpen || hasText || hasImages; + const hasFiles = Array.isArray(this.selectedFiles) && this.selectedFiles.length > 0; + return this.quickMenuOpen || hasText || hasImages || hasFiles; }, showScrollToBottomButton() { return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom; diff --git a/static/src/app/methods/message/runtimeQueue.ts b/static/src/app/methods/message/runtimeQueue.ts index 808f0ef1..232e7fd4 100644 --- a/static/src/app/methods/message/runtimeQueue.ts +++ b/static/src/app/methods/message/runtimeQueue.ts @@ -13,7 +13,8 @@ export const runtimeQueueMethods = { list.map((item) => [ String(item?.id || ''), String(item?.text || ''), - Number(item?.createdAt || 0) + Number(item?.createdAt || 0), + Array.isArray(item?.files) ? item.files : [] ]) ); }, @@ -123,11 +124,17 @@ export const runtimeQueueMethods = { if (!id || !text) return null; const previous = previousById.get(id); const rawCreatedAt = Number(item.created_at ?? item.createdAt ?? Date.now()); + const rawFiles = Array.isArray(item.files) + ? item.files + : Array.isArray(previous?.files) + ? previous.files + : []; return { id, text, createdAt: Number.isFinite(rawCreatedAt) ? rawCreatedAt : Date.now(), - source: previous?.source || 'user' + source: previous?.source || 'user', + files: rawFiles.filter((path) => typeof path === 'string' && path).slice(0, 9) }; }) .filter((item) => !!item); @@ -177,11 +184,14 @@ export const runtimeQueueMethods = { this.runtimeQueuedMessages = normalized; return normalized; }, - async enqueueRuntimeQueuedMessage(rawMessage) { + async enqueueRuntimeQueuedMessage(rawMessage, rawFiles = []) { const text = (rawMessage || '').toString().trim(); if (!text) { return false; } + const files = (Array.isArray(rawFiles) ? rawFiles : []) + .filter((path) => typeof path === 'string' && path) + .slice(0, 9); try { const { useTaskStore } = await import('../../../stores/task'); const taskStore = useTaskStore(); @@ -200,7 +210,8 @@ export const runtimeQueueMethods = { 'Content-Type': 'application/json' }, body: JSON.stringify({ - message: text + message: text, + files: files.length ? files : undefined }) }); const payload = await response.json().catch(() => ({})); @@ -264,7 +275,8 @@ export const runtimeQueueMethods = { if (!this.composerBusy) { const sent = !!(await this.sendMessage({ presetText: String(item.text || '').trim(), - source: 'runtime_queue_manual_guide' + source: 'runtime_queue_manual_guide', + files: Array.isArray(item?.files) ? [...item.files] : [] })); if (!sent) { this.uiPushToast({ @@ -349,6 +361,7 @@ export const runtimeQueueMethods = { let nextText = ''; let source = 'runtime_queue'; let runtimeQueueMessageId = null; + let nextFiles: string[] = []; let fallbackIndex = -1; if (fallbackQueue.length > 0) { @@ -381,6 +394,7 @@ export const runtimeQueueMethods = { nextText = candidateText; source = next?.source || 'runtime_queue'; runtimeQueueMessageId = candidateId; + nextFiles = Array.isArray(next?.files) ? [...next.files] : []; break; } } @@ -394,7 +408,8 @@ export const runtimeQueueMethods = { try { sent = !!(await this.sendMessage({ presetText: nextText, - source + source, + files: nextFiles })); } catch { sent = false; diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index e1fc244f..a69b8d2d 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -22,6 +22,17 @@ export const sendMethods = { const hasMedia = (Array.isArray(this.selectedImages) && this.selectedImages.length > 0) || (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); + const hasFiles = Array.isArray(this.selectedFiles) && this.selectedFiles.length > 0; + // 文件只是路径引用,不能单独构成一条消息,必须随文字/媒体一起发送。 + // 注意:仅在主对话空闲时拦截;运行中该按钮是「停止」语义,不能影响停止功能。 + if (hasFiles && !hasText && !hasMedia && !this.composerBusy) { + this.uiPushToast({ + title: '需要文字消息', + message: '附加文件需随文字消息一起发送', + type: 'warning' + }); + return; + } // 主对话空闲但 composerBusy=true:composerBusy 只因后台子智能体在跑而保持。 // 传统模式:waitingForSubAgent=true(taskInProgress=true 。多智能体模式:has_running_multi_agent=true。 // 此时新文本消息应直接发送,触发主智能体下一轮工作,而不是被进队列等任务结束。 @@ -64,9 +75,13 @@ export const sendMethods = { } return; } - const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage); + const queued = await this.enqueueRuntimeQueuedMessage( + this.inputMessage, + Array.isArray(this.selectedFiles) ? [...this.selectedFiles] : [] + ); if (queued) { this.inputClearMessage(); + this.inputClearSelectedFiles(); this.inputSetLineCount(1); this.inputSetMultiline(false); this.autoResizeInput(); @@ -151,9 +166,18 @@ export const sendMethods = { : Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; + // 附加文件:普通发送取输入栏选择;队列自动续发(presetText)从队列条目携带 + const files = usePresetText + ? Array.isArray(options?.files) + ? options.files.slice(0, 9) + : [] + : Array.isArray(this.selectedFiles) + ? this.selectedFiles.slice(0, 9) + : []; const hasText = text.length > 0; const hasImages = images.length > 0; const hasVideos = videos.length > 0; + const hasFiles = files.length > 0; if (!hasText && !hasImages && !hasVideos) { return false; @@ -326,7 +350,14 @@ export const sendMethods = { this.startRunningStateReconcile(); } const localMessageSource = usePresetText ? (options?.source === 'runtime_queue_manual_guide' ? 'guidance' : 'presend') : 'user'; - this.chatAddUserMessage(message, images, videos, [], localMessageSource); + this.chatAddUserMessage( + message, + images, + videos, + [], + localMessageSource, + hasFiles ? { files: [...files] } : {} + ); // 关键体验修复:用户发送后立刻显示 assistant 头部 + 工作中计时 + 等待提示, // 不等待 createTask / 轮询首事件返回。 this.chatStartAssistantMessage(); @@ -366,6 +397,7 @@ export const sendMethods = { message_source: localMessageSource, goal_mode: startingGoalMode, skill_refs: skillRefs, + files, eventHandler: (event: any) => this.handleTaskEvent(event) }); @@ -393,6 +425,7 @@ export const sendMethods = { this.inputClearMessage(); this.inputClearSelectedImages(); this.inputClearSelectedVideos(); + this.inputClearSelectedFiles(); this.inputSetImagePickerOpen(false); this.inputSetVideoPickerOpen(false); this.inputSetLineCount(1); diff --git a/static/src/app/methods/upload/confirm.ts b/static/src/app/methods/upload/confirm.ts index 35c434be..a2ffe838 100644 --- a/static/src/app/methods/upload/confirm.ts +++ b/static/src/app/methods/upload/confirm.ts @@ -23,5 +23,8 @@ export const confirmMethods = { }, handleRemoveVideo(path) { this.inputRemoveSelectedVideo(path); + }, + handleRemoveFile(path) { + this.inputRemoveSelectedFile(path); } }; diff --git a/static/src/app/methods/upload/drag.ts b/static/src/app/methods/upload/drag.ts index 220e087a..cfe098bb 100644 --- a/static/src/app/methods/upload/drag.ts +++ b/static/src/app/methods/upload/drag.ts @@ -76,9 +76,9 @@ export const dragMethods = { await this.handleDroppedVideo(videoFiles[0]); // 视频只取第一个 } - // 处理其他文件(普通文件上传) + // 处理其他文件(上传后附加到输入栏,随消息一同发送) if (otherFiles.length > 0) { - this.uploadHandleSelected(otherFiles); + await this.handleLocalGenericFiles(otherFiles); } }, async handleDroppedImages(files: File[]) { diff --git a/static/src/app/methods/upload/index.ts b/static/src/app/methods/upload/index.ts index 23c0b236..596f23f0 100644 --- a/static/src/app/methods/upload/index.ts +++ b/static/src/app/methods/upload/index.ts @@ -5,6 +5,7 @@ import { entriesMethods } from './entries'; import { confirmMethods } from './confirm'; import { quickMethods } from './quick'; import { dragMethods } from './drag'; +import { pasteMethods } from './paste'; export const uploadMethods = { ...pickerMethods, @@ -13,4 +14,5 @@ export const uploadMethods = { ...confirmMethods, ...quickMethods, ...dragMethods, + ...pasteMethods, }; diff --git a/static/src/app/methods/upload/paste.ts b/static/src/app/methods/upload/paste.ts new file mode 100644 index 00000000..b0fb0f26 --- /dev/null +++ b/static/src/app/methods/upload/paste.ts @@ -0,0 +1,37 @@ +// @ts-nocheck +import { usePolicyStore } from '../../../stores/policy'; + +export const pasteMethods = { + /** + * 处理输入框粘贴事件中携带的文件(截图/复制的图片等)。 + * 复用拖拽上传的路由逻辑:图片走模型能力校验后批量上传并挂到输入栏, + * 视频取第一个上传,其余文件走普通文件上传。 + */ + handlePastedFiles(files: File[]) { + const list = Array.isArray(files) ? files.filter(Boolean) : []; + if (!list.length) { + return; + } + + if (!this.isConnected) { + this.uiPushToast({ + title: '未连接', + message: '请等待服务器连接后再上传', + type: 'warning' + }); + return; + } + + const policyStore = usePolicyStore(); + if (policyStore.uiBlocks?.block_upload) { + this.uiPushToast({ + title: '上传被禁用', + message: '已被管理员禁用上传功能', + type: 'warning' + }); + return; + } + + this.processDroppedFiles(list); + } +}; diff --git a/static/src/app/methods/upload/picker.ts b/static/src/app/methods/upload/picker.ts index 8c3117b8..d6dd54d2 100644 --- a/static/src/app/methods/upload/picker.ts +++ b/static/src/app/methods/upload/picker.ts @@ -25,7 +25,10 @@ export const pickerMethods = { }); return; } - this.uploadHandleSelected(files); + // 快速上传与拖拽/粘贴统一走三路分发:图片→图片附加,视频→视频附加,其余→文件附加 + const list = Array.isArray(files) ? files.filter(Boolean) : Array.from(files || []).filter(Boolean); + if (!list.length) return; + this.processDroppedFiles(list); }, async openImagePicker() { const modelStore = useModelStore(); diff --git a/static/src/app/methods/upload/process.ts b/static/src/app/methods/upload/process.ts index a65f8928..f8aa1e68 100644 --- a/static/src/app/methods/upload/process.ts +++ b/static/src/app/methods/upload/process.ts @@ -13,8 +13,10 @@ export const processMethods = { }, isImageFile(file) { const name = file?.name || ''; - const type = file?.type || ''; - return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name); + const type = (file?.type || '').toLowerCase(); + // SVG 不走图片链路(模型图片输入与 view 工具均不支持 svg),统一按普通文件处理 + if (type === 'image/svg+xml' || /\.svg$/i.test(name)) return false; + return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp)$/i.test(name); }, isVideoFile(file) { const name = file?.name || ''; @@ -86,6 +88,58 @@ export const processMethods = { // 上传完成后自动关闭选择窗口 this.closeImagePicker(); }, + // 普通文件上传后附加到输入栏(与图片/视频并列,随消息一同发送) + async handleLocalGenericFiles(files) { + if (!this.isConnected) { + return; + } + if (this.uploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待当前文件上传完成', + type: 'info' + }); + return; + } + const list = this.normalizeLocalFiles(files); + if (!list.length) { + this.uiPushToast({ + title: '未获取到文件', + message: '系统未返回有效的文件内容,请重试', + type: 'warning' + }); + return; + } + const existingCount = Array.isArray(this.selectedFiles) ? this.selectedFiles.length : 0; + const remaining = Math.max(0, 9 - existingCount); + if (!remaining) { + this.uiPushToast({ + title: '已达上限', + message: '最多只能附加 9 个文件', + type: 'warning' + }); + return; + } + const limited = list.slice(0, remaining); + if (list.length > remaining) { + this.uiPushToast({ + title: '已超出数量', + message: `最多还能附加 ${remaining} 个文件,已自动截断`, + type: 'warning' + }); + } + const uploaded = await this.uploadBatchFiles(limited, { + markUploading: true, + markMediaUploading: false + }); + if (!uploaded.length) { + return; + } + uploaded.forEach((item) => { + if (!item?.path) return; + this.inputAddSelectedFile(item.path); + }); + }, async handleLocalVideoFiles(files) { if (!this.isConnected) { return; diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index c2c97280..103fd5fc 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -85,11 +85,15 @@ -
+
+
(typeof item === 'string' && item) || (item && typeof item.path === 'string' && item.path)) + .slice(0, 9); +} + +function previewMessageImage(message: any, input: any) { + const url = getPreviewUrl(message, input, 'image'); + if (!url) return; + useUiStore().openImagePreview({ url, name: formatImageName(input) }); +} + function resolveMessageMediaRef(message: any, input: any, kind: 'image' | 'video'): any | null { const refs = message?.media_refs || message?.metadata?.media_refs || []; if (!Array.isArray(refs) || !refs.length) { diff --git a/static/src/components/chat/FileChips.vue b/static/src/components/chat/FileChips.vue new file mode 100644 index 00000000..f0a65c9a --- /dev/null +++ b/static/src/components/chat/FileChips.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/static/src/components/chat/quickdock/FilePreviewPanel.vue b/static/src/components/chat/quickdock/FilePreviewPanel.vue index 59adeaa6..465af6ad 100644 --- a/static/src/components/chat/quickdock/FilePreviewPanel.vue +++ b/static/src/components/chat/quickdock/FilePreviewPanel.vue @@ -40,9 +40,16 @@
加载中…
{{ error }}