393 lines
10 KiB
TypeScript
393 lines
10 KiB
TypeScript
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<string, string> = {
|
|
'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<string, unknown> | 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<string, unknown>
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
if (typeof data === 'object' && !Array.isArray(data)) {
|
|
return data as Record<string, unknown>
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getCandidateObjects(data: Record<string, unknown>): Record<string, unknown>[] {
|
|
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<string, unknown>)
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
function pickFirstString(
|
|
objects: Record<string, unknown>[],
|
|
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<string, unknown> | null = null
|
|
if (typeof input === 'string') {
|
|
obj = JSON.parse(input)
|
|
} else if (input && typeof input === 'object') {
|
|
obj = input as Record<string, unknown>
|
|
}
|
|
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<string, unknown> | 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<string, unknown>
|
|
}
|
|
|
|
// 从对象中提取字段
|
|
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 || '未命名'
|
|
}
|