返回 oh-my-ppt
file.ts
根目录 / src / main / styles / import / file.ts
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
257 lines TYPESCRIPT