ai-chat-ui/src/components/chat/ChatMain.vue

380 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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);
// 提取非图片文件URLtxt, 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>