-
+
+
+
@@ -546,10 +577,12 @@ import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.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 StatusAvatar from '@/components/avatar/StatusAvatar.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';
+import { useUiStore } from '@/stores/ui';
defineOptions({ name: 'InputComposer' });
@@ -578,8 +611,10 @@ const emit = defineEmits([
'compress-conversation',
'toggle-approval-panel',
'file-selected',
+ 'paste-files',
'remove-image',
'remove-video',
+ 'remove-file',
'open-review',
'open-path-authorization',
'toggle-permission-menu',
@@ -631,6 +666,7 @@ const props = defineProps<{
}>;
currentModelKey: string;
selectedImages?: string[];
+ selectedFiles?: string[];
selectedVideos?: string[];
mediaUploading?: boolean;
blockUpload?: boolean;
@@ -845,6 +881,12 @@ const formatImageName = (path: string): string => {
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 => {
if (!path) return '';
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
@@ -1091,7 +1133,8 @@ const updateFileAtMenuPosition = (tokenEnd: number) => {
const isImageFile = (path: string): boolean => {
if (!path) return false;
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) => {
@@ -1790,6 +1833,23 @@ const onInputBlur = () => {
}, 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 lines = String(text || '').split('\n');
return {
@@ -1899,6 +1959,15 @@ const editor = useEditor({
return true;
}
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() {
@@ -2464,6 +2533,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
+ const hasFiles = Array.isArray(props.selectedFiles) && props.selectedFiles.length > 0;
return (
hasQueue ||
floatingStatusVisible.value ||
@@ -2472,6 +2542,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
fileAtOpen.value ||
hasImages ||
hasVideos ||
+ hasFiles ||
!!props.inputIsMultiline ||
!!props.goalModeArmed ||
!!props.goalRunning ||
@@ -3469,40 +3540,53 @@ onBeforeUnmount(() => {
width: 60px;
height: 60px;
border-radius: 6px;
- overflow: hidden;
cursor: pointer;
border: 1px solid var(--border-default);
}
+/* 圆角裁切下移到 img 自身,wrapper 不再 overflow:hidden,
+ 以便删除按钮可以半外凸(与文件块统一) */
.image-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
+ border-radius: 5px;
}
+/* 与 FileChips 的 .file-chip-remove 保持一致 */
.image-remove-btn-hover {
position: absolute;
- top: 0;
- right: 0;
- background: none;
- color: var(--on-accent);
- border: none;
- font-size: 20px;
- font-weight: bold;
- line-height: 1;
+ 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);
cursor: pointer;
display: none;
- padding: 2px 4px;
- text-shadow: 0 0 3px var(--text-shadow-legible);
+ align-items: center;
+ justify-content: center;
+ padding: 0;
transition: color 0.15s ease;
}
.image-thumbnail-wrapper:hover .image-remove-btn-hover {
- display: block;
+ display: flex;
}
.image-remove-btn-hover:hover {
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);
+}
diff --git a/static/src/components/overlay/ImageLightbox.vue b/static/src/components/overlay/ImageLightbox.vue
new file mode 100644
index 00000000..38747b69
--- /dev/null
+++ b/static/src/components/overlay/ImageLightbox.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
![]()
+
{{ preview.name }}
+
+
+
+
+
+
+
+
+
diff --git a/static/src/components/overlay/ImagePicker.vue b/static/src/components/overlay/ImagePicker.vue
index 59ba21cc..f07566cd 100644
--- a/static/src/components/overlay/ImagePicker.vue
+++ b/static/src/components/overlay/ImagePicker.vue
@@ -12,7 +12,7 @@
ref="localInput"
type="file"
class="file-input-hidden"
- accept="image/*"
+ accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
multiple
@change="onLocalChange"
/>
diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue
index db44b0a2..c1eb25f5 100644
--- a/static/src/components/personalization/PersonalizationDrawer.vue
+++ b/static/src/components/personalization/PersonalizationDrawer.vue
@@ -779,6 +779,28 @@
class="fancy-path"
>
+
堆叠块显示模式 item !== path);
+ },
+ clearSelectedFiles() {
+ this.selectedFiles = [];
+ },
// ---- 目标模式 ----
toggleGoalArmed() {
// 运行中不允许通过开关切换
diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts
index bb23dc69..ac0c5ae1 100644
--- a/static/src/stores/personalization.ts
+++ b/static/src/stores/personalization.ts
@@ -32,6 +32,7 @@ interface PersonalForm {
show_git_status_bar: boolean;
auto_open_terminal_panel: boolean;
quick_dock_auto_expand: boolean;
+ file_preview_auto_wrap: boolean;
stacked_hide_borders: boolean;
minimal_expand_height_limited: boolean;
enhanced_tool_display_categories: string[];
@@ -227,6 +228,7 @@ const defaultForm = (): PersonalForm => ({
show_git_status_bar: true,
auto_open_terminal_panel: true,
quick_dock_auto_expand: loadCachedQuickDockAutoExpand(),
+ file_preview_auto_wrap: false,
stacked_hide_borders: loadCachedStackedHideBorders(),
minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(),
enhanced_tool_display_categories: [],
@@ -435,6 +437,7 @@ export const usePersonalizationStore = defineStore('personalization', {
show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== 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,
minimal_expand_height_limited: data.minimal_expand_height_limited !== false,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts
index 5f83fd94..2087fd0f 100644
--- a/static/src/stores/task.ts
+++ b/static/src/stores/task.ts
@@ -84,6 +84,7 @@ export const useTaskStore = defineStore('task', {
message_source?: string | null;
goal_mode?: boolean | null;
skill_refs?: Array<{ name?: string; path: string }> | null;
+ files?: string[] | null;
eventHandler?: (event: any) => void;
} = {}
) {
@@ -106,7 +107,9 @@ export const useTaskStore = defineStore('task', {
typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined,
message_source: options.message_source ?? 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
})
});
diff --git a/static/src/stores/ui.ts b/static/src/stores/ui.ts
index f5412d87..dd1c33ee 100644
--- a/static/src/stores/ui.ts
+++ b/static/src/stores/ui.ts
@@ -70,6 +70,8 @@ interface UiState {
isMobileViewport: boolean;
mobileOverlayMenuOpen: boolean;
activeMobileOverlay: MobileOverlayTarget;
+ // 图片大图预览(Lightbox):url 为空表示关闭
+ imagePreview: { url: string; name: string } | null;
}
// 首帧即判定移动端视口:初始值不能等 mounted 里的 matchMedia 监听,
@@ -107,9 +109,18 @@ export const useUiStore = defineStore('ui', {
},
isMobileViewport: initialIsMobileViewport,
mobileOverlayMenuOpen: false,
- activeMobileOverlay: null
+ activeMobileOverlay: null,
+ imagePreview: null
}),
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) {
this.sidebarCollapsed = collapsed;
},
diff --git a/static/src/styles/base/_tokens.scss b/static/src/styles/base/_tokens.scss
index b5db9de8..6df18bb5 100644
--- a/static/src/styles/base/_tokens.scss
+++ b/static/src/styles/base/_tokens.scss
@@ -28,6 +28,21 @@
/* 拖拽预览浮层投影(主题无关,固定黑投影) */
--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 滑块填充与动效 */
--effort-low: #f5c542;
--effort-medium: #3fd07c;