import type { TaskArtifact } from '../../types' /** * 工具类型枚举 */ export enum ToolType { SHELL_EXECUTE = 'shell_execute', SCRIPT_FILE = 'script_file', OTHER = 'other', } /** * 脚本文件扩展名 */ const SCRIPT_EXTENSIONS = ['py', 'js', 'ts', 'sh', 'bash', 'zsh', 'fish', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'swift'] const INLINE_PREVIEW_EXTENSIONS = [ 'md', 'markdown', 'txt', 'html', 'csv', 'json', ...SCRIPT_EXTENSIONS, ] const MIME_TYPE_ALIASES: Record = { 'text/markdown': 'md', 'text/x-markdown': 'md', 'application/markdown': 'md', 'text/html': 'html', 'application/pdf': 'pdf', 'text/csv': 'csv', 'application/csv': 'csv', 'application/json': 'json', 'text/plain': 'txt', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'application/vnd.ms-powerpoint': 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', 'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', } const TOOL_OUTPUT_FILE_TYPE_FIELDS = ['file_type', 'type', 'mime_type'] const TOOL_OUTPUT_FILE_NAME_FIELDS = ['file_name', 'name', 'title'] const TOOL_OUTPUT_PATH_FIELDS = ['path', 'file_path', 'file_url', 'url', 'download_url'] const TOOL_OUTPUT_CONTENT_FIELDS = ['content', 'text', 'body', 'source', 'code', 'file_content'] function toObject(data: unknown): Record | null { if (!data) return null if (typeof data === 'string') { try { const parsed = JSON.parse(data) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } } catch { return null } } if (typeof data === 'object' && !Array.isArray(data)) { return data as Record } return null } function getCandidateObjects(data: Record): Record[] { const candidates = [data] for (const key of ['result', 'data', 'file']) { const nested = data[key] if (nested && typeof nested === 'object' && !Array.isArray(nested)) { candidates.push(nested as Record) } } return candidates } function pickFirstString( objects: Record[], fields: string[], ): string { for (const obj of objects) { for (const field of fields) { const value = obj[field] if (typeof value === 'string' && value.trim()) { return value.trim() } } } return '' } function getBaseName(path?: string): string { if (!path) return '' const cleanPath = path.split(/[?#]/)[0] const segments = cleanPath.split('/').filter(Boolean) return segments[segments.length - 1] || '' } function isInlinePreviewFileType(fileType: string): boolean { return INLINE_PREVIEW_EXTENSIONS.includes(fileType) } function isAbsoluteHttpUrl(value?: string): boolean { return !!value && /^https?:\/\//.test(value) } /** * 从路径中提取文件扩展名 */ export function getFileExtension(path?: string): string { if (!path) return '' const baseName = getBaseName(path) || path const match = baseName.match(/\.([^.]+)$/) return match ? match[1].toLowerCase() : '' } /** * 统一文件类型,兼容扩展名与 MIME type */ export function normalizeArtifactFileType( fileType?: string, fileName?: string, path?: string, ): string { const normalizedFileType = (fileType || '') .trim() .toLowerCase() .split(';')[0] if (normalizedFileType) { if (MIME_TYPE_ALIASES[normalizedFileType]) { return MIME_TYPE_ALIASES[normalizedFileType] } if (normalizedFileType.includes('/')) { const subtype = normalizedFileType.split('/').pop() || '' if (subtype === 'svg+xml') return 'svg' if (subtype.includes('markdown')) return 'md' if (subtype.includes('presentationml.presentation')) return 'pptx' if (subtype.includes('ms-powerpoint')) return 'ppt' if (subtype.includes('spreadsheetml.sheet')) return 'xlsx' if (subtype.includes('ms-excel')) return 'xls' if (subtype.includes('wordprocessingml.document')) return 'docx' if (subtype.includes('msword')) return 'doc' return subtype } return normalizedFileType } return getFileExtension(fileName) || getFileExtension(path) } /** * 从 tool_input 中提取 file_path */ export function getFilePathFromInput(input: unknown): string { try { let obj: Record | null = null if (typeof input === 'string') { obj = JSON.parse(input) } else if (input && typeof input === 'object') { obj = input as Record } if (obj && typeof obj.file_path === 'string') { return obj.file_path } } catch { // ignore } return '' } /** * 判断普通文件是否是脚本类型(根据扩展名) */ export function isScriptLikeFile(artifact: TaskArtifact): boolean { const extFromPath = getFileExtension(artifact.path) const extFromName = getFileExtension(artifact.file_name) const extFromType = normalizeArtifactFileType( artifact.file_type, artifact.file_name, artifact.path, ) const ext = extFromPath || extFromName || extFromType if (!ext) return false return SCRIPT_EXTENSIONS.includes(ext) } /** * 从 tool_output 中提取可直接预览的文件 artifact */ export function extractToolOutputArtifact( artifact: TaskArtifact, ): TaskArtifact | null { const rawOutput = toObject(artifact.tool_output) if (!rawOutput) return null const candidates = getCandidateObjects(rawOutput) const outputPath = pickFirstString(candidates, TOOL_OUTPUT_PATH_FIELDS) const outputName = pickFirstString(candidates, TOOL_OUTPUT_FILE_NAME_FIELDS) const outputType = pickFirstString(candidates, TOOL_OUTPUT_FILE_TYPE_FIELDS) const normalizedType = normalizeArtifactFileType(outputType, outputName, outputPath) const outputContent = pickFirstString(candidates, TOOL_OUTPUT_CONTENT_FIELDS) if (!normalizedType && !outputPath && !outputName) { return null } const canPreviewInline = !!outputContent && isInlinePreviewFileType(normalizedType) if (!outputPath && !canPreviewInline) { return null } const fallbackBaseName = artifact.tool_name || artifact.file_name || 'tool_output' const fallbackFileName = normalizedType ? `${fallbackBaseName}.${normalizedType}` : fallbackBaseName const fileName = outputName || getBaseName(outputPath) || fallbackFileName return { path: outputPath || '', file_name: fileName, file_type: normalizedType || getFileExtension(fileName), content: canPreviewInline ? outputContent : undefined, url: isAbsoluteHttpUrl(outputPath) ? outputPath : undefined, task_id: artifact.task_id, } } /** * 判断工具类型 */ export function detectToolType(artifact: TaskArtifact): ToolType { const toolName = artifact.tool_name // 1. shell_execute 特殊处理 if (toolName === 'shell_execute') { return ToolType.SHELL_EXECUTE } // 2. 检查文件扩展名 const filePath = getFilePathFromInput(artifact.tool_input) const fileExt = getFileExtension(filePath) || getFileExtension(artifact.path) || getFileExtension(artifact.file_name) || normalizeArtifactFileType(artifact.file_type, artifact.file_name, artifact.path) if (SCRIPT_EXTENSIONS.includes(fileExt)) { return ToolType.SCRIPT_FILE } // 3. 检查工具名称关键字 if (toolName && ( toolName.toLowerCase().includes('execute') || toolName.toLowerCase().includes('shell') || toolName.toLowerCase().includes('code') || toolName.toLowerCase().includes('script') || toolName.toLowerCase().includes('python') || toolName.toLowerCase().includes('javascript') || toolName.toLowerCase().includes('node') || toolName.toLowerCase().includes('bash') || toolName.toLowerCase().includes('cmd') )) { return ToolType.SCRIPT_FILE } return ToolType.OTHER } /** * 移除 ANSI 转义序列 */ export function removeAnsiCodes(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, '') } /** * 从数据中提取字符串 */ function extractFromObject(data: unknown, fields: string[]): string | null { let obj: Record | null = null // 解析 JSON 字符串 if (typeof data === 'string') { try { const parsed = JSON.parse(data) if (parsed && typeof parsed === 'object') { obj = parsed } else if (typeof parsed === 'string') { return parsed } } catch { return data } } else if (data && typeof data === 'object') { obj = data as Record } // 从对象中提取字段 if (obj) { for (const field of fields) { const value = obj[field] if (typeof value === 'string' && value) { return value } } } return null } /** * 提取 shell_execute 输出 */ export function extractShellOutput(output: unknown): string { // 处理数组 if (Array.isArray(output)) { if (output.length === 1 && typeof output[0] === 'string') { return removeAnsiCodes(output[0]) } return removeAnsiCodes(output.filter(item => typeof item === 'string').join('\n')) } // 解析 JSON if (typeof output === 'string') { try { const parsed = JSON.parse(output) if (Array.isArray(parsed)) { if (parsed.length === 1 && typeof parsed[0] === 'string') { return removeAnsiCodes(parsed[0]) } return removeAnsiCodes(parsed.filter(item => typeof item === 'string').join('\n')) } } catch { return removeAnsiCodes(output) } } // 从对象中提取 const result = extractFromObject(output, ['output', 'result', 'stdout']) if (result) { return removeAnsiCodes(result) } return JSON.stringify(output, null, 2) } /** * 提取脚本代码 */ export function extractScriptCode(input: unknown): string { const codeFields = [ 'file_content', 'content', 'code', 'script', 'command', 'cmd', 'source', 'text', 'body', ] const result = extractFromObject(input, codeFields) return result || JSON.stringify(input, null, 2) } /** * 获取显示标题 */ export function getDisplayTitle(artifact: TaskArtifact): string { const filePath = getFilePathFromInput(artifact.tool_input) return filePath || artifact.file_name || artifact.tool_name || '未命名' }