Browse Source

feat:添加附件图片、视频媒介,进行AI对话

zhangwl 1 month ago
parent
commit
5e40b2fcd6

+ 433 - 144
chat-ai-ui-main/src/components/ChatInput.vue

@@ -1,5 +1,16 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref } from 'vue';
+import { ref, computed, onMounted } from 'vue';
+import axios from 'axios';
+import { useUserStore } from '../stores/user';
+
+interface FileAttachment {
+  file_id: string;
+  filename: string;
+  media_type: string;
+  size: number;
+  preview_url?: string;
+  uploading?: boolean;
+}
 
 
 const props = defineProps<{
 const props = defineProps<{
   isLoading?: boolean;
   isLoading?: boolean;
@@ -8,36 +19,314 @@ const props = defineProps<{
 const message = ref('');
 const message = ref('');
 const stream = ref(true);
 const stream = ref(true);
 const emit = defineEmits(['send']);
 const emit = defineEmits(['send']);
+const userStore = useUserStore();
+
+// ============ 附件相关 ============
+const attachments = ref<FileAttachment[]>([]);
+const fileInput = ref<HTMLInputElement | null>(null);
+const showMediaMenu = ref(false);
+
+// 文件类型配置(已移除音频)
+const MEDIA_TYPES = {
+  image: { accept: '.jpg,.jpeg,.png,.gif,.webp,.bmp', label: '📷 图片', maxSize: 20 },
+  video: { accept: '.mp4,.mov,.avi,.mkv,.webm', label: '🎬 视频', maxSize: 100 },
+};
+
+let currentAccept = '';
+
+const selectMediaType = (type: 'image' | 'video') => {
+  showMediaMenu.value = false;
+  currentAccept = MEDIA_TYPES[type].accept;
+  if (fileInput.value) {
+    fileInput.value.accept = currentAccept;
+    fileInput.value.click();
+  }
+};
+
+const handleFileSelect = async (event: Event) => {
+  const input = event.target as HTMLInputElement;
+  if (!input.files?.length) return;
+
+  for (const file of Array.from(input.files)) {
+    await uploadFile(file);
+  }
+  input.value = '';
+};
+
+const uploadFile = async (file: File) => {
+  let previewUrl: string | undefined;
+  if (file.type.startsWith('image/')) {
+    previewUrl = URL.createObjectURL(file);
+  }
+
+  const placeholder: FileAttachment = {
+    file_id: '',
+    filename: file.name,
+    media_type: detectMediaType(file.name),
+    size: file.size,
+    preview_url: previewUrl,
+    uploading: true,
+  };
+  attachments.value.push(placeholder);
+  const idx = attachments.value.length - 1;
+
+  try {
+    const formData = new FormData();
+    formData.append('file', file);
 
 
-// 可用的模型选项
-// const models = [
-//   { value: "qwen-turbo-2025-04-28", label: "Qwen Turbo" },
-//   { value: "qwen-plus-2025-07-14", label: "Qwen Plus" },
-//   { value: "qwen-flash", label: "Qwen Flash" }
-// ];
+    const { data } = await axios.post(
+      `${import.meta.env.VITE_API_URL}/chat/upload`,
+      formData,
+      {
+        headers: {
+          'Authorization': `Bearer ${userStore.userId}`,
+          'Content-Type': 'multipart/form-data',
+        },
+      }
+    );
+
+    attachments.value[idx] = {
+      file_id: data.file_id,
+      filename: data.filename,
+      media_type: data.media_type,
+      size: data.size,
+      preview_url: previewUrl,
+      uploading: false,
+    };
+  } catch (error: any) {
+    console.error('文件上传失败:', error);
+    attachments.value.splice(idx, 1);
+    alert(error.response?.data?.detail || '文件上传失败');
+  }
+};
+
+const detectMediaType = (filename: string): string => {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
+  if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(ext)) return 'video';
+  return 'image';
+};
+
+const removeAttachment = (index: number) => {
+  const att = attachments.value[index];
+  if (att.preview_url) {
+    URL.revokeObjectURL(att.preview_url);
+  }
+  attachments.value.splice(index, 1);
+};
+
+const hasUploadingFiles = () => attachments.value.some(a => a.uploading);
 
 
 const sendMessage = () => {
 const sendMessage = () => {
-  if (!message.value.trim() || props.isLoading) return;
-  emit('send', message.value, stream.value);
+  if ((!message.value.trim() && attachments.value.length === 0) || props.isLoading || hasUploadingFiles()) return;
+  const files = attachments.value.map(({ file_id, filename, media_type, size }) => ({ file_id, filename, media_type, size }));
+  emit('send', message.value, stream.value, files.length > 0 ? files : undefined);
   message.value = '';
   message.value = '';
+  attachments.value = [];
+};
+
+// ============ 语音录音相关 ============
+const voiceSupported = ref(true);
+const isVoiceMode = ref(false);
+const isRecording = ref(false);
+const isCancelling = ref(false);
+const recordingDuration = ref(0);
+const isTranscribing = ref(false);
+
+let mediaRecorder: MediaRecorder | null = null;
+let audioChunks: Blob[] = [];
+let recordingTimer: ReturnType<typeof setInterval> | null = null;
+let startY = 0;
+const CANCEL_THRESHOLD = 80; // 上移取消阈值(px)
+const MAX_DURATION = 60;     // 最大录音时长(秒)
+const MIN_DURATION = 1;      // 最短录音时长(秒)
+
+onMounted(() => {
+  // 检测浏览器是否支持录音
+  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+    voiceSupported.value = false;
+    return;
+  }
+  if (typeof MediaRecorder === 'undefined') {
+    voiceSupported.value = false;
+    return;
+  }
+  // 检查 webm/opus 支持
+  if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
+    // 尝试回退到 audio/webm
+    if (!MediaRecorder.isTypeSupported('audio/webm')) {
+      voiceSupported.value = false;
+    }
+  }
+});
+
+const toggleVoiceMode = () => {
+  if (isRecording.value || isTranscribing.value) return;
+  isVoiceMode.value = !isVoiceMode.value;
+};
+
+const recordingAreaClasses = computed(() => {
+  if (isTranscribing.value) return 'bg-blue-900/50 border-blue-500';
+  if (isCancelling.value) return 'bg-red-900/50 border-red-500';
+  if (isRecording.value) return 'bg-green-900/50 border-green-500 recording-pulse';
+  return 'bg-gray-700 border-gray-600';
+});
+
+const formatDuration = (seconds: number): string => {
+  const m = Math.floor(seconds / 60).toString().padStart(2, '0');
+  const s = (seconds % 60).toString().padStart(2, '0');
+  return `${m}:${s}`;
+};
+
+const startRecording = async (event: MouseEvent | TouchEvent) => {
+  if (isTranscribing.value || props.isLoading) return;
+
+  startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
+  isCancelling.value = false;
+  audioChunks = [];
+  recordingDuration.value = 0;
+
+  try {
+    const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+    const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+      ? 'audio/webm;codecs=opus'
+      : 'audio/webm';
+
+    mediaRecorder = new MediaRecorder(audioStream, { mimeType });
+
+    mediaRecorder.ondataavailable = (e) => {
+      if (e.data.size > 0) audioChunks.push(e.data);
+    };
+
+    mediaRecorder.start(100); // 每 100ms 生成一个 chunk
+    isRecording.value = true;
+
+    recordingTimer = setInterval(() => {
+      recordingDuration.value++;
+      if (recordingDuration.value >= MAX_DURATION) {
+        stopRecording();
+      }
+    }, 1000);
+  } catch (err: any) {
+    console.error('无法获取麦克风权限:', err);
+    alert('请允许麦克风权限以使用语音功能');
+  }
+};
+
+const stopRecording = async () => {
+  if (!isRecording.value || !mediaRecorder) return;
+
+  if (isCancelling.value) {
+    cancelRecording();
+    return;
+  }
+
+  // 停止计时器
+  if (recordingTimer) {
+    clearInterval(recordingTimer);
+    recordingTimer = null;
+  }
+
+  // 检查录音时长
+  const duration = recordingDuration.value;
+
+  // 停止录音
+  mediaRecorder.stop();
+  mediaRecorder.stream.getTracks().forEach(track => track.stop());
+  isRecording.value = false;
+
+  // 等待最后的 dataavailable
+  await new Promise<void>(resolve => {
+    if (mediaRecorder) {
+      mediaRecorder.onstop = () => resolve();
+    } else {
+      resolve();
+    }
+  });
+
+  if (duration < MIN_DURATION) {
+    alert('录音太短,请至少录制 1 秒');
+    audioChunks = [];
+    return;
+  }
+
+  if (audioChunks.length === 0) return;
+
+  const audioBlob = new Blob(audioChunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
+  audioChunks = [];
+  await sendVoice(audioBlob);
+};
+
+const handleMouseMove = (event: MouseEvent) => {
+  if (!isRecording.value) return;
+  const deltaY = startY - event.clientY;
+  isCancelling.value = deltaY > CANCEL_THRESHOLD;
+};
+
+const handleTouchMove = (event: TouchEvent) => {
+  if (!isRecording.value) return;
+  const deltaY = startY - event.touches[0].clientY;
+  isCancelling.value = deltaY > CANCEL_THRESHOLD;
+};
+
+const cancelRecording = () => {
+  if (!mediaRecorder) return;
+  if (recordingTimer) {
+    clearInterval(recordingTimer);
+    recordingTimer = null;
+  }
+  mediaRecorder.stop();
+  mediaRecorder.stream.getTracks().forEach(track => track.stop());
+  isRecording.value = false;
+  isCancelling.value = false;
+  audioChunks = [];
+};
+
+const handleMouseLeave = () => {
+  // 鼠标离开区域时如果正在录音且已标记取消,则取消
+  if (isRecording.value && isCancelling.value) {
+    cancelRecording();
+  }
+};
+
+const sendVoice = async (audioBlob: Blob) => {
+  isTranscribing.value = true;
+
+  try {
+    const formData = new FormData();
+    formData.append('file', audioBlob, 'voice.webm');
+
+    const { data } = await axios.post(
+      `${import.meta.env.VITE_API_URL}/chat/voice`,
+      formData,
+      {
+        headers: {
+          'Authorization': `Bearer ${userStore.userId}`,
+          'Content-Type': 'multipart/form-data',
+        },
+        timeout: 30000,
+      }
+    );
+
+    if (data.text && data.text.trim()) {
+      emit('send', data.text, stream.value, undefined);
+    } else {
+      alert('未能识别语音内容,请重试');
+    }
+  } catch (error: any) {
+    console.error('语音转写失败:', error);
+    alert(error.response?.data?.detail || '语音转写失败,请重试');
+  } finally {
+    isTranscribing.value = false;
+  }
 };
 };
 </script>
 </script>
-<!-- bg-gray-800 -->
+
 <template>
 <template>
   <div class="p-2 sm:p-4 space-y-2 sm:space-y-4">
   <div class="p-2 sm:p-4 space-y-2 sm:space-y-4">
-    <!-- 第一行:流式输出、状态指示器、模型选择 -->
+    <!-- 第一行:状态指示器 -->
     <div class="flex items-center space-x-3 sm:space-x-6">
     <div class="flex items-center space-x-3 sm:space-x-6">
-      <!-- 流式输出开关 -->
-<!--      <label class="flex items-center space-x-2 text-white cursor-pointer">-->
-<!--        <input-->
-<!--          v-model="stream"-->
-<!--          type="checkbox"-->
-<!--          class="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"-->
-<!--        />-->
-<!--        <span class="text-sm">流式输出</span>-->
-<!--      </label>-->
-
-      <!-- 状态指示器 -->
       <div class="flex items-center space-x-1">
       <div class="flex items-center space-x-1">
         <div
         <div
           :class="[
           :class="[
@@ -54,101 +343,140 @@ const sendMessage = () => {
           {{ stream ? '流式模式' : '完整模式' }}
           {{ stream ? '流式模式' : '完整模式' }}
         </span>
         </span>
       </div>
       </div>
-
-      <!-- 模型选择下拉框 -->
-<!--      <div class="flex items-center space-x-2">
-        <label class="text-sm text-gray-300">模型:</label>
-        <select
-          v-model="model"
-          class="bg-gray-700 text-white text-sm rounded-lg border border-gray-600 px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[140px]"
-        >
-          <option
-            v-for="model in models"
-            :key="model.value"
-            :value="model.value"
-            class="bg-gray-700"
-          >
-            {{ model.label }}
-          </option>
-        </select>
-      </div>
-
-      &lt;!&ndash; 模型显示 &ndash;&gt;
-      <div class="flex items-center space-x-1 text-xs text-gray-400">
-        <span>当前:</span>
-        <span class="text-blue-400 font-medium">
-          {{ models.find(m => m.value === model)?.label }}
-        </span>
-      </div>-->
     </div>
     </div>
 
 
-    <!-- 第二行:温度和Token控制滑块 -->
-<!--    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
-      &lt;!&ndash; 温度控制 &ndash;&gt;
-      <div class="space-y-2">
-        <div class="flex items-center justify-between">
-          <label class="text-sm text-gray-300">温度 (Temperature)</label>
-          <span class="text-xs text-blue-400 font-mono bg-gray-700 px-2 py-1 rounded">
-            {{ temperature.toFixed(1) }}
-          </span>
+    <!-- 附件预览区 -->
+    <div v-if="attachments.length > 0" class="flex flex-wrap gap-2 px-1">
+      <div
+        v-for="(att, idx) in attachments"
+        :key="idx"
+        class="relative group rounded-lg border border-gray-600 bg-gray-700/50 p-1.5 flex items-center gap-2"
+      >
+        <img
+          v-if="att.media_type === 'image' && att.preview_url"
+          :src="att.preview_url"
+          class="w-10 h-10 rounded object-cover"
+          :alt="att.filename"
+        />
+        <div v-else class="w-10 h-10 rounded bg-gray-600 flex items-center justify-center text-lg">
+          🎬
         </div>
         </div>
-        <div class="relative">
-          <input
-            v-model.number="temperature"
-            type="range"
-            min="0"
-            max="2"
-            step="0.1"
-            class="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer slider-thumb"
-          />
-          <div class="flex justify-between text-xs text-gray-500 mt-1">
-            <span>保守 (0.0)</span>
-            <span>平衡 (1.0)</span>
-            <span>创造 (2.0)</span>
-          </div>
+        <div class="flex flex-col max-w-[100px]">
+          <span class="text-xs text-gray-300 truncate">{{ att.filename }}</span>
+          <span v-if="att.uploading" class="text-xs text-yellow-400">上传中...</span>
+          <span v-else class="text-xs text-green-400">✓</span>
         </div>
         </div>
+        <button
+          @click="removeAttachment(idx)"
+          class="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
+        >
+          ×
+        </button>
       </div>
       </div>
+    </div>
 
 
-      &lt;!&ndash; 最大Token控制 &ndash;&gt;
-      <div class="space-y-2">
-        <div class="flex items-center justify-between">
-          <label class="text-sm text-gray-300">最大Tokens</label>
-          <span class="text-xs text-green-400 font-mono bg-gray-700 px-2 py-1 rounded">
-            {{ maxTokens.toLocaleString() }}
-          </span>
-        </div>
-        <div class="relative">
-          <input
-            v-model.number="maxTokens"
-            type="range"
-            min="100"
-            max="8000"
-            step="100"
-            class="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer slider-thumb"
-          />
-          <div class="flex justify-between text-xs text-gray-500 mt-1">
-            <span>短 (100)</span>
-            <span>中 (4000)</span>
-            <span>长 (8000)</span>
-          </div>
+    <!-- 输入框和发送按钮 -->
+    <div class="flex space-x-2">
+      <!-- 附件按钮 -->
+      <div class="relative">
+        <button
+          @click="showMediaMenu = !showMediaMenu"
+          :disabled="isLoading || isVoiceMode"
+          class="p-2 sm:p-3 rounded-lg bg-gray-700 border border-gray-600 text-gray-300 hover:text-white hover:bg-gray-600 transition-colors flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
+          title="添加附件"
+        >
+          <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
+          </svg>
+        </button>
+        <!-- 媒体类型菜单 -->
+        <div
+          v-if="showMediaMenu"
+          class="absolute bottom-full left-0 mb-2 bg-gray-800 border border-gray-600 rounded-lg shadow-xl py-1 min-w-[120px] z-50"
+        >
+          <button
+            v-for="(cfg, type) in MEDIA_TYPES"
+            :key="type"
+            @click="selectMediaType(type as 'image' | 'video')"
+            class="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
+          >
+            {{ cfg.label }}
+          </button>
         </div>
         </div>
       </div>
       </div>
-    </div>-->
 
 
-    <!-- 输入框和发送按钮 -->
-    <div class="flex space-x-2">
+      <!-- 隐藏的文件输入框 -->
+      <input
+        ref="fileInput"
+        type="file"
+        class="hidden"
+        @change="handleFileSelect"
+      />
+
+      <!-- 文字输入模式 -->
       <input
       <input
+        v-if="!isVoiceMode"
         v-model="message"
         v-model="message"
         placeholder="输入你的消息..."
         placeholder="输入你的消息..."
         type="text"
         type="text"
         class="flex-1 p-2 sm:p-3 rounded-lg bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 border border-gray-600 text-sm sm:text-base"
         class="flex-1 p-2 sm:p-3 rounded-lg bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 border border-gray-600 text-sm sm:text-base"
+        @keydown.enter.prevent="sendMessage"
       />
       />
+
+      <!-- 录音模式 -->
+      <div
+        v-else
+        class="flex-1 p-2 sm:p-3 rounded-lg border select-none flex items-center justify-center cursor-pointer transition-all duration-200 text-sm sm:text-base"
+        :class="recordingAreaClasses"
+        @mousedown.prevent="startRecording"
+        @mouseup.prevent="stopRecording"
+        @mousemove="handleMouseMove"
+        @mouseleave="handleMouseLeave"
+        @touchstart.prevent="startRecording"
+        @touchend.prevent="stopRecording"
+        @touchmove.prevent="handleTouchMove"
+      >
+        <span v-if="!isRecording && !isTranscribing" class="text-gray-400">按住说话</span>
+        <span v-else-if="isTranscribing" class="text-blue-400 flex items-center gap-2">
+          <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
+          </svg>
+          转写中...
+        </span>
+        <span v-else-if="isCancelling" class="text-red-400">↑ 松开取消</span>
+        <span v-else class="text-green-400">
+          🎙️ {{ formatDuration(recordingDuration) }} · 松开发送
+        </span>
+      </div>
+
+      <!-- 麦克风按钮 -->
+      <button
+        v-if="voiceSupported"
+        @click="toggleVoiceMode"
+        :disabled="isTranscribing"
+        :class="[
+          'p-2 sm:p-3 rounded-lg border transition-colors flex-shrink-0',
+          isVoiceMode
+            ? 'bg-green-600 border-green-500 text-white'
+            : 'bg-gray-700 border-gray-600 text-gray-300 hover:text-white hover:bg-gray-600'
+        ]"
+        :title="isVoiceMode ? '切换文字输入' : '语音输入'"
+      >
+        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+            d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4M12 15a3 3 0 003-3V5a3 3 0 00-6 0v7a3 3 0 003 3z" />
+        </svg>
+      </button>
+
+      <!-- 发送按钮(仅文字模式显示) -->
       <button
       <button
+        v-if="!isVoiceMode"
         @click="sendMessage"
         @click="sendMessage"
-        :disabled="!message.trim() || isLoading"
+        :disabled="(!message.trim() && attachments.length === 0) || isLoading || hasUploadingFiles()"
         :class="[
         :class="[
           'px-3 sm:px-6 py-2 sm:py-3 rounded-lg font-medium transition-all duration-200 flex-shrink-0',
           'px-3 sm:px-6 py-2 sm:py-3 rounded-lg font-medium transition-all duration-200 flex-shrink-0',
-          !message.trim() || isLoading
+          (!message.trim() && attachments.length === 0) || isLoading || hasUploadingFiles()
             ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
             ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
             : 'bg-blue-500 hover:bg-blue-600 text-white cursor-pointer transform hover:scale-105'
             : 'bg-blue-500 hover:bg-blue-600 text-white cursor-pointer transform hover:scale-105'
         ]"
         ]"
@@ -165,52 +493,13 @@ const sendMessage = () => {
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
-/* 自定义滑块样式 */
-.slider-thumb::-webkit-slider-thumb {
-  appearance: none;
-  height: 20px;
-  width: 20px;
-  border-radius: 50%;
-  background: #3b82f6;
-  cursor: pointer;
-  border: 2px solid #1f2937;
-  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
-  transition: all 0.2s ease;
+/* 录音脉冲动画 */
+@keyframes pulse-ring {
+  0% { transform: scale(0.98); opacity: 0.8; }
+  50% { transform: scale(1); opacity: 1; }
+  100% { transform: scale(0.98); opacity: 0.8; }
 }
 }
-
-.slider-thumb::-webkit-slider-thumb:hover {
-  background: #2563eb;
-  transform: scale(1.1);
-  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
-}
-
-.slider-thumb::-moz-range-thumb {
-  height: 20px;
-  width: 20px;
-  border-radius: 50%;
-  background: #3b82f6;
-  cursor: pointer;
-  border: 2px solid #1f2937;
-  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
-  transition: all 0.2s ease;
-}
-
-.slider-thumb::-moz-range-thumb:hover {
-  background: #2563eb;
-  transform: scale(1.1);
-  box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
-}
-
-/* 滑块轨道样式 */
-.slider-thumb::-webkit-slider-runnable-track {
-  height: 8px;
-  background: linear-gradient(to right, #374151 0%, #3b82f6 50%, #374151 100%);
-  border-radius: 4px;
-}
-
-.slider-thumb::-moz-range-track {
-  height: 8px;
-  background: linear-gradient(to right, #374151 0%, #3b82f6 50%, #374151 100%);
-  border-radius: 4px;
+.recording-pulse {
+  animation: pulse-ring 1.5s ease-in-out infinite;
 }
 }
 </style>
 </style>

+ 34 - 7
chat-ai-ui-main/src/stores/chat.ts

@@ -32,6 +32,14 @@ interface FormattedMessage {
   content: string;
   content: string;
   thinking?: string | null;
   thinking?: string | null;
   searching?: string | null;
   searching?: string | null;
+  attachments?: FileAttachment[] | null;
+}
+
+interface FileAttachment {
+  file_id: string;
+  filename: string;
+  media_type: string;
+  size: number;
 }
 }
 
 
 interface Session {
 interface Session {
@@ -50,6 +58,7 @@ interface Message {
   searchingItems: string[];
   searchingItems: string[];
   phase: 'thinking' | 'searching' | 'answer' | 'done';
   phase: 'thinking' | 'searching' | 'answer' | 'done';
   isStreaming: boolean;
   isStreaming: boolean;
+  attachments?: FileAttachment[];
 }
 }
 
 
 const blankMeta = () => ({
 const blankMeta = () => ({
@@ -116,25 +125,38 @@ export const useChatStore = defineStore('chat', () => {
           searchingItems: msg.searching ? [msg.searching] : [],
           searchingItems: msg.searching ? [msg.searching] : [],
           phase: 'done' as const,
           phase: 'done' as const,
           isStreaming: false,
           isStreaming: false,
+          attachments: msg.attachments || undefined,
         }));
         }));
     } catch (error) {
     } catch (error) {
       console.error('Error loading chat history: ', error);
       console.error('Error loading chat history: ', error);
     }
     }
   };
   };
 
 
-  const sendMessage = async (message: string, stream: boolean) => {
-    if (!message.trim() || !userStore.userId || isLoading.value) return;
+  const sendMessage = async (message: string, stream: boolean, files?: FileAttachment[]) => {
+    if (!message.trim() && (!files || files.length === 0)) return;
+    if (!userStore.userId || isLoading.value) return;
 
 
-    messages.value.push({ role: 'user', content: message, displayContent: message, ...blankMeta() });
+    messages.value.push({
+      role: 'user',
+      content: message,
+      displayContent: message,
+      ...blankMeta(),
+      attachments: files,
+    });
     isLoading.value = true;
     isLoading.value = true;
 
 
     try {
     try {
       if (stream) {
       if (stream) {
-        await handleStreamResponse();
+        await handleStreamResponse(files);
       } else {
       } else {
+        const requestMessages = [{
+          role: 'user',
+          content: message,
+          attachments: files,
+        }];
         const { data } = await axios.post(
         const { data } = await axios.post(
           `${import.meta.env.VITE_API_URL}/chat/chat`,
           `${import.meta.env.VITE_API_URL}/chat/chat`,
-          { messages: [{ role: 'user', content: message }], stream: false, sessionId: sessionId.value },
+          { messages: requestMessages, stream: false, sessionId: sessionId.value },
           { headers: { 'Authorization': `Bearer ${userStore.userId}`, 'Content-Type': 'application/json' } }
           { headers: { 'Authorization': `Bearer ${userStore.userId}`, 'Content-Type': 'application/json' } }
         );
         );
         messages.value.push({
         messages.value.push({
@@ -156,8 +178,13 @@ export const useChatStore = defineStore('chat', () => {
     }
     }
   };
   };
 
 
-  const handleStreamResponse = async () => {
-    const historySnapshot = [{ role: 'user', content: messages.value[messages.value.length - 1].content }];
+  const handleStreamResponse = async (files?: FileAttachment[]) => {
+    const lastMsg = messages.value[messages.value.length - 1];
+    const historySnapshot = [{
+      role: 'user',
+      content: lastMsg.content,
+      attachments: files,
+    }];
 
 
     const aiMessageIndex = messages.value.length;
     const aiMessageIndex = messages.value.length;
     messages.value.push({
     messages.value.push({

+ 21 - 0
chat-ai-ui-main/src/views/ChatView.vue

@@ -190,6 +190,27 @@ watch(
               v-if="msg.role === 'user'"
               v-if="msg.role === 'user'"
               class="max-w-xs sm:max-w-sm md:max-w-2xl px-3 sm:px-4 py-2 sm:py-3 rounded-lg shadow-sm prose prose-invert max-w-none relative bg-blue-600 text-white rounded-br-sm prose-headings:text-white prose-p:text-white prose-strong:text-white prose-em:text-white text-sm sm:text-base"
               class="max-w-xs sm:max-w-sm md:max-w-2xl px-3 sm:px-4 py-2 sm:py-3 rounded-lg shadow-sm prose prose-invert max-w-none relative bg-blue-600 text-white rounded-br-sm prose-headings:text-white prose-p:text-white prose-strong:text-white prose-em:text-white text-sm sm:text-base"
             >
             >
+              <!-- 附件展示区 -->
+              <div v-if="msg.attachments && msg.attachments.length > 0" class="mb-2 flex flex-wrap gap-2">
+                <template v-for="(att, attIdx) in msg.attachments" :key="attIdx">
+                  <!-- 图片附件:缩略图 -->
+                  <div v-if="att.media_type === 'image'" class="rounded overflow-hidden border border-blue-400/30">
+                    <div class="w-16 h-16 bg-blue-700/50 flex items-center justify-center text-xs text-blue-200">
+                      📷 {{ att.filename.length > 8 ? att.filename.slice(0,8) + '...' : att.filename }}
+                    </div>
+                  </div>
+                  <!-- 视频附件 -->
+                  <div v-else-if="att.media_type === 'video'" class="flex items-center gap-1 px-2 py-1 rounded bg-blue-700/50 border border-blue-400/30">
+                    <span>🎬</span>
+                    <span class="text-xs truncate max-w-[80px]">{{ att.filename }}</span>
+                  </div>
+                  <!-- 音频附件 -->
+                  <div v-else-if="att.media_type === 'audio'" class="flex items-center gap-1 px-2 py-1 rounded bg-blue-700/50 border border-blue-400/30">
+                    <span>🎵</span>
+                    <span class="text-xs truncate max-w-[80px]">{{ att.filename }}</span>
+                  </div>
+                </template>
+              </div>
               <div v-html="formatMessage(msg.displayContent || msg.content, msg.role)"></div>
               <div v-html="formatMessage(msg.displayContent || msg.content, msg.role)"></div>
             </div>
             </div>