11<script setup lang="ts">
22import { ref , computed , nextTick , watch , onMounted } from ' vue' ;
33import { invoke } from ' @tauri-apps/api/core' ;
4- import { Send , Square , Plus , Wrench , ChevronDown , ChevronRight , CheckCircle2 , XCircle , Loader2 , Brain , Paperclip , X } from ' lucide-vue-next' ;
4+ import { Send , Square , Plus , Wrench , ChevronDown , ChevronRight , CheckCircle2 , XCircle , Loader2 , Brain , Paperclip , X , Image as ImageIcon , File as FileIcon } from ' lucide-vue-next' ;
55import MarkdownIt from ' markdown-it' ;
66import hljs from ' highlight.js' ;
77import ' highlight.js/styles/github-dark.css' ; // 使用 GitHub Dark 风格
@@ -50,6 +50,7 @@ interface Message {
5050 toolCallId? : string ;
5151 rawMeta? : Record <string , unknown >;
5252 fromHistory? : boolean ;
53+ attachments? : FileAttachmentDto [];
5354}
5455
5556const expandedTools = ref <Record <number , boolean >>({});
@@ -98,6 +99,7 @@ const props = defineProps<{
9899 isTyping: boolean ;
99100 themeMode? : string ;
100101 historyPrefs? : HistoryPrefs ;
102+ currentModel? : string ;
101103}>();
102104
103105const emit = defineEmits <{
@@ -112,6 +114,43 @@ const inputRef = ref<HTMLTextAreaElement | null>(null);
112114const fileInputRef = ref <HTMLInputElement | null >(null );
113115const attachments = ref <FileAttachmentDto []>([]);
114116const uploading = ref (false );
117+ const uploadError = ref <string | null >(null );
118+
119+ const visionModels = new Set ([' gpt-4o' , ' gpt-4o-mini' , ' gpt-4.1' , ' gpt-4.1-mini' ]);
120+
121+ const normalizeModelId = (model ? : string ) => {
122+ const trimmed = (model || ' ' ).trim ().toLowerCase ();
123+ const parts = trimmed .split (' /' );
124+ return parts [parts .length - 1 ] || trimmed ;
125+ };
126+
127+ const supportsVisionModel = (model ? : string ) => visionModels .has (normalizeModelId (model ));
128+
129+ const isImageAttachment = (attachment : FileAttachmentDto ) =>
130+ attachment .mime_type ?.toLowerCase ().startsWith (' image/' ) ?? false ;
131+
132+ const hasImageAttachments = computed (() => attachments .value .some (isImageAttachment ));
133+
134+ const showVisionWarning = computed (
135+ () => hasImageAttachments .value && ! supportsVisionModel (props .currentModel )
136+ );
137+
138+ const formatFileSize = (size : number ) => {
139+ if (! Number .isFinite (size ) || size < 0 ) return ' ' ;
140+ if (size < 1024 ) return ` ${size } B ` ;
141+ const kb = size / 1024 ;
142+ if (kb < 1024 ) return ` ${kb .toFixed (kb >= 10 ? 0 : 1 )} KB ` ;
143+ const mb = kb / 1024 ;
144+ return ` ${mb .toFixed (mb >= 10 ? 0 : 1 )} MB ` ;
145+ };
146+
147+ const attachmentLabel = (attachment : FileAttachmentDto ) =>
148+ isImageAttachment (attachment ) ? ' 图片' : ' 文件' ;
149+
150+ const attachmentTypeText = (attachment : FileAttachmentDto ) => {
151+ const size = formatFileSize (attachment .size );
152+ return [attachmentLabel (attachment ), size ].filter (Boolean ).join (' · ' );
153+ };
115154
116155const effectiveHistoryPrefs = computed <HistoryPrefs >(() => ({
117156 ... defaultHistoryPrefs ,
@@ -168,6 +207,7 @@ const handleFileSelect = async (event: Event) => {
168207 if (! files || files .length === 0 ) return ;
169208
170209 uploading .value = true ;
210+ uploadError .value = null ;
171211 try {
172212 for (const file of files ) {
173213 const bytes = await file .arrayBuffer ();
@@ -177,6 +217,7 @@ const handleFileSelect = async (event: Event) => {
177217 }
178218 } catch (error ) {
179219 console .error (' Failed to upload file:' , error );
220+ uploadError .value = ` 文件上传失败:${error instanceof Error ? error .message : String (error )} ` ;
180221 } finally {
181222 uploading .value = false ;
182223 if (fileInputRef .value ) {
@@ -187,6 +228,9 @@ const handleFileSelect = async (event: Event) => {
187228
188229const handleRemoveAttachment = (index : number ) => {
189230 attachments .value .splice (index , 1 );
231+ if (attachments .value .length === 0 ) {
232+ uploadError .value = null ;
233+ }
190234};
191235
192236const handleSend = () => {
@@ -199,6 +243,7 @@ const handleSend = () => {
199243 emit (' send' , message , currentAttachments );
200244 input .value = ' ' ;
201245 attachments .value = [];
246+ uploadError .value = null ;
202247};
203248
204249const handleClear = async () => {
@@ -433,6 +478,23 @@ const getEmotionEmoji = (emotion?: string) => {
433478 <div class =" w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style =" animation-delay : 0.2s " />
434479 </div >
435480 <div v-else class =" markdown-body" v-html =" md.render(msg.content)" ></div >
481+ <div
482+ v-if =" msg.attachments && msg.attachments.length > 0"
483+ class =" mt-2 flex flex-wrap gap-1.5"
484+ >
485+ <div
486+ v-for =" attachment in msg.attachments"
487+ :key =" attachment.file_id"
488+ class =" flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
489+ :class =" isImageAttachment(attachment) ? 'border-pink-200 bg-pink-50/80 text-pink-700' : 'border-gray-200 bg-white/70 text-gray-600'"
490+ :title =" `${attachment.filename} (${attachmentTypeText(attachment)})`"
491+ >
492+ <ImageIcon v-if =" isImageAttachment (attachment )" :size =" 13 " class="shrink-0" />
493+ <FileIcon v-else :size =" 13 " class="shrink-0" />
494+ <span class =" max-w-[160px] truncate font-medium" >{{ attachment.filename }}</span >
495+ <span class =" shrink-0 opacity-70" >{{ formatFileSize(attachment.size) }}</span >
496+ </div >
497+ </div >
436498 </div >
437499
438500 <!-- Timestamp -->
@@ -466,18 +528,35 @@ const getEmotionEmoji = (emotion?: string) => {
466528 <div
467529 v-for =" (attachment, index) in attachments"
468530 :key =" attachment.file_id"
469- class =" flex items-center gap-1 bg-gray-100 rounded-lg px-2 py-1 text-xs"
531+ class =" flex max-w-full items-center gap-1.5 rounded-lg border px-2 py-1 text-xs"
532+ :class =" isImageAttachment(attachment) ? 'border-pink-200 bg-pink-50 text-pink-700' : 'border-gray-200 bg-gray-100 text-gray-700'"
533+ :title =" `${attachment.filename} (${attachmentTypeText(attachment)})`"
470534 >
471- <Paperclip :size =" 12 " class="text-gray-500" />
472- <span class =" text-gray-700 truncate max-w-[100px]" >{{ attachment.filename }}</span >
535+ <ImageIcon v-if =" isImageAttachment (attachment )" :size =" 12 " class="shrink-0" />
536+ <Paperclip v-else :size =" 12 " class="shrink-0 text-gray-500" />
537+ <span class =" truncate max-w-[120px] font-medium" >{{ attachment.filename }}</span >
538+ <span class =" shrink-0 opacity-70" >{{ formatFileSize(attachment.size) }}</span >
473539 <button
474540 @click =" handleRemoveAttachment(index)"
475541 class =" text-gray-400 hover:text-red-500"
542+ :title =" '移除附件'"
476543 >
477544 <X :size =" 12 " />
478545 </button >
479546 </div >
480547 </div >
548+ <div
549+ v-if =" showVisionWarning"
550+ class =" mb-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"
551+ >
552+ 当前模型可能无法识别图片,请切换到 gpt-4o / gpt-4.1 系列 vision 模型,或发送文字描述。
553+ </div >
554+ <div
555+ v-if =" uploadError"
556+ class =" mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700"
557+ >
558+ {{ uploadError }}
559+ </div >
481560 <div class =" flex items-center space-x-3 bg-white rounded-xl border border-gray-200 px-2 py-2 shadow-sm focus-within:ring-2 focus-within:ring-pink-500/20 focus-within:border-pink-500 transition-all" >
482561 <button
483562 @click =" handleClear"
0 commit comments