feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality. - Enhanced `MessageList.vue` to handle loading states and improved message rendering. - Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability. - Added loading indicators and improved user experience during message processing. - Ensured backward compatibility and maintained existing functionalities.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
283
dashboard/src/components/chat/ChatInput.vue
Normal file
283
dashboard/src/components/chat/ChatInput.vue
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<div class="input-area fade-in">
|
||||||
|
<div class="input-container"
|
||||||
|
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
|
||||||
|
<textarea
|
||||||
|
ref="inputField"
|
||||||
|
v-model="localPrompt"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
:disabled="disabled"
|
||||||
|
placeholder="Ask AstrBot..."
|
||||||
|
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
|
||||||
|
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||||
|
<ProviderModelSelector ref="providerModelSelectorRef" />
|
||||||
|
|
||||||
|
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
|
||||||
|
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
|
||||||
|
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
||||||
|
<input type="file" ref="imageInputRef" @change="handleFileSelect" accept="image/*"
|
||||||
|
style="display: none" multiple />
|
||||||
|
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
||||||
|
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
|
||||||
|
class="add-btn" size="small" />
|
||||||
|
<v-btn @click="handleRecordClick"
|
||||||
|
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||||
|
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
|
||||||
|
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
|
||||||
|
:disabled="!canSend" class="send-btn" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附件预览区 -->
|
||||||
|
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
|
||||||
|
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
|
||||||
|
<img :src="img" class="preview-image" />
|
||||||
|
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
|
||||||
|
size="small" color="error" variant="text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||||
|
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
||||||
|
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||||
|
{{ tm('voice.recording') }}
|
||||||
|
</v-chip>
|
||||||
|
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
|
||||||
|
color="error" variant="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
import ProviderModelSelector from './ProviderModelSelector.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prompt: string;
|
||||||
|
stagedImagesUrl: string[];
|
||||||
|
stagedAudioUrl: string;
|
||||||
|
disabled: boolean;
|
||||||
|
enableStreaming: boolean;
|
||||||
|
isRecording: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:prompt': [value: string];
|
||||||
|
send: [];
|
||||||
|
toggleStreaming: [];
|
||||||
|
removeImage: [index: number];
|
||||||
|
removeAudio: [];
|
||||||
|
startRecording: [];
|
||||||
|
stopRecording: [];
|
||||||
|
pasteImage: [event: ClipboardEvent];
|
||||||
|
fileSelect: [files: FileList];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
|
||||||
|
const inputField = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
|
||||||
|
|
||||||
|
const localPrompt = computed({
|
||||||
|
get: () => props.prompt,
|
||||||
|
set: (value) => emit('update:prompt', value)
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSend = computed(() => {
|
||||||
|
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl+B 长按录音相关
|
||||||
|
const ctrlKeyDown = ref(false);
|
||||||
|
const ctrlKeyTimer = ref<number | null>(null);
|
||||||
|
const ctrlKeyLongPressThreshold = 300;
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Enter 发送消息
|
||||||
|
if (e.keyCode === 13 && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canSend.value) {
|
||||||
|
emit('send');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+B 录音
|
||||||
|
if (e.ctrlKey && e.keyCode === 66) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (ctrlKeyDown.value) return;
|
||||||
|
|
||||||
|
ctrlKeyDown.value = true;
|
||||||
|
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||||
|
if (ctrlKeyDown.value && !props.isRecording) {
|
||||||
|
emit('startRecording');
|
||||||
|
}
|
||||||
|
}, ctrlKeyLongPressThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
|
if (e.keyCode === 66) {
|
||||||
|
ctrlKeyDown.value = false;
|
||||||
|
|
||||||
|
if (ctrlKeyTimer.value) {
|
||||||
|
clearTimeout(ctrlKeyTimer.value);
|
||||||
|
ctrlKeyTimer.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.isRecording) {
|
||||||
|
emit('stopRecording');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e: ClipboardEvent) {
|
||||||
|
emit('pasteImage', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImageInput() {
|
||||||
|
imageInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const files = target.files;
|
||||||
|
if (files) {
|
||||||
|
emit('fileSelect', files);
|
||||||
|
}
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRecordClick() {
|
||||||
|
if (props.isRecording) {
|
||||||
|
emit('stopRecording');
|
||||||
|
} else {
|
||||||
|
emit('startRecording');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentSelection() {
|
||||||
|
return providerModelSelectorRef.value?.getCurrentSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (inputField.value) {
|
||||||
|
inputField.value.addEventListener('paste', handlePaste);
|
||||||
|
}
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (inputField.value) {
|
||||||
|
inputField.value.removeEventListener('paste', handlePaste);
|
||||||
|
}
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getCurrentSelection
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-area {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--v-theme-surface);
|
||||||
|
position: relative;
|
||||||
|
border-top: 1px solid var(--v-theme-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 8px auto 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview,
|
||||||
|
.audio-preview {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-chip {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-attachment-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-attachment-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-toggle-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-toggle-chip:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.input-area {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
310
dashboard/src/components/chat/ConversationSidebar.vue
Normal file
310
dashboard/src/components/chat/ConversationSidebar.vue
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sidebar-panel"
|
||||||
|
:class="{
|
||||||
|
'sidebar-collapsed': sidebarCollapsed && !isMobile,
|
||||||
|
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||||
|
'mobile-sidebar': isMobile
|
||||||
|
}"
|
||||||
|
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
|
||||||
|
@mouseenter="handleSidebarMouseEnter"
|
||||||
|
@mouseleave="handleSidebarMouseLeave">
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
|
||||||
|
v-if="chatboxMode">
|
||||||
|
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
|
||||||
|
<span v-if="!sidebarCollapsed"
|
||||||
|
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||||
|
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||||
|
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
|
||||||
|
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-collapse-btn-container" v-if="isMobile">
|
||||||
|
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
|
||||||
|
color="deep-purple">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 16px; padding-top: 8px;">
|
||||||
|
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currCid"
|
||||||
|
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
|
||||||
|
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
|
||||||
|
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currCid"
|
||||||
|
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!sidebarCollapsed || isMobile">
|
||||||
|
<v-divider class="mx-4"></v-divider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
|
||||||
|
v-if="!sidebarCollapsed || isMobile">
|
||||||
|
<v-card v-if="conversations.length > 0" flat style="background-color: transparent;">
|
||||||
|
<v-list density="compact" nav class="conversation-list"
|
||||||
|
style="background-color: transparent;" :selected="selectedConversations"
|
||||||
|
@update:selected="$emit('selectConversation', $event)">
|
||||||
|
<v-list-item v-for="item in conversations" :key="item.cid" :value="item.cid"
|
||||||
|
rounded="lg" class="conversation-item" active-color="secondary">
|
||||||
|
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
|
||||||
|
{{ item.title || tm('conversation.newConversation') }}
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||||
|
{{ formatDate(item.updated_at) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
|
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||||
|
<div class="conversation-actions">
|
||||||
|
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||||
|
class="edit-title-btn"
|
||||||
|
@click.stop="$emit('editTitle', item.cid, item.title)" />
|
||||||
|
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||||
|
class="delete-conversation-btn" color="error"
|
||||||
|
@click.stop="$emit('deleteConversation', item.cid)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-fade-transition>
|
||||||
|
<div class="no-conversations" v-if="conversations.length === 0">
|
||||||
|
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||||
|
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
|
||||||
|
{{ tm('conversation.noHistory') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-fade-transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
|
import type { Conversation } from '@/composables/useConversations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversations: Conversation[];
|
||||||
|
selectedConversations: string[];
|
||||||
|
currCid: string;
|
||||||
|
isDark: boolean;
|
||||||
|
chatboxMode: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
mobileMenuOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
newChat: [];
|
||||||
|
selectConversation: [cids: string[]];
|
||||||
|
editTitle: [cid: string, title: string];
|
||||||
|
deleteConversation: [cid: string];
|
||||||
|
closeMobileSidebar: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const sidebarCollapsed = ref(true);
|
||||||
|
const sidebarHovered = ref(false);
|
||||||
|
const sidebarHoverTimer = ref<number | null>(null);
|
||||||
|
const sidebarHoverExpanded = ref(false);
|
||||||
|
const sidebarHoverDelay = 100;
|
||||||
|
|
||||||
|
// 从 localStorage 读取侧边栏折叠状态
|
||||||
|
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||||
|
if (savedCollapsedState !== null) {
|
||||||
|
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
|
||||||
|
} else {
|
||||||
|
sidebarCollapsed.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
if (sidebarHoverExpanded.value) {
|
||||||
|
sidebarHoverExpanded.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarMouseEnter() {
|
||||||
|
if (!sidebarCollapsed.value || props.isMobile) return;
|
||||||
|
|
||||||
|
sidebarHovered.value = true;
|
||||||
|
sidebarHoverTimer.value = window.setTimeout(() => {
|
||||||
|
if (sidebarHovered.value) {
|
||||||
|
sidebarHoverExpanded.value = true;
|
||||||
|
sidebarCollapsed.value = false;
|
||||||
|
}
|
||||||
|
}, sidebarHoverDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarMouseLeave() {
|
||||||
|
sidebarHovered.value = false;
|
||||||
|
|
||||||
|
if (sidebarHoverTimer.value) {
|
||||||
|
clearTimeout(sidebarHoverTimer.value);
|
||||||
|
sidebarHoverTimer.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sidebarHoverExpanded.value) {
|
||||||
|
sidebarCollapsed.value = true;
|
||||||
|
}
|
||||||
|
sidebarHoverExpanded.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
const locale = t('core.common.locale') || 'zh-CN';
|
||||||
|
return date.toLocaleString(locale, options).replace(/\//g, '-').replace(/, /g, ' ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-panel {
|
||||||
|
max-width: 270px;
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed {
|
||||||
|
max-width: 75px;
|
||||||
|
min-width: 75px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-width: 280px !important;
|
||||||
|
min-width: 280px !important;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-open {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapse-btn-container {
|
||||||
|
margin: 16px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapse-btn {
|
||||||
|
opacity: 0.6;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover .conversation-actions {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-title-btn,
|
||||||
|
.delete-conversation-btn {
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-title-btn:hover,
|
||||||
|
.delete-conversation-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
line-height: 1;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversations {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 150px;
|
||||||
|
opacity: 0.6;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversations-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeInContent 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInContent {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -37,42 +37,49 @@
|
|||||||
</v-avatar>
|
</v-avatar>
|
||||||
<div class="bot-message-content">
|
<div class="bot-message-content">
|
||||||
<div class="message-bubble bot-bubble">
|
<div class="message-bubble bot-bubble">
|
||||||
<!-- Reasoning Block (Collapsible) -->
|
<!-- Loading state -->
|
||||||
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" class="reasoning-container">
|
<div v-if="msg.content.isLoading" class="loading-container">
|
||||||
<div class="reasoning-header" @click="toggleReasoning(index)">
|
<span class="loading-text">{{ tm('message.loading') }}</span>
|
||||||
<v-icon size="small" class="reasoning-icon">
|
|
||||||
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
|
||||||
</v-icon>
|
|
||||||
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
|
|
||||||
<div v-html="md.render(msg.content.reasoning)" class="markdown-content reasoning-text"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text -->
|
<template v-else>
|
||||||
<div v-if="msg.content.message && msg.content.message.trim()"
|
<!-- Reasoning Block (Collapsible) -->
|
||||||
v-html="md.render(msg.content.message)" class="markdown-content"></div>
|
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" class="reasoning-container">
|
||||||
|
<div class="reasoning-header" @click="toggleReasoning(index)">
|
||||||
<!-- Image -->
|
<v-icon size="small" class="reasoning-icon">
|
||||||
<div class="embedded-images"
|
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||||
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
|
</v-icon>
|
||||||
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
|
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
|
||||||
class="embedded-image">
|
</div>
|
||||||
<img :src="img" class="bot-embedded-image"
|
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
|
||||||
@click="$emit('openImagePreview', img)" />
|
<div v-html="md.render(msg.content.reasoning)" class="markdown-content reasoning-text"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Text -->
|
||||||
|
<div v-if="msg.content.message && msg.content.message.trim()"
|
||||||
|
v-html="md.render(msg.content.message)" class="markdown-content"></div>
|
||||||
|
|
||||||
<!-- Audio -->
|
<!-- Image -->
|
||||||
<div class="embedded-audio" v-if="msg.content.embedded_audio">
|
<div class="embedded-images"
|
||||||
<audio controls class="audio-player">
|
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
|
||||||
<source :src="msg.content.embedded_audio" type="audio/wav">
|
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
|
||||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
class="embedded-image">
|
||||||
</audio>
|
<img :src="img" class="bot-embedded-image"
|
||||||
</div>
|
@click="$emit('openImagePreview', img)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio -->
|
||||||
|
<div class="embedded-audio" v-if="msg.content.embedded_audio">
|
||||||
|
<audio controls class="audio-player">
|
||||||
|
<source :src="msg.content.embedded_audio" type="audio/wav">
|
||||||
|
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-actions">
|
<div class="message-actions" v-if="!msg.content.isLoading">
|
||||||
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
|
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
|
||||||
:class="{ 'copy-success': isCopySuccess(index) }"
|
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||||
@@ -841,6 +848,29 @@ export default {
|
|||||||
margin: 10px 0;
|
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 {
|
.markdown-content blockquote {
|
||||||
border-left: 4px solid var(--v-theme-secondary);
|
border-left: 4px solid var(--v-theme-secondary);
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
|
|||||||
145
dashboard/src/composables/useConversations.ts
Normal file
145
dashboard/src/composables/useConversations.ts
Normal file
@@ -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<Conversation[]>([]);
|
||||||
|
const selectedConversations = ref<string[]>([]);
|
||||||
|
const currCid = ref('');
|
||||||
|
const pendingCid = ref<string | null>(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
|
||||||
|
};
|
||||||
|
}
|
||||||
104
dashboard/src/composables/useMediaHandling.ts
Normal file
104
dashboard/src/composables/useMediaHandling.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export function useMediaHandling() {
|
||||||
|
const stagedImagesName = ref<string[]>([]);
|
||||||
|
const stagedImagesUrl = ref<string[]>([]);
|
||||||
|
const stagedAudioUrl = ref<string>('');
|
||||||
|
const mediaCache = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function getMediaFile(filename: string): Promise<string> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
303
dashboard/src/composables/useMessages.ts
Normal file
303
dashboard/src/composables/useMessages.ts
Normal file
@@ -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<string>,
|
||||||
|
getMediaFile: (filename: string) => Promise<string>,
|
||||||
|
updateConversationTitle: (cid: string, title: string) => void,
|
||||||
|
onConversationsUpdate: () => void
|
||||||
|
) {
|
||||||
|
const messages = ref<Message[]>([]);
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
74
dashboard/src/composables/useRecording.ts
Normal file
74
dashboard/src/composables/useRecording.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export function useRecording() {
|
||||||
|
const isRecording = ref(false);
|
||||||
|
const audioChunks = ref<Blob[]>([]);
|
||||||
|
const mediaRecorder = ref<MediaRecorder | null>(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<string> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user