chore: initialize qiming workspace repository
This commit is contained in:
63
qiming-mobile/subpackages/utils/containerHelper.uts
Normal file
63
qiming-mobile/subpackages/utils/containerHelper.uts
Normal 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}`;
|
||||
}
|
||||
|
||||
350
qiming-mobile/subpackages/utils/customActionService.uts
Normal file
350
qiming-mobile/subpackages/utils/customActionService.uts
Normal 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)
|
||||
472
qiming-mobile/subpackages/utils/fileTree.uts
Normal file
472
qiming-mobile/subpackages/utils/fileTree.uts
Normal 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
|
||||
102
qiming-mobile/subpackages/utils/functionUtils.uts
Normal file
102
qiming-mobile/subpackages/utils/functionUtils.uts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
266
qiming-mobile/subpackages/utils/historyMessageAdapter.uts
Normal file
266
qiming-mobile/subpackages/utils/historyMessageAdapter.uts
Normal 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))
|
||||
}
|
||||
111
qiming-mobile/subpackages/utils/parseUrlInfo.uts
Normal file
111
qiming-mobile/subpackages/utils/parseUrlInfo.uts
Normal 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️⃣ 完整 URL(https://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️⃣ 仅 query(a=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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user