From cdda8ffba7a84aa130ec7e43a4f394914ebfd90b Mon Sep 17 00:00:00 2001 From: rd <1344903914@qq.com> Date: Thu, 28 Aug 2025 14:46:57 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=B8=AD=E6=96=AD=E6=89=93=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/xt-chat/chat-view/constants.ts | 43 ++--- .../xt-chat/chat-view/useChatHandler.tsx | 7 +- .../xt-chat/xt-bubble/hooks/useTypedEffect.ts | 151 +++++++++++------- src/types/message.ts | 2 + 4 files changed, 121 insertions(+), 82 deletions(-) diff --git a/src/components/xt-chat/chat-view/constants.ts b/src/components/xt-chat/chat-view/constants.ts index f223056..a0a23a1 100644 --- a/src/components/xt-chat/chat-view/constants.ts +++ b/src/components/xt-chat/chat-view/constants.ts @@ -1,6 +1,29 @@ import type { Ref } from 'vue'; import type { BubbleListProps } from '@/components/xt-chat/xt-bubble/types'; +export interface UseChatHandlerReturn { + roles?: BubbleListProps['roles']; + generateTeamRunTaskId?: Ref; + handleMessage?: (parsedData: { event: string; data: MESSAGE.Answer }) => void; + handleOpen?: (data: Response) => void; + generateLoading?: Ref; + conversationList?: Ref; + showRightView?: Ref; + rightViewData?: Ref; + senderRef?: Ref +} +export enum EnumTeamRunStatus { + TeamRunStarted = 'TeamRunStarted', // 对话开始 + TeamRunResponseContent = 'TeamRunResponseContent', // 对话执行中 + TeamRunCompleted = 'TeamRunCompleted', // 对话完成 + RunStarted = 'RunStarted', // l2开始运行 + RunResponseContent = 'RunResponseContent', // l2执行中 + RunCompleted = 'RunCompleted', // l2完成 +} +export interface UseChatHandlerOptions { + initSse: (inputInfo: CHAT.TInputInfo) => Promise; // 明确 initSse 带参 +} + // 定义角色常量 export const LOADING_ROLE = 'loading'; // 加载中 export const INTELLECTUAL_THINKING_ROLE = 'intellectual_thinking'; // 智能思考标题 @@ -22,26 +45,6 @@ export const ANSWER_STYLE = { left: '6px', }; -export interface UseChatHandlerReturn { - roles?: BubbleListProps['roles']; - generateTeamRunTaskId?: Ref; - handleMessage?: (parsedData: { event: string; data: MESSAGE.Answer }) => void; - handleOpen?: (data: Response) => void; - generateLoading?: Ref; - conversationList?: Ref; - showRightView?: Ref; - rightViewData?: Ref; - senderRef?: Ref -} -export enum EnumTeamRunStatus { - TeamRunStarted = 'TeamRunStarted', // 对话开始 - TeamRunResponseContent = 'TeamRunResponseContent', // 对话执行中 - TeamRunCompleted = 'TeamRunCompleted', // 对话完成 - RunStarted = 'RunStarted', // l2开始运行 - RunResponseContent = 'RunResponseContent', // l2执行中 - RunCompleted = 'RunCompleted', // l2完成 -} - export const FILE_TYPE = { topic_only: 'topic_only', // 排期&选题 topic_with_content: 'topic_with_content', // 选题&内容稿件 diff --git a/src/components/xt-chat/chat-view/useChatHandler.tsx b/src/components/xt-chat/chat-view/useChatHandler.tsx index ebb9d22..48ca636 100644 --- a/src/components/xt-chat/chat-view/useChatHandler.tsx +++ b/src/components/xt-chat/chat-view/useChatHandler.tsx @@ -31,18 +31,19 @@ import { REMOTE_ASSISTANT_ROLE, FILE_TYPE_MAP, } from './constants'; -import type { UseChatHandlerReturn } from './constants'; +import type { UseChatHandlerReturn, UseChatHandlerOptions } from './constants'; /** * 聊天处理器Hook * @returns 包含角色配置、消息处理函数和对话列表的对象 */ -export default function useChatHandler({ initSse }): UseChatHandlerReturn { +export default function useChatHandler(options: UseChatHandlerOptions): UseChatHandlerReturn { + const { initSse } = options; // 在内部定义对话列表 const { copy } = useClipboard(); const senderRef = ref(null); - const conversationList = ref([]); + const conversationList = ref([]); const generateLoading = ref(false); const generateTeamRunTaskId = ref(null); const showRightView = ref(false); diff --git a/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts index deb13a4..d28d183 100644 --- a/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts +++ b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts @@ -1,23 +1,16 @@ -/* - * @Author: RenXiaoDong - * @Date: 2025-08-20 23:26:23 - * @Description: 打字效果Hook - * 实现文本内容的渐进式显示,模拟打字机效果 - */ import useState from '@/hooks/useState'; -import { computed, onWatcherCleanup, unref, watch } from 'vue'; +import { computed, onUnmounted, unref, watch } from 'vue'; import type { Ref } from 'vue'; import type { BubbleContentType } from '../types'; -/** - * 类型守卫:检查值是否为字符串类型 - * @param str - 待检查的值 - * @returns 是否为字符串 - */ -function isString(str: any): str is string { - return typeof str === 'string'; +function isString(content: BubbleContentType): content is string { + return typeof content === 'string'; } +// 类型守卫:判断是否为非字符串类型(用于快速排除) +function isNonString(content: BubbleContentType): content is Exclude { + return !isString(content); +} /** * 打字效果Hook * 当启用打字效果时,返回渐进式显示的内容和打字状态 @@ -40,75 +33,115 @@ const useTypedEffect = ( const [prevContent, setPrevContent] = useState(''); // 当前打字位置(已显示的字符数) const [typingIndex, setTypingIndex] = useState(1); + let timer: number | null = null; - // 合并打字启用状态:只有在启用打字且内容为字符串时才生效 + // 合并启用状态:仅当 启用打字 且 内容是字符串 时生效 const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value)); - // 监听内容变化,重置打字索引 + // 清理定时器 + const clearTypingTimer = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + + // 监听内容变化,重置打字状态 watch( content, - () => { - const prevContentValue = unref(prevContent); - // 更新上一次的内容记录 - setPrevContent(content.value); + (newVal) => { + const prevVal = unref(prevContent); + setPrevContent(newVal); - // 如果未启用打字效果且内容为字符串 - if (!mergedTypingEnabled.value && isString(content.value)) { - // 若外部触发中止,则保持当前索引,不再自动跳到全文 + // 非字符串类型:直接返回原始值,不执行打字逻辑 + if (isNonString(newVal)) { + setTypingIndex(0); // 重置索引(无意义,仅为状态一致) + clearTypingTimer(); + return; + } + + // 字符串类型的逻辑 + if (!mergedTypingEnabled.value) { if (!(abortRef && abortRef.value)) { - setTypingIndex(content.value.length); + setTypingIndex(newVal.length); // 直接显示完整内容 } } else if ( - // 如果内容为字符串,且新内容不是以旧内容开头,重置打字索引 - isString(content.value) && - isString(prevContentValue) && - content.value.indexOf(prevContentValue) !== 0 + isString(prevVal) && + newVal.indexOf(prevVal) !== 0 // 新内容不是旧内容的前缀,重置打字 ) { setTypingIndex(1); } + clearTypingTimer(); }, { immediate: true }, ); - // 启动打字效果 + // 打字逻辑(仅处理字符串类型) + const startTyping = () => { + clearTypingTimer(); + + // 终止条件:未启用打字 / 内容不是字符串 / 已中止 + if (!mergedTypingEnabled.value || isNonString(content.value) || (abortRef && abortRef.value)) { + return; + } + + const currentContent = content.value; // 此时 TypeScript 可推断为 string(因为 mergedTypingEnabled 已过滤) + const currentIndex = unref(typingIndex); + + // 已打完所有字符,停止 + if (currentIndex >= currentContent.length) { + return; + } + + // 计算下一步索引(避免超出长度) + const nextIndex = Math.min(currentIndex + typingStep.value, currentContent.length); + + timer = window.setTimeout(() => { + setTypingIndex(nextIndex); + startTyping(); // 递归触发下一次打字 + }, typingInterval.value); + }; + + // 监听打字相关依赖变化 watch( - [typingIndex, typingEnabled, content, abortRef as any], + [typingEnabled, abortRef, () => content.value], // 监听 content 变化 () => { - // 只有在启用打字、内容为字符串且未显示完所有内容时才执行 - if ( - mergedTypingEnabled.value && - isString(content.value) && - unref(typingIndex) < content.value.length && - !(abortRef && abortRef.value) - ) { - // 设置定时器,逐步增加显示字符数 - const id = setTimeout(() => { - setTypingIndex(unref(typingIndex) + typingStep.value); - }, typingInterval.value); - - // 清理定时器,避免内存泄漏 - onWatcherCleanup(() => { - clearTimeout(id); - }); - } + startTyping(); }, { immediate: true }, ); - // 计算当前应该显示的内容 - const mergedTypingContent = computed(() => - // 如果启用打字且内容为字符串,显示部分内容; - // 或外部中止时,固定在当前索引;否则显示全部内容 - (mergedTypingEnabled.value || (abortRef && abortRef.value)) && isString(content.value) - ? content.value.slice(0, unref(typingIndex)) - : content.value, + // 计算当前显示的内容(核心:仅对字符串调用 slice) + const mergedTypingContent = computed(() => { + const currentContent = content.value; + + // 非字符串类型:直接返回原始值(不处理打字) + if (isNonString(currentContent)) { + return currentContent; + } + + // 字符串类型:根据打字状态返回部分/完整内容 + if (mergedTypingEnabled.value || (abortRef && abortRef.value)) { + return currentContent.slice(0, unref(typingIndex)); // 此时确定是 string,可安全调用 slice + } + + return currentContent; // 不启用打字时,返回完整字符串 + }); + + // 是否正在打字(仅字符串类型可能为 true) + const isTyping = computed( + () => + mergedTypingEnabled.value && + unref(typingIndex) < content.value.length && // 此时 content.value 已被 mergedTypingEnabled 限定为 string + !(abortRef && abortRef.value), ); - return [ - mergedTypingContent, - // 计算是否正在打字:启用打字 + 内容为字符串 + 未显示完所有内容 - computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length), - ]; + // 组件卸载时清理定时器 + onUnmounted(() => { + clearTypingTimer(); + }); + + return [mergedTypingContent, isTyping]; }; export default useTypedEffect; diff --git a/src/types/message.ts b/src/types/message.ts index bd33fde..b10a012 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -5,6 +5,7 @@ declare global { interface Answer { message?: string; + role?: string; node?: string; output?: string; run_id?: string; @@ -20,6 +21,7 @@ declare global { type: string; data: Record; }; + [key: string]: any; } } }