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

474 lines
13 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, Suggestion } 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[],
options?: {
deepSearch?: boolean;
webSearch?: boolean;
deepThinking?: boolean;
systemPrompt?: string;
},
) {
console.log("handleSend", text, attachments, options);
// 检查是否还有正在上传的附件
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();
}
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
const existingMessages = currentConversation.value?.messages || [];
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮40 条消息)
const historyMessages = existingMessages
.filter(
(m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
)
.filter((m: any) => m.content?.text) // 过滤掉空消息
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
// 添加用户消息
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,
model: settings.value.defaultModel,
stream: true,
history: historyMessages,
deepSearch: options?.deepSearch,
webSearch: options?.webSearch,
deepThinking: options?.deepThinking,
systemPrompt: options?.systemPrompt,
},
abortController.value.signal,
);
let fullText = "";
let reasoningText = "";
let isInReasoning = false;
isTyping.value = false;
for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break;
if (chunk.type === "reasoning") {
// 深度思考内容
if (!isInReasoning) {
// 开始深度思考块
reasoningText = "";
isInReasoning = true;
fullText += "<think>\n";
}
reasoningText += chunk.text;
fullText += chunk.text;
} else {
// 普通内容
if (isInReasoning) {
// 结束深度思考块
isInReasoning = false;
fullText += "\n</think>\n";
}
fullText += chunk.text;
}
chatStore.updateMessageContent(aiMessage.id, fullText);
}
// 如果最后还在深度思考块中,关闭它
if (isInReasoning) {
fullText += "\n</think>";
}
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;
// 提取重试位置之前的历史消息(用于上下文记忆)
const MAX_HISTORY_ROUNDS = 20;
const priorMessages = messages.value
.slice(0, messageIndex - 1) // 不包含当前 user 消息和要重试的 assistant 消息
.filter(
(m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
)
.filter((m: any) => m.content?.text)
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
// 重置消息状态
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,
history: priorMessages,
},
abortController.value.signal,
);
let fullText = "";
let reasoningText = "";
let isInReasoning = false;
for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break;
if (chunk.type === "reasoning") {
// 深度思考内容
if (!isInReasoning) {
// 开始深度思考块
reasoningText = "";
isInReasoning = true;
fullText += "<think>\n";
}
reasoningText += chunk.text;
fullText += chunk.text;
} else {
// 普通内容
if (isInReasoning) {
// 结束深度思考块
isInReasoning = false;
fullText += "\n</think>\n";
}
fullText += chunk.text;
}
chatStore.updateMessageContent(messageId, fullText);
}
// 如果最后还在深度思考块中,关闭它
if (isInReasoning) {
fullText += "\n</think>";
}
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(suggestion: Suggestion) {
handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt });
}
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;
border-radius: 15px;
min-height: 0;
.dark & {
background: #11111b;
}
&.wide-mode {
.input-container {
min-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 150px 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
background: linear-gradient(to top, #11111b 80%, transparent);
}
}
.input-container {
width: 100%;
min-width: 1000px;
// margin: 0 auto;
transition: max-width 0.3s ease;
&.wide {
min-width: 1000px;
}
}
</style>