{
debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
+ const meta = message.metadata || {};
+ if (message.role === 'user' && meta.system_injected_image) {
+ debugLog('跳过系统代发的图片消息(仅用于模型查看,不在前端展示)');
+ return;
+ }
if (message.role === 'user') {
// 用户消息 - 先结束之前的assistant消息
@@ -1768,10 +1807,14 @@ const appOptions = {
this.messages.push(currentAssistantMessage);
currentAssistantMessage = null;
}
-
+ const images = message.images || (message.metadata && message.metadata.images) || [];
+ if (Array.isArray(images) && images.length) {
+ historyHasImages = true;
+ }
this.messages.push({
role: 'user',
- content: message.content || ''
+ content: message.content || '',
+ images
});
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
@@ -1967,6 +2010,8 @@ const appOptions = {
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
this.messages.push(currentAssistantMessage);
}
+
+ this.conversationHasImages = historyHasImages;
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
this.logMessageState('renderHistoryMessages:after-render');
@@ -2257,7 +2302,12 @@ const appOptions = {
return;
}
- if (!this.inputMessage.trim()) {
+ const text = (this.inputMessage || '').trim();
+ const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : [];
+ const hasText = text.length > 0;
+ const hasImages = images.length > 0;
+
+ if (!hasText && !hasImages) {
return;
}
@@ -2266,12 +2316,22 @@ const appOptions = {
this.showQuotaToast({ type: quotaType });
return;
}
-
- const message = this.inputMessage;
-
- if (message.startsWith('/')) {
+
+ if (hasImages && this.currentModelKey !== 'qwen3-vl-plus') {
+ this.uiPushToast({
+ title: '当前模型不支持图片',
+ message: '请切换到 Qwen-VL 再发送图片',
+ type: 'error'
+ });
+ return;
+ }
+
+ const message = text;
+ const isCommand = hasText && !hasImages && message.startsWith('/');
+ if (isCommand) {
this.socket.emit('send_command', { command: message });
this.inputClearMessage();
+ this.inputClearSelectedImages();
this.autoResizeInput();
return;
}
@@ -2288,14 +2348,19 @@ const appOptions = {
// 标记任务进行中,直到任务完成或用户手动停止
this.taskInProgress = true;
- this.chatAddUserMessage(message);
- this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId });
+ this.chatAddUserMessage(message, images);
+ this.socket.emit('send_message', { message: message, images, conversation_id: this.currentConversationId });
if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply();
}
this.inputClearMessage();
+ this.inputClearSelectedImages();
+ this.inputSetImagePickerOpen(false);
this.inputSetLineCount(1);
this.inputSetMultiline(false);
+ if (hasImages) {
+ this.conversationHasImages = true;
+ }
if (this.autoScrollEnabled) {
this.scrollToBottom();
}
@@ -2447,6 +2512,7 @@ const appOptions = {
return;
}
this.modeMenuOpen = false;
+ this.modelMenuOpen = false;
const nextState = this.inputToggleToolMenu();
if (nextState) {
this.inputSetSettingsOpen(false);
@@ -2466,12 +2532,117 @@ const appOptions = {
const opened = this.inputToggleQuickMenu();
if (!opened) {
this.modeMenuOpen = false;
+ this.modelMenuOpen = false;
}
},
closeQuickMenu() {
this.inputCloseMenus();
this.modeMenuOpen = false;
+ this.modelMenuOpen = false;
+ },
+
+ async openImagePicker() {
+ if (this.currentModelKey !== 'qwen3-vl-plus') {
+ this.uiPushToast({
+ title: '当前模型不支持图片',
+ message: '请选择 Qwen-VL 后再发送图片',
+ type: 'error'
+ });
+ return;
+ }
+ this.closeQuickMenu();
+ this.inputSetImagePickerOpen(true);
+ await this.loadWorkspaceImages();
+ },
+
+ closeImagePicker() {
+ this.inputSetImagePickerOpen(false);
+ },
+
+ async loadWorkspaceImages() {
+ this.imageLoading = true;
+ try {
+ const entries = await this.fetchAllImageEntries('');
+ this.imageEntries = entries;
+ if (!entries.length) {
+ this.uiPushToast({
+ title: '未找到图片',
+ message: '工作区内没有可用的图片文件',
+ type: 'info'
+ });
+ }
+ } catch (error) {
+ console.error('加载图片列表失败', error);
+ this.uiPushToast({
+ title: '加载图片失败',
+ message: error?.message || '请稍后重试',
+ type: 'error'
+ });
+ } finally {
+ this.imageLoading = false;
+ }
+ },
+
+ async fetchAllImageEntries(startPath = '') {
+ const queue: string[] = [startPath || ''];
+ const visited = new Set
();
+ const results: Array<{ name: string; path: string }> = [];
+ const exts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg']);
+ const maxFolders = 120;
+
+ while (queue.length && visited.size < maxFolders) {
+ const path = queue.shift() || '';
+ if (visited.has(path)) {
+ continue;
+ }
+ visited.add(path);
+ try {
+ const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, {
+ method: 'GET',
+ credentials: 'include',
+ headers: { Accept: 'application/json' }
+ });
+ const data = await resp.json().catch(() => null);
+ if (!data?.success) {
+ continue;
+ }
+ const items = Array.isArray(data?.data?.items) ? data.data.items : [];
+ for (const item of items) {
+ const rawPath =
+ item?.path ||
+ [path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
+ const type = String(item?.type || '').toLowerCase();
+ if (type === 'directory' || type === 'folder') {
+ queue.push(rawPath);
+ continue;
+ }
+ const ext =
+ String(item?.extension || '').toLowerCase() ||
+ (rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : '');
+ if (exts.has(ext)) {
+ results.push({
+ name: item?.name || rawPath.split('/').pop() || rawPath,
+ path: rawPath
+ });
+ if (results.length >= 400) {
+ return results;
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('遍历文件夹失败', path, error);
+ }
+ }
+ return results;
+ },
+
+ handleImagesConfirmed(list) {
+ this.inputSetSelectedImages(Array.isArray(list) ? list : []);
+ this.inputSetImagePickerOpen(false);
+ },
+ handleRemoveImage(path) {
+ this.inputRemoveSelectedImage(path);
},
handleQuickUpload() {
@@ -2488,6 +2659,25 @@ const appOptions = {
const next = !this.modeMenuOpen;
this.modeMenuOpen = next;
if (next) {
+ this.modelMenuOpen = false;
+ }
+ if (next) {
+ this.inputSetToolMenuOpen(false);
+ this.inputSetSettingsOpen(false);
+ if (!this.quickMenuOpen) {
+ this.inputOpenQuickMenu();
+ }
+ }
+ },
+
+ toggleModelMenu() {
+ if (!this.isConnected || this.streamingMessage) {
+ return;
+ }
+ const next = !this.modelMenuOpen;
+ this.modelMenuOpen = next;
+ if (next) {
+ this.modeMenuOpen = false;
this.inputSetToolMenuOpen(false);
this.inputSetSettingsOpen(false);
if (!this.quickMenuOpen) {
@@ -2503,6 +2693,56 @@ const appOptions = {
await this.setRunMode(mode);
},
+ async handleModelSelect(key) {
+ if (!this.isConnected || this.streamingMessage) {
+ return;
+ }
+ if (this.conversationHasImages && key !== 'qwen3-vl-plus') {
+ this.uiPushToast({
+ title: '切换失败',
+ message: '当前对话包含图片,仅支持 Qwen-VL',
+ type: 'error'
+ });
+ return;
+ }
+ const modelStore = useModelStore();
+ const prev = this.currentModelKey;
+ try {
+ const resp = await fetch('/api/model', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model_key: key })
+ });
+ const payload = await resp.json();
+ if (!resp.ok || !payload.success) {
+ throw new Error(payload.error || payload.message || '切换失败');
+ }
+ const data = payload.data || {};
+ modelStore.setModel(data.model_key || key);
+ if (data.run_mode) {
+ this.runMode = data.run_mode;
+ this.thinkingMode = data.thinking_mode ?? (data.run_mode !== 'fast');
+ }
+ this.uiPushToast({
+ title: '模型已切换',
+ message: modelStore.currentModel?.label || key,
+ type: 'success'
+ });
+ } catch (error) {
+ modelStore.setModel(prev);
+ const msg = error instanceof Error ? error.message : String(error || '切换失败');
+ this.uiPushToast({
+ title: '切换模型失败',
+ message: msg,
+ type: 'error'
+ });
+ } finally {
+ this.modelMenuOpen = false;
+ this.inputCloseMenus();
+ this.inputSetQuickMenuOpen(false);
+ }
+ },
+
async handleCycleRunMode() {
const modes: Array<'fast' | 'thinking' | 'deep'> = ['fast', 'thinking', 'deep'];
const currentMode = this.resolvedRunMode;
@@ -2511,11 +2751,39 @@ const appOptions = {
await this.setRunMode(nextMode);
},
- async setRunMode(mode) {
+ async setRunMode(mode, options = {}) {
if (!this.isConnected || this.streamingMessage) {
this.modeMenuOpen = false;
return;
}
+ const modelStore = useModelStore();
+ const fastOnly = modelStore.currentModel?.fastOnly;
+ const currentModelKey = modelStore.currentModel?.key;
+ if (fastOnly && mode !== 'fast') {
+ if (!options.suppressToast) {
+ this.uiPushToast({
+ title: '模式不可用',
+ message: 'Qwen-Max只支持快速模式',
+ type: 'warning'
+ });
+ }
+ this.modeMenuOpen = false;
+ this.inputCloseMenus();
+ return;
+ }
+ // Qwen-VL 不支持深度思考模式
+ if (currentModelKey === 'qwen3-vl-plus' && mode === 'deep') {
+ if (!options.suppressToast) {
+ this.uiPushToast({
+ title: '模式不可用',
+ message: 'Qwen-VL 不支持深度思考模式,请使用快速或思考模式',
+ type: 'warning'
+ });
+ }
+ this.modeMenuOpen = false;
+ this.inputCloseMenus();
+ return;
+ }
if (mode === this.resolvedRunMode) {
this.modeMenuOpen = false;
this.closeQuickMenu();
@@ -2774,6 +3042,7 @@ const appOptions = {
return;
}
this.modeMenuOpen = false;
+ this.modelMenuOpen = false;
const nextState = this.inputToggleSettingsMenu();
if (nextState) {
this.inputSetToolMenuOpen(false);
@@ -2961,7 +3230,8 @@ const appOptions = {
LiquidGlassWidget,
QuickMenu,
InputComposer,
- AppShell
+ AppShell,
+ ImagePicker
};
export default appOptions;
diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue
index af9dd1f..256e1d4 100644
--- a/static/src/components/chat/ChatArea.vue
+++ b/static/src/components/chat/ChatArea.vue
@@ -1,13 +1,18 @@
-
+
-
{{ msg.content }}
+
+
{{ msg.content }}
+
+ {{ formatImageName(img) }}
+
+
$emit('select-run-mode', mode)"
+ @toggle-model-menu="$emit('toggle-model-menu')"
+ @select-model="(key) => $emit('select-model', key)"
@update-tool-category="(id, enabled) => $emit('update-tool-category', id, enabled)"
@realtime-terminal="$emit('realtime-terminal')"
@toggle-focus-panel="$emit('toggle-focus-panel')"
@@ -92,16 +108,20 @@ const emit = defineEmits([
'send-message',
'send-or-stop',
'quick-upload',
+ 'pick-images',
'toggle-tool-menu',
'toggle-mode-menu',
+ 'toggle-model-menu',
'select-run-mode',
+ 'select-model',
'toggle-settings',
'update-tool-category',
'realtime-terminal',
'toggle-focus-panel',
'toggle-token-panel',
'compress-conversation',
- 'file-selected'
+ 'file-selected',
+ 'remove-image'
]);
const props = defineProps<{
@@ -117,6 +137,7 @@ const props = defineProps<{
quickMenuOpen: boolean;
toolMenuOpen: boolean;
modeMenuOpen: boolean;
+ modelMenuOpen: boolean;
toolSettings: Array<{ id: string; label: string; enabled: boolean }>;
toolSettingsLoading: boolean;
settingsOpen: boolean;
@@ -124,6 +145,9 @@ const props = defineProps<{
currentConversationId: string | null;
iconStyle: (key: string) => Record;
toolCategoryIcon: (categoryId: string) => string;
+ modelOptions: Array<{ key: string; label: string; description: string }>;
+ currentModelKey: string;
+ selectedImages?: string[];
}>();
const inputStore = useInputStore();
@@ -132,6 +156,12 @@ const compactInputShell = ref(null);
const stadiumInput = ref(null);
const fileUploadInput = ref(null);
+const formatImageName = (path: string): string => {
+ if (!path) return '';
+ const parts = path.split(/[/\\]/);
+ return parts[parts.length - 1] || path;
+};
+
const applyLineMetrics = (lines: number, multiline: boolean) => {
inputStore.setInputLineCount(lines);
inputStore.setInputMultiline(multiline);
@@ -204,3 +234,18 @@ onMounted(() => {
adjustTextareaSize();
});
+
+
diff --git a/static/src/components/input/QuickMenu.vue b/static/src/components/input/QuickMenu.vue
index 3d34ba2..2608895 100644
--- a/static/src/components/input/QuickMenu.vue
+++ b/static/src/components/input/QuickMenu.vue
@@ -4,6 +4,15 @@
+
+