Files
test1/components/nova-sdk/task-panel/Preview/previewUtils.ts
2026-03-20 07:33:46 +00:00

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 || '未命名'
}