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,63 @@
/**
* 工具调用容器辅助函数
* 用于生成和管理工具调用相关的自定义容器块
*/
/**
* 生成 markdown 自定义容器块
* @param blockName 容器块名称
* @param data 属性数据
* @returns 格式化的容器块字符串
*/
export function getBlockWrapper(blockName: string, data: Record<string, any>): string {
const attrs = Object.entries(data)
.map(([key, value]) => `${key}="${value}"`)
.join(" ");
// 修复DOM嵌套验证错误使用div代替p标签因为自定义组件包含块级元素
return `\n\n:::${blockName} ${attrs}\n:::\n\n`;
}
/**
* 获取容器块名称
* @returns 容器块名称
*/
export function getBlockName(): string {
return `container`;
}
/**
* 生成工具调用自定义块
* @param beforeText 之前的文本内容
* @param toolCallData 工具调用数据
* @returns 包含自定义块的完整文本
*/
export function getCustomBlock(
beforeText: string,
toolCallData: { type?: string; name?: string; executeId?: string; status?: string }
): string {
const { type, executeId } = toolCallData;
// 如果 type 或 executeId 不存在,则返回原文本
if (!type || !executeId) {
return beforeText;
}
const blockName = getBlockName();
const hasBlock = beforeText.includes(`executeId="${executeId}"`);
if (hasBlock) {
// 如果已经存在相同的执行ID则不重复添加
return beforeText;
}
const blockContent = getBlockWrapper(blockName, {
executeId,
type,
status: toolCallData.status || "",
name: toolCallData.name || "",
});
return `${beforeText}${blockContent}`;
}

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,472 @@
import type { FileNode } from '@/types/interfaces/agent'
import { ACCESS_TOKEN } from '@/constants/home.constants'
import { COMMON_HEADERS } from '@/constants/common.constants'
import { t } from "@/utils/i18n";
/**
* 文件相关常量
*/
export const FILE_CONSTANTS = {
// 支持预览的文件扩展名白名单
SUPPORTED_EXTENSIONS: [
// 图片文件
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
'ico',
'tiff',
// 代码文件
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'css',
'less',
'scss',
'sass',
'html',
'htm',
'vue',
'json',
'jsonc',
'yaml',
'yml',
'xml',
'toml',
'ini',
'py',
'java',
'c',
'cpp',
'cs',
'php',
'rb',
'go',
'rs',
'swift',
'kt',
'scala',
'sh',
'bash',
'zsh',
'fish',
'ps1',
'bat',
'sql',
'dockerfile',
'makefile',
// 文本文件
'txt',
'md',
'markdown',
'log',
'csv',
'tsv',
'rtf',
],
// 忽略的文件模式
IGNORED_FILE_PATTERNS: [
/^\./, // 以 . 开头的隐藏文件
/^\.DS_Store$/,
/^Thumbs\.db$/,
/\.tmp$/,
/\.bak$/,
],
DEFAULT_FILE_LANGUAGE: 'Plain Text',
FALLBACK_SIZE: 0,
TREE_ROOT_LEVEL: 0,
INDENT_SIZE: 16,
REQUEST_ID_PREFIX: 'req_',
SESSION_ID_PREFIX: 'session_',
};
/**
* 将扁平的文件列表转换为树形结构
*/
export const transformFlatListToTree = (files: any[]): FileNode[] => {
const root: FileNode[] = [];
const map = new Map<string, FileNode>();
// 过滤掉系统文件
const filteredFiles = files.filter((file) => {
const fileName = file.name.split('/').pop();
return !FILE_CONSTANTS.IGNORED_FILE_PATTERNS.some((pattern) =>
pattern.test(fileName || ''),
);
});
// 创建所有文件节点和必要的文件夹节点
filteredFiles.forEach((file) => {
const pathParts = file.name.split('/').filter(Boolean);
const fileName = pathParts[pathParts.length - 1];
// 如果文件是目录则认为是文件后端给了isDir字段表示是否为目录兼容之前逻辑
const isFile = !file.isDir || fileName.includes('.');
const node: FileNode = {
id: file.name,
name: fileName,
type: isFile ? 'file' : 'folder',
path: file.name,
children: [],
binary: file.binary || false,
size:
file.size || file.sizeExceeded
? FILE_CONSTANTS.FALLBACK_SIZE
: file.contents?.length || FILE_CONSTANTS.FALLBACK_SIZE,
status: file.status || null,
fullPath: file.name,
parentPath: pathParts.slice(0, -1).join('/') || null,
content: file.contents || '',
lastModified: Date.now(),
fileProxyUrl: file?.fileProxyUrl || '',
isLink: file?.isLink || false,
};
map.set(file.name, node);
// 如果文件在子目录中,确保创建所有必要的父文件夹节点
if (pathParts.length > 1) {
for (let i = pathParts.length - 2; i >= 0; i--) {
const parentPath = pathParts.slice(0, i + 1).join('/');
const parentName = pathParts[i];
if (!map.has(parentPath)) {
const parentNode: FileNode = {
id: parentPath,
name: parentName,
type: 'folder',
path: parentPath,
children: [],
parentPath: i > 0 ? pathParts.slice(0, i).join('/') : null,
lastModified: Date.now(),
};
map.set(parentPath, parentNode);
}
}
}
});
// 构建层次结构
map.forEach((node) => {
if (node.parentPath && map.has(node.parentPath)) {
const parentNode = map.get(node.parentPath)!;
if (
!parentNode.children?.find((child: FileNode) => child.id === node.id)
) {
parentNode.children?.push(node);
}
} else if (!node.parentPath) {
if (!root.find((item: FileNode) => item.id === node.id)) {
root.push(node);
}
}
});
// 排序:文件夹在前,文件在后,同类型按名称排序
const sortNodes = (nodes: FileNode[]): FileNode[] => {
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};
return sortNodes(root).map((node) => ({
...node,
children: node.children ? sortNodes(node.children) : undefined,
}));
};
/**
* 从响应头中解析文件名
* @param contentDisposition Content-Disposition 响应头
* @param defaultName 默认文件名
*/
const parseFileName = (contentDisposition: string | undefined, defaultName: string): string => {
if (!contentDisposition) return defaultName;
// 解析 Content-Disposition 头中的文件名
const filenameMatch = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, '');
}
return defaultName;
};
/**
* H5 环境:导出整个项目压缩包
* @param blob Blob 对象
* @param headers 响应头
* @param defaultName 默认文件名称
*/
// #ifdef H5 || WEB
export const exportWholeProjectZipH5 = async (blob: Blob, headers: any, defaultName: string) => {
// 从响应头中获取文件名
const contentDisposition = headers?.['content-disposition'];
const filename = parseFileName(contentDisposition, defaultName);
// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理URL对象
window.URL.revokeObjectURL(url);
};
// #endif
/**
* 小程序环境下载文件并打包成zip
* @param downloadUrl 下载地址应该是返回zip包的接口
* @param filename 文件名称
* @returns 返回保存的zip文件路径
*/
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
export const downloadFileInMiniProgram = async (downloadUrl: string, filename: string): Promise<string> => {
return new Promise<string>((resolve, reject) => {
// 获取访问令牌并构建请求头
const accessToken = uni.getStorageSync(ACCESS_TOKEN);
const header: any = {
// ...COMMON_HEADERS,
};
if (accessToken) {
header['Authorization'] = `Bearer ${accessToken}`;
}
uni.showLoading({ title: t("Mobile.FileTree.downloading") });
uni.downloadFile({
url: downloadUrl,
header, // 添加请求头,确保认证通过
success: (res) => {
console.log('下载文件响应:', res);
if (res.statusCode === 200) {
// 验证下载的文件是否为zip格式
const fs = uni.getFileSystemManager();
const tempPath = res.tempFilePath;
// 检查文件信息
fs.stat({
path: tempPath,
success: (statRes) => {
console.log('文件信息:', statRes);
// 如果文件太小小于1KB可能是JSON错误响应
if (statRes.size < 1024) {
// 尝试读取文件内容检查是否为JSON
fs.readFile({
filePath: tempPath,
encoding: 'utf8',
success: (readRes) => {
try {
const content = readRes.data as string;
if (content.trim().startsWith('{') || content.trim().startsWith('[')) {
// 是JSON响应说明下载失败
uni.hideLoading();
const errorData = JSON.parse(content);
uni.showToast({
title:
errorData.message ||
t("Mobile.FileTree.downloadServerError"),
icon: 'none',
duration: 3000,
});
reject(
new Error(
errorData.message ||
t("Mobile.FileTree.serverError"),
),
);
return;
}
} catch (e) {
// 不是JSON继续处理
}
},
fail: () => {
// 读取失败,继续保存流程
},
});
}
// 先使用 uni.saveFile 保存文件然后尝试重命名为正确的zip文件名
// 确保文件名有 .zip 扩展名
const zipFileName = filename.endsWith('.zip') ? filename : `${filename}.zip`;
uni.saveFile({
tempFilePath: tempPath,
success: (saveRes) => {
let savedFilePath = saveRes.savedFilePath;
console.log('初始保存路径:', savedFilePath);
// 如果保存的文件没有正确的扩展名,尝试重命名
if (!savedFilePath.endsWith('.zip')) {
// 获取文件目录和原文件名
const pathParts = savedFilePath.split('/');
const dirPath = pathParts.slice(0, -1).join('/');
const newPath = `${dirPath}/${zipFileName}`;
// 尝试重命名文件(异步方式)
fs.rename({
oldPath: savedFilePath,
newPath: newPath,
success: () => {
savedFilePath = newPath;
console.log('文件已重命名为:', savedFilePath);
finishSave(savedFilePath, zipFileName);
},
fail: (renameErr) => {
console.warn('重命名文件失败,尝试复制方式:', renameErr);
// 如果重命名失败,尝试读取原文件并写入新文件
fs.readFile({
filePath: savedFilePath,
success: (readFileRes) => {
fs.writeFile({
filePath: newPath,
data: readFileRes.data as ArrayBuffer,
success: () => {
// 删除旧文件
fs.unlink({
filePath: savedFilePath,
success: () => {
console.log('已删除旧文件');
},
fail: (e) => {
console.warn('删除旧文件失败:', e);
},
});
savedFilePath = newPath;
console.log('文件已复制并重命名为:', savedFilePath);
finishSave(savedFilePath, zipFileName);
},
fail: (writeErr) => {
console.error('写入新文件失败:', writeErr);
// 如果复制也失败,使用原路径
finishSave(savedFilePath, zipFileName);
},
});
},
fail: (readErr) => {
console.error('读取文件失败:', readErr);
// 如果读取失败,使用原路径
finishSave(savedFilePath, zipFileName);
},
});
},
});
} else {
// 已经有正确的扩展名,直接完成保存
finishSave(savedFilePath, zipFileName);
}
},
fail: (err) => {
uni.hideLoading();
console.error('保存ZIP文件失败:', err);
uni.showToast({
title: t("Mobile.FileTree.saveFailed"),
icon: 'none',
duration: 2000,
});
reject(err);
},
});
// 完成保存流程的辅助函数
const finishSave = (filePath: string, zipFileName: string) => {
uni.hideLoading();
// 弹窗提示用户文件保存位置
uni.showModal({
title: t("Mobile.FileTree.downloadComplete"),
content: t("Mobile.FileTree.zipSavedContent", {
fileName: zipFileName,
filePath,
}),
confirmText: t("Mobile.Common.open"),
cancelText: t("Mobile.Common.cancel"),
success: (modalRes) => {
if (modalRes.confirm) {
// 打开文件
uni.openDocument({
filePath: filePath,
fileType: 'zip',
showMenu: true,
success: () => {
console.log('打开文件成功');
},
fail: (err) => {
console.error('打开文件失败:', err);
uni.showToast({
title: t("Mobile.FileTree.openFailed"),
icon: 'none',
duration: 3000,
});
},
});
}
},
});
resolve(filePath);
};
},
fail: (statErr) => {
uni.hideLoading();
console.error('获取文件信息失败:', statErr);
uni.showToast({
title: t("Mobile.FileTree.fileValidateFailed"),
icon: 'none',
duration: 2000,
});
reject(statErr);
},
});
} else {
uni.hideLoading();
uni.showToast({
title: t("Mobile.FileTree.downloadFailedWithCode", {
code: res.statusCode,
}),
icon: 'none',
duration: 2000,
});
reject(
new Error(
t("Mobile.FileTree.downloadFailedWithCode", {
code: res.statusCode,
}),
),
);
}
},
fail: (err) => {
uni.hideLoading();
console.error('下载文件失败:', err);
uni.showToast({
title: t("Mobile.FileTree.downloadNetworkFailed"),
icon: 'none',
duration: 2000,
});
reject(err);
},
});
});
};
// #endif

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,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,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;
}
}