chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
/**
* 将字节Byte转换为适当的单位KB, MB, GB, TB
* @param bytes 字节数
* @param decimals 小数点后位数
* @returns 格式化后的字符串
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (
Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
);
}
/**
* 将字节转换为千字节KB
* @param bytes 字节数
* @param decimals 小数点后位数
* @returns 千字节数值
*/
export function bytesToKB(bytes: number, decimals = 2): number {
return Number.parseFloat((bytes / 1024).toFixed(decimals));
}
/**
* 将字节转换为兆字节MB
* @param bytes 字节数
* @param decimals 小数点后位数
* @returns 兆字节数值
*/
export function bytesToMB(bytes: number, decimals = 2): number {
return Number.parseFloat((bytes / (1024 * 1024)).toFixed(decimals));
}
/**
* 将字节转换为千字节KB并格式化
* @param bytes 字节数
* @param decimals 小数点后位数
* @returns 格式化后的千字节字符串
*/
export function formatBytesToKB(bytes: number, decimals = 2): string {
return `${bytesToKB(bytes, decimals)} KB`;
}
/**
* 将字节转换为兆字节MB并格式化
* @param bytes 字节数
* @param decimals 小数点后位数
* @returns 格式化后的兆字节字符串
*/
export function formatBytesToMB(bytes: number, decimals = 2): string {
return `${bytesToMB(bytes, decimals)} MB`;
}

View File

@@ -0,0 +1,429 @@
import { MsgItem, markdownElItem } from '@/uni_modules/uni-ai-x/types'
import { MessageInfo } from '@/types/interfaces/conversationInfo'
import { AssistantRoleEnum, MessageModeEnum, MessageTypeEnum } from '@/types/enums/agent'
import { MessageStatusEnum } from '@/types/enums/common'
/**
* SSE数据块接口 - 匹配实际返回的数据结构
*/
export interface SSEChunk {
requestId: string
messageType?: string
message_type?: string
subType?: string
sub_type?: string
eventType: string
error: string | null
data: {
id: string
role: string
type: string
text: string
time: string | null
attachments: any[] | null
think: string | null
quotedText: string | null
ext: any[] | null
finished: boolean
executeId: string | null
messageType: string
}
completed: boolean
}
/**
* 聊天数据适配器
* 用于将MessageInfo格式转换为uni-ai-x期望的MsgItem格式
*/
export class ChatDataAdapter {
/**
* 判断SSE数据块是否可以按聊天文本消息渲染
*/
static isRenderableSSEChunk(chunk: SSEChunk): boolean {
const messageType = (chunk as any).messageType || (chunk as any).message_type
if (messageType === 'acpRequestPermission') {
return false
}
const data = (chunk as any).data
if (!data || typeof data !== 'object') {
return false
}
return typeof data.id === 'string' && data.id.length > 0
}
/**
* 将SSE数据块转换为MsgItem
* @param chunk SSE数据块
* @param chatId 聊天ID
* @returns uni-ai-x的MsgItem格式
*/
static convertSSEChunkToMsgItem(chunk: SSEChunk, chatId: string): MsgItem {
console.log(`Converting SSE chunk to MsgItem:`, chunk)
const data = (chunk as any).data || {}
const msgItem: MsgItem = {
_id: data.id || this.generateId(),
from_uid: this.getFromUidFromRole(data.role || 'ASSISTANT'),
body: data.text || '',
create_time: this.parseTime(data.time) || Date.now(),
state: data.finished ? 3 : 2, // 完成状态为3未完成为2
chat_id: chatId,
markdownElList: [],
rendered: false
}
console.log(`Converted MsgItem:`, msgItem)
return msgItem
}
/**
* 将MessageInfo转换为MsgItem
* @param messageInfo 消息信息
* @returns uni-ai-x的MsgItem格式
*/
static convertToMsgItem(messageInfo: MessageInfo): MsgItem {
const msgItem: MsgItem = {
_id: messageInfo.id?.toString() || '',
from_uid: this.getFromUid(messageInfo.role),
body: messageInfo.text || '',
create_time: this.parseTime(messageInfo.time),
state: this.getMsgState(messageInfo.status),
chat_id: '', // 需要从外部传入
markdownElList: (messageInfo as any).markdownElList || [],
rendered: false,
// 传递processingList用于自定义组件渲染
processingList: messageInfo.processingList || []
}
return msgItem
}
/**
* 将MsgItem转换为MessageInfo
* @param msgItem uni-ai-x的MsgItem格式
* @returns MessageInfo格式
*/
static convertToMessageInfo(msgItem: MsgItem): MessageInfo {
const messageInfo: MessageInfo = {
id: msgItem._id,
role: this.getAssistantRole(msgItem.from_uid),
type: this.getMessageMode(msgItem.from_uid),
text: msgItem.body,
think: (msgItem as any).thinkContent || '',
time: new Date(msgItem.create_time).toISOString(),
attachments: [],
ext: [],
finished: false,
metadata: null,
messageType: MessageTypeEnum.USER,
status: this.getMessageStatus(msgItem.state),
markdownElList: msgItem.markdownElList || [],
processingList: [],
finalResult: null,
requestId: ''
}
return messageInfo
}
/**
* 批量转换MessageInfo数组为MsgItem数组
* @param messageList 消息列表
* @param chatId 聊天ID
* @returns MsgItem数组
*/
static convertMessageListToMsgItems(messageList: MessageInfo[], chatId: string = ''): MsgItem[] {
return messageList.map(message => {
const msgItem = this.convertToMsgItem(message)
msgItem.chat_id = chatId
return msgItem
})
}
/**
* 批量转换MsgItem数组为MessageInfo数组
* @param msgItems 消息项数组
* @returns MessageInfo数组
*/
static convertMsgItemsToMessageList(msgItems: MsgItem[]): MessageInfo[] {
return msgItems.map(msgItem => this.convertToMessageInfo(msgItem))
}
/**
* 处理SSE流式数据累积文本内容
* @param chunks SSE数据块数组
* @param chatId 聊天ID
* @returns 处理后的MsgItem数组
*/
static processSSEChunks(chunks: SSEChunk[], chatId: string): MsgItem[] {
const messageMap = new Map<string, MsgItem>()
chunks.forEach(chunk => {
if (!this.isRenderableSSEChunk(chunk)) {
return
}
const messageId = chunk.data.id
if (!messageMap.has(messageId)) {
// 创建新消息
const msgItem = this.convertSSEChunkToMsgItem(chunk, chatId)
messageMap.set(messageId, msgItem)
} else {
// 累积文本内容
const existingMsg = messageMap.get(messageId)!
existingMsg.body += chunk.data.text || ''
// 更新状态
if (chunk.data.finished) {
existingMsg.state = 3 // 完成状态
}
}
})
// 兼容性处理将Map值转换为数组
const result: MsgItem[] = []
messageMap.forEach((value) => {
result.push(value)
})
return result
}
/**
* 根据角色获取from_uid
* @param role 角色
* @returns from_uid
*/
private static getFromUid(role: AssistantRoleEnum): string {
switch (role) {
case AssistantRoleEnum.USER:
return 'user'
case AssistantRoleEnum.ASSISTANT:
return 'uni-ai'
case AssistantRoleEnum.SYSTEM:
return 'system'
case AssistantRoleEnum.FUNCTION:
return 'tool'
default:
return 'user'
}
}
/**
* 根据角色字符串获取from_uid
* @param role 角色字符串
* @returns from_uid
*/
private static getFromUidFromRole(role: string): string {
switch (role) {
case 'USER':
return 'user'
case 'ASSISTANT':
return 'uni-ai'
case 'SYSTEM':
return 'system'
case 'FUNCTION':
return 'tool'
default:
return 'user'
}
}
/**
* 根据from_uid获取角色
* @param fromUid from_uid
* @returns 角色
*/
private static getAssistantRole(fromUid: string): AssistantRoleEnum {
switch (fromUid) {
case 'user':
return AssistantRoleEnum.USER
case 'uni-ai':
return AssistantRoleEnum.ASSISTANT
case 'system':
return AssistantRoleEnum.SYSTEM
case 'tool':
return AssistantRoleEnum.FUNCTION
default:
return AssistantRoleEnum.USER
}
}
/**
* 根据from_uid获取消息模式
* @param fromUid from_uid
* @returns 消息模式
*/
private static getMessageMode(fromUid: string): MessageModeEnum {
switch (fromUid) {
case 'user':
return MessageModeEnum.CHAT
case 'uni-ai':
return MessageModeEnum.ANSWER
case 'system':
return MessageModeEnum.GUID
case 'tool':
return MessageModeEnum.CHAT
default:
return MessageModeEnum.CHAT
}
}
/**
* 根据消息状态获取MsgItem状态
* @param status 消息状态
* @returns MsgItem状态
*/
private static getMsgState(status?: MessageStatusEnum | null): number {
switch (status) {
case MessageStatusEnum.Loading:
return 1 // 加载中
case MessageStatusEnum.Incomplete:
return 2 // 未完成
case MessageStatusEnum.Complete:
return 3 // 完成
case MessageStatusEnum.Error:
return 4 // 错误
default:
return 3 // 默认完成
}
}
/**
* 根据MsgItem状态获取消息状态
* @param state MsgItem状态
* @returns 消息状态
*/
private static getMessageStatus(state: number): MessageStatusEnum | null {
switch (state) {
case 1:
return MessageStatusEnum.Loading
case 2:
return MessageStatusEnum.Incomplete
case 3:
return MessageStatusEnum.Complete
case 4:
return MessageStatusEnum.Error
default:
return null
}
}
/**
* 解析时间字符串为时间戳
* @param timeStr 时间字符串
* @returns 时间戳
*/
private static parseTime(timeStr: string | null): number {
if (!timeStr) return Date.now()
try {
return new Date(timeStr).getTime()
} catch (error) {
return Date.now()
}
}
/**
* 创建新的用户消息
* @param content 消息内容
* @param chatId 聊天ID
* @param attachments 附件
* @returns 新的MsgItem
*/
static createUserMessage(content: string, chatId: string, attachments: any[] = []): MsgItem {
return {
_id: this.generateId(),
from_uid: 'user',
body: content,
create_time: Date.now(),
state: 3, // 完成状态
chat_id: chatId,
markdownElList: [],
rendered: false
}
}
/**
* 创建新的AI消息
* @param content 消息内容
* @param chatId 聊天ID
* @param markdownElements markdown元素
* @returns 新的MsgItem
*/
static createAIMessage(content: string, chatId: string, markdownElements: markdownElItem[] = []): MsgItem {
return {
_id: this.generateId(),
from_uid: 'uni-ai',
body: content,
create_time: Date.now(),
state: 2, // 未完成状态
chat_id: chatId,
markdownElList: markdownElements,
rendered: false
}
}
/**
* 生成唯一ID
* @returns 唯一ID
*/
private static generateId(): string {
// #ifdef MP-WEIXIN
// 微信小程序中uni.getRandomValues返回Promise这里简化处理
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
// #endif
// #ifdef H5
// 这里需要导入uuid库
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
// #endif
// 默认返回值
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}
}
// 导出适配器实例
export const chatDataAdapter = new ChatDataAdapter()
/*
使用示例:
// 1. 在聊天页面中导入适配器
import { chatDataAdapter, SSEChunk } from '@/utils/chatDataAdapter'
// 2. 处理SSE流式数据
const handleSSEChunk = (chunk: SSEChunk) => {
// 解析SSE数据
const sseData = JSON.parse(chunk)
// 累积数据块
sseChunks.value.push(sseData)
// 处理并转换为MsgItem
const msgItems = chatDataAdapter.processSSEChunks(sseChunks.value, 'chat-' + conversationId.value)
// 更新渲染列表
msgItemsForRender.value = msgItems
}
// 3. 在模板中使用
<template v-for="item in msgItemsForRender" :key="item._id">
<uni-ai-x-msg v-if="item.from_uid == 'uni-ai'" :msg="item" />
<text v-else class="user-msg">{{item.body}}</text>
</template>
// 4. 处理流式响应
const handleStreamResponse = (chunk: string) => {
try {
const sseChunk: SSEChunk = JSON.parse(chunk)
handleSSEChunk(sseChunk)
} catch (error) {
console.error('解析SSE数据失败:', error)
}
}
*/

View File

@@ -0,0 +1,145 @@
import { ChatMessage, StreamChunk } from '@/types/interfaces/chat'
import { StreamRequest } from '@/utils/streamRequest'
import { CHAT_API, API_HEADERS } from '@/constants/api.constants'
import { ACCESS_TOKEN } from '@/constants/home.constants'
// 聊天服务类
export class ChatService {
private currentRequest: any = null
private isStreaming: boolean = false
// 发送消息并获取流式响应
async sendMessage(
data: any,
onChunk: (chunk: string) => void,
onComplete: () => void,
onError: (error: any) => void,
// 是否是临时会话
isTempChat?: boolean = false,
// 超时回调微信小程序专用60秒内无数据流动时触发
onTimeout?: () => void
): Promise<void> {
// if (this.isStreaming) {
// onError(new Error('已有请求正在进行中'))
// return
// }
this.isStreaming = true
try {
// 使用配置的API端点
const apiUrl = isTempChat ? CHAT_API.TEMP_STREAM : CHAT_API.STREAM
const token = uni.getStorageSync(ACCESS_TOKEN)
this.currentRequest = new StreamRequest()
const header = {
...API_HEADERS,
}
// #ifdef H5 || WEB
if(process.env.NODE_ENV === 'development'){
header['Authorization'] = `Bearer ${token}`
}
// #endif
// #ifdef MP-WEIXIN
header['Authorization'] = `Bearer ${token}`
// #endif
await this.currentRequest.request({
url: apiUrl,
method: 'POST',
headers: header,
body: {
...data,
// conversationId: 1442734, //TODO 调试 先写死 后面一定删除
stream: true
},
onChunk: (chunk: StreamChunk) => {
if (chunk.type === 'content') {
onChunk(chunk.data)
}
},
onError: (error: any) => {
this.isStreaming = false
onError(error)
},
onComplete: () => {
this.isStreaming = false
onComplete()
},
onTimeout: () => {
this.isStreaming = false
if (onTimeout) {
onTimeout()
}
}
})
} catch (error) {
this.isStreaming = false
onError(error)
}
}
// 中止当前请求
abort(): void {
if (this.currentRequest) {
this.currentRequest.abort()
this.currentRequest = null
}
this.isStreaming = false
}
// 创建用户消息
createUserMessage(content: string): ChatMessage {
return {
id: this.generateId(),
role: 'user',
content: content,
timestamp: Date.now(),
status: 'sent'
}
}
// 创建AI消息
createAIMessage(content: string = ''): ChatMessage {
return {
id: this.generateId(),
role: 'assistant',
content: content,
timestamp: Date.now(),
status: 'processing'
}
}
// 更新AI消息内容
updateAIMessage(message: ChatMessage, content: string): void {
message.content = content
}
// 完成AI消息
completeAIMessage(message: ChatMessage): void {
message.status = 'sent'
message.rendered = true
}
// 设置消息错误
setMessageError(message: ChatMessage, error: string): void {
message.status = 'error'
message.error = error
}
// 生成唯一ID
private generateId(): string {
return 'msg_' + Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 检查是否正在流式传输
get isCurrentlyStreaming(): boolean {
return this.isStreaming
}
}
// 创建聊天服务实例
export const chatService = new ChatService()

View File

@@ -0,0 +1,544 @@
import { t } from "@/utils/i18n";
// 过滤非数字
const getNumbersOnly = (text: string) => {
return text?.replace(/[^0-9]/g, "");
};
// 判断字符串是否全是数字
const isNumber = (value: string) => {
return !Number.isNaN(Number(value));
};
const isWeakNumber = (value: any) => {
if (typeof value === "number") {
return true;
}
if (value && typeof value === "string") {
return isNumber(value);
}
return false;
};
// 校验手机号是否合法
function isValidPhone(phone: string) {
const reg = /^1[3456789]\d{9}$/;
return reg.test(phone);
}
// 校验邮箱地址是否合法
function isValidEmail(email: string) {
const reg = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,}$/;
return reg.test(email);
}
// 校验数据库表名是否合法
// 1. 表名必须以字母开头,后面可以跟字母、数字或下划线。
function validateTableName(tableName: string) {
const reg = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
return reg.test(tableName);
}
// 检测字符串是否为有效的JSON格式
function isValidJSON(str: string): boolean {
if (!str || typeof str !== "string") {
return false;
}
// 去除首尾空白字符
const trimmedStr = str.trim();
// 检查基本结构:对象必须以 { 开头, 以} 结尾;
if (!/^{/.test(trimmedStr) || !/}$/.test(trimmedStr)) {
return false;
}
try {
JSON.parse(trimmedStr);
return true;
} catch (error) {
return false;
}
}
// 检测字符串是否为有效的JSON格式并返回解析结果
function parseJSON<T = any>(
str: string,
): { isValid: boolean; data?: T; error?: string } {
if (!str || typeof str !== "string") {
return { isValid: false, error: t("Mobile.Common.invalidInputString") };
}
try {
const data = JSON.parse(str) as T;
return { isValid: true, data };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: t("Mobile.Common.invalidJsonFormat"),
};
}
}
// 校验登录密码
function validatePassword(password: string) {
return password?.length >= 6;
// const reg = /^(?=.*\d)(?=.*[a-zA-Z])[\da-zA-Z~!@#$%^&*]{8,16}$/;
// return reg.test(password);
}
// 获取url地址search中的参数
const getURLParams = () => {
// #ifdef H5
const searchParams = window.location.search.substring(1).split("&");
// 为了解决元素隐式具有 "any" 类型的问题,将 params 的类型定义为 Record<string, string>
// 这样就可以使用 string 类型的 key 来索引 params 对象
const params: Record<string, string> = {};
for (const param of searchParams) {
const [key, value] = param.split("=");
params[key] = value;
}
return params;
// #endif
// #ifndef H5
// 非H5平台从页面参数中获取
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const params: Record<string, string> = {};
if (currentPage?.options) {
for (const key in currentPage.options) {
params[key] = currentPage.options[key] || "";
}
}
return params;
// #endif
};
// 给页面head添加base标签: <base target="_blank">
const addBaseTarget = () => {
// #ifdef H5
if (!document.head.querySelector("base")) {
const base = document.createElement("base");
base.target = "_blank";
document.head.append(base);
}
// #endif
// #ifndef H5
// 非H5平台无需处理
// #endif
};
// 判断对象是否为空
const isEmptyObject = (obj: Record<string, any>) => {
return obj && typeof obj === "object" && Object.keys(obj).length === 0;
};
// 格式化时间
function formatTimeAgo(targetTime: string) {
if (!targetTime) {
return "";
}
const now = new Date().getTime(); // 当前时间戳,单位为毫秒
const target = new Date(targetTime).getTime();
const diff = now - target; // 时间差(毫秒)
const diffSeconds = Math.floor(diff / 1000); // 转换为秒
const diffMinutes = Math.floor(diffSeconds / 60); // 转换为分钟
const diffHours = Math.floor(diffMinutes / 60); // 转换为小时
const diffDays = Math.floor(diffHours / 24); // 转换为天
if (diffDays > 365 * 2) {
return t("Mobile.Time.yearsAgo", {
count: Math.floor(diffDays / 365),
});
} else if (diffDays > 365) {
return t("Mobile.Time.lastYear");
} else if (diffDays > 30) {
const currentDate = new Date();
const inputDate = new Date(targetTime);
// 计算月份差
let monthsDifference =
(currentDate.getFullYear() - inputDate.getFullYear()) * 12;
monthsDifference += currentDate.getMonth() - inputDate.getMonth();
// 如果当前日期的日小于输入日期的日,月份差减 1
if (currentDate.getDate() < inputDate.getDate()) {
monthsDifference--;
}
if (monthsDifference >= 1) {
return t("Mobile.Time.monthsAgo", {
count: monthsDifference,
});
}
return null;
} else if (diffDays > 6) {
return t("Mobile.Time.daysAgo", {
count: diffDays,
});
} else if (diffDays > 2) {
let date = new Date(targetTime);
let month = date.getMonth() + 1;
let day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
return `${month}-${day}`;
} else if (diffDays === 2) {
return t("Mobile.Time.dayBeforeYesterday");
} else if (diffDays === 1) {
return t("Mobile.Time.yesterday");
} else if (diffHours > 1) {
return t("Mobile.Time.hoursAgo", {
count: diffHours,
});
} else if (diffMinutes > 0) {
return t("Mobile.Time.minutesAgo", {
count: diffMinutes,
});
} else {
return t("Mobile.Time.justNow");
}
}
// html自定义转义
function encodeHTML(str: string) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 检查第一个数组每个元素是否都存在于第二个数组中。
const arraysContainSameItems = (arr1: string[], arr2: string[]) => {
// 使用 Set 去重
const set1 = new Set(arr1);
const set2 = new Set(arr2);
// 检查 set1 中的每个元素是否都在 set2 中
for (let item of set1) {
if (!set2.has(item)) {
return false;
}
}
return true;
};
// 向上查找元素
const findParentElement = (element: HTMLElement, className: string) => {
// #ifdef H5
let currentElement = element;
while (currentElement.parentElement) {
if (currentElement.parentElement.classList.contains(className)) {
return currentElement.parentElement;
}
currentElement = currentElement.parentElement;
}
return null;
// #endif
// #ifndef H5
// 非H5平台不支持DOM操作返回null
return null;
// #endif
};
const findClassElement = (currentElement: HTMLElement, className: string) => {
// #ifdef H5
if (currentElement.classList.contains(className)) {
return currentElement;
}
return findParentElement(currentElement, className);
// #endif
// #ifndef H5
// 非H5平台不支持DOM操作返回null
return null;
// #endif
};
const noop = () => {};
/**
* 生成唯一ID同步方法适用于所有平台
* 格式:时间戳(13位) + 随机数(7位) = 20位字符串
* @returns 唯一ID字符串
*/
function generateUniqueId(): string {
// 时间戳(毫秒)
const timestamp = Date.now().toString(36);
// 随机数部分 - 使用多个随机源提高唯一性
const random1 = Math.random().toString(36).substring(2, 9);
const random2 = Math.random().toString(36).substring(2, 5);
// 组合成唯一ID
return `${timestamp}${random1}${random2}`;
}
/**
* 生成类似 UUID 格式的ID同步方法
* 格式xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* @returns UUID格式的字符串
*/
function generateUUID(): string {
const timestamp = Date.now();
const random = () => Math.floor(Math.random() * 16).toString(16);
// 使用时间戳和随机数组合
let uuid = "";
for (let i = 0; i < 32; i++) {
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += "-";
}
if (i === 12) {
uuid += "4"; // UUID version 4
} else if (i === 16) {
uuid += ((Math.random() * 4) | 8).toString(16); // UUID variant
} else {
// 混合时间戳和随机数
const useTime = i < 8;
uuid += useTime
? ((timestamp >> ((7 - i) * 4)) & 0xf).toString(16)
: random();
}
}
return uuid;
}
/**
* 替换路径模板中的动态变量
* @param template - 路径模板,例如 /view/detail/{id}
* @param params - 参数对象,例如 { id: 123 }
* @returns 替换后的路径,例如 /view/detail/123
*/
const fillPathParams = (
template: string,
params: Record<string, string | number>,
): string => {
return template.replace(/{(\w+)}/g, (_, key) => {
if (params[key] === undefined) {
throw new Error(
t("Mobile.Common.missingPathParam", {
key,
}),
);
}
return String(params[key]);
});
};
/**
* 检查路径模板中的变量是否在 data 中存在且值有效
* @param template 路径模板,例如 /user/{id}/{name}
* @param data 参数对象,例如 { id: 1, name: 'Tom' }
* @returns 是否全部存在且有效
*/
const checkPathParams = (
template: string,
data: Record<string, any>,
): boolean => {
const keys = [...template.matchAll(/{(\w+)}/g)].map((m) => m[1]);
return (
keys.length === 0 ||
keys.every(
(k) => data[k] !== undefined && data[k] !== null && data[k] !== "",
)
);
};
/**
* 将对象转换为 URL 查询字符串
* 用于替代 URLSearchParams微信小程序不支持
* @param params 参数对象
* @returns URL 查询字符串,例如 "key1=value1&key2=value2"
*/
const objectToQueryString = (
params: Record<string, any> | null | undefined,
): string => {
if (!params || typeof params !== "object") {
return "";
}
const queryParts: string[] = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
const value = params[key];
// 跳过 undefined 和 null 值
if (value === undefined || value === null || value === "") {
continue;
}
// 对键和值进行 URL 编码
const encodedKey = encodeURIComponent(key);
const encodedValue = encodeURIComponent(String(value));
queryParts.push(`${encodedKey}=${encodedValue}`);
}
}
return queryParts.join("&");
};
/**
* 从 URL 中移除指定 query 参数(支持多个、支持 hash、支持不存在的参数
* @param url
* @param keys
* @returns
*/
const removeQueryCompat = (url: string, keys: string | string[]): string => {
const arr = Array.isArray(keys) ? keys : [keys];
const [base, hash = ""] = url.split("#");
const [path, query = ""] = base.split("?");
if (!query) return url; // 没 query 无需处理
const params = query.split("&").filter((item) => {
const [key] = item.split("=");
return !arr.includes(key);
});
const newUrl = params.length ? `${path}?${params.join("&")}` : path;
return hash ? `${newUrl}#${hash}` : newUrl;
};
/**
* 登录后跳转页面如果redirectUrl存在则跳转到redirectUrl否则跳转到/pages/index/index
* @param redirectUrl
* @returns
*/
const redirectTo = (redirectUrl: string) => {
if (!redirectUrl) {
uni.reLaunch({ url: "/pages/index/index" });
return;
}
// #ifdef H5 || WEB
location.replace(redirectUrl);
// #endif
// #ifdef MP-WEIXIN
// 先解码URL,避免路径被错误拼接(如 %2Fpages%2Findex 会被拼接到当前路径后)
const decodedUrl = decodeURIComponent(redirectUrl);
uni.reLaunch({ url: decodedUrl });
// 清空全局变量
globalThis.appConfig.redirectUrl = null;
// #endif
};
/**
* 获取当前页面的路径
*/
const getCurrentPagePath = () => {
// #ifdef H5 || WEB
return window.location.href;
// #endif
// #ifdef MP-WEIXIN
return getCurrentPages()[getCurrentPages().length - 1]?.route;
// #endif
};
/**
* 获取当前页面的参数
*/
const getCurrentPageParams = () => {
// #ifdef H5 || WEB
// 从 hash 中提取查询参数(适配 hash 路由模式)
const hash = window.location.hash;
const questionMarkIndex = hash.indexOf("?");
if (questionMarkIndex === -1) {
return {};
}
const queryString = hash.substring(questionMarkIndex + 1);
const params: Record<string, any> = {};
queryString.split("&").forEach((param) => {
const [key, value] = param.split("=");
if (key) {
params[key] = value ? decodeURIComponent(value) : "";
}
});
return params;
// #endif
// #ifdef MP-WEIXIN
return getCurrentPages()[getCurrentPages().length - 1]?.options;
// #endif
};
/**
* 获取当前页面的完整路径
* @param fullPath 是否包含参数
* @returns 当前页面的完整路径
*/
const getCurrentPageFullPath = (fullPath: boolean = true) => {
const path = getCurrentPagePath();
const params = objectToQueryString(getCurrentPageParams());
return fullPath && params ? `${path}?${params}` : path;
};
/**
* 跳转至上一页,如果页面栈中有多个页面,正常返回,如果页面栈只有一个页面(浏览器刷新后),使用浏览器历史记录返回 如果没有历史记录,跳转到首页
*/
const jumpNavigateBack = () => {
// 获取当前页面栈
const pages = getCurrentPages();
// 如果页面栈中有多个页面,正常返回
if (pages.length > 1) {
uni.navigateBack({
delta: 1,
});
} else {
// 如果页面栈只有一个页面(浏览器刷新后),使用浏览器历史记录返回 如果没有历史记录,跳转到首页
uni.switchTab({
url: "/pages/index/index",
});
}
};
// 判断 url 地址是否是 http 或者 https 开头
const isHttpUrl = (url: string) => {
return url.startsWith("http://") || url.startsWith("https://");
};
export {
addBaseTarget,
arraysContainSameItems,
encodeHTML,
findClassElement,
findParentElement,
formatTimeAgo,
generateUniqueId,
generateUUID,
getNumbersOnly,
getURLParams,
isEmptyObject,
isNumber,
isValidEmail,
isValidJSON,
isValidPhone,
isWeakNumber,
noop,
parseJSON,
validatePassword,
validateTableName,
fillPathParams,
checkPathParams,
objectToQueryString,
removeQueryCompat,
redirectTo,
getCurrentPagePath,
getCurrentPageParams,
getCurrentPageFullPath,
jumpNavigateBack,
isHttpUrl,
};

View File

@@ -0,0 +1,124 @@
import { apiPublishedAgentInfo } from "@/servers/agentDev";
import { HideChatAreaEnum, ExpandPageAreaEnum } from "@/types/enums/agent";
import { API_BASE_URL } from "@/constants/config";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { apiUserTicketCreate } from "@/servers/agentDev";
import { getCurrentPageFullPath } from "@/utils/common";
import { t } from "@/utils/i18n";
/**
* 获取ticket
*/
export const getTicket = async (): Promise<string> => {
const ticketResult = await apiUserTicketCreate();
let _ticket: string = "";
if (ticketResult.code === SUCCESS_CODE && ticketResult.data) {
_ticket = ticketResult.data;
}
return _ticket;
};
/**
* 判断是否是根页面或id页面
* @param str
* @returns
*/
export const isRootOrId = (str: string) => {
return str === "/" || /^\/\?id=\d+$/.test(str);
};
/**
* 获取当前页面地址不包含url参数 H5 || WEB
*/
export const getCurrentPagePath = () => {
// #ifdef H5 || WEB
const currentUrl = getCurrentPageFullPath()?.split("#")?.[1] || "";
return currentUrl === "/" ? "/pages/index/index" : currentUrl;
// #endif
// #ifdef MP-WEIXIN
return "/" + getCurrentPageFullPath();
// #endif
};
/**
* 将ticket添加到url并跳转至webview页面
* @param url
* @param jump_type
* @returns
*/
export const addTicketAndJumpToWebview = async (
url: string,
jump_type: "inner" | "outer" = "inner",
title: string = "",
): Promise<void> => {
const resolvedTitle = title || t("Mobile.Common.appName");
const andStr = url.includes("?") ? "&" : "?";
const baseWebviewUrl = `${API_BASE_URL}${url}${andStr}title=${resolvedTitle}`;
let webviewUrl = "";
// #ifdef H5 || WEB
// 当前页面地址
const backUrl = getCurrentPageFullPath()?.split("#")?.[1] || "";
// 保存需要返回的地址在storage中
uni.setStorageSync("backUrl", backUrl);
webviewUrl = encodeURIComponent(baseWebviewUrl);
// #endif
// #ifdef MP-WEIXIN
const _ticket = await getTicket();
webviewUrl = encodeURIComponent(
baseWebviewUrl + `&_ticket=${_ticket}&jump_type=${jump_type}`,
);
// #endif
uni.navigateTo({
url: `/subpackages/pages/webview/webview?url=${webviewUrl}`,
});
};
// 跳转至智能体详情页面
export const jumpToAgentDetailPage = async (
agentId: number,
conversationId: number = null,
agentType: "ChatBot" | "PageApp" | "TaskAgent" = "ChatBot",
title: string = "",
) => {
const resolvedTitle = title || t("Mobile.Common.appName");
let url = `/subpackages/pages/agent-detail/agent-detail?id=${agentId}`;
if (conversationId) {
url = url + "&conversationId=" + conversationId;
}
// 问答型智能体、任务型智能体直接跳转至智能体详情页面
if (agentType === "ChatBot" || agentType === "TaskAgent") {
uni.navigateTo({ url });
return;
}
if (agentType === "PageApp") {
try {
uni.showLoading({ title: t("Mobile.Page.loading") });
const { code, data, message } = await apiPublishedAgentInfo(agentId);
if (code === SUCCESS_CODE) {
await addTicketAndJumpToWebview(
data.pageHomeIndex,
"inner",
resolvedTitle,
);
}
} finally {
uni.hideLoading();
}
}
};
// 判断当前页面是否是tab页面
export const isTabPage = (url: string) => {
const tabPageList = [
"/pages/index/index", // 首页
"/pages/agent-union-record/agent-union-record", // 智联录
"/pages/agent-list/agent-list", // 智能体
"/pages/page-app/page-app", // 应用
];
return tabPageList.includes(url);
};

View File

@@ -0,0 +1,15 @@
let vConsole: any = null;
export function initConsole() {
// #ifdef H5
if (process.env.NODE_ENV !== "production") {
// 使用动态导入以避免在正式打包时进行静态依赖扫描
import("vconsole").then((module) => {
const VConsole = module.default;
vConsole = new VConsole();
}).catch(err => {
console.error('vConsole 加载失败', err);
});
}
// #endif
}

View File

@@ -0,0 +1,350 @@
/**
* 统一的自定义操作服务
* 管理所有自定义元素的交互事件:打开预览、远程桌面、刷新文件树、打开页面等
*
* 设计参考 PC端: src/models/conversationInfo.ts
* - openPreviewView: 打开预览视图设置viewMode为'preview',显示文件树,刷新文件列表
* - openDesktopView: 打开远程桌面视图设置viewMode为'desktop',启动容器,启动保活
* - handleRefreshFileList: 刷新文件列表调用apiGetStaticFileList
*
* 详细接入文档请参考: integration_guide.md
*/
import { API_BASE_URL } from '@/constants/config'
/**
* 事件类型枚举对应PC端 EVENT_TYPE
*/
export enum CustomEventType {
/** 刷新文件列表 */
REFRESH_FILE_LIST = 'custom_action_refresh_file_list',
/** 打开预览视图 */
OPEN_PREVIEW = 'custom_action_open_preview',
/** 打开远程桌面 */
OPEN_DESKTOP = 'custom_action_open_desktop',
/** 打开扩展页面 */
OPEN_PAGE = 'custom_action_open_page',
/** 打开外部链接 */
OPEN_LINK = 'custom_action_open_link',
}
/**
* 视图模式枚举对应PC端 viewMode
*/
export enum ViewModeEnum {
/** 预览模式 */
PREVIEW = 'preview',
/** 远程桌面模式 */
DESKTOP = 'desktop',
}
/**
* 操作类型枚举
*/
export enum CustomActionType {
/** 打开预览视图(文件预览模式) */
OPEN_PREVIEW = 'OPEN_PREVIEW',
/** 打开远程桌面视图 */
OPEN_DESKTOP = 'OPEN_DESKTOP',
/** 刷新文件树 */
REFRESH_FILE_LIST = 'REFRESH_FILE_LIST',
/** 打开扩展页面iframe/webview */
OPEN_PAGE = 'OPEN_PAGE',
/** 打开外部链接 */
OPEN_LINK = 'OPEN_LINK',
}
/**
* 操作参数接口
*/
export interface CustomActionParams {
type: CustomActionType
conversationId?: string
uri?: string
params?: Record<string, any>
method?: string
dataType?: string
requestId?: string
}
/**
* 页面预览选项
*/
export interface OpenPageOptions {
uri: string
params?: Record<string, any>
method?: string
dataType?: string
requestId?: string
}
/**
* 打开预览事件数据对应PC端 openPreviewView 逻辑)
*/
export interface OpenPreviewEventData {
conversationId: string
viewMode: ViewModeEnum
}
/**
* 打开远程桌面事件数据对应PC端 openDesktopView 逻辑)
*/
export interface OpenDesktopEventData {
conversationId: string
viewMode: ViewModeEnum
}
/**
* 刷新文件列表事件数据对应PC端 handleRefreshFileList 逻辑)
*/
export interface RefreshFileListEventData {
conversationId: string
}
/**
* 打开页面事件数据
*/
export interface OpenPageEventData {
uri: string
method: string
dataType: string
requestId: string
}
/**
* 统一的自定义操作服务类
*
* 使用示例:
* - CustomActionService.openPreviewView(conversationId) // 打开预览视图
* - CustomActionService.openDesktopView(conversationId) // 打开远程桌面
* - CustomActionService.refreshFileList(conversationId) // 刷新文件树
* - CustomActionService.openPage({ uri: '/page/index', params: { id: 1 } }) // 打开扩展页面
* - CustomActionService.openLink('https://example.com') // 打开外部链接
*
* 事件监听示例:
* uni.$on(CustomEventType.OPEN_PREVIEW, (data: OpenPreviewEventData) => { ... })
* uni.$on(CustomEventType.REFRESH_FILE_LIST, (data: RefreshFileListEventData) => { ... })
*/
export class CustomActionService {
/**
* 打开预览视图
* 对应PC端 openPreviewView 方法
* 切换到文件预览模式并刷新文件列表
* @param conversationId 会话ID
*/
static openPreviewView(conversationId: string): void {
if (!conversationId) {
console.warn('[CustomActionService] openPreviewView: conversationId is required')
return
}
// 移除 chat- 前缀
const idStr = String(conversationId)
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
console.log('[CustomActionService] openPreviewView:', cid)
// 发送打开预览视图事件对应PC端 setViewMode('preview') + setIsFileTreeVisible(true)
const eventData: OpenPreviewEventData = {
conversationId: cid,
viewMode: ViewModeEnum.PREVIEW
}
console.log('[CustomActionService] Action: OPEN_PREVIEW, Params:', { conversationId })
uni.$emit(CustomEventType.OPEN_PREVIEW, eventData)
// 触发文件列表刷新对应PC端 eventBus.emit(EVENT_TYPE.RefreshFileList, cId)
this.refreshFileList(conversationId)
}
/**
* 打开远程桌面视图
* 对应PC端 openDesktopView 方法
* 切换到远程桌面模式并启动VNC连接
* @param conversationId 会话ID
*/
static openDesktopView(conversationId: string): void {
if (!conversationId) {
console.warn('[CustomActionService] openDesktopView: conversationId is required')
return
}
// 移除 chat- 前缀
const idStr = String(conversationId)
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
console.log('[CustomActionService] openDesktopView:', cid)
// 发送打开远程桌面事件对应PC端 setViewMode('desktop') + setIsFileTreeVisible(true) + apiEnsurePod
const eventData: OpenDesktopEventData = {
conversationId: cid,
viewMode: ViewModeEnum.DESKTOP
}
console.log('[CustomActionService] Action: OPEN_DESKTOP, Params:', { conversationId })
uni.$emit(CustomEventType.OPEN_DESKTOP, eventData)
}
/**
* 刷新文件树
* 对应PC端 handleRefreshFileList 方法
* 重新获取会话相关的文件列表
* @param conversationId 会话ID
*/
static refreshFileList(conversationId: string): void {
if (!conversationId) {
console.warn('[CustomActionService] refreshFileList: conversationId is required')
return
}
// 移除 chat- 前缀
const idStr = String(conversationId)
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
console.log('[CustomActionService] refreshFileList:', cid)
// 发送刷新文件列表事件对应PC端 runGetStaticFileList(targetId)
const eventData: RefreshFileListEventData = {
conversationId: cid
}
console.log('[CustomActionService] Emitting REFRESH_FILE_LIST event:', eventData)
// uni.$emit(CustomEventType.REFRESH_FILE_LIST, eventData)
uni.$emit('refreshFileList', cid)
}
/**
* 打开扩展页面
* 在iframe或webview中打开指定页面
* @param options 页面选项
*/
static openPage(options: OpenPageOptions): void {
if (!options.uri) {
console.warn('[CustomActionService] openPage: uri is required')
return
}
console.log('[CustomActionService] openPage:', options)
// 构建完整的URL
let fullUri = options.uri
if (options.params && Object.keys(options.params).length > 0) {
const queryString = Object.entries(options.params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
fullUri = `${fullUri}${fullUri.includes('?') ? '&' : '?'}${queryString}`
}
// 发送打开页面事件
const eventData: OpenPageEventData = {
uri: fullUri,
method: options.method || 'browser_open_page',
dataType: options.dataType || 'html',
requestId: options.requestId || ''
}
console.log('[CustomActionService] Action: OPEN_PAGE, Params:', { uri: fullUri, method: eventData.method })
uni.$emit(CustomEventType.OPEN_PAGE, eventData)
}
/**
* 打开外部链接
* 在新窗口或跳转页面打开链接
* @param uri 链接地址
* @param params 查询参数
*/
static openLink(uri: string, params?: Record<string, any>): void {
if (!uri) {
console.warn('[CustomActionService] openLink: uri is required')
return
}
console.log('[CustomActionService] openLink:', uri, params)
// 构建完整的URL
let fullUri = uri
if (params && Object.keys(params).length > 0) {
const queryString = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
fullUri = `${fullUri}${fullUri.includes('?') ? '&' : '?'}${queryString}`
}
// #ifdef H5 || WEB
window.open(fullUri, '_blank')
// #endif
// #ifdef MP-WEIXIN
// 小程序环境下使用webview打开
uni.navigateTo({
url: `/subpackages/pages/common/webview/webview?url=${encodeURIComponent(fullUri)}`
})
// #endif
// 同时发送事件,允许页面自定义处理
console.log('[CustomActionService] Action: OPEN_LINK, Params:', { uri: fullUri })
uni.$emit(CustomEventType.OPEN_LINK, { uri: fullUri })
}
/**
* 统一的动作分发器
* 根据动作类型调用相应的处理方法
* @param action 动作参数
*/
static dispatch(action: CustomActionParams): void {
if (!action || !action.type) {
console.warn('[CustomActionService] dispatch: action type is required')
return
}
console.log('[CustomActionService] dispatch:', action.type, action)
switch (action.type) {
case CustomActionType.OPEN_PREVIEW:
if (action.conversationId) {
this.openPreviewView(action.conversationId)
}
break
case CustomActionType.OPEN_DESKTOP:
if (action.conversationId) {
this.openDesktopView(action.conversationId)
}
break
case CustomActionType.REFRESH_FILE_LIST:
if (action.conversationId) {
this.refreshFileList(action.conversationId)
}
break
case CustomActionType.OPEN_PAGE:
if (action.uri) {
this.openPage({
uri: action.uri,
params: action.params,
method: action.method,
dataType: action.dataType,
requestId: action.requestId
})
}
break
case CustomActionType.OPEN_LINK:
if (action.uri) {
this.openLink(action.uri, action.params)
}
break
default:
console.warn('[CustomActionService] dispatch: unknown action type:', action.type)
}
}
}
// 导出事件类型,方便外部监听
export { CustomEventType as EVENT_TYPE }
// 导出默认实例方法,方便使用
export const openPreviewView = CustomActionService.openPreviewView.bind(CustomActionService)
export const openDesktopView = CustomActionService.openDesktopView.bind(CustomActionService)
export const refreshFileList = CustomActionService.refreshFileList.bind(CustomActionService)
export const openPage = CustomActionService.openPage.bind(CustomActionService)
export const openLink = CustomActionService.openLink.bind(CustomActionService)
export const dispatchAction = CustomActionService.dispatch.bind(CustomActionService)

View File

@@ -0,0 +1,65 @@
/**
* 事件总线封装
*
* 对外提供与 PC 端一致的 APIon / off / emit / once
* 内部使用 uni-app x 原生事件系统uni.$on / uni.$off / uni.$emit / uni.$once
*
* 参考文档https://doc.dcloud.net.cn/uni-app-x/api/event-bus.html
*/
type EventCallback = (...args: any[]) => void;
/**
* 事件总线类
* 封装 uni-app 原生事件 API提供统一接口
*/
class EventBus {
/**
* 订阅事件
* @param eventName 事件名称
* @param callback 回调函数
*/
on(eventName: string, callback: EventCallback): void {
uni.$on(eventName, callback);
}
/**
* 取消订阅事件
* @param eventName 事件名称
* @param callback 回调函数
*/
off(eventName: string, callback: EventCallback): void {
uni.$off(eventName, callback);
}
/**
* 发布事件
* @param eventName 事件名称
* @param args 参数
*/
emit(eventName: string, ...args: any[]): void {
uni.$emit(eventName, ...args);
}
/**
* 一次性订阅事件(触发一次后自动取消)
* @param eventName 事件名称
* @param callback 回调函数
*/
once(eventName: string, callback: EventCallback): void {
uni.$once(eventName, callback);
}
}
// 创建全局事件总线实例
const eventBus = new EventBus();
export default eventBus;
// 定义常用的事件名称常量(与 PC 端保持一致)
export const EVENT_NAMES = {
// 发送聊天消息事件
SEND_CHAT_MESSAGE: 'send_chat_message',
// 清空聊天输入框事件
CLEAR_CHAT_INPUT: 'clear_chat_input',
} as const;

View File

@@ -0,0 +1,102 @@
/**
* 节流函数工具
* 用于限制函数的执行频率,在指定时间间隔内只执行一次
*/
/**
* 创建一个节流函数
* @param func 需要节流的函数
* @param delay 节流时间间隔(毫秒)
* @returns 节流后的函数
*
* @example
* const handleClick = throttle(() => {
* console.log('点击事件');
* }, 1000);
*
* // 即使快速点击多次1秒内只会执行一次
* button.addEventListener('click', handleClick);
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
delay: number,
): (...args: Parameters<T>) => void {
let lastTime = 0;
return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
// 如果距离上次执行时间超过了延迟时间,则执行函数
if (now - lastTime >= delay) {
lastTime = now;
func.apply(this, args);
}
};
}
/**
* 创建一个防抖函数
* @param func 需要防抖的函数
* @param delay 防抖时间间隔(毫秒)
* @returns 防抖后的函数
*
* @example
* const handleInput = debounce((value: string) => {
* console.log('搜索:', value);
* }, 500);
*
* // 用户停止输入 500ms 后才会执行
* input.addEventListener('input', (e) => handleInput(e.target.value));
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number,
): (...args: Parameters<T>) => void {
let timer: number = 0;
return function (this: any, ...args: Parameters<T>) {
// 清除之前的定时器
if (timer) {
clearTimeout(timer);
}
// 设置新的定时器
timer = setTimeout(() => {
func.apply(this, args);
timer = 0;
}, delay);
};
}
/**
* 创建一个节流函数(立即执行版本)
* 第一次调用时立即执行,之后在时间间隔内的调用会被忽略
* @param func 需要节流的函数
* @param delay 节流时间间隔(毫秒)
* @returns 节流后的函数
*/
export function throttleImmediate<T extends (...args: any[]) => any>(
func: T,
delay: number,
): (...args: Parameters<T>) => void {
let lastTime = 0;
let isFirstCall = true;
return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
// 第一次调用立即执行
if (isFirstCall) {
isFirstCall = false;
lastTime = now;
func.apply(this, args);
return;
}
// 如果距离上次执行时间超过了延迟时间,则执行函数
if (now - lastTime >= delay) {
lastTime = now;
func.apply(this, args);
}
};
}

View File

@@ -0,0 +1,173 @@
/**
* GPT 风格数学公式转换工具
* 支持将 GPT 生成的数学公式标记转换为标准 LaTeX 格式
*
* GPT 通常生成以下格式:
* - 行内公式: (\( ... \))
* - 块级公式: (\[ ... \])
*
* 转换为标准 LaTeX 格式:
* - 行内公式: \( ... \)
* - 块级公式: \[ ... \]
*/
/**
* 转换 GPT 风格的数学公式标记
* @param text 包含 GPT 风格数学公式的文本
* @returns 转换后的文本
*/
export function convertGptStyleMath(text: string): string {
if (!text || text.trim() === '') {
return text
}
let result = text
// 转换 GPT 风格的行内公式: (\( ... \)) → \( ... \)
result = result.replace(/\(\\\(/g, '\\(')
result = result.replace(/\\\)\)/g, '\\)')
// 转换 GPT 风格的块级公式: (\[ ... \]) → \[ ... \]
result = result.replace(/\(\\\[/g, '\\[')
result = result.replace(/\\\]\)/g, '\\]')
return result
}
/**
* 检测文本中是否包含 GPT 风格的数学公式
* @param text 要检测的文本
* @returns 是否包含 GPT 风格数学公式
*/
export function hasGptStyleMath(text: string): boolean {
if (!text || text.trim() === '') {
return false
}
// 检查是否包含 GPT 风格的数学公式标记
return /\(\\\(|\\\)\)|\(\\\[|\\\]\)/.test(text)
}
/**
* 获取文本中所有 GPT 风格数学公式的位置信息
* @param text 要分析的文本
* @returns 数学公式位置信息数组
*/
export interface MathFormulaInfo {
type: 'inline' | 'block'
start: number
end: number
content: string
original: string
}
export function findGptStyleMath(text: string): MathFormulaInfo[] {
if (!text || text.trim() === '') {
return []
}
const results: MathFormulaInfo[] = []
// 查找 GPT 风格的行内公式: (\( ... \))
const inlineRegex = /\(\\\(((?:\\.|[^\)])+?)\\\)\)/g
let inlineMatch
while ((inlineMatch = inlineRegex.exec(text)) !== null) {
results.push({
type: 'inline',
start: inlineMatch.index,
end: inlineMatch.index + inlineMatch[0].length,
content: inlineMatch[1],
original: inlineMatch[0]
})
}
// 查找 GPT 风格的块级公式: (\[ ... \])
const blockRegex = /\(\\\[((?:\\.|[^\]])+?)\\\]\)/g
let blockMatch
while ((blockMatch = blockRegex.exec(text)) !== null) {
results.push({
type: 'block',
start: blockMatch.index,
end: blockMatch.index + blockMatch[0].length,
content: blockMatch[1],
original: blockMatch[0]
})
}
// 按位置排序
return results.sort((a, b) => a.start - b.start)
}
/**
* 将 GPT 风格的数学公式转换为 mp-html 支持的格式
* @param text 原始文本
* @param displayMode 是否为块级显示
* @returns 转换后的 HTML 内容
*/
export function convertToMpHtmlFormat(text: string, displayMode: boolean = false): string {
if (!text || text.trim() === '') {
return ''
}
// 先转换 GPT 风格标记
let convertedText = convertGptStyleMath(text)
// 将 LaTeX 风格的数学公式转换为 KaTeX 能识别的格式
// LaTeX 风格: \(...\) → $...$ (行内)
convertedText = convertedText.replace(/\\\(/g, '$')
convertedText = convertedText.replace(/\\\)/g, '$')
// LaTeX 风格: \[...\] → $$...$$ (块级)
convertedText = convertedText.replace(/\\\[/g, '$$')
convertedText = convertedText.replace(/\\\]/g, '$$')
// 根据显示模式包装
if (displayMode) {
// 块级显示:如果已经是 $$...$$ 格式,直接返回;否则包装
if (convertedText.startsWith('$$') && convertedText.endsWith('$$')) {
return convertedText
} else {
return `$$${convertedText}$$`
}
} else {
// 内联显示:如果已经是 $...$ 格式,直接返回;否则包装
if (convertedText.startsWith('$') && convertedText.endsWith('$')) {
return convertedText
} else {
return `$${convertedText}$`
}
}
}
/**
* 批量转换文本中的数学公式
* @param text 包含多个数学公式的文本
* @returns 转换后的文本
*/
export function batchConvertMathFormulas(text: string): string {
if (!text || text.trim() === '') {
return text
}
// 查找所有数学公式
const mathFormulas = findGptStyleMath(text)
// 从后往前替换,避免位置偏移
for (let i = mathFormulas.length - 1; i >= 0; i--) {
const formula = mathFormulas[i]
const before = text.substring(0, formula.start)
const after = text.substring(formula.end)
// 根据类型转换
let replacement: string
if (formula.type === 'inline') {
replacement = `\\(${formula.content}\\)`
} else {
replacement = `\\[${formula.content}\\]`
}
text = before + replacement + after
}
return text
}

View File

@@ -0,0 +1,266 @@
/**
* 历史消息适配器
* 将PC端历史会话消息中的自定义HTML标签转换为移动端支持的Markdown容器语法
*
* PC端格式: <div><markdown-custom-process executeId="xxx" type="xxx" status="xxx" name="xxx"></markdown-custom-process></div>
* 移动端格式: :::container executeId="xxx" type="xxx" status="xxx" name="xxx"\n:::\n
*/
import type { MessageInfo, ProcessingInfo, ExecuteResultInfo } from '@/types/interfaces/conversationInfo'
import { ProcessingEnum } from '@/types/enums/common'
import { AssistantRoleEnum, MessageTypeEnum, AgentComponentTypeEnum } from '@/types/enums/agent'
/**
* 组件执行列表项接口来自API返回
*/
export interface ComponentExecutedItem {
name: string
result: {
executeId: string
id: number
name: string
type: string // "Plan", "ToolCall", "Skill", "Workflow", "Page", etc.
startTime: number
endTime?: number
success?: boolean
data?: any
input?: any
}
status: string // "FINISHED" | "FAILED" | "EXECUTING"
targetId: number
type: string // "Plan", "ToolCall", "Skill", "Workflow", "Page", etc.
}
/**
* 解析的自定义标签信息
*/
interface ParsedCustomTag {
executeId: string
type: string
status: string
name: string
fullMatch: string // 完整的匹配字符串,用于替换
}
/**
* 解析PC端的markdown-custom-process HTML标签
* @param text 包含HTML标签的文本
* @returns 解析出的标签信息数组
*/
export function parseMarkdownCustomProcess(text: string): ParsedCustomTag[] {
if (!text) return []
const results: ParsedCustomTag[] = []
// 匹配 <div><markdown-custom-process ...></markdown-custom-process></div> 格式
// 以及 <markdown-custom-process ...></markdown-custom-process> 格式无div包裹
const regex = /<div>[\s\n]*<markdown-custom-process([^>]*)><\/markdown-custom-process>[\s\n]*<\/div>|<markdown-custom-process([^>]*)><\/markdown-custom-process>/g
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const attrString = match[1] || match[2] || ''
const fullMatch = match[0]
// 解析属性
const executeId = extractAttribute(attrString, 'executeId')
const type = extractAttribute(attrString, 'type')
const status = extractAttribute(attrString, 'status')
const name = extractAttribute(attrString, 'name')
if (executeId) {
results.push({
executeId,
type: type || '',
status: status || '',
name: name || '',
fullMatch
})
}
}
return results
}
/**
* 从属性字符串中提取指定属性的值
* @param attrString 属性字符串,如 ' executeId="xxx" type="yyy"'
* @param attrName 属性名
* @returns 属性值
*/
function extractAttribute(attrString: string, attrName: string): string {
const regex = new RegExp(`${attrName}="([^"]*)"`)
const match = attrString.match(regex)
return match ? match[1] : ''
}
/**
* 将PC端HTML标签转换为移动端Markdown容器语法
* @param tag 解析的标签信息
* @returns Markdown容器语法字符串
*/
export function convertToMobileContainerSyntax(tag: ParsedCustomTag): string {
const attrs = [
`executeId="${tag.executeId}"`,
tag.type ? `type="${tag.type}"` : '',
tag.status ? `status="${tag.status}"` : '',
tag.name ? `name="${tag.name}"` : ''
].filter(Boolean).join(' ')
return `\n\n:::container ${attrs}\n:::\n\n`
}
/**
* 解析状态字符串为ProcessingEnum
* @param status 状态字符串
* @returns ProcessingEnum值
*/
function parseStatus(status: string): ProcessingEnum {
switch (status) {
case 'FINISHED':
return ProcessingEnum.FINISHED
case 'FAILED':
return ProcessingEnum.FAILED
case 'EXECUTING':
default:
return ProcessingEnum.EXECUTING
}
}
/**
* 解析组件类型字符串为AgentComponentTypeEnum
* @param type 类型字符串
* @returns AgentComponentTypeEnum值
*/
function parseComponentType(type: string): AgentComponentTypeEnum {
switch (type) {
case 'Plan':
return AgentComponentTypeEnum.Plan
case 'ToolCall':
return AgentComponentTypeEnum.ToolCall
case 'Skill':
return AgentComponentTypeEnum.Skill
case 'Workflow':
return AgentComponentTypeEnum.Workflow
case 'Knowledge':
return AgentComponentTypeEnum.Knowledge
case 'Page':
return AgentComponentTypeEnum.Page
case 'Plugin':
return AgentComponentTypeEnum.Plugin
case 'Event':
return AgentComponentTypeEnum.Event
case 'Agent':
return AgentComponentTypeEnum.Agent
case 'Mcp':
case 'MCP':
return AgentComponentTypeEnum.MCP
default:
// 默认返回 ToolCall因为这是最常见的类型
return AgentComponentTypeEnum.ToolCall
}
}
/**
* 将componentExecutedList转换为processingList
* @param componentExecutedList 组件执行列表
* @returns ProcessingInfo数组
*/
export function convertComponentExecutedListToProcessingList(
componentExecutedList: ComponentExecutedItem[] | null | undefined
): ProcessingInfo[] {
if (!componentExecutedList || !Array.isArray(componentExecutedList)) {
return []
}
return componentExecutedList.map((item): ProcessingInfo => {
const result = item.result || {}
const status = parseStatus(item.status)
const componentType = parseComponentType(item.type)
// 构建ExecuteResultInfo
const executeResult: ExecuteResultInfo = {
kind: result?.kind || '',
id: result.id || -1,
icon: '',
name: result.name || item.name || '',
type: componentType,
success: result.success ?? (item.status === 'FINISHED'),
error: '',
data: result.data || null,
startTime: result.startTime || 0,
endTime: result.endTime || 0,
input: result.input || null
}
return {
executeId: result.executeId || '',
name: item.name || result.name || '',
type: componentType,
status: status,
targetId: item.targetId || result.id || -1,
result: executeResult,
cardBindConfig: null as any
} as ProcessingInfo
})
}
/**
* 适配单条历史消息
* 将PC端格式的自定义标签转换为移动端支持的格式
* @param message 原始消息
* @returns 适配后的消息
*/
export function adaptHistoryMessage(message: MessageInfo): MessageInfo {
if (!message) return message
// 只处理ASSISTANT消息非ASSISTANT消息确保text有值后直接返回
if (message.role !== AssistantRoleEnum.ASSISTANT && message.messageType !== MessageTypeEnum.ASSISTANT) {
// 确保 text 字段有值
if (message.text === undefined || message.text === null) {
return { ...message, text: '' }
}
return message
}
let adaptedText = message.text || ''
let processingList = message.processingList || []
// 1. 从componentExecutedList转换processingList
const componentExecutedList = (message as any).componentExecutedList as ComponentExecutedItem[] | null
if (componentExecutedList && componentExecutedList.length > 0) {
const convertedProcessingList = convertComponentExecutedListToProcessingList(componentExecutedList)
// 合并已有的processingList如果有的话
processingList = [...processingList, ...convertedProcessingList]
}
// 2. 解析并转换PC端HTML标签为移动端Markdown容器语法
const parsedTags = parseMarkdownCustomProcess(adaptedText)
for (const tag of parsedTags) {
const containerSyntax = convertToMobileContainerSyntax(tag)
// 替换HTML标签为Markdown容器语法
adaptedText = adaptedText.replace(tag.fullMatch, containerSyntax)
}
// 3. 清理多余的空行超过2个连续换行符的情况
adaptedText = adaptedText.replace(/\n{3,}/g, '\n\n')
return {
...message,
text: adaptedText || '', // 确保 text 始终有值
processingList
}
}
/**
* 批量适配历史消息列表
* @param messageList 原始消息列表
* @returns 适配后的消息列表
*/
export function adaptHistoryMessageList(messageList: MessageInfo[]): MessageInfo[] {
if (!messageList || !Array.isArray(messageList)) {
return []
}
return messageList.map(message => adaptHistoryMessage(message))
}

View File

@@ -0,0 +1,665 @@
/**
* HTML 转 Markdown 转换器(增强版)
* 适用于微信小程序环境
* 支持处理大文本和复杂 HTML 结构
*/
/**
* HTML 节点类型
*/
interface HtmlNode {
type: 'element' | 'text';
tag?: string;
content?: string;
children?: HtmlNode[];
attributes?: Map<string, string>;
}
/**
* 将 HTML 字符串转换为 Markdown 字符串(增强版)
* @param html HTML 字符串
* @param options 转换选项
* @returns Markdown 字符串
*/
export function htmlToMarkdown(html: string, options?: {
preserveNewlines?: boolean;
codeBlockLanguage?: string;
maxNestingDepth?: number;
}): string {
if (!html) return '';
const opts = {
preserveNewlines: options?.preserveNewlines ?? false,
codeBlockLanguage: options?.codeBlockLanguage ?? '',
maxNestingDepth: options?.maxNestingDepth ?? 20
};
// 预处理:移除注释和脚本
let processed = preprocess(html);
// 使用流式处理来处理大文本
let markdown = processHtmlStream(processed, opts);
// 后处理:清理和格式化
markdown = postprocess(markdown);
return markdown;
}
/**
* 预处理 HTML
*/
function preprocess(html: string): string {
let result = html;
// 移除注释
result = result.replace(/<!--[\s\S]*?-->/g, '');
// 移除 script 标签
result = result.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// 移除 style 标签
result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 标准化空白字符(保留必要的空格)
result = result.replace(/\r\n/g, '\n');
result = result.replace(/\r/g, '\n');
return result;
}
/**
* 流式处理 HTML避免一次性处理导致的内存问题
*/
function processHtmlStream(html: string, options: any): string {
let markdown = html;
// 第一轮:处理代码块(优先处理,避免代码内容被转换)
markdown = processCodeBlocks(markdown, options);
// 第二轮:处理表格
markdown = processTables(markdown);
// 第三轮:处理列表(嵌套支持)
markdown = processLists(markdown, 0);
// 第四轮:处理引用块(嵌套支持)
markdown = processBlockquotes(markdown, 0);
// 第五轮:处理标题
markdown = processHeadings(markdown);
// 第六轮:处理图片和链接
markdown = processImages(markdown);
markdown = processLinks(markdown);
// 第七轮:处理文本格式(加粗、斜体、删除线等)
markdown = processTextFormatting(markdown);
// 第八轮:处理段落和换行
markdown = processParagraphs(markdown);
// 第九轮:处理水平线
markdown = markdown.replace(/<hr[^>]*\/?>/gi, '\n---\n');
// 第十轮:移除剩余的 HTML 标签
markdown = removeRemainingTags(markdown);
// 解码 HTML 实体
markdown = decodeHtmlEntities(markdown);
return markdown;
}
/**
* 处理代码块
*/
function processCodeBlocks(html: string, options: any): string {
let result = html;
// 处理 <pre><code class="language-xxx">
result = result.replace(/<pre[^>]*>\s*<code[^>]*class=["']language-(\w+)["'][^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi,
(match, lang, code) => {
const decodedCode = decodeHtmlEntities(code).trim();
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
}
);
// 处理 <pre><code>
result = result.replace(/<pre[^>]*>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi,
(match, code) => {
const decodedCode = decodeHtmlEntities(code).trim();
const lang = options.codeBlockLanguage || '';
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
}
);
// 处理 <pre>
result = result.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi,
(match, code) => {
const decodedCode = decodeHtmlEntities(code).trim();
const lang = options.codeBlockLanguage || '';
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
}
);
// 处理行内代码 <code>(排除已处理的)
result = result.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi,
(match, code) => {
// 检查是否在 ``` 代码块标记附近,避免重复处理
return '`' + decodeHtmlEntities(code) + '`';
}
);
return result;
}
/**
* 处理表格
*/
function processTables(html: string): string {
return html.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (match, content) => {
let result = '\n';
// 提取表头
const theadMatch = content.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i);
let hasHeader = false;
if (theadMatch) {
const headerRows = theadMatch[1].match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
if (headerRows && headerRows.length > 0) {
const firstRow = headerRows[0];
const headerCells = firstRow.match(/<th[^>]*>([\s\S]*?)<\/th>/gi);
if (headerCells) {
const headers = headerCells.map((th: string) => {
return cleanHtmlTags(th.replace(/<\/?th[^>]*>/gi, '')).trim();
});
result += '| ' + headers.join(' | ') + ' |\n';
result += '| ' + headers.map(() => '---').join(' | ') + ' |\n';
hasHeader = true;
}
}
}
// 提取表体
const tbodyMatch = content.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i);
const bodyContent = tbodyMatch ? tbodyMatch[1] : content;
const rows = bodyContent.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
if (rows) {
rows.forEach((row: string, index: number) => {
const cells = row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
if (cells) {
const cellTexts = cells.map((td: string) => {
return cleanHtmlTags(td.replace(/<\/?td[^>]*>/gi, '')).trim();
});
// 如果没有表头,第一行作为表头
if (!hasHeader && index === 0) {
result += '| ' + cellTexts.join(' | ') + ' |\n';
result += '| ' + cellTexts.map(() => '---').join(' | ') + ' |\n';
hasHeader = true;
} else {
result += '| ' + cellTexts.join(' | ') + ' |\n';
}
}
});
}
return result + '\n';
});
}
/**
* 处理列表(支持嵌套)
*/
function processLists(html: string, depth: number): string {
if (depth > 10) return html; // 防止无限递归
const indent = ' '.repeat(depth);
let result = html;
// 处理无序列表
result = result.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
let listResult = '\n';
const items = extractListItems(content);
items.forEach((item: string) => {
// 递归处理嵌套列表
const processedItem = processLists(item, depth + 1);
const cleanedItem = cleanHtmlTags(processedItem).trim();
if (cleanedItem) {
listResult += indent + '- ' + cleanedItem + '\n';
}
});
return listResult;
});
// 处理有序列表
result = result.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
let listResult = '\n';
const items = extractListItems(content);
items.forEach((item: string, index: number) => {
// 递归处理嵌套列表
const processedItem = processLists(item, depth + 1);
const cleanedItem = cleanHtmlTags(processedItem).trim();
if (cleanedItem) {
listResult += indent + `${index + 1}. ` + cleanedItem + '\n';
}
});
return listResult;
});
return result;
}
/**
* 提取列表项
*/
function extractListItems(content: string): string[] {
const items: string[] = [];
const regex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let match;
// 使用 exec 方法逐个匹配,避免一次性处理大量数据
while ((match = regex.exec(content)) !== null) {
items.push(match[1]);
}
return items;
}
/**
* 处理引用块(支持嵌套)
*/
function processBlockquotes(html: string, depth: number): string {
if (depth > 10) return html;
return html.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => {
// 递归处理嵌套引用
const processedContent = processBlockquotes(content, depth + 1);
const lines = processedContent.trim().split('\n');
const quotedLines = lines.map((line: string) => {
const trimmedLine = line.trim();
// 如果已经是引用格式,增加引用层级
if (trimmedLine.startsWith('>')) {
return '> ' + trimmedLine;
}
return '> ' + trimmedLine;
});
return '\n' + quotedLines.join('\n') + '\n';
});
}
/**
* 处理标题
*/
function processHeadings(html: string): string {
let result = html;
result = result.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n');
result = result.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n');
result = result.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n');
result = result.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n');
result = result.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n');
result = result.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n');
return result;
}
/**
* 处理图片
*/
function processImages(html: string): string {
let result = html;
// <img> with alt and src
result = result.replace(/<img[^>]*\salt=["']([^"']*)["'][^>]*\ssrc=["']([^"']*)["'][^>]*\/?>/gi, '![$1]($2)');
result = result.replace(/<img[^>]*\ssrc=["']([^"']*)["'][^>]*\salt=["']([^"']*)["'][^>]*\/?>/gi, '![$2]($1)');
// <img> with only src
result = result.replace(/<img[^>]*\ssrc=["']([^"']*)["'][^>]*\/?>/gi, '![]($1)');
return result;
}
/**
* 处理链接
*/
function processLinks(html: string): string {
return html.replace(/<a[^>]*\shref=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
}
/**
* 处理文本格式
*/
function processTextFormatting(html: string): string {
let result = html;
// 处理加粗和斜体组合 <strong><em> 或 <b><i>
result = result.replace(/<strong[^>]*>\s*<em[^>]*>([\s\S]*?)<\/em>\s*<\/strong>/gi, '***$1***');
result = result.replace(/<em[^>]*>\s*<strong[^>]*>([\s\S]*?)<\/strong>\s*<\/em>/gi, '***$1***');
result = result.replace(/<b[^>]*>\s*<i[^>]*>([\s\S]*?)<\/i>\s*<\/b>/gi, '***$1***');
result = result.replace(/<i[^>]*>\s*<b[^>]*>([\s\S]*?)<\/b>\s*<\/i>/gi, '***$1***');
// 处理加粗 <strong> 和 <b>
result = result.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
result = result.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
// 处理斜体 <em> 和 <i>
result = result.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
result = result.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
// 处理删除线 <del>、<s> 和 <strike>
result = result.replace(/<del[^>]*>([\s\S]*?)<\/del>/gi, '~~$1~~');
result = result.replace(/<s[^>]*>([\s\S]*?)<\/s>/gi, '~~$1~~');
result = result.replace(/<strike[^>]*>([\s\S]*?)<\/strike>/gi, '~~$1~~');
// 处理下划线 <u>Markdown 不直接支持,用 HTML
result = result.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '<u>$1</u>');
// 处理上标 <sup> 和下标 <sub>
result = result.replace(/<sup[^>]*>([\s\S]*?)<\/sup>/gi, '^$1^');
result = result.replace(/<sub[^>]*>([\s\S]*?)<\/sub>/gi, '~$1~');
// 处理标记/高亮 <mark>
result = result.replace(/<mark[^>]*>([\s\S]*?)<\/mark>/gi, '==$1==');
return result;
}
/**
* 处理段落和换行
*/
function processParagraphs(html: string): string {
let result = html;
// 处理 div作为段落分隔符
result = result.replace(/<div[^>]*>([\s\S]*?)<\/div>/gi, '\n$1\n');
// 处理段落
result = result.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n');
// 处理换行
result = result.replace(/<br\s*\/?>/gi, '\n');
return result;
}
/**
* 移除剩余的 HTML 标签
*/
function removeRemainingTags(html: string): string {
// 移除常见的容器标签但保留内容
let result = html;
const containerTags = ['article', 'section', 'nav', 'aside', 'header', 'footer', 'main', 'figure', 'figcaption', 'span', 'time', 'address'];
containerTags.forEach((tag: string) => {
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'gi');
result = result.replace(regex, '$1');
});
// 移除所有剩余标签
result = result.replace(/<[^>]+>/g, '');
return result;
}
/**
* 清理 HTML 标签(用于表格等特殊场景)
*/
function cleanHtmlTags(text: string): string {
let result = text;
// 先处理格式标签
result = processTextFormatting(result);
// 然后移除剩余标签
result = result.replace(/<[^>]+>/g, '');
return result;
}
/**
* 后处理:清理和格式化
*/
function postprocess(markdown: string): string {
let result = markdown;
// 第一步:清理行首尾空白(先处理,避免空白行干扰)
const lines = result.split('\n');
result = lines.map((line: string) => {
// 保留列表和引用的缩进
if (line.match(/^\s*[-*+]\s/) || line.match(/^\s*\d+\.\s/) || line.match(/^\s*>/)) {
return line.trimEnd();
}
return line.trim();
}).join('\n');
// 第二步:清理连续的空行(最多保留一个空行)
result = result.replace(/\n{3,}/g, '\n\n');
// 第三步:特殊处理:在代码块、表格、水平线前后确保有空行(但不超过一个)
// 代码块前后
result = result.replace(/([^\n])\n```/g, '$1\n\n```');
result = result.replace(/```\n([^\n])/g, '```\n\n$1');
// 表格前后(以 | 开头的行)
result = result.replace(/([^\n])\n\|/g, '$1\n\n|');
result = result.replace(/\|\s*\n([^\n|])/g, '|\n\n$1');
// 水平线前后
result = result.replace(/([^\n])\n---\n/g, '$1\n\n---\n');
result = result.replace(/\n---\n([^\n])/g, '\n---\n\n$1');
// 标题前后(以 # 开头的行)
result = result.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2');
result = result.replace(/(#{1,6}\s[^\n]*)\n([^\n#])/g, '$1\n\n$2');
// 引用块前后(以 > 开头的行)
result = result.replace(/([^\n>])\n>/g, '$1\n\n>');
result = result.replace(/>\s*([^\n>])\n([^\n>])/g, '> $1\n\n$2');
// 列表前后
result = result.replace(/([^\n])\n([-*+]|\d+\.)\s/g, '$1\n\n$2 ');
result = result.replace(/([-*+]|\d+\.)\s[^\n]*\n([^\n\s-*+\d])/g, (match, p1, p2) => {
// 确保列表后有空行,但不在列表项之间添加
return match.replace(/\n([^\n\s-*+\d])/, '\n\n$1');
});
// 第四步:再次清理可能产生的多余空行
result = result.replace(/\n{3,}/g, '\n\n');
// 第五步:清理首尾空白
result = result.trim();
return result;
}
/**
* 解码 HTML 实体(增强版)
* @param text 包含 HTML 实体的文本
* @returns 解码后的文本
*/
function decodeHtmlEntities(text: string): string {
// 常见 HTML 实体映射表(扩展版)
const entities: Map<string, string> = new Map([
// 基础实体
['&nbsp;', ' '],
['&lt;', '<'],
['&gt;', '>'],
['&amp;', '&'],
['&quot;', '"'],
['&#39;', "'"],
['&apos;', "'"],
// 货币符号
['&cent;', '¢'],
['&pound;', '£'],
['&yen;', '¥'],
['&euro;', '€'],
['&curren;', '¤'],
// 版权和商标
['&copy;', '©'],
['&reg;', '®'],
['&trade;', '™'],
// 数学符号
['&times;', '×'],
['&divide;', '÷'],
['&plusmn;', '±'],
['&minus;', ''],
['&frac12;', '½'],
['&frac14;', '¼'],
['&frac34;', '¾'],
['&deg;', '°'],
['&permil;', '‰'],
['&infin;', '∞'],
['&radic;', '√'],
['&sum;', '∑'],
['&prod;', '∏'],
['&int;', '∫'],
['&ne;', '≠'],
['&equiv;', '≡'],
['&le;', '≤'],
['&ge;', '≥'],
['&asymp;', '≈'],
// 标点符号
['&mdash;', '—'],
['&ndash;', ''],
['&ldquo;', '"'],
['&rdquo;', '"'],
['&lsquo;', "'"],
['&rsquo;', "'"],
['&sbquo;', ''],
['&bdquo;', '„'],
['&hellip;', '…'],
['&bull;', '•'],
['&middot;', '·'],
['&sect;', '§'],
['&para;', '¶'],
['&dagger;', '†'],
['&Dagger;', '‡'],
// 箭头
['&larr;', '←'],
['&uarr;', '↑'],
['&rarr;', '→'],
['&darr;', '↓'],
['&harr;', '↔'],
['&crarr;', '↵'],
['&lArr;', '⇐'],
['&uArr;', '⇑'],
['&rArr;', '⇒'],
['&dArr;', '⇓'],
['&hArr;', '⇔'],
// 希腊字母(小写)
['&alpha;', 'α'],
['&beta;', 'β'],
['&gamma;', 'γ'],
['&delta;', 'δ'],
['&epsilon;', 'ε'],
['&zeta;', 'ζ'],
['&eta;', 'η'],
['&theta;', 'θ'],
['&iota;', 'ι'],
['&kappa;', 'κ'],
['&lambda;', 'λ'],
['&mu;', 'μ'],
['&nu;', 'ν'],
['&xi;', 'ξ'],
['&omicron;', 'ο'],
['&pi;', 'π'],
['&rho;', 'ρ'],
['&sigma;', 'σ'],
['&tau;', 'τ'],
['&upsilon;', 'υ'],
['&phi;', 'φ'],
['&chi;', 'χ'],
['&psi;', 'ψ'],
['&omega;', 'ω'],
// 希腊字母(大写)
['&Alpha;', 'Α'],
['&Beta;', 'Β'],
['&Gamma;', 'Γ'],
['&Delta;', 'Δ'],
['&Epsilon;', 'Ε'],
['&Zeta;', 'Ζ'],
['&Eta;', 'Η'],
['&Theta;', 'Θ'],
['&Iota;', 'Ι'],
['&Kappa;', 'Κ'],
['&Lambda;', 'Λ'],
['&Mu;', 'Μ'],
['&Nu;', 'Ν'],
['&Xi;', 'Ξ'],
['&Omicron;', 'Ο'],
['&Pi;', 'Π'],
['&Rho;', 'Ρ'],
['&Sigma;', 'Σ'],
['&Tau;', 'Τ'],
['&Upsilon;', 'Υ'],
['&Phi;', 'Φ'],
['&Chi;', 'Χ'],
['&Psi;', 'Ψ'],
['&Omega;', 'Ω'],
// 其他符号
['&hearts;', '♥'],
['&diams;', '♦'],
['&clubs;', '♣'],
['&spades;', '♠'],
]);
let result = text;
// 先处理 &amp; 特殊情况(避免重复解码)
// 临时标记已解码的 &
result = result.replace(/&amp;/g, '\u0001AMP\u0001');
// 替换其他命名实体
entities.forEach((value, key) => {
if (key !== '&amp;') {
result = result.replace(new RegExp(key, 'g'), value);
}
});
// 替换数字实体 &#xxx;(十进制)
result = result.replace(/&#(\d+);/g, (match, dec) => {
const code = parseInt(dec);
if (code >= 0 && code <= 0x10FFFF) {
return String.fromCharCode(code);
}
return match;
});
// 替换数字实体 &#xHHH;(十六进制)
result = result.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
const code = parseInt(hex, 16);
if (code >= 0 && code <= 0x10FFFF) {
return String.fromCharCode(code);
}
return match;
});
// 恢复 &amp; 的解码
result = result.replace(/\u0001AMP\u0001/g, '&');
return result;
}
/**
* 默认导出
*/
export default {
htmlToMarkdown,
decodeHtmlEntities
};

View File

@@ -0,0 +1,51 @@
import { reactive } from "vue";
import { DEFAULT_LANG } from "@/constants/i18n.constants";
import { getLocalFallback, toBundleKey } from "@/constants/i18n.local.constants";
export interface UniLoadMoreContentText {
contentdown: string;
contentrefresh: string;
contentnomore: string;
}
interface ThirdPartyLocaleState {
currentLang: string;
}
/**
* 第三方库语言状态适配层。
* 这里不维护独立词典,只缓存当前项目语言,确保所有第三方入口都跟随主 i18n 状态。
*/
export const thirdPartyLocaleState = reactive<ThirdPartyLocaleState>({
currentLang: DEFAULT_LANG,
});
const normalizeThirdPartyLang = (lang: string): string => {
return toBundleKey(lang);
};
/**
* 同步第三方库当前语言。
* 目前先作为统一收口点,后续新增 dayjs / markdown / UI 库时只需要继续往这里扩展。
*/
export const syncThirdPartyLocales = (lang: string) => {
thirdPartyLocaleState.currentLang = normalizeThirdPartyLang(lang);
};
/**
* 获取 `uni-load-more` 组件所需的文案对象。
* 通过显式传入 `content-text`,避免组件内部继续使用自己的默认语言逻辑。
*/
export const getUniLoadMoreContentText = (
overrides: Partial<UniLoadMoreContentText> = {},
): UniLoadMoreContentText => {
const lang = normalizeThirdPartyLang(thirdPartyLocaleState.currentLang);
return {
contentdown:
overrides.contentdown || getLocalFallback(lang, "Mobile.Common.loadMore"),
contentrefresh:
overrides.contentrefresh || getLocalFallback(lang, "Mobile.Page.loadingMore"),
contentnomore:
overrides.contentnomore || getLocalFallback(lang, "Mobile.Common.noMoreData"),
};
};

View File

@@ -0,0 +1,431 @@
import { computed, reactive } from "vue";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import {
DEFAULT_LANG,
I18N_LANG_LIST_STORAGE_KEY,
I18N_LANG_MAP_STORAGE_KEY,
I18N_LANG_STORAGE_KEY,
} from "@/constants/i18n.constants";
import {
getLocalFallback,
resolveLiteralKey,
toBundleKey,
} from "@/constants/i18n.local.constants";
import { apiI18nLangList, apiI18nQuery, apiI18nSetLang } from "@/servers/i18n";
import type { I18nLangDto, I18nState } from "@/types/interfaces/i18n";
import { syncThirdPartyLocales } from "@/utils/i18n-third-party";
const parseJSON = <T = any,>(value: string, fallback: T): T => {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch (error) {
return fallback;
}
};
const isH5Platform = (): boolean => {
// #ifdef H5 || WEB
return true;
// #endif
return false;
};
const isMpWeixinPlatform = (): boolean => {
// #ifdef MP-WEIXIN
return true;
// #endif
return false;
};
const getBrowserLang = (): string => {
// #ifdef H5 || WEB
const navLang = navigator?.language || DEFAULT_LANG;
return navLang;
// #endif
// #ifdef MP-WEIXIN
try {
const sysLang = uni.getSystemInfoSync().language;
if (sysLang) return sysLang;
} catch (e) {}
// #endif
return DEFAULT_LANG;
};
export const normalizeLang = (lang: string): string => {
const source = (lang || "").trim();
if (!source) return DEFAULT_LANG;
const bundleKey = toBundleKey(source);
// 将 bundle key 转换为 PascalCase 显示格式
if (bundleKey === "zh-cn") return "zh-CN";
if (bundleKey === "zh-tw") return "zh-TW";
if (bundleKey === "zh-hk") return "zh-HK";
if (bundleKey === "en-us") return "en-US";
// 未知语言保持原始归一化形式
return source.replaceAll("_", "-");
};
const getStoredLang = (): string => {
const cachedLang = uni.getStorageSync(I18N_LANG_STORAGE_KEY);
if (cachedLang) return normalizeLang(cachedLang);
return "";
};
const setStoredLang = (lang: string) => {
uni.setStorageSync(I18N_LANG_STORAGE_KEY, normalizeLang(lang));
};
const setStoredLangMap = (langMap: Record<string, string>) => {
uni.setStorageSync(I18N_LANG_MAP_STORAGE_KEY, JSON.stringify(langMap || {}));
};
const setStoredLangList = (langList: I18nLangDto[]) => {
uni.setStorageSync(
I18N_LANG_LIST_STORAGE_KEY,
JSON.stringify(langList || []),
);
};
const getCachedLangMap = (): Record<string, string> => {
const text = uni.getStorageSync(I18N_LANG_MAP_STORAGE_KEY) || "";
return parseJSON<Record<string, string>>(text, {});
};
const getCachedLangList = (): I18nLangDto[] => {
const text = uni.getStorageSync(I18N_LANG_LIST_STORAGE_KEY) || "";
return parseJSON<I18nLangDto[]>(text, []);
};
const sortLangList = (list: I18nLangDto[]): I18nLangDto[] => {
const filtered = (list || []).filter((item) => item?.status === 1);
return filtered.sort((a, b) => {
if (a.sort !== b.sort) return a.sort - b.sort;
return a.id - b.id;
});
};
const getDefaultLangFromList = (list: I18nLangDto[]): string => {
const defaultItem = list.find((item) => item?.isDefault === 1);
return normalizeLang(defaultItem?.lang || DEFAULT_LANG);
};
export const i18nState = reactive<I18nState>({
currentLang: DEFAULT_LANG,
langMap: {},
langList: [],
loaded: false,
loading: false,
});
let loadI18nTask: Promise<void> | null = null;
let loadLangListTask: Promise<void> | null = null;
const TAB_BAR_I18N_ITEMS: Array<{ index: number; key: string }> = [
{ index: 0, key: "Mobile.Nav.home" },
{ index: 1, key: "Mobile.Nav.unionRecord" },
{ index: 2, key: "Mobile.Nav.agent" },
{ index: 3, key: "Mobile.Nav.app" },
];
const fillTemplate = (
source: string,
params: Record<string, string | number> = {},
): string => {
let output = source || "";
Object.keys(params || {}).forEach((key) => {
const val = String(params[key] ?? "");
output = output.replace(new RegExp(`\\{${key}\\}`, "g"), val);
});
return output;
};
const pickLangForInit = (): string => {
const cacheLang = getStoredLang();
const browserLang = normalizeLang(getBrowserLang());
return normalizeLang(cacheLang || browserLang || DEFAULT_LANG);
};
const fetchLangList = async (): Promise<I18nLangDto[]> => {
// 允许在 H5 和微信小程序环境调用接口
if (isH5Platform() || isMpWeixinPlatform()) {
try {
const res = await apiI18nLangList();
if (res?.code === SUCCESS_CODE && Array.isArray(res?.data)) {
const sortedList = sortLangList(res.data);
setStoredLangList(sortedList);
return sortedList;
}
} catch (error) {
console.error("fetchLangList failed:", error);
}
}
return sortLangList(getCachedLangList());
};
const fetchLangMap = async (lang: string = "", active: boolean = false): Promise<Record<string, string>> => {
// 允许在 H5 和微信小程序环境调用接口
if (!isH5Platform() && !isMpWeixinPlatform()) {
return {};
}
// 仅在主动切换 (active=true) 且有有效 lang 时才透传给后端,
// 否则传空字符串,后端将返回默认翻译而不会修改用户配置。
const targetLang = (active && lang) ? normalizeLang(lang) : "";
try {
// lang 为空时由后端按用户已保存语言/命中逻辑返回对应词典
const res = await apiI18nQuery(targetLang);
// 只有成功且有数据才返回数据其余失败、异常、data=null统统返回空对象以触发兜底逻辑
if (res?.code === SUCCESS_CODE && res?.data) {
return res.data;
}
} catch (error) {
console.error("fetchLangMap failed:", error);
}
return {};
};
export const bootstrapI18nCache = () => {
const lang = getStoredLang();
i18nState.currentLang = normalizeLang(lang || pickLangForInit());
i18nState.langMap = getCachedLangMap();
i18nState.langList = sortLangList(getCachedLangList());
i18nState.loaded = Object.keys(i18nState.langMap || {}).length > 0;
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
};
const resolveMessage = (
lang: string,
keyOrText: string,
fallback: string = "",
): string => {
const key = resolveLiteralKey(keyOrText);
// 1. 优先尝试从服务端下发的语言包匹配
const langMap = i18nState.langMap;
if (langMap != null) {
const fromServer = langMap[key];
// 如果 key 存在(即便 value 是空字符串)
if (fromServer !== undefined) {
// 如果 value 为空字符串,则返回 key 本身;否则返回翻译值(保持同 PC 端一致)
return fromServer === "" ? key : fromServer;
}
}
// 2. 映射未命中或原生端通过本地包兜底
const currentLangKey = toBundleKey(i18nState.currentLang || DEFAULT_LANG);
// 如果当前是中文类语言,优先尝试本地中文包兜底(实现原生端默认中文需求)
if (currentLangKey.startsWith("zh")) {
const zhFallbackValue = getLocalFallback(currentLangKey, key);
if (zhFallbackValue) return zhFallbackValue;
}
// 3. 全局英语兜底(仅当非中文且远程失败,或中文本地包仍缺失时)
const enFallbackValue = getLocalFallback("en-us", key);
if (enFallbackValue) return enFallbackValue;
// 3. 最后兜底返回
return fallback || keyOrText;
};
export const t = (
key: string,
params: Record<string, string | number> = {},
fallback: string = "",
): string => {
const lang = normalizeLang(i18nState.currentLang || DEFAULT_LANG);
const value = resolveMessage(lang, key, fallback);
return fillTemplate(value, params);
};
export const translateText = (
text: string,
params: Record<string, string | number> = {},
): string => {
if (!text) return "";
return t(text, params, text);
};
export const applyTabBarI18n = () => {
// #ifndef MP-WEIXIN || H5 || WEB
return;
// #endif
TAB_BAR_I18N_ITEMS.forEach((item) => {
try {
uni.setTabBarItem({
index: item.index,
text: t(item.key),
fail: (err) => {
// Ignore tabBar sync failures on non-tab pages.
},
});
} catch (error) {
// Ignore tabBar sync failures on non-tab pages.
}
});
};
export const loadI18n = async (active: boolean = false) => {
if (loadI18nTask) {
await loadI18nTask;
return;
}
loadI18nTask = (async () => {
i18nState.loading = true;
try {
const initLang = pickLangForInit();
let targetLang = initLang;
// 如果翻译包为空(接口失败或返回 null统一强制回退到本地英语态作为安全兜底
i18nState.currentLang = normalizeLang(targetLang);
const langMap = await fetchLangMap(targetLang, active);
// 如果翻译包为空(接口失败或返回 null且非中文环境统一回退到本地英语态作为安全兜底
if (Object.keys(langMap).length === 0 && !targetLang.toLowerCase().startsWith("zh")) {
i18nState.currentLang = normalizeLang("en-us");
i18nState.langMap = {};
} else {
i18nState.currentLang = normalizeLang(targetLang);
i18nState.langMap = langMap;
}
i18nState.loaded = true;
setStoredLang(i18nState.currentLang);
setStoredLangMap(i18nState.langMap);
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
} finally {
i18nState.loading = false;
}
})();
try {
await loadI18nTask;
} finally {
loadI18nTask = null;
}
};
/**
* 确保语言列表已加载(按需加载)
*/
export const ensureLangList = async () => {
if (loadLangListTask) {
await loadLangListTask;
return;
}
loadLangListTask = (async () => {
try {
const list = await fetchLangList();
i18nState.langList = list;
} catch (error) {
console.error("ensureLangList failed:", error);
}
})();
try {
await loadLangListTask;
} finally {
loadLangListTask = null;
}
};
export const setLanguage = async (lang: string): Promise<boolean> => {
const targetLang = normalizeLang(lang);
if (!targetLang) return false;
const previousLang = i18nState.currentLang;
const previousLangMap = i18nState.langMap;
try {
const queryResult = await apiI18nQuery(targetLang);
// 接口请求失败或业务码错误,执行统一英语兜底
if (!queryResult || queryResult.code !== SUCCESS_CODE || !queryResult.data) {
i18nState.currentLang = normalizeLang("en-us");
i18nState.langMap = {};
} else {
i18nState.currentLang = targetLang;
i18nState.langMap = queryResult.data;
}
setStoredLang(i18nState.currentLang);
setStoredLangMap(i18nState.langMap);
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
return true;
} catch (error) {
console.error("setLanguage failed, perform English fallback:", error);
// 接口异常,执行统一英语兜底
i18nState.currentLang = normalizeLang("en-us");
i18nState.langMap = {};
setStoredLang(i18nState.currentLang);
setStoredLangMap(i18nState.langMap);
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
return true; // 即使异常也视为“处理成功(切换到了兜底态)”以避免业务阻塞
}
};
export const syncLanguageFromUser = async (
lang: string = "",
): Promise<void> => {
const userLang = normalizeLang(lang);
if (!userLang) return;
if (userLang === normalizeLang(i18nState.currentLang)) {
setStoredLang(userLang);
if (Object.keys(i18nState.langMap || {}).length === 0) {
const latestLangMap = await fetchLangMap(userLang, false);
// 如果翻译包为空且本地已为空,强制开启英语兜底状态
if (Object.keys(latestLangMap).length === 0) {
i18nState.currentLang = normalizeLang("en-us");
i18nState.langMap = {};
} else {
i18nState.langMap = latestLangMap;
}
setStoredLangMap(i18nState.langMap);
setStoredLang(i18nState.currentLang);
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
}
return;
}
i18nState.currentLang = userLang;
setStoredLang(userLang);
const latestLangMap = await fetchLangMap(userLang, false);
// 如果同步中翻译包为空(接口异常等),统一回退到英语
if (Object.keys(latestLangMap).length === 0) {
i18nState.currentLang = normalizeLang("en-us");
i18nState.langMap = {};
} else {
i18nState.langMap = latestLangMap;
}
setStoredLangMap(i18nState.langMap);
setStoredLang(i18nState.currentLang);
syncThirdPartyLocales(i18nState.currentLang);
applyTabBarI18n();
};
export const useI18n = () => {
return {
i18nState,
t,
translateText,
currentLang: computed(() => i18nState.currentLang),
langList: computed(() => sortLangList(i18nState.langList || [])),
loadI18n,
ensureLangList,
setLanguage,
syncLanguageFromUser,
};
};

View File

@@ -0,0 +1,198 @@
const defaultDelimiters = [
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false },
];
// 转义括号规则 - 通用数学公式解析器
function escapedBracketRule(delimiters: any) {
return (text: string, startPos: number = 0) => {
const max = text.length;
const start = startPos;
for (const { left, right, display } of delimiters) {
// 检查是否以左标记开始
if (!text.slice(start).startsWith(left)) continue;
// 跳过左标记的长度
let pos = start + left.length;
// 寻找匹配的右标记
while (pos < max) {
if (text.slice(pos).startsWith(right)) {
break;
}
pos++;
}
// 没找到匹配的右标记,跳过,进入下个匹配
if (pos >= max) continue;
// 提取数学公式内容
const content = text.slice(start + left.length, pos);
const endPos = pos + right.length;
return {
formula: content,
display,
start,
end: endPos,
left,
right,
success: true,
};
}
return {
formula: '',
display: false,
start: 0,
end: 0,
left: '',
right: '',
success: false,
};
};
}
/**
* 根据规则将连续的过程标签ToolCall, Skill等合并
* 规则:
* 1. 连续 2 个及以上的过程标签合并
* 2. 中间包含“执行计划”的不合并(作为分隔符)
* 3. 标签间只包含空白字符时不中断合并
* 4. 支持 :::container 语法和 PC 端的 HTML 标签语法
* @param text - 待处理的 Markdown 文本
* @returns 处理后的文本
*/
export function groupMarkdownContainers(text : string) : string {
if (!text) return '';
// 1. 匹配 HTML 格式的过程标签 (markdown-custom-process)
// 2. 匹配 :::container 格式的标签
const blockRegex = /(?:[\r\n\s]*)(?:(?:<(?:div|p)\b[^>]*>)?\s*(<markdown-custom-process\b[^>]*?>(?:<\/markdown-custom-process>|\/>)?)\s*(?:<\/(?:div|p)>)?|(:::container\b[^\n]*?\n:::))/g;
let result = '';
let lastIndex = 0;
let currentGroup : string[] = [];
let match : RegExpExecArray | null;
const flushGroup = () => {
if (currentGroup.length > 0) {
if (currentGroup.length >= 2) {
// 多个时,为了支持层级,必须使用规范化后的 HTML 标签
const groupContent = `\n\n<markdown-custom-process-group>\n${normalizedGroup.join('\n')}\n</markdown-custom-process-group>\n\n`;
result += groupContent;
} else {
// 只有一个工具时,保持原样输出,最稳妥
result += `\n\n${currentGroup[0]}\n\n`;
}
currentGroup = [];
normalizedGroup = [];
}
};
let count = 0;
let normalizedGroup : string[] = [];
while ((match = blockRegex.exec(text)) !== null) {
count++;
const textBefore = text.slice(lastIndex, match.index);
if (textBefore.trim() !== '') {
flushGroup();
result += textBefore;
} else {
if (currentGroup.length === 0) {
result += textBefore;
}
}
let tagMatch = match[1] || match[2] || '';
// 为了分组显示,我们依然需要一份规范化后的 HTML 标签
let normalizedTag = '';
if (tagMatch.startsWith(':::container')) {
const executeId = extractAttribute(tagMatch, 'executeId');
const type = extractAttribute(tagMatch, 'type');
const status = extractAttribute(tagMatch, 'status');
const name = extractAttribute(tagMatch, 'name');
const safeName = name.replace(/"/g, '&quot;');
normalizedTag = `<markdown-custom-process executeid="${executeId}" type="${type || ''}" status="${status || ''}" name="${safeName}"></markdown-custom-process>`;
} else {
normalizedTag = tagMatch;
if (!normalizedTag.includes('</markdown-custom-process>') && !normalizedTag.endsWith('/>')) {
normalizedTag = normalizedTag.replace('>', '></markdown-custom-process>');
}
}
const isPlan = /type=["']Plan["']/.test(normalizedTag);
if (isPlan) {
flushGroup();
// Plan 类型保持原样输出
result += `\n\n${tagMatch}\n\n`;
} else {
currentGroup.push(tagMatch);
normalizedGroup.push(normalizedTag);
}
lastIndex = blockRegex.lastIndex;
}
flushGroup();
result += text.slice(lastIndex);
const finalResult = result.replace(/\n{3,}/g, '\n\n').trim();
return finalResult;
}
/**
* 从属性字符串中提取指定属性的值
*/
function extractAttribute(attrString : string, attrName : string) : string {
const regex = new RegExp(`${attrName}=["']([^"']*)["']`);
const match = attrString.match(regex);
if (match != null) {
const val = match[1];
// 还原 HTML 实体,避免在后续处理中出现双重转义
return val.replace(/&quot;/g, '"');
}
return '';
}
// 新的数学公式替换函数 - 直接替换为 $$ 分隔符
export function replaceMathBracket(text : string) : string {
// return text; //临时关闭
// 创建只包含非美元符号分隔符的选项
const nonDollarDelimiters = defaultDelimiters.filter(
(delimiter) =>
!delimiter.left.includes('$') && !delimiter.right.includes('$'),
);
const rule = escapedBracketRule(nonDollarDelimiters);
let result = '';
let pos = 0;
while (pos < text.length) {
const match = rule(text, pos);
if (match.success) {
// 添加匹配前的文本
result += text.slice(pos, match.start);
// 替换为 $$ 分隔符
const delimiter = match.display ? '$$' : '$';
result += `${delimiter}${match.formula}${delimiter}`;
pos = match.end;
} else {
// 没有匹配,添加当前字符
result += text[pos];
pos++;
}
}
return result;
}

View File

@@ -0,0 +1,79 @@
import { MarkdownElement } from '@/types/interfaces/chat'
// Markdown解析器
export class MarkdownParser {
// 解析markdown文本
parse(text: string): MarkdownElement[] {
const elements: MarkdownElement[] = []
const lines = text.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (line.startsWith('#')) {
// 解析标题
const level = line.match(/^#+/)?.[0].length || 1
const content = line.replace(/^#+\s*/, '')
elements.push({
type: 'heading',
content: content,
level: level
})
} else if (line.startsWith('```')) {
// 解析代码块
const language = line.replace(/^```/, '').trim()
let codeContent = ''
i++
while (i < lines.length && !lines[i].startsWith('```')) {
codeContent += lines[i] + '\n'
i++
}
elements.push({
type: 'code',
content: codeContent.trim(),
language: language
})
} else if (line.startsWith('- ')) {
// 解析列表
const content = line.replace(/^-\s*/, '')
elements.push({
type: 'list',
content: content
})
} else if (line.startsWith('> ')) {
// 解析引用
const content = line.replace(/^>\s*/, '')
elements.push({
type: 'blockquote',
content: content
})
} else if (line === '---') {
// 解析分割线
elements.push({
type: 'hr',
content: ''
})
} else if (line.includes('[') && line.includes('](')) {
// 解析链接
const match = line.match(/\[([^\]]+)\]\(([^)]+)\)/)
if (match && match[1] && match[2]) {
elements.push({
type: 'link',
content: match[1],
href: match[2]
})
}
} else if (line) {
// 普通文本
elements.push({
type: 'text',
content: line
})
}
}
return elements
}
}
export const markdownParser = new MarkdownParser()

View File

@@ -0,0 +1,465 @@
/**
* 模拟后端API服务
* 用于语音转文字功能的模拟接口实现
*/
// API响应接口
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: {
code: string
message: string
}
timestamp: number
}
// 语音转文字请求参数
export interface VoiceToTextRequest {
audio: any // 音频文件
format: string // 音频格式
duration: number // 音频时长(秒)
language?: string // 语言类型
userId?: string // 用户ID
}
// 语音转文字响应数据
export interface VoiceToTextResponse {
text: string // 转换后的文本
confidence: number // 识别置信度 (0-1)
duration: number // 音频时长
segments?: Array<{ // 分段结果
text: string
start: number
end: number
confidence: number
}>
}
/**
* 模拟API服务类
*/
export class MockApiService {
private static instance: MockApiService
private requestDelay: number = 1500 // 模拟网络延迟
private successRate: number = 0.95 // 成功率
// 预设的模拟文本库
private mockTexts = {
short: [
"你好",
"谢谢",
"再见",
"是的",
"不是",
"好的",
"没问题",
"可以"
],
medium: [
"这是一段测试语音",
"语音识别功能正常",
"请检查录音质量",
"系统运行正常",
"欢迎使用语音功能",
"录音测试成功",
"语音转文字完成"
],
long: [
"这是一段较长的语音转文字测试内容,用于验证系统的识别能力",
"人工智能语音识别技术在近年来取得了重大突破,准确率不断提升",
"语音交互将成为未来人机交互的重要方式,应用前景非常广阔",
"通过深度学习和神经网络技术,语音识别系统能够更好地理解人类语言",
"语音转文字功能在智能客服、会议记录、语音助手等场景中发挥重要作用"
]
}
// 错误类型库
private errorTypes = [
{
code: 'NETWORK_ERROR',
message: '网络连接异常,请检查网络设置'
},
{
code: 'AUDIO_QUALITY_LOW',
message: '音频质量较低,识别结果可能不准确'
},
{
code: 'SERVER_BUSY',
message: '服务器繁忙,请稍后重试'
},
{
code: 'FORMAT_NOT_SUPPORTED',
message: '音频格式不支持,请使用标准格式'
},
{
code: 'DURATION_TOO_SHORT',
message: '音频时长过短,无法识别'
},
{
code: 'LANGUAGE_NOT_SUPPORTED',
message: '不支持的语言类型'
}
]
private constructor() {}
/**
* 获取单例实例
*/
static getInstance(): MockApiService {
if (!MockApiService.instance) {
MockApiService.instance = new MockApiService()
}
return MockApiService.instance
}
/**
* 语音转文字接口
*/
async voiceToText(request: VoiceToTextRequest): Promise<ApiResponse<VoiceToTextResponse>> {
// 参数验证
const validationError = this.validateRequest(request)
if (validationError) {
return this.createErrorResponse(validationError.code, validationError.message)
}
// 模拟网络延迟
await this.delay(this.requestDelay + Math.random() * 1000)
// 模拟请求失败
if (Math.random() > this.successRate) {
const randomError = this.errorTypes[Math.floor(Math.random() * this.errorTypes.length)]
return this.createErrorResponse(randomError.code, randomError.message)
}
// 生成模拟结果
const result = this.generateMockResult(request)
return this.createSuccessResponse(result)
}
/**
* 验证请求参数
*/
private validateRequest(request: VoiceToTextRequest): { code: string, message: string } | null {
if (!request.audio) {
return { code: 'MISSING_AUDIO', message: '音频文件不能为空' }
}
if (request.duration < 0.5) {
return { code: 'DURATION_TOO_SHORT', message: '音频时长过短至少需要0.5秒' }
}
if (request.duration > 600) {
return { code: 'DURATION_TOO_LONG', message: '音频时长过长最多支持10分钟' }
}
const supportedFormats = ['mp3', 'wav', 'aac', 'PCM', 'webm']
if (!supportedFormats.includes(request.format?.toLowerCase())) {
return { code: 'FORMAT_NOT_SUPPORTED', message: '不支持的音频格式' }
}
return null
}
/**
* 生成模拟识别结果
*/
private generateMockResult(request: VoiceToTextRequest): VoiceToTextResponse {
const duration = request.duration
let selectedText: string
// 根据音频时长选择合适的文本
if (duration < 2) {
selectedText = this.mockTexts.short[Math.floor(Math.random() * this.mockTexts.short.length)]
} else if (duration < 8) {
selectedText = this.mockTexts.medium[Math.floor(Math.random() * this.mockTexts.medium.length)]
} else {
selectedText = this.mockTexts.long[Math.floor(Math.random() * this.mockTexts.long.length)]
}
// 根据语言类型调整文本
if (request.language === 'en-US') {
selectedText = this.translateToEnglish(selectedText)
}
// 生成置信度 (时长越长,置信度可能越低)
let baseConfidence = 0.95
if (duration > 30) {
baseConfidence = 0.85
} else if (duration > 60) {
baseConfidence = 0.80
}
const confidence = baseConfidence - Math.random() * 0.15
return {
text: selectedText,
confidence: Math.max(0.60, confidence),
duration: duration,
segments: this.generateSegments(selectedText, duration)
}
}
/**
* 简单的中英文翻译(模拟)
*/
private translateToEnglish(chineseText: string): string {
const translations: Record<string, string> = {
'你好': 'Hello',
'谢谢': 'Thank you',
'再见': 'Goodbye',
'是的': 'Yes',
'不是': 'No',
'好的': 'OK',
'没问题': 'No problem',
'可以': 'Sure',
'这是一段测试语音': 'This is a test voice',
'语音识别功能正常': 'Voice recognition is working',
'请检查录音质量': 'Please check recording quality',
'系统运行正常': 'System is running normally',
'欢迎使用语音功能': 'Welcome to use voice function',
'录音测试成功': 'Recording test successful',
'语音转文字完成': 'Speech to text completed'
}
return translations[chineseText] || 'This is a voice recognition test'
}
/**
* 生成分段结果
*/
private generateSegments(text: string, duration: number): Array<{
text: string
start: number
end: number
confidence: number
}> {
const segments = []
const words = text.split('')
const avgTimePerChar = duration / words.length
let currentTime = 0
let currentSegment = ''
const segmentLength = Math.max(1, Math.floor(words.length / Math.max(1, Math.floor(duration / 3))))
for (let i = 0; i < words.length; i++) {
currentSegment += words[i]
if (currentSegment.length >= segmentLength || i === words.length - 1) {
const segmentDuration = currentSegment.length * avgTimePerChar
segments.push({
text: currentSegment,
start: currentTime,
end: currentTime + segmentDuration,
confidence: 0.70 + Math.random() * 0.30
})
currentTime += segmentDuration
currentSegment = ''
}
}
return segments
}
/**
* 创建成功响应
*/
private createSuccessResponse<T>(data: T): ApiResponse<T> {
return {
success: true,
data,
timestamp: Date.now()
}
}
/**
* 创建错误响应
*/
private createErrorResponse(code: string, message: string): ApiResponse {
return {
success: false,
error: {
code,
message
},
timestamp: Date.now()
}
}
/**
* 延迟函数
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 设置请求延迟
*/
setRequestDelay(delay: number): void {
this.requestDelay = Math.max(0, delay)
}
/**
* 设置成功率
*/
setSuccessRate(rate: number): void {
this.successRate = Math.max(0, Math.min(1, rate))
}
/**
* 添加自定义文本
*/
addMockText(category: 'short' | 'medium' | 'long', text: string): void {
this.mockTexts[category].push(text)
}
/**
* 添加自定义错误类型
*/
addErrorType(code: string, message: string): void {
this.errorTypes.push({ code, message })
}
}
/**
* API客户端类
* 用于统一管理API调用
*/
export class ApiClient {
private baseUrl: string = ''
private timeout: number = 30000
private mockService: MockApiService
constructor(baseUrl: string = '', useMock: boolean = true) {
this.baseUrl = baseUrl
this.mockService = MockApiService.getInstance()
// 如果没有提供baseUrl或者明确使用模拟服务则使用模拟服务
if (!baseUrl || useMock) {
console.log('使用模拟API服务')
}
}
/**
* 语音转文字接口调用
*/
async voiceToText(audioFile: any, options: {
format: string
duration: number
language?: string
userId?: string
}): Promise<ApiResponse<VoiceToTextResponse>> {
// 如果没有配置真实API地址使用模拟服务
if (!this.baseUrl) {
return this.mockService.voiceToText({
audio: audioFile,
format: options.format,
duration: options.duration,
language: options.language || 'zh-CN',
userId: options.userId
})
}
// 真实API调用实现
return this.realApiCall(audioFile, options)
}
/**
* 真实API调用
*/
private async realApiCall(audioFile: any, options: any): Promise<ApiResponse<VoiceToTextResponse>> {
try {
const response = await uni.request({
url: `${this.baseUrl}/api/voice-to-text`,
method: 'POST',
data: {
audio: audioFile,
format: options.format,
duration: options.duration,
language: options.language || 'zh-CN',
userId: options.userId
},
timeout: this.timeout,
header: {
'Content-Type': 'application/json'
}
})
if (response.statusCode === 200) {
return response.data as ApiResponse<VoiceToTextResponse>
} else {
throw new Error(`API调用失败状态码: ${response.statusCode}`)
}
} catch (error: any) {
return {
success: false,
error: {
code: 'API_ERROR',
message: error.message || 'API调用失败'
},
timestamp: Date.now()
}
}
}
/**
* 设置超时时间
*/
setTimeout(timeout: number): void {
this.timeout = timeout
}
/**
* 获取模拟服务实例
*/
getMockService(): MockApiService {
return this.mockService
}
}
/**
* 创建API客户端实例
*/
export function createApiClient(baseUrl?: string, useMock: boolean = true): ApiClient {
return new ApiClient(baseUrl, useMock)
}
/**
* 默认API客户端实例
*/
export const defaultApiClient = createApiClient()
/**
* 便捷的语音转文字函数
*/
export async function convertVoiceToText(
audioFile: any,
options: {
format: string
duration: number
language?: string
userId?: string
}
): Promise<string> {
try {
const response = await defaultApiClient.voiceToText(audioFile, options)
if (response.success && response.data) {
return response.data.text
} else {
throw new Error(response.error?.message || '语音转换失败')
}
} catch (error: any) {
console.error('语音转文字失败:', error)
throw error
}
}
// 导出模拟服务实例
export const mockApiService = MockApiService.getInstance()

View File

@@ -0,0 +1,111 @@
/**
* URL 解析工具(兼容 H5 & 微信小程序)
*/
export interface ParseUrlResult {
path: string;
query: Record<string, string>;
}
/**
* 从 url 中解析 path 和指定 query 参数
* @param input url 字符串(完整 url / 相对路径 / query
* @param pickKeys 需要提取的 query key不传则全部返回
*/
export function parseUrlInfo(
input: string,
pickKeys?: string[],
): ParseUrlResult {
if (!input) {
return { path: "", query: {} };
}
// 统一 decode避免多次 decode
let decoded = input;
try {
decoded = decodeURIComponent(input);
} catch {
decoded = input;
}
let path = "";
let queryStr = "";
// 1⃣ 完整 URLhttps://xxx.com/xx?a=1
if (/^https?:\/\//i.test(decoded)) {
const [p, q = ""] = decoded.split("?");
path = p.replace(/^https?:\/\/[^/]+/, "");
queryStr = q;
}
// 2⃣ 相对路径(/xx/yy?a=1
else if (decoded.includes("?")) {
const [p, q] = decoded.split("?");
path = p;
queryStr = q;
}
// 3⃣ 仅 path
else if (decoded.startsWith("/")) {
path = decoded;
}
// 4⃣ 仅 querya=1&b=2
else {
queryStr = decoded;
}
// 解析 query
const query: Record<string, string> = {};
if (queryStr) {
queryStr.split("&").forEach((item) => {
if (!item) return;
const [k, v = ""] = item.split("=");
if (!k) return;
// 二次保护 decode
try {
query[k] = decodeURIComponent(v);
} catch {
query[k] = v;
}
});
}
// 只取指定 key
if (pickKeys?.length) {
const picked: Record<string, string> = {};
pickKeys.forEach((k) => {
if (query[k] !== undefined) {
picked[k] = query[k];
}
});
return { path, query: picked };
}
return { path, query };
}
/**
* 从 URL 或字符串中获取文件名
* @param input URL 或 文件名
* @returns 文件名
*/
export function getFileName(input: string): string {
if (!input) return '';
// 去掉 query 参数
const withoutQuery = input.split('?')[0];
// 取最后一个 /
const lastSlashIndex = withoutQuery.lastIndexOf('/');
const fileName =
lastSlashIndex !== -1
? withoutQuery.slice(lastSlashIndex + 1)
: withoutQuery;
// 解码(防止中文被 encode
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
}

View File

@@ -0,0 +1,262 @@
/**
* 权限处理工具类
* 用于处理H5端的各种权限请求和状态检测
*/
import { t } from "@/utils/i18n";
// 权限状态类型
export type PermissionState = 'granted' | 'denied' | 'prompt' | 'unknown'
// 权限类型
export type PermissionType = 'microphone' | 'camera' | 'geolocation' | 'notifications'
/**
* 权限管理器类
*/
export class PermissionManager {
private static instance: PermissionManager | null = null
private constructor() {}
/**
* 获取单例实例
*/
static getInstance(): PermissionManager {
if (!PermissionManager.instance) {
PermissionManager.instance = new PermissionManager()
}
return PermissionManager.instance
}
/**
* 检查权限状态
* @param permissionType 权限类型
* @returns 权限状态
*/
async checkPermission(permissionType: PermissionType): Promise<PermissionState> {
// #ifdef H5
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: permissionType as PermissionName })
return permission.state
} else {
// 如果不支持 permissions API尝试获取权限来检测状态
return await this.checkPermissionByRequest(permissionType)
}
} catch (error) {
console.error(`检查${permissionType}权限失败:`, error)
return 'unknown'
}
// #endif
// #ifndef H5
return 'granted' // 非H5平台默认有权限
// #endif
}
/**
* 通过请求权限来检测权限状态
* @param permissionType 权限类型
* @returns 权限状态
*/
private async checkPermissionByRequest(permissionType: PermissionType): Promise<PermissionState> {
// #ifdef H5
try {
switch (permissionType) {
case 'microphone': // 麦克风
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach(track => track.stop())
return 'granted' // 允许
case 'camera': // 摄像头
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true })
videoStream.getTracks().forEach(track => track.stop())
return 'granted' // 允许
default: // 其他
return 'unknown' // 未知
}
} catch (error: any) {
if (error.name === 'NotAllowedError') {
return 'denied' // 拒绝
} else {
return 'prompt' // 提示
}
}
// #endif
// #ifndef H5
return 'granted'
// #endif
}
/**
* 请求权限
* @param permissionType 权限类型
* @returns 是否成功获取权限
*/
async requestPermission(permissionType: PermissionType): Promise<boolean> {
// #ifdef H5
try {
switch (permissionType) {
case 'microphone': // 麦克风
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach(track => track.stop())
return true
case 'camera': // 摄像头
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true })
videoStream.getTracks().forEach(track => track.stop())
return true
default: // 其他
return false // 失败
}
} catch (error: any) {
console.error(`请求${permissionType}权限失败:`, error)
return false
}
// #endif
// #ifndef H5
return true
// #endif
}
/**
* 监听权限状态变化
* @param permissionType 权限类型
* @param callback 回调函数
*/
async watchPermission(permissionType: PermissionType, callback: (state: PermissionState) => void): Promise<void> {
// #ifdef H5
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: permissionType as PermissionName })
permission.onchange = () => {
callback(permission.state)
}
}
} catch (error) {
console.error(`监听${permissionType}权限变化失败:`, error)
}
// #endif
}
/**
* 显示权限引导弹窗
* @param permissionType 权限类型
*/
showPermissionGuide(permissionType: PermissionType): void {
const guides = {
microphone: {
title: t("Mobile.Permission.microphoneTitle"),
description: t("Mobile.Permission.microphoneDescription"),
steps: [
t("Mobile.Permission.stepClickLock"),
t("Mobile.Permission.stepAllowMicrophone"),
t("Mobile.Permission.stepRefreshRetryRecord")
]
},
camera: {
title: t("Mobile.Permission.cameraTitle"),
description: t("Mobile.Permission.cameraDescription"),
steps: [
t("Mobile.Permission.stepClickLock"),
t("Mobile.Permission.stepAllowCamera"),
t("Mobile.Permission.stepRefreshRetryPhoto")
]
}
}
const guide = guides[permissionType]
if (guide) {
uni.showModal({
title: guide.title,
content: t("Mobile.Permission.guideContent", {
description: guide.description,
steps: guide.steps.map((step, index) => `${index + 1}. ${step}`).join('\n'),
}),
showCancel: true,
cancelText: t("Mobile.Common.cancel"),
confirmText: t("Mobile.Permission.gotIt"),
success: (res) => {
if (res.confirm) {
console.log('用户确认了权限引导')
}
}
})
}
}
/**
* 检查是否支持权限API
* @returns 是否支持
*/
isPermissionAPISupported(): boolean {
// #ifdef H5
return !!(navigator.permissions && navigator.permissions.query)
// #endif
// #ifndef H5
return false
// #endif
}
}
/**
* 麦克风权限专用工具函数
*/
export class MicrophonePermissionHelper {
private static manager = PermissionManager.getInstance()
/**
* 检查麦克风权限状态
*/
static async check(): Promise<PermissionState> {
return await this.manager.checkPermission('microphone')
}
/**
* 请求麦克风权限
*/
static async request(): Promise<boolean> {
return await this.manager.requestPermission('microphone')
}
/**
* 监听麦克风权限变化
*/
static async watch(callback: (state: PermissionState) => void): Promise<void> {
return await this.manager.watchPermission('microphone', callback)
}
/**
* 显示麦克风权限引导
*/
static showGuide(): void {
this.manager.showPermissionGuide('microphone')
}
/**
* 确保麦克风权限可用
* @param showGuide 是否显示权限引导
* @returns 是否成功获取权限
*/
static async ensure(showGuide: boolean = true): Promise<boolean> {
// Always attempt to request permission first
const success = await this.request()
if (success) {
return true
}
// If request fails, check the current state
const state = await this.check()
// Show guide if denied and showGuide is true
if (showGuide && state === 'denied') {
this.showGuide()
}
return false
}
}
// 导出默认实例
export default PermissionManager.getInstance()

10410
qiming-mobile/utils/pinyin.uts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
import { ChatDataAdapter, SSEChunk } from './chatDataAdapter'
import { MsgItem } from '@/uni_modules/uni-ai-x/types'
/**
* SSE数据处理器
* 专门用于处理Server-Sent Events流式数据
*/
export class SSEDataProcessor {
// 存储累积的SSE数据块
private sseChunks: SSEChunk[] = []
// 当前会话ID
private chatId: string = ''
// 消息映射表,用于累积文本内容
private messageMap = new Map<string, MsgItem>()
constructor(chatId: string) {
this.chatId = chatId
}
/**
* 处理单个SSE数据块
* @param chunk SSE数据块
* @returns 处理后的MsgItem数组
*/
processChunk(chunk: SSEChunk): MsgItem[] {
// 添加到累积列表
this.sseChunks.push(chunk)
if (!ChatDataAdapter.isRenderableSSEChunk(chunk)) {
return this.getAllMessages()
}
// 处理数据块
this.processMessage(chunk)
// 返回当前所有消息
return this.getAllMessages()
}
/**
* 处理消息累积
* @param chunk SSE数据块
*/
private processMessage(chunk: SSEChunk): void {
if (!ChatDataAdapter.isRenderableSSEChunk(chunk)) {
return
}
const messageId = chunk.data.id
const text = chunk.data.text || ''
const isFinished = chunk.data.finished
// console.log(`Processing message ${messageId}: text="${text}", finished=${isFinished}`)
if (!this.messageMap.has(messageId)) {
// 创建新消息
// console.log(`Creating new message for ID: ${messageId}`)
const msgItem = ChatDataAdapter.convertSSEChunkToMsgItem(chunk, this.chatId)
this.messageMap.set(messageId, msgItem)
// console.log(`New message created:`, msgItem)
} else {
// 累积文本内容,使用更智能的去重逻辑
// console.log(`Updating existing message for ID: ${messageId}`)
const existingMsg = this.messageMap.get(messageId)!
// console.log(`Previous body: "${existingMsg.body}"`)
// 智能去重:检查新文本是否是现有内容的子串或重复
if (this.shouldAppendText(existingMsg.body, text)) {
existingMsg.body += text
// console.log(`New body: "${existingMsg.body}"`)
} else {
// console.log(`Text skipped (duplicate or redundant): "${text}"`)
}
// 更新状态
if (isFinished) {
existingMsg.state = 3 // 完成状态
console.log(`Message ${messageId} marked as finished`)
}
}
// console.log(`Current message map size: ${this.messageMap.size}`)
// this.messageMap.forEach((msg, id) => {
// console.log(`Message ${id}: body="${msg.body}", state=${msg.state}`)
// })
}
/**
* 智能判断是否应该追加文本
* @param existingBody 现有文本内容
* @param newText 新文本内容
* @returns 是否应该追加
*/
private shouldAppendText(existingBody: string, newText: string): boolean {
// 如果新文本为空,不追加
if (!newText || newText.trim().length === 0) {
return false
}
// 如果现有内容已经包含新文本,不追加
if (existingBody.includes(newText)) {
return false
}
// 如果新文本是现有内容的子串,不追加
if (newText.length < existingBody.length && existingBody.includes(newText)) {
return false
}
// 检查是否是重复的标点符号或空格
const lastChar = existingBody.charAt(existingBody.length - 1)
const firstChar = newText.charAt(0)
// 避免重复的标点符号
if (lastChar === firstChar && '.,!?;:'.includes(lastChar)) {
return false
}
// 避免重复的空格
if (lastChar === ' ' && firstChar === ' ') {
return false
}
return true
}
/**
* 获取所有消息
* @returns MsgItem数组
*/
getAllMessages(): MsgItem[] {
const result: MsgItem[] = []
// console.log(`Getting all messages from map (size: ${this.messageMap.size})`)
this.messageMap.forEach((value, key) => {
// console.log(`Adding message ${key}:`, value)
result.push(value)
})
// console.log(`Returning ${result.length} messages:`, result)
return result
}
/**
* 获取指定ID的消息
* @param messageId 消息ID
* @returns MsgItem或null
*/
getMessage(messageId: string): MsgItem | null {
return this.messageMap.get(messageId) || null
}
/**
* 清除所有数据
*/
clear(): void {
this.sseChunks = []
this.messageMap.clear()
}
/**
* 获取累积的SSE数据块
* @returns SSE数据块数组
*/
getSSEChunks(): SSEChunk[] {
return [...this.sseChunks]
}
/**
* 检查是否有未完成的消息
* @returns 是否有未完成的消息
*/
hasIncompleteMessages(): boolean {
let hasIncomplete = false
this.messageMap.forEach((msg) => {
if (msg.state === 2) { // 未完成状态
hasIncomplete = true
}
})
return hasIncomplete
}
/**
* 获取未完成的消息数量
* @returns 未完成消息数量
*/
getIncompleteMessageCount(): number {
let count = 0
this.messageMap.forEach((msg) => {
if (msg.state === 2) { // 未完成状态
count++
}
})
return count
}
}
/**
* 解析SSE数据字符串
* @param sseString SSE原始字符串
* @returns SSEChunk数组
*/
export function parseSSEString(sseString: string): SSEChunk[] {
const chunks: SSEChunk[] = []
// 按行分割
const lines = sseString.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
// 跳过空行和注释行
if (!trimmedLine || trimmedLine.startsWith(':')) {
continue
}
// 检查是否是data行
if (trimmedLine.startsWith('data:')) {
const dataContent = trimmedLine.substring(5).trim()
// 跳过[DONE]标记
if (dataContent === '[DONE]') {
continue
}
try {
// 解析JSON数据
const chunk: SSEChunk = JSON.parse(dataContent)
chunks.push(chunk)
} catch (error) {
console.error('解析SSE数据失败:', error, '原始数据:', dataContent)
}
}
}
return chunks
}
/**
* 处理完整的SSE响应
* @param sseString 完整的SSE响应字符串
* @param chatId 聊天ID
* @returns 处理后的MsgItem数组
*/
export function processCompleteSSEResponse(sseString: string, chatId: string): MsgItem[] {
const chunks = parseSSEString(sseString)
const processor = new SSEDataProcessor(chatId)
chunks.forEach(chunk => {
processor.processChunk(chunk)
})
return processor.getAllMessages()
}
/**
* 创建SSE数据处理器实例
* @param chatId 聊天ID
* @returns SSEDataProcessor实例
*/
export function createSSEProcessor(chatId: string): SSEDataProcessor {
return new SSEDataProcessor(chatId)
}
// 重新导出SSEChunk类型
export { SSEChunk } from './chatDataAdapter'

View File

@@ -0,0 +1,645 @@
import { StreamRequestConfig, StreamChunk } from "@/types/interfaces/chat";
import { createSSEConnection } from "@/servers/useRequest.uts";
import { utf8ArrayToString } from "@/utils/utf8.uts";
import { t } from "@/utils/i18n";
// 全局错误处理 - 捕获未处理的 AbortError
// #ifdef H5
if (typeof window !== "undefined") {
window.addEventListener("unhandledrejection", (event) => {
if (event.reason && event.reason.name === "AbortError") {
console.log("捕获到未处理的 AbortError已静默处理");
event.preventDefault(); // 阻止默认的错误处理
}
});
}
// #endif
// 流式请求工具类
export class StreamRequest {
private requestTask: RequestTask | null = null;
private isAborted: boolean = false;
private abortController: AbortController | null = null;
private buffer: string = ""; // 用于缓存不完整的 SSE 消息(以 \n\n 为完整标记)
private pendingBytes: Uint8Array = new Uint8Array(0); // 用于缓存不完整的 UTF-8 字节序列
// 微信小程序数据流超时监控
private lastChunkReceivedTime: number = 0; // 最后一次接收数据的时间戳
private timeoutCheckTimer: any = null; // 超时检查定时器(使用 any 类型以兼容不同平台)
private readonly TIMEOUT_DURATION: number = 60000; // 超时时长 60 秒
private timeoutCallback: (() => void) | null = null; // 超时回调
// 发起流式请求
async request(config: StreamRequestConfig): Promise<void> {
this.isAborted = false;
this.buffer = ""; // 重置缓冲区
this.pendingBytes = new Uint8Array(0); // 重置字节缓冲区
// #ifdef H5
// 只在 H5 环境中创建 AbortController
this.abortController = new AbortController();
// #endif
// 清除可能存在的旧定时器(重要:避免定时器泄漏)
console.log("[超时监控] 新请求开始,清除旧定时器");
this.clearTimeoutMonitor();
// 保存超时回调
this.timeoutCallback = config.onTimeout || null;
try {
// #ifdef H5 || WEB
await this.requestH5(config);
// #endif
// #ifdef MP-WEIXIN
await this.requestWeapp(config);
// #endif
// #ifdef APP-PLUS
await this.requestApp(config);
// #endif
} catch (error) {
// 如果是用户主动中止,不触发错误回调
// #ifdef H5
if (error.name === "AbortError" || this.isAborted) {
console.log("请求已被中止");
return;
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
if (this.isAborted) {
console.log("请求已被中止");
return;
}
// #endif
config.onError?.(error);
}
}
// H5环境下的流式请求
private async requestH5(config: StreamRequestConfig): Promise<void> {
try {
// 关键修复:在创建 requestTask 后立即启动超时监控
// 因为 onChunkReceived 可能比 success 回调更早触发
console.log("[请求] requestTask 已创建,立即启动超时监控");
this.startTimeoutMonitor();
console.log("[请求] 超时监控启动完成");
await createSSEConnection({
url: config.url,
method: config.method,
headers: config.headers as any,
body: config.body,
abortController: this.abortController,
onMessage: (data: any) => {
if (this.isAborted) return;
// 更新最后接收数据的时间
this.updateLastChunkTime();
try {
// 检查是否完成
if (data && data.completed) {
console.log("[SSE] 收到完成标记 completed");
// 清除超时监控
console.log(
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] completed 标记处理完成,定时器已清除");
config.onComplete?.();
return;
}
// 处理数据块
const chunk: StreamChunk = {
type: "content",
data: data ? JSON.stringify(data) : "",
};
config.onChunk?.(chunk);
} catch (e) {
console.error("Parse chunk error:", e);
}
},
onError: (error: Error) => {
// 清除超时监控
this.clearTimeoutMonitor();
// 如果是中止错误,不触发错误回调
if (error.name === "AbortError" || this.isAborted) {
console.log("SSE连接已被中止");
return;
}
config.onError?.(error);
},
onOpen: (response: any) => {
console.log("SSE连接已建立:", response);
},
onClose: () => {
console.log("SSE连接已关闭");
// 调用超时回调-关闭loading状态以及恢复停止按钮
config.onTimeout?.();
},
});
} catch (error) {
// 捕获所有可能的错误,包括 AbortError
if (
error.message === "Connection aborted by user" ||
error.name === "AbortError" ||
this.isAborted
) {
console.log("SSE连接已被中止");
return;
}
// 只有真正的错误才触发错误回调
config.onError?.(error);
throw error; // 重新抛出非中止错误
}
}
// 处理完整的 SSE 消息
private processSSEMessage(
content: string,
config: StreamRequestConfig,
resolve: (value: void | PromiseLike<void>) => void,
): void {
// 检查是否完成
if (content === "[DONE]") {
console.log("[SSE] 收到完成标记 [DONE]");
// 清除超时监控
console.log(
"[SSE] [DONE] 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] [DONE] 标记处理完成,定时器已清除");
config.onComplete?.();
resolve();
return;
}
// 检查是否看起来像 JSON (以 { 或 [ 开头)
const looksLikeJSON = content.startsWith("{") || content.startsWith("[");
if (looksLikeJSON) {
// 尝试解析 JSON 数据
try {
const data = JSON.parse(content);
if (data && data.completed) {
console.log("[SSE] 收到完成标记 completed");
// 清除超时监控
console.log(
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] completed 标记处理完成,定时器已清除");
config.onComplete?.();
resolve();
return;
}
// 处理数据块
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
} catch (e) {
console.log("[SSE] JSON 解析失败,内容:", content);
}
} else {
// 不是 JSON 格式,直接作为内容处理
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
}
}
// 启动超时监控
private startTimeoutMonitor(): void {
console.log(
"[超时监控] 准备启动超时监控,当前 timer ID:",
this.timeoutCheckTimer,
);
// 记录初始时间
this.lastChunkReceivedTime = Date.now();
// 先清除可能存在的旧定时器
if (
this.timeoutCheckTimer !== null &&
this.timeoutCheckTimer !== undefined
) {
console.log(
"[超时监控] 发现旧定时器先清除ID:",
this.timeoutCheckTimer,
);
clearInterval(this.timeoutCheckTimer);
this.timeoutCheckTimer = null;
}
// 每 10 秒检查一次是否超时
const timerId = setInterval(() => {
const now = Date.now();
const timeSinceLastChunk = now - this.lastChunkReceivedTime;
console.log(
`[超时监控] 检查中... 距离上次接收数据: ${timeSinceLastChunk}ms定时器 ID: ${this.timeoutCheckTimer}`,
);
if (timeSinceLastChunk >= this.TIMEOUT_DURATION) {
console.log(
`[超时监控] 数据流超时: ${timeSinceLastChunk}ms 未收到数据`,
);
this.handleTimeout();
}
}, 10000);
this.timeoutCheckTimer = timerId;
console.log("[超时监控] 定时器已创建,新 ID:", this.timeoutCheckTimer);
}
// 清除超时监控
private clearTimeoutMonitor(): void {
console.log("[超时监控] 尝试清除,当前 timer ID:", this.timeoutCheckTimer);
if (
this.timeoutCheckTimer !== null &&
this.timeoutCheckTimer !== undefined
) {
console.log("[超时监控] 清除超时监控timer ID:", this.timeoutCheckTimer);
clearInterval(this.timeoutCheckTimer);
this.timeoutCheckTimer = null;
} else {
console.log("[超时监控] 定时器 ID 为空,无需清除");
}
}
// 更新最后接收数据的时间
private updateLastChunkTime(): void {
this.lastChunkReceivedTime = Date.now();
}
// 处理超时
private handleTimeout(): void {
console.log("[超时监控] 触发超时处理");
// 清除定时器
this.clearTimeoutMonitor();
// 标记为中止状态
this.isAborted = true;
// 中止请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// 显示轻提示
uni.showToast({
title: t("Mobile.Stream.timeoutDisconnected"),
icon: "none",
duration: 2000,
});
// 调用超时回调
if (this.timeoutCallback) {
this.timeoutCallback();
}
}
// 处理 UTF-8 字节流,处理分片造成的字符截断问题
private decodeUTF8(chunk: ArrayBuffer): string {
const newBytes = new Uint8Array(chunk);
let combined = newBytes;
if (this.pendingBytes.length > 0) {
combined = new Uint8Array(this.pendingBytes.length + newBytes.length);
combined.set(this.pendingBytes);
combined.set(newBytes, this.pendingBytes.length);
this.pendingBytes = new Uint8Array(0);
}
if (combined.length === 0) return "";
// 寻找最后一个安全的字节边界
let safeEnd = combined.length;
let i = combined.length - 1;
let backtrack = 0;
// 向前回溯,检查末尾是否被截断
// UTF-8 最大字符长度为 4 字节
while (backtrack < 4 && i >= 0) {
const b = combined[i];
// ASCII (0xxxxxxx) - 字符结束,且是单字节,安全
if ((b & 0x80) === 0) {
// 如果我们是回溯过程中遇到的 ASCII说明之前回溯过的字节在 ASCII 之后)是孤立的或不完整的?
// 比如 [ASCII, Cont, Cont],我们在 Cont 回溯。
// 遇到 ASCII说明后面的 Cont 没有 Start byte是无效的。
// 但如果是 [ASCII]backtrack=0safeEnd=length。
// 这里的逻辑:遇到 ASCII说明前面的序列都结束了。
// 那么从 i+1 到 length 的字节就是我们回溯过的“剩余字节”。
safeEnd = combined.length - backtrack;
break;
}
// Start byte (11xxxxxx)
if ((b & 0xc0) === 0xc0) {
let need = 2;
if ((b & 0xe0) === 0xe0) need = 3;
else if ((b & 0xf0) === 0xf0) need = 4;
const have = combined.length - i;
if (have < need) {
// 不完整,需要保留从 i 开始的所有字节
safeEnd = i;
} else {
// 完整
safeEnd = combined.length;
}
break;
}
i--;
backtrack++;
}
// 如果 safeEnd 小于总长度,保存剩余字节
if (safeEnd < combined.length) {
this.pendingBytes = combined.slice(safeEnd);
return utf8ArrayToString(combined.slice(0, safeEnd));
}
return utf8ArrayToString(combined);
}
// 微信小程序环境下的流式请求
private async requestWeapp(config: StreamRequestConfig): Promise<void> {
return new Promise((resolve, reject) => {
this.requestTask = uni.request({
url: config.url,
method: config.method,
header: config.headers as any,
data: config.body,
enableChunked: true, // 启用分块传输
responseType: "arraybuffer", // 响应类型
timeout: 1000 * 60 * 60 * 12, // 设置超时时间为 12小时
success: (res) => {
console.log("[请求] success 回调触发statusCode:", res.statusCode);
if (res.statusCode === 200) {
// 流式数据通过 onChunkReceived 接收success 仅表示请求建立成功
// 实际的流式数据处理在 onChunkReceived 回调中
config.onOpen?.(res);
resolve(); // 请求成功建立连接后即可 resolve
} else {
console.log("[请求] 请求失败statusCode:", res.statusCode);
// 清除超时监控
this.clearTimeoutMonitor();
reject(
new Error(
t("Mobile.Stream.httpErrorWithCode", {
code: res.statusCode,
}),
),
);
}
},
fail: (err) => {
// 清除超时监控
this.clearTimeoutMonitor();
if (!this.isAborted) {
// 忽略超时错误,因为对于长连接流式请求,超时是预期内的行为
// 只要 connection 还在接收数据,就不应该视为真正的错误
if (
err.errMsg &&
(err.errMsg.includes("time out") ||
err.errMsg.includes("timeout"))
) {
console.log("请求超时警告(流式请求正常现象):", err);
// 不 reject也不触发 onError保持连接继续接收数据
return;
}
config.onError?.(err);
reject(err);
}
},
});
// 关键修复:在创建 requestTask 后立即启动超时监控
// 因为 onChunkReceived 可能比 success 回调更早触发
console.log("[请求] requestTask 已创建,立即启动超时监控");
this.startTimeoutMonitor();
console.log("[请求] 超时监控启动完成");
// 监听分块数据接收
this.requestTask?.onChunkReceived?.((res) => {
// console.log('[数据接收] onChunkReceived 触发')
if (this.isAborted) return;
// 更新最后接收数据的时间
this.updateLastChunkTime();
try {
// 使用 decodeUTF8 处理分块数据,解决字符截断问题
const str = this.decodeUTF8(res.data as ArrayBuffer);
// console.log('[SSE] 接收到分块数据, 长度:', str.length)
// 将新数据添加到缓冲区
this.buffer += str;
// SSE 协议:完整的消息以 \n\n 结尾
// 按 \n\n 分割出完整的消息块
const messages = this.buffer.split("\n\n");
// 保存最后一个可能不完整的消息块
this.buffer = messages.pop() || "";
// 处理所有完整的消息块
for (const message of messages) {
if (!message.trim()) continue;
// 按行分割消息块,处理多行的情况
const lines = message.split("\n");
let dataContent = "";
// 提取所有 data: 开头的行
for (const line of lines) {
if (line.startsWith("data:")) {
// 移除 "data:" 前缀,保留后面的内容
const content = line.slice(5).trim();
// 拼接多个 data: 行的内容(用换行符连接)
if (dataContent.length > 0) {
dataContent += "\n" + content;
} else {
dataContent = content;
}
}
}
// 如果提取到了数据内容,进行处理
if (dataContent.length > 0) {
// 统一由 processSSEMessage 处理所有消息,包括 [DONE] 标记
this.processSSEMessage(dataContent, config, resolve);
}
}
} catch (error) {
console.error("处理分块数据失败:", error);
if (!this.isAborted) {
config.onError?.(error);
}
}
});
});
}
// App环境下的流式请求
private async requestApp(config: StreamRequestConfig): Promise<void> {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
this.requestTask = uni.request({
url: config.url,
method: config.method,
header: config.headers as any,
data: config.body,
success: (res) => {
if (res.statusCode === 200) {
// 处理流式响应
this.handleStreamResponse(res.data as string, config);
resolve();
} else {
reject(
new Error(
t("Mobile.Stream.httpErrorWithCode", {
code: res.statusCode,
}),
),
);
}
},
fail: (err) => {
if (!this.isAborted) {
reject(err);
}
},
});
});
// #endif
}
// 处理流式响应数据
private handleStreamResponse(
data: string,
config: StreamRequestConfig,
): void {
console.log("[SSE-APP] 处理流式响应数据, 长度:", data.length);
const lines = data.split("\n");
for (const line of lines) {
if (line.trim() === "") continue;
if (line.startsWith("data:")) {
const content = line.slice(5).trim();
// 跳过空内容
if (!content) {
console.log("[SSE-APP] 跳过空内容行");
continue;
}
console.log("[SSE-APP] 处理数据行:", content);
// 检查是否完成
if (content === "[DONE]") {
console.log("[SSE-APP] 收到完成标记 [DONE]");
config.onComplete?.();
return;
}
// 检查是否看起来像 JSON (以 { 或 [ 开头)
const looksLikeJSON =
content.startsWith("{") || content.startsWith("[");
if (looksLikeJSON) {
// 尝试解析 JSON 数据
try {
const parseData = JSON.parse(content);
if (parseData && parseData.completed) {
console.log("[SSE-APP] 收到完成标记 completed");
config.onComplete?.();
return;
}
} catch (e) {
// JSON 解析失败,继续处理为普通数据
console.log("[SSE-APP] JSON 解析失败,作为普通内容处理");
}
} else {
console.log("[SSE-APP] 非 JSON 格式,直接处理");
}
// 处理数据块
try {
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
} catch (e) {
console.error("[SSE-APP] 处理数据块失败:", e);
}
}
}
}
// 中止请求
abort(): void {
this.isAborted = true;
this.buffer = ""; // 清空缓冲区
// 清除超时监控(所有平台都需要)
this.clearTimeoutMonitor();
// #ifdef H5
// 中止 H5 的 SSE 连接
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
// #endif
// #ifdef MP-WEIXIN
// 中止小程序的请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// #endif
// #ifdef APP-PLUS
// 中止 App 的请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// #endif
}
}
// 创建流式请求实例
export const streamRequest = new StreamRequest();

View File

@@ -0,0 +1,329 @@
import { ALLOW_EXTERNAL_LINK_DOMAIN } from "@/constants/config";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { apiTenantConfig } from "@/servers/account";
import { apiGetStaticFileList } from "@/servers/agentDev.uts";
import { t } from "@/utils/i18n";
const SYSTEM_INFO = uni.getSystemInfoSync();
export const getStatusBarHeight = () => SYSTEM_INFO.statusBarHeight || 15;
export const formatDate = (
input: string | number | Date,
format:
| "YYYY-MM-DD"
| "MM-DD"
| "HH:mm"
| "YYYY-MM-DD HH:mm:ss"
| "YYYY-MM-DD HH:mm" = "MM-DD",
): string => {
const date = new Date(input);
const YYYY = date.getFullYear();
const MM = String(date.getMonth() + 1).padStart(2, "0");
const DD = String(date.getDate()).padStart(2, "0");
const HH = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
switch (format) {
case "YYYY-MM-DD":
return `${YYYY}-${MM}-${DD}`;
case "YYYY-MM-DD HH:mm:ss":
const ss = String(date.getSeconds()).padStart(2, "0");
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`;
case "YYYY-MM-DD HH:mm":
return `${YYYY}-${MM}-${DD} ${HH}:${mm}`;
case "HH:mm":
return `${HH}:${mm}`;
default:
return `${MM}-${DD}`;
}
};
// 判断是否是允许外部链接跳转的域名
export const isAllowExternalLinkDomain = (url: string) => {
return ALLOW_EXTERNAL_LINK_DOMAIN.some((domain) => url.includes(domain));
};
/**
* 根据链接地址判断不允许调整的页面需要复制链接到剪切板
* @param url 链接地址
* @returns void
*/
export const handleExternalLink = (url: string) => {
// 如果链接地址为空,则提示链接地址配置错误
if (!url) {
uni.showToast({
title: t("Mobile.Link.invalidConfig"),
icon: "none",
});
return;
}
// #ifdef H5 || WEB
// H5 环境直接打开外部链接
try {
// 判断是否为有效的 URL
const isValidUrl =
url.startsWith("http://") ||
url.startsWith("https://") ||
url.startsWith("//");
if (isValidUrl) {
// 尝试在新标签页中打开链接
const newWindow = window.open(url, "_blank");
// 检查是否被浏览器拦截(某些浏览器会阻止弹窗)
if (
!newWindow ||
newWindow.closed ||
typeof newWindow.closed === "undefined"
) {
// 如果被拦截,提示用户并复制链接到剪贴板
console.warn("链接被浏览器拦截,使用备用方案");
// 尝试复制到剪贴板
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(url)
.then(() => {
uni.showToast({
title: t("Mobile.Link.copyAndOpen"),
icon: "none",
duration: 3000,
});
})
.catch(() => {
// 如果复制失败,直接在当前页面打开
window.location.href = url;
});
} else {
// 如果不支持剪贴板 API直接在当前页面打开
window.location.href = url;
}
}
} else {
// 如果不是完整的 URL使用 navigateTo 跳转到 webview 页面
uni.navigateTo({
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(url)}`,
});
}
} catch (error) {
console.error("打开链接失败:", error);
uni.showToast({
title: t("Mobile.Link.openFailed"),
icon: "none",
});
}
// #endif
// #ifdef MP-WEIXIN
// 如果允许外部链接跳转,则打开外部链接
if (isAllowExternalLinkDomain(url)) {
uni.navigateTo({
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(url)}`,
});
} else {
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({
title: t("Mobile.Link.copied"),
icon: "none",
});
},
});
}
// #endif
};
// 获取用户配置并设置页面标题和缓存租户信息
export const fetchTenantConfig = async () => {
const { code, data } = await apiTenantConfig();
const siteName = data?.siteName;
if (code === SUCCESS_CODE && siteName) {
// 设置当前页面导航栏标题
uni.setNavigationBarTitle({ title: siteName });
// 缓存租户信息
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(data));
}
};
// 设置当前页面导航栏标题
export const setCurrentPageNavigationBarTitle = async () => {
// 获取本地用户配置
const tenantConfigInfoString = await uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
try {
// JSON.parse 返回 unknown这里显式约束为仅关心 siteName 的结构
const tenantConfig = JSON.parse(tenantConfigInfoString) as {
siteName?: string;
};
const { siteName } = tenantConfig;
if (siteName) {
// 设置当前页面导航栏标题
uni.setNavigationBarTitle({ title: siteName });
return;
}
} catch (error) {
console.error("获取本地用户配置失败:", error);
}
}
fetchTenantConfig(); // 获取用户配置并设置页面标题和缓存租户信息
};
// 根据会话id和文件路径 跳转至预览页面
export const jumpToFilePreviewPage = (
conversationId: string,
fileProxyUrl: string,
) => {
if (!conversationId || !fileProxyUrl) {
return;
}
const path = "/subpackages/pages/file-preview-page/file-preview-page";
const url = `${path}?cId=${conversationId}&fileProxyUrl=${encodeURIComponent(fileProxyUrl)}`;
uni.navigateTo({ url });
};
// 根据会话id查询文件列表并根据文件路径获取到文件代理url
export const getFileProxyUrlByConversationIdAndFilePath = async (
conversationId: string,
filePath: string,
) => {
if (!conversationId || !filePath) {
return null;
}
// 接口要求 number 类型,先做显式转换并校验,避免把非法字符串传入服务层
const normalizedConversationId = Number(conversationId);
if (Number.isNaN(normalizedConversationId)) {
return null;
}
const result = await apiGetStaticFileList(normalizedConversationId);
if (result.code === SUCCESS_CODE) {
const { files } = result.data || {};
const file = files.find((item) => item.name === filePath);
return file?.fileProxyUrl;
}
return null;
};
/**
* 任务结果数据
*/
interface TaskResultData {
hasTaskResult: boolean;
description?: string;
file?: string;
}
/**
* 提取任务结果数据
* @param text 文本
* @returns 任务结果数据
*/
export const extractTaskResult = (text: string): TaskResultData => {
const result: TaskResultData = {
hasTaskResult: false,
};
if (!text) return result;
// 1⃣ 匹配 <task-result>...</task-result>
const taskResultMatch = text.match(/<task-result>([\s\S]*?)<\/task-result>/);
if (!taskResultMatch) {
return result;
}
result.hasTaskResult = true;
const taskResultContent = taskResultMatch[1];
// 2⃣ 提取 description
const descriptionMatch = taskResultContent.match(
/<description>([\s\S]*?)<\/description>/,
);
if (descriptionMatch) {
result.description = descriptionMatch[1].trim();
}
// 3⃣ 提取 file
const fileMatch = taskResultContent.match(/<file>([\s\S]*?)<\/file>/);
if (fileMatch) {
result.file = fileMatch[1].trim();
}
return result;
};
/**
* 提取字符串中最后一个 <task-result> 内的 <file> 内容
* @param text 原始字符串
* @returns file 内容或 null
*/
export const extractLastTaskResultFile = (text: string): string | null => {
if (!text) return null;
// 匹配所有 <task-result>...</task-result>
const taskResultMatches = text.match(/<task-result>[\s\S]*?<\/task-result>/g);
if (!taskResultMatches || taskResultMatches.length === 0) {
return null;
}
// 取最后一个 <task-result>
const lastTaskResult = taskResultMatches[taskResultMatches.length - 1];
// 在其中提取 <file> 内容
const fileMatch = lastTaskResult.match(/<file>([\s\S]*?)<\/file>/);
return fileMatch?.[1]?.trim() ?? null;
};
/**
* 健壮的千分位数字格式化工具函数(支持整数、浮点数以及负数,带有强健的边界容错降级机制)
* @param value 输入数值或字符
* @returns 格式化后的千分位字符串
*/
export const formatNumber = (value: any, precision: number = -1): string => {
if (value == null) {
return "0";
}
let str = String(value).trim();
if (str === "" || str === "null" || str === "undefined") {
return "0";
}
const num = Number(str);
if (Number.isNaN(num) || !Number.isFinite(num)) {
return "0";
}
if (precision >= 0) {
str = num.toFixed(precision);
} else {
str = num.toString();
}
const isNegative = str.startsWith("-");
if (isNegative) {
str = str.substring(1);
}
const parts = str.split(".");
const integerPart = parts[0];
const decimalPart = parts.length > 1 ? "." + parts[1] : "";
let result = "";
let counter = 0;
for (let i = integerPart.length - 1; i >= 0; i--) {
result = integerPart.charAt(i) + result;
counter++;
if (counter === 3 && i !== 0) {
result = "," + result;
counter = 0;
}
}
return (isNegative ? "-" : "") + result + decimalPart;
};

View File

@@ -0,0 +1,30 @@
// UTF-8 解码工具:将 Uint8Array 转换为字符串,兼容微信小程序无 TextDecoder 场景
export function utf8ArrayToString(bytes: Uint8Array): string {
let out = ''
let i = 0
const len = bytes.length
while (i < len) {
const c = bytes[i++]
if (c < 0x80) {
out += String.fromCharCode(c)
} else if (c < 0xE0) {
const c2 = bytes[i++]
out += String.fromCharCode(((c & 0x1F) << 6) | (c2 & 0x3F))
} else if (c < 0xF0) {
const c2 = bytes[i++]
const c3 = bytes[i++]
out += String.fromCharCode(((c & 0x0F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F))
} else {
const c2 = bytes[i++]
const c3 = bytes[i++]
const c4 = bytes[i++]
let codepoint = ((c & 0x07) << 18) | ((c2 & 0x3F) << 12) | ((c3 & 0x3F) << 6) | (c4 & 0x3F)
codepoint -= 0x10000
out += String.fromCharCode(0xD800 + (codepoint >> 10))
out += String.fromCharCode(0xDC00 + (codepoint & 0x3FF))
}
}
return out
}