Files
qiming/qiming-mobile/subpackages/pages/chat-conversation-component/chat-conversation-component.uvue

1591 lines
52 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view ref="msgListContentRef" class="agent-detail-container page-container">
<!-- 导航栏 -->
<!-- 临时会话不显示Logo, 显示标题`和xxx开始会话` -->
<custom-nav-bar
:title="headerTitle"
:show-back="!isAppDetails"
:icon="isTempChat ? '' : agentInfo?.icon"
>
<template v-slot:left>
<!-- #ifdef MP-WEIXIN -->
<menu-dropdown
v-if="!isAppDetails"
:list="customMenuList"
:onClick="handleMenuDropdownClick"
/>
<!-- #endif -->
<!-- 应用详情页显示菜单按钮 -->
<!-- #ifdef MP-WEIXIN || H5 || WEB -->
<text
v-if="isAppDetails"
class="iconfont icon-Menu"
@tap="onMenuClick"
></text>
<!-- #endif -->
<!-- #ifdef H5 || WEB -->
<text
v-if="isShowWebView"
class="iconfont icon-Home"
@tap="handleBackHome"
></text>
<!-- #endif -->
</template>
<template v-slot:right>
<!-- #ifdef H5 || WEB -->
<view class="right-buttons">
<view
class="icon-box"
hover-class="icon-box-hover"
@tap="handleMorePopup"
v-if="!isTempChat && !isAppDetails"
>
<text class="iconfont icon-More"></text>
</view>
<template
v-if="
!(
agentInfo?.expandPageArea === ExpandPageAreaEnum.Yes &&
agentInfo?.hideChatArea === HideChatAreaEnum.Yes
)
"
>
<!-- 创建新会话 -->
<view
class="icon-box"
hover-class="icon-box-hover"
@tap="handleCreateNewConversation"
>
<text class="iconfont icon-message-add"></text>
</view>
</template>
</view>
<!-- #endif -->
</template>
</custom-nav-bar>
<!-- 默认展开扩展页面区域,且隐藏聊天区域 -->
<template v-if="isShowWebView">
<web-view
class="w-full"
v-if="pageHomeUrl?.length"
:src="pageHomeUrl + (ticket ? '?_ticket=' + ticket : '')"
id="webview"
></web-view>
</template>
<template v-else>
<view class="chat-container">
<!-- 聊天内容 -->
<view class="scroll-view-box">
<scroll-view
direction="vertical"
class="msg-list"
:scroll-into-view="scrollIntoView"
id="msg-list"
:scroll-with-animation="scrollWithAnimation"
:upper-threshold="50"
@scrolltoupper="handleScrollToUpper"
@scroll="onScroll"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<!-- 加载更多历史消息提示 -->
<view class="loading-more-tip" v-if="isLoadingMoreMessages">
<uni-load-more
status="loading"
:content-text="historyLoadMoreContentText"
/>
</view>
<new-conversation-set
ref="newConversationSetRef"
:variables="variables"
:user-fill-variables="userFillVariables"
:disabled="isSendMessageRef"
@toggleWholeDisabled="handleToggleWholeDisabled"
/>
<view class="msg-list-content" v-if="messageList?.length">
<template
v-for="(item, listIndex) in messageList"
:key="`${item.id}_${item.index}`"
>
<view :id="`msg-wrapper-${item.id}_${item.index}`">
<!-- 智能体会话消息 -->
<uni-ai-x-msg
v-if="item.messageType === MessageTypeEnum.ASSISTANT"
:msg="handleMessageContent(item)"
:is-last-msg="messageList?.length - 1 === listIndex"
:is-conversation-active="isConversationActive"
:task-status="conversationInfo?.taskStatus"
@longpress="onlongTapMsg(convertMessageInfoToMsgItem(item))"
@changeThinkContent="changeThinkContent"
:id="`msg-item-${item.id}_${item.index}`"
:ref="(el) => setMessageRef(el, `${item.id}_${item.index}`)"
></uni-ai-x-msg>
<!-- 用户消息 -->
<template v-else>
<!-- 图片以及文件 -->
<scroll-view
class="files-container"
v-if="item?.attachments?.length"
direction="horizontal"
:scroll-with-animation="true"
:show-scrollbar="false"
>
<view class="files-list">
<view
class="files-box"
:class="
!file?.mimeType?.includes('image/')
? 'files-box-file'
: 'files-box-image'
"
v-for="file in item.attachments"
:key="file.fileKey"
@click="onPreview(file, item?.attachments)"
>
<image
class="image"
v-if="file?.mimeType?.includes('image/')"
:src="file?.fileUrl"
mode="aspectFill"
:alt="
file?.fileName || t('Mobile.Chat.uploadImageAlt')
"
/>
<!-- 文件 -->
<template v-else>
<image
class="doc-image"
:src="docImage"
mode="aspectFill"
:alt="t('Mobile.Chat.uploadFileAlt')"
/>
<view class="doc-info-container">
<view class="doc-name text-ellipsis">{{
file?.fileName
}}</view>
<text class="doc-size text-ellipsis">{{
formatBytes(file?.fileSize)
}}</text>
</view>
</template>
</view>
</view>
</scroll-view>
<rich-text
class="user-msg"
v-if="item?.text"
:user-select="true"
:id="`msg-item-${item.id}_${item.index}`"
:nodes="item.text"
></rich-text>
</template>
</view>
</template>
<view class="content" v-if="chatSuggestList?.length">
<button-wrapper
:chat-suggest-list="chatSuggestList"
@button-click="handleButtonClick"
/>
</view>
<!-- 智能体任务执行中,显示加载中图标 -->
<image
v-if="conversationInfo?.taskStatus === TaskStatus.EXECUTING"
class="icon-loading-image"
src="@/static/assets/icon_loading.svg"
alt=""
:show-menu-by-longpress="false"
@longtap.stop.prevent
@contextmenu.stop.prevent="true"
mode="widthFix"
/>
</view>
<!-- 空状态内容 -->
<view class="content" v-else>
<view class="title-section">
<sub-markdown-msg :text="agentInfo?.openingChatMsg" />
</view>
<button-wrapper
v-if="chatSuggestList?.length"
:chat-suggest-list="chatSuggestList"
@button-click="handleButtonClick"
/>
</view>
<!-- 滚动到底部的锚点 -->
<view style="height: 35px" id="last-msg"></view>
</scroll-view>
<!-- 滚动到底部按钮 -->
<view
:style="{ visibility: scrollInBottom ? 'hidden' : 'visible' }"
class="scroll-to-bottom"
@click="scrollToLastMsg(false)"
>
<text class="iconfont icon-a-Chevrondown"></text>
</view>
</view>
<!-- 输入框 -->
<chat-input-phone
v-if="isRenderInputPhone"
key="agent-details-chat-input"
ref="agentDetailInputPhoneRef"
:page-home-index="agentInfo?.pageHomeIndex || ''"
:expand-page-area="agentInfo?.expandPageArea || ExpandPageAreaEnum.No"
:whole-disabled="wholeDisabled"
:manual-components="agentInfo?.manualComponents || []"
:default-selected-components="selectedComponents"
:is-conversation-active="isConversationActive"
:is-stopping-conversation="isStoppingConversation"
@onSendMessage="handleSendMessage"
@onStopConversation="handleStopConversation"
@onOpenPagePreview="(uri:string)=>handleOpenPagePreview({uri})"
:is-task-agent="agentInfo?.type === AgentTypeEnum.TaskAgent"
@onOpenFileTree="handleOpenFileTree"
:sandbox-list="sandboxList"
:current-sandbox-id="currentSandboxId"
:is-sandbox-switch-disabled="isSandboxLocked"
:is-sandbox-unavailable="isSandboxUnavailable"
:sandbox-disabled-text="sandboxDisabledText"
:readonly="agentInfo?.allowPrivateSandbox === DefaultSelectedEnum.No"
:enable-skill-at="agentInfo?.allowAtSkill === DefaultSelectedEnum.Yes"
:is-temp-chat="isTempChat"
@onSandboxChange="(id: string) => handleSelectSandbox(id)"
:has-permission="agentInfo?.hasPermission ?? true"
:allow-other-model="
agentInfo?.allowOtherModel === DefaultSelectedEnum.Yes
"
:current-model-id="data.currentModelId.value"
:current-model-name="data.currentModelName.value"
:agent-id="agentId"
:agent-type="agentInfo?.type"
:subscription-required="subscriptionRequired"
@onModelChange="(id: number, name: string) => handleSelectModel(id, name)"
@onOpenSubscription="handleOpenSubscription"
/>
</view>
</template>
<more-popup
v-if="!isTempChat"
ref="refMorePopup"
:agent-info="agentInfo"
:is-task-agent="agentInfo?.type === AgentTypeEnum.TaskAgent"
:has-history="messageList.length > 0"
:current-sandbox-id="currentSandboxId"
@collect="handleCollect"
@restartAgent="handleRestartAgent"
@restartSandbox="handleRestartSandbox"
@mySubscription="handleOpenSubscription"
/>
<template v-else>
<!-- #ifdef H5 || WEB-->
<button :id="buttonId" type="button" class="captcha-button" />
<aliyun-captcha-h5
:config="tenantConfigInfo"
:element-id="buttonId"
@doAction="handleCreateTempChat"
@onReady="handleCaptchaReady"
/>
<!-- #endif -->
</template>
<!-- 页面预览 -->
<page-preview-iframe
ref="pagePreviewIframeRef"
@update-visible="updateVisible"
/>
<!-- 文件树 -->
<file-tree
ref="fileTreeRef"
v-if="agentInfo?.type === AgentTypeEnum.TaskAgent"
:cId="conversationId"
:files="fileList"
:is-loading="isLoadingFiles"
@update-visible="updatePopupFileTreeVisible"
/>
<!-- 工具详情弹窗 -->
<tool-details-modal
ref="toolDetailsModalRef"
:title="toolDetailTitle"
:detail-data="toolDetailData"
/>
<!-- 订阅套餐弹窗 -->
<agent-subscription-modal
ref="refSubscriptionModal"
v-if="agentInfo"
:agent-id="agentId"
:current-plan-id="agentInfo?.planId"
:current-plan-price="agentInfo?.price"
@purchase-success="handlePurchaseSuccess"
@update-visible="handleUpdateModalVisible"
/>
</view>
</template>
<script setup lang="uts">
// ==================== 导入模块 ====================
// 分层架构
import AgentDetailData from "./layers/AgentDetailData.uts";
import AgentDetailService from "./layers/AgentDetailService.uts";
import ComponentManager from "./layers/ComponentManager.uts";
import ScrollManager from "./layers/ScrollManager.uts";
// 类型定义
import {
MessageTypeEnum,
HideChatAreaEnum,
ExpandPageAreaEnum,
AgentTypeEnum,
} from "@/types/enums/agent";
import type { ChatInputPhoneRef } from "@/types/interfaces/chat.uts";
import type { MessageInfo } from "@/types/interfaces/conversationInfo.uts";
import type { UploadFileStatus } from "@/types/enums/common.uts";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import {
AttachmentFile,
OpenAppParams,
} from "@/types/interfaces/conversationInfo.uts";
import { EventBindConfig } from "@/types/interfaces/agent";
import type { ShowPagePreviewOptions } from "@/types/interfaces/agent";
import { ProcessingEnum } from "@/types/enums/common.uts";
import { TaskStatus } from "@/types/enums/agent";
import { DefaultSelectedEnum } from "@/types/enums/agent.uts";
import { UploadFileInfo } from "@/types/interfaces/common";
// 组件
import MorePopup from "./components/more-popup/more-popup.uvue";
import AgentSubscriptionModal from "./components/agent-subscription-modal/agent-subscription-modal.uvue";
import SubMarkdownMsg from "@/subpackages/components/markdown-msg/markdown-msg.uvue";
import ChatInputPhone from "@/subpackages/components/chat-input-phone/chat-input-phone.uvue";
import NewConversationSet from "./components/new-conversation-set/new-conversation-set.uvue";
import FileTree from "./components/file-tree/file-tree.uvue";
import ToolDetailsModal from "./components/tool-details-modal/tool-details-modal.uvue";
// 常量
import { SUCCESS_CODE } from "@/constants/codes.constants";
import {
TEMP_CONVERSATION_UID,
TEMP_CHAT_KEY,
} from "@/constants/common.constants";
import docImage from "@/static/assets/doc_image.png";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import { API_BASE_URL } from "@/constants/config";
import { EVENT_TYPE } from "@/subpackages/constants/event.constants";
// ==================== API ====================
import { apiAgentConversation } from "@/servers/conversation";
import { apiTenantConfig } from "@/servers/account";
import { apiUserTicketCreate } from "@/servers/agentDev";
import {
useMessageEventDelegate,
handleMessageEventClick,
EventDedupManager,
} from "@/subpackages/hooks/useMessageEventDelegate.uts";
import { usePageResume } from "@/subpackages/hooks/usePageResume.uts";
// ==================== 工具方法 ====================
import { formatBytes } from "@/utils/byteConverter.uts";
import { objectToQueryString } from "@/utils/common";
import { chatService } from "@/utils/chatService";
import { handleExternalLink } from "@/utils/system.uts";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import eventBus from "@/utils/eventBus";
import { throttle } from "@/subpackages/utils/functionUtils";
import { useI18n } from "@/utils/i18n";
import { getUniLoadMoreContentText } from "@/utils/i18n-third-party";
const { t } = useI18n();
const historyLoadMoreContentText = computed(() =>
getUniLoadMoreContentText({
contentrefresh: t("Mobile.Conversation.loadingHistory"),
}),
);
// 接收组件属性,设置默认值
const props = withDefaults(
defineProps<{
isTempChat?: boolean;
isAppDetails?: boolean; // 是否是应用智能体页面
}>(),
{
isTempChat: false,
isAppDetails: false,
},
);
console.log("props.isAppDetails", props.isAppDetails);
console.log("props.isTempChat", props.isTempChat);
// 定义 emits
const emit = defineEmits<{
// 菜单按钮点击事件
(e: "menu-click"): void;
}>();
// 菜单按钮点击事件
const onMenuClick = () => {
emit("menu-click");
};
// 创建防重复触发管理器(可选)
const dedupManager = new EventDedupManager(1000);
// 定义 chat-input-phone 组件的引用
const agentDetailInputPhoneRef = ref<ChatInputPhoneRef | null>(null);
const newConversationSetRef = ref<any>(null);
// 聊天内容区域引用用于绑定自定义事件代理
const msgListContentRef = ref<HTMLElement | null>(null);
// 事件绑定配置
const eventBindConfig = ref<EventBindConfig | null>(null);
// 阿里云验证码按钮id
const buttonId = ref<string>("aliyun-captcha-id");
// 租户配置信息
const tenantConfigInfo = ref<TenantConfigInfo>(null);
// 页面预览iframe引用
const pagePreviewIframeRef = ref<any>(null);
// 页面预览是否可见
const pagePreviewVisible = ref(false);
const msgListRef = ref<HTMLElement | null>(null);
// 用户临时票据
const ticket = ref<string>("");
/**
* 是否渲染输入框组件, 进入页面后渲染,离开页面后卸载组件
* 出现一个bug当从智能体详情页面返回首页时录音后发送消息事件触发的是智能体详情页绑定的消息事件而不是首页绑定的消息事件
* 所以需要卸载输入框组件,重新渲染输入框组件
*/
const isRenderInputPhone = ref<boolean>(false);
// 文件树弹窗是否可见
const popupFileTreeVisible = ref<boolean>(false);
// 文件树引用
const fileTreeRef = ref<{
open: () => void;
close: () => void;
} | null>(null);
// 工具详情弹窗引用
const toolDetailsModalRef = ref<any>(null);
const toolDetailData = ref<any>(null);
const toolDetailTitle = ref<string>("");
const customMenuList = computed<MenuListItem[]>(() => {
const collectIcon = agentInfo.value?.collect
? "icon-Star-fill"
: "icon-Star";
const list = [
{
label: "Mobile.Conversation.newConversation",
value: "add",
icon: "icon-message-add",
onClick: handleCreateNewConversation,
},
{
label: "Mobile.Conversation.moreInfo",
value: "info",
icon: "icon-Info",
onClick: handleOpenInfo,
},
{
label: "Mobile.Conversation.favorite",
value: "collect",
icon: collectIcon,
onClick: handleCollect,
},
];
if (
agentInfo.value?.type === AgentTypeEnum.TaskAgent &&
messageList.value.length > 0
) {
list.push({
label: "Mobile.Sandbox.restartAgent",
value: "restartAgent",
icon: "icon-restart",
onClick: handleRestartAgent,
});
list.push({
label:
currentSandboxId.value === "-1"
? "Mobile.Sandbox.restartCloudComp"
: "Mobile.Sandbox.restartClient",
value: "restartSandbox",
icon: "icon-restart_agent",
onClick: handleRestartSandbox,
});
}
if (
agentInfo.value?.paymentRequired === true &&
data.enableSubscription.value === 1
) {
list.push({
label: "Mobile.Conversation.mySubscription",
value: "mySubscription",
icon: "icon-Heart",
onClick: handleOpenSubscription,
});
}
return list;
});
// 是否显示webview
const isShowWebView = computed(() => {
return (
agentInfo.value?.expandPageArea === ExpandPageAreaEnum.Yes &&
agentInfo.value?.hideChatArea === HideChatAreaEnum.Yes
);
});
// 处理返回首页
const handleBackHome = () => {
uni.switchTab({
url: "/pages/index/index",
});
};
const updateVisible = (value: boolean) => {
pagePreviewVisible.value = value;
};
// 处理文件树弹窗可见性变化
const updatePopupFileTreeVisible = (value: boolean) => {
popupFileTreeVisible.value = value;
};
// ==================== 初始化分层架构 ====================
// 创建各层实例
const data = new AgentDetailData();
data.isTempChat.value = props.isTempChat;
const service = new AgentDetailService(data);
const componentManager = new ComponentManager(data);
const scrollManager = new ScrollManager(data);
// ==================== 暴露方法给模板 ====================
// 数据层
const {
agentId,
agentName,
agentInfo,
pageHomeUrl,
conversationId,
messageList,
chatSuggestList,
requestId,
isTempChat,
messageIdRef,
captchaVerifyParam,
chatKey,
conversationUid,
conversationInfo,
needUpdateTopicRef,
manualComponents,
selectedComponents,
variables,
userFillVariables,
processingList,
scrollIntoView,
scrollInBottom,
scrollWithAnimation,
scrollTouch,
scrolling,
keyboardHeight,
refMorePopup,
refSubscriptionModal,
isStoppingConversation,
isConversationActive,
isSendMessageRef,
wholeDisabled,
fileList,
isLoadingFiles,
sandboxList,
currentSandboxId,
isSandboxSwitchDisabled,
isSandboxUnavailable,
sandboxDisabledText,
isLoadingMoreMessages,
hasMoreMessages,
currentModelId,
currentModelName,
modelSelectVisible,
subscriptionRequired,
subscriptionModalVisible,
} = data;
// 处理禁用发送按钮
const handleToggleWholeDisabled = (disabled: boolean) => {
data.wholeDisabled.value = disabled;
};
// 服务层 - 使用箭头函数保持this上下文
const handleQueryConversation = (...args: any[]) =>
service.handleQueryConversation(...args);
const handleChangeMessageList = (...args: any[]) =>
service.handleChangeMessageList(...args);
// 停止会话
const handleStopConversation = () =>
service.handleStopConversation(props.isTempChat);
const createNewConversation = (...args: any[]) =>
service.createNewConversation(...args);
const getPublishedAgentInfo = (...args: any[]) =>
service.getPublishedAgentInfo(...args);
const handleCollect = (...args: any[]) => service.handleCollect(...args);
// 重启智能体
const handleRestartAgent = () => service.handleRestartAgent();
// 重启沙箱
const handleRestartSandbox = () =>
service.handleRestartSandbox(data.currentSandboxId.value === "-1");
// 清空会话数据
const clearConversationData = (isTempChat: boolean) =>
service.clearConversationData(isTempChat);
// 触发打开订阅/升级计划模态框
const handleOpenSubscription = () => {
subscriptionModalVisible.value = true;
nextTick(() => {
if (refSubscriptionModal.value != null) {
refSubscriptionModal.value!.open();
} else {
setTimeout(() => {
refSubscriptionModal.value?.open();
}, 50);
}
});
};
// 订阅/购买套餐成功回调
const handlePurchaseSuccess = (plan: any) => {
service.handleCheckSubscriptionLimit();
};
// 弹窗可见性回调
const handleUpdateModalVisible = (visible: boolean) => {
subscriptionModalVisible.value = visible;
};
// 监听订阅弹窗的显示状态,唤起或关闭组件
watch(subscriptionModalVisible, (visible: boolean) => {
if (visible) {
nextTick(() => {
if (refSubscriptionModal.value != null) {
refSubscriptionModal.value!.open();
} else {
// 如果首次 nextTick 还没挂载完(常见于小程序首屏自动弹窗),则在小延时后重试
setTimeout(() => {
refSubscriptionModal.value?.open();
}, 100);
}
});
} else {
refSubscriptionModal.value?.close();
}
});
// 临时会话
const queryTempConversation = (conversationUid: string) =>
service.queryTempConversation(conversationUid);
const createTempChatConversation = (captchaVerifyParam: string) =>
service.createTempChatConversation(captchaVerifyParam);
// 获取文件树列表
const fetchFileList = (cId: number) => service.fetchFileList(cId);
const handleSelectSandbox = (sandboxId: string) =>
service.handleSelectSandbox(sandboxId);
// 模型相关
const handleSelectModel = (modelId: number, modelName: string) =>
service.handleSelectModel(modelId, modelName);
// ==================== 页面唤醒恢复逻辑 ====================
/**
* 处理会话恢复
* 当页面从后台恢复时,检测并恢复会话状态
*/
const handleSessionResume = async (): Promise<void> => {
// 页面切回前台时,同步并更新最新的租户配置
await initTenantConfig();
// 检查会话ID是否存在
if (!conversationId.value) {
return;
}
// 重新获取会话订阅与限制状态,确保在支付返回后能够实时同步状态与解锁输入框
await service.handleCheckSubscriptionLimit();
// 调用服务层的恢复方法
const needResume = await service.resumeConversationState();
if (needResume) {
console.log("[PageResume] 会话状态已恢复");
}
};
// 使用页面唤醒检测 Hook
// 注意H5 环境会自动监听 visibilitychange
// 小程序环境需要配合 App.uvue 的 onShow 使用
// 使用页面唤醒检测 Hook
// 注意H5 环境会自动监听 visibilitychange
// 小程序环境需要配合 App.uvue 的 onShow 使用
const {
isHidden: isPageHidden,
triggerResume,
triggerHide,
} = usePageResume({
onResume: handleSessionResume,
onHide: () => {
// console.log("[PageResume] 页面进入后台");
},
// 最小隐藏时间 5 秒,避免短暂切换触发恢复
minHiddenDuration: 5000,
});
// 组件管理层 - 使用箭头函数保持this上下文
const setMessageRef = (...args: any[]) =>
componentManager.setMessageRef(...args);
const removeMessageRef = (...args: any[]) =>
componentManager.removeMessageRef(...args);
const updateMessageComponent = (...args: any[]) =>
componentManager.updateMessageComponent(...args);
const convertMessageInfoToMsgItem = (...args: any[]) =>
componentManager.convertMessageInfoToMsgItem(...args);
// 处理消息内容
const handleMessageContent = (item: any) => {
// 处理消息内容中的data属性
item.text = item.text.replace(/data="(\{.*?\})"/g, (_, json) => {
return `data='${json}'`;
});
return convertMessageInfoToMsgItem(item);
};
// 滚动管理层 - 使用箭头函数保持this上下文
const scrollToLastMsg = (...args: any[]) =>
scrollManager.scrollToLastMsg(...args);
const setIsInScrollBottom = (...args: any[]) =>
scrollManager.setIsInScrollBottom(...args);
const lockAutoToLastMsg = (...args: any[]) =>
scrollManager.lockAutoToLastMsg(...args);
const onScroll = (...args: any[]) => {
// #ifdef MP-WEIXIN
// 微信小程序中,由于使用 scrollIntoView 等机制,可能导致系统内部 scrolltoupper 的触发状态机未复位
// 表现为“必须先下滑一点,再上滑才能触发”。我们在此加入滚动高度探测做双重保障。
if (args.length > 0) {
const e = args[0] as UniScrollEvent;
// 避免数值过大如1000px导致离顶部尚远时便在下滑过程中误触发
if (e && e.detail && e.detail.scrollTop <= 100) {
if (!data.isLoadingMoreMessages.value && data.hasMoreMessages.value) {
handleScrollToUpper();
}
}
}
// #endif
scrollManager.onScroll(...args);
};
const scrollToMessage = (...args: any[]) =>
scrollManager.scrollToMessage(...args);
// 加载更多历史消息
const handleLoadMoreMessages = () => service.handleLoadMoreMessages();
/**
* 处理上滑到顶部事件,加载更多历史消息
*/
const handleScrollToUpper = async () => {
if (
!hasMoreMessages.value ||
isLoadingMoreMessages.value ||
!messageList.value?.length
) {
return;
}
// 记录当前第一条可见消息的 ID 和 游标,用于加载完成后保持滚动位置
const firstMessage = messageList.value[0];
const firstVisibleMessageId = firstMessage
? `${firstMessage.id}_${firstMessage.index}`
: "";
// 临时禁用自动滚动,避免加载更多时触发自动滚动到底部
const previousAutoToLastMsg = data.autoToLastMsg.value;
data.autoToLastMsg.value = false;
await handleLoadMoreMessages();
// 加载完成后,滚动到之前的第一条消息位置,保持用户视角不变
if (firstVisibleMessageId) {
await nextTick();
scrollToMessage(firstVisibleMessageId);
}
// 恢复自动滚动状态
data.autoToLastMsg.value = previousAutoToLastMsg;
};
// ==================== 视图层方法 (原ViewManager逻辑) ====================
const handleCaptchaReady = () => {
document.getElementById(buttonId.value)?.click();
};
// 发送消息
const handleSendMessage = (_data: any[]) => {
// 取消订阅 ChatFinished 事件(发送新消息时,旧会话已结束)
eventBus.off(EVENT_TYPE.ChatFinished, handleChatFinishedInternal);
data.autoToLastMsg.value = true;
data.needSetLockAutoToLastMsg.value = true;
// 发送过消息,则禁用变量参数
data.isSendMessageRef.value = true;
scrollToLastMsg(false);
let params;
if (data.urlOtherParams.value) {
// 用户自带的url参数中的附件文件列表
const otherAttachments = (data.urlOtherParams.value?.attachments ||
[]) as AttachmentFile[];
// 用户自带的url参数中的附件文件列表转为file类型方便统一处理
const otherAttachmentsFiles = otherAttachments.map((item) => ({
// 文件URL
url: item.fileUrl,
// 文件类型
type: item.mimeType,
name: item?.fileName || "",
key: item?.fileKey || "",
})) as UploadFileInfo[];
// 用户自带的url参数中的附件文件列表
const otherFiles = (data.urlOtherParams.value?.files ||
[]) as UploadFileInfo[];
// 用户自带的url参数中的组件列表
const _selectedComponents = (data.urlOtherParams.value
?.selectedComponents || []) as AgentSelectedComponentInfo[];
// 用户自带的url参数中的技能ID列表
const otherSkillIds = (data.urlOtherParams.value?.skillIds ||
[]) as number[];
// 用户自带的url参数中的模型ID
const otherModelId = data.urlOtherParams.value?.modelId;
// 用户自带的url参数中的沙盒ID
const otherSandboxId = data.urlOtherParams.value?.sandboxId;
params = {
..._data,
// 变量参数
variableParams: newConversationSetRef.value?.variableParams || null,
// 附件文件列表
files: [
...otherFiles,
...otherAttachmentsFiles,
...(_data?.files || []),
],
selectedComponents: [
..._selectedComponents,
...(_data?.selectedComponents || []),
],
// 技能ID列表
skillIds: [...otherSkillIds, ...(_data?.skillIds || [])],
// 模型ID
modelId: _data?.modelId || otherModelId,
// 沙盒ID
sandboxId: _data?.sandboxId || otherSandboxId,
};
} else {
params = {
..._data,
variableParams: newConversationSetRef.value?.variableParams || null,
};
}
service.handleSendMessage(params, props.isTempChat, () => {
// 将工具调用状态从 loading 修改为 error
// service.updateProcessingListToFailed()
// 中止当前 SSE 连接
chatService?.abort();
// 注意:超时时不调用停止接口,只中止 SSE 连接
// 停止接口只应该由用户手动点击停止按钮时调用
// 重置按钮状态(按钮变为发送按钮)
data.isConversationActive.value = false;
data.isStoppingConversation.value = false;
});
};
/**
* 处理菜单下拉框点击,强同步拉取最新配置,以控制订阅选项的实时展示
*/
const handleMenuDropdownClick = (): void => {
initTenantConfig();
};
/**
* 处理创建新会话
*/
const handleCreateNewConversation = (): void => {
// 中止当前 SSE 连接
chatService?.abort();
// 注意:创建新会话时不调用停止接口,只中止 SSE 连接
// 停止接口只应该由用户手动点击停止按钮时调用
// 重置按钮状态(按钮变为发送按钮)
data.isConversationActive.value = false;
data.isStoppingConversation.value = false;
// 取消订阅 ChatFinished 事件(防止旧会话的事件影响新会话)
eventBus.off(EVENT_TYPE.ChatFinished, handleChatFinishedInternal);
// 清空本地缓存中的临时会话uid
uni.removeStorageSync(TEMP_CONVERSATION_UID);
if (props.isTempChat) {
// 清空会话数据
clearConversationData(true);
// 创建临时会话
createTempChatConversation(data.captchaVerifyParam.value);
} else {
createNewConversation(props.isAppDetails);
}
};
/**
* 处理更多弹窗
*/
const handleMorePopup = (): void => {
if (data.refMorePopup.value) {
nextTick(() => {
data.refMorePopup.value?.open();
});
}
};
/**
* 处理功能按钮点击
*/
const handleButtonClick = (guideQuestion: string): void => {
if (data.wholeDisabled.value) {
uni.showToast({
title: t("Mobile.Chat.fillRequiredParams"),
icon: "none",
duration: 2000,
});
return;
}
// 判断是否存在上传失败的图片或文件
const existErrorFile = agentDetailInputPhoneRef.value?.files?.some(
(file) => file.status === UploadFileStatus.error,
);
if (existErrorFile) {
uni.showToast({
title: t("Mobile.Chat.removeUploadFailed"),
icon: "none",
duration: 1500,
});
return;
}
const params = {
messageInfo: guideQuestion,
files: agentDetailInputPhoneRef.value?.files || [],
selectedComponents:
agentDetailInputPhoneRef.value?.selectedComponents || [],
};
handleSendMessage(params);
};
// 预览图片
const onPreview = (file: AttachmentFile, attachments: AttachmentFile[]) => {
if (!file.mimeType.includes("image/")) {
uni.showToast({
title: t("Mobile.Chat.previewTypeUnsupported"),
icon: "none",
duration: 1500,
});
return;
}
const urls =
attachments
?.filter((f) => f.mimeType.includes("image/"))
?.map((f) => f.fileUrl) || [];
if (urls.length > 0 && file?.fileUrl) {
uni.previewImage({
urls,
current: file.fileUrl,
});
}
};
/**
* 长按消息处理
*/
const onlongTapMsg = (item: any): void => {
// 显示操作菜单
};
/**
* 思考内容变化处理
*/
const changeThinkContent = (hideThinkContent: boolean): void => {
// 注意:不要在这里调用 setIsInScrollBottom(),因为没有事件参数会导致状态错误
if (!data.scrollTouch.value && data.autoToLastMsg.value) {
nextTick(() => {
// 再次检查条件,防止用户在这期间滚动了
if (!data.scrollTouch.value && data.autoToLastMsg.value) {
scrollToLastMsg(false);
}
});
}
};
/**
* 键盘高度变化处理
*/
const onKeyboardheightchange = (
res: UniInputKeyboardHeightChangeEvent,
): void => {
data.keyboardHeight.value = res.detail.height;
};
// ==================== 计算属性 ====================
const lastMessage = computed(
() => data.messageList.value[data.messageList.value.length - 1] ?? null,
);
// 标题
const headerTitle = computed(() =>
props.isTempChat && agentInfo.value?.name
? t("Mobile.Conversation.startWithAgent", {
name: agentInfo.value?.name || "",
})
: agentInfo.value?.name,
);
// 沙盒是否锁定 (综合判断: 服务层限制 || 已发送消息 || 存在历史消息)
const isSandboxLocked = computed(() => {
// 1. 服务层限制 (权限, 个人/共享电脑绑定)
if (data.isSandboxSwitchDisabled.value) return true;
// 2. 已发送消息 (变量锁定)
if (data.isSendMessageRef.value) return true;
// 3. 存在历史消息 (会话已开始)
if (data.messageList.value.length > 0) return true;
return false;
});
// ==================== 页面加载处理 ====================
const handleScrollToLastMsg = async () => {
// 注意:不要在这里调用 setIsInScrollBottom(),因为没有事件参数会导致状态错误
lockAutoToLastMsg();
if (!data.scrollTouch.value && data.autoToLastMsg.value) {
await nextTick();
setTimeout(() => {
// 在 setTimeout 内再次检查条件,防止用户在这期间滚动了
if (!data.scrollTouch.value && data.autoToLastMsg.value) {
scrollToLastMsg(false);
}
}, 100);
}
};
watch(lastMessage, handleScrollToLastMsg, { deep: true });
// 消息列表变化处理
watch(data.messageList, handleScrollToLastMsg, { deep: true });
// 问题建议列表变化处理
watch(
chatSuggestList,
(newVal) => {
if (newVal?.length) {
handleScrollToLastMsg();
}
},
{ deep: true },
);
watch(
conversationId,
async (newVal) => {
if (!eventBindConfig.value && newVal && !props.isTempChat) {
// 查询历史会话信息(接口返回数据中包含智能体详情信息, 所以不在需要单独查询智能体详情信息了)
const res = await apiAgentConversation(newVal);
if (res.code === SUCCESS_CODE) {
// 事件绑定配置
eventBindConfig.value = res.data?.agent?.eventBindConfig || null;
}
}
},
{ deep: true },
);
// 监听键盘高度变化
watch(data.keyboardHeight, (newVal) => {
if (newVal > 0) {
handleScrollToLastMsg();
}
});
// 记录上一次成功获取租户配置的时间戳,避免首屏加载与 onShow 唤醒时并发重复请求
let lastTenantConfigFetchTime: number = 0;
/**
* 初始化并更新租户配置,同步 enableSubscription 状态
*/
const initTenantConfig = async (): Promise<void> => {
const now = Date.now();
// 2秒防抖时间窗口如果距离上次成功获取配置的时间不足2秒则直接跳过防止并发快速重复拉取
if (
now - lastTenantConfigFetchTime < 2000 &&
data.enableSubscription.value != null
) {
return;
}
try {
const res = await apiTenantConfig();
if (res.code === SUCCESS_CODE && res.data != null) {
data.enableSubscription.value = res.data!.enableSubscription ?? 0;
tenantConfigInfo.value = res.data!;
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(res.data));
lastTenantConfigFetchTime = Date.now(); // 记录成功获取的时间戳
} else {
const info = uni.getStorageSync(TENANT_CONFIG_INFO);
if (info) {
const cachedConfig = JSON.parse(info) as TenantConfigInfo;
data.enableSubscription.value = cachedConfig.enableSubscription ?? 0;
tenantConfigInfo.value = cachedConfig;
}
}
} catch (e) {
console.error("初始化租户配置失败:", e);
const info = uni.getStorageSync(TENANT_CONFIG_INFO);
if (info) {
const cachedConfig = JSON.parse(info) as TenantConfigInfo;
data.enableSubscription.value = cachedConfig.enableSubscription ?? 0;
tenantConfigInfo.value = cachedConfig;
}
}
};
/**
* 处理页面加载
*/
const handlePageLoad = async (options): Promise<void> => {
// 智能体id
data.agentId.value = options.id ? Number(options.id) : "";
// 如果存在会话id则初始化ParseMarkdown查询历史会话信息
if (options.conversationId) {
const id = Number(options.conversationId);
// 先设置一个临时值,防止 watch(conversationId) 触发时重复调用接口
eventBindConfig.value = {} as EventBindConfig;
data.conversationId.value = id;
if (props.isTempChat) return;
// 查询历史会话信息(接口返回数据中包含智能体详情信息, 所以不在需要单独查询智能体详情信息了)
const res = await apiAgentConversation(id);
if (res.code === SUCCESS_CODE) {
// 事件绑定配置
eventBindConfig.value = res.data?.agent?.eventBindConfig || null;
// 处理消息列表
handleQueryConversation(res);
// 检查会话是否正在执行中taskStatus === 'EXECUTING'
if (res.data?.taskStatus === TaskStatus.EXECUTING) {
// 设置会话为活跃状态(按钮显示为停止按钮)
data.isConversationActive.value = true;
// 订阅 ChatFinished 事件
eventBus.on(EVENT_TYPE.ChatFinished, handleChatFinishedInternal);
}
}
} else {
// 如果是应用详情页则从URL参数中获取消息信息、文件信息、组件列表、变量参数等信息
// 自动解码uni-app 会自动对 URL 参数进行解码,所以你不需要手动调用 decodeURIComponent
const paramsFromUrl = options?.params;
const paramsData = paramsFromUrl ? JSON.parse(paramsFromUrl) : null;
// 查询智能体详情信息并初始化会话id
getPublishedAgentInfo(paramsData);
}
};
/**
* 使用chatKey、captchaVerifyParam创建临时会话
* chatKey: 链接key
* captchaVerifyParam: 验证码参数 (可选)
*/
const handleCreateTempChat = async (captchaVerifyParam: string = "") => {
// 验证码参数
data.captchaVerifyParam.value = captchaVerifyParam;
data.isLoadingConversation.value = true;
const uid = uni.getStorageSync(TEMP_CONVERSATION_UID);
if (uid) {
data.conversationUid.value = uid;
// 查询临时会话详细
queryTempConversation(uid);
} else {
// 创建临时会话
createTempChatConversation(captchaVerifyParam);
}
};
/**
* 处理临时会话页面加载
*/
const handleTempChatPageLoad = async (options) => {
// 临时会话key
data.chatKey.value = options.chatKey;
// 是否存在缓存的chatKey值
const _cacheChatKey = uni.getStorageSync(TEMP_CHAT_KEY);
/**
* 如果缓存的chatKey值存在且与当前的chatKey值不一致则清空缓存的临时会话uid
* (防止用户手动修改url地址中的chatKey值后刷新页面因为缓存TEMP_CONVERSATION_UID的值与chatKey值不匹配导致后面查询会话详情时提示会话ID不存在的bug)
*/
if (_cacheChatKey && options.chatKey !== _cacheChatKey) {
uni.removeStorageSync(TEMP_CONVERSATION_UID);
}
// 缓存chatKey
uni.setStorageSync(TEMP_CHAT_KEY, options.chatKey);
const _tenantConfigInfo = tenantConfigInfo.value;
if (_tenantConfigInfo != null) {
const { captchaSceneId, captchaPrefix, openCaptcha } = _tenantConfigInfo!;
// 是否需要阿里云验证码, 只有同时满足三个条件才启用验证码场景ID存在、身份标存在、开启验证码
const isNeedCaptcha = !!(captchaSceneId && captchaPrefix && openCaptcha);
// 不需要阿里云验证码时,直接调用接口
if (!isNeedCaptcha) {
handleCreateTempChat();
}
}
};
const handlePagePreviewDetail = (toolCall: any, detailData: any) => {
toolDetailData.value = detailData;
const rawTitle =
toolCall.name || t("Mobile.ThirdParty.MpHtml.executionPlan");
toolDetailTitle.value = rawTitle.replace(/&quot;/g, '"');
toolDetailsModalRef.value?.open();
};
const handlePagePreviewExecuting = (data: any) => {
const input = data.result?.input || null;
if (!input) {
return;
}
/**
* 如果uri_type不存在则默认设置为Page
* 如果uri_type存在则使用uri_type
*/
input.uri_type = input.uri_type ?? "Page";
// 拼接 query 参数
const argumentsString = objectToQueryString(input.arguments);
// 处理 query 参数
const queryString = argumentsString ? `?${argumentsString}` : "";
if (input.uri_type === "Link" && data.status === ProcessingEnum.FINISHED) {
const pageUrl = input.uri + queryString;
window.open(pageUrl, "_blank");
return;
}
if (input.uri_type === "Page") {
// 构建完整的页面 URL
const fullUri = input.uri + queryString;
// 手动打开页面首页
if (data.status === ProcessingEnum.FINISHED) {
// 打开页面首页
handleOpenPagePreview({ uri: fullUri });
return;
}
// 自动打开预览页面
if (data.status === ProcessingEnum.EXECUTING) {
// 打开页面并调用接口
handleOpenPagePreview({
uri: fullUri,
method: input.method,
data_type: input.data_type,
request_id: input.request_id,
});
return;
}
}
};
/**
* 打开页面预览
* @param uri 页面URI
* @param method 请求方法 (默认: browser_open_page)
* @param data_type 数据类型 (默认: html)
* @param request_id 请求ID (默认: '')
*/
const handleOpenPagePreview = async ({
uri,
method = "browser_open_page",
data_type = "html",
request_id = "",
}: ShowPagePreviewOptions) => {
// 打开页面首页
// #ifdef H5 || WEB
pagePreviewIframeRef.value?.handlePageOpen({
uri: `${API_BASE_URL}${uri}`,
method,
data_type,
request_id,
});
// #endif
// #ifdef MP-WEIXIN
const { code, data } = await apiUserTicketCreate();
if (code === SUCCESS_CODE && data) {
uri = uri + (uri.includes("?") ? "&" : "?" + "_ticket=") + data;
}
const pageHomeTitle = encodeURIComponent(t("Mobile.Common.pageHome"));
handleExternalLink(
`${API_BASE_URL}${uri}&jump_type=inner&title=${pageHomeTitle}&method=${method}&data_type=${data_type}&request_id=${request_id}`,
);
// #endif
};
// 处理更多信息
const handleOpenInfo = () => {
nextTick(() => {
refMorePopup.value?.popupMore?.open();
});
};
// 事件代理
useMessageEventDelegate(
msgListContentRef,
eventBindConfig,
(data: ShowPagePreviewOptions) => {
// 打开引导问题页面弹窗
handleOpenPagePreview({ uri: data.uri });
},
);
// ==================== 生命周期 ====================
onLoad(async (options) => {
// 获取并更新租户配置,初始化 enableSubscription
await initTenantConfig();
// 设置当前页面导航栏标题
setCurrentPageNavigationBarTitle();
ticket.value = options._ticket || "";
// 进入页面后渲染输入框组件
isRenderInputPhone.value = true;
// 微信小程序事件绑定
// #ifdef MP-WEIXIN
uni.$on("message-event-delegate", (data) => {
const eventType = data.attrs["event-type"];
const dataStr = data.attrs["data"];
if (eventType && dataStr) {
handleMessageEventClick(
eventType,
dataStr,
eventBindConfig.value,
handleOpenPagePreview,
dedupManager,
);
} else {
console.warn("[Event Delegate] 缺少必要属性", { eventType, dataStr });
}
});
// #endif
// 判断是否是临时会话
if (props.isTempChat) {
await handleTempChatPageLoad(options);
} else {
await handlePageLoad(options);
}
});
// 刷新文件列表
const onRefreshFileList = () => {
// 如果智能体类型为任务型智能体并且会话id存在,并且文件树弹窗可见,则获取文件树列表
if (
agentInfo.value?.type === AgentTypeEnum.TaskAgent &&
conversationId.value &&
popupFileTreeVisible.value
) {
fetchFileList(conversationId.value);
}
};
// 处理消息组件更新事件
const handleUpdateMessageComponent = (eventData: any) => {
updateMessageComponent(eventData.messageId, eventData.data);
};
// 流式消息滚动定时器
let streamScrollTimer: number = 0;
// 触摸结束延迟计时器
let touchEndTimer: number = 0;
// 触摸开始处理
const handleTouchStart = () => {
// 清除触摸结束延迟计时器,确保触摸开始时立即生效
if (touchEndTimer) {
clearTimeout(touchEndTimer);
touchEndTimer = 0;
}
data.scrollTouch.value = true;
};
// 触摸结束处理 - 延迟重置 scrollTouch
const handleTouchEnd = () => {
// 清除之前的计时器
if (touchEndTimer) {
clearTimeout(touchEndTimer);
}
// 延迟 300ms 后才重置 scrollTouch
// 这样在用户快速滑动时,自动滚动不会在手指抬起的瞬间抢占
touchEndTimer = setTimeout(() => {
data.scrollTouch.value = false;
touchEndTimer = 0;
// 触摸结束后,如果需要,自动滚动到底部
handleScrollToLastMsg();
}, 300);
};
const handleStreamMessageUpdate = () => {
// 使用防抖处理,避免频繁滚动影响性能
// 150ms 可以在保证流畅滚动的同时减少性能开销
if (streamScrollTimer) {
clearTimeout(streamScrollTimer);
}
streamScrollTimer = setTimeout(() => {
if (!data.scrollTouch.value && data.autoToLastMsg.value) {
scrollToLastMsg(false);
}
}, 150);
};
// ChatFinished 事件处理函数(带节流)
const handleChatFinishedInternal = (event: any) => {
// 判断是否是当前会话
if (conversationId.value === Number(event.conversationId)) {
// 定义事件回调函数(使用 throttle 工具进行节流500ms 内只执行一次)
throttle(() => {
// 关闭会话活跃状态(按钮变为发送按钮)
if (data.isConversationActive.value) {
data.isConversationActive.value = false;
}
// 重新查询会话信息以更新状态
if (conversationId.value && !props.isTempChat) {
apiAgentConversation(conversationId.value).then((res) => {
if (res.code === SUCCESS_CODE) {
handleQueryConversation(res);
}
});
}
}, 500)();
}
};
// 设置事件监听器
onMounted(async () => {
// 监听流式消息更新事件,触发自动滚动到底部
// 兼容微信小程序和 H5
uni.$on("streamMessageUpdate", handleStreamMessageUpdate);
uni.$on("refreshFileList", onRefreshFileList);
uni.$on("updateMessageComponent", handleUpdateMessageComponent);
// #ifdef H5
window.addEventListener("wheel", (e: WheelEvent) => {
data.mouseScroll.value = true;
let clear: number = 0;
clear = setTimeout(() => {
data.mouseScroll.value = false;
clearTimeout(clear);
}, 1000);
});
// #endif
uni.$on("page_preview_executing", handlePagePreviewExecuting);
uni.$on("page_preview_detail", handlePagePreviewDetail);
});
// 清理事件监听器
onUnmounted(() => {
// 页面卸载时立即中断 SSE 连接并清除超时定时器
// 注意:只中断连接,不调用停止接口
console.log("[页面卸载] 准备中断 SSE 连接");
chatService?.abort();
console.log("[页面卸载] SSE 连接已中断");
uni.$off("streamMessageUpdate", handleStreamMessageUpdate);
uni.$off("refreshFileList", onRefreshFileList);
uni.$off("updateMessageComponent", handleUpdateMessageComponent);
// 页面卸载时取消监听,防止内存泄漏
uni.$off("page_preview_executing", handlePagePreviewExecuting);
// 清理微信小程序事件监听器,防止返回首页后仍然触发当前页面的事件
// #ifdef MP-WEIXIN
uni.$off("message-event-delegate");
// #endif
// 离开页面后卸载输入框组件
isRenderInputPhone.value = false;
// 取消订阅 ChatFinished 事件(必须传入回调函数)
eventBus.off(EVENT_TYPE.ChatFinished, handleChatFinishedInternal);
});
// 打开工作台文件树弹窗
const handleOpenFileTree = () => {
popupFileTreeVisible.value = true;
// 打开文件树弹窗
fileTreeRef.value?.open();
// 查询文件列表
onRefreshFileList();
};
defineExpose({
triggerResume,
triggerHide,
getAgentInfo: () => agentInfo.value,
});
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
.captcha-button {
position: absolute;
left: 10px;
top: 10px;
visibility: hidden;
opacity: 0;
}
.icon-loading-image {
width: 48rpx;
height: 48rpx;
margin-left: 32rpx;
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.loading-more-tip {
display: flex;
justify-content: center;
align-items: center;
padding: 24rpx 0;
}
</style>