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:
parent
b51b2597a8
commit
9fff41650d
@ -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"))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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] = []
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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=true:composerBusy 只因后台子智能体在跑而保持。
|
// 主对话空闲但 composerBusy=true:composerBusy 只因后台子智能体在跑而保持。
|
||||||
// 传统模式:waitingForSubAgent=true(taskInProgress=true 。多智能体模式:has_running_multi_agent=true。
|
// 传统模式:waitingForSubAgent=true(taskInProgress=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);
|
||||||
|
|||||||
@ -23,5 +23,8 @@ export const confirmMethods = {
|
|||||||
},
|
},
|
||||||
handleRemoveVideo(path) {
|
handleRemoveVideo(path) {
|
||||||
this.inputRemoveSelectedVideo(path);
|
this.inputRemoveSelectedVideo(path);
|
||||||
|
},
|
||||||
|
handleRemoveFile(path) {
|
||||||
|
this.inputRemoveSelectedFile(path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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[]) {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
37
static/src/app/methods/upload/paste.ts
Normal file
37
static/src/app/methods/upload/paste.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
288
static/src/components/chat/FileChips.vue
Normal file
288
static/src/components/chat/FileChips.vue
Normal 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>
|
||||||
@ -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>
|
||||||
<!-- 行号列 + 整段高亮 pre:Prism 的 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>
|
||||||
|
<!-- 不换行(默认):行号列 + 整段高亮 pre:Prism 的 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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
161
static/src/components/overlay/ImageLightbox.vue
Normal file
161
static/src/components/overlay/ImageLightbox.vue
Normal 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>
|
||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
// 运行中不允许通过开关切换
|
// 运行中不允许通过开关切换
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,8 @@ interface UiState {
|
|||||||
isMobileViewport: boolean;
|
isMobileViewport: boolean;
|
||||||
mobileOverlayMenuOpen: boolean;
|
mobileOverlayMenuOpen: boolean;
|
||||||
activeMobileOverlay: MobileOverlayTarget;
|
activeMobileOverlay: MobileOverlayTarget;
|
||||||
|
// 图片大图预览(Lightbox):url 为空表示关闭
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user