578 lines
16 KiB
Vue
578 lines
16 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 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);
|
||
|
||
// 提取非图片文件URL(txt, 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>
|