380 lines
10 KiB
Vue
380 lines
10 KiB
Vue
<template>
|
||
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
|
||
<!-- 头部 -->
|
||
<ChatHeader
|
||
:title="currentConversation?.title || '新对话'"
|
||
:message-count="messages.length"
|
||
:show-sidebar-toggle="sidebarCollapsed"
|
||
:is-wide-mode="isWideMode"
|
||
:is-pinned="currentConversation?.pinned"
|
||
@toggle-sidebar="$emit('toggle-sidebar')"
|
||
@toggle-wide-mode="toggleWideMode"
|
||
@clear="handleClear"
|
||
@export="handleExport"
|
||
@pin="handlePin"
|
||
/>
|
||
|
||
<!-- 消息列表 -->
|
||
<MessageList
|
||
ref="messageListRef"
|
||
:messages="messages"
|
||
:show-timestamp="settings.showTimestamp"
|
||
:compact="settings.compactMode"
|
||
:is-typing="isTyping"
|
||
@retry="handleRetry"
|
||
@regenerate="handleRegenerate"
|
||
@select-suggestion="handleSuggestion"
|
||
/>
|
||
|
||
<!-- 输入区域 -->
|
||
<div class="input-wrapper">
|
||
<div class="input-container" :class="{ wide: isWideMode }">
|
||
<ChatInput
|
||
ref="chatInputRef"
|
||
:placeholder="inputPlaceholder"
|
||
:is-streaming="isStreaming"
|
||
:send-on-enter="settings.sendOnEnter"
|
||
:disabled="false"
|
||
@send="handleSend"
|
||
@stop="handleStop"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick } from "vue";
|
||
import { storeToRefs } from "pinia";
|
||
import { useChatStore } from "@/stores/chat";
|
||
import { useSettingsStore } from "@/stores/settings";
|
||
import ChatHeader from "./ChatHeader.vue";
|
||
import MessageList from "./MessageList.vue";
|
||
import ChatInput from "@/components/input/ChatInput.vue";
|
||
import { MessageType, MessageRole } from "@/types/chat";
|
||
import type { Attachment } from "@/types/chat";
|
||
import { chatApi } from "@/services/api";
|
||
|
||
defineEmits<{
|
||
"toggle-sidebar": [];
|
||
}>();
|
||
|
||
const chatStore = useChatStore();
|
||
const settingsStore = useSettingsStore();
|
||
|
||
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
||
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
||
|
||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||
const isWideMode = ref(true);
|
||
const isTyping = ref(false);
|
||
const currentStreamingMessageId = ref<string | null>(null);
|
||
const abortController: any = ref<AbortController | null>(null);
|
||
|
||
const messages: any = computed(() => currentConversation.value?.messages || []);
|
||
|
||
const inputPlaceholder = computed(() => {
|
||
if (isStreaming.value) return "正在生成回复...";
|
||
return "输入你的问题,按 Ctrl+Enter 发送";
|
||
});
|
||
|
||
function toggleWideMode() {
|
||
isWideMode.value = !isWideMode.value;
|
||
}
|
||
|
||
function handleClear() {
|
||
if (currentConversation.value) {
|
||
chatStore.clearConversation(currentConversation.value.id);
|
||
}
|
||
}
|
||
|
||
function handleExport() {
|
||
if (!currentConversation.value) return;
|
||
|
||
const data = {
|
||
title: currentConversation.value.title,
|
||
messages: currentConversation.value.messages,
|
||
exportedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||
type: "application/json",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${currentConversation.value.title}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function handlePin() {
|
||
if (currentConversation.value) {
|
||
chatStore.togglePinConversation(currentConversation.value.id);
|
||
}
|
||
}
|
||
|
||
// 发送消息 - 使用真实 API
|
||
async function handleSend(text: string, attachments: Attachment[]) {
|
||
// 检查是否还有正在上传的附件
|
||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
||
if (uploadingAttachments.length > 0) {
|
||
// 等待所有上传完成
|
||
const uploads = uploadingAttachments.map(async (attachment) => {
|
||
// 这里我们可以通过检查附件状态来判断是否上传完成
|
||
// 但更简单的方法是等待一小段时间,让上传有机会完成
|
||
return new Promise<void>((resolve) => {
|
||
const checkUpload = () => {
|
||
const stillUploading = attachments.some(
|
||
(a) => a.id === attachment.id && a.uploading,
|
||
);
|
||
if (!stillUploading) {
|
||
resolve();
|
||
} else {
|
||
setTimeout(checkUpload, 100); // 每100ms检查一次
|
||
}
|
||
};
|
||
checkUpload();
|
||
});
|
||
});
|
||
|
||
try {
|
||
await Promise.all(uploads);
|
||
} catch (error) {
|
||
console.error("等待上传完成时发生错误:", error);
|
||
}
|
||
}
|
||
|
||
// 如果没有当前对话,创建新对话
|
||
if (!currentConversation.value) {
|
||
chatStore.createConversation();
|
||
}
|
||
|
||
// 添加用户消息
|
||
chatStore.addMessage(MessageRole.USER, {
|
||
type: MessageType.TEXT,
|
||
text,
|
||
images: attachments.filter((a) => a.type === "image"),
|
||
files: attachments.filter((a) => a.type === "file"),
|
||
});
|
||
|
||
// 添加 AI 消息占位符
|
||
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
||
type: MessageType.TEXT,
|
||
text: "",
|
||
});
|
||
|
||
currentStreamingMessageId.value = aiMessage.id;
|
||
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
|
||
chatStore.startStreaming();
|
||
isTyping.value = true;
|
||
|
||
// 创建 AbortController
|
||
abortController.value = new AbortController();
|
||
try {
|
||
// 提取图片URL用于发送给API
|
||
const imageUrls = attachments
|
||
.filter((a) => a.type === "image")
|
||
.map((a) => a.url);
|
||
|
||
// 提取非图片文件URL(txt, pdf, docx 等)
|
||
const fileUrls = attachments
|
||
.filter((a) => a.type === "file")
|
||
.map((a) => a.url);
|
||
|
||
const stream = chatApi.streamChat(
|
||
{
|
||
message: text,
|
||
conversationId: currentConversation.value?.id || "",
|
||
images: imageUrls,
|
||
files: fileUrls, // 传递文件 URL,后端会读取内容
|
||
model: settings.value.defaultModel,
|
||
stream: true,
|
||
},
|
||
abortController.value.signal,
|
||
);
|
||
|
||
let fullText = "";
|
||
isTyping.value = false;
|
||
|
||
for await (const chunk of stream) {
|
||
if (abortController.value?.signal.aborted) break;
|
||
fullText += chunk;
|
||
chatStore.updateMessageContent(aiMessage.id, fullText);
|
||
}
|
||
|
||
if (!abortController.value?.signal.aborted) {
|
||
chatStore.updateMessage(aiMessage.id, {
|
||
isStreaming: false,
|
||
content: {
|
||
type: MessageType.TEXT,
|
||
text: fullText,
|
||
},
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
if (error.name !== "AbortError") {
|
||
chatStore.updateMessage(aiMessage.id, {
|
||
isStreaming: false,
|
||
isError: true,
|
||
errorMessage: error.message || "请求失败",
|
||
});
|
||
}
|
||
} finally {
|
||
chatStore.stopStreaming();
|
||
currentStreamingMessageId.value = null;
|
||
}
|
||
}
|
||
|
||
// 停止生成
|
||
function handleStop() {
|
||
if (abortController.value) {
|
||
abortController.value.abort();
|
||
abortController.value = null;
|
||
}
|
||
|
||
chatStore.stopStreaming();
|
||
chatApi.stopChat(messages.value.at(-1)["messageId"]);
|
||
if (currentStreamingMessageId.value) {
|
||
chatStore.updateMessage(currentStreamingMessageId.value, {
|
||
isStreaming: false,
|
||
});
|
||
currentStreamingMessageId.value = null;
|
||
}
|
||
}
|
||
|
||
// 重试
|
||
async function handleRetry(messageId: string) {
|
||
const message = messages.value.find((m: any) => m.id === messageId);
|
||
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
||
|
||
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
|
||
if (messageIndex <= 0) return;
|
||
|
||
const userMessage = messages.value[messageIndex - 1];
|
||
if (userMessage.role !== MessageRole.USER) return;
|
||
|
||
// 重置消息状态
|
||
chatStore.updateMessage(messageId, {
|
||
isError: false,
|
||
errorMessage: undefined,
|
||
isStreaming: true,
|
||
isEnd: true,
|
||
content: { type: MessageType.TEXT, text: "" },
|
||
});
|
||
|
||
currentStreamingMessageId.value = messageId;
|
||
chatStore.startStreaming();
|
||
abortController.value = new AbortController();
|
||
|
||
try {
|
||
const stream = chatApi.streamChat(
|
||
{
|
||
message: userMessage.content.text || "",
|
||
conversationId: currentConversation.value?.id,
|
||
model: settings.value.defaultModel,
|
||
stream: true,
|
||
},
|
||
abortController.value.signal,
|
||
);
|
||
|
||
let fullText = "";
|
||
|
||
for await (const chunk of stream) {
|
||
if (abortController.value?.signal.aborted) break;
|
||
fullText += chunk;
|
||
chatStore.updateMessageContent(messageId, fullText);
|
||
}
|
||
|
||
if (!abortController.value?.signal.aborted) {
|
||
chatStore.updateMessage(messageId, {
|
||
isStreaming: false,
|
||
content: {
|
||
type: MessageType.TEXT,
|
||
text: fullText,
|
||
},
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
if (error.name !== "AbortError") {
|
||
chatStore.updateMessage(messageId, {
|
||
isStreaming: false,
|
||
isError: true,
|
||
errorMessage: error.message || "请求失败",
|
||
});
|
||
}
|
||
} finally {
|
||
chatStore.stopStreaming();
|
||
currentStreamingMessageId.value = null;
|
||
}
|
||
}
|
||
|
||
function handleRegenerate(messageId: string) {
|
||
handleRetry(messageId);
|
||
}
|
||
|
||
function handleSuggestion(text: string) {
|
||
handleSend(text, []);
|
||
}
|
||
|
||
function focusInput() {
|
||
chatInputRef.value?.focus();
|
||
}
|
||
|
||
defineExpose({
|
||
focusInput,
|
||
messageListRef,
|
||
});
|
||
|
||
watch(
|
||
() => currentConversation.value?.id,
|
||
() => {
|
||
nextTick(() => {
|
||
focusInput();
|
||
});
|
||
},
|
||
);
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.chat-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
height: 100vh;
|
||
background: #ffffff;
|
||
overflow: hidden;
|
||
|
||
.dark & {
|
||
background: #11111b;
|
||
}
|
||
|
||
&.wide-mode {
|
||
.input-container {
|
||
max-width: 1000px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.input-wrapper {
|
||
flex-shrink: 0;
|
||
padding: 16px 24px 24px;
|
||
background: linear-gradient(to top, white 80%, transparent);
|
||
|
||
.dark & {
|
||
background: linear-gradient(to top, #11111b 80%, transparent);
|
||
}
|
||
}
|
||
|
||
.input-container {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
transition: max-width 0.3s ease;
|
||
|
||
&.wide {
|
||
max-width: 1000px;
|
||
}
|
||
}
|
||
</style>
|