perf: 中断打字
This commit is contained in:
@ -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', // 选题&内容稿件
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user