feat(chat): 输入栏附件系统增强——粘贴图片、文件附加、图片灯箱与文件预览联动

- 输入框支持直接粘贴图片(Tiptap handlePaste,复用拖拽三路分发)
- 文件附加:拖拽/粘贴/选择的普通文件以文件块(FileChips)附加到输入栏,
  随消息发送;后端在主消息 metadata.files 记录并插入 hidden file_paths
  通知消息告知模型文件位置;运行中队列/自动续发/引导全链路携带 files
- 文件块视觉:196x60 与图片同排同高同圆角,类型彩色图标(文档/表格/
  演示/PDF/文本/代码/压缩包),深色提亮变体,SVG 删除按钮与图片统一
- 图片大图预览灯箱(ImageLightbox):最大 1100x780 不铺满全屏,移动端适配
- 文件块点击 → 快捷窗口文件预览面板(复用 /api/file/content)
- 预览面板:hover 灰行在横向滚动下贯穿整行(qd-code 宽度锚定内容全宽);
  新增设置项「预览窗口自动换行显示」(默认关闭,换行模式逐行渲染 +
  行级 content-visibility 屏外跳过渲染优化)
- SVG 从图片链路剔除(isImageFile 排除 + 图片选择器白名单),按普通文件处理
This commit is contained in:
JOJO 2026-08-11 17:16:56 +08:00
parent b51b2597a8
commit 9fff41650d
32 changed files with 1041 additions and 62 deletions

View File

@ -107,6 +107,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"default_hide_workspace": False, # 默认隐藏工作区 "default_hide_workspace": False, # 默认隐藏工作区
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
"quick_dock_auto_expand": True, # 快捷窗口自动展开True-有内容时自动展开 / False-只能手动点击按钮展开 "quick_dock_auto_expand": True, # 快捷窗口自动展开True-有内容时自动展开 / False-只能手动点击按钮展开
"file_preview_auto_wrap": False, # 文件预览窗口自动换行True-按面板宽度换行显示 / False-长行横向滚动
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
@ -543,6 +544,12 @@ def sanitize_personalization_payload(
else: else:
base["quick_dock_auto_expand"] = bool(base.get("quick_dock_auto_expand", True)) 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: if "group_sidebar_by_workspace" in data:
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))

View File

@ -126,9 +126,10 @@ def detect_malformed_tool_call(text):
return _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 [] videos = videos or []
files = files or []
auto_user_message_event = bool(getattr(terminal, "_auto_user_message_event", False)) auto_user_message_event = bool(getattr(terminal, "_auto_user_message_event", False))
try: try:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -146,6 +147,7 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl
username, username,
videos, videos,
auto_user_message_event=auto_user_message_event, 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 使用)。""" """同步执行(测试/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)

View File

@ -200,6 +200,32 @@ def _build_media_paths_message(images: Optional[List[Any]], videos: Optional[Lis
return "\n".join(lines) 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( def _should_skip_versioning_for_message(
*, *,
message: str, message: str,
@ -1325,6 +1351,7 @@ async def handle_task_with_sender(
username: str, username: str,
videos=None, videos=None,
auto_user_message_event: bool = False, auto_user_message_event: bool = False,
files=None,
): ):
"""处理任务并发送消息 - 集成token统计版本""" """处理任务并发送消息 - 集成token统计版本"""
from .extensions import socketio from .extensions import socketio
@ -1349,6 +1376,7 @@ async def handle_task_with_sender(
pending_master_messages_count=pending_count, pending_master_messages_count=pending_count,
) )
videos = videos or [] videos = videos or []
files = _normalize_attached_files(files)
raw_sender = sender raw_sender = sender
def sender(event_type, data): def sender(event_type, data):
@ -1443,6 +1471,9 @@ async def handle_task_with_sender(
user_message_metadata["starts_work"] = False user_message_metadata["starts_work"] = False
else: else:
user_message_metadata["auto_message_type"] = "completion_notice" 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( saved_user_message = web_terminal.context_manager.add_conversation(
"user", "user",
message, message,
@ -1499,6 +1530,19 @@ async def handle_task_with_sender(
"hidden": True, "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: try:
sender( sender(
'user_message', 'user_message',

View File

@ -27,13 +27,14 @@ _VALID_SOURCES = {
"permission_change", "permission_change",
"execution_change", "execution_change",
"network_change", "network_change",
"file_paths",
} }
def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str, Any]: 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.""" """Return UI metadata for model-facing user messages injected by the runtime."""
normalized = str(src or "").strip().lower() 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} return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}: if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False} return {"visibility": "compact", "starts_work": False}

View File

@ -1388,12 +1388,20 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
injected_count = 0 injected_count = 0
for raw_item in runtime_guidance_items: for raw_item in runtime_guidance_items:
runtime_guidance_source = "guidance" runtime_guidance_source = "guidance"
runtime_guidance_files: List[str] = []
if isinstance(raw_item, dict): if isinstance(raw_item, dict):
runtime_guidance_text = str(raw_item.get("text") or "").strip() runtime_guidance_text = str(raw_item.get("text") or "").strip()
runtime_guidance_source = ( runtime_guidance_source = (
str(raw_item.get("source") or "guidance").strip().lower() str(raw_item.get("source") or "guidance").strip().lower()
or "guidance" 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: else:
runtime_guidance_text = str(raw_item or "").strip() runtime_guidance_text = str(raw_item or "").strip()
if not runtime_guidance_text: if not runtime_guidance_text:
@ -1406,7 +1414,22 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
sender=sender, sender=sender,
conversation_id=conversation_id, conversation_id=conversation_id,
inline=True, 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 injected_count += 1
if injected_count: if injected_count:
debug_log( debug_log(

View File

@ -25,7 +25,7 @@ from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
from server.tasks import task_manager from server.tasks import task_manager
from server.tasks.skills import _build_skill_context_messages from server.tasks.skills import _build_skill_context_messages
from server.tasks.helpers import _task_public_payload 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 {} payload = request.get_json() or {}
message = (payload.get("message") or "").strip() message = (payload.get("message") or "").strip()
images, videos = _normalize_media_payload(payload.get("images") or [], payload.get("videos") or []) 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") conversation_id = payload.get("conversation_id")
if not message and not images and not videos: if not message and not images and not videos:
return jsonify({"success": False, "error": "消息不能为空"}), 400 return jsonify({"success": False, "error": "消息不能为空"}), 400
@ -204,6 +205,7 @@ def create_task_api():
message_source=message_source, message_source=message_source,
goal_mode=goal_mode, goal_mode=goal_mode,
skill_context_messages=skill_context_messages, skill_context_messages=skill_context_messages,
files=files,
) )
except RuntimeError as exc: except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 409 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() message = (payload.get("message") or "").strip()
if not message: if not message:
return jsonify({"success": False, "error": "消息不能为空"}), 400 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"): if not result.get("success"):
code = result.get("code") or "runtime_queue_enqueue_failed" code = result.get("code") or "runtime_queue_enqueue_failed"
if code == "task_not_found": if code == "task_not_found":

View File

@ -48,6 +48,25 @@ def _is_image_item(item: Any) -> bool:
mime, _ = mimetypes.guess_type(path) mime, _ = mimetypes.guess_type(path)
return bool(mime and mime.startswith("image/")) 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]]: def _normalize_media_payload(images: List[Any], videos: List[Any]) -> tuple[List[Any], List[Any]]:
"""纠偏媒体字段:把误传到 images 的视频项自动归入 videos。""" """纠偏媒体字段:把误传到 images 的视频项自动归入 videos。"""
fixed_images: List[Any] = [] fixed_images: List[Any] = []

View File

@ -136,6 +136,7 @@ class TaskManager:
message_source: Optional[str] = None, message_source: Optional[str] = None,
goal_mode: bool = False, goal_mode: bool = False,
skill_context_messages: Optional[List[Dict[str, str]]] = None, skill_context_messages: Optional[List[Dict[str, str]]] = None,
files: Optional[List[str]] = None,
task_type: str = "chat", task_type: str = "chat",
) -> TaskRecord: ) -> TaskRecord:
if run_mode: if run_mode:
@ -199,7 +200,7 @@ class TaskManager:
record.session_data = {} record.session_data = {}
with self._lock: with self._lock:
self._tasks[task_id] = record 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.thread = thread
record.status = "running" record.status = "running"
record.updated_at = time.time() record.updated_at = time.time()
@ -322,10 +323,12 @@ class TaskManager:
item_id = str(raw_item.get("id") or "").strip() item_id = str(raw_item.get("id") or "").strip()
text = str(raw_item.get("text") or "").strip() text = str(raw_item.get("text") or "").strip()
created_at = raw_item.get("created_at") created_at = raw_item.get("created_at")
raw_files = raw_item.get("files")
else: else:
item_id = "" item_id = ""
text = str(raw_item or "").strip() text = str(raw_item or "").strip()
created_at = None created_at = None
raw_files = None
if not text: if not text:
continue continue
if not item_id: if not item_id:
@ -334,13 +337,20 @@ class TaskManager:
created_at_float = float(created_at) created_at_float = float(created_at)
except Exception: except Exception:
created_at_float = now_ts created_at_float = now_ts
normalized.append( entry = {
{ "id": item_id,
"id": item_id, "text": text,
"text": text, "created_at": created_at_float,
"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 return normalized
@staticmethod @staticmethod
@ -353,17 +363,23 @@ class TaskManager:
item_id = str(item.get("id") or "").strip() item_id = str(item.get("id") or "").strip()
if not text or not item_id: if not text or not item_id:
continue continue
result.append( entry = {
{ "id": item_id,
"id": item_id, "text": text,
"text": text, "created_at": item.get("created_at"),
"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 return result
def enqueue_runtime_pending_message( 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]: ) -> Dict[str, Any]:
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
@ -387,6 +403,12 @@ class TaskManager:
"text": text, "text": text,
"created_at": time.time(), "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) queue.append(item)
rec.runtime_pending_queue = queue rec.runtime_pending_queue = queue
rec.updated_at = time.time() rec.updated_at = time.time()
@ -463,7 +485,11 @@ class TaskManager:
selected_text = str(selected.get("text") or "").strip() selected_text = str(selected.get("text") or "").strip()
if not selected_text: if not selected_text:
return {"success": False, "code": "empty_message", "error": "消息内容为空"} 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_guidance_queue = guidance_queue
rec.runtime_pending_queue = remain_queue rec.runtime_pending_queue = remain_queue
rec.updated_at = time.time() rec.updated_at = time.time()
@ -577,7 +603,15 @@ class TaskManager:
if not text: if not text:
continue continue
src = str(item.get("source") or "").strip().lower() 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: else:
text = str(item or "").strip() text = str(item or "").strip()
if text: if text:
@ -739,7 +773,7 @@ class TaskManager:
}) })
rec.updated_at = time.time() 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 username = rec.username
workspace_id = rec.workspace_id workspace_id = rec.workspace_id
terminal = None terminal = None
@ -948,6 +982,7 @@ class TaskManager:
workspace=workspace, workspace=workspace,
username=username, username=username,
videos=videos, videos=videos,
files=files or [],
) )
finally: finally:
try: try:

View File

@ -275,6 +275,7 @@
:tool-category-icon="toolCategoryIcon" :tool-category-icon="toolCategoryIcon"
:selected-images="selectedImages" :selected-images="selectedImages"
:selected-videos="selectedVideos" :selected-videos="selectedVideos"
:selected-files="selectedFiles"
:block-upload="policyUiBlocks.block_upload" :block-upload="policyUiBlocks.block_upload"
:block-tool-toggle="policyUiBlocks.block_tool_toggle" :block-tool-toggle="policyUiBlocks.block_tool_toggle"
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal" :block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
@ -333,10 +334,12 @@
@compress-conversation="handleCompressConversationClick" @compress-conversation="handleCompressConversationClick"
@toggle-approval-panel="handleApprovalPanelToggleClick" @toggle-approval-panel="handleApprovalPanelToggleClick"
@file-selected="handleFileSelected" @file-selected="handleFileSelected"
@paste-files="handlePastedFiles"
@pick-images="openImagePicker" @pick-images="openImagePicker"
@pick-video="openVideoPicker" @pick-video="openVideoPicker"
@remove-image="handleRemoveImage" @remove-image="handleRemoveImage"
@remove-video="handleRemoveVideo" @remove-video="handleRemoveVideo"
@remove-file="handleRemoveFile"
@open-review="openReviewDialog" @open-review="openReviewDialog"
@toggle-permission-menu="togglePermissionMenu" @toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode" @change-permission-mode="changePermissionMode"
@ -484,6 +487,7 @@
@local-files="handleLocalVideoFiles" @local-files="handleLocalVideoFiles"
/> />
</transition> </transition>
<ImageLightbox />
<transition name="overlay-fade"> <transition name="overlay-fade">
<ConversationReviewDialog <ConversationReviewDialog
v-if="reviewDialogOpen" v-if="reviewDialogOpen"
@ -804,6 +808,7 @@
import { defineAsyncComponent, onMounted, ref } from 'vue'; import { defineAsyncComponent, onMounted, ref } from 'vue';
import appOptions from './app'; import appOptions from './app';
import VideoPicker from './components/overlay/VideoPicker.vue'; import VideoPicker from './components/overlay/VideoPicker.vue';
import ImageLightbox from './components/overlay/ImageLightbox.vue';
import QuickDock from './components/chat/quickdock/QuickDock.vue'; import QuickDock from './components/chat/quickdock/QuickDock.vue';
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue'; import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
import { useTutorialStore } from './stores/tutorial'; import { useTutorialStore } from './stores/tutorial';

View File

@ -118,6 +118,9 @@ const appOptions = {
inputAddSelectedVideo: 'addSelectedVideo', inputAddSelectedVideo: 'addSelectedVideo',
inputClearSelectedVideos: 'clearSelectedVideos', inputClearSelectedVideos: 'clearSelectedVideos',
inputRemoveSelectedVideo: 'removeSelectedVideo', inputRemoveSelectedVideo: 'removeSelectedVideo',
inputAddSelectedFile: 'addSelectedFile',
inputRemoveSelectedFile: 'removeSelectedFile',
inputClearSelectedFiles: 'clearSelectedFiles',
inputToggleGoalArmed: 'toggleGoalArmed', inputToggleGoalArmed: 'toggleGoalArmed',
inputSetGoalArmed: 'setGoalArmed', inputSetGoalArmed: 'setGoalArmed',
inputSetGoalRunning: 'setGoalRunning', inputSetGoalRunning: 'setGoalRunning',

View File

@ -138,6 +138,7 @@ export const computed = {
'videoPickerOpen', 'videoPickerOpen',
'selectedImages', 'selectedImages',
'selectedVideos', 'selectedVideos',
'selectedFiles',
'goalModeArmed', 'goalModeArmed',
'goalRunning', 'goalRunning',
'goalProgress', 'goalProgress',
@ -303,7 +304,8 @@ export const computed = {
composerInteractionActive() { composerInteractionActive() {
const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0);
const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 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() { showScrollToBottomButton() {
return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom; return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom;

View File

@ -13,7 +13,8 @@ export const runtimeQueueMethods = {
list.map((item) => [ list.map((item) => [
String(item?.id || ''), String(item?.id || ''),
String(item?.text || ''), 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; if (!id || !text) return null;
const previous = previousById.get(id); const previous = previousById.get(id);
const rawCreatedAt = Number(item.created_at ?? item.createdAt ?? Date.now()); 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 { return {
id, id,
text, text,
createdAt: Number.isFinite(rawCreatedAt) ? rawCreatedAt : Date.now(), 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); .filter((item) => !!item);
@ -177,11 +184,14 @@ export const runtimeQueueMethods = {
this.runtimeQueuedMessages = normalized; this.runtimeQueuedMessages = normalized;
return normalized; return normalized;
}, },
async enqueueRuntimeQueuedMessage(rawMessage) { async enqueueRuntimeQueuedMessage(rawMessage, rawFiles = []) {
const text = (rawMessage || '').toString().trim(); const text = (rawMessage || '').toString().trim();
if (!text) { if (!text) {
return false; return false;
} }
const files = (Array.isArray(rawFiles) ? rawFiles : [])
.filter((path) => typeof path === 'string' && path)
.slice(0, 9);
try { try {
const { useTaskStore } = await import('../../../stores/task'); const { useTaskStore } = await import('../../../stores/task');
const taskStore = useTaskStore(); const taskStore = useTaskStore();
@ -200,7 +210,8 @@ export const runtimeQueueMethods = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
message: text message: text,
files: files.length ? files : undefined
}) })
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
@ -264,7 +275,8 @@ export const runtimeQueueMethods = {
if (!this.composerBusy) { if (!this.composerBusy) {
const sent = !!(await this.sendMessage({ const sent = !!(await this.sendMessage({
presetText: String(item.text || '').trim(), presetText: String(item.text || '').trim(),
source: 'runtime_queue_manual_guide' source: 'runtime_queue_manual_guide',
files: Array.isArray(item?.files) ? [...item.files] : []
})); }));
if (!sent) { if (!sent) {
this.uiPushToast({ this.uiPushToast({
@ -349,6 +361,7 @@ export const runtimeQueueMethods = {
let nextText = ''; let nextText = '';
let source = 'runtime_queue'; let source = 'runtime_queue';
let runtimeQueueMessageId = null; let runtimeQueueMessageId = null;
let nextFiles: string[] = [];
let fallbackIndex = -1; let fallbackIndex = -1;
if (fallbackQueue.length > 0) { if (fallbackQueue.length > 0) {
@ -381,6 +394,7 @@ export const runtimeQueueMethods = {
nextText = candidateText; nextText = candidateText;
source = next?.source || 'runtime_queue'; source = next?.source || 'runtime_queue';
runtimeQueueMessageId = candidateId; runtimeQueueMessageId = candidateId;
nextFiles = Array.isArray(next?.files) ? [...next.files] : [];
break; break;
} }
} }
@ -394,7 +408,8 @@ export const runtimeQueueMethods = {
try { try {
sent = !!(await this.sendMessage({ sent = !!(await this.sendMessage({
presetText: nextText, presetText: nextText,
source source,
files: nextFiles
})); }));
} catch { } catch {
sent = false; sent = false;

View File

@ -22,6 +22,17 @@ export const sendMethods = {
const hasMedia = const hasMedia =
(Array.isArray(this.selectedImages) && this.selectedImages.length > 0) || (Array.isArray(this.selectedImages) && this.selectedImages.length > 0) ||
(Array.isArray(this.selectedVideos) && this.selectedVideos.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=truecomposerBusy 只因后台子智能体在跑而保持。 // 主对话空闲但 composerBusy=truecomposerBusy 只因后台子智能体在跑而保持。
// 传统模式waitingForSubAgent=truetaskInProgress=true 。多智能体模式has_running_multi_agent=true。 // 传统模式waitingForSubAgent=truetaskInProgress=true 。多智能体模式has_running_multi_agent=true。
// 此时新文本消息应直接发送,触发主智能体下一轮工作,而不是被进队列等任务结束。 // 此时新文本消息应直接发送,触发主智能体下一轮工作,而不是被进队列等任务结束。
@ -64,9 +75,13 @@ export const sendMethods = {
} }
return; return;
} }
const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage); const queued = await this.enqueueRuntimeQueuedMessage(
this.inputMessage,
Array.isArray(this.selectedFiles) ? [...this.selectedFiles] : []
);
if (queued) { if (queued) {
this.inputClearMessage(); this.inputClearMessage();
this.inputClearSelectedFiles();
this.inputSetLineCount(1); this.inputSetLineCount(1);
this.inputSetMultiline(false); this.inputSetMultiline(false);
this.autoResizeInput(); this.autoResizeInput();
@ -151,9 +166,18 @@ export const sendMethods = {
: Array.isArray(this.selectedVideos) : Array.isArray(this.selectedVideos)
? this.selectedVideos.slice(0, 1) ? 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 hasText = text.length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const hasVideos = videos.length > 0; const hasVideos = videos.length > 0;
const hasFiles = files.length > 0;
if (!hasText && !hasImages && !hasVideos) { if (!hasText && !hasImages && !hasVideos) {
return false; return false;
@ -326,7 +350,14 @@ export const sendMethods = {
this.startRunningStateReconcile(); this.startRunningStateReconcile();
} }
const localMessageSource = usePresetText ? (options?.source === 'runtime_queue_manual_guide' ? 'guidance' : 'presend') : 'user'; 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 头部 + 工作中计时 + 等待提示, // 关键体验修复:用户发送后立刻显示 assistant 头部 + 工作中计时 + 等待提示,
// 不等待 createTask / 轮询首事件返回。 // 不等待 createTask / 轮询首事件返回。
this.chatStartAssistantMessage(); this.chatStartAssistantMessage();
@ -366,6 +397,7 @@ export const sendMethods = {
message_source: localMessageSource, message_source: localMessageSource,
goal_mode: startingGoalMode, goal_mode: startingGoalMode,
skill_refs: skillRefs, skill_refs: skillRefs,
files,
eventHandler: (event: any) => this.handleTaskEvent(event) eventHandler: (event: any) => this.handleTaskEvent(event)
}); });
@ -393,6 +425,7 @@ export const sendMethods = {
this.inputClearMessage(); this.inputClearMessage();
this.inputClearSelectedImages(); this.inputClearSelectedImages();
this.inputClearSelectedVideos(); this.inputClearSelectedVideos();
this.inputClearSelectedFiles();
this.inputSetImagePickerOpen(false); this.inputSetImagePickerOpen(false);
this.inputSetVideoPickerOpen(false); this.inputSetVideoPickerOpen(false);
this.inputSetLineCount(1); this.inputSetLineCount(1);

View File

@ -23,5 +23,8 @@ export const confirmMethods = {
}, },
handleRemoveVideo(path) { handleRemoveVideo(path) {
this.inputRemoveSelectedVideo(path); this.inputRemoveSelectedVideo(path);
},
handleRemoveFile(path) {
this.inputRemoveSelectedFile(path);
} }
}; };

View File

@ -76,9 +76,9 @@ export const dragMethods = {
await this.handleDroppedVideo(videoFiles[0]); // 视频只取第一个 await this.handleDroppedVideo(videoFiles[0]); // 视频只取第一个
} }
// 处理其他文件(普通文件上传) // 处理其他文件(上传后附加到输入栏,随消息一同发送
if (otherFiles.length > 0) { if (otherFiles.length > 0) {
this.uploadHandleSelected(otherFiles); await this.handleLocalGenericFiles(otherFiles);
} }
}, },
async handleDroppedImages(files: File[]) { async handleDroppedImages(files: File[]) {

View File

@ -5,6 +5,7 @@ import { entriesMethods } from './entries';
import { confirmMethods } from './confirm'; import { confirmMethods } from './confirm';
import { quickMethods } from './quick'; import { quickMethods } from './quick';
import { dragMethods } from './drag'; import { dragMethods } from './drag';
import { pasteMethods } from './paste';
export const uploadMethods = { export const uploadMethods = {
...pickerMethods, ...pickerMethods,
@ -13,4 +14,5 @@ export const uploadMethods = {
...confirmMethods, ...confirmMethods,
...quickMethods, ...quickMethods,
...dragMethods, ...dragMethods,
...pasteMethods,
}; };

View File

@ -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);
}
};

View File

@ -25,7 +25,10 @@ export const pickerMethods = {
}); });
return; 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() { async openImagePicker() {
const modelStore = useModelStore(); const modelStore = useModelStore();

View File

@ -13,8 +13,10 @@ export const processMethods = {
}, },
isImageFile(file) { isImageFile(file) {
const name = file?.name || ''; const name = file?.name || '';
const type = file?.type || ''; const type = (file?.type || '').toLowerCase();
return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name); // 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) { isVideoFile(file) {
const name = file?.name || ''; const name = file?.name || '';
@ -86,6 +88,58 @@ export const processMethods = {
// 上传完成后自动关闭选择窗口 // 上传完成后自动关闭选择窗口
this.closeImagePicker(); 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) { async handleLocalVideoFiles(files) {
if (!this.isConnected) { if (!this.isConnected) {
return; return;

View File

@ -85,11 +85,15 @@
<span v-html="renderUserMessageContent(msg.content)"></span> <span v-html="renderUserMessageContent(msg.content)"></span>
</template> </template>
</div> </div>
<div v-if="msg.images && msg.images.length" class="image-inline-row"> <div
v-if="(msg.images && msg.images.length) || messageFiles(msg).length"
class="image-inline-row"
>
<div <div
class="image-thumbnail-wrapper" class="image-thumbnail-wrapper"
v-for="(img, imgIndex) in msg.images" v-for="(img, imgIndex) in msg.images"
:key="mediaPreviewKey(msg, img, imgIndex)" :key="mediaPreviewKey(msg, img, imgIndex)"
@click.stop="previewMessageImage(msg, img)"
> >
<img <img
:src="getPreviewUrl(msg, img, 'image')" :src="getPreviewUrl(msg, img, 'image')"
@ -97,6 +101,7 @@
class="image-thumbnail" class="image-thumbnail"
/> />
</div> </div>
<FileChips v-if="messageFiles(msg).length" :files="messageFiles(msg)" />
</div> </div>
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row"> <div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
<div <div
@ -722,6 +727,8 @@ import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue'; import MinimalBlocks from './MinimalBlocks.vue';
import MarkdownRenderer from './MarkdownRenderer.vue'; import MarkdownRenderer from './MarkdownRenderer.vue';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useUiStore } from '@/stores/ui';
import FileChips from '@/components/chat/FileChips.vue';
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility'; import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
const props = defineProps<{ const props = defineProps<{
@ -1892,6 +1899,21 @@ function formatImageName(input: any): string {
return parts[parts.length - 1] || path; return parts[parts.length - 1] || path;
} }
// metadata.files
function messageFiles(message: any): any[] {
const raw = message?.metadata?.files;
if (!Array.isArray(raw)) return [];
return raw
.filter((item: any) => (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 { function resolveMessageMediaRef(message: any, input: any, kind: 'image' | 'video'): any | null {
const refs = message?.media_refs || message?.metadata?.media_refs || []; const refs = message?.media_refs || message?.metadata?.media_refs || [];
if (!Array.isArray(refs) || !refs.length) { if (!Array.isArray(refs) || !refs.length) {

View File

@ -0,0 +1,288 @@
<template>
<div class="file-chip-row" role="list">
<div
v-for="file in normalizedFiles"
:key="file.path"
class="file-chip"
:class="{
'is-clickable': canPreview,
'is-previewing': canPreview && quickDock.previewPath === file.path
}"
role="listitem"
:title="file.name"
@click="previewFile(file)"
>
<span class="file-chip-icon" :style="{ background: `var(--file-kind-${file.kind})` }">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="FILE_BODY_PATH" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<template v-if="file.kind === 'word'">
<path d="M9 13h6" />
<path d="M9 16.5h6" />
</template>
<template v-else-if="file.kind === 'excel'">
<path d="M9 12h6v5H9z" />
<path d="M9 14.5h6" />
<path d="M12 12v5" />
</template>
<template v-else-if="file.kind === 'ppt'">
<path d="M9.5 17v-3.5" />
<path d="M12.5 17V12" />
<path d="M15.5 17v-2" />
</template>
<template v-else-if="file.kind === 'pdf'">
<path d="M12 11.5v6" />
<path d="m9.5 15 2.5 2.5 2.5-2.5" />
</template>
<template v-else-if="file.kind === 'text'">
<path d="M9 13h6" />
<path d="M9 16.5h4" />
</template>
<template v-else-if="file.kind === 'code'">
<path d="m10 12.5-2 2 2 2" />
<path d="m14 12.5 2 2-2 2" />
</template>
<template v-else-if="file.kind === 'archive'">
<path d="M9 12.5h6v4.5H9z" />
<path d="M11 12.5V11h2v1.5" />
</template>
</svg>
</span>
<span class="file-chip-meta">
<span class="file-chip-name">{{ file.name }}</span>
<span class="file-chip-type">{{ file.label }}</span>
</span>
<button
v-if="removable"
type="button"
class="file-chip-remove"
:aria-label="`移除 ${file.name}`"
@click.stop="$emit('remove', file.path)"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path
d="M2.5 2.5l5 5M7.5 2.5l-5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useQuickDockStore } from '@/stores/quickDock';
import { useUiStore } from '@/stores/ui';
interface FileChipEntry {
path: string;
name: string;
kind: string;
label: string;
}
const FILE_BODY_PATH =
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z';
// { --file-kind-* token / , }
const FILE_KIND_MAP: Record<string, { kind: string; label: string }> = {
doc: { kind: 'word', label: '文档' },
docx: { kind: 'word', label: '文档' },
xls: { kind: 'excel', label: '电子表格' },
xlsx: { kind: 'excel', label: '电子表格' },
csv: { kind: 'excel', label: '电子表格' },
ppt: { kind: 'ppt', label: '演示文稿' },
pptx: { kind: 'ppt', label: '演示文稿' },
pdf: { kind: 'pdf', label: 'PDF' },
txt: { kind: 'text', label: '文本' },
md: { kind: 'text', label: '文本' },
log: { kind: 'text', label: '文本' },
zip: { kind: 'archive', label: '压缩包' },
tar: { kind: 'archive', label: '压缩包' },
gz: { kind: 'archive', label: '压缩包' },
tgz: { kind: 'archive', label: '压缩包' },
bz2: { kind: 'archive', label: '压缩包' },
xz: { kind: 'archive', label: '压缩包' },
'7z': { kind: 'archive', label: '压缩包' },
rar: { kind: 'archive', label: '压缩包' }
};
const CODE_EXTENSIONS = new Set([
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx', 'vue', 'py', 'java', 'c', 'h', 'cpp', 'cc', 'hpp',
'go', 'rs', 'rb', 'php', 'html', 'htm', 'css', 'scss', 'less', 'json', 'xml', 'yml', 'yaml',
'toml', 'ini', 'cfg', 'sh', 'bash', 'zsh', 'sql', 'swift', 'kt', 'kts', 'lua', 'r', 'dart'
]);
const props = withDefaults(
defineProps<{
files?: Array<string | { path?: string; name?: string }>;
removable?: boolean;
}>(),
{ files: () => [], removable: false }
);
defineEmits<{ (e: 'remove', path: string): void }>();
const basename = (path: string): string => {
const parts = String(path || '').split(/[/\\]/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : String(path || '');
};
const resolveKind = (name: string): { kind: string; label: string } => {
const ext = (name.includes('.') ? name.split('.').pop() || '' : '').trim().toLowerCase();
if (ext && FILE_KIND_MAP[ext]) {
return FILE_KIND_MAP[ext];
}
if (ext && CODE_EXTENSIONS.has(ext)) {
return { kind: 'code', label: '代码' };
}
return { kind: 'generic', label: '文件' };
};
const normalizedFiles = computed<FileChipEntry[]>(() => {
return (props.files || [])
.map((item) => {
const path = typeof item === 'string' ? item : String(item?.path || '');
if (!path) return null;
const name =
(typeof item === 'object' && item && typeof item.name === 'string' && item.name) ||
basename(path);
const { kind, label } = resolveKind(name);
return { path, name, kind, label };
})
.filter((entry): entry is FileChipEntry => !!entry);
});
const quickDock = useQuickDockStore();
const uiStore = useUiStore();
//
const canPreview = computed(() => !uiStore.isMobileViewport);
//
// openPreview toggle
const previewFile = (file: FileChipEntry) => {
if (!canPreview.value) return;
if (quickDock.previewPath === file.path) return;
quickDock.openPreview(file.path);
};
</script>
<style scoped>
.file-chip-row {
display: contents;
}
.file-chip {
position: relative;
flex: none;
box-sizing: border-box;
width: 196px;
height: 60px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px 0 10px;
background: var(--surface-base);
border: 1px solid var(--border-default);
border-radius: 6px;
}
/* (.stadium-shell) --badge-bg(#2a2a2a)
--surface-base(#1a1a1a) 比壳更暗会沉成一坨近黑
这里以 --chip-bg 为基色提亮一级并加强边框保证轮廓 */
body[data-theme='dark'] .file-chip {
background: color-mix(in srgb, var(--chip-bg) 88%, white);
border-color: var(--border-strong);
}
.file-chip.is-clickable {
cursor: pointer;
}
.file-chip.is-clickable:hover,
.file-chip.is-previewing {
border-color: var(--border-strong);
}
.file-chip-icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--on-accent);
}
.file-chip-icon svg {
display: block;
}
.file-chip-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.25;
}
.file-chip-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-chip-type {
font-size: 11px;
color: var(--text-secondary);
}
.file-chip-remove {
position: absolute;
top: -7px;
right: -7px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--surface-base);
border: 1px solid var(--border-default);
color: var(--text-secondary);
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: color 0.15s ease;
}
body[data-theme='dark'] .file-chip-remove {
background: color-mix(in srgb, var(--chip-bg) 88%, white);
border-color: var(--border-strong);
}
.file-chip:hover .file-chip-remove {
display: flex;
}
.file-chip-remove:hover {
color: var(--state-danger);
}
</style>

View File

@ -40,9 +40,16 @@
<div v-if="loading" class="qd-preview__loading">加载中</div> <div v-if="loading" class="qd-preview__loading">加载中</div>
<div v-else-if="error" class="qd-preview__error">{{ error }}</div> <div v-else-if="error" class="qd-preview__error">{{ error }}</div>
<template v-else> <template v-else>
<!-- 行号列 + 整段高亮 prePrism token 可能跨行多行注释/字符串 --> <!-- 自动换行模式设置项开启逐行渲染行号内嵌不会错位行高亮走 CSS :hover -->
<div v-if="autoWrap" class="qd-code qd-code--wrap">
<div v-for="(lineHtml, idx) in wrappedLines" :key="idx" class="qd-code-line">
<span class="qd-code-line-no" aria-hidden="true">{{ idx + 1 }}</span>
<span class="qd-code-line-text" v-html="lineHtml"></span>
</div>
</div>
<!-- 不换行默认行号列 + 整段高亮 prePrism token 可能跨行多行注释/字符串 -->
<!-- 不能按行拆分 v-html hover 用绝对定位高亮条实现 --> <!-- 不能按行拆分 v-html hover 用绝对定位高亮条实现 -->
<div class="qd-code" @mousemove="onCodeMouseMove" @mouseleave="hoverLine = -1"> <div v-else class="qd-code" @mousemove="onCodeMouseMove" @mouseleave="hoverLine = -1">
<div <div
v-if="hoverLine >= 0" v-if="hoverLine >= 0"
class="qd-code-hoverline" class="qd-code-hoverline"
@ -64,6 +71,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useQuickDockStore } from '@/stores/quickDock'; import { useQuickDockStore } from '@/stores/quickDock';
import { usePersonalizationStore } from '@/stores/personalization';
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight'; import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
/** 与 quickdock.css 中 .qd-code 的 font-size(12px) × line-height(1.75) 保持同步 */ /** 与 quickdock.css 中 .qd-code 的 font-size(12px) × line-height(1.75) 保持同步 */
@ -192,6 +200,17 @@ const highlightedHtml = computed(() => {
const lineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0)); const lineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0));
const personalization = usePersonalizationStore();
/** 设置项「预览窗口自动换行显示」,默认关闭(长行横向滚动) */
const autoWrap = computed(() => personalization.form.file_preview_auto_wrap === true);
// +
// / token
const wrappedLines = computed<string[]>(() => {
if (!rawText.value) return [];
return rawText.value.split('\n').map((line) => highlightCode(line, previewLang.value));
});
const fileName = computed(() => { const fileName = computed(() => {
const p = previewPath.value || ''; const p = previewPath.value || '';
return p.split('/').pop() || p; return p.split('/').pop() || p;

View File

@ -900,6 +900,10 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
/* 宽度取内容全宽可视宽的较大者长行横向滚动时
hover 行高亮条right:0 锚定本容器才能始终贯穿整行 */
width: max-content;
min-width: 100%;
font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.75; line-height: 1.75;
@ -942,6 +946,48 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* 自动换行模式设置项预览窗口自动换行显示开启时
逐行块布局内容按面板宽度换行行号随行不错位行高亮走 :hover */
.qd-code--wrap {
display: block;
width: 100%;
}
.qd-code-line {
display: flex;
align-items: flex-start;
min-height: 21px;
/* 屏外行跳过渲染大文件预览性能行高固定 21px 基准估值极准
auto 前缀让浏览器缓存已渲染行的真实高度滚动修正幅度小
宽度不佔位flex 行块宽度由容器决定只约束高度估值 */
content-visibility: auto;
contain-intrinsic-width: none;
contain-intrinsic-height: auto 21px;
}
.qd-code-line:hover {
background: var(--hover-bg);
}
.qd-code-line-no {
flex: none;
width: 44px;
padding-right: 12px;
box-sizing: border-box;
text-align: right;
color: var(--text-tertiary);
user-select: none;
}
.qd-code-line-text {
flex: 1;
min-width: 0;
padding-right: 14px;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text-primary);
}
/* ---------------- 全局 ⋯ 菜单fixed 单例) ---------------- */ /* ---------------- 全局 ⋯ 菜单fixed 单例) ---------------- */
.qd-menu { .qd-menu {

View File

@ -227,17 +227,41 @@
@change="onFileChange" @change="onFileChange"
/> />
<div class="input-stack"> <div class="input-stack">
<div v-if="selectedImages && selectedImages.length" class="image-inline-row"> <div
<div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img"> v-if="
(selectedImages && selectedImages.length) ||
(selectedFiles && selectedFiles.length)
"
class="image-inline-row"
>
<div
class="image-thumbnail-wrapper"
v-for="img in selectedImages"
:key="img"
@click.stop="previewImage(img)"
>
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" /> <img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
<button <button
type="button" type="button"
class="image-remove-btn-hover" class="image-remove-btn-hover"
@click.stop="$emit('remove-image', img)" @click.stop="$emit('remove-image', img)"
> >
× <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path
d="M2.5 2.5l5 5M7.5 2.5l-5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button> </button>
</div> </div>
<FileChips
v-if="selectedFiles && selectedFiles.length"
:files="selectedFiles"
removable
@remove="$emit('remove-file', $event)"
/>
</div> </div>
<div <div
v-if="selectedVideos && selectedVideos.length" v-if="selectedVideos && selectedVideos.length"
@ -254,7 +278,14 @@
class="image-remove-btn-hover" class="image-remove-btn-hover"
@click.stop="$emit('remove-video', video)" @click.stop="$emit('remove-video', video)"
> >
× <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path
d="M2.5 2.5l5 5M7.5 2.5l-5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -546,10 +577,12 @@ import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state'; import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.vue'; import QuickMenu from '@/components/input/QuickMenu.vue';
import FileAtMenu, { type FileAtItem } from '@/components/input/FileAtMenu.vue'; import FileAtMenu, { type FileAtItem } from '@/components/input/FileAtMenu.vue';
import FileChips from '@/components/chat/FileChips.vue';
import RollingNumber from '@/components/input/RollingNumber.vue'; import RollingNumber from '@/components/input/RollingNumber.vue';
import StatusAvatar from '@/components/avatar/StatusAvatar.vue'; import StatusAvatar from '@/components/avatar/StatusAvatar.vue';
import { useInputStore } from '@/stores/input'; import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useUiStore } from '@/stores/ui';
defineOptions({ name: 'InputComposer' }); defineOptions({ name: 'InputComposer' });
@ -578,8 +611,10 @@ const emit = defineEmits([
'compress-conversation', 'compress-conversation',
'toggle-approval-panel', 'toggle-approval-panel',
'file-selected', 'file-selected',
'paste-files',
'remove-image', 'remove-image',
'remove-video', 'remove-video',
'remove-file',
'open-review', 'open-review',
'open-path-authorization', 'open-path-authorization',
'toggle-permission-menu', 'toggle-permission-menu',
@ -631,6 +666,7 @@ const props = defineProps<{
}>; }>;
currentModelKey: string; currentModelKey: string;
selectedImages?: string[]; selectedImages?: string[];
selectedFiles?: string[];
selectedVideos?: string[]; selectedVideos?: string[];
mediaUploading?: boolean; mediaUploading?: boolean;
blockUpload?: boolean; blockUpload?: boolean;
@ -845,6 +881,12 @@ const formatImageName = (path: string): string => {
return parts[parts.length - 1] || path; return parts[parts.length - 1] || path;
}; };
const previewImage = (path: string) => {
const url = getPreviewUrl(path);
if (!url) return;
useUiStore().openImagePreview({ url, name: formatImageName(path) });
};
const getPreviewUrl = (path: string): string => { const getPreviewUrl = (path: string): string => {
if (!path) return ''; if (!path) return '';
return `/api/gui/files/download?path=${encodeURIComponent(path)}`; return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
@ -1091,7 +1133,8 @@ const updateFileAtMenuPosition = (tokenEnd: number) => {
const isImageFile = (path: string): boolean => { const isImageFile = (path: string): boolean => {
if (!path) return false; if (!path) return false;
const ext = path.split('.').pop()?.toLowerCase() || ''; const ext = path.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext); // svg view
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico'].includes(ext);
}; };
const fetchProjectFileSearch = async (query: string) => { const fetchProjectFileSearch = async (query: string) => {
@ -1790,6 +1833,23 @@ const onInputBlur = () => {
}, 120); }, 120);
}; };
const collectClipboardFiles = (event: ClipboardEvent): File[] => {
const data = event.clipboardData;
if (!data) return [];
const files: File[] = [];
if (data.items && data.items.length) {
for (const item of Array.from(data.items)) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (!files.length && data.files && data.files.length) {
files.push(...Array.from(data.files));
}
return files;
};
const textToTiptapContent = (text = ''): JSONContent => { const textToTiptapContent = (text = ''): JSONContent => {
const lines = String(text || '').split('\n'); const lines = String(text || '').split('\n');
return { return {
@ -1899,6 +1959,15 @@ const editor = useEditor({
return true; return true;
} }
return onKeydown(event); return onKeydown(event);
},
handlePaste(_view, event) {
// /
// false
const files = collectClipboardFiles(event);
if (!files.length) return false;
event.preventDefault();
emit('paste-files', files);
return true;
} }
}, },
onUpdate() { onUpdate() {
@ -2464,6 +2533,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0; const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0; const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0; const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
const hasFiles = Array.isArray(props.selectedFiles) && props.selectedFiles.length > 0;
return ( return (
hasQueue || hasQueue ||
floatingStatusVisible.value || floatingStatusVisible.value ||
@ -2472,6 +2542,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
fileAtOpen.value || fileAtOpen.value ||
hasImages || hasImages ||
hasVideos || hasVideos ||
hasFiles ||
!!props.inputIsMultiline || !!props.inputIsMultiline ||
!!props.goalModeArmed || !!props.goalModeArmed ||
!!props.goalRunning || !!props.goalRunning ||
@ -3469,40 +3540,53 @@ onBeforeUnmount(() => {
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 6px; border-radius: 6px;
overflow: hidden;
cursor: pointer; cursor: pointer;
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
} }
/* img wrapper overflow:hidden
以便删除按钮可以半外凸与文件块统一 */
.image-thumbnail { .image-thumbnail {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 5px;
} }
/* 与 FileChips 的 .file-chip-remove 保持一致 */
.image-remove-btn-hover { .image-remove-btn-hover {
position: absolute; position: absolute;
top: 0; top: -7px;
right: 0; right: -7px;
background: none; width: 18px;
color: var(--on-accent); height: 18px;
border: none; border-radius: 50%;
font-size: 20px; background: var(--surface-base);
font-weight: bold; border: 1px solid var(--border-default);
line-height: 1; color: var(--text-secondary);
cursor: pointer; cursor: pointer;
display: none; display: none;
padding: 2px 4px; align-items: center;
text-shadow: 0 0 3px var(--text-shadow-legible); justify-content: center;
padding: 0;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.image-thumbnail-wrapper:hover .image-remove-btn-hover { .image-thumbnail-wrapper:hover .image-remove-btn-hover {
display: block; display: flex;
} }
.image-remove-btn-hover:hover { .image-remove-btn-hover:hover {
color: var(--state-danger); color: var(--state-danger);
} }
body[data-theme='dark'] .image-thumbnail-wrapper {
border-color: var(--border-strong);
}
body[data-theme='dark'] .image-remove-btn-hover {
background: color-mix(in srgb, var(--chip-bg) 88%, white);
border-color: var(--border-strong);
}
</style> </style>

View File

@ -0,0 +1,161 @@
<template>
<Teleport to="body">
<Transition name="lightbox-fade">
<div
v-if="preview"
class="image-lightbox"
role="dialog"
aria-modal="true"
:aria-label="preview.name || '图片预览'"
@click.self="close"
>
<button
type="button"
class="image-lightbox__close"
aria-label="关闭预览"
@click.stop="close"
>
×
</button>
<div class="image-lightbox__stage" @click.self="close">
<img
class="image-lightbox__img"
:src="preview.url"
:alt="preview.name || '图片预览'"
draggable="false"
/>
<div v-if="preview.name" class="image-lightbox__caption">{{ preview.name }}</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue';
import { useUiStore } from '@/stores/ui';
const uiStore = useUiStore();
const preview = computed(() => uiStore.imagePreview);
const close = () => {
uiStore.closeImagePreview();
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && preview.value) {
event.preventDefault();
close();
}
};
// ESC
const syncLock = (open: boolean) => {
if (typeof document === 'undefined') return;
if (open) {
document.addEventListener('keydown', onKeydown);
document.body.style.overflow = 'hidden';
} else {
document.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
}
};
watch(preview, (value) => syncLock(!!value), { immediate: true });
onBeforeUnmount(() => syncLock(false));
</script>
<style scoped>
.image-lightbox {
position: fixed;
inset: 0;
z-index: 4000;
background: var(--overlay-scrim);
display: flex;
align-items: center;
justify-content: center;
/* 移动端安全区适配 */
padding:
calc(12px + env(safe-area-inset-top, 0px))
calc(12px + env(safe-area-inset-right, 0px))
calc(12px + env(safe-area-inset-bottom, 0px))
calc(12px + env(safe-area-inset-left, 0px));
box-sizing: border-box;
}
.image-lightbox__stage {
max-width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 0;
}
.image-lightbox__img {
/* 最大预览面积:桌面收敛在 1100x780 以内,再叠加视口上限,不铺满全屏 */
max-width: min(1100px, 88vw);
max-height: min(780px, 76vh);
object-fit: contain;
border-radius: 6px;
user-select: none;
-webkit-user-drag: none;
}
/* 移动端屏幕小,适度放宽比例 */
@media (max-width: 767px) {
.image-lightbox__img {
max-width: 94vw;
max-height: 80vh;
}
}
.image-lightbox__caption {
flex: none;
max-width: 80vw;
font-size: 12px;
line-height: 1.4;
color: var(--lightbox-text);
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-lightbox__close {
position: absolute;
top: calc(10px + env(safe-area-inset-top, 0px));
right: calc(10px + env(safe-area-inset-right, 0px));
z-index: 1;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--lightbox-btn-bg);
color: var(--lightbox-text);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease;
}
.image-lightbox__close:hover {
background: var(--lightbox-btn-bg-hover);
}
.lightbox-fade-enter-active,
.lightbox-fade-leave-active {
transition: opacity 0.16s ease;
}
.lightbox-fade-enter-from,
.lightbox-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -12,7 +12,7 @@
ref="localInput" ref="localInput"
type="file" type="file"
class="file-input-hidden" class="file-input-hidden"
accept="image/*" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
multiple multiple
@change="onLocalChange" @change="onLocalChange"
/> />

View File

@ -779,6 +779,28 @@
class="fancy-path" class="fancy-path"
></path></svg></span ></path></svg></span
></label> ></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">预览窗口自动换行显示</span
><span class="settings-row-desc"
>文件预览按面板宽度自动换行关闭时长行横向滚动</span
></span
><input
type="checkbox"
:checked="form.file_preview_auto_wrap"
@change="
personalization.updateField({
key: 'file_preview_auto_wrap',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-select-row"> <div class="settings-select-row">
<span class="settings-row-copy" <span class="settings-row-copy"
><span class="settings-row-title">堆叠块显示模式</span ><span class="settings-row-title">堆叠块显示模式</span

View File

@ -12,6 +12,8 @@ interface InputState {
selectedImages: string[]; selectedImages: string[];
videoPickerOpen: boolean; videoPickerOpen: boolean;
selectedVideos: string[]; selectedVideos: string[];
// 待发送的附加文件(工作区相对路径,随消息一同发送,上限 9 个)
selectedFiles: string[];
// 目标模式Goal Mode // 目标模式Goal Mode
goalModeArmed: boolean; // 已就绪:下一条消息作为目标发送 goalModeArmed: boolean; // 已就绪:下一条消息作为目标发送
goalRunning: boolean; // 运行中:目标循环正在进行 goalRunning: boolean; // 运行中:目标循环正在进行
@ -32,6 +34,7 @@ export const useInputStore = defineStore('input', {
selectedImages: [], selectedImages: [],
videoPickerOpen: false, videoPickerOpen: false,
selectedVideos: [], selectedVideos: [],
selectedFiles: [],
goalModeArmed: false, goalModeArmed: false,
goalRunning: false, goalRunning: false,
goalProgress: null, goalProgress: null,
@ -120,6 +123,17 @@ export const useInputStore = defineStore('input', {
clearSelectedVideos() { clearSelectedVideos() {
this.selectedVideos = []; this.selectedVideos = [];
}, },
addSelectedFile(path: string) {
if (!path) return;
const next = Array.from(new Set([...this.selectedFiles, path]));
this.selectedFiles = next.slice(0, 9);
},
removeSelectedFile(path: string) {
this.selectedFiles = this.selectedFiles.filter((item) => item !== path);
},
clearSelectedFiles() {
this.selectedFiles = [];
},
// ---- 目标模式 ---- // ---- 目标模式 ----
toggleGoalArmed() { toggleGoalArmed() {
// 运行中不允许通过开关切换 // 运行中不允许通过开关切换

View File

@ -32,6 +32,7 @@ interface PersonalForm {
show_git_status_bar: boolean; show_git_status_bar: boolean;
auto_open_terminal_panel: boolean; auto_open_terminal_panel: boolean;
quick_dock_auto_expand: boolean; quick_dock_auto_expand: boolean;
file_preview_auto_wrap: boolean;
stacked_hide_borders: boolean; stacked_hide_borders: boolean;
minimal_expand_height_limited: boolean; minimal_expand_height_limited: boolean;
enhanced_tool_display_categories: string[]; enhanced_tool_display_categories: string[];
@ -227,6 +228,7 @@ const defaultForm = (): PersonalForm => ({
show_git_status_bar: true, show_git_status_bar: true,
auto_open_terminal_panel: true, auto_open_terminal_panel: true,
quick_dock_auto_expand: loadCachedQuickDockAutoExpand(), quick_dock_auto_expand: loadCachedQuickDockAutoExpand(),
file_preview_auto_wrap: false,
stacked_hide_borders: loadCachedStackedHideBorders(), stacked_hide_borders: loadCachedStackedHideBorders(),
minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(), minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(),
enhanced_tool_display_categories: [], enhanced_tool_display_categories: [],
@ -435,6 +437,7 @@ export const usePersonalizationStore = defineStore('personalization', {
show_git_status_bar: data.show_git_status_bar !== false, show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== false, auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
quick_dock_auto_expand: data.quick_dock_auto_expand !== false, quick_dock_auto_expand: data.quick_dock_auto_expand !== false,
file_preview_auto_wrap: !!data.file_preview_auto_wrap,
stacked_hide_borders: !!data.stacked_hide_borders, stacked_hide_borders: !!data.stacked_hide_borders,
minimal_expand_height_limited: data.minimal_expand_height_limited !== false, minimal_expand_height_limited: data.minimal_expand_height_limited !== false,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories) enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)

View File

@ -84,6 +84,7 @@ export const useTaskStore = defineStore('task', {
message_source?: string | null; message_source?: string | null;
goal_mode?: boolean | null; goal_mode?: boolean | null;
skill_refs?: Array<{ name?: string; path: string }> | null; skill_refs?: Array<{ name?: string; path: string }> | null;
files?: string[] | null;
eventHandler?: (event: any) => void; eventHandler?: (event: any) => void;
} = {} } = {}
) { ) {
@ -106,7 +107,9 @@ export const useTaskStore = defineStore('task', {
typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined, typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined,
message_source: options.message_source ?? undefined, message_source: options.message_source ?? undefined,
goal_mode: options.goal_mode === true ? true : undefined, goal_mode: options.goal_mode === true ? true : undefined,
skill_refs: Array.isArray(options.skill_refs) ? options.skill_refs : undefined skill_refs: Array.isArray(options.skill_refs) ? options.skill_refs : undefined,
files:
Array.isArray(options.files) && options.files.length ? options.files : undefined
}) })
}); });

View File

@ -70,6 +70,8 @@ interface UiState {
isMobileViewport: boolean; isMobileViewport: boolean;
mobileOverlayMenuOpen: boolean; mobileOverlayMenuOpen: boolean;
activeMobileOverlay: MobileOverlayTarget; activeMobileOverlay: MobileOverlayTarget;
// 图片大图预览Lightboxurl 为空表示关闭
imagePreview: { url: string; name: string } | null;
} }
// 首帧即判定移动端视口:初始值不能等 mounted 里的 matchMedia 监听, // 首帧即判定移动端视口:初始值不能等 mounted 里的 matchMedia 监听,
@ -107,9 +109,18 @@ export const useUiStore = defineStore('ui', {
}, },
isMobileViewport: initialIsMobileViewport, isMobileViewport: initialIsMobileViewport,
mobileOverlayMenuOpen: false, mobileOverlayMenuOpen: false,
activeMobileOverlay: null activeMobileOverlay: null,
imagePreview: null
}), }),
actions: { actions: {
openImagePreview(payload: { url: string; name?: string }) {
const url = String(payload?.url || '');
if (!url) return;
this.imagePreview = { url, name: String(payload?.name || '') };
},
closeImagePreview() {
this.imagePreview = null;
},
setSidebarCollapsed(collapsed: boolean) { setSidebarCollapsed(collapsed: boolean) {
this.sidebarCollapsed = collapsed; this.sidebarCollapsed = collapsed;
}, },

View File

@ -28,6 +28,21 @@
/* 拖拽预览浮层投影(主题无关,固定黑投影) */ /* 拖拽预览浮层投影(主题无关,固定黑投影) */
--drag-preview-shadow: rgba(0, 0, 0, 0.35); --drag-preview-shadow: rgba(0, 0, 0, 0.35);
/* 图片大图预览浮层(主题无关,固定在深色遮罩之上) */
--lightbox-text: #ffffff;
--lightbox-btn-bg: rgba(0, 0, 0, 0.45);
--lightbox-btn-bg-hover: rgba(0, 0, 0, 0.65);
/* 文件类型身份色(主题无关,固定功能指示色):文件附加块 FileChips 类型图标底色 */
--file-kind-word: #5b7fc4;
--file-kind-excel: #529658;
--file-kind-ppt: #c9734b;
--file-kind-pdf: #c25e52;
--file-kind-text: #71808f;
--file-kind-code: #8a6fc0;
--file-kind-archive: #a08255;
--file-kind-generic: #8b887f;
/* 推理强度档位色主题无关固定功能指示色EffortSlider 滑块填充与动效 */ /* 推理强度档位色主题无关固定功能指示色EffortSlider 滑块填充与动效 */
--effort-low: #f5c542; --effort-low: #f5c542;
--effort-medium: #3fd07c; --effort-medium: #3fd07c;