-
-
+
@@ -841,6 +848,29 @@ export default {
margin: 10px 0;
}
+.loading-container {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 0;
+ margin-top: 2px;
+}
+
+.loading-text {
+ font-size: 14px;
+ color: var(--v-theme-secondaryText);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 0.6;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
.markdown-content blockquote {
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;
diff --git a/dashboard/src/composables/useConversations.ts b/dashboard/src/composables/useConversations.ts
new file mode 100644
index 000000000..cf86246c8
--- /dev/null
+++ b/dashboard/src/composables/useConversations.ts
@@ -0,0 +1,145 @@
+import { ref, computed } from 'vue';
+import axios from 'axios';
+import { useRouter } from 'vue-router';
+
+export interface Conversation {
+ cid: string;
+ title: string;
+ updated_at: number;
+}
+
+export function useConversations(chatboxMode: boolean = false) {
+ const router = useRouter();
+ const conversations = ref([]);
+ const selectedConversations = ref([]);
+ const currCid = ref('');
+ const pendingCid = ref(null);
+
+ // 编辑标题相关
+ const editTitleDialog = ref(false);
+ const editingTitle = ref('');
+ const editingCid = ref('');
+
+ const getCurrentConversation = computed(() => {
+ if (!currCid.value) return null;
+ return conversations.value.find(c => c.cid === currCid.value);
+ });
+
+ async function getConversations() {
+ try {
+ const response = await axios.get('/api/chat/conversations');
+ conversations.value = response.data.data;
+
+ // 处理待加载的会话
+ if (pendingCid.value) {
+ const conversation = conversations.value.find(c => c.cid === pendingCid.value);
+ if (conversation) {
+ selectedConversations.value = [pendingCid.value];
+ pendingCid.value = null;
+ }
+ } else if (!currCid.value && conversations.value.length > 0) {
+ // 默认选择第一个会话
+ const firstConversation = conversations.value[0];
+ selectedConversations.value = [firstConversation.cid];
+ }
+ } catch (err: any) {
+ if (err.response?.status === 401) {
+ router.push('/auth/login?redirect=/chatbox');
+ }
+ console.error(err);
+ }
+ }
+
+ async function newConversation() {
+ try {
+ const response = await axios.get('/api/chat/new_conversation');
+ const cid = response.data.data.conversation_id;
+ currCid.value = cid;
+
+ // 更新 URL
+ const basePath = chatboxMode ? '/chatbox' : '/chat';
+ router.push(`${basePath}/${cid}`);
+
+ await getConversations();
+ return cid;
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+ }
+
+ async function deleteConversation(cid: string) {
+ try {
+ await axios.get('/api/chat/delete_conversation?conversation_id=' + cid);
+ await getConversations();
+ currCid.value = '';
+ selectedConversations.value = [];
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ function showEditTitleDialog(cid: string, title: string) {
+ editingCid.value = cid;
+ editingTitle.value = title || '';
+ editTitleDialog.value = true;
+ }
+
+ async function saveTitle() {
+ if (!editingCid.value) return;
+
+ const trimmedTitle = editingTitle.value.trim();
+ try {
+ await axios.post('/api/chat/rename_conversation', {
+ conversation_id: editingCid.value,
+ title: trimmedTitle
+ });
+
+ // 更新本地会话标题
+ const conversation = conversations.value.find(c => c.cid === editingCid.value);
+ if (conversation) {
+ conversation.title = trimmedTitle;
+ }
+ editTitleDialog.value = false;
+ } catch (err) {
+ console.error('重命名对话失败:', err);
+ }
+ }
+
+ function updateConversationTitle(cid: string, title: string) {
+ const conversation = conversations.value.find(c => c.cid === cid);
+ if (conversation) {
+ conversation.title = title;
+ }
+ }
+
+ function newChat(closeMobileSidebar?: () => void) {
+ currCid.value = '';
+ selectedConversations.value = [];
+
+ const basePath = chatboxMode ? '/chatbox' : '/chat';
+ router.push(basePath);
+
+ if (closeMobileSidebar) {
+ closeMobileSidebar();
+ }
+ }
+
+ return {
+ conversations,
+ selectedConversations,
+ currCid,
+ pendingCid,
+ editTitleDialog,
+ editingTitle,
+ editingCid,
+ getCurrentConversation,
+ getConversations,
+ newConversation,
+ deleteConversation,
+ showEditTitleDialog,
+ saveTitle,
+ updateConversationTitle,
+ newChat
+ };
+}
diff --git a/dashboard/src/composables/useMediaHandling.ts b/dashboard/src/composables/useMediaHandling.ts
new file mode 100644
index 000000000..e24c25fb8
--- /dev/null
+++ b/dashboard/src/composables/useMediaHandling.ts
@@ -0,0 +1,104 @@
+import { ref } from 'vue';
+import axios from 'axios';
+
+export function useMediaHandling() {
+ const stagedImagesName = ref([]);
+ const stagedImagesUrl = ref([]);
+ const stagedAudioUrl = ref('');
+ const mediaCache = ref>({});
+
+ async function getMediaFile(filename: string): Promise {
+ if (mediaCache.value[filename]) {
+ return mediaCache.value[filename];
+ }
+
+ try {
+ const response = await axios.get('/api/chat/get_file', {
+ params: { filename },
+ responseType: 'blob'
+ });
+
+ const blobUrl = URL.createObjectURL(response.data);
+ mediaCache.value[filename] = blobUrl;
+ return blobUrl;
+ } catch (error) {
+ console.error('Error fetching media file:', error);
+ return '';
+ }
+ }
+
+ async function processAndUploadImage(file: File) {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ const response = await axios.post('/api/chat/post_image', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ });
+
+ const img = response.data.data.filename;
+ stagedImagesName.value.push(img);
+ stagedImagesUrl.value.push(URL.createObjectURL(file));
+ } catch (err) {
+ console.error('Error uploading image:', err);
+ }
+ }
+
+ async function handlePaste(event: ClipboardEvent) {
+ const items = event.clipboardData?.items;
+ if (!items) return;
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.indexOf('image') !== -1) {
+ const file = items[i].getAsFile();
+ if (file) {
+ await processAndUploadImage(file);
+ }
+ }
+ }
+ }
+
+ function removeImage(index: number) {
+ const urlToRevoke = stagedImagesUrl.value[index];
+ if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
+ URL.revokeObjectURL(urlToRevoke);
+ }
+
+ stagedImagesName.value.splice(index, 1);
+ stagedImagesUrl.value.splice(index, 1);
+ }
+
+ function removeAudio() {
+ stagedAudioUrl.value = '';
+ }
+
+ function clearStaged() {
+ stagedImagesName.value = [];
+ stagedImagesUrl.value = [];
+ stagedAudioUrl.value = '';
+ }
+
+ function cleanupMediaCache() {
+ Object.values(mediaCache.value).forEach(url => {
+ if (url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ });
+ mediaCache.value = {};
+ }
+
+ return {
+ stagedImagesName,
+ stagedImagesUrl,
+ stagedAudioUrl,
+ getMediaFile,
+ processAndUploadImage,
+ handlePaste,
+ removeImage,
+ removeAudio,
+ clearStaged,
+ cleanupMediaCache
+ };
+}
diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts
new file mode 100644
index 000000000..dc243f1b4
--- /dev/null
+++ b/dashboard/src/composables/useMessages.ts
@@ -0,0 +1,303 @@
+import { ref, reactive, type Ref } from 'vue';
+import axios from 'axios';
+import { useToast } from '@/utils/toast';
+
+export interface MessageContent {
+ type: string;
+ message: string;
+ reasoning?: string;
+ image_url?: string[];
+ audio_url?: string;
+ embedded_images?: string[];
+ embedded_audio?: string;
+ isLoading?: boolean;
+}
+
+export interface Message {
+ content: MessageContent;
+}
+
+export function useMessages(
+ currCid: Ref,
+ getMediaFile: (filename: string) => Promise,
+ updateConversationTitle: (cid: string, title: string) => void,
+ onConversationsUpdate: () => void
+) {
+ const messages = ref([]);
+ const isStreaming = ref(false);
+ const isConvRunning = ref(false);
+ const isToastedRunningInfo = ref(false);
+ const activeSSECount = ref(0);
+ const enableStreaming = ref(true);
+
+ // 从 localStorage 读取流式响应开关状态
+ const savedStreamingState = localStorage.getItem('enableStreaming');
+ if (savedStreamingState !== null) {
+ enableStreaming.value = JSON.parse(savedStreamingState);
+ }
+
+ function toggleStreaming() {
+ enableStreaming.value = !enableStreaming.value;
+ localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
+ }
+
+ async function getConversationMessages(cid: string, router: any) {
+ if (!cid) return;
+
+ try {
+ const response = await axios.get('/api/chat/get_conversation?conversation_id=' + cid);
+ isConvRunning.value = response.data.data.is_running || false;
+ let history = response.data.data.history;
+
+ if (isConvRunning.value) {
+ if (!isToastedRunningInfo.value) {
+ useToast().info("该对话正在运行中。", { timeout: 5000 });
+ isToastedRunningInfo.value = true;
+ }
+
+ // 如果对话还在运行,3秒后重新获取消息
+ setTimeout(() => {
+ getConversationMessages(currCid.value, router);
+ }, 3000);
+ }
+
+ // 处理历史消息中的媒体文件
+ for (let i = 0; i < history.length; i++) {
+ let content = history[i].content;
+
+ if (content.message?.startsWith('[IMAGE]')) {
+ let img = content.message.replace('[IMAGE]', '');
+ const imageUrl = await getMediaFile(img);
+ if (!content.embedded_images) {
+ content.embedded_images = [];
+ }
+ content.embedded_images.push(imageUrl);
+ content.message = '';
+ }
+
+ if (content.message?.startsWith('[RECORD]')) {
+ let audio = content.message.replace('[RECORD]', '');
+ const audioUrl = await getMediaFile(audio);
+ content.embedded_audio = audioUrl;
+ content.message = '';
+ }
+
+ if (content.image_url && content.image_url.length > 0) {
+ for (let j = 0; j < content.image_url.length; j++) {
+ content.image_url[j] = await getMediaFile(content.image_url[j]);
+ }
+ }
+
+ if (content.audio_url) {
+ content.audio_url = await getMediaFile(content.audio_url);
+ }
+ }
+
+ messages.value = history;
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ async function sendMessage(
+ prompt: string,
+ imageNames: string[],
+ audioName: string,
+ selectedProviderId: string,
+ selectedModelName: string
+ ) {
+ // Create user message
+ const userMessage: MessageContent = {
+ type: 'user',
+ message: prompt,
+ image_url: [],
+ audio_url: undefined
+ };
+
+ // Convert image filenames to blob URLs
+ if (imageNames.length > 0) {
+ const imagePromises = imageNames.map(name => {
+ if (!name.startsWith('blob:')) {
+ return getMediaFile(name);
+ }
+ return Promise.resolve(name);
+ });
+ userMessage.image_url = await Promise.all(imagePromises);
+ }
+
+ // Convert audio filename to blob URL
+ if (audioName) {
+ if (!audioName.startsWith('blob:')) {
+ userMessage.audio_url = await getMediaFile(audioName);
+ } else {
+ userMessage.audio_url = audioName;
+ }
+ }
+
+ messages.value.push({ content: userMessage });
+
+ // 添加一个加载中的机器人消息占位符
+ const loadingMessage = reactive({
+ type: 'bot',
+ message: '',
+ reasoning: '',
+ isLoading: true
+ });
+ messages.value.push({ content: loadingMessage });
+
+ try {
+ activeSSECount.value++;
+ if (activeSSECount.value === 1) {
+ isConvRunning.value = true;
+ }
+
+ const response = await fetch('/api/chat/send', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + localStorage.getItem('token')
+ },
+ body: JSON.stringify({
+ message: prompt,
+ conversation_id: currCid.value,
+ image_url: imageNames,
+ audio_url: audioName ? [audioName] : [],
+ selected_provider: selectedProviderId,
+ selected_model: selectedModelName,
+ enable_streaming: enableStreaming.value
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const reader = response.body!.getReader();
+ const decoder = new TextDecoder();
+ let in_streaming = false;
+ let message_obj: any = null;
+
+ isStreaming.value = true;
+
+ while (true) {
+ try {
+ const { done, value } = await reader.read();
+ if (done) {
+ console.log('SSE stream completed');
+ break;
+ }
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i].trim();
+ if (!line) continue;
+
+ let chunk_json;
+ try {
+ chunk_json = JSON.parse(line.replace('data: ', ''));
+ } catch (parseError) {
+ console.warn('JSON解析失败:', line, parseError);
+ continue;
+ }
+
+ if (!chunk_json || typeof chunk_json !== 'object' || !chunk_json.hasOwnProperty('type')) {
+ console.warn('无效的数据对象:', chunk_json);
+ continue;
+ }
+
+ if (chunk_json.type === 'error') {
+ console.error('Error received:', chunk_json.data);
+ continue;
+ }
+
+ if (chunk_json.type === 'image') {
+ let img = chunk_json.data.replace('[IMAGE]', '');
+ const imageUrl = await getMediaFile(img);
+ let bot_resp: MessageContent = {
+ type: 'bot',
+ message: '',
+ embedded_images: [imageUrl]
+ };
+ messages.value.push({ content: bot_resp });
+ } else if (chunk_json.type === 'record') {
+ let audio = chunk_json.data.replace('[RECORD]', '');
+ const audioUrl = await getMediaFile(audio);
+ let bot_resp: MessageContent = {
+ type: 'bot',
+ message: '',
+ embedded_audio: audioUrl
+ };
+ messages.value.push({ content: bot_resp });
+ } else if (chunk_json.type === 'plain') {
+ const chain_type = chunk_json.chain_type || 'normal';
+
+ if (!in_streaming) {
+ // 移除加载占位符
+ const lastMsg = messages.value[messages.value.length - 1];
+ if (lastMsg?.content?.isLoading) {
+ messages.value.pop();
+ }
+
+ message_obj = reactive({
+ type: 'bot',
+ message: chain_type === 'reasoning' ? '' : chunk_json.data,
+ reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
+ });
+ messages.value.push({ content: message_obj });
+ in_streaming = true;
+ } else {
+ if (chain_type === 'reasoning') {
+ // 使用 reactive 对象,直接修改属性会触发响应式更新
+ message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
+ } else {
+ message_obj.message = (message_obj.message || '') + chunk_json.data;
+ }
+ }
+ } else if (chunk_json.type === 'update_title') {
+ updateConversationTitle(chunk_json.cid, chunk_json.data);
+ }
+
+ if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
+ in_streaming = false;
+ if (!chunk_json.streaming) {
+ isStreaming.value = false;
+ }
+ }
+ }
+ } catch (readError) {
+ console.error('SSE读取错误:', readError);
+ break;
+ }
+ }
+
+ // 获取最新的对话列表
+ onConversationsUpdate();
+
+ } catch (err) {
+ console.error('发送消息失败:', err);
+ // 移除加载占位符
+ const lastMsg = messages.value[messages.value.length - 1];
+ if (lastMsg?.content?.isLoading) {
+ messages.value.pop();
+ }
+ } finally {
+ isStreaming.value = false;
+ activeSSECount.value--;
+ if (activeSSECount.value === 0) {
+ isConvRunning.value = false;
+ }
+ }
+ }
+
+ return {
+ messages,
+ isStreaming,
+ isConvRunning,
+ enableStreaming,
+ getConversationMessages,
+ sendMessage,
+ toggleStreaming
+ };
+}
diff --git a/dashboard/src/composables/useRecording.ts b/dashboard/src/composables/useRecording.ts
new file mode 100644
index 000000000..4b03e8508
--- /dev/null
+++ b/dashboard/src/composables/useRecording.ts
@@ -0,0 +1,74 @@
+import { ref } from 'vue';
+import axios from 'axios';
+
+export function useRecording() {
+ const isRecording = ref(false);
+ const audioChunks = ref([]);
+ const mediaRecorder = ref(null);
+
+ async function startRecording(onStart?: (label: string) => void) {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ mediaRecorder.value = new MediaRecorder(stream);
+
+ mediaRecorder.value.ondataavailable = (event) => {
+ audioChunks.value.push(event.data);
+ };
+
+ mediaRecorder.value.start();
+ isRecording.value = true;
+
+ if (onStart) {
+ onStart('录音中...');
+ }
+ } catch (error) {
+ console.error('Failed to start recording:', error);
+ }
+ }
+
+ async function stopRecording(onStop?: (label: string) => void): Promise {
+ return new Promise((resolve, reject) => {
+ if (!mediaRecorder.value) {
+ reject('No media recorder');
+ return;
+ }
+
+ isRecording.value = false;
+ if (onStop) {
+ onStop('聊天输入框');
+ }
+
+ mediaRecorder.value.stop();
+ mediaRecorder.value.onstop = async () => {
+ const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
+ audioChunks.value = [];
+
+ mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
+
+ const formData = new FormData();
+ formData.append('file', audioBlob);
+
+ try {
+ const response = await axios.post('/api/chat/post_file', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ });
+
+ const audio = response.data.data.filename;
+ console.log('Audio uploaded:', audio);
+ resolve(audio);
+ } catch (err) {
+ console.error('Error uploading audio:', err);
+ reject(err);
+ }
+ };
+ });
+ }
+
+ return {
+ isRecording,
+ startRecording,
+ stopRecording
+ };
+}