Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f57a3bb6d0 |
@@ -26,10 +26,11 @@
|
||||
@createProject="showCreateProjectDialog"
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@openMultiChatMode="openMultiChatDialog"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
<div class="chat-content-panel" v-if="!isMultiChatMode">
|
||||
|
||||
<div class="conversation-header fade-in" v-if="isMobile">
|
||||
<!-- 手机端菜单按钮 -->
|
||||
@@ -146,6 +147,27 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 多对话模式视图 -->
|
||||
<MultiChatView
|
||||
v-if="isMultiChatMode"
|
||||
:sessionIds="multiChatSessionIds"
|
||||
:sessions="sessions"
|
||||
:isDark="isDark"
|
||||
:isStreaming="isStreaming"
|
||||
:isConvRunning="isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:getSessionMessages="getMessagesForMultiChat"
|
||||
@exitMultiMode="exitMultiChatMode"
|
||||
@openImagePreview="openImagePreview"
|
||||
@sendMessage="handleMultiChatSendMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="(sessionId, event) => handlePaste(event)"
|
||||
@fileSelect="(sessionId, files) => handleFileSelect(files)"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -184,11 +206,19 @@
|
||||
:project="editingProject"
|
||||
@save="handleSaveProject"
|
||||
/>
|
||||
|
||||
<!-- 多对话模式选择对话框 -->
|
||||
<SessionSelectDialog
|
||||
v-model="multiChatDialog"
|
||||
:sessions="sessions"
|
||||
@confirm="enterMultiChatMode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
@@ -198,6 +228,8 @@ import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
||||
import ProjectView from '@/components/chat/ProjectView.vue';
|
||||
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
||||
import SessionSelectDialog from '@/components/chat/SessionSelectDialog.vue';
|
||||
import MultiChatView from '@/components/chat/MultiChatView.vue';
|
||||
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
||||
import { useSessions } from '@/composables/useSessions';
|
||||
import { useMessages } from '@/composables/useMessages';
|
||||
@@ -301,6 +333,11 @@ const currentProject = computed(() =>
|
||||
projects.value.find(p => p.project_id === selectedProjectId.value)
|
||||
);
|
||||
|
||||
// 多对话模式状态
|
||||
const multiChatDialog = ref(false);
|
||||
const isMultiChatMode = ref(false);
|
||||
const multiChatSessionIds = ref<string[]>([]);
|
||||
|
||||
// 引用消息状态
|
||||
interface ReplyInfo {
|
||||
messageId: number; // PlatformSessionHistoryMessage 的 id
|
||||
@@ -496,6 +533,90 @@ async function handleDeleteProject(projectId: string) {
|
||||
await deleteProject(projectId);
|
||||
}
|
||||
|
||||
// 多对话模式相关函数
|
||||
function openMultiChatDialog() {
|
||||
multiChatDialog.value = true;
|
||||
}
|
||||
|
||||
function enterMultiChatMode(sessionIds: string[]) {
|
||||
if (sessionIds.length < 2) return;
|
||||
|
||||
multiChatSessionIds.value = sessionIds;
|
||||
isMultiChatMode.value = true;
|
||||
|
||||
// 手机端关闭侧边栏
|
||||
if (isMobile.value) {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
function exitMultiChatMode() {
|
||||
isMultiChatMode.value = false;
|
||||
multiChatSessionIds.value = [];
|
||||
|
||||
// 恢复到第一个会话
|
||||
if (sessions.value.length > 0) {
|
||||
handleSelectConversation([sessions.value[0].session_id]);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMessagesForMultiChat(sessionId: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
|
||||
let history = response.data.data.history || [];
|
||||
|
||||
// 处理历史消息(解析附件等)
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
let content = history[i].content;
|
||||
// 这里可以调用 parseMessageContent 如果需要
|
||||
// 但为了简化,我们直接返回原始数据
|
||||
}
|
||||
|
||||
return history;
|
||||
} catch (error) {
|
||||
console.error(`获取会话 ${sessionId} 消息失败:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMultiChatSendMessage(sessionId: string, data: any) {
|
||||
// 保存原始状态
|
||||
const previousSessionId = currSessionId.value;
|
||||
const previousPrompt = prompt.value;
|
||||
|
||||
try {
|
||||
// 临时切换到目标会话
|
||||
currSessionId.value = sessionId;
|
||||
prompt.value = data.prompt;
|
||||
|
||||
// 获取选择的提供商和模型
|
||||
const selection = chatInputRef.value?.getCurrentSelection();
|
||||
const selectedProviderId = selection?.providerId || '';
|
||||
const selectedModelName = selection?.modelName || '';
|
||||
|
||||
// 发送消息
|
||||
await sendMsg(
|
||||
data.prompt,
|
||||
data.stagedFiles || [],
|
||||
data.stagedAudios || '',
|
||||
selectedProviderId,
|
||||
selectedModelName,
|
||||
data.replyTo || null
|
||||
);
|
||||
|
||||
// 发送成功后,触发该会话消息列表刷新
|
||||
// MultiChatView 会监听 sessions 的变化或者我们可以手动触发重新加载
|
||||
// 由于 useMessages 已经处理了消息的更新,我们只需要等待下一个 tick
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
console.error('多对话模式发送消息失败:', error);
|
||||
} finally {
|
||||
// 恢复原始状态
|
||||
currSessionId.value = previousSessionId;
|
||||
prompt.value = previousPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
await startRec();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 多对话模式入口 -->
|
||||
<div style="padding: 0 8px 8px 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('openMultiChatMode')"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-view-carousel">{{ tm('multiChat.multiMode') }}</v-btn>
|
||||
<v-btn icon="mdi-view-carousel" rounded="xl" @click="$emit('openMultiChatMode')"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
@@ -178,6 +186,7 @@ const emit = defineEmits<{
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
openMultiChatMode: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="multi-chat-view">
|
||||
<div class="multi-chat-header">
|
||||
<v-btn icon="mdi-close" variant="text" @click="exitMultiMode" />
|
||||
<span class="multi-chat-title">{{ tm('multiChat.multiMode') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="chat-container-wrapper"
|
||||
ref="containerRef"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="chat-panels-track">
|
||||
<div
|
||||
v-for="(sessionId, index) in sessionIds"
|
||||
:key="sessionId"
|
||||
class="chat-panel"
|
||||
:style="{
|
||||
zIndex: index + 1,
|
||||
left: `${index * 16}px`
|
||||
}"
|
||||
:ref="el => { if (el) panelRefs[index] = el }"
|
||||
>
|
||||
<div class="chat-panel-inner" :class="{ 'panel-stacked': shouldShowShadow(index) }">
|
||||
<div class="session-header">
|
||||
<span class="session-title">
|
||||
{{ getSessionTitle(sessionId) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="message-list-container">
|
||||
<MessageList
|
||||
:messages="sessionMessages[sessionId] || []"
|
||||
:isDark="isDark"
|
||||
:isStreaming="activeSessionId === sessionId && (isStreaming || isConvRunning)"
|
||||
:isLoadingMessages="loadingSessionIds.has(sessionId)"
|
||||
@openImagePreview="(url) => $emit('openImagePreview', url)"
|
||||
@replyMessage="(msg, idx) => handleReplyMessage(sessionId, msg, idx)"
|
||||
@replyWithText="(data) => handleReplyWithText(sessionId, data)"
|
||||
:ref="el => { if (el) messageListRefs[index] = el }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
v-model:prompt="prompts[sessionId]"
|
||||
:stagedImagesUrl="stagedImages[sessionId] || []"
|
||||
:stagedAudioUrl="stagedAudios[sessionId] || ''"
|
||||
:stagedFiles="stagedFiles[sessionId] || []"
|
||||
:disabled="isStreaming && activeSessionId === sessionId"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording && activeSessionId === sessionId"
|
||||
:session-id="sessionId"
|
||||
:current-session="getSession(sessionId)"
|
||||
:replyTo="replyToMap[sessionId]"
|
||||
@send="handleSendMessage(sessionId)"
|
||||
@toggleStreaming="$emit('toggleStreaming')"
|
||||
@removeImage="(idx) => removeImage(sessionId, idx)"
|
||||
@removeAudio="removeAudio(sessionId)"
|
||||
@removeFile="(idx) => removeFile(sessionId, idx)"
|
||||
@startRecording="handleStartRecording(sessionId)"
|
||||
@stopRecording="handleStopRecording(sessionId)"
|
||||
@pasteImage="(file) => handlePasteImage(sessionId, file)"
|
||||
@fileSelect="(files) => handleFileSelect(sessionId, files)"
|
||||
@clearReply="clearReply(sessionId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface Props {
|
||||
sessionIds: string[];
|
||||
sessions: Session[];
|
||||
isDark: boolean;
|
||||
isStreaming: boolean;
|
||||
isConvRunning: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
getSessionMessages?: (sessionId: string) => Promise<any[]>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
getSessionMessages: undefined
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
exitMultiMode: [];
|
||||
openImagePreview: [url: string];
|
||||
sendMessage: [sessionId: string, data: any];
|
||||
toggleStreaming: [];
|
||||
startRecording: [sessionId: string];
|
||||
stopRecording: [sessionId: string];
|
||||
pasteImage: [sessionId: string, event: ClipboardEvent];
|
||||
fileSelect: [sessionId: string, files: FileList];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
// 状态管理
|
||||
const currentIndex = ref(0);
|
||||
const scrollLeft = ref(0);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const panelRefs = reactive<any[]>([]);
|
||||
const messageListRefs = reactive<any[]>([]);
|
||||
|
||||
// 每个会话的独立状态
|
||||
const sessionMessages = reactive<Record<string, any[]>>({});
|
||||
const prompts = reactive<Record<string, string>>({});
|
||||
const stagedImages = reactive<Record<string, string[]>>({});
|
||||
const stagedAudios = reactive<Record<string, string>>({});
|
||||
const stagedFiles = reactive<Record<string, any[]>>({});
|
||||
const replyToMap = reactive<Record<string, any>>({});
|
||||
const loadingSessionIds = reactive(new Set<string>());
|
||||
const activeSessionId = ref('');
|
||||
|
||||
// 计算属性 - 每个面板宽度为650px和视口宽度的最小值
|
||||
const panelWidth = computed(() => {
|
||||
if (!containerRef.value) {
|
||||
return Math.min(650, window.innerWidth);
|
||||
}
|
||||
return Math.min(650, containerRef.value.offsetWidth);
|
||||
});
|
||||
|
||||
// 计算面板是否应该显示阴影
|
||||
function shouldShowShadow(index: number): boolean {
|
||||
if (index === 0) return false;
|
||||
// 当面板已经开始固定时(滚动超过它的位置)
|
||||
const threshold = (index - 0.98) * panelWidth.value;
|
||||
return scrollLeft.value >= threshold;
|
||||
}
|
||||
|
||||
// 初始化所有会话的状态
|
||||
onMounted(async () => {
|
||||
props.sessionIds.forEach(sessionId => {
|
||||
if (!prompts[sessionId]) prompts[sessionId] = '';
|
||||
if (!stagedImages[sessionId]) stagedImages[sessionId] = [];
|
||||
if (!stagedAudios[sessionId]) stagedAudios[sessionId] = '';
|
||||
if (!stagedFiles[sessionId]) stagedFiles[sessionId] = [];
|
||||
if (!sessionMessages[sessionId]) sessionMessages[sessionId] = [];
|
||||
});
|
||||
|
||||
// 加载初始会话消息
|
||||
if (props.sessionIds.length > 0) {
|
||||
activeSessionId.value = props.sessionIds[0];
|
||||
// 并行加载前两个会话的消息
|
||||
const loadPromises = props.sessionIds.slice(0, 2).map(id => loadSessionMessages(id));
|
||||
await Promise.all(loadPromises);
|
||||
}
|
||||
});
|
||||
|
||||
// 辅助函数
|
||||
let scrollTimeout: number | null = null;
|
||||
|
||||
// 滚动处理
|
||||
function handleScroll() {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
// 实时更新滚动位置
|
||||
scrollLeft.value = containerRef.value.scrollLeft;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
|
||||
// 使用防抖,滚动停止150ms后才更新currentIndex用于预加载
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const scrollLeft = containerRef.value.scrollLeft;
|
||||
const newIndex = Math.round(scrollLeft / panelWidth.value);
|
||||
|
||||
if (newIndex >= 0 && newIndex < props.sessionIds.length && newIndex !== currentIndex.value) {
|
||||
currentIndex.value = newIndex;
|
||||
activeSessionId.value = props.sessionIds[newIndex];
|
||||
preloadAdjacentSessions();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// 预加载相邻会话
|
||||
function preloadAdjacentSessions() {
|
||||
const indicesToLoad = [
|
||||
currentIndex.value - 1,
|
||||
currentIndex.value,
|
||||
currentIndex.value + 1
|
||||
].filter(i => i >= 0 && i < props.sessionIds.length);
|
||||
|
||||
indicesToLoad.forEach(i => {
|
||||
const sessionId = props.sessionIds[i];
|
||||
if (!sessionMessages[sessionId] || sessionMessages[sessionId].length === 0) {
|
||||
loadSessionMessages(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessionMessages(sessionId: string) {
|
||||
if (loadingSessionIds.has(sessionId)) return;
|
||||
|
||||
loadingSessionIds.add(sessionId);
|
||||
|
||||
try {
|
||||
if (props.getSessionMessages) {
|
||||
const messages = await props.getSessionMessages(sessionId);
|
||||
sessionMessages[sessionId] = messages || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载会话 ${sessionId} 消息失败:`, error);
|
||||
sessionMessages[sessionId] = [];
|
||||
} finally {
|
||||
loadingSessionIds.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionTitle(sessionId: string): string {
|
||||
const session = props.sessions.find(s => s.session_id === sessionId);
|
||||
return session?.display_name || tm('conversation.newConversation');
|
||||
}
|
||||
|
||||
function getSession(sessionId: string): Session | null {
|
||||
return props.sessions.find(s => s.session_id === sessionId) || null;
|
||||
}
|
||||
|
||||
// 消息处理
|
||||
function handleReplyMessage(sessionId: string, msg: any, index: number) {
|
||||
const messageId = msg.id;
|
||||
if (!messageId) return;
|
||||
|
||||
let messageContent = '';
|
||||
if (typeof msg.content.message === 'string') {
|
||||
messageContent = msg.content.message;
|
||||
} else if (Array.isArray(msg.content.message)) {
|
||||
const textParts = msg.content.message
|
||||
.filter((part: any) => part.type === 'plain' && part.text)
|
||||
.map((part: any) => part.text);
|
||||
messageContent = textParts.join('');
|
||||
}
|
||||
|
||||
if (messageContent.length > 100) {
|
||||
messageContent = messageContent.substring(0, 100) + '...';
|
||||
}
|
||||
|
||||
replyToMap[sessionId] = {
|
||||
messageId,
|
||||
selectedText: messageContent || '[媒体内容]'
|
||||
};
|
||||
}
|
||||
|
||||
function handleReplyWithText(sessionId: string, replyData: any) {
|
||||
const { messageId, selectedText } = replyData;
|
||||
if (!messageId) return;
|
||||
|
||||
replyToMap[sessionId] = {
|
||||
messageId,
|
||||
selectedText
|
||||
};
|
||||
}
|
||||
|
||||
function clearReply(sessionId: string) {
|
||||
delete replyToMap[sessionId];
|
||||
}
|
||||
|
||||
function handleSendMessage(sessionId: string) {
|
||||
const data = {
|
||||
prompt: prompts[sessionId],
|
||||
stagedImages: stagedImages[sessionId],
|
||||
stagedAudios: stagedAudios[sessionId],
|
||||
stagedFiles: stagedFiles[sessionId],
|
||||
replyTo: replyToMap[sessionId]
|
||||
};
|
||||
|
||||
emit('sendMessage', sessionId, data);
|
||||
|
||||
// 清空输入
|
||||
prompts[sessionId] = '';
|
||||
stagedImages[sessionId] = [];
|
||||
stagedAudios[sessionId] = '';
|
||||
stagedFiles[sessionId] = [];
|
||||
clearReply(sessionId);
|
||||
}
|
||||
|
||||
function removeImage(sessionId: string, index: number) {
|
||||
stagedImages[sessionId].splice(index, 1);
|
||||
}
|
||||
|
||||
function removeAudio(sessionId: string) {
|
||||
stagedAudios[sessionId] = '';
|
||||
}
|
||||
|
||||
function removeFile(sessionId: string, index: number) {
|
||||
stagedFiles[sessionId].splice(index, 1);
|
||||
}
|
||||
|
||||
function handleStartRecording(sessionId: string) {
|
||||
activeSessionId.value = sessionId;
|
||||
emit('startRecording', sessionId);
|
||||
}
|
||||
|
||||
function handleStopRecording(sessionId: string) {
|
||||
emit('stopRecording', sessionId);
|
||||
}
|
||||
|
||||
function handlePasteImage(sessionId: string, event: ClipboardEvent) {
|
||||
emit('pasteImage', sessionId, event);
|
||||
}
|
||||
|
||||
function handleFileSelect(sessionId: string, files: FileList) {
|
||||
emit('fileSelect', sessionId, files);
|
||||
}
|
||||
|
||||
function exitMultiMode() {
|
||||
emit('exitMultiMode');
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理定时器
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.multi-chat-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.multi-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.multi-chat-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.session-indicator {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-container-wrapper {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chat-panels-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
width: min(650px, 100vw);
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.chat-panel-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-right: 1px solid var(--v-theme-border);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-panel-inner.panel-stacked {
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
.session-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-list-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持功能 */
|
||||
.chat-container-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-container-wrapper {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-dialog v-model="isOpen" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>{{ tm('multiChat.selectSessions') }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="close" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="mb-3 text-subtitle-2 text-medium-emphasis">
|
||||
{{ tm('multiChat.selectTip') }}
|
||||
</div>
|
||||
|
||||
<v-list density="compact" class="session-select-list">
|
||||
<v-list-item
|
||||
v-for="session in sessions"
|
||||
:key="session.session_id"
|
||||
@click="toggleSession(session.session_id)"
|
||||
:class="{ 'selected-session': isSelected(session.session_id) }"
|
||||
class="session-item"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-checkbox
|
||||
:model-value="isSelected(session.session_id)"
|
||||
hide-details
|
||||
class="session-checkbox"
|
||||
@click.stop="toggleSession(session.session_id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>
|
||||
{{ session.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ new Date(session.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-if="sessions.length === 0" class="text-center py-8 text-medium-emphasis">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-message-text-outline</v-icon>
|
||||
<div class="mt-2">{{ tm('conversation.noHistory') }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="close">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="confirm"
|
||||
:disabled="selectedSessionIds.length < 2"
|
||||
>
|
||||
{{ tm('multiChat.enterMultiMode') }} ({{ selectedSessionIds.length }})
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'confirm': [sessionIds: string[]];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const isOpen = ref(props.modelValue);
|
||||
const selectedSessionIds = ref<string[]>([]);
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
isOpen.value = newVal;
|
||||
if (newVal) {
|
||||
selectedSessionIds.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
});
|
||||
|
||||
function isSelected(sessionId: string): boolean {
|
||||
return selectedSessionIds.value.includes(sessionId);
|
||||
}
|
||||
|
||||
function toggleSession(sessionId: string) {
|
||||
const index = selectedSessionIds.value.indexOf(sessionId);
|
||||
if (index > -1) {
|
||||
selectedSessionIds.value.splice(index, 1);
|
||||
} else {
|
||||
selectedSessionIds.value.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (selectedSessionIds.value.length >= 2) {
|
||||
emit('confirm', [...selectedSessionIds.value]);
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-select-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.selected-session {
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
}
|
||||
|
||||
.selected-session:hover {
|
||||
background-color: rgba(103, 58, 183, 0.12);
|
||||
}
|
||||
|
||||
.session-checkbox {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -89,6 +89,12 @@
|
||||
"noSessions": "No conversations in this project",
|
||||
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
||||
},
|
||||
"multiChat": {
|
||||
"multiMode": "Multi-Chat Mode",
|
||||
"selectSessions": "Select Conversations",
|
||||
"selectTip": "Select at least 2 conversations to enter multi-chat mode",
|
||||
"enterMultiMode": "Enter Multi-Chat Mode"
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
|
||||
@@ -91,6 +91,12 @@
|
||||
"noSessions": "该项目暂无对话",
|
||||
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
||||
},
|
||||
"multiChat": {
|
||||
"multiMode": "多对话模式",
|
||||
"selectSessions": "选择对话",
|
||||
"selectTip": "至少选择2个对话进入多对话模式",
|
||||
"enterMultiMode": "进入多对话模式"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天"
|
||||
|
||||
Reference in New Issue
Block a user