({});
+
+ // 初始化markdown
+ const md = markdownit({
+ html: true,
+ breaks: true,
+ linkify: true,
+ typographer: true,
+ });
+
+ // 定义角色配置
+ const roles: BubbleListProps['roles'] = {
+ [LOADING_ROLE]: {
+ placement: 'start',
+ variant: 'borderless',
+ style: { ...ROLE_STYLE, paddingLeft: '12px' },
+ },
+ [INTELLECTUAL_THINKING_ROLE]: {
+ placement: 'start',
+ variant: 'borderless',
+ typing: { step: 2, interval: 100 },
+ style: ROLE_STYLE,
+ },
+ [ANSWER_ROLE]: {
+ placement: 'start',
+ variant: 'borderless',
+ typing: { step: 2, interval: 100 },
+ // onTypingComplete: () => {
+ // generateTeamRunTaskId.value = null;
+ // },
+ style: ROLE_STYLE,
+ },
+ [QUESTION_ROLE]: {
+ placement: 'end',
+ shape: 'round',
+ style: ROLE_STYLE,
+ },
+ [REMOTE_USER_ROLE]: {
+ placement: 'end',
+ shape: 'round',
+ style: ROLE_STYLE,
+ },
+ [REMOTE_ASSISTANT_ROLE]: {
+ placement: 'start',
+ variant: 'borderless',
+ style: ROLE_STYLE,
+ messageRender: (message: string) => {
+ return ;
+ },
+ footer: (params) => {
+ const { content, item } = params as { content: string; item: MESSAGE.Answer };
+ const isShow = conversationList.value[conversationList.value.length - 1].run_id === item.run_id;
+ return (
+
+ onCopy(content)}>
+
+
+ {isShow && (
+ handleRemoteRefresh(item)}>
+
+
+ )}
+
+ );
+ },
+ },
+ };
+
+ // 下载处理
+ const onDownload = () => {
+ console.log('onDownload', rightViewData.value);
+ };
+
+ const onCopy = (content: string) => {
+ copy(content);
+ antdMessage.success('复制成功!');
+ };
+
+ // 重置生成状态
+ const resetGenerateStatus = () => {
+ generateLoading.value = false;
+ generateTeamRunTaskId.value = null;
+ };
+
+ const handleRemoteRefresh = (item: MESSAGE.Answer) => {
+ generateLoading.value = true;
+
+ const targetIndex = conversationList.value.findIndex(
+ (v) => v.teamRunTaskId === item.teamRunTaskId && v.run_id === item.run_id && v.role === REMOTE_ASSISTANT_ROLE,
+ );
+ const message = conversationList.value[targetIndex - 1]?.content;
+ conversationList.value.splice(targetIndex, 1);
+
+ initSse({ message });
+ };
+
+ const onRefresh = (run_id: string) => {
+ generateLoading.value = true;
+
+ const targetIndex = conversationList.value.findIndex((v) => v.teamRunTaskId === run_id);
+ conversationList.value = conversationList.value.filter((item) => item.teamRunTaskId !== run_id);
+ const message = conversationList.value[targetIndex - 1]?.content;
+ initSse({ message });
+ };
+
+ const getAllRunTask = (teamRunTaskId: string) => {
+ return conversationList.value.filter(
+ (item) => item.role === ANSWER_ROLE && item.teamRunTaskId === teamRunTaskId && !item.isTeamRunTask,
+ );
+ };
+ const getRunTask = (run_id: string) => {
+ return conversationList.value.find((item) => item.run_id === run_id && !item.isTeamRunTask);
+ };
+ // 设置当前对话所有思考过程任务展开收起状态
+ const setRunTaskCollapse = (teamRunTaskId: string, isCollapse: boolean) => {
+ getAllRunTask(teamRunTaskId).forEach((item) => {
+ item.content.isCollapse = isCollapse;
+ });
+ };
+ // 获取同一个对话下的最后一个run_task
+ const getLastRunTask = (teamRunTaskId: string) => {
+ const allRunTask = getAllRunTask(teamRunTaskId);
+ return allRunTask[allRunTask.length - 1] ?? {};
+ };
+ const getFirstRunTask = (teamRunTaskId: string) => {
+ const allRunTask = getAllRunTask(teamRunTaskId);
+ return allRunTask[0] ?? {};
+ };
+ // 判断当前对话是否含有过程任务
+ const hasRunTask = (teamRunTaskId: string) => {
+ return conversationList.value.some((item) => item.teamRunTaskId === teamRunTaskId && !item.isTeamRunTask);
+ };
+
+ const getTeamRunTask = (teamRunTaskId: string) => {
+ return conversationList.value.find((item) => item.teamRunTaskId === teamRunTaskId);
+ };
+ const isLastRunTask = (data: MESSAGE.Answer): boolean => {
+ const { teamRunTaskId, run_id } = data;
+ return getLastRunTask(teamRunTaskId).run_id === run_id;
+ };
+ const isFirstRunTask = (data: MESSAGE.Answer): boolean => {
+ const { teamRunTaskId, run_id } = data;
+ return getFirstRunTask(teamRunTaskId).run_id === run_id;
+ };
+
+ // 过程节点开始
+ const handleRunTaskStart = (data: MESSAGE.Answer) => {
+ const { run_id } = data;
+ // generateTeamRunTaskId.value = run_id;
+ conversationList.value.push({
+ run_id,
+ key: run_id,
+ teamRunTaskId: generateTeamRunTaskId.value,
+ content: { ...data, runStatus: EnumTeamRunStatus.RunStarted, teamRunTaskId: generateTeamRunTaskId.value },
+ output: data.output,
+ role: ANSWER_ROLE,
+ messageRender: (data: MESSAGE.Answer) => {
+ const { node, output, runStatus, isCollapse = true, customRender, teamRunTaskId } = data;
+ const isRulCompleted = runStatus === EnumTeamRunStatus.RunCompleted;
+
+ let outputEleClass: string = `thought-chain-output border-l-#E6E6E8 border-l-1px pl-12px relative left-6px mb-4px`;
+ !isLastRunTask(data) && (outputEleClass += ' hasLine pb-12px pt-4px');
+
+ return (
+ <>
+ {isFirstRunTask(data) && (
+ setRunTaskCollapse(teamRunTaskId, !isCollapse)}
+ >
+ 智能思考
+ {isCollapse ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {isCollapse ? (
+
+
+

+
{node}
+
+
+
+ ) : (
+ customRender?.()
+ )}
+ >
+ );
+ },
+ });
+ };
+ // 过程节点更新
+ const handleRunTaskUpdate = (data: MESSAGE.Answer) => {
+ const { run_id, output } = data;
+
+ const existingItem = conversationList.value.find((item) => item.run_id === run_id);
+ if (existingItem && output) {
+ existingItem.content.output += output;
+ existingItem.content.runStatus = EnumTeamRunStatus.RunResponseContent;
+ }
+ };
+ // 过程节点结束
+ const handleRunTaskEnd = (data: MESSAGE.Answer) => {
+ const { output } = data;
+
+ const existingItem = getRunTask(data.run_id);
+
+ if (existingItem) {
+ existingItem.content.output += output;
+ existingItem.content.runStatus = EnumTeamRunStatus.RunCompleted;
+ }
+ };
+
+ // 任务开始
+ const handleTeamRunTaskStart = (data: MESSAGE.Answer) => {
+ const { run_id } = data;
+ generateTeamRunTaskId.value = run_id;
+ conversationList.value.push({
+ run_id,
+ isTeamRunTask: true,
+ teamRunTaskId: generateTeamRunTaskId.value,
+ key: run_id,
+ content: { ...data, teamRunStatus: EnumTeamRunStatus.TeamRunStarted, teamRunTaskId: run_id },
+ output: data.output,
+ role: ANSWER_ROLE,
+ messageRender: (data: MESSAGE.Answer) => {
+ return ;
+ },
+ });
+ };
+ // 任务更新
+ const handleTeamRunTaskUpdate = (data: MESSAGE.Answer) => {
+ const { run_id, output } = data;
+ const existingItem = conversationList.value.find((item) => item.run_id === run_id);
+ if (existingItem && output) {
+ existingItem.content.output += output;
+ existingItem.content.teamRunStatus = EnumTeamRunStatus.TeamRunResponseContent;
+ }
+ };
+ // 任务结束
+ const handleTeamRunTaskEnd = (data: MESSAGE.Answer) => {
+ resetGenerateStatus();
+
+ const { run_id: teamRunTaskId, extra_data, output } = data;
+
+ const _hasRunTask = hasRunTask(teamRunTaskId);
+ const _targetTask = _hasRunTask ? getLastRunTask(teamRunTaskId) : getTeamRunTask(teamRunTaskId);
+
+ if (isEmpty(_targetTask)) {
+ return;
+ }
+
+ // 含有思考过程,折叠思考过程,展示结果
+ if (_hasRunTask) {
+ setRunTaskCollapse(teamRunTaskId, false);
+ if (extra_data) {
+ showRightView.value = true;
+ rightViewData.value = extra_data.data;
+ }
+
+ _targetTask.content.customRender = () => (
+ <>
+
+ {extra_data && (
+
+
+
+
+
+ 创建时间:{exactFormatTime(dayjs().unix())}
+
+
+
+ )}
+ >
+ );
+ } else {
+ _targetTask.content.teamRunStatus = EnumTeamRunStatus.TeamRunCompleted;
+ }
+
+ _targetTask.footer = () => {
+ const isShow = conversationList.value[conversationList.value.length - 1].teamRunTaskId === teamRunTaskId;
+ return (
+
+ {!extra_data && (
+ // ? (
+ //
+ //
+ //
+ // ) :
+ onCopy(_targetTask.content.output)}>
+
+
+ )}
+ {isShow && (
+ onRefresh(teamRunTaskId)}>
+
+
+ )}
+
+ );
+ };
+ };
+
+ // 消息处理主函数
+ const handleMessage = (parsedData: { event: string; data: MESSAGE.Answer }) => {
+ const { data } = parsedData;
+ const { status } = data;
+ switch (status) {
+ case EnumTeamRunStatus.RunStarted:
+ handleRunTaskStart(data);
+ break;
+ case EnumTeamRunStatus.RunResponseContent:
+ handleRunTaskUpdate(data);
+ break;
+ case EnumTeamRunStatus.RunCompleted:
+ handleRunTaskEnd(data);
+ break;
+ case EnumTeamRunStatus.TeamRunStarted:
+ handleTeamRunTaskStart(data);
+ break;
+ case EnumTeamRunStatus.TeamRunResponseContent:
+ handleTeamRunTaskUpdate(data);
+ break;
+ case EnumTeamRunStatus.TeamRunCompleted:
+ handleTeamRunTaskEnd(data);
+ break;
+ default:
+ break;
+ }
+ };
+
+ return {
+ roles,
+ senderRef,
+ generateTeamRunTaskId,
+ handleMessage,
+ generateLoading,
+ conversationList,
+ showRightView,
+ rightViewData,
+ };
+}
diff --git a/src/components/xt-chat/xt-bubble/context.ts b/src/components/xt-chat/xt-bubble/context.ts
new file mode 100644
index 0000000..25303c0
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/context.ts
@@ -0,0 +1,81 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:17:49
+ * @Description: 气泡上下文管理
+ * 提供气泡组件间的通信机制,支持全局状态管理和组件间数据传递
+ */
+import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue";
+import type { ComputedRef, InjectionKey } from "vue";
+import { objectType } from "@/utils/type";
+import type { BubbleContextProps } from "./types";
+
+/**
+ * 气泡上下文注入键
+ * 用于Vue的依赖注入系统,确保上下文的唯一性
+ */
+const BubbleContextKey: InjectionKey> =
+ Symbol('BubbleContext');
+
+/**
+ * 全局气泡上下文API
+ * 提供全局访问气泡上下文的能力,用于跨组件通信
+ */
+export const globalBubbleContextApi = shallowRef();
+
+/**
+ * 气泡上下文提供者Hook
+ * 向子组件提供气泡上下文,并同步更新全局API
+ *
+ * @param value - 要提供的上下文值(计算属性)
+ */
+export const useBubbleContextProvider = (value: ComputedRef) => {
+ // 向子组件提供上下文
+ provide(BubbleContextKey, value);
+
+ // 监听上下文变化,同步更新全局API
+ watch(
+ value,
+ () => {
+ globalBubbleContextApi.value = unref(value);
+ // 触发响应式更新
+ triggerRef(globalBubbleContextApi);
+ },
+ { immediate: true, deep: true }, // 立即执行,深度监听
+ );
+};
+
+/**
+ * 气泡上下文注入Hook
+ * 从父组件或全局API获取气泡上下文
+ *
+ * @returns 气泡上下文(计算属性)
+ */
+export const useBubbleContextInject = () => {
+ return inject(
+ BubbleContextKey,
+ // 如果没有找到注入的上下文,使用全局API作为后备
+ computed(() => globalBubbleContextApi.value || {}),
+ );
+};
+
+/**
+ * 气泡上下文提供者组件
+ * 用于在模板中提供气泡上下文,简化使用方式
+ */
+export const BubbleContextProvider = defineComponent({
+ props: {
+ // 上下文值,支持对象类型验证
+ value: objectType(),
+ },
+ setup(props, { slots }) {
+ // 使用计算属性包装props.value,确保响应式
+ useBubbleContextProvider(computed(() => props.value));
+
+ // 渲染默认插槽内容
+ return () => {
+ return slots.default?.();
+ };
+ },
+});
+
+export default BubbleContextProvider;
\ No newline at end of file
diff --git a/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts b/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts
new file mode 100644
index 0000000..932eef6
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/hooks/useDisplayData.ts
@@ -0,0 +1,87 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:12:42
+ * @Description: 聊天气泡列表数据显示逻辑Hook
+ * 用于控制气泡列表的渐进式显示,实现打字效果和动态加载
+ */
+import { computed, unref, watch } from 'vue';
+import type { Ref } from 'vue';
+import { useEventCallback } from '@/hooks/useEventCallback';
+import useState from '@/hooks/useState';
+import type { ListItemType } from './useListData';
+
+/**
+ * Hook返回值类型定义
+ * @returns [displayList: 当前显示的数据列表, onTypingComplete: 打字完成回调函数]
+ */
+type UseDisplayDataReturn = [Ref, (value: string | number) => void];
+
+/**
+ * 数据显示逻辑Hook
+ * 实现气泡列表的渐进式显示,支持打字效果和动态加载
+ *
+ * @param items - 完整的数据列表引用
+ * @returns [displayList, onTypingComplete] - 当前显示列表和打字完成回调
+ */
+export default function useDisplayData(items: Ref): UseDisplayDataReturn {
+ // 当前显示的数据条数,初始值为完整列表的长度
+ const [displayCount, setDisplayCount] = useState(items.value.length);
+
+ // 计算当前应该显示的数据列表(从完整列表中截取前displayCount条)
+ const displayList = computed(() => items.value.slice(0, unref(displayCount)));
+
+ // 获取当前显示列表中最后一条数据的key,用于判断是否继续显示下一条
+ const displayListLastKey = computed(() => {
+ const lastItem = unref(displayList)[unref(displayList).length - 1];
+ return lastItem ? lastItem.key : null;
+ });
+
+ // 监听完整数据列表的变化,智能调整显示数量
+ watch(
+ items,
+ () => {
+ // 当数据列表变化时,先尝试显示所有数据
+ setDisplayCount(items.value.length);
+
+ // 检查当前显示列表是否与完整列表的前N项完全匹配
+ // 如果匹配,说明数据没有变化,保持当前显示状态
+ if (
+ unref(displayList).length &&
+ unref(displayList).every((item, index) => item.key === items.value[index]?.key)
+ ) {
+ return;
+ }
+
+ // 如果当前没有显示任何数据,显示第一条
+ if (unref(displayList).length === 0) {
+ setDisplayCount(1);
+ } else {
+ // 找到第一个不匹配的位置,将显示数量设置为该位置
+ // 这样可以保持已显示数据的连续性,避免跳跃
+ for (let i = 0; i < unref(displayList).length; i += 1) {
+ if (unref(displayList)[i].key !== items.value[i]?.key) {
+ setDisplayCount(i);
+ break;
+ }
+ }
+ }
+ },
+ { immediate: true, deep: true }, // 立即执行,深度监听
+ );
+
+ /**
+ * 打字完成回调函数
+ * 当某个气泡的打字效果完成时,显示下一条数据
+ *
+ * @param key - 完成打字的项目key
+ */
+ const onTypingComplete = useEventCallback((key: string | number) => {
+ // 只有当完成打字的是当前显示列表的最后一条时,才显示下一条
+ // 这确保了显示的顺序性和连续性
+ if (key === unref(displayListLastKey)) {
+ setDisplayCount(unref(displayCount) + 1);
+ }
+ });
+
+ return [displayList, onTypingComplete] as const;
+}
diff --git a/src/components/xt-chat/xt-bubble/hooks/useListData.ts b/src/components/xt-chat/xt-bubble/hooks/useListData.ts
new file mode 100644
index 0000000..7ab47b1
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/hooks/useListData.ts
@@ -0,0 +1,79 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:14:55
+ * @Description: 气泡列表数据处理Hook
+ * 用于处理气泡数据列表,支持角色配置和属性合并
+ */
+import { computed, type Ref } from 'vue';
+import type { BubbleDataType, BubbleListProps } from '../types';
+import type { BubbleProps } from '../types';
+
+/**
+ * 类型工具:从Ref类型中提取原始类型
+ * @template T - Ref类型
+ * @returns 原始类型
+ */
+export type UnRef> = T extends Ref ? R : never;
+
+/**
+ * 列表项类型定义
+ * 从useListData返回值中提取单个列表项的类型
+ */
+export type ListItemType = UnRef>[number];
+
+/**
+ * 气泡列表数据处理Hook
+ * 处理原始气泡数据,应用角色配置,生成最终的气泡属性
+ *
+ * @param items - 原始气泡数据列表
+ * @param roles - 角色配置,可以是对象或函数
+ * @returns 处理后的气泡数据列表
+ */
+export default function useListData(
+ items: Ref,
+ roles?: Ref,
+) {
+ /**
+ * 获取角色对应的气泡属性配置
+ * 根据气泡的角色类型,返回对应的样式和属性配置
+ *
+ * @param bubble - 气泡数据
+ * @param index - 气泡在列表中的索引
+ * @returns 角色对应的气泡属性
+ */
+ const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial => {
+ // 如果roles是函数,调用函数获取配置
+ if (typeof roles.value === 'function') {
+ return roles.value(bubble, index);
+ }
+
+ // 如果roles是对象,根据bubble.role获取对应配置
+ if (roles) {
+ return roles.value?.[bubble.role!] || {};
+ }
+
+ // 如果没有角色配置,返回空对象
+ return {};
+ }
+
+ /**
+ * 计算处理后的列表数据
+ * 合并角色配置和原始数据,生成最终的气泡属性
+ */
+ const listData = computed(() =>
+ (items.value || []).map((bubbleData, i) => {
+ // 生成唯一key:优先使用传入的 key,其次使用 id,最后回退到预设格式
+ const mergedKey = (bubbleData as any).key ?? (bubbleData as any).id ?? `preset_${i}`;
+
+ return {
+ // 先应用角色配置(作为默认值)
+ ...getRoleBubbleProps(bubbleData, i),
+ // 再应用原始数据(会覆盖角色配置中的相同属性)
+ ...bubbleData,
+ // 最后设置key(确保唯一性)
+ key: mergedKey,
+ };
+ }));
+
+ return listData as Ref;
+}
\ No newline at end of file
diff --git a/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts
new file mode 100644
index 0000000..26509b1
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/hooks/useTypedEffect.ts
@@ -0,0 +1,147 @@
+import useState from '@/hooks/useState';
+import { computed, onUnmounted, unref, watch } from 'vue';
+import type { Ref } from 'vue';
+import type { BubbleContentType } from '../types';
+
+function isString(content: BubbleContentType): content is string {
+ return typeof content === 'string';
+}
+
+// 类型守卫:判断是否为非字符串类型(用于快速排除)
+function isNonString(content: BubbleContentType): content is Exclude {
+ return !isString(content);
+}
+/**
+ * 打字效果Hook
+ * 当启用打字效果时,返回渐进式显示的内容和打字状态
+ * 否则直接返回原始内容
+ *
+ * @param content - 原始内容
+ * @param typingEnabled - 是否启用打字效果
+ * @param typingStep - 每次显示的字符数
+ * @param typingInterval - 打字间隔时间(毫秒)
+ * @returns [typedContent: 当前显示的内容, isTyping: 是否正在打字]
+ */
+const useTypedEffect = (
+ content: Ref,
+ typingEnabled: Ref,
+ typingStep: Ref,
+ typingInterval: Ref,
+ abortRef?: Ref,
+): [typedContent: Ref, isTyping: Ref] => {
+ // 记录上一次的内容,用于检测内容变化
+ 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,
+ (newVal) => {
+ const prevVal = unref(prevContent);
+ setPrevContent(newVal);
+
+ // 非字符串类型:直接返回原始值,不执行打字逻辑
+ if (isNonString(newVal)) {
+ setTypingIndex(0); // 重置索引(无意义,仅为状态一致)
+ clearTypingTimer();
+ return;
+ }
+
+ // 字符串类型的逻辑
+ if (!mergedTypingEnabled.value) {
+ if (!(abortRef && abortRef.value)) {
+ setTypingIndex(newVal.length); // 直接显示完整内容
+ }
+ } else if (
+ 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(
+ [typingEnabled, abortRef, () => content.value], // 监听 content 变化
+ () => {
+ startTyping();
+ },
+ { immediate: true },
+ );
+
+ // 计算当前显示的内容(核心:仅对字符串调用 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 as string).length && // 此时 content.value 已被 mergedTypingEnabled 限定为 string
+ !(abortRef && abortRef.value),
+ );
+
+ // 组件卸载时清理定时器
+ onUnmounted(() => {
+ clearTypingTimer();
+ });
+
+ return [mergedTypingContent, isTyping];
+};
+
+export default useTypedEffect;
diff --git a/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts b/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts
new file mode 100644
index 0000000..ecdcc8d
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/hooks/useTypingConfig.ts
@@ -0,0 +1,61 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:27:05
+ * @Description: 打字配置Hook
+ * 处理打字效果的配置参数,提供默认值和配置合并功能
+ */
+import { computed, toValue, type MaybeRefOrGetter } from 'vue';
+import type { BubbleProps, TypingOption } from '../types';
+
+/**
+ * 打字配置Hook
+ * 解析打字配置参数,提供默认值,返回配置化的打字参数
+ *
+ * @param typing - 打字配置,可以是布尔值、配置对象或null
+ * @returns [typingEnabled, step, interval, suffix] - 打字启用状态、步长、间隔、后缀
+ */
+function useTypingConfig(typing: MaybeRefOrGetter) {
+ /**
+ * 计算是否启用打字效果
+ * 只有当typing为真值时才启用打字效果
+ */
+ const typingEnabled = computed(() => {
+ if (!toValue(typing)) {
+ return false;
+ }
+ return true;
+ });
+
+ /**
+ * 基础配置:提供默认的打字参数
+ * 当用户没有提供完整配置时,使用这些默认值
+ */
+ const baseConfig: Required = {
+ step: 1, // 每次显示的字符数
+ interval: 50, // 打字间隔时间(毫秒)
+ suffix: null, // 打字时的后缀(默认为空)
+ };
+
+ /**
+ * 合并配置:将用户配置与默认配置合并
+ * 用户配置会覆盖默认配置中的相同属性
+ */
+ const config = computed(() => {
+ const typingRaw = toValue(typing);
+ return {
+ ...baseConfig,
+ // 如果typing是对象,则合并对象属性;否则使用默认配置
+ ...(typeof typingRaw === 'object' ? typingRaw : {})
+ }
+ });
+
+ // 返回解构的配置参数,方便使用
+ return [
+ typingEnabled, // 是否启用打字效果
+ computed(() => config.value.step), // 每次显示的字符数
+ computed(() => config.value.interval), // 打字间隔时间
+ computed(() => config.value.suffix) // 打字后缀
+ ] as const;
+}
+
+export default useTypingConfig;
\ No newline at end of file
diff --git a/src/components/xt-chat/xt-bubble/index.ts b/src/components/xt-chat/xt-bubble/index.ts
new file mode 100644
index 0000000..75d4bce
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/index.ts
@@ -0,0 +1,9 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 22:16:14
+ */
+export { default as Bubble } from './xt-bubble.vue';
+export { default as BubbleList } from './xt-bubbleList.vue';
+export * from './types';
+
+
diff --git a/src/components/xt-chat/xt-bubble/loading.vue b/src/components/xt-chat/xt-bubble/loading.vue
new file mode 100644
index 0000000..1a8e64f
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/loading.vue
@@ -0,0 +1,66 @@
+
+
+
+
diff --git a/src/components/xt-chat/xt-bubble/style.scss b/src/components/xt-chat/xt-bubble/style.scss
new file mode 100644
index 0000000..6d2f469
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/style.scss
@@ -0,0 +1,146 @@
+.xt-bubble-list {
+ height: 100%;
+ gap: 8px;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+}
+
+.xt-bubble {
+ display: flex;
+ justify-content: start;
+ column-gap: 12px;
+ @mixin cts {
+ color: var(--Text-1, #211f24);
+ font-family: $font-family-regular;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 22px;
+ }
+ &.xt-bubble-start {
+ flex-direction: row;
+ }
+
+ &.xt-bubble-end {
+ justify-content: end;
+ flex-direction: row-reverse;
+ }
+
+ .thought-chain-item {
+ position: relative;
+ margin: 0;
+ font-size: 14px;
+ list-style: none;
+ .thought-chain-output {
+ position: relative;
+ &.hasLine {
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-left: 1px solid #E6E6E8;
+ }
+ }
+ }
+ }
+
+ .xt-bubble-avatar {
+ }
+
+ .xt-bubble-content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ min-width: 0;
+ max-width: 100%;
+ @include cts;
+ &-filled {
+ background-color: #f2f3f5;
+ padding: 6px 12px;
+ border-radius: 10px;
+ }
+
+ &-outlined {
+ background-color: #fff;
+ border: 1px solid #e5e6eb;
+ padding: 6px 12px;
+ border-radius: 10px;
+ }
+
+ &-shadow {
+ background-color: #fff;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ padding: 6px 12px;
+ border-radius: 10px;
+ }
+
+ &-borderless {
+ background-color: transparent !important;
+ }
+
+ // 形状样式
+ &-default {
+ border-radius: 10px;
+ }
+
+ &-round {
+ padding: 6px 12px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 16px;
+ background-color: var(--BG-200, #f2f3f5);
+ }
+
+ &-corner {
+ border-radius: 4px;
+ }
+
+ :deep(table) {
+ border-collapse: collapse;
+ thead {
+ tr {
+ th {
+ @include cts;
+ padding: 6px 8px;
+ border: 1px solid #E6E6E8;
+ text-align: left;
+
+ }
+ }
+ }
+ tbody {
+ tr {
+ td {
+ @include cts;
+ border: 1px solid #E6E6E8;
+ padding: 16px 8px;
+ text-align: left;
+ }
+ }
+ }
+ }
+ }
+
+ .xt-bubble-header {
+ @include cts;
+ margin-bottom: 6px;
+ }
+
+ .xt-bubble-footer {
+ @include cts;
+ margin-top: 6px;
+ }
+
+ &.xt-bubble-typing {
+ display: inline-block;
+ min-width: 36px;
+ }
+
+}
diff --git a/src/components/xt-chat/xt-bubble/types.ts b/src/components/xt-chat/xt-bubble/types.ts
new file mode 100644
index 0000000..333d7f4
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/types.ts
@@ -0,0 +1,193 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 22:04:54
+ * @Description: 聊天气泡组件的类型定义
+ */
+import type { AvatarProps } from 'ant-design-vue';
+import type { CSSProperties, HTMLAttributes, VNode } from 'vue';
+
+/**
+ * 避免类型验证的包装类型
+ * 用于包装可能引起TypeScript严格检查问题的类型
+ */
+export type AvoidValidation = T;
+
+/**
+ * 打字效果配置选项
+ * 控制消息的打字动画效果
+ */
+export interface TypingOption {
+ /**
+ * 每次打字的字符数
+ * @default 1
+ */
+ step?: number;
+ /**
+ * 打字间隔时间(毫秒)
+ * @default 50
+ */
+ interval?: number;
+ /**
+ * 打字时的后缀显示内容
+ * @default null
+ */
+ suffix?: VNode | string;
+}
+
+/**
+ * 语义化类型
+ * 定义气泡组件中各个部分的语义化标识
+ */
+export type SemanticType = 'avatar' | 'content' | 'header' | 'footer';
+
+/**
+ * 气泡内容类型
+ * 支持多种内容格式:VNode、字符串、对象、数字等
+ */
+export type BubbleContentType = VNode | string | Record | number;
+
+/**
+ * 插槽信息类型
+ * 传递给插槽函数的额外信息
+ */
+export type SlotInfoType = {
+ /** 气泡的唯一标识键 */
+ key?: string | number;
+};
+
+/**
+ * 头像属性扩展接口
+ * 继承自ant-design-vue的AvatarProps,添加了class和style属性
+ */
+export interface _AvatarProps extends AvatarProps {
+ /** 自定义CSS类名 */
+ class: string;
+ /** 自定义内联样式 */
+ style: CSSProperties;
+}
+
+/**
+ * 气泡组件属性接口
+ * 定义气泡组件的所有可配置属性
+ */
+export interface BubbleProps
+ extends /* @vue-ignore */ Omit {
+ /** 组件前缀类名 */
+ prefixCls?: string;
+ /** 根元素的自定义类名 */
+ rootClassName?: string;
+ /** 各部分的样式配置 */
+ styles?: Partial>;
+ /** 各部分的类名配置 */
+ classNames?: Partial>;
+ /** 头像配置:可以是属性对象、VNode或渲染函数 */
+ avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
+ /** 气泡位置:start(左侧) | end(右侧) */
+ placement?: 'start' | 'end';
+ /** 是否显示加载状态 */
+ loading?: boolean;
+ /** 打字效果配置:可以是配置对象或布尔值 */
+ typing?: AvoidValidation;
+ /** 气泡内容 */
+ content?: ContentType;
+ /** 自定义消息渲染函数 */
+ messageRender?: (content: ContentType) => VNode | string;
+ /** 自定义加载状态渲染函数 */
+ loadingRender?: () => VNode;
+ /** 气泡样式变体:filled(填充) | borderless(无边框) | outlined(轮廓) | shadow(阴影) */
+ variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
+ /** 气泡形状:round(圆角) | corner(直角) */
+ shape?: 'round' | 'corner';
+ /** 内部使用的唯一标识键 */
+ _key?: number | string;
+ /** 打字完成时的回调函数 */
+ onTypingComplete?: VoidFunction;
+ /** 头部内容:可以是VNode、字符串或渲染函数 */
+ header?: AvoidValidation<
+ VNode | string | ((params: { content: ContentType; info: SlotInfoType; item: BubbleProps }) => VNode | string)
+ >;
+ /** 底部内容:可以是VNode、字符串或渲染函数 */
+ footer?: AvoidValidation<
+ VNode | string | ((params: { content: ContentType; info: SlotInfoType; item: BubbleProps }) => VNode | string)
+ >;
+}
+
+/**
+ * 气泡组件引用接口
+ * 提供对气泡组件DOM元素的访问
+ */
+export interface BubbleRef {
+ /** 气泡组件的原生DOM元素 */
+ nativeElement: HTMLElement;
+ /** 中止当前打字效果并立即展示完整内容 */
+ abortTyping: VoidFunction;
+}
+
+/**
+ * 气泡上下文属性接口
+ * 用于气泡组件间的通信
+ */
+export interface BubbleContextProps {
+ /** 更新回调函数 */
+ onUpdate?: VoidFunction;
+}
+
+/**
+ * 气泡列表引用接口
+ * 提供对气泡列表组件的访问和控制方法
+ */
+export interface BubbleListRef {
+ /** 气泡列表的原生DOM元素 */
+ nativeElement: HTMLDivElement;
+ /** 滚动到指定位置的方法 */
+ scrollTo: (info: {
+ /** 滚动偏移量 */
+ offset?: number;
+ /** 目标气泡的键值 */
+ key?: string | number;
+ /** 滚动行为:smooth(平滑) | auto(自动) */
+ behavior?: ScrollBehavior;
+ /** 滚动位置:start | center | end | nearest */
+ block?: ScrollLogicalPosition;
+ }) => void;
+}
+
+/**
+ * 气泡数据类型
+ * 扩展了BubbleProps,添加了key和role属性
+ */
+export type BubbleDataType = BubbleProps & {
+ /** 气泡的唯一标识键 */
+ key?: string | number;
+ /** 气泡的角色类型 */
+ role?: string;
+};
+
+/**
+ * 角色类型
+ * 定义不同角色的气泡样式配置
+ */
+export type RoleType = Partial, 'content'>>;
+
+/**
+ * 角色配置类型
+ * 可以是角色配置对象或根据气泡数据和索引返回角色配置的函数
+ */
+export type RolesType = Record | ((bubbleDataP: BubbleDataType, index: number) => RoleType);
+
+/**
+ * 气泡列表属性接口
+ * 定义气泡列表组件的所有可配置属性
+ */
+export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes {
+ /** 组件前缀类名 */
+ prefixCls?: string;
+ /** 根元素的自定义类名 */
+ rootClassName?: string;
+ /** 气泡数据数组 */
+ items?: BubbleDataType[];
+ /** 是否自动滚动到最新消息 */
+ autoScroll?: boolean;
+ /** 角色配置:定义不同角色的气泡样式 */
+ roles?: AvoidValidation;
+}
diff --git a/src/components/xt-chat/xt-bubble/xt-bubble.vue b/src/components/xt-chat/xt-bubble/xt-bubble.vue
new file mode 100644
index 0000000..80ab0e2
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/xt-bubble.vue
@@ -0,0 +1,145 @@
+
+
+
diff --git a/src/components/xt-chat/xt-bubble/xt-bubbleList.vue b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue
new file mode 100644
index 0000000..89026b3
--- /dev/null
+++ b/src/components/xt-chat/xt-bubble/xt-bubbleList.vue
@@ -0,0 +1,164 @@
+
+
+
diff --git a/src/components/xt-chat/xt-conversations/index.vue b/src/components/xt-chat/xt-conversations/index.vue
new file mode 100644
index 0000000..4b6bb33
--- /dev/null
+++ b/src/components/xt-chat/xt-conversations/index.vue
@@ -0,0 +1,180 @@
+
+
+
+
diff --git a/src/components/xt-chat/xt-conversations/style.scss b/src/components/xt-chat/xt-conversations/style.scss
new file mode 100644
index 0000000..59c9b5e
--- /dev/null
+++ b/src/components/xt-chat/xt-conversations/style.scss
@@ -0,0 +1,13 @@
+.xt-conversations-container {
+ width: 100%;
+ height: 100%;
+ overflow-y: auto;
+ :deep(.overflow-text) {
+ color: var(--Text-1, #211f24);
+ font-family: $font-family-regular;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 22px;
+ }
+}
diff --git a/src/config/settings.json b/src/config/settings.json
index ef20b23..cb5615b 100644
--- a/src/config/settings.json
+++ b/src/config/settings.json
@@ -8,7 +8,8 @@
"menuCollapse": false,
"footer": true,
"themeColor": "#165DFF",
- "menuWidth": 220,
+ "menuWidth": 138,
+ "menuWidthFold": 74,
"globalSettings": false,
"device": "desktop",
"tabBar": false,
diff --git a/src/hooks/useEventCallback.ts b/src/hooks/useEventCallback.ts
new file mode 100644
index 0000000..1e21f73
--- /dev/null
+++ b/src/hooks/useEventCallback.ts
@@ -0,0 +1,15 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:12:14
+ */
+import { ref } from 'vue';
+
+export function useEventCallback(handler?: (value: T) => void): (value: T) => void {
+ const callbackRef = ref(handler);
+ const fn = ref((value: T) => {
+ callbackRef.value && callbackRef.value(value);
+ });
+ callbackRef.value = handler;
+
+ return fn.value;
+}
\ No newline at end of file
diff --git a/src/hooks/useState.ts b/src/hooks/useState.ts
new file mode 100644
index 0000000..7393c43
--- /dev/null
+++ b/src/hooks/useState.ts
@@ -0,0 +1,21 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:14:22
+ */
+import type { Ref } from 'vue';
+import { ref } from 'vue';
+
+export default function useState>(
+ defaultStateValue?: T | (() => T),
+): [R, (val: T) => void] {
+ const initValue: T =
+ typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue;
+
+ const innerValue = ref(initValue) as Ref;
+
+ function triggerChange(newValue: T) {
+ innerValue.value = newValue;
+ }
+
+ return [innerValue as unknown as R, triggerChange];
+}
\ No newline at end of file
diff --git a/src/layouts/Basic.vue b/src/layouts/Basic.vue
index 7d179f6..4f31f77 100644
--- a/src/layouts/Basic.vue
+++ b/src/layouts/Basic.vue
@@ -1,5 +1,10 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/Page.vue b/src/layouts/Page.vue
index c17a8a5..7b36d06 100644
--- a/src/layouts/Page.vue
+++ b/src/layouts/Page.vue
@@ -8,7 +8,7 @@ const route = useRoute();
const routerKey = computed(() => {
return route.path + Math.random();
});
-const hideFooter = computed(() => route.meta?.hideFooter);
+// const hideFooter = computed(() => route.meta?.hideFooter);
/*** - end */
@@ -20,10 +20,10 @@ const hideFooter = computed(() => route.meta?.hideFooter);
-