Files
qiming/qiming-mobile/subpackages/components/chat-input-phone/chat-input-phone.uvue

1518 lines
42 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 class="relative container">
<!-- 技能选择按钮 -->
<skill-tags-bar
v-if="isTaskAgent && enableSkillAt"
:selected-skills="selectedSkills"
@onRemove="removeSkill"
@onAdd="skillSelectModalVisible = true"
/>
<view class="flex flex-row items-center overflow-hide">
<!-- 手动选择组件 -->
<manual-component-item
v-if="
(manualComponents?.length && !isRecording) ||
(pageHomeIndex && expandPageArea === ExpandPageAreaEnum.Yes) ||
isTaskAgent ||
allowOtherModel
"
:manual-components="manualComponents || []"
:selected-components="selectedComponents"
:page-home-index="pageHomeIndex"
:expand-page-area="expandPageArea"
@onSelectComponent="handleToggleSelectComponent"
@onOpenPagePreview="handleOpenPagePreview"
@onOpenFileTree="handleOpenFileTree"
:is-task-agent="isTaskAgent"
:sandbox-list="sandboxList"
:current-sandbox-id="currentSandboxId"
:is-sandbox-switch-disabled="isSandboxSwitchDisabled"
:is-sandbox-unavailable="isSandboxUnavailable"
:sandbox-disabled-text="sandboxDisabledText"
:readonly="readonly"
:enable-skill-at="enableSkillAt"
@onSandboxChange="handleSandboxChange"
:allow-other-model="allowOtherModel"
:current-model-id="currentModelId"
:current-model-name="currentModelName"
:agent-id="agentId"
:agent-type="agentType"
:is-temp-chat="isTempChat"
@onModelChange="handleModelChange"
/>
</view>
<!--图片列表-->
<template v-if="files?.length">
<chat-upload-image
:files="files"
@onDel="handleDelFile"
@onAdd="handleAddFile"
:show-add-button="showAddButton"
/>
<view class="split-line" />
</template>
<view
class="record-tip-container"
:class="{ 'remove-shadow': isRecording }"
>
<!-- 无权限蒙层 -->
<view class="permission-mask" v-if="!hasPermission">
<text class="permission-text">{{
t("Mobile.Chat.noAgentPermission")
}}</text>
</view>
<!-- 订阅限制蒙层 -->
<!-- <view
class="permission-mask clickable"
v-else-if="subscriptionRequired"
@tap="handleOpenSubscription"
>
<text class="permission-text text-blue">{{
t("Mobile.Chat.subscriptionRequiredTip")
}}</text>
<text class="sub-arrow">➔</text>
</view> -->
<!-- 录音波形 -->
<view v-if="isRecording" class="waveform">
<view
v-for="(bar, index) in waveformBars"
:key="index"
class="waveform-bar"
:style="{
height: `${bar.height}px`,
animationDelay: `${bar.delay}ms`,
}"
/>
</view>
<view class="chat-input-container" :class="{ 'multi-line': isMultiLine }">
<voice-recorder-button
v-if="inputMethod === ChatInputMethod.Voice"
:duration="600000"
:is-conversation-active="isConversationActive"
@onRecordStart="handleVoiceStart"
@onRecordStop="handleVoiceStop"
@onError="handleVoiceError"
@onRecordCancel="handleRecordCancel"
@onRecordEnd="handleRecordEnd"
:format="'default'"
/>
<!--输入框-->
<!-- placeholder-class微信小程序placeholder样式无效 -->
<textarea
v-else
ref="inputRef"
class="input"
:class="{ 'input-text': messageInfo?.length }"
placeholder-class="input-placeholder"
:value="messageInfo"
:focus="isInputFocused"
:cursor="textareaCursor"
@input="handleInput"
adjust-position="false"
@keyboardheightchange="onKeyboardheightchange"
:auto-height="true"
@focus="onInputFocus"
@blur="onInputBlur"
@confirm="handleSendMessage"
@linechange="handleLineChange"
:placeholder="t('Mobile.TempSession.inputPlaceholder')"
:show-confirm-bar="false"
:maxlength="-1"
:disable-default-padding="true"
>
</textarea>
<!-- 图标容器 -->
<view class="icon-container" v-if="!isRecording">
<!-- 如果不存在输入内容,则显示输入方式 -->
<template
v-if="!messageInfo || (messageInfo && messageInfo.trim() === '')"
>
<!-- 输入方式: 键盘 -->
<text
v-if="inputMethod === ChatInputMethod.Voice"
class="iconfont icon-keyboard font-48"
@click="handleChangeInputMethod"
/>
<!-- 输入方式: 语音 -->
<text
v-else
class="iconfont icon-voice font-48"
@click="handleChangeInputMethod"
/>
</template>
<!-- 正在会话中,则显示停止会话按钮 -->
<template v-if="isConversationActive">
<!-- 正在停止会话 loading -->
<uni-icons
v-if="isStoppingConversation"
class="icon-loop"
type="loop"
size="48rpx"
color="#999"
/>
<!-- 停止会话 -->
<text
v-else
class="iconfont icon-stop-circle icon-image font-48"
@click.stop.prevent="handleStopConversation"
/>
</template>
<!-- 如果存在输入内容或图片文件,则显示发送按钮。没有输入内容和图片文件,则显示添加图片或文件按钮 -->
<text
v-else-if="
(messageInfo?.length && messageInfo.trim() !== '') ||
files?.length
"
class="iconfont icon-send icon-send-image"
@click="handleSendMessage"
/>
<!-- 输入方式: 上传图片 -->
<text
v-else
class="iconfont icon-a-Pluscircle font-48"
@click="toggleExtraContainer"
/>
</view>
</view>
</view>
<!-- 功能栏 -->
<view
class="extra-container"
:class="{ show: showExtraContainer }"
v-if="showExtraContainer"
>
<view class="extra-box" @click="chooseImage('camera')">
<view class="icon-box">
<text class="iconfont icon-Camera" />
</view>
<text class="text">
{{ t("Mobile.Chat.takePhoto") }}
</text>
</view>
<view class="extra-box" @click="chooseImage('album')">
<view class="icon-box">
<text class="iconfont icon-Image" />
</view>
<text class="text">
{{ t("Mobile.Chat.album") }}
</text>
</view>
<view class="extra-box" @click="chooseFile">
<view class="icon-box">
<text class="iconfont icon-Folder" />
</view>
<text class="text">
{{ t("Mobile.Chat.file") }}
</text>
</view>
<view
class="extra-box"
v-if="isTaskAgent && enableSkillAt"
@click="chooseSkill"
>
<view class="icon-box">
<text class="iconfont icon-skill" />
</view>
<text class="text">
{{ t("Mobile.Chat.skill") }}
</text>
</view>
</view>
<!-- 开启键盘时,底部背景高度为键盘高度 -->
<view
class="ai-generate-text-container"
:class="{ 'ios-wechat': isIOSWechatMiniProgram }"
><text class="ai-generate-text">{{
t("Mobile.Chat.aiGeneratedDisclaimer")
}}</text></view
>
<view
class="keyboard-cover"
:style="{ height: keyboardHeight + 'px' }"
></view>
<!-- 技能选择模态框 -->
<skill-select-modal
:visible="skillSelectModalVisible"
@onClose="handleSkillModalClose"
@onSelect="handleSkillSelect"
/>
</view>
</template>
<script lang="uts" setup>
import SkillSelectModal from "./skill-select-modal/skill-select-modal.uvue";
import SkillTagsBar from "./skill-tags-bar/skill-tags-bar.uvue";
import { SkillInfoForAt } from "@/types/interfaces/skill";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { UploadFileInfo } from "@/types/interfaces/common.uts";
import { UploadFileStatus } from "@/types/enums/common.uts";
import ChatUploadImage from "@/components/chat-upload-image/chat-upload-image.uvue";
import ManualComponentItem from "./manual-component-item/manual-component-item.uvue";
import VoiceRecorderButton from "@/components/voice-recorder-button/voice-recorder-button.uvue";
import {
AgentManualComponentInfo,
AgentSelectedComponentInfo,
} from "@/types/interfaces/agent.uts";
import {
AgentComponentTypeEnum,
DefaultSelectedEnum,
AgentTypeEnum,
} from "@/types/enums/agent.uts";
import { ChatInputMethod } from "@/subpackages/types/enums/chat.uts";
import {
UPLOAD_FILE_ACTION,
DEFAULT_IMAGE_COUNT,
DEFAULT_FILE_COUNT,
CHAT_INPUT_METHOD,
} from "@/constants/common.constants";
import {
MicrophonePermissionHelper,
type PermissionState,
} from "@/utils/permissionHelper";
import { ExpandPageAreaEnum } from "@/types/enums/agent.uts";
import { ACCESS_TOKEN } from "@/constants/home.constants";
import { SandboxInfo } from "@/types/interfaces/sandbox";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 文档
const files = ref<UploadFileInfo[]>([]);
// 输入内容
const messageInfo = ref<string>("");
// 已选技能列表
const selectedSkills = ref<SkillInfoForAt[]>([]);
// 技能选择模态框可见状态
const skillSelectModalVisible = ref<boolean>(false);
const isMultiLine = ref<boolean>(false);
// 键盘高度
const keyboardHeight = ref<number>(0);
// 是否显示额外功能栏
const showExtraContainer = ref<boolean>(false);
// 输入方式
const inputMethod = ref<ChatInputMethod>(ChatInputMethod.Input);
// 输入框ref
const inputRef = ref<UniInputElement | null>(null);
// 是否正在录音
const isRecording = ref<boolean>(false);
// 是否取消录音
const isCancelled = ref<boolean>(false);
// 已选组件列表
const selectedComponents = ref<AgentSelectedComponentInfo[]>([]);
// 录音时长计时器
let durationTimer: any = null;
// 波形动画数据
const waveformBars = ref([]);
// 是否是 iOS 微信小程序
const isIOSWechatMiniProgram = ref<boolean>(false);
// 标记是否由输入 @ 触发
const isAtTrigger = ref<boolean>(false);
// 控制输入框聚焦状态(兼容小程序)
const isInputFocused = ref<boolean>(false);
// 记录输入 @ 时的光标索引定位
const atCursorIndex = ref<number>(-1);
// 绑定文本框光标位置
const textareaCursor = ref<number>(-1);
const props = withDefaults(
defineProps<{
manualComponents?: AgentManualComponentInfo[];
defaultSelectedComponents?: AgentSelectedComponentInfo[] | null;
// 会话是否正在进行中(有消息正在处理)
isConversationActive?: boolean;
// 停止操作是否正在进行中
isStoppingConversation?: boolean;
// 是否禁用发送按钮
wholeDisabled?: boolean;
// 首页页面URL
pageHomeIndex?: string;
// 是否默认展开扩展页面区域
expandPageArea?: number;
isTaskAgent?: boolean;
sandboxList?: SandboxInfo[];
currentSandboxId?: string;
isSandboxSwitchDisabled?: boolean;
isSandboxUnavailable?: boolean;
sandboxDisabledText?: string;
hasPermission?: boolean;
readonly?: boolean;
enableSkillAt?: boolean;
// 模型选择相关
allowOtherModel?: boolean;
currentModelId?: number;
currentModelName?: string;
agentId?: number;
agentType?: AgentTypeEnum;
isTempChat?: boolean;
subscriptionRequired?: boolean;
}>(),
{
manualComponents: () => [],
defaultSelectedComponents: () => null,
isConversationActive: false,
isStoppingConversation: false,
wholeDisabled: false,
isTaskAgent: false,
sandboxList: () => [],
currentSandboxId: "",
isSandboxSwitchDisabled: false,
isSandboxUnavailable: false,
sandboxDisabledText: "",
hasPermission: true,
readonly: false,
enableSkillAt: true,
allowOtherModel: false,
currentModelId: 0,
currentModelName: "",
agentId: 0,
agentType: AgentTypeEnum.ChatBot,
isTempChat: false,
subscriptionRequired: false,
},
);
// 暴露messageInfo和files
defineExpose({
files,
selectedComponents,
});
// 添加文件、图片等
const handleAddFile = () => {
// 显示额外功能栏
showExtraContainer.value = true;
};
// 声明事件
const emit = defineEmits<{
// 智能体主页和临时聊天页发送消息
onSendMessage: (data: {
messageInfo: string;
files: UploadFileInfo[];
selectedComponents: AgentSelectedComponentInfo[];
skillIds: number[];
}) => void;
onStopConversation: () => void;
onOpenPagePreview: (uri: string) => void;
// 打开工作台
onOpenFileTree: () => void;
onSandboxChange: (sandboxId: string) => void;
onModelChange: (modelId: number, name: string) => void;
onOpenSubscription: () => void;
}>();
// 切换沙盒
const handleSandboxChange = (sandboxId: string) => {
emit("onSandboxChange", sandboxId);
};
// 切换模型
const handleModelChange = (modelId: number, name: string) => {
emit("onModelChange", modelId, name);
};
// 触发打开订阅弹窗
const handleOpenSubscription = () => {
emit("onOpenSubscription");
};
// 打开文件树
const handleOpenFileTree = () => {
emit("onOpenFileTree");
};
// 停止会话
const handleStopConversation = () => {
showExtraContainer.value = false;
emit("onStopConversation");
};
const handleOpenPagePreview = (uri: string) => {
emit("onOpenPagePreview", uri);
};
// 初始化波形动画数据
const initWaveform = () => {
// Dynamically generate waveform bars based on 80% of width
const targetWidth = 640 * 0.8; // rpx
const barWidth = 6; // rpx
const gap = 4; // rpx
const numBars = Math.max(
3,
Math.floor((targetWidth + gap) / (barWidth + gap)),
); // At least 3 bars
waveformBars.value = Array.from({ length: numBars }, (_, index) => ({
height: Math.random() * 20 + 5, // Initial random height 5-25
delay: index * 100,
}));
};
// 确保组件挂载后也初始化波形和输入方式
onMounted(() => {
// 初始化波形动画数据
initWaveform();
// 从缓存中读取输入方式
const chatInputMethod = uni.getStorageSync(CHAT_INPUT_METHOD);
if (
chatInputMethod &&
(chatInputMethod === ChatInputMethod.Voice ||
chatInputMethod === ChatInputMethod.Input)
) {
inputMethod.value = chatInputMethod as ChatInputMethod;
}
// 判断是否是 iOS 微信小程序
// #ifdef MP-WEIXIN
try {
const systemInfo = uni.getSystemInfoSync();
if (systemInfo.osName === "ios") {
isIOSWechatMiniProgram.value = true;
}
} catch (e) {
console.error("获取系统信息失败:", e);
}
// #endif
// 小程序端首页页面录音波形动画在开始录音时没有正常显示需要延迟初始化确保DOM已渲染
// #ifdef MP-WEIXIN
// 延迟初始化确保DOM已渲染
setTimeout(() => {
if (waveformBars.value.length === 0) {
initWaveform();
}
}, 100);
// #endif
});
// 更新波形动画
const updateWaveform = () => {
waveformBars.value = waveformBars.value.map((bar) => ({
...bar,
height: Math.random() * 20 + 5,
}));
};
// 开始录音时长计时
const startDurationTimer = () => {
durationTimer = setInterval(() => {
updateWaveform();
}, 150);
};
// 停止录音时长计时
const stopDurationTimer = () => {
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
};
// 组件卸载时清理资源
onUnmounted(() => {
stopDurationTimer();
});
// 选择组件
const handleToggleSelectComponent = (item: AgentSelectedComponentInfo) => {
if (selectedComponents.value?.some((selected) => selected.id === item.id)) {
selectedComponents.value = selectedComponents.value.filter(
(selected) => selected.id !== item.id,
);
} else {
selectedComponents.value = [...selectedComponents.value, item];
}
};
// 监听 props 变化并更新 selectedComponents
watch(
() => [props.defaultSelectedComponents, props.manualComponents],
([newValue]) => {
if (newValue) {
selectedComponents.value = newValue;
} else {
selectedComponents.value = props.manualComponents
?.filter((item) => item.defaultSelected === DefaultSelectedEnum.Yes)
.map((item) => ({
id: item.id,
type: item.type,
}));
}
},
{ immediate: true, deep: true },
);
// 键盘高度变化
const onKeyboardheightchange = (res: UniInputKeyboardHeightChangeEvent) => {
// keyboardHeight.value = res.detail.height
// 微信小程序
// #ifdef MP-WEIXIN
if (res.detail.height > 31) {
keyboardHeight.value = showExtraContainer.value ? 30 + 115 : 30;
} else {
keyboardHeight.value = 0;
}
// #endif
// 非微信小程序
// #ifndef MP-WEIXIN
keyboardHeight.value = res.detail.height;
// #endif
};
// 生成文件唯一ID
const getFileUid = () => {
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
};
// 计算已选择图片数量
const imageCount = computed(() => {
return (
files.value?.reduce(
(acc, file) => acc + (file.type?.includes("image/") ? 1 : 0),
0,
) || 0
);
});
// 计算已选择文件数量
const fileCount = computed(() => {
return (
files.value?.reduce(
(acc, file) => acc + !(file.type?.includes("image/") ? 1 : 0),
0,
) || 0
);
});
// 计算是否显示添加按钮
const showAddButton = computed(() => {
return (
imageCount.value < DEFAULT_IMAGE_COUNT ||
fileCount.value < DEFAULT_FILE_COUNT
);
});
// 选择文件
const chooseFile = () => {
// 如果已选择文件数量大于等于默认文件数量,则提示
if (fileCount.value >= DEFAULT_FILE_COUNT) {
uni.showToast({
position: "bottom",
title: t("Mobile.Chat.fileLimitReached", {
count: DEFAULT_FILE_COUNT,
}),
});
return;
}
// 可选择文件数量
const maxCount = DEFAULT_FILE_COUNT - fileCount.value;
uni.chooseFile({
type: "file",
count: maxCount,
success: (res) => {
// 当前选择文件数量
const uploadCount = res.tempFiles.length;
// 如果当前选择图片数量大于可选择图片数量,则返回
if (uploadCount > maxCount) {
uni.showToast({
title: t("Mobile.Chat.fileMaxSelect", { count: maxCount }),
icon: "none",
duration: 2000,
});
return;
}
const _files = res.tempFiles.map((file) => ({
url: file.path,
name: file.name,
type: file.type,
size: file.size,
status: UploadFileStatus.uploading,
uid: getFileUid(),
}));
files.value = [...files.value, ..._files] as UploadFileInfo[];
// 一次性上传所有选中的图片
const filePaths = res.tempFiles.map((file) => file.path);
uploadMultipleFiles(filePaths);
},
fail: (err) => {
console.error("choose file: " + err.errMsg);
},
});
};
// 选择技能
const chooseSkill = () => {
showExtraContainer.value = false;
skillSelectModalVisible.value = true;
};
// 选中技能
const handleSkillSelect = (skill: SkillInfoForAt) => {
skillSelectModalVisible.value = false; // 关闭选择技能弹框
if (!selectedSkills.value.some((s) => s.id === skill.id)) {
selectedSkills.value.push(skill);
}
let text = messageInfo.value || "";
const index = atCursorIndex.value;
let targetCursor: number = -1;
if (
isAtTrigger.value &&
index !== undefined &&
index >= 1 &&
index <= text.length
) {
// 在光标位置原地补全
const before = text.substring(0, index - 1);
const after = text.substring(index);
const addedText = `@${skill.name} `;
text = before + addedText + after;
targetCursor = before.length + addedText.length;
} else {
// 降级处理(末尾追加)
let tempText = text;
if (isAtTrigger.value && tempText.endsWith("@")) {
tempText = tempText.slice(0, -1);
}
const prefix =
tempText && tempText.length > 0
? tempText.endsWith(" ")
? ""
: " "
: "";
const addedText = prefix + `@${skill.name} `;
text = tempText + addedText;
targetCursor = tempText.length + addedText.length;
}
messageInfo.value = text;
const triggerWasActive = isAtTrigger.value;
isAtTrigger.value = false; // 重置状态
atCursorIndex.value = -1; // 重置光标位置
if (triggerWasActive && targetCursor >= 0) {
textareaCursor.value = targetCursor;
}
// #ifdef MP-WEIXIN
isInputFocused.value = false;
setTimeout(() => {
isInputFocused.value = true;
setTimeout(() => {
textareaCursor.value = -1;
}, 50);
}, 50);
// #endif
// #ifndef MP-WEIXIN
nextTick(() => {
if (inputRef.value && typeof inputRef.value?.focus === "function") {
inputRef.value.focus();
setTimeout(() => {
textareaCursor.value = -1;
}, 100);
}
});
// #endif
};
// 关闭技能选择框时的回调
const handleSkillModalClose = () => {
skillSelectModalVisible.value = false;
if (isAtTrigger.value && atCursorIndex.value >= 0) {
textareaCursor.value = atCursorIndex.value;
// #ifdef MP-WEIXIN
isInputFocused.value = false;
setTimeout(() => {
isInputFocused.value = true;
setTimeout(() => {
textareaCursor.value = -1;
}, 50);
}, 50);
// #endif
// #ifndef MP-WEIXIN
nextTick(() => {
if (inputRef.value && typeof inputRef.value?.focus === "function") {
inputRef.value.focus();
setTimeout(() => {
textareaCursor.value = -1;
}, 100);
}
});
// #endif
}
isAtTrigger.value = false;
atCursorIndex.value = -1;
};
// 移除技能
const removeSkill = (index: number) => {
const skill = selectedSkills.value[index];
if (skill != null) {
selectedSkills.value.splice(index, 1);
const mentionWithSpace = `@${skill.name} `;
const mention = `@${skill.name}`;
let text = messageInfo.value || "";
// 本地变量操作,避免对 messageInfo.value 作多次 Response 促发
while (text.includes(mentionWithSpace)) {
text = text.replace(mentionWithSpace, "");
}
while (text.includes(mention)) {
text = text.replace(mention, "");
}
messageInfo.value = text;
}
};
// 拍照或相册选择图片
const chooseImage = (type: string) => {
// 如果已选择图片数量大于等于默认图片数量,则提示
if (imageCount.value >= DEFAULT_IMAGE_COUNT) {
uni.showToast({
position: "bottom",
title: t("Mobile.Chat.imageLimitReached", {
count: DEFAULT_IMAGE_COUNT,
}),
});
return;
}
// 可选择图片数量
const maxCount = DEFAULT_IMAGE_COUNT - imageCount.value;
uni.chooseImage({
sourceType: [type === "camera" ? "camera" : "album"],
sizeType: ["original", "compressed"],
count: maxCount,
success: (res) => {
// 当前选择图片数量
const uploadCount = res.tempFiles.length;
// 如果当前选择图片数量大于可选择图片数量,则返回
if (uploadCount > maxCount) {
uni.showToast({
title: t("Mobile.Chat.imageMaxSelect", { count: maxCount }),
icon: "none",
duration: 2000,
});
return;
}
const _files = res.tempFiles.map((file) => ({
url: file.path,
name: file.name,
type: file.type,
size: file.size,
status: UploadFileStatus.uploading,
uid: getFileUid(),
}));
files.value = [...files.value, ..._files] as UploadFileInfo[];
// 一次性上传所有选中的图片
const filePaths = res.tempFiles.map((file) => file.path);
uploadMultipleFiles(filePaths);
},
fail: (err) => {
// 如果取消选择图片,则不显示错误提示
if (err.errMsg.includes("cancel")) {
return;
}
uni.showToast({
title: t("Mobile.Chat.chooseImageFailed", {
reason: err?.errMsg || "",
}),
position: "bottom",
});
},
});
};
// 上传单张图片返回Promise
const uploadFile = (
filePath: string,
uid: string,
): Promise<UploadFileInfo> => {
// 令牌
const token = uni.getStorageSync(ACCESS_TOKEN) ?? "";
const header = {};
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
header["Authorization"] = `Bearer ${token}`;
}
// #endif
// #ifdef MP-WEIXIN
header["Authorization"] = `Bearer ${token}`;
// #endif
return new Promise((resolve, reject) => {
const fileName = filePath.split("/").pop() || "image.jpg";
const uploadTask: UploadTask = uni.uploadFile({
url: UPLOAD_FILE_ACTION,
filePath: filePath,
name: "file",
header,
formData: {
type: "tmp",
},
success: (res) => {
try {
const result = JSON.parse(res.data);
const { code, data, message } = result;
if (code === SUCCESS_CODE) {
const uploadedFile: UploadFileInfo = {
key: data?.key || "",
name: data?.fileName || fileName,
type: data?.mimeType || "image/jpeg",
size: data?.size || 0,
url: data?.url || "",
uid,
status: UploadFileStatus.done,
percent: 100,
response: result,
};
resolve(uploadedFile);
} else {
reject(
new Error(
data?.message ||
message ||
t("Mobile.AudioUploader.uploadFailed"),
),
);
}
} catch (error) {
console.error("解析上传响应失败:", error);
reject(error);
}
},
fail: (error) => {
// console.error('上传失败:', error);
reject(error);
},
});
// 上传进度
uploadTask.onProgressUpdate((res: OnProgressUpdateResult) => {
const index = files.value.findIndex((file) => file.uid === uid);
if (index !== -1) {
files.value[index].percent = res.progress;
}
});
});
};
// 多文件上传
const uploadMultipleFiles = async (filePaths: string[]) => {
if (filePaths.length === 0) return;
try {
// 并发上传所有图片
const uploadPromises = filePaths.map((filePath) => {
// 找到对应的文件以获取uid
const file = files.value?.find((f) => f.url === filePath);
return uploadFile(filePath, file?.uid || "");
});
// 使用 Promise.allSettled 处理部分成功、部分失败的情况
const uploadResults = await Promise.allSettled(uploadPromises);
// 处理上传结果
uploadResults.forEach((result, index) => {
const filePath = filePaths[index];
const file = files.value?.find((f) => f.url === filePath);
// 上传成功
if (result.status === "fulfilled") {
// 上传成功,更新文件状态
const uploadedFile = result.value;
const fileIndex = files.value?.findIndex(
(f) => f.uid === uploadedFile.uid,
);
if (fileIndex !== -1) {
files.value[fileIndex] = uploadedFile;
}
} else {
// 上传失败,更新对应文件状态为 error
if (file) {
const fileIndex = files.value?.findIndex((f) => f.uid === file.uid);
if (fileIndex !== -1) {
files.value[fileIndex] = {
...files.value[fileIndex],
status: UploadFileStatus.error,
};
}
}
}
});
// 隐藏额外功能栏
showExtraContainer.value = false;
} catch (error) {
console.error("批量上传处理失败:", error);
}
};
// 输入框获取焦点
const onInputFocus = () => {
isInputFocused.value = true;
// 隐藏额外功能栏
showExtraContainer.value = false;
// 滚动到最后一个消息
props.onScrollToLastMsg?.(true);
};
// 输入框失去焦点
const onInputBlur = () => {
isInputFocused.value = false;
// 隐藏额外功能栏
showExtraContainer.value = false;
};
// 切换额外功能栏显示/隐藏
const toggleExtraContainer = () => {
showExtraContainer.value = !showExtraContainer.value;
};
// 打开麦克风权限
const openMicrophonePermission = async () => {
// #ifdef H5
try {
const permissionState = await MicrophonePermissionHelper.check();
console.log("切换到语音模式, 检查麦克风权限", permissionState);
if (permissionState !== "granted") {
MicrophonePermissionHelper.request();
}
} catch (error) {
console.error("切换到语音模式, 检查麦克风权限失败:", error);
}
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
// 小程序端检查录音权限
try {
const authResult = await uni.authorize({
scope: "scope.record",
});
} catch (error: any) {
// 权限被拒绝,引导用户手动开启
uni.showModal({
title: t("Mobile.Voice.permissionRequired"),
content: t("Mobile.Voice.permissionContent"),
confirmText: t("Mobile.Voice.goToSettings"),
success: (res) => {
if (res.confirm) {
uni.openSetting({
success: (settingRes) => {
if (settingRes.authSetting["scope.record"]) {
// 用户开启了权限,可以重新尝试录音
}
},
});
}
},
});
}
// #endif
};
// 切换输入方式
const handleChangeInputMethod = async () => {
// if (props.isConversationActive.value) return;
if (inputMethod.value === ChatInputMethod.Voice) {
inputMethod.value = ChatInputMethod.Input;
uni.setStorageSync(CHAT_INPUT_METHOD, ChatInputMethod.Input);
// 隐藏额外功能栏
showExtraContainer.value = false;
nextTick(() => {
if (inputRef.value && typeof inputRef.value?.focus === "function") {
inputRef.value.focus();
}
});
} else {
// 切换到语音模式
inputMethod.value = ChatInputMethod.Voice;
uni.setStorageSync(CHAT_INPUT_METHOD, ChatInputMethod.Voice);
inputRef.value = null;
}
};
// 监听输入方式变化
watch(
inputMethod,
(newVal) => {
if (newVal === ChatInputMethod.Voice) {
openMicrophonePermission();
}
},
{ immediate: true },
);
// 输入事件
const handleInput = (e: UniInputEvent) => {
const value = e.detail.value ?? "";
const prevValue = messageInfo.value || "";
const cursor = e.detail.cursor;
// 兼容判定:如果取不到 cursor fallback 到 endsWith
const isAtTyped =
cursor !== undefined
? cursor > 0 && value.substring(cursor - 1, cursor) === "@"
: value.endsWith("@");
const isAddingOne = value.length === prevValue.length + 1;
// 输入 @ 弹出技能框
if (props.isTaskAgent && props.enableSkillAt && isAddingOne && isAtTyped) {
uni.hideKeyboard(); // 提前收起键盘
isAtTrigger.value = true;
atCursorIndex.value = cursor ?? -1;
showExtraContainer.value = false;
skillSelectModalVisible.value = true;
}
messageInfo.value = value;
selectedSkills.value = selectedSkills.value.filter(
(s: SkillInfoForAt): boolean => value.includes(`@${s.name}`),
);
};
// 点击发送事件
const handleSendMessage = () => {
if (props.wholeDisabled) {
// 未填写必填参数时,清空输入内容,让用户可以再次切换输入方式
messageInfo.value = "";
uni.showToast({
title: t("Mobile.Chat.fillRequiredParams"),
icon: "none",
duration: 2000,
});
return;
}
// 判断是否存在上传失败的图片或文件
const existErrorFile = files.value?.some(
(file) => file.status === UploadFileStatus.error,
);
if (existErrorFile) {
uni.showToast({
title: t("Mobile.Chat.removeUploadFailed"),
icon: "none",
duration: 1500,
});
return;
}
if (
(messageInfo.value && messageInfo.value?.trim()) ||
files.value?.length > 0 ||
selectedSkills.value.length > 0
) {
const data = {
messageInfo: messageInfo.value,
files: files.value,
selectedComponents: selectedComponents.value,
skillIds: selectedSkills.value.map((s) => s.targetId),
};
const pages = getCurrentPages();
// 当前页面
const currentPage = pages[pages.length - 1];
// 当前页面是首页
if (currentPage.route.includes("home")) {
uni.$emit("homeVoiceRecorderStart", data);
} else {
// 当前页面不是首页(智能体主页、临时聊天页),发送消息
emit("onSendMessage", data);
}
// 置空
selectedSkills.value = [];
files.value = [];
messageInfo.value = "";
}
};
// 语音录制开始
const handleVoiceStart = () => {
isRecording.value = true;
startDurationTimer();
};
// 语音录制结束并转换为文字
const handleVoiceStop = async (transcriptText: string) => {
isRecording.value = false;
stopDurationTimer();
if (transcriptText && typeof transcriptText === "string") {
// 将语音转换的文字作为消息发送
messageInfo.value = transcriptText;
await nextTick();
handleSendMessage();
}
};
// 语音录制错误
const handleVoiceError = (error: any) => {
isRecording.value = false;
stopDurationTimer();
let errorMessage = t("Mobile.Voice.recordFailed");
if (error.message) {
errorMessage = error.message;
} else if (error.errMsg) {
errorMessage = error.errMsg;
}
uni.showToast({
title: errorMessage,
icon: "none",
duration: 2000,
});
};
// 取消录音
// const handleCancelChange = (value: boolean) => {
// isCancelled.value = value;
// }
// Add @onRecordCancel to voice-recorder-button
const handleRecordCancel = () => {
isRecording.value = false;
isCancelled.value = false; // Optional: reset cancel state
};
// Add handler
const handleRecordEnd = () => {
isRecording.value = false;
isCancelled.value = false;
};
// Add handler for line change
const handleLineChange = (e: UniTextareaLineChangeEvent) => {
isMultiLine.value = e.detail.lineCount > 1;
};
// 删除文档
const handleDelFile = (uid: string) => {
files.value = files.value?.filter((file) => file.uid !== uid) || [];
};
</script>
<style lang="scss" scoped>
.container {
padding: 16rpx 32rpx 0 32rpx;
gap: 20rpx;
background-color: #fff;
border-radius: 16rpx 16rpx 0 0;
// 确保容器为相对定位,用于容纳绝对定位的蒙层
position: relative;
overflow: visible;
.split-line {
width: 100%;
height: 1rpx;
background-color: #f0f0f0;
}
.record-tip-container {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 24rpx;
box-shadow:
0 9rpx 45rpx 0 rgba(0, 0, 0, 0.08),
0 2rpx 10rpx 0 rgba(0, 0, 0, 0.08),
0 0 3rpx 0 rgba(0, 0, 0, 0.08);
position: relative;
// #ifdef H5
border: 1rpx solid rgba(0, 0, 0, 0.15);
// #endif
&.remove-shadow {
box-shadow: none;
border-radius: 0;
border-color: transparent;
}
.waveform {
height: 70rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4rpx;
width: 100%;
.waveform-bar {
width: 6rpx;
background-color: rgb(108, 101, 244);
border-radius: 3rpx;
transition: height 0.3s ease;
}
}
}
.chat-input-container {
width: 100%;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
min-height: 90rpx;
.input {
width: 552rpx;
// width: 90%;
// min-height: 44rpx;
// line-height: 32rpx; // 过小导致闪烁
line-height: 44rpx; // 恢复正常行高
max-height: 320rpx;
margin: 10rpx 0 10rpx 10rpx;
// 彻底移除垂直 padding消除底部留白
padding: 0 10rpx;
border: none;
font-size: 32rpx;
font-weight: 400;
color: #000;
box-sizing: border-box;
resize: none;
outline: none;
// #ifndef MP-WEIXIN
// 允许纵向滚动,但隐藏滚动条样式 (H5等)
overflow-y: auto;
// #endif
&.input-text {
width: 90%;
}
// 隐藏滚动条 - 兼容各浏览器
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
.input-placeholder {
// line-height: 44rpx;
color: rgba(0, 0, 0, 0.25);
font-size: 28rpx;
font-weight: 400;
}
.icon-container {
position: absolute;
right: 0rpx;
bottom: 4rpx;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
// gap: 32rpx;
// height: 50rpx;
z-index: 50;
background-color: #fff;
.iconfont {
padding: 15rpx;
}
.icon-image {
color: #5147ff;
}
.icon-send-image {
color: #5147ff !important;
font-size: 48rpx;
}
}
}
.chat-input-container.multi-line {
flex-direction: column;
align-items: stretch;
gap: 16rpx;
.input {
width: 87%;
line-height: 44rpx;
}
.icon-container {
// position: inherit;
align-self: flex-end; // Align icons to right or adjust as needed
flex-direction: row;
justify-content: flex-end;
}
}
}
.extra-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 32rpx;
justify-content: flex-start;
// padding: 8rpx 32rpx;
max-height: 0;
overflow: hidden;
transition:
max-height 0.3s ease-in-out,
padding 0.3s ease-in-out,
opacity 0.3s ease-in-out;
opacity: 0;
&.show {
max-height: 200rpx;
// padding: 8rpx 32rpx;
opacity: 1;
}
.extra-box {
// width: 164rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16rpx;
.icon-box {
width: 112rpx;
height: 112rpx;
display: flex;
justify-content: center;
align-items: center;
gap: 16rpx;
border-radius: 16rpx;
background-color: #f5f5f5;
.iconfont {
font-size: 48rpx;
color: #333;
}
}
.text {
font-size: 28rpx;
color: #15171f;
font-weight: 400;
line-height: 44rpx;
}
}
}
.icon-loop {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 键盘弹出后,垫高底部。默认占位大小为底部安全区域高度
.keyboard-cover {
// Android 的键盘高度不包含安全区域高度,其他平台包含
// #ifdef APP-ANDROID
margin-bottom: env(safe-area-inset-bottom);
// #endif
// #ifndef APP-ANDROID
padding-bottom: env(safe-area-inset-bottom);
// #endif
}
.ai-generate-text-container {
margin-top: -15rpx;
margin-bottom: -15rpx;
// iOS 微信小程序特殊样式
&.ios-wechat {
position: absolute;
bottom: -24rpx;
left: 0;
right: 0;
margin-bottom: env(safe-area-inset-bottom);
}
}
.ai-generate-text {
font-size: 18rpx;
color: #999;
line-height: 32rpx;
text-align: center;
}
.permission-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 99;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
// 匹配父容器圆角
border-radius: 24rpx;
// backdrop-filter: blur(2px); // 兼容性较差,可能不需要
&.clickable {
cursor: pointer;
background-color: rgba(247, 250, 255, 0.95);
border: 1px dashed rgba(0, 122, 255, 0.3);
padding: 0 16rpx;
&:active {
background-color: rgba(235, 243, 255, 0.98);
}
}
.permission-text {
font-size: 28rpx;
color: #ccc;
&.text-blue {
color: #007aff;
font-weight: 500;
text-align: center;
}
}
.sub-arrow {
color: #007aff;
font-size: 32rpx;
margin-left: 12rpx;
animation: bounceRight 1.5s infinite ease-in-out;
}
}
@keyframes bounceRight {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(6rpx);
}
}
</style>