| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import log from 'electron-log/main.js' |
| 4 | import { nanoid } from 'nanoid' |
| 5 | import { FilesystemBackend, createDeepAgent } from 'deepagents' |
| 6 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 7 | import { extractJsonBlock, extractModelText, resolveModel } from '../../agent-runtime/model' |
| 8 | import type { ModelRuntimeConfig } from '../../agent-runtime/model' |
| 9 | import { buildStyleImportPrompt } from '../../agent-runtime/prompt' |
| 10 | |
| 11 | export interface StyleParseResult { |
| 12 | label: string |
| 13 | description: string |
| 14 | category: string |
| 15 | aliases: string[] |
| 16 | styleSkill: string |
| 17 | styleCase: string |
| 18 | } |
| 19 | |
| 20 | const ALLOWED_EXTENSIONS = new Set(['.md', '.txt', '.html', '.htm']) |
| 21 | const MAX_FILE_SIZE_MB = 10 |
| 22 | const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 |
| 23 | |
| 24 | type PreparedStyleSourceFile = { |
| 25 | name: string |
| 26 | ext: string |
| 27 | workspacePath: string |
| 28 | virtualPath: string |
| 29 | } |
| 30 | |
| 31 | export async function parseStyleFile(args: { |
| 32 | filePath: string |
| 33 | provider: string |
| 34 | apiKey: string |
| 35 | model: string |
| 36 | baseUrl: string |
| 37 | maxTokens?: number |
| 38 | modelRuntime?: ModelRuntimeConfig |
| 39 | modelTimeoutMs: number |
| 40 | workspaceDir: string |
| 41 | }): Promise<StyleParseResult> { |
| 42 | await fs.promises.mkdir(args.workspaceDir, { recursive: true }) |
| 43 | const sourceFile = await prepareStyleSourceFile(args.filePath, args.workspaceDir) |
| 44 | const responseText = await runStyleImportAgent({ |
| 45 | provider: args.provider, |
| 46 | apiKey: args.apiKey, |
| 47 | model: args.model, |
| 48 | baseUrl: args.baseUrl, |
| 49 | maxTokens: args.maxTokens, |
| 50 | modelRuntime: args.modelRuntime, |
| 51 | modelTimeoutMs: args.modelTimeoutMs, |
| 52 | workspaceDir: args.workspaceDir, |
| 53 | file: sourceFile |
| 54 | }) |
| 55 | return parseStyleImportResponse(responseText) |
| 56 | } |
| 57 | |
| 58 | async function prepareStyleSourceFile( |
| 59 | sourcePathInput: string, |
| 60 | workspaceDir: string |
| 61 | ): Promise<PreparedStyleSourceFile> { |
| 62 | const resolvedPath = path.resolve(sourcePathInput) |
| 63 | const ext = path.extname(resolvedPath).toLowerCase() |
| 64 | if (!ALLOWED_EXTENSIONS.has(ext)) { |
| 65 | throw new Error(`不支持的文件格式:${ext},仅支持 ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`) |
| 66 | } |
| 67 | |
| 68 | const stat = await fs.promises.stat(resolvedPath) |
| 69 | if (!stat.isFile()) { |
| 70 | throw new Error(`路径不是文件:${resolvedPath}`) |
| 71 | } |
| 72 | if (stat.size > MAX_FILE_SIZE) { |
| 73 | throw new Error( |
| 74 | `文件过大(${(stat.size / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_SIZE_MB}MB` |
| 75 | ) |
| 76 | } |
| 77 | log.info('[styles:parseFile] read source file', { |
| 78 | fileName: path.basename(resolvedPath), |
| 79 | extension: ext, |
| 80 | size: stat.size |
| 81 | }) |
| 82 | |
| 83 | const workspaceName = `${Date.now()}-${nanoid(8)}-${path.basename(resolvedPath)}` |
| 84 | const workspacePath = path.join(workspaceDir, workspaceName) |
| 85 | await fs.promises.copyFile(resolvedPath, workspacePath) |
| 86 | |
| 87 | return { |
| 88 | name: path.basename(resolvedPath), |
| 89 | ext, |
| 90 | workspacePath, |
| 91 | virtualPath: `/${workspaceName}` |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | async function runStyleImportAgent(args: { |
| 96 | provider: string |
| 97 | apiKey: string |
| 98 | model: string |
| 99 | baseUrl: string |
| 100 | maxTokens?: number |
| 101 | modelRuntime?: ModelRuntimeConfig |
| 102 | modelTimeoutMs: number |
| 103 | workspaceDir: string |
| 104 | file: PreparedStyleSourceFile |
| 105 | }): Promise<string> { |
| 106 | const model = resolveModel( |
| 107 | args.provider, |
| 108 | args.apiKey, |
| 109 | args.model, |
| 110 | args.baseUrl, |
| 111 | 0.2, |
| 112 | args.maxTokens, |
| 113 | args.modelRuntime |
| 114 | ) |
| 115 | const prompt = buildStyleImportPrompt(args.file.virtualPath) |
| 116 | log.info('[styles:parseFile] agent read_file requested', { |
| 117 | virtualPath: args.file.virtualPath, |
| 118 | workspaceName: path.basename(args.file.workspacePath) |
| 119 | }) |
| 120 | |
| 121 | const agent = createDeepAgent({ |
| 122 | model, |
| 123 | backend: new FilesystemBackend({ |
| 124 | rootDir: args.workspaceDir, |
| 125 | virtualMode: true |
| 126 | }), |
| 127 | systemPrompt: |
| 128 | 'You are a style-import parsing agent. You must use read_file to read the uploaded file before generating the result. Return strict JSON only: label, description, category, aliases, styleCase, styleSkill.' |
| 129 | }) |
| 130 | |
| 131 | const stream = await agent.stream( |
| 132 | { |
| 133 | messages: [ |
| 134 | { |
| 135 | role: 'user', |
| 136 | content: prompt |
| 137 | } |
| 138 | ] |
| 139 | }, |
| 140 | { |
| 141 | streamMode: ['updates', 'messages'], |
| 142 | subgraphs: true, |
| 143 | signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'document')) |
| 144 | } |
| 145 | ) |
| 146 | |
| 147 | let messageBuffer = '' |
| 148 | let latestAssistantStateText = '' |
| 149 | for await (const chunk of stream as AsyncIterable<unknown>) { |
| 150 | if (!Array.isArray(chunk) || chunk.length < 3) continue |
| 151 | const mode = chunk[1] |
| 152 | const data = chunk[2] |
| 153 | |
| 154 | if (mode === 'updates') { |
| 155 | const assistantTexts = extractAssistantTextsFromState(data) |
| 156 | const longestText = assistantTexts.sort((a, b) => b.length - a.length)[0] || '' |
| 157 | if (longestText.length >= latestAssistantStateText.length) { |
| 158 | latestAssistantStateText = longestText |
| 159 | } |
| 160 | continue |
| 161 | } |
| 162 | |
| 163 | if (mode !== 'messages' || !Array.isArray(data)) continue |
| 164 | for (const message of data as Array<Record<string, unknown>>) { |
| 165 | const content = extractModelText(message).trim() |
| 166 | if (content) messageBuffer += content |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | if (latestAssistantStateText.length > messageBuffer.length) { |
| 171 | return latestAssistantStateText |
| 172 | } |
| 173 | return messageBuffer |
| 174 | } |
| 175 | |
| 176 | function parseStyleImportResponse(response: unknown): StyleParseResult { |
| 177 | const text = extractModelText(response) || (typeof response === 'string' ? response : JSON.stringify(response)) |
| 178 | const jsonText = extractJsonBlock(text).trim() |
| 179 | if (!jsonText) throw new Error('LLM 返回格式异常:未找到 JSON') |
| 180 | |
| 181 | let parsed: Record<string, unknown> |
| 182 | try { |
| 183 | parsed = JSON.parse(jsonText) |
| 184 | } catch (parseError) { |
| 185 | const hint = jsonText.length > 200 ? `${jsonText.slice(0, 200)}...` : jsonText |
| 186 | const reason = parseError instanceof Error ? parseError.message : String(parseError) |
| 187 | log.warn('[styles:parseFile] JSON parse failed', { reason, jsonPreview: hint }) |
| 188 | throw new Error(`LLM 返回的 JSON 格式异常:${reason}`) |
| 189 | } |
| 190 | |
| 191 | const label = String(parsed.label || '').trim() |
| 192 | const styleSkill = String(parsed.styleSkill || '').trim() |
| 193 | if (!label || !styleSkill) { |
| 194 | throw new Error('LLM 返回缺少必填字段(label / styleSkill)') |
| 195 | } |
| 196 | |
| 197 | return { |
| 198 | label, |
| 199 | description: String(parsed.description || '').trim(), |
| 200 | category: String(parsed.category || '自定义').trim(), |
| 201 | aliases: Array.isArray(parsed.aliases) |
| 202 | ? parsed.aliases.map((item) => String(item || '').trim()).filter((item) => item.length > 0) |
| 203 | : [], |
| 204 | styleSkill, |
| 205 | styleCase: String(parsed.styleCase || '').trim() |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | function extractAssistantTextsFromState(data: unknown): string[] { |
| 210 | const texts: string[] = [] |
| 211 | const seen = new Set<object>() |
| 212 | const getObject = (value: unknown): Record<string, unknown> | null => |
| 213 | value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null |
| 214 | |
| 215 | const visit = (value: unknown): void => { |
| 216 | if (!value || typeof value !== 'object') return |
| 217 | if (seen.has(value as object)) return |
| 218 | seen.add(value as object) |
| 219 | |
| 220 | if (Array.isArray(value)) { |
| 221 | value.forEach(visit) |
| 222 | return |
| 223 | } |
| 224 | |
| 225 | const record = value as Record<string, unknown> |
| 226 | const role = String(record.role || '').toLowerCase() |
| 227 | const type = String(record.type || '').toLowerCase() |
| 228 | const constructorName = String( |
| 229 | getObject(record.lc_kwargs)?.type ?? getObject(record.kwargs)?.type ?? '' |
| 230 | ).toLowerCase() |
| 231 | const isAssistant = |
| 232 | role === 'assistant' || type === 'ai' || type === 'assistant' || constructorName === 'ai' |
| 233 | const isToolOrHuman = |
| 234 | role === 'tool' || |
| 235 | role === 'user' || |
| 236 | role === 'system' || |
| 237 | type === 'tool' || |
| 238 | type === 'human' || |
| 239 | type === 'system' |
| 240 | if (!isAssistant || isToolOrHuman) { |
| 241 | for (const nested of Object.values(record)) { |
| 242 | if (nested && typeof nested === 'object') visit(nested) |
| 243 | } |
| 244 | return |
| 245 | } |
| 246 | const text = extractModelText(record).trim() |
| 247 | if (text) texts.push(text) |
| 248 | |
| 249 | for (const nested of Object.values(record)) { |
| 250 | if (nested && typeof nested === 'object') visit(nested) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | visit(data) |
| 255 | return texts |
| 256 | } |
| 257 |