初始化模版工程
This commit is contained in:
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal file
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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 || '未命名'
|
||||
}
|
||||
Reference in New Issue
Block a user