返回 oh-my-ppt
source-prepare.ts
根目录 / src / main / thinking / source-prepare.ts
1 import { createRequire } from 'module'
2 import fs from 'fs'
3 import path from 'path'
4 import log from 'electron-log/main.js'
5 import { nanoid } from 'nanoid'
6 import { HumanMessage } from '@langchain/core/messages'
7 import { resolveModelTimeoutMs } from '@shared/model-timeout'
8 import { isSupportedImageMimeType, normalizeImageMimeType } from '@shared/image-mime'
9 import { resolveModel } from '../agent'
10 import type { ModelRuntimeConfig } from '../agent'
11 import { extractModelText } from '../ipc/utils'
12 import type { ThinkingSource } from '@shared/thinking'
13
14 const require = createRequire(import.meta.url)
15 const mammoth = require('mammoth') as typeof import('mammoth')
16 const TurndownService = require('turndown') as new (options?: Record<string, unknown>) => {
17 use: (plugin: unknown) => void
18 turndown: (html: string) => string
19 }
20 const { gfm } = require('@joplin/turndown-plugin-gfm') as { gfm: unknown }
21
22 const NULL_CHAR_PATTERN = new RegExp(String.fromCharCode(0), 'g')
23
24 const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.text', '.csv', '.docx'])
25 const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
26 const MAX_FILE_SIZE = 10 * 1024 * 1024
27 const MAX_IMAGE_SIZE = 5 * 1024 * 1024
28
29 const stripControlChars = (value: string): string =>
30 value.replace(NULL_CHAR_PATTERN, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
31
32 const compactText = (value: string): string =>
33 stripControlChars(value)
34 .split('\n')
35 .map((line) => line.replace(/[ \t]+/g, ' ').trim())
36 .join('\n')
37 .replace(/\n{4,}/g, '\n\n\n')
38 .trim()
39
40 const stripInlineImagesFromHtml = (html: string): string =>
41 html.replace(/<img\b[^>]*>/gi, (tag) => {
42 const alt = tag.match(/\balt=(["'])(.*?)\1/i)?.[2]?.trim()
43 return alt ? `<p>[图片:${alt}]</p>` : ''
44 })
45
46 const stripMarkdownDataImages = (markdown: string): string =>
47 markdown.replace(/!\[[^\]]*]\(data:[^)]+\)/gi, '').replace(/!\[[^\]]*]\(\s*\)/g, '')
48
49 const convertDocxToMarkdown = async (filePath: string): Promise<string> => {
50 const result = await mammoth.convertToHtml({ path: filePath })
51 const turndown = new TurndownService({
52 headingStyle: 'atx',
53 bulletListMarker: '-',
54 codeBlockStyle: 'fenced'
55 })
56 turndown.use(gfm)
57 return compactText(
58 stripMarkdownDataImages(turndown.turndown(stripInlineImagesFromHtml(result.value)))
59 )
60 }
61
62 const toSafeFileName = (value: string): string =>
63 value
64 .replace(/[\\/:"*?<>|]+/g, '-')
65 .replace(/\s+/g, '-')
66 .replace(/^-+|-+$/g, '')
67 .slice(0, 80) || 'source'
68
69 const mimeTypeFromExtension = (ext: string): string => {
70 if (ext === '.png') return 'image/png'
71 if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
72 if (ext === '.webp') return 'image/webp'
73 return ''
74 }
75
76 const buildImageTextExtractionPrompt = (name: string): string =>
77 [
78 `请只识别这张图片中的文字、数字、表格、图表标签、界面文案或可直接引用的数据。图片文件名:${name}`,
79 '',
80 '严格要求:',
81 '- 只做文字/数据识别,不做视觉理解、图片摘要、用途建议或风格分析。',
82 '- 不要描述配色、构图、审美、质感、版式、插画风格或设计方向。',
83 '- 不要判断这张图适合做封面、插图、风格参考或证据素材。',
84 '- 不要编造图片中不存在的文字、数字或事实。',
85 '- 如果没有可识别文字或数据,只输出“未识别到明确文字内容”。',
86 '',
87 '请按以下 Markdown 小节输出:',
88 '## Extracted Text',
89 '## Extracted Data'
90 ].join('\n')
91
92 const buildFallbackImageTextExtraction = (name: string): string =>
93 [
94 '## Extracted Text',
95 `图片 ${name} 已上传,但未识别到明确文字内容。`,
96 '',
97 '## Extracted Data',
98 '- 未识别到明确数据。'
99 ].join('\n')
100
101 export type SourceKind = ThinkingSource['kind']
102
103 export interface PreparedSource {
104 id: string
105 name: string
106 kind: SourceKind
107 sourcePath: string
108 assetsPath?: string
109 }
110
111 export interface ImageTextExtractionOptions {
112 provider: string
113 apiKey: string
114 model: string
115 baseUrl: string
116 maxTokens?: number
117 modelRuntime?: ModelRuntimeConfig
118 modelTimeoutMs: number
119 }
120
121 const IMAGE_TEXT_MARKER = '<!-- image-text-extracted -->'
122
123 function detectKind(ext: string): SourceKind {
124 if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) return 'image'
125 if (ext === '.docx') return 'docx'
126 if (ext === '.md') return 'markdown'
127 if (ext === '.csv') return 'csv'
128 return 'text'
129 }
130
131 async function extractImageText(args: {
132 filePath: string
133 name: string
134 ext: string
135 options: ImageTextExtractionOptions
136 }): Promise<string> {
137 const mimeType = normalizeImageMimeType(mimeTypeFromExtension(args.ext))
138 if (!isSupportedImageMimeType(mimeType)) {
139 throw new Error(`不支持的图片格式:${mimeType || 'unknown'}`)
140 }
141 const imageBase64 = await fs.promises.readFile(args.filePath, 'base64')
142 const imageBytes = Buffer.byteLength(imageBase64, 'base64')
143 log.info('[thinking:image-text] invoke thinking image text model', {
144 provider: args.options.provider,
145 model: args.options.model,
146 mimeType,
147 imageBytes
148 })
149 const model = resolveModel(
150 args.options.provider,
151 args.options.apiKey,
152 args.options.model,
153 args.options.baseUrl,
154 0.1,
155 args.options.maxTokens,
156 args.options.modelRuntime
157 )
158 const result = await model.invoke(
159 [
160 new HumanMessage({
161 content: [
162 { type: 'text', text: buildImageTextExtractionPrompt(args.name) },
163 { type: 'image_url', image_url: { url: `data:${mimeType};base64,${imageBase64}` } }
164 ]
165 })
166 ],
167 {
168 signal: AbortSignal.timeout(resolveModelTimeoutMs(args.options.modelTimeoutMs, 'document'))
169 }
170 )
171 const response = extractModelText(result)
172 return response.trim() || buildFallbackImageTextExtraction(args.name)
173 }
174
175 const parseImageAssetPath = (content: string): string => {
176 const match = content.match(/^- thinkingAssetPath:\s*(.+)$/m)
177 return match?.[1]?.trim() || ''
178 }
179
180 export async function extractPendingImageTextSources(
181 thinkingDir: string,
182 options: ImageTextExtractionOptions
183 ): Promise<void> {
184 const sourcesDir = path.join(thinkingDir, 'sources')
185 if (!fs.existsSync(sourcesDir)) return
186
187 const entries = await fs.promises.readdir(sourcesDir, { withFileTypes: true })
188 for (const entry of entries) {
189 if (!entry.isFile() || !entry.name.endsWith('.image.md')) continue
190 const sourcePath = path.join(sourcesDir, entry.name)
191 const content = await fs.promises.readFile(sourcePath, 'utf-8')
192 if (content.includes(IMAGE_TEXT_MARKER)) continue
193
194 const imagePath = parseImageAssetPath(content)
195 if (!imagePath || !fs.existsSync(imagePath)) continue
196
197 try {
198 const extractedText = await extractImageText({
199 filePath: imagePath,
200 name: path.basename(imagePath),
201 ext: path.extname(imagePath).toLowerCase(),
202 options
203 })
204 await fs.promises.writeFile(
205 sourcePath,
206 [
207 content.trimEnd(),
208 '',
209 IMAGE_TEXT_MARKER,
210 '## Notes',
211 '- 以下内容仅来自图片中的文字/数据识别,不包含风格、配色、构图或用途分析。',
212 '',
213 extractedText.trim()
214 ].join('\n') + '\n',
215 'utf-8'
216 )
217 } catch (err) {
218 log.warn('[thinking:source-prepare] image text extraction failed', {
219 source: entry.name,
220 message: err instanceof Error ? err.message : String(err)
221 })
222 await fs.promises.writeFile(
223 sourcePath,
224 [
225 content.trimEnd(),
226 '',
227 IMAGE_TEXT_MARKER,
228 '## Extracted Text',
229 '- 图片文字识别失败。',
230 '',
231 '## Extracted Data',
232 '- 未识别到明确数据。'
233 ].join('\n') + '\n',
234 'utf-8'
235 )
236 }
237 }
238 }
239
240 export async function prepareSourceFile(
241 filePath: string,
242 thinkingDir: string
243 ): Promise<PreparedSource> {
244 const resolved = path.resolve(filePath)
245 const stat = await fs.promises.stat(resolved)
246 if (!stat.isFile()) throw new Error(`Not a file: ${resolved}`)
247
248 const ext = path.extname(resolved).toLowerCase()
249 const isImage = SUPPORTED_IMAGE_EXTENSIONS.has(ext)
250
251 if (!SUPPORTED_EXTENSIONS.has(ext) && !isImage) {
252 throw new Error(`Unsupported file type: ${ext}`)
253 }
254
255 if (isImage && stat.size > MAX_IMAGE_SIZE) {
256 throw new Error(`Image file too large (max 5MB): ${path.basename(resolved)}`)
257 }
258 if (!isImage && stat.size > MAX_FILE_SIZE) {
259 throw new Error(`File too large (max 10MB): ${path.basename(resolved)}`)
260 }
261
262 const sourcesDir = path.join(thinkingDir, 'sources')
263 const assetsDir = path.join(thinkingDir, 'assets')
264 await fs.promises.mkdir(sourcesDir, { recursive: true })
265 await fs.promises.mkdir(assetsDir, { recursive: true })
266
267 const kind = detectKind(ext)
268 const baseName = path.basename(resolved, ext)
269 const safeName = toSafeFileName(baseName)
270 const stamp = Date.now()
271 const uid = nanoid(8)
272
273 const id = `${stamp}-${uid}-${safeName}`
274 let sourcePath: string
275 let assetsPath: string | undefined
276
277 if (kind === 'docx') {
278 const mdName = `${id}.md`
279 sourcePath = path.join(sourcesDir, mdName)
280 const markdown = await convertDocxToMarkdown(resolved)
281 await fs.promises.writeFile(
282 sourcePath,
283 [`# ${baseName}`, '', `> Converted from Word .docx`, '', markdown].join('\n'),
284 'utf-8'
285 )
286 } else if (kind === 'image') {
287 const imgName = `${id}${ext}`
288 const mdName = `${id}.image.md`
289 sourcePath = path.join(sourcesDir, mdName)
290 assetsPath = path.join(assetsDir, imgName)
291 await fs.promises.copyFile(resolved, assetsPath)
292 await fs.promises.writeFile(
293 sourcePath,
294 [
295 `# 图片:${path.basename(resolved)}`,
296 '',
297 '## Asset',
298 `- assetId: ${id}`,
299 `- fileName: ${imgName}`,
300 `- originalPath: ${resolved}`,
301 `- thinkingAssetPath: ${assetsPath}`,
302 `- thinkingPublicPath: assets/${imgName}`,
303 '- sessionAssetPath: (set during generation copy)',
304 '- publicPath: (set during generation copy)',
305 '',
306 '## Notes',
307 '- 图片已复制到素材库,上传阶段不进行识别或解析。',
308 '- 用户发送消息后才会识别图片中的文字/数据;不会解析图片风格。'
309 ].join('\n'),
310 'utf-8'
311 )
312 } else {
313 const fileName = `${id}${ext}`
314 sourcePath = path.join(sourcesDir, fileName)
315 await fs.promises.copyFile(resolved, sourcePath)
316 }
317
318 log.info('[thinking:source-prepare] prepared', {
319 kind,
320 name: path.basename(resolved),
321 id
322 })
323
324 return {
325 id,
326 name: path.basename(resolved),
327 kind,
328 sourcePath,
329 assetsPath
330 }
331 }
332
333 export async function prepareMultipleSources(
334 filePaths: string[],
335 thinkingDir: string
336 ): Promise<PreparedSource[]> {
337 const results: PreparedSource[] = []
338 for (const filePath of filePaths) {
339 results.push(await prepareSourceFile(filePath, thinkingDir))
340 }
341 return results
342 }
343
343 lines TYPESCRIPT