返回 oh-my-ppt
document-parse-handlers.ts
根目录 / src / main / io / document-parse-handlers.ts
1 import { ipcMain } from 'electron'
2 import fs from 'fs'
3 import { createRequire } from 'module'
4 import path from 'path'
5 import log from 'electron-log/main.js'
6 import { nanoid } from 'nanoid'
7 import pLimit from 'p-limit'
8 import { extractJsonBlock, extractModelText, resolveModel } from '../agent-runtime/model'
9 import type { ModelRuntimeConfig } from '../agent-runtime/model'
10 import type { IpcContext } from '../ipc/context'
11 import type {
12 DocumentPlanPageSkeletonItem,
13 ParseDocumentPlanPayload,
14 ParseImageReferencePayload,
15 ParsedDocumentPlanResult,
16 PrepareReferenceDocumentPayload,
17 PreparedReferenceDocumentResult
18 } from '@shared/generation'
19 import { isSectionAgendaReason } from '@shared/generation'
20 import { resolveModelTimeoutMs } from '@shared/model-timeout'
21 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
22 import { assertImageWasRead, isImageUnsupportedError } from '../styles/import/image'
23 import { invokeVisionModelText } from '../agent-runtime/provider/vision'
24 import { normalizeGeneratedPlan as normalizeDocumentPlan } from './document-plan-normalizer'
25 import { convertCsvTextToMarkdown } from './document-csv-to-markdown'
26 import {
27 deriveOutlinePageCandidates,
28 estimateOutlinePageCount,
29 formatDocumentOutlineScanForPrompt,
30 scanDocumentOutline,
31 scanHasMultipleSlideCandidates,
32 scanHeadingTitles,
33 type DocumentOutlinePageCandidate,
34 type DocumentOutlineScan
35 } from './document-outline-scan'
36 import {
37 buildDocumentPlanPageSkeleton,
38 sanitizeDocumentPlanPageSkeletonContent
39 } from './document-plan-page-skeleton'
40
41 type PreparedSourceFile = ParsedDocumentPlanResult['files'][number] & {
42 originalPath: string
43 workspacePath: string
44 virtualPath: string
45 }
46
47 const MAX_DOCUMENT_FILES = 1
48 const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024
49 const MAX_PAGE_COUNT = 500
50 const MAX_PARSE_SOURCE_PREVIEW_CHARS = 20_000
51 const PAGE_SUMMARY_BATCH_SIZE = 10
52 const PAGE_SUMMARY_BATCH_CONCURRENCY = 2
53 const PAGE_SUMMARY_BATCH_START_DELAY_MS = 200
54 const PAGE_SUMMARY_BATCH_MAX_ATTEMPTS = 3
55 const MAX_PAGE_SUMMARY_PASSAGE_CHARS = 1_600
56 const MAX_PAGE_SUMMARY_TOTAL_PAGES = 300
57 const MAX_PAGE_SUMMARY_CHARS = 80
58
59 const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.text', '.csv', '.docx'])
60 const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
61 const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
62 '.png': 'image/png',
63 '.jpg': 'image/jpeg',
64 '.jpeg': 'image/jpeg',
65 '.webp': 'image/webp'
66 }
67 const NULL_CHAR_PATTERN = new RegExp(String.fromCharCode(0), 'g')
68 const CJK_PATTERN = /[\u3400-\u9fff]/
69 const LATIN_WORD_PATTERN = /\b[A-Za-z][A-Za-z'-]{2,}\b/g
70
71 class RetryableDocumentPlanQualityError extends Error {
72 constructor(message: string) {
73 super(message)
74 this.name = 'RetryableDocumentPlanQualityError'
75 }
76 }
77
78 type PageSummaryTarget = {
79 id: string
80 item: DocumentPlanPageSkeletonItem
81 passage: string
82 }
83
84 const require = createRequire(import.meta.url)
85 const mammoth = require('mammoth') as typeof import('mammoth')
86 const TurndownService = require('turndown') as new (options?: Record<string, unknown>) => {
87 use: (plugin: unknown) => void
88 turndown: (html: string) => string
89 }
90 const { gfm } = require('@joplin/turndown-plugin-gfm') as { gfm: unknown }
91
92 const stripControlChars = (value: string): string =>
93 value.replace(NULL_CHAR_PATTERN, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
94
95 const compactText = (value: string): string =>
96 stripControlChars(value)
97 .split('\n')
98 .map((line) => line.replace(/[ \t]+/g, ' ').trim())
99 .join('\n')
100 .replace(/\n{4,}/g, '\n\n\n')
101 .trim()
102
103 const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
104
105 const countCjkChars = (value: string): number =>
106 Array.from(value).filter((char) => CJK_PATTERN.test(char)).length
107
108 const countLatinWords = (value: string): number => value.match(LATIN_WORD_PATTERN)?.length ?? 0
109
110 const isMostlyEnglishText = (value: string): boolean => {
111 const sample = value.slice(0, 30_000)
112 const latinWords = countLatinWords(sample)
113 const cjkChars = countCjkChars(sample)
114 return latinWords >= 30 && cjkChars <= Math.max(10, latinWords * 0.08)
115 }
116
117 const isMostlyChineseText = (value: string): boolean => {
118 const sample = value.slice(0, 30_000)
119 const latinWords = countLatinWords(sample)
120 const cjkChars = countCjkChars(sample)
121 return cjkChars >= 50 && cjkChars > latinWords
122 }
123
124 const ENGLISH_BRIEF_LABEL_PATTERN =
125 /(?:^|\n)\s*(?:Presentation\s*goal|Presentationgoal|Audience\s*\/\s*context|Audiencecontext|Core\s*argument|Coreargument|Recommended\s*outline|Recommendedoutline|Per[-\s]*page\s*points|Per-pagepoints|Perpagepoints|Facts\s*\/\s*metrics\s*\/\s*terms\s*to\s*preserve|Facts\/metrics\/termstopreserve|Factsmetricstermstopreserve|Style\s*or\s*expression\s*notes|Styleorexpressionnotes|Page\s*\d{1,2})\s*[::]/i
126
127 const assertPlanLanguageMatchesSource = async (args: {
128 file: PreparedSourceFile
129 plan: Pick<ParsedDocumentPlanResult, 'topic' | 'briefText'>
130 userText: string
131 }): Promise<void> => {
132 if (args.file.type === 'image') return
133 if (countCjkChars(args.userText) >= 6) return
134
135 const sourceText = await fs.promises.readFile(args.file.workspacePath, 'utf-8').catch(() => '')
136 const outputText = `${args.plan.topic}\n${args.plan.briefText}`
137
138 if (isMostlyEnglishText(sourceText) && countCjkChars(outputText) >= 12) {
139 throw new RetryableDocumentPlanQualityError(
140 'The source document is primarily English, but topic/briefText were returned in Chinese. Return topic and briefText in English; do not translate the outline into Chinese.'
141 )
142 }
143
144 if (isMostlyChineseText(sourceText) && ENGLISH_BRIEF_LABEL_PATTERN.test(args.plan.briefText)) {
145 throw new RetryableDocumentPlanQualityError(
146 '源文档主要是中文,但 briefText 使用了英文结构标签。请用中文结构标签返回,例如:演示目标、受众/场景、核心观点、建议大纲、每页要点、必须保留的事实/指标/术语、风格/表达要求。不要使用 Presentation goal、Audience/context、Core argument、Recommended outline、Per-page points、Page 1 等英文模板标签。'
147 )
148 }
149 }
150
151 const stripInlineImagesFromHtml = (html: string): string =>
152 html.replace(/<img\b[^>]*>/gi, (tag) => {
153 const alt = tag.match(/\balt=(["'])(.*?)\1/i)?.[2]?.trim()
154 return alt ? `<p>[图片:${alt}]</p>` : ''
155 })
156
157 const stripMarkdownDataImages = (markdown: string): string =>
158 markdown.replace(/!\[[^\]]*]\(data:[^)]+\)/gi, '').replace(/!\[[^\]]*]\(\s*\)/g, '')
159
160 const convertDocxToMarkdown = async (filePath: string): Promise<string> => {
161 const result = await mammoth.convertToHtml({ path: filePath })
162 if (result.messages.length > 0) {
163 log.info('[documents:parsePlan] mammoth warnings', {
164 filePath,
165 messages: result.messages.map((message) => message.message)
166 })
167 }
168 const turndown = new TurndownService({
169 headingStyle: 'atx',
170 bulletListMarker: '-',
171 codeBlockStyle: 'fenced'
172 })
173 turndown.use(gfm)
174 return compactText(
175 stripMarkdownDataImages(turndown.turndown(stripInlineImagesFromHtml(result.value)))
176 )
177 }
178
179 const toSafeFileName = (value: string): string =>
180 value
181 .replace(/[\\/:"*?<>|]+/g, '-')
182 .replace(/\s+/g, '-')
183 .replace(/^-+|-+$/g, '')
184 .slice(0, 80) || 'source'
185
186 const prepareSourceFile = async (
187 file: { path?: unknown; name?: unknown },
188 workspaceDir: string
189 ): Promise<PreparedSourceFile> => {
190 const rawPath = typeof file.path === 'string' ? file.path.trim() : ''
191 if (!rawPath) throw new Error('无法读取文档路径')
192 const filePath = path.resolve(rawPath)
193 const stat = await fs.promises.stat(filePath)
194 if (!stat.isFile()) throw new Error(`文档不是文件: ${filePath}`)
195 if (stat.size > MAX_DOCUMENT_SIZE) throw new Error('单个文档不能超过 10MB')
196
197 const ext = path.extname(filePath).toLowerCase()
198 const isImage = SUPPORTED_IMAGE_EXTENSIONS.has(ext)
199 if (!SUPPORTED_EXTENSIONS.has(ext) && !isImage) {
200 throw new Error('暂只支持 md、txt、csv、docx 文档,以及 png、jpg、jpeg、webp 图片')
201 }
202 log.info('[documents:parsePlan] read source file', {
203 fileName: path.basename(filePath),
204 extension: ext,
205 size: stat.size
206 })
207
208 const name =
209 typeof file.name === 'string' && file.name.trim().length > 0
210 ? file.name.trim()
211 : path.basename(filePath)
212 let type: PreparedSourceFile['type'] = isImage
213 ? 'image'
214 : ext === '.docx'
215 ? 'docx'
216 : ext === '.md'
217 ? 'markdown'
218 : ext === '.csv'
219 ? 'csv'
220 : 'text'
221
222 const safeBaseName = toSafeFileName(path.basename(name, ext))
223 const stamp = Date.now()
224 const uniqueId = nanoid(8)
225 const workspaceName =
226 ext === '.docx' || ext === '.csv'
227 ? `${stamp}-${uniqueId}-${safeBaseName || 'source'}.md`
228 : `${stamp}-${uniqueId}-${safeBaseName}${ext}`
229 const workspacePath = path.join(workspaceDir, workspaceName)
230 let characterCount = stat.size
231
232 if (isImage) {
233 if (path.resolve(filePath) !== path.resolve(workspacePath)) {
234 await fs.promises.copyFile(filePath, workspacePath)
235 }
236 log.info('[documents:parsePlan] image source prepared for vision', {
237 originalName: name,
238 workspaceName,
239 size: stat.size
240 })
241 } else if (ext === '.docx') {
242 const markdown = await convertDocxToMarkdown(filePath)
243 if (!markdown) throw new Error(`${name} 未解析出可用文本`)
244 await fs.promises.writeFile(
245 workspacePath,
246 [
247 `# ${path.basename(name, ext)}`,
248 '',
249 '> Converted from Word .docx for agent reading. Inline images were omitted; image alt text may be preserved when available.',
250 '',
251 markdown
252 ].join('\n'),
253 'utf-8'
254 )
255 characterCount = markdown.length
256 log.info('[documents:parsePlan] docx converted for reading', {
257 originalName: name,
258 workspaceName,
259 characterCount
260 })
261 } else if (ext === '.csv') {
262 const csvText = await fs.promises.readFile(filePath, 'utf-8')
263 const markdown = convertCsvTextToMarkdown(csvText, {
264 title: path.basename(name, ext)
265 })
266 if (!markdown) throw new Error(`${name} 未解析出可用文本`)
267 await fs.promises.writeFile(workspacePath, markdown, 'utf-8')
268 type = 'markdown'
269 characterCount = markdown.length
270 log.info('[documents:parsePlan] csv converted for reading', {
271 originalName: name,
272 workspaceName,
273 characterCount
274 })
275 } else {
276 if (path.resolve(filePath) !== path.resolve(workspacePath)) {
277 await fs.promises.copyFile(filePath, workspacePath)
278 }
279 log.info('[documents:parsePlan] text source prepared for reading', {
280 originalName: name,
281 workspaceName,
282 characterCount
283 })
284 }
285
286 return {
287 name,
288 type,
289 characterCount,
290 path: workspacePath,
291 originalPath: filePath,
292 workspacePath,
293 virtualPath: `/${workspaceName}`
294 }
295 }
296
297 const resolveOutlineScanFormat = (file: PreparedSourceFile): DocumentOutlineScan['format'] => {
298 if (file.type === 'csv') return 'csv'
299 if (file.type === 'text') return 'text'
300 return 'markdown'
301 }
302
303 const scanPreparedSourceOutline = async (
304 file: PreparedSourceFile
305 ): Promise<{
306 scan: DocumentOutlineScan
307 pageCandidates: DocumentOutlinePageCandidate[]
308 } | null> => {
309 if (file.type === 'image') {
310 log.info('[documents:parsePlan] document outline scan skipped', {
311 sourceVirtualPath: file.virtualPath,
312 reason: 'image-source'
313 })
314 return null
315 }
316 const content = await fs.promises.readFile(file.workspacePath, 'utf-8').catch((error) => {
317 log.warn('[documents:parsePlan] document outline scan read failed', {
318 sourceVirtualPath: file.virtualPath,
319 message: error instanceof Error ? error.message : String(error)
320 })
321 return ''
322 })
323 if (!content.trim()) {
324 log.info('[documents:parsePlan] document outline scan skipped', {
325 sourceVirtualPath: file.virtualPath,
326 reason: 'empty-source'
327 })
328 return null
329 }
330 const scan = scanDocumentOutline(content, resolveOutlineScanFormat(file))
331 const pageCandidates = deriveOutlinePageCandidates(scan)
332 log.info('[documents:parsePlan] document outline scanned', {
333 sourceVirtualPath: file.virtualPath,
334 format: scan.format,
335 headingCount: scan.headingCount,
336 topLevelTitle: scan.topLevelTitle,
337 pageCandidateCount: pageCandidates.length,
338 splitHintCount: scan.recommendedSplitHints.length,
339 headingPreview: scanHeadingTitles(scan).slice(0, 15),
340 splitHintsPreview: scan.recommendedSplitHints.slice(0, 5)
341 })
342 return { scan, pageCandidates }
343 }
344
345 const assertPlanMatchesDocumentOutline = (args: {
346 scan: DocumentOutlineScan | null
347 pageCandidates: DocumentOutlinePageCandidate[]
348 plan: Pick<ParsedDocumentPlanResult, 'pageCount' | 'briefText'>
349 }): void => {
350 if (!args.scan || !scanHasMultipleSlideCandidates(args.scan)) return
351 if (args.plan.pageCount <= 1) {
352 throw new RetryableDocumentPlanQualityError(
353 'The source document has multiple Markdown/source sections, but the plan collapsed it to one slide. Rebuild the outline from the document heading structure and infer a multi-slide pageCount.'
354 )
355 }
356 const pageCountEstimate = estimateOutlinePageCount(args.scan, args.pageCandidates)
357 if (
358 args.pageCandidates.length > 0 &&
359 pageCountEstimate &&
360 args.plan.pageCount !== pageCountEstimate.preferredPageCount
361 ) {
362 throw new RetryableDocumentPlanQualityError(
363 `The source document scan provided an authoritative page candidate skeleton of ${pageCountEstimate.preferredPageCount} slides, but the plan returned pageCount=${args.plan.pageCount}. Rebuild the outline from the page candidate skeleton without compressing or expanding it.`
364 )
365 }
366 if (
367 pageCountEstimate &&
368 (args.plan.pageCount < pageCountEstimate.minPageCount ||
369 args.plan.pageCount > pageCountEstimate.maxPageCount)
370 ) {
371 throw new RetryableDocumentPlanQualityError(
372 `The source document structure suggests ${pageCountEstimate.preferredPageCount} slides with acceptable range ${pageCountEstimate.minPageCount}-${pageCountEstimate.maxPageCount}, but the plan returned pageCount=${args.plan.pageCount}. Rebuild the outline using the deterministic source-structure page-count estimate.`
373 )
374 }
375
376 const briefText = args.plan.briefText
377 if (hasOutlinePageCandidateSkeleton(args.pageCandidates)) return
378
379 const hasSourceHeadingLabel =
380 /源文档结构|来源标题|Source document structure|Source heading/i.test(briefText)
381 const headingTitles = scanHeadingTitles(args.scan)
382 const mentionedHeadingCount = headingTitles.filter((title) => briefText.includes(title)).length
383 if (!hasSourceHeadingLabel && mentionedHeadingCount < Math.min(2, headingTitles.length)) {
384 throw new RetryableDocumentPlanQualityError(
385 'The source document has a heading structure, but briefText does not preserve source headings. Include a compact source-structure section and source heading for each page entry.'
386 )
387 }
388 }
389
390 const isDocumentOutlineQualityError = (error: unknown): boolean =>
391 error instanceof RetryableDocumentPlanQualityError &&
392 /multiple Markdown\/source sections|heading structure|source-structure page-count estimate|page candidate skeleton/i.test(
393 error.message
394 )
395
396 const hasOutlinePageCandidateSkeleton = (pageCandidates: DocumentOutlinePageCandidate[]): boolean =>
397 pageCandidates.length > 0
398
399 const formatLightweightPageCandidateSkeletonForPrompt = (args: {
400 scan: DocumentOutlineScan | null
401 pageCandidates: DocumentOutlinePageCandidate[]
402 }): string => {
403 if (!args.scan || args.pageCandidates.length === 0) return ''
404 return [
405 'Document structure scan:',
406 `- Format: ${args.scan.format}`,
407 `- Markdown headings detected: ${args.scan.headingCount}`,
408 args.scan.topLevelTitle ? `- Top-level title: ${args.scan.topLevelTitle}` : '',
409 `- Authoritative page candidate skeleton: ${args.pageCandidates.length} slides.`,
410 ...args.pageCandidates.map(
411 (candidate, index) =>
412 ` ${index + 1}. [${candidate.role}] ${candidate.sourceHeading} (lines ${candidate.lineStart}-${candidate.lineEnd})`
413 )
414 ]
415 .filter(Boolean)
416 .join('\n')
417 }
418
419 const normalizeLightweightGeneratedPlan = (
420 rawText: string,
421 fallback: {
422 topic: string
423 pageCount: number
424 }
425 ): Pick<ParsedDocumentPlanResult, 'topic' | 'pageCount' | 'briefText'> => {
426 const parsed = (() => {
427 try {
428 return JSON.parse(extractJsonBlock(rawText)) as unknown
429 } catch {
430 return null
431 }
432 })()
433 const record =
434 parsed && typeof parsed === 'object' && !Array.isArray(parsed)
435 ? (parsed as Record<string, unknown>)
436 : {}
437 const topic =
438 typeof record.topic === 'string' && record.topic.trim() ? record.topic.trim() : fallback.topic
439 const rawPageCount = Number(record.pageCount ?? record.page_count ?? fallback.pageCount)
440 const pageCount = Number.isFinite(rawPageCount)
441 ? Math.min(MAX_PAGE_COUNT, Math.max(1, Math.round(rawPageCount)))
442 : fallback.pageCount
443 if (!topic) throw new Error('文档解析完成,但模型未返回 topic')
444 return {
445 topic,
446 pageCount,
447 briefText: ''
448 }
449 }
450
451 const formatPageSkeletonBriefText = (args: {
452 topic: string
453 pageSkeleton: DocumentPlanPageSkeletonItem[]
454 }): string => {
455 const useChineseLabels = CJK_PATTERN.test(
456 `${args.topic}\n${args.pageSkeleton.map((item) => `${item.title}\n${item.reason}`).join('\n')}`
457 )
458 const outlineLabel = useChineseLabels ? '建议大纲' : 'Recommended outline'
459 const summaryLabel = useChineseLabels ? '简要总结' : 'Brief summary'
460 const pageLabel = useChineseLabels ? '第' : 'Page'
461 const pageSuffix = useChineseLabels ? ' 页' : ''
462
463 return [
464 `## ${outlineLabel}`,
465 ...args.pageSkeleton.map(
466 (item) =>
467 `### ${pageLabel} ${item.pageNumber}${pageSuffix}: ${item.title}\n\n- **${summaryLabel}:** ${item.reason || item.title}`
468 )
469 ].join('\n\n')
470 }
471
472 const buildSingleShotDocumentPlanPrompt = (args: {
473 topic: string
474 existingBrief: string
475 file: PreparedSourceFile
476 outlineScan: DocumentOutlineScan | null
477 pageCandidates: DocumentOutlinePageCandidate[]
478 sourcePreview: string
479 sourcePreviewLimit: number
480 sourcePreviewTruncated: boolean
481 retryHint?: string
482 }): string => {
483 const useLightweightSourcePlan = hasOutlinePageCandidateSkeleton(args.pageCandidates)
484
485 return [
486 'Turn the uploaded document into the fixed JSON needed by the PPT creation form.',
487 'This is a single-shot document parsing task. The host already scanned the source document with Markdown/GFM AST when possible.',
488 'Do not ask to read the file, do not mention tools, and do not reconstruct the source document.',
489 useLightweightSourcePlan
490 ? 'Use the page candidate skeleton as the authoritative outline. Do not need source body text during parsing; later slide generation will read source passages by line range.'
491 : 'No authoritative page candidate skeleton is available. Use the bounded source preview and any structure scan to infer a concise source-ordered page outline.',
492 '',
493 'Return only a JSON object. Do not return Markdown, explanations, or extra fields.',
494 useLightweightSourcePlan
495 ? 'Use exactly these fields: topic, pageCount. Do not include briefText.'
496 : 'Use exactly these fields: topic, pageCount, briefText.',
497 '',
498 'Output language rules:',
499 '- Use the dominant language of the source structure, user topic, and existing brief.',
500 useLightweightSourcePlan
501 ? ''
502 : '- If the source structure is primarily Chinese, use Chinese labels in briefText.',
503 useLightweightSourcePlan
504 ? ''
505 : '- If the source structure is primarily English, use English labels in briefText.',
506 '- Keep proper nouns, product names, technical terms, quoted text, and metrics in their original form when appropriate.',
507 '',
508 'Field rules:',
509 '- topic: a concise title suitable for the creation form topic input.',
510 `- pageCount: an integer from 1 to ${MAX_PAGE_COUNT}.`,
511 useLightweightSourcePlan
512 ? '- Return pageCount equal to the page candidate skeleton count.'
513 : '- Infer pageCount from source structure, information density, paragraphs, lists, tables, and semantic transitions. Do not return 1 for ordinary multi-section documents.',
514 useLightweightSourcePlan
515 ? '- Per-page summaries are generated by a later batch pass using source line ranges. Do not write any outline or page summaries now.'
516 : '- briefText: a compact page skeleton, not a detailed fact summary.',
517 useLightweightSourcePlan
518 ? ''
519 : '- briefText must include source document structure, recommended outline, and per-page points.',
520 useLightweightSourcePlan
521 ? ''
522 : '- Recommended outline must contain exactly pageCount numbered items.',
523 useLightweightSourcePlan
524 ? ''
525 : '- Per-page points must contain exactly pageCount page entries.',
526 useLightweightSourcePlan
527 ? ''
528 : '- Each page entry should include: page title, page role, source anchor when available, and one short page purpose.',
529 '- Do not write detailed facts, metrics, scripts, examples, risks, or per-page summaries during parsing. Later slide generation will inspect source passages again.',
530 '- Preserve source order and hierarchy. Do not rewrite the source into a generic storyline, marketing narrative, consulting framework, or inspirational theme.',
531 '- Keep chapter divider slides as standalone section-divider pages and include a role marker such as 页面角色:章节页 or Page role: chapter divider.',
532 '- Do not add agenda/background/outlook/summary/next-step pages unless present in the source skeleton or requested by the user.',
533 '',
534 args.outlineScan ? 'Host-provided document structure:' : '',
535 args.outlineScan
536 ? useLightweightSourcePlan
537 ? formatLightweightPageCandidateSkeletonForPrompt({
538 scan: args.outlineScan,
539 pageCandidates: args.pageCandidates
540 })
541 : formatDocumentOutlineScanForPrompt(args.outlineScan, args.pageCandidates)
542 : '',
543 '',
544 args.sourcePreview ? 'Bounded source preview for unstructured parsing:' : '',
545 args.sourcePreviewTruncated
546 ? `The preview is capped at ${args.sourcePreviewLimit} characters. Use it conservatively with the structure scan; do not invent unsupported later-section details.`
547 : '',
548 args.sourcePreview ? '```text' : '',
549 args.sourcePreview,
550 args.sourcePreview ? '```' : '',
551 args.retryHint
552 ? useLightweightSourcePlan
553 ? `\nRetry requirement: the previous output failed validation because: ${args.retryHint}. Fix this issue. Return topic and pageCount only, with pageCount exactly matching the page candidate skeleton.`
554 : `\nRetry requirement: the previous output failed validation because: ${args.retryHint}. Fix this issue. Ensure briefText is non-empty and pageCount exactly matches the page-level outline and per-page points.`
555 : '',
556 args.topic
557 ? `\nUser-provided topic: ${args.topic}`
558 : '\nThe user did not provide a topic; infer it from the document structure.',
559 args.existingBrief ? `\nExisting user brief:\n${args.existingBrief}` : '',
560 `\nSource document path for later generation only: ${args.file.virtualPath}`,
561 '',
562 'Return format examples:',
563 useLightweightSourcePlan
564 ? '{"topic":"直播与短视频自然流增长:汽车经销商新媒体实战指南","pageCount":65}'
565 : '',
566 useLightweightSourcePlan
567 ? ''
568 : 'For Chinese source: {"topic":"直播与短视频自然流增长——汽车经销商新媒体实战指南","pageCount":65,"briefText":"演示目标:...\\n源文档结构:...\\n建议大纲:\\n1. 手册结构导航\\n2. 阅读角色指引\\n每页要点:\\n第 1 页:手册结构导航\\n页面角色:内容页\\n来源标题:### 手册结构导航\\n来源范围:lines 17-32\\n页面目的:说明本手册的结构导航。"}',
569 useLightweightSourcePlan
570 ? ''
571 : 'For English source: {"topic":"Product Launch Readiness Review","pageCount":8,"briefText":"Presentation goal: ...\\nSource document structure: ...\\nRecommended outline:\\n1. Launch Readiness\\nPer-page points:\\nPage 1: Launch Readiness\\nPage role: content\\nSource heading: ## Launch Readiness\\nSource range: lines 10-32\\nPage purpose: Anchor the launch readiness section."}'
572 ].join('\n')
573 }
574
575 const runSingleShotDocumentPlanModel = async (args: {
576 provider: string
577 apiKey: string
578 model: string
579 baseUrl: string
580 maxTokens: number | undefined
581 modelRuntime: ModelRuntimeConfig
582 modelTimeoutMs: number
583 file: PreparedSourceFile
584 outlineScan: DocumentOutlineScan | null
585 pageCandidates: DocumentOutlinePageCandidate[]
586 topic: string
587 existingBrief: string
588 retryHint?: string
589 }): Promise<string> => {
590 const client = resolveModel(
591 args.provider,
592 args.apiKey,
593 args.model,
594 args.baseUrl,
595 0.2,
596 args.maxTokens,
597 args.modelRuntime
598 )
599 const sourceText = await fs.promises.readFile(args.file.workspacePath, 'utf-8')
600 const pageCandidateCount = args.pageCandidates.length
601 const useLightweightSourcePlan = hasOutlinePageCandidateSkeleton(args.pageCandidates)
602 const sourcePreview = useLightweightSourcePlan
603 ? ''
604 : sourceText.slice(0, MAX_PARSE_SOURCE_PREVIEW_CHARS)
605 const sourcePreviewTruncated =
606 !useLightweightSourcePlan && sourceText.length > sourcePreview.length
607 const prompt = buildSingleShotDocumentPlanPrompt({
608 topic: args.topic,
609 existingBrief: args.existingBrief,
610 file: args.file,
611 outlineScan: args.outlineScan,
612 pageCandidates: args.pageCandidates,
613 sourcePreview,
614 sourcePreviewLimit: MAX_PARSE_SOURCE_PREVIEW_CHARS,
615 sourcePreviewTruncated,
616 retryHint: args.retryHint
617 })
618 log.info('[documents:parsePlan] single-shot model invoke', {
619 sourceVirtualPath: args.file.virtualPath,
620 headingCount: args.outlineScan?.headingCount ?? 0,
621 pageCandidateCount,
622 sourceLength: sourceText.length,
623 sourcePreviewLength: sourcePreview.length,
624 sourcePreviewTruncated,
625 promptLength: prompt.length
626 })
627 const result = await client.invoke(
628 [
629 {
630 role: 'system' as const,
631 content: useLightweightSourcePlan
632 ? 'You are a document-to-PPT-creation-form parser. You have no filesystem tools in this call. Use the host-provided structure scan. Return strict JSON only: topic, pageCount.'
633 : 'You are a document-to-PPT-creation-form parser. You have no filesystem tools in this call. Use the host-provided structure scan and any bounded source preview. Return strict JSON only: topic, pageCount, briefText.'
634 },
635 {
636 role: 'user' as const,
637 content: prompt
638 }
639 ],
640 {
641 signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'document'))
642 }
643 )
644 return extractModelText(result)
645 }
646
647 const extractSourcePassageByLines = (
648 sourceLines: string[],
649 item: DocumentPlanPageSkeletonItem
650 ): string => {
651 const start = Math.max(1, Math.floor(item.lineStart || 1))
652 const end = Math.max(start, Math.floor(item.lineEnd || start))
653 const text = sourceLines
654 .slice(start - 1, end)
655 .map((line) =>
656 line
657 .replace(/^\s{0,3}#{1,6}\s+/, '')
658 .replace(/^\s*(?:[-*+]|\d+[.)、.])\s+/, '')
659 .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
660 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
661 .replace(/[*_`~>|]+/g, ' ')
662 .replace(/[ \t]+/g, ' ')
663 .trim()
664 )
665 .filter(Boolean)
666 .join(' ')
667 .trim()
668 return text.length > MAX_PAGE_SUMMARY_PASSAGE_CHARS
669 ? `${text.slice(0, MAX_PAGE_SUMMARY_PASSAGE_CHARS).trim()}\n...[truncated]`
670 : text
671 }
672
673 const buildPageSummaryBatchPrompt = (args: {
674 topic: string
675 targets: PageSummaryTarget[]
676 retryHint?: string
677 }): string =>
678 [
679 'Summarize source passages for PPT page suggestions.',
680 'The host has already selected exact source line ranges for each page. Use only the provided passages.',
681 'Return strict JSON only: {"items":[{"id":"page-1","pageNumber":1,"summary":"..."}]}',
682 '',
683 'Rules:',
684 '- Return exactly one non-empty summary for every page listed.',
685 '- Preserve each page ID exactly as provided. Do not renumber pages within this batch.',
686 '- Write a very brief factual summary for each page, grounded in its source passage when body text is available.',
687 `- Each summary must be very concise and at most ${MAX_PAGE_SUMMARY_CHARS} characters.`,
688 '- Preserve important facts, metrics, terms, names, and source language.',
689 '- Do not invent missing facts. If the passage has no body text beyond a heading, summarize the page role or section based on the page title and source heading instead of returning an empty summary.',
690 '- Keep each summary short enough for an editable PPT creation dialog.',
691 args.retryHint
692 ? `Retry requirement: the previous response was invalid because: ${args.retryHint}. Return all listed IDs exactly once in this attempt.`
693 : '',
694 '',
695 args.topic ? `Deck topic: ${args.topic}` : '',
696 '',
697 'Pages:',
698 ...args.targets.map((target) =>
699 [
700 `ID: ${target.id}`,
701 `Page ${target.item.pageNumber}: ${target.item.title}`,
702 `Role: ${target.item.role}`,
703 `Source heading: ${target.item.sourceHeading}`,
704 `Source lines: ${target.item.lineStart}-${target.item.lineEnd}`,
705 `Passage: ${target.passage || '(No body text was extracted for this page.)'}`
706 ].join('\n')
707 )
708 ]
709 .filter(Boolean)
710 .join('\n\n')
711
712 const readPageSummaryItems = (
713 responseText: string,
714 allowedTargets: Map<string, number>
715 ): Map<number, string> => {
716 const parsed = (() => {
717 try {
718 return JSON.parse(extractJsonBlock(responseText)) as unknown
719 } catch {
720 return null
721 }
722 })()
723 const rawItems =
724 parsed && typeof parsed === 'object' && !Array.isArray(parsed)
725 ? (parsed as Record<string, unknown>).items
726 : parsed
727 if (!Array.isArray(rawItems)) return new Map()
728
729 const result = new Map<number, string>()
730 for (const rawItem of rawItems) {
731 if (!rawItem || typeof rawItem !== 'object') continue
732 const record = rawItem as Record<string, unknown>
733 const id = typeof record.id === 'string' ? record.id.trim() : ''
734 const pageNumber = id ? allowedTargets.get(id) : undefined
735 const summary =
736 typeof record.summary === 'string'
737 ? record.summary.replace(/\s+/g, ' ').trim().slice(0, MAX_PAGE_SUMMARY_CHARS).trim()
738 : ''
739 if (!pageNumber || !summary) continue
740 result.set(pageNumber, summary)
741 }
742 return result
743 }
744
745 const missingPageSummaryIds = (
746 batch: PageSummaryTarget[],
747 summaries: Map<number, string>
748 ): string[] =>
749 batch.filter((target) => !summaries.get(target.item.pageNumber)).map((target) => target.id)
750
751 const summarizePageSummaryBatch = async (args: {
752 batch: PageSummaryTarget[]
753 batchIndex: number
754 topic: string
755 client: ReturnType<typeof resolveModel>
756 modelTimeoutMs: number
757 sourceVirtualPath: string
758 totalSummaryTargets: number
759 waitForBatchStartSlot: () => Promise<void>
760 }): Promise<Map<number, string>> => {
761 let lastError: unknown = null
762 for (let attempt = 1; attempt <= PAGE_SUMMARY_BATCH_MAX_ATTEMPTS; attempt += 1) {
763 const retryHint = attempt > 1 && lastError instanceof Error ? lastError.message : undefined
764 const prompt = buildPageSummaryBatchPrompt({
765 topic: args.topic,
766 targets: args.batch,
767 retryHint
768 })
769 try {
770 await args.waitForBatchStartSlot()
771 log.info('[documents:parsePlan] page summary batch invoke', {
772 sourceVirtualPath: args.sourceVirtualPath,
773 batchIndex: args.batchIndex + 1,
774 attempt,
775 maxAttempts: PAGE_SUMMARY_BATCH_MAX_ATTEMPTS,
776 batchSize: args.batch.length,
777 totalSummaryTargets: args.totalSummaryTargets,
778 concurrency: PAGE_SUMMARY_BATCH_CONCURRENCY,
779 startDelayMs: PAGE_SUMMARY_BATCH_START_DELAY_MS,
780 promptLength: prompt.length,
781 hasRetryHint: Boolean(retryHint)
782 })
783 const result = await args.client.invoke(
784 [
785 {
786 role: 'system' as const,
787 content:
788 'You summarize source passages for PPT page suggestions. Return strict JSON only.'
789 },
790 {
791 role: 'user' as const,
792 content: prompt
793 }
794 ],
795 {
796 signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'document'))
797 }
798 )
799 const batchSummaries = readPageSummaryItems(
800 extractModelText(result),
801 new Map(args.batch.map((target) => [target.id, target.item.pageNumber]))
802 )
803 const missingIds = missingPageSummaryIds(args.batch, batchSummaries)
804 if (missingIds.length === 0) return batchSummaries
805 throw new Error(`模型摘要返回缺少页面 ID: ${missingIds.join(', ')}`)
806 } catch (error) {
807 lastError = error
808 log.warn('[documents:parsePlan] page summary batch attempt failed', {
809 sourceVirtualPath: args.sourceVirtualPath,
810 batchIndex: args.batchIndex + 1,
811 attempt,
812 maxAttempts: PAGE_SUMMARY_BATCH_MAX_ATTEMPTS,
813 batchSize: args.batch.length,
814 message: error instanceof Error ? error.message : String(error)
815 })
816 }
817 }
818 throw lastError || new Error('模型摘要批次失败')
819 }
820
821 const summarizePageSkeletonContentInBatches = async (args: {
822 provider: string
823 apiKey: string
824 model: string
825 baseUrl: string
826 maxTokens: number | undefined
827 modelRuntime: ModelRuntimeConfig
828 modelTimeoutMs: number
829 file: PreparedSourceFile
830 topic: string
831 pageSkeleton: DocumentPlanPageSkeletonItem[]
832 }): Promise<DocumentPlanPageSkeletonItem[]> => {
833 if (args.file.type === 'image' || args.pageSkeleton.length === 0) return args.pageSkeleton
834 const sourceText = await fs.promises.readFile(args.file.workspacePath, 'utf-8')
835 const sourceLines = stripControlChars(sourceText).split('\n')
836 const summaryTargets = args.pageSkeleton
837 .filter((item) => !isSectionAgendaReason(item.reason))
838 .slice(0, MAX_PAGE_SUMMARY_TOTAL_PAGES)
839 .map((item) => ({
840 id: item.id || `page-${item.pageNumber}`,
841 item,
842 passage: extractSourcePassageByLines(sourceLines, item)
843 }))
844 .sort((a, b) => a.item.pageNumber - b.item.pageNumber)
845 if (summaryTargets.length === 0) return args.pageSkeleton
846
847 const client = resolveModel(
848 args.provider,
849 args.apiKey,
850 args.model,
851 args.baseUrl,
852 0.1,
853 args.maxTokens,
854 args.modelRuntime
855 )
856 const summaries = new Map<number, string>()
857 const batches: PageSummaryTarget[][] = []
858
859 for (let index = 0; index < summaryTargets.length; index += PAGE_SUMMARY_BATCH_SIZE) {
860 batches.push(summaryTargets.slice(index, index + PAGE_SUMMARY_BATCH_SIZE))
861 }
862
863 const limit = pLimit(PAGE_SUMMARY_BATCH_CONCURRENCY)
864 let nextBatchStartAt = Date.now()
865 const waitForBatchStartSlot = async (): Promise<void> => {
866 const now = Date.now()
867 const waitMs = Math.max(0, nextBatchStartAt - now)
868 nextBatchStartAt = Math.max(now, nextBatchStartAt) + PAGE_SUMMARY_BATCH_START_DELAY_MS
869 if (waitMs > 0) await sleep(waitMs)
870 }
871
872 await Promise.all(
873 batches.map((batch, batchIndex) =>
874 limit(async () => {
875 try {
876 const batchSummaries = await summarizePageSummaryBatch({
877 batch,
878 batchIndex,
879 topic: args.topic,
880 client,
881 modelTimeoutMs: args.modelTimeoutMs,
882 sourceVirtualPath: args.file.virtualPath,
883 totalSummaryTargets: summaryTargets.length,
884 waitForBatchStartSlot
885 })
886 batchSummaries.forEach((summary, pageNumber) => summaries.set(pageNumber, summary))
887 } catch (error) {
888 log.warn('[documents:parsePlan] page summary batch failed, keeping existing summaries', {
889 sourceVirtualPath: args.file.virtualPath,
890 batchIndex: batchIndex + 1,
891 maxAttempts: PAGE_SUMMARY_BATCH_MAX_ATTEMPTS,
892 batchSize: batch.length,
893 message: error instanceof Error ? error.message : String(error)
894 })
895 }
896 })
897 )
898 )
899
900 if (args.pageSkeleton.length > summaryTargets.length) {
901 log.info('[documents:parsePlan] page summary target capped', {
902 sourceVirtualPath: args.file.virtualPath,
903 summarizedPages: summaryTargets.length,
904 totalPages: args.pageSkeleton.length
905 })
906 }
907
908 return args.pageSkeleton.map((item) => ({
909 ...item,
910 reason: summaries.get(item.pageNumber) || item.reason || item.title
911 }))
912 }
913
914 const buildImageDocumentPlanPrompt = (args: {
915 topic: string
916 existingBrief: string
917 fileName: string
918 retryHint?: string
919 }): string =>
920 [
921 'Analyze the attached image or screenshot and produce the fixed structure needed by the PPT creation form.',
922 'The image is attached to this same message as a multimodal image block. Do not look for a file upload tool, file path, or external attachment.',
923 'You must directly inspect the attached image content before answering.',
924 '',
925 'Return only a JSON object. Do not return Markdown, explanations, or extra fields.',
926 'Use exactly these fields: topic, pageCount, briefText.',
927 '',
928 'Interpretation rules:',
929 '- If the image is a slide, dashboard, poster, whiteboard, document screenshot, product screenshot, chart, or design mockup, infer the presentation topic and outline from visible text, chart labels, layout, and visual context.',
930 '- If visible text is limited, produce a conservative editable brief based on what can be observed. Do not invent exact numbers or facts that are not visible.',
931 '- Preserve visible names, metrics, labels, dates, and terminology when they are readable.',
932 '- Mention uncertainty explicitly inside briefText when image content is ambiguous.',
933 '- Treat the image as an input reference only. Do not assume the original image will be available during later slide generation.',
934 '- Therefore briefText must fully capture both the content reference and the visual style reference needed for generation.',
935 '',
936 'Output language rules:',
937 '- Use the dominant language visible in the image and the latest user-provided topic/brief.',
938 '- If the user explicitly asks for a language, use that language.',
939 '- If the image is primarily Chinese, use Chinese section labels such as 演示目标、受众/场景、核心观点、建议大纲、每页要点、必须保留的事实/指标/术语、风格/表达要求.',
940 '- If the image is primarily English, use English section labels.',
941 '',
942 'Field rules:',
943 '- topic: a concise title suitable for the creation form topic input.',
944 `- pageCount: an integer from 1 to ${MAX_PAGE_COUNT}.`,
945 '- pageCount means the target number of PPT slides to generate from this image/reference. It is not the number of attached images.',
946 '- Use pageCount=1 only for a single simple visual with one presentation point; if the image contains multiple sections, panels, metrics, or a document-like screenshot, infer a multi-slide deck.',
947 '- briefText: a concise but structured outline suitable for the creation form detailed-brief input.',
948 '- briefText should include presentation goal, audience/context, core argument, recommended outline, per-page points, facts/metrics/terms to preserve, and visual/style reference.',
949 '- visual/style reference should cover approximate colors, background, typography feel, layout density, alignment, cards/shapes/borders/shadows, chart style, image/illustration style, and any mood or motion guidance that would help recreate the look.',
950 '- The recommended outline and per-page points should align with pageCount.',
951 '- Infer the target PPT slide count from the image structure. Do not return 1 merely because one image was attached.',
952 args.retryHint
953 ? `\nRetry requirement: the previous output failed validation because: ${args.retryHint}. Fix this issue. Ensure briefText is non-empty and pageCount matches the page-level outline.`
954 : '',
955 args.topic
956 ? `\nUser-provided topic: ${args.topic}`
957 : '\nThe user did not provide a topic; infer it from the image.',
958 args.existingBrief ? `\nExisting user brief:\n${args.existingBrief}` : '',
959 `\nImage file name: ${args.fileName}`,
960 '',
961 'Return format examples:',
962 '{"topic":"AI动漫产业发展分析","pageCount":7,"briefText":"演示目标:...\\n受众/场景:...\\n核心观点:...\\n建议大纲:\\n1. ...\\n每页要点:\\n第 1 页:...\\n必须保留的事实/指标/术语:...\\n风格/表达要求:..."}',
963 '{"topic":"Product Launch Readiness Review","pageCount":8,"briefText":"Presentation goal: ...\\nAudience/context: ...\\nCore argument: ...\\nRecommended outline:\\n1. ...\\nPer-page points:\\nPage 1: ...\\nFacts/metrics/terms to preserve: ...\\nStyle or expression notes: ..."}'
964 ].join('\n')
965
966 // Image plan parsing is for creation-form suggestions and writes a structured
967 // reference file from the accepted plan.
968 const writeImagePlanReferenceFile = async (args: {
969 file: PreparedSourceFile
970 plan: Pick<ParsedDocumentPlanResult, 'topic' | 'pageCount' | 'briefText'>
971 }): Promise<PreparedSourceFile> => {
972 const ext = path.extname(args.file.workspacePath).toLowerCase()
973 const mdPath = args.file.workspacePath.replace(/\.[^.]+$/, '.image.md')
974 const briefText = compactText(args.plan.briefText)
975 if (!briefText) throw new Error('图片解析完成,但模型未返回可用参考内容')
976 const useChineseLabels = isMostlyChineseText(`${args.plan.topic}\n${briefText}`)
977 const markdown = [
978 `# ${path.basename(args.file.name, ext) || (useChineseLabels ? '图片参考' : 'Image reference')}`,
979 '',
980 `> Source image: ${args.file.name}`,
981 '> This file was generated after the user explicitly parsed the uploaded image, so later generation can use it as text reference.',
982 '',
983 `## ${useChineseLabels ? '主题' : 'Topic'}`,
984 '',
985 args.plan.topic,
986 '',
987 `## ${useChineseLabels ? '建议页数' : 'Suggested page count'}`,
988 '',
989 String(args.plan.pageCount),
990 '',
991 `## ${useChineseLabels ? '图片解析参考' : 'Image analysis reference'}`,
992 '',
993 briefText
994 ].join('\n')
995 await fs.promises.writeFile(mdPath, markdown, 'utf-8')
996
997 return {
998 ...args.file,
999 name: `${args.file.name}.image.md`,
1000 type: 'markdown',
1001 characterCount: markdown.length,
1002 path: mdPath,
1003 workspacePath: mdPath,
1004 virtualPath: `/${path.basename(mdPath)}`
1005 }
1006 }
1007
1008 // Image plan parsing is for creation-form suggestions: topic/pageCount/briefText.
1009 // This separate image-reference path only converts an image into readable source notes.
1010 const buildImageReferenceMarkdownPrompt = (fileName: string): string =>
1011 [
1012 'Analyze the attached image or screenshot and convert it into a readable Markdown reference document.',
1013 'The image is attached to this same message as a multimodal image block. Directly inspect the image before answering.',
1014 '',
1015 'Return Markdown only. Do not return JSON. Do not include task explanations.',
1016 'Do not generate a PPT outline, page count, slide plan, or creation-form suggestions. Only organize what can be read or observed from the image.',
1017 '',
1018 `# 图片参考:${fileName}`,
1019 '',
1020 'Required sections:',
1021 '## 可见文字',
1022 '- Transcribe readable text, headings, labels, chart labels, names, metrics, dates, and terminology. Keep the original language.',
1023 '- Preserve line breaks or hierarchy when they are visible.',
1024 '## 内容整理',
1025 '- Organize the observed content into concise Markdown bullets or tables when helpful.',
1026 '- Mark uncertain or unreadable items clearly instead of guessing.',
1027 '## 视觉信息',
1028 '- Briefly describe visible layout, chart/table/UI structure, colors, and other visual cues that may help later generation.',
1029 '',
1030 'Rules:',
1031 '- Do not invent exact numbers or facts that are not visible.',
1032 '- If text is unreadable, say it is unreadable.',
1033 '- If the image is mainly visual with little text, describe only the observable visual content.'
1034 ].join('\n')
1035
1036 const convertImageReferenceToMarkdown = async (args: {
1037 file: PreparedSourceFile
1038 provider: string
1039 apiKey: string
1040 model: string
1041 baseUrl: string
1042 maxTokens: number | undefined
1043 modelRuntime: ModelRuntimeConfig
1044 modelTimeoutMs: number
1045 }): Promise<PreparedSourceFile> => {
1046 const ext = path.extname(args.file.workspacePath).toLowerCase()
1047 const mimeType = IMAGE_MIME_BY_EXTENSION[ext]
1048 if (!mimeType) throw new Error('暂只支持 png、jpg、jpeg、webp 图片')
1049
1050 const imageBase64 = (await fs.promises.readFile(args.file.workspacePath)).toString('base64')
1051 let markdown = ''
1052 try {
1053 markdown = await invokeVisionModelText({
1054 imageBase64,
1055 mimeType,
1056 prompt: buildImageReferenceMarkdownPrompt(args.file.name),
1057 provider: args.provider,
1058 apiKey: args.apiKey,
1059 model: args.model,
1060 baseUrl: args.baseUrl,
1061 maxTokens: args.maxTokens,
1062 modelRuntime: args.modelRuntime,
1063 modelTimeoutMs: args.modelTimeoutMs,
1064 logTag: 'documents:parseImageReference'
1065 })
1066 } catch (error) {
1067 if (isImageUnsupportedError(error)) {
1068 throw new Error('当前模型不支持图片解析,请在设置中切换到支持多模态的模型')
1069 }
1070 throw error
1071 }
1072
1073 const content = compactText(markdown)
1074 assertImageWasRead(content)
1075 if (!content) throw new Error('图片解析完成,但模型未返回可用内容')
1076
1077 const mdPath = args.file.workspacePath.replace(/\.[^.]+$/, '.image.md')
1078 await fs.promises.writeFile(
1079 mdPath,
1080 [
1081 `# ${path.basename(args.file.name, ext) || '图片参考'}`,
1082 '',
1083 `> Source image: ${args.file.name}`,
1084 '> This file was generated after the user explicitly parsed the uploaded image into a readable Markdown reference.',
1085 '',
1086 content
1087 ].join('\n'),
1088 'utf-8'
1089 )
1090
1091 return {
1092 ...args.file,
1093 name: `${args.file.name}.image.md`,
1094 type: 'markdown',
1095 characterCount: content.length,
1096 path: mdPath,
1097 workspacePath: mdPath,
1098 virtualPath: `/${path.basename(mdPath)}`
1099 }
1100 }
1101
1102 const runImageDocumentPlanModel = async (args: {
1103 provider: string
1104 apiKey: string
1105 model: string
1106 baseUrl: string
1107 maxTokens: number | undefined
1108 modelRuntime: ModelRuntimeConfig
1109 modelTimeoutMs: number
1110 file: PreparedSourceFile
1111 topic: string
1112 existingBrief: string
1113 retryHint?: string
1114 }): Promise<string> => {
1115 const ext = path.extname(args.file.workspacePath).toLowerCase()
1116 const mimeType = IMAGE_MIME_BY_EXTENSION[ext]
1117 if (!mimeType) throw new Error('暂只支持 png、jpg、jpeg、webp 图片')
1118
1119 const imageBase64 = (await fs.promises.readFile(args.file.workspacePath)).toString('base64')
1120 const prompt = buildImageDocumentPlanPrompt({
1121 topic: args.topic,
1122 existingBrief: args.existingBrief,
1123 fileName: args.file.name,
1124 retryHint: args.retryHint
1125 })
1126 try {
1127 return await invokeVisionModelText({
1128 imageBase64,
1129 mimeType,
1130 prompt,
1131 provider: args.provider,
1132 apiKey: args.apiKey,
1133 model: args.model,
1134 baseUrl: args.baseUrl,
1135 maxTokens: args.maxTokens,
1136 modelRuntime: args.modelRuntime,
1137 modelTimeoutMs: args.modelTimeoutMs,
1138 logTag: 'documents:parsePlan:image'
1139 })
1140 } catch (error) {
1141 if (isImageUnsupportedError(error)) {
1142 throw new Error('当前模型不支持图片解析,请在设置中切换到支持多模态的模型')
1143 }
1144 throw error
1145 }
1146 }
1147
1148 export function registerDocumentParseHandlers(ctx: IpcContext): void {
1149 const { resolveStoragePath } = ctx
1150
1151 ipcMain.handle(
1152 'documents:prepareReference',
1153 async (_event, payload: PrepareReferenceDocumentPayload) => {
1154 const input = payload && typeof payload === 'object' ? payload : { files: [] }
1155 const files = Array.isArray(input.files) ? input.files.slice(0, MAX_DOCUMENT_FILES) : []
1156 if (files.length === 0) throw new Error('请先选择要附加的参考文件')
1157
1158 const docsDir = path.join(await resolveStoragePath(), 'docs')
1159 await fs.promises.mkdir(docsDir, { recursive: true })
1160 const preparedFiles = await Promise.all(files.map((file) => prepareSourceFile(file, docsDir)))
1161
1162 return {
1163 files: preparedFiles.map(({ name, type, characterCount, workspacePath }) => ({
1164 name,
1165 type,
1166 characterCount,
1167 path: workspacePath
1168 }))
1169 } satisfies PreparedReferenceDocumentResult
1170 }
1171 )
1172
1173 ipcMain.handle(
1174 'documents:parseImageReference',
1175 async (_event, payload: ParseImageReferencePayload) => {
1176 const input: Partial<ParseImageReferencePayload> =
1177 payload && typeof payload === 'object' ? payload : {}
1178 const rawFile = input.file && typeof input.file === 'object' ? input.file : null
1179 if (!rawFile) throw new Error('请先选择要解析的图片')
1180
1181 const docsDir = path.join(await resolveStoragePath(), 'docs')
1182 await fs.promises.mkdir(docsDir, { recursive: true })
1183 const sourceFile = await prepareSourceFile(rawFile, docsDir)
1184 if (sourceFile.type !== 'image') throw new Error('请选择 png、jpg、jpeg、webp 图片')
1185
1186 const activeModel = await resolveModelConfigForTask(ctx, {
1187 modelConfigId: input.modelConfigId,
1188 purpose: 'documents:parseImageReference'
1189 })
1190 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
1191 const referenceFile = await convertImageReferenceToMarkdown({
1192 file: sourceFile,
1193 provider: activeModel.provider,
1194 apiKey: activeModel.apiKey,
1195 model: activeModel.model,
1196 baseUrl: activeModel.baseUrl,
1197 maxTokens: activeModel.maxTokens,
1198 modelRuntime: ctx.modelRuntime,
1199 modelTimeoutMs: modelTimeouts.document
1200 })
1201
1202 return {
1203 files: [
1204 {
1205 name: referenceFile.name,
1206 type: referenceFile.type,
1207 characterCount: referenceFile.characterCount,
1208 path: referenceFile.workspacePath
1209 }
1210 ]
1211 } satisfies PreparedReferenceDocumentResult
1212 }
1213 )
1214
1215 ipcMain.handle('documents:parsePlan', async (_event, payload: ParseDocumentPlanPayload) => {
1216 const parseStartedAt = Date.now()
1217 const parseStartedAtIso = new Date(parseStartedAt).toISOString()
1218 let parseEndStatus: 'success' | 'error' = 'error'
1219 let parseEndSourceVirtualPath: string | null = null
1220 let parseEndPageCount: number | null = null
1221 let parseEndError: string | null = null
1222 try {
1223 const input = payload && typeof payload === 'object' ? payload : { files: [] }
1224 const files = Array.isArray(input.files) ? input.files.slice(0, MAX_DOCUMENT_FILES) : []
1225 if (files.length === 0) throw new Error('请先选择要解析的文档')
1226 log.info('[documents:parsePlan] invoke', {
1227 files: files.map((file) => ({
1228 name: typeof file.name === 'string' ? file.name : path.basename(String(file.path || '')),
1229 pathProvided: typeof file.path === 'string' && file.path.trim().length > 0
1230 })),
1231 startedAt: parseStartedAtIso
1232 })
1233
1234 const docsDir = path.join(await resolveStoragePath(), 'docs')
1235 await fs.promises.mkdir(docsDir, { recursive: true })
1236 const preparedFiles = await Promise.all(files.map((file) => prepareSourceFile(file, docsDir)))
1237 const [sourceFile] = preparedFiles
1238 if (!sourceFile) throw new Error('请先选择要解析的文档')
1239 parseEndSourceVirtualPath = sourceFile.virtualPath
1240 const outlineResult = await scanPreparedSourceOutline(sourceFile)
1241 const outlineScan = outlineResult?.scan ?? null
1242 const pageCandidates = outlineResult?.pageCandidates ?? []
1243 const pageCountEstimate = estimateOutlinePageCount(outlineScan, pageCandidates)
1244 if (pageCountEstimate) {
1245 log.info('[documents:parsePlan] document outline page-count estimate', {
1246 preferredPageCount: pageCountEstimate.preferredPageCount,
1247 minPageCount: pageCountEstimate.minPageCount,
1248 maxPageCount: pageCountEstimate.maxPageCount,
1249 basis: pageCountEstimate.basis,
1250 sourceVirtualPath: sourceFile.virtualPath
1251 })
1252 }
1253
1254 const activeModel = await resolveModelConfigForTask(ctx, {
1255 modelConfigId: input.modelConfigId,
1256 purpose: 'documents:parsePlan'
1257 })
1258 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
1259 const { provider, model, apiKey } = activeModel
1260 const baseUrl = activeModel.baseUrl
1261 const maxTokens = activeModel.maxTokens
1262 const modelTimeoutMs = modelTimeouts.document
1263
1264 const topic = typeof input.topic === 'string' ? input.topic.trim() : ''
1265 const existingBrief =
1266 typeof input.existingBrief === 'string' ? input.existingBrief.trim() : ''
1267 const fallbackPlan = {
1268 topic: topic || path.basename(sourceFile.name, path.extname(sourceFile.name)),
1269 pageCount: null,
1270 briefText: existingBrief
1271 }
1272 const MAX_ATTEMPTS = 2
1273 let plan: Pick<ParsedDocumentPlanResult, 'topic' | 'pageCount' | 'briefText'> | null = null
1274 let lastError: unknown = null
1275 const useLightweightSourcePlan =
1276 sourceFile.type !== 'image' && hasOutlinePageCandidateSkeleton(pageCandidates)
1277
1278 for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1279 const retryHint = attempt > 1 && lastError instanceof Error ? lastError.message : undefined
1280 const responseText = (
1281 sourceFile.type === 'image'
1282 ? await runImageDocumentPlanModel({
1283 provider,
1284 apiKey,
1285 model,
1286 baseUrl,
1287 maxTokens,
1288 modelRuntime: ctx.modelRuntime,
1289 modelTimeoutMs,
1290 file: sourceFile,
1291 topic,
1292 existingBrief,
1293 retryHint
1294 })
1295 : await runSingleShotDocumentPlanModel({
1296 provider,
1297 apiKey,
1298 model,
1299 baseUrl,
1300 maxTokens,
1301 modelRuntime: ctx.modelRuntime,
1302 modelTimeoutMs,
1303 file: sourceFile,
1304 outlineScan,
1305 pageCandidates,
1306 topic,
1307 existingBrief,
1308 retryHint
1309 })
1310 ).trim()
1311 if (!responseText) {
1312 lastError = new Error('文档解析完成,但模型未返回可用内容')
1313 log.warn('[documents:parsePlan] empty response', { attempt })
1314 continue
1315 }
1316 log.info('[documents:parsePlan] agent response received', {
1317 attempt,
1318 responseLength: responseText.length,
1319 sourceVirtualPath: sourceFile.virtualPath
1320 })
1321 try {
1322 const candidatePlan = useLightweightSourcePlan
1323 ? normalizeLightweightGeneratedPlan(responseText, {
1324 topic: fallbackPlan.topic,
1325 pageCount: pageCandidates.length
1326 })
1327 : normalizeDocumentPlan(responseText, fallbackPlan)
1328 log.info('[documents:parsePlan] normalized candidate plan', {
1329 attempt,
1330 pageCount: candidatePlan.pageCount,
1331 briefLength: candidatePlan.briefText.length,
1332 lightweightSourcePlan: useLightweightSourcePlan,
1333 outlineScanHeadingCount: outlineScan?.headingCount ?? 0,
1334 scanHasMultipleSlideCandidates: scanHasMultipleSlideCandidates(outlineScan)
1335 })
1336 if (sourceFile.type === 'image') {
1337 assertImageWasRead(`${candidatePlan.topic}\n${candidatePlan.briefText}`)
1338 }
1339 await assertPlanLanguageMatchesSource({
1340 file: sourceFile,
1341 plan: candidatePlan,
1342 userText: `${topic}\n${existingBrief}`
1343 })
1344 assertPlanMatchesDocumentOutline({
1345 scan: outlineScan,
1346 pageCandidates,
1347 plan: candidatePlan
1348 })
1349 plan = candidatePlan
1350 break
1351 } catch (error) {
1352 lastError = error
1353 if (
1354 error instanceof RetryableDocumentPlanQualityError &&
1355 attempt >= MAX_ATTEMPTS &&
1356 !isDocumentOutlineQualityError(error)
1357 ) {
1358 plan = useLightweightSourcePlan
1359 ? normalizeLightweightGeneratedPlan(responseText, {
1360 topic: fallbackPlan.topic,
1361 pageCount: pageCandidates.length
1362 })
1363 : normalizeDocumentPlan(responseText, fallbackPlan)
1364 log.warn(
1365 '[documents:parsePlan] quality check failed after retry, returning editable plan',
1366 {
1367 attempt,
1368 message: error.message,
1369 responsePreview: responseText.slice(0, 400)
1370 }
1371 )
1372 break
1373 }
1374 if (isDocumentOutlineQualityError(error) && attempt >= MAX_ATTEMPTS) {
1375 log.warn(
1376 '[documents:parsePlan] outline quality check failed after retry, rejecting plan',
1377 {
1378 attempt,
1379 message: error instanceof Error ? error.message : String(error),
1380 responsePreview: responseText.slice(0, 400)
1381 }
1382 )
1383 }
1384 log.warn(
1385 attempt < MAX_ATTEMPTS
1386 ? '[documents:parsePlan] normalize failed, will retry'
1387 : '[documents:parsePlan] normalize failed, no attempts left',
1388 {
1389 attempt,
1390 message: error instanceof Error ? error.message : String(error),
1391 responsePreview: responseText.slice(0, 400)
1392 }
1393 )
1394 }
1395 }
1396 if (!plan) throw lastError || new Error('文档解析完成,但模型未返回可用解析结果')
1397 const resultFiles =
1398 sourceFile.type === 'image'
1399 ? [await writeImagePlanReferenceFile({ file: sourceFile, plan })]
1400 : preparedFiles
1401
1402 const pageSkeletonBase = sanitizeDocumentPlanPageSkeletonContent({
1403 pageSkeleton: buildDocumentPlanPageSkeleton({
1404 scan: outlineScan,
1405 pageCandidates,
1406 pageCount: plan.pageCount
1407 })
1408 })
1409 const pageSkeleton = await summarizePageSkeletonContentInBatches({
1410 provider,
1411 apiKey,
1412 model,
1413 baseUrl,
1414 maxTokens,
1415 modelRuntime: ctx.modelRuntime,
1416 modelTimeoutMs,
1417 file: sourceFile,
1418 topic: plan.topic,
1419 pageSkeleton: pageSkeletonBase
1420 })
1421 const sourcePlan =
1422 pageSkeleton.length > 0
1423 ? {
1424 version: 1 as const,
1425 confidence: 'high' as const,
1426 sourceDocumentPath: resultFiles[0]?.virtualPath,
1427 sourceDocumentName: resultFiles[0]?.name,
1428 pageSkeleton
1429 }
1430 : undefined
1431 const resultPlan =
1432 useLightweightSourcePlan && pageSkeleton.length > 0
1433 ? {
1434 ...plan,
1435 briefText: formatPageSkeletonBriefText({
1436 topic: plan.topic,
1437 pageSkeleton
1438 })
1439 }
1440 : plan
1441 const result = {
1442 ...resultPlan,
1443 ...(pageSkeleton.length > 0 ? { pageSkeleton } : {}),
1444 ...(sourcePlan ? { sourcePlan } : {}),
1445 files: resultFiles.map(({ name, type, characterCount, workspacePath }) => ({
1446 name,
1447 type,
1448 characterCount,
1449 path: workspacePath
1450 }))
1451 } satisfies ParsedDocumentPlanResult
1452 parseEndStatus = 'success'
1453 parseEndPageCount = resultPlan.pageCount
1454 return result
1455 } catch (error) {
1456 parseEndError = error instanceof Error ? error.message : String(error)
1457 throw error
1458 } finally {
1459 const parseEndedAt = Date.now()
1460 log.info('[documents:parsePlan] end', {
1461 status: parseEndStatus,
1462 startedAt: parseStartedAtIso,
1463 endedAt: new Date(parseEndedAt).toISOString(),
1464 durationMs: parseEndedAt - parseStartedAt,
1465 sourceVirtualPath: parseEndSourceVirtualPath,
1466 pageCount: parseEndPageCount,
1467 error: parseEndError
1468 })
1469 }
1470 })
1471 }
1472
1472 lines TYPESCRIPT