perf: 中断打字

This commit is contained in:
rd
2025-08-28 14:46:57 +08:00
parent 6074f716ef
commit cdda8ffba7
4 changed files with 121 additions and 82 deletions

View File

@ -1,6 +1,29 @@
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { BubbleListProps } from '@/components/xt-chat/xt-bubble/types'; import type { BubbleListProps } from '@/components/xt-chat/xt-bubble/types';
export interface UseChatHandlerReturn {
roles?: BubbleListProps['roles'];
generateTeamRunTaskId?: Ref<string | null>;
handleMessage?: (parsedData: { event: string; data: MESSAGE.Answer }) => void;
handleOpen?: (data: Response) => void;
generateLoading?: Ref<boolean>;
conversationList?: Ref<any[]>;
showRightView?: Ref<boolean>;
rightViewData?: Ref<any>;
senderRef?: Ref<null>
}
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<void>; // 明确 initSse 带参
}
// 定义角色常量 // 定义角色常量
export const LOADING_ROLE = 'loading'; // 加载中 export const LOADING_ROLE = 'loading'; // 加载中
export const INTELLECTUAL_THINKING_ROLE = 'intellectual_thinking'; // 智能思考标题 export const INTELLECTUAL_THINKING_ROLE = 'intellectual_thinking'; // 智能思考标题
@ -22,26 +45,6 @@ export const ANSWER_STYLE = {
left: '6px', left: '6px',
}; };
export interface UseChatHandlerReturn {
roles?: BubbleListProps['roles'];
generateTeamRunTaskId?: Ref<string | null>;
handleMessage?: (parsedData: { event: string; data: MESSAGE.Answer }) => void;
handleOpen?: (data: Response) => void;
generateLoading?: Ref<boolean>;
conversationList?: Ref<any[]>;
showRightView?: Ref<boolean>;
rightViewData?: Ref<any>;
senderRef?: Ref<null>
}
export enum EnumTeamRunStatus {
TeamRunStarted = 'TeamRunStarted', // 对话开始
TeamRunResponseContent = 'TeamRunResponseContent', // 对话执行中
TeamRunCompleted = 'TeamRunCompleted', // 对话完成
RunStarted = 'RunStarted', // l2开始运行
RunResponseContent = 'RunResponseContent', // l2执行中
RunCompleted = 'RunCompleted', // l2完成
}
export const FILE_TYPE = { export const FILE_TYPE = {
topic_only: 'topic_only', // 排期&选题 topic_only: 'topic_only', // 排期&选题
topic_with_content: 'topic_with_content', // 选题&内容稿件 topic_with_content: 'topic_with_content', // 选题&内容稿件

View File

@ -31,18 +31,19 @@ import {
REMOTE_ASSISTANT_ROLE, REMOTE_ASSISTANT_ROLE,
FILE_TYPE_MAP, FILE_TYPE_MAP,
} from './constants'; } from './constants';
import type { UseChatHandlerReturn } from './constants'; import type { UseChatHandlerReturn, UseChatHandlerOptions } from './constants';
/** /**
* 聊天处理器Hook * 聊天处理器Hook
* @returns 包含角色配置、消息处理函数和对话列表的对象 * @returns 包含角色配置、消息处理函数和对话列表的对象
*/ */
export default function useChatHandler({ initSse }): UseChatHandlerReturn { export default function useChatHandler(options: UseChatHandlerOptions): UseChatHandlerReturn {
const { initSse } = options;
// 在内部定义对话列表 // 在内部定义对话列表
const { copy } = useClipboard(); const { copy } = useClipboard();
const senderRef = ref(null); const senderRef = ref(null);
const conversationList = ref<any[]>([]); const conversationList = ref<MESSAGE.Answer[]>([]);
const generateLoading = ref<boolean>(false); const generateLoading = ref<boolean>(false);
const generateTeamRunTaskId = ref<string | null>(null); const generateTeamRunTaskId = ref<string | null>(null);
const showRightView = ref(false); const showRightView = ref(false);

View File

@ -1,23 +1,16 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-08-20 23:26:23
* @Description: 打字效果Hook
* 实现文本内容的渐进式显示,模拟打字机效果
*/
import useState from '@/hooks/useState'; 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 { Ref } from 'vue';
import type { BubbleContentType } from '../types'; import type { BubbleContentType } from '../types';
/** function isString(content: BubbleContentType): content is string {
* 类型守卫:检查值是否为字符串类型 return typeof content === 'string';
* @param str - 待检查的值
* @returns 是否为字符串
*/
function isString(str: any): str is string {
return typeof str === 'string';
} }
// 类型守卫:判断是否为非字符串类型(用于快速排除)
function isNonString(content: BubbleContentType): content is Exclude<BubbleContentType, string> {
return !isString(content);
}
/** /**
* 打字效果Hook * 打字效果Hook
* 当启用打字效果时,返回渐进式显示的内容和打字状态 * 当启用打字效果时,返回渐进式显示的内容和打字状态
@ -40,75 +33,115 @@ const useTypedEffect = (
const [prevContent, setPrevContent] = useState<BubbleContentType>(''); const [prevContent, setPrevContent] = useState<BubbleContentType>('');
// 当前打字位置(已显示的字符数) // 当前打字位置(已显示的字符数)
const [typingIndex, setTypingIndex] = useState<number>(1); const [typingIndex, setTypingIndex] = useState<number>(1);
let timer: number | null = null;
// 合并打字启用状态:只有在启用打字内容字符串时生效 // 合并启用状态:仅当 启用打字内容字符串 时生效
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value)); const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
// 监听内容变化,重置打字索引 // 清理定时器
const clearTypingTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
// 监听内容变化,重置打字状态
watch( watch(
content, content,
() => { (newVal) => {
const prevContentValue = unref(prevContent); const prevVal = unref(prevContent);
// 更新上一次的内容记录 setPrevContent(newVal);
setPrevContent(content.value);
// 如果未启用打字效果且内容为字符串 // 非字符串类型:直接返回原始值,不执行打字逻辑
if (!mergedTypingEnabled.value && isString(content.value)) { if (isNonString(newVal)) {
// 若外部触发中止,则保持当前索引,不再自动跳到全文 setTypingIndex(0); // 重置索引(无意义,仅为状态一致)
clearTypingTimer();
return;
}
// 字符串类型的逻辑
if (!mergedTypingEnabled.value) {
if (!(abortRef && abortRef.value)) { if (!(abortRef && abortRef.value)) {
setTypingIndex(content.value.length); setTypingIndex(newVal.length); // 直接显示完整内容
} }
} else if ( } else if (
// 如果内容为字符串,且新内容不是以旧内容开头,重置打字索引 isString(prevVal) &&
isString(content.value) && newVal.indexOf(prevVal) !== 0 // 新内容不是旧内容的前缀,重置打字
isString(prevContentValue) &&
content.value.indexOf(prevContentValue) !== 0
) { ) {
setTypingIndex(1); setTypingIndex(1);
} }
clearTypingTimer();
}, },
{ immediate: true }, { immediate: true },
); );
// 启动打字效果 // 打字逻辑(仅处理字符串类型)
watch( const startTyping = () => {
[typingIndex, typingEnabled, content, abortRef as any], clearTypingTimer();
() => {
// 只有在启用打字、内容为字符串且未显示完所有内容时才执行
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(() => { if (!mergedTypingEnabled.value || isNonString(content.value) || (abortRef && abortRef.value)) {
clearTimeout(id); 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(
[typingEnabled, abortRef, () => content.value], // 监听 content 变化
() => {
startTyping();
}, },
{ immediate: true }, { immediate: true },
); );
// 计算当前应该显示的内容 // 计算当前显示的内容(核心:仅对字符串调用 slice
const mergedTypingContent = computed(() => const mergedTypingContent = computed(() => {
// 如果启用打字且内容为字符串,显示部分内容; const currentContent = content.value;
// 或外部中止时,固定在当前索引;否则显示全部内容
(mergedTypingEnabled.value || (abortRef && abortRef.value)) && isString(content.value) // 非字符串类型:直接返回原始值(不处理打字)
? content.value.slice(0, unref(typingIndex)) if (isNonString(currentContent)) {
: content.value, 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, onUnmounted(() => {
// 计算是否正在打字:启用打字 + 内容为字符串 + 未显示完所有内容 clearTypingTimer();
computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length), });
];
return [mergedTypingContent, isTyping];
}; };
export default useTypedEffect; export default useTypedEffect;

View File

@ -5,6 +5,7 @@ declare global {
interface Answer { interface Answer {
message?: string; message?: string;
role?: string;
node?: string; node?: string;
output?: string; output?: string;
run_id?: string; run_id?: string;
@ -20,6 +21,7 @@ declare global {
type: string; type: string;
data: Record<string, any>; data: Record<string, any>;
}; };
[key: string]: any;
} }
} }
} }