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

578 lines
16 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 v-if="hasAttachments" class="attachments-preview-container">
<AttachmentPreview
:attachments="currentAttachments"
@remove="handleRemoveAttachment"
/>
</div>
<div class="input-container" :class="{ wide: isWideMode }">
<ChatInput
ref="chatInputRef"
:placeholder="inputPlaceholder"
:is-streaming="isStreaming"
:send-on-enter="settings.sendOnEnter"
:disabled="false"
:supports_thinking="currentModelCapabilities.supports_thinking"
:supports_web_search="currentModelCapabilities.supports_web_search"
:supports_vision="currentModelCapabilities.supports_vision"
:supports_files="currentModelCapabilities.supports_files"
@send="handleSend"
@stop="handleStop"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
import AttachmentPreview from "@/components/input/AttachmentPreview.vue";
import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment, Suggestion } from "@/types/chat";
import { chatApi, type ModelInfo } from "@/services/api";
defineEmits<{
"toggle-sidebar": [];
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
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 availableModels = ref<ModelInfo[]>([]);
// 获取模型列表
onMounted(async () => {
try {
availableModels.value = await chatApi.getModels();
} catch (error) {
console.error("获取模型列表失败:", error);
}
});
// 当前模型的能力
const currentModelCapabilities = computed(() => {
const modelId = settings.value.defaultModel;
const model = availableModels.value.find((m) => m.id === modelId);
console.log(model);
if (model) {
return {
supports_thinking: model.supports_thinking ?? false,
supports_web_search: model.supports_web_search ?? false,
supports_vision: model.supports_vision ?? false,
supports_files: model.supports_files ?? false,
};
}
// 默认全部支持
return {
supports_thinking: true,
supports_web_search: true,
supports_vision: true,
supports_files: true,
};
});
const messages: any = computed(() => currentConversation.value?.messages || []);
const inputPlaceholder = computed(() => {
if (isStreaming.value) return "正在生成回复...";
return "输入你的问题,按 Ctrl+Enter 发送";
});
// 附件相关
const currentAttachments = computed(() => chatInputRef.value?.attachments || []);
const hasAttachments = computed(() => currentAttachments.value.length > 0);
function handleRemoveAttachment(id: string) {
chatInputRef.value?.removeAttachment(id);
}
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;
skipUserMessage?: boolean;
conversationTitle?: string;
},
) {
// 检查认证状态
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
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) {
await chatStore.createConversation(options?.conversationTitle || text);
} else if (currentConversation.value.title === "新对话") {
// 如果当前对话是"新对话",用传入的标题或用户输入重命名
chatStore.renameConversation(
currentConversation.value.id,
options?.conversationTitle || text
);
}
// 获取系统提示词(优先使用传入的,否则使用会话设置)
const systemPrompt = options?.systemPrompt || currentConversation.value?.settings?.systemPrompt;
// 检查是否需要添加系统消息
const existingMessages = currentConversation.value?.messages || [];
const hasSystemMessage = existingMessages.some((m: any) => m.role === MessageRole.SYSTEM);
// 如果有系统提示词且对话中没有系统消息,添加系统消息
if (systemPrompt && !hasSystemMessage) {
await chatStore.addMessage(MessageRole.SYSTEM, {
type: MessageType.TEXT,
text: systemPrompt,
});
}
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
const updatedMessages = currentConversation.value?.messages || [];
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮40 条消息)
const historyMessages = updatedMessages.filter((m: any) => m.content?.text) // 过滤掉空消息
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
// 添加用户消息(如果不需要跳过)
if (!options?.skipUserMessage) {
await 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 = await 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: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : 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) {
// 检查认证状态
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
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,
skipUserMessage: true,
conversationTitle: suggestion.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;
border-radius: 15px;
min-height: 0;
.dark & {
background: #11111b;
}
&.wide-mode {
.input-container {
// min-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 10% 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
background: linear-gradient(to top, #11111b 80%, transparent);
}
}
.attachments-preview-container {
margin-bottom: 12px;
background: #f3f4f5;
border-radius: 16px;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.input-container {
width: 100%;
// min-width: 1000px;
// margin: 0 auto;
transition: max-width 0.3s ease;
&.wide {
// min-width: 1000px;
}
}
</style>