返回 oh-my-ppt
agent-runner.ts
根目录 / src / main / generation / agent-runner.ts
1 /** Generation orchestration: LLM planning + DeepAgent execution. */
2 import fs from 'fs'
3 import pLimit from 'p-limit'
4 import log from 'electron-log/main.js'
5 import { createSessionDeckAgent, createSessionEditAgent } from '../agent-runtime/agent'
6 import { extractJsonBlock, extractModelText, resolveModel } from '../agent-runtime/model'
7 import type { GenerationAgentManager } from './context'
8 import type { ModelRuntimeConfig } from '../agent-runtime/model'
9 import {
10 buildDesignContractSystemPrompt,
11 buildDesignContractUserPrompt,
12 buildEditUserPrompt,
13 buildPlanningSystemPrompt,
14 buildPlanningUserPrompt,
15 buildSinglePageGenerationPrompt,
16 CONTENT_LANGUAGE_RULES
17 } from '../agent-runtime/prompt'
18 import type {
19 AnimationPreferencesPayload,
20 DeckEditScope,
21 DesignContract,
22 FontSelection,
23 GenerateChunkEvent,
24 OutlineItem,
25 SelectedElementRuntimeContext
26 } from '@shared/generation'
27 import { isSectionAgendaOutline } from '@shared/generation'
28 import { normalizeLayoutIntent, type LayoutIntent } from '@shared/layout-intent'
29 import { formatLayoutMasterPrompt, resolveLayoutMasterTemplate } from '@shared/layout-master'
30 import { resolveModelTimeoutMs, type ModelTimeoutProfile } from '@shared/model-timeout'
31 import { progressLabel, progressText } from '@shared/progress'
32 import type { SlideSizePreset } from '@shared/slide-size'
33 import { isPlaceholderPageHtml } from '../presentation/html/html-utils'
34 import {
35 assertFontFamilyAvailable,
36 buildAvailableFontsForPrompt,
37 type AvailableFont
38 } from '../presentation/fonts/font-registry'
39 import { sleep } from '../ipc/utils'
40 import {
41 createReferenceDocumentRetriever,
42 formatReferenceDocumentSnippets
43 } from './reference-document-retrieval'
44 import { logAgentToolEvents } from '../utils/agent-tool-logger'
45 import { normalizeKeyPoints, normalizeOutlineText } from './outline-normalizer'
46 import { buildLocalCompletedGenerationPageSummary } from './generation-summary'
47 import { readSessionLayoutLibrary } from '../session/master-service'
48
49 type AppLocale = 'zh' | 'en'
50
51 const uiText = (locale: AppLocale | undefined, zh: string, en: string): string =>
52 locale === 'en' ? en : zh
53
54 const resolveLayoutMasterOutlineItems = async (
55 projectDir: string,
56 outlineItems: OutlineItem[]
57 ): Promise<OutlineItem[]> => {
58 const layoutLibrary = (await readSessionLayoutLibrary(projectDir)).library
59 return outlineItems.map((item) => {
60 if (!item.layoutIntent) return item
61 const template = resolveLayoutMasterTemplate(layoutLibrary, item.layoutIntent)
62 return {
63 ...item,
64 layoutId: template.id,
65 layoutPrompt: formatLayoutMasterPrompt(template)
66 }
67 })
68 }
69
70 async function readPageHtmlIfExists(filePath: string): Promise<string> {
71 try {
72 return await fs.promises.readFile(filePath, 'utf-8')
73 } catch {
74 return ''
75 }
76 }
77
78 const modelCallSignal = (
79 timeoutMs: unknown,
80 profile: ModelTimeoutProfile,
81 upstreamSignal?: AbortSignal
82 ): AbortSignal => {
83 const timeoutSignal = AbortSignal.timeout(resolveModelTimeoutMs(timeoutMs, profile))
84 return upstreamSignal ? AbortSignal.any([timeoutSignal, upstreamSignal]) : timeoutSignal
85 }
86
87 // ── Shared agent stream processor ───────────────────────────────────────
88
89 interface DeckToolStatusChunk {
90 type?: string
91 label?: string
92 detail?: string
93 progress?: number
94 pageId?: string
95 agentName?: string
96 }
97
98 interface StreamProcessOptions {
99 emit?: (chunk: GenerateChunkEvent) => void
100 runId: string
101 stage: string
102 totalPages: number
103 provider: string
104 model: string
105 sessionId: string
106 workerLabel?: string
107 /**
108 * Called for each `deck_tool_status` custom chunk.
109 * Return `true` to break the stream loop (e.g. all pages written).
110 */
111 onCustom?: (custom: DeckToolStatusChunk) => boolean | void
112 /** Called when `updates.model` is detected — the model is actively thinking. */
113 onModelThinking?: (defaultProgress: number) => void
114 }
115
116 async function processAgentStreamCore(
117 stream: AsyncIterable<unknown>,
118 options: StreamProcessOptions
119 ): Promise<void> {
120 const { sessionId, workerLabel, onCustom, onModelThinking } = options
121 let firstChunkLogged = false
122 const seenToolEvents = new Set<string>()
123
124 for await (const chunk of stream) {
125 if (!firstChunkLogged) {
126 firstChunkLogged = true
127 log.info('[deepagent] stream first chunk', { sessionId, worker: workerLabel })
128 }
129 if (!Array.isArray(chunk) || chunk.length < 3) continue
130 const parts = chunk as unknown[]
131 const mode = parts[1] as string
132 const data = parts[2]
133
134 if (mode === 'updates') {
135 logAgentToolEvents(data, seenToolEvents, { tag: 'deepagent', source: 'updates' })
136 } else if (mode === 'messages') {
137 logAgentToolEvents(data, seenToolEvents, { tag: 'deepagent', source: 'messages' })
138 }
139
140 if (mode === 'custom' && data && typeof data === 'object') {
141 const custom = data as DeckToolStatusChunk
142 if (custom.type === 'deck_tool_status' && custom.label) {
143 const shouldBreak = onCustom?.(custom)
144 if (shouldBreak) break
145 }
146 continue
147 }
148
149 if (mode === 'updates' && data && typeof data === 'object') {
150 const updates = data as Record<string, unknown>
151 if (updates.model) {
152 onModelThinking?.(42)
153 }
154 continue
155 }
156 }
157 }
158
159 const normalizeDesignContract = (value: unknown): DesignContract => {
160 const record =
161 value && typeof value === 'object' && !Array.isArray(value)
162 ? (value as Record<string, unknown>)
163 : {}
164 const readText = (key: keyof Omit<DesignContract, 'palette'>): string => {
165 const text = String(record[key] ?? '')
166 .replace(/\s+/g, ' ')
167 .trim()
168 return text.length > 220 ? `${text.slice(0, 220).trimEnd()}…` : text
169 }
170 const paletteRaw = Array.isArray(record.palette) ? record.palette : []
171 const palette = paletteRaw
172 .map((item) => String(item ?? '').trim())
173 .filter((item) => item.length > 0)
174 .slice(0, 6)
175 return {
176 theme: readText('theme'),
177 background: readText('background'),
178 palette,
179 titleStyle: readText('titleStyle'),
180 layoutMotif: readText('layoutMotif'),
181 chartStyle: readText('chartStyle'),
182 shapeLanguage: readText('shapeLanguage'),
183 titleFont: readText('titleFont'),
184 bodyFont: readText('bodyFont')
185 }
186 }
187
188 const unwrapJsonLikeString = (value: string): string => {
189 const source = value.trim()
190 if (source.length < 2 || !source.startsWith('"') || !source.endsWith('"')) {
191 return source
192 }
193 const inner = source
194 .slice(1, -1)
195 .replace(/\\"/g, '"')
196 .replace(/\\n/g, '\n')
197 .replace(/\\r/g, '\r')
198 .replace(/\\t/g, '\t')
199 .trim()
200 return inner.startsWith('{') || inner.startsWith('[') || inner.startsWith('```') ? inner : source
201 }
202
203 const parseModelJson = (responseText: string, appLocale?: AppLocale): unknown => {
204 let source = responseText.trim()
205 let lastError: unknown
206
207 for (let attempt = 0; attempt < 6; attempt += 1) {
208 const candidates = Array.from(new Set([source, extractJsonBlock(source)]))
209 let decodedJsonString = false
210
211 for (const candidate of candidates) {
212 try {
213 const parsed = JSON.parse(candidate) as unknown
214 if (typeof parsed !== 'string') {
215 return parsed
216 }
217 source = parsed.trim()
218 lastError = null
219 decodedJsonString = true
220 break
221 } catch (err) {
222 lastError = err
223 }
224 }
225
226 if (decodedJsonString) {
227 continue
228 }
229
230 const unwrapped = unwrapJsonLikeString(source)
231 if (unwrapped !== source) {
232 source = unwrapped
233 continue
234 }
235
236 const block = extractJsonBlock(source)
237 if (block !== source) {
238 source = block
239 continue
240 }
241
242 break
243 }
244
245 const preview = source.length > 200 ? `${source.slice(0, 200)}…` : source
246 throw new Error(
247 uiText(
248 appLocale,
249 `LLM 返回的 JSON 解析失败: ${lastError instanceof Error ? lastError.message : String(lastError)}. 原始文本预览: ${preview}`,
250 `Failed to parse JSON returned by the LLM: ${lastError instanceof Error ? lastError.message : String(lastError)}. Raw text preview: ${preview}`
251 )
252 )
253 }
254
255 const buildPlanningRetryUserPrompt = (
256 userPrompt: string,
257 totalPages: number,
258 previousError: string
259 ): string =>
260 [
261 userPrompt,
262 '',
263 'Planning retry requirement:',
264 `- The previous planning response failed validation: ${previousError}`,
265 `- Retry now and return exactly ${totalPages} items.`,
266 '- Return only a raw JSON array. Do not wrap it in Markdown. Do not add explanations.',
267 '- Each item must have exactly these fields: title, keyPoints, layoutIntent.',
268 '- keyPoints must be an array with 1-10 short strings.'
269 ].join('\n')
270
271 const buildDesignContractRetryUserPrompt = (userPrompt: string, previousError: string): string =>
272 [
273 userPrompt,
274 '',
275 'Design contract retry requirement:',
276 `- The previous design contract response failed validation: ${previousError}`,
277 '- Retry now and return only a raw JSON object. Do not wrap it in Markdown. Do not add explanations.',
278 '- Use exactly these fields: theme, background, palette, titleStyle, layoutMotif, chartStyle, shapeLanguage, titleFont, bodyFont.',
279 '- palette must be an array with 3-6 color strings.',
280 '- titleFont and bodyFont must be exact family values from availableFonts in the original system prompt.',
281 '- titleStyle should usually use text-4xl or text-5xl and must not use text-6xl, text-7xl, or text-8xl.'
282 ].join('\n')
283
284 const detectFontLanguageHint = (text: string): string => {
285 if (/[\u3400-\u9fff]/.test(text)) return 'cjk'
286 return 'latin'
287 }
288
289 const resolveFontPair = (
290 value: FontSelection | undefined
291 ): { titleFont: string; bodyFont: string } | null => {
292 if (!value || value.mode !== 'pair') return null
293 const titleFont = String(value.title?.family || '').trim()
294 const bodyFont = String(value.body?.family || '').trim()
295 return titleFont && bodyFont ? { titleFont, bodyFont } : null
296 }
297
298 export const planDeckWithLLM = async (args: {
299 provider: string
300 apiKey: string
301 model: string
302 baseUrl: string
303 temperature?: number
304 maxTokens?: number
305 modelRuntime?: ModelRuntimeConfig
306 styleId: string | null | undefined
307 totalPages: number
308 appLocale?: AppLocale
309 modelTimeoutMs?: number
310 topic: string
311 userMessage: string
312 sourceDocumentPaths?: string[]
313 hasSourceMaterials?: boolean
314 emit?: (chunk: GenerateChunkEvent) => void
315 runId?: string
316 signal?: AbortSignal
317 }): Promise<OutlineItem[]> => {
318 const client = resolveModel(
319 args.provider,
320 args.apiKey,
321 args.model,
322 args.baseUrl,
323 args.temperature,
324 args.maxTokens,
325 args.modelRuntime
326 )
327 const systemPrompt = buildPlanningSystemPrompt(args.totalPages)
328 const userPrompt = buildPlanningUserPrompt({
329 topic: args.topic,
330 totalPages: args.totalPages,
331 userMessage: args.userMessage,
332 hasSourceMaterials: args.hasSourceMaterials || Boolean(args.sourceDocumentPaths?.length)
333 })
334 const parsePlanningItems = (responseText: string): OutlineItem[] => {
335 const parsed = parseModelJson(responseText, args.appLocale)
336 if (!Array.isArray(parsed)) {
337 throw new Error(
338 uiText(
339 args.appLocale,
340 'LLM plan_deck 返回格式不正确,期望 [{title, keyPoints[], layoutIntent}] 数组。',
341 'LLM plan_deck returned an invalid format; expected an array like [{ title, keyPoints[], layoutIntent }].'
342 )
343 )
344 }
345 if (parsed.length === 0 || typeof parsed[0] !== 'object' || parsed[0] === null) {
346 throw new Error(
347 uiText(
348 args.appLocale,
349 'LLM plan_deck pages 返回格式不正确,期望 [{title, keyPoints[], layoutIntent}] 数组。',
350 'LLM plan_deck pages returned an invalid format; expected an array like [{ title, keyPoints[], layoutIntent }].'
351 )
352 )
353 }
354 const items: OutlineItem[] = (parsed as Array<Record<string, unknown>>).map((item, index) => {
355 const title = String(item.title ?? '').trim()
356 const keyPoints = normalizeKeyPoints(item.keyPoints)
357 if (!title) {
358 throw new Error(
359 uiText(
360 args.appLocale,
361 `LLM plan_deck 第 ${index + 1} 项缺少 title,期望格式: { title, keyPoints[], layoutIntent }`,
362 `LLM plan_deck item ${index + 1} is missing title; expected format: { title, keyPoints[], layoutIntent }`
363 )
364 )
365 }
366 if (keyPoints.length < 1) {
367 throw new Error(
368 uiText(
369 args.appLocale,
370 `LLM plan_deck 第 ${index + 1} 项 keyPoints 为空,至少需要 1 条。`,
371 `LLM plan_deck item ${index + 1} has empty keyPoints; at least one item is required.`
372 )
373 )
374 }
375 return {
376 title,
377 contentOutline: normalizeOutlineText(keyPoints.join(';')),
378 layoutIntent: normalizeLayoutIntent(item.layoutIntent)
379 }
380 })
381 if (items.length === 0) {
382 throw new Error(
383 uiText(
384 args.appLocale,
385 'LLM plan_deck 返回空大纲。',
386 'LLM plan_deck returned an empty outline.'
387 )
388 )
389 }
390 // Pad if LLM returned fewer pages than requested
391 while (items.length < args.totalPages) {
392 items.push({
393 title: uiText(args.appLocale, `第 ${items.length + 1} 页`, `Page ${items.length + 1}`),
394 contentOutline: '',
395 layoutIntent: 'concept'
396 })
397 }
398 return items.slice(0, args.totalPages)
399 }
400
401 args.emit?.({
402 type: 'llm_status',
403 payload: {
404 runId: args.runId || '',
405 stage: 'planning',
406 label: progressText(args.appLocale, 'planning'),
407 progress: 4,
408 totalPages: args.totalPages,
409 provider: args.provider,
410 model: args.model,
411 detail: uiText(
412 args.appLocale,
413 `正在生成 ${args.totalPages} 页的标题与要点`,
414 `Generating titles and key points for ${args.totalPages} pages`
415 )
416 }
417 })
418 const maxAttempts = 2
419 let lastError: unknown = null
420 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
421 if (attempt > 1) {
422 args.emit?.({
423 type: 'llm_status',
424 payload: {
425 runId: args.runId || '',
426 stage: 'planning',
427 label: progressText(args.appLocale, 'planning'),
428 progress: 5,
429 totalPages: args.totalPages,
430 provider: args.provider,
431 model: args.model,
432 detail: uiText(
433 args.appLocale,
434 '页面计划格式异常,正在自动重试一次',
435 'The page plan format was invalid; retrying once'
436 )
437 }
438 })
439 }
440 const previousError =
441 lastError instanceof Error ? lastError.message : lastError ? String(lastError) : ''
442 const effectiveUserPrompt =
443 attempt === 1
444 ? userPrompt
445 : buildPlanningRetryUserPrompt(userPrompt, args.totalPages, previousError)
446 log.info('[llm] invoke plan_deck', {
447 provider: args.provider,
448 model: args.model,
449 temperature: args.temperature ?? null,
450 styleId: args.styleId || '',
451 totalPages: args.totalPages,
452 topic: args.topic,
453 attempt,
454 maxAttempts
455 })
456 try {
457 const combinedSignal = modelCallSignal(args.modelTimeoutMs, 'planning', args.signal)
458 const response = await client.invoke(
459 [
460 { role: 'system' as const, content: systemPrompt },
461 { role: 'user' as const, content: effectiveUserPrompt }
462 ],
463 { signal: combinedSignal }
464 )
465 const responseText = extractModelText(response)
466 args.emit?.({
467 type: 'llm_status',
468 payload: {
469 runId: args.runId || '',
470 stage: 'planning',
471 label: progressText(args.appLocale, 'planning'),
472 progress: 9,
473 totalPages: args.totalPages,
474 provider: args.provider,
475 model: args.model,
476 detail: uiText(
477 args.appLocale,
478 '正在整理成可执行页面计划',
479 'Converting outline into an executable page plan'
480 )
481 }
482 })
483 log.info('[llm] plan_deck response', {
484 attempt,
485 textLength: responseText.length,
486 preview: JSON.stringify(
487 responseText.length > 240 ? `${responseText.slice(0, 240)}…` : responseText
488 )
489 })
490 return parsePlanningItems(responseText)
491 } catch (error) {
492 lastError = error
493 if (args.signal?.aborted || attempt >= maxAttempts) {
494 throw error
495 }
496 log.warn('[llm] plan_deck retry scheduled', {
497 provider: args.provider,
498 model: args.model,
499 attempt,
500 maxAttempts,
501 reason: error instanceof Error ? error.message : String(error)
502 })
503 }
504 }
505 throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Planning failed'))
506 }
507
508 export const planNewPage = async (args: {
509 provider: string
510 apiKey: string
511 model: string
512 baseUrl: string
513 temperature?: number
514 maxTokens?: number
515 modelRuntime?: ModelRuntimeConfig
516 appLocale?: AppLocale
517 modelTimeoutMs?: number
518 userDescription: string
519 topic?: string
520 existingTitles?: string[]
521 sourceDocumentPaths?: string[]
522 signal?: AbortSignal
523 }): Promise<{ title: string; contentOutline: string; layoutIntent: LayoutIntent }> => {
524 const client = resolveModel(
525 args.provider,
526 args.apiKey,
527 args.model,
528 args.baseUrl,
529 args.temperature,
530 args.maxTokens,
531 args.modelRuntime
532 )
533 const systemPrompt = [
534 'You are a PPT slide planner. The user wants to add ONE new slide to an existing deck.',
535 'Generate a title, concise key points (1-10 items), and a layout intent for this single slide.',
536 '',
537 CONTENT_LANGUAGE_RULES,
538 '',
539 'The new slide must fit naturally into the existing deck:',
540 '- The title language and style must match existing slide titles.',
541 '- Do NOT duplicate or closely paraphrase any existing slide title.',
542 args.topic ? `- Deck topic: ${args.topic}` : '',
543 args.sourceDocumentPaths?.length
544 ? [
545 '',
546 'Source document context:',
547 '- This deck has user-imported reference documents. Plan a slide title and key points that can be verified against the source during generation.',
548 `- sourceDocumentPaths: ${args.sourceDocumentPaths.join(', ')}`,
549 '- Do not invent unsupported exact facts, metrics, examples, risks, decisions, or conclusions in this planning step.'
550 ].join('\n')
551 : '',
552 '',
553 'Assign layoutIntent based on the slide content type:',
554 ' - data-focus: metrics, KPIs, trends, or quantitative results',
555 ' - comparison: comparing 2+ options or alternatives',
556 ' - timeline: phases, stages, roadmap',
557 ' - concept: ideas, frameworks, principles',
558 ' - process: how something works, step-by-step',
559 ' - summary: conclusion, key takeaways',
560 ' - quote: a single statement or judgment',
561 ' - image-focus: products, scenes, visuals',
562 '',
563 'Return only a JSON object with exactly these fields: title, keyPoints, layoutIntent.',
564 'Do not add explanations, Markdown, or extra text.',
565 'keyPoints must contain 1-10 short phrases. If the user explicitly lists topics for this slide, preserve each listed topic as a separate key point when possible.'
566 ]
567 .filter(Boolean)
568 .join('\n')
569 const contextParts: string[] = []
570 if (args.existingTitles && args.existingTitles.length > 0) {
571 contextParts.push('Existing slide titles (do NOT duplicate these):')
572 args.existingTitles.forEach((t, i) => contextParts.push(` ${i + 1}. ${t}`))
573 contextParts.push('')
574 }
575 contextParts.push('User request for the new slide:')
576 contextParts.push(args.userDescription)
577 const userPrompt = contextParts.join('\n')
578
579 const combinedSignal = args.modelTimeoutMs
580 ? AbortSignal.any([
581 AbortSignal.timeout(args.modelTimeoutMs),
582 args.signal || AbortSignal.timeout(120_000)
583 ])
584 : args.signal || undefined
585
586 const response = await client.invoke(
587 [
588 { role: 'system' as const, content: systemPrompt },
589 { role: 'user' as const, content: userPrompt }
590 ],
591 { signal: combinedSignal }
592 )
593 const responseText = extractModelText(response)
594 const parsed = parseModelJson(responseText, args.appLocale)
595
596 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
597 throw new Error('LLM plan_new_page returned invalid format; expected a JSON object.')
598 }
599 const item = parsed as Record<string, unknown>
600 const title = String(item.title ?? '').trim()
601 if (!title) {
602 throw new Error('LLM plan_new_page missing title field.')
603 }
604 const keyPoints = normalizeKeyPoints(item.keyPoints)
605 const contentOutline = normalizeOutlineText(keyPoints.join(';'))
606 const layoutIntent = normalizeLayoutIntent(item.layoutIntent)
607
608 return { title, contentOutline, layoutIntent }
609 }
610
611 export const buildDesignContractWithLLM = async (args: {
612 provider: string
613 apiKey: string
614 model: string
615 baseUrl: string
616 temperature?: number
617 maxTokens?: number
618 modelRuntime?: ModelRuntimeConfig
619 styleId: string | null | undefined
620 styleSkillPrompt: string
621 styleKey?: string
622 styleName?: string
623 styleVersion?: string
624 appLocale?: AppLocale
625 modelTimeoutMs?: number
626 totalPages: number
627 slideSize: SlideSizePreset
628 topic?: string
629 userMessage?: string
630 fontSelection?: FontSelection
631 emit?: (chunk: GenerateChunkEvent) => void
632 runId?: string
633 signal?: AbortSignal
634 }): Promise<DesignContract> => {
635 const client = resolveModel(
636 args.provider,
637 args.apiKey,
638 args.model,
639 args.baseUrl,
640 args.temperature,
641 args.maxTokens,
642 args.modelRuntime
643 )
644 const totalPages = Math.max(1, args.totalPages)
645 const availableFonts: AvailableFont[] = await buildAvailableFontsForPrompt()
646 const requestedFontPair = resolveFontPair(args.fontSelection)
647 if (requestedFontPair) {
648 await assertFontFamilyAvailable(requestedFontPair.titleFont, 'titleFont')
649 await assertFontFamilyAvailable(requestedFontPair.bodyFont, 'bodyFont')
650 }
651 const languageHint = detectFontLanguageHint(
652 [args.topic || '', args.userMessage || '', args.styleSkillPrompt || ''].join('\n')
653 )
654 const systemPrompt = buildDesignContractSystemPrompt({
655 styleSkill: args.styleSkillPrompt,
656 availableFonts,
657 requestedFontPair,
658 languageHint,
659 slideSize: args.slideSize
660 })
661 const userPrompt = buildDesignContractUserPrompt()
662 const parseDesignContract = async (responseText: string): Promise<DesignContract> => {
663 const parsed = parseModelJson(responseText, args.appLocale)
664 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
665 throw new Error(
666 uiText(
667 args.appLocale,
668 'LLM design_contract 返回格式不正确,期望 JSON object。',
669 'LLM design_contract returned an invalid format; expected a JSON object.'
670 )
671 )
672 }
673 const record = parsed as Record<string, unknown>
674 const requiredKeys = [
675 'theme',
676 'background',
677 'palette',
678 'titleStyle',
679 'layoutMotif',
680 'chartStyle',
681 'shapeLanguage',
682 'titleFont',
683 'bodyFont'
684 ]
685 const missingKeys = requiredKeys.filter(
686 (key) => record[key] === undefined || record[key] === ''
687 )
688 if (missingKeys.length > 0) {
689 throw new Error(
690 uiText(
691 args.appLocale,
692 `LLM design_contract 缺少字段:${missingKeys.join(', ')}`,
693 `LLM design_contract is missing fields: ${missingKeys.join(', ')}`
694 )
695 )
696 }
697 if (!Array.isArray(record.palette) || record.palette.length < 3) {
698 throw new Error(
699 uiText(
700 args.appLocale,
701 'LLM design_contract palette 至少需要 3 个颜色。',
702 'LLM design_contract palette must contain at least 3 colors.'
703 )
704 )
705 }
706 const contract = normalizeDesignContract(parsed)
707 if (requestedFontPair) {
708 if (
709 contract.titleFont !== requestedFontPair.titleFont ||
710 contract.bodyFont !== requestedFontPair.bodyFont
711 ) {
712 throw new Error(
713 uiText(
714 args.appLocale,
715 `LLM design_contract 字体与用户选择不一致:titleFont=${contract.titleFont}, bodyFont=${contract.bodyFont}`,
716 `LLM design_contract fonts do not match the user selection: titleFont=${contract.titleFont}, bodyFont=${contract.bodyFont}`
717 )
718 )
719 }
720 }
721 await assertFontFamilyAvailable(contract.titleFont, 'titleFont')
722 await assertFontFamilyAvailable(contract.bodyFont, 'bodyFont')
723 return contract
724 }
725 args.emit?.({
726 type: 'llm_status',
727 payload: {
728 runId: args.runId || '',
729 stage: 'planning',
730 label: progressText(args.appLocale, 'planning'),
731 progress: 9,
732 totalPages,
733 provider: args.provider,
734 model: args.model,
735 detail: uiText(args.appLocale, '正在生成独立设计契约', 'Generating design contract')
736 }
737 })
738 const maxAttempts = 2
739 let lastError: unknown = null
740 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
741 if (attempt > 1) {
742 args.emit?.({
743 type: 'llm_status',
744 payload: {
745 runId: args.runId || '',
746 stage: 'planning',
747 label: progressText(args.appLocale, 'planning'),
748 progress: 9,
749 totalPages,
750 provider: args.provider,
751 model: args.model,
752 detail: uiText(
753 args.appLocale,
754 '设计契约格式异常,正在自动重试一次',
755 'The design contract format was invalid; retrying once'
756 )
757 }
758 })
759 }
760 const previousError =
761 lastError instanceof Error ? lastError.message : lastError ? String(lastError) : ''
762 const effectiveUserPrompt =
763 attempt === 1 ? userPrompt : buildDesignContractRetryUserPrompt(userPrompt, previousError)
764 try {
765 const combinedSignal = modelCallSignal(args.modelTimeoutMs, 'design', args.signal)
766 const response = await client.invoke(
767 [
768 {
769 role: 'system' as const,
770 content: systemPrompt
771 },
772 {
773 role: 'user' as const,
774 content: effectiveUserPrompt
775 }
776 ],
777 { signal: combinedSignal }
778 )
779 const responseText = extractModelText(response)
780 log.info('[llm] design_contract response', {
781 attempt,
782 textLength: responseText.length,
783 preview: JSON.stringify(
784 responseText.length > 240 ? `${responseText.slice(0, 240)}…` : responseText
785 )
786 })
787 const contract = await parseDesignContract(responseText)
788 args.emit?.({
789 type: 'llm_status',
790 payload: {
791 runId: args.runId || '',
792 stage: 'planning',
793 label: progressText(args.appLocale, 'planning'),
794 progress: 10,
795 totalPages,
796 provider: args.provider,
797 model: args.model,
798 detail: contract.theme
799 }
800 })
801 return contract
802 } catch (error) {
803 if (args.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
804 throw error
805 }
806 lastError = error
807 if (attempt < maxAttempts) {
808 log.warn('[llm] design_contract retry scheduled', {
809 provider: args.provider,
810 model: args.model,
811 attempt,
812 maxAttempts,
813 message: error instanceof Error ? error.message : String(error)
814 })
815 continue
816 }
817 }
818 }
819 log.warn('[llm] design_contract failed', {
820 provider: args.provider,
821 model: args.model,
822 temperature: args.temperature ?? null,
823 styleId: args.styleId || '',
824 message: lastError instanceof Error ? lastError.message : String(lastError)
825 })
826 throw new Error(
827 uiText(
828 args.appLocale,
829 `设计契约生成失败:${lastError instanceof Error ? lastError.message : String(lastError)}`,
830 `Failed to generate design contract: ${
831 lastError instanceof Error ? lastError.message : String(lastError)
832 }`
833 )
834 )
835 }
836
837 export const runDeepAgentDeckGeneration = async (args: {
838 sessionId: string
839 provider: string
840 apiKey: string
841 model: string
842 baseUrl: string
843 temperature?: number
844 maxTokens?: number
845 styleId: string | null | undefined
846 styleSkillPrompt: string
847 styleKey?: string
848 styleName?: string
849 styleVersion?: string
850 slideSize: import('@shared/slide-size').SlideSizePreset
851 appLocale?: AppLocale
852 animationPreferences?: AnimationPreferencesPayload | null
853 modelTimeoutMs?: number
854 topic: string
855 deckTitle: string
856 userMessage: string
857 outlineTitles: string[]
858 outlineItems: OutlineItem[]
859 sourceDocumentPaths?: string[]
860 systemPromptAddendum?: string
861 singlePagePromptAddendum?: string
862 requireTemplatePageRead?: boolean
863 generationMode?: 'generate' | 'retry'
864 renderingLabel?: string
865 pageTasks?: Array<{
866 pageNumber: number
867 pageId: string
868 title: string
869 contentOutline?: string | null
870 layoutIntent?: OutlineItem['layoutIntent']
871 }>
872 designContract?: DesignContract
873 projectDir: string
874 indexPath: string
875 pageFileMap: Record<string, string>
876 pageNumbers?: Record<string, number>
877 agentManager: GenerationAgentManager
878 emit?: (chunk: GenerateChunkEvent) => void
879 onPageCompleted?: (page: {
880 pageNumber: number
881 pageId: string
882 title: string
883 contentOutline: string
884 layoutIntent?: OutlineItem['layoutIntent']
885 htmlPath: string
886 }) => Promise<void>
887 onPageFailed?: (page: {
888 pageNumber: number
889 pageId: string
890 title: string
891 contentOutline: string
892 layoutIntent?: OutlineItem['layoutIntent']
893 htmlPath: string
894 reason: string
895 }) => Promise<void>
896 runId?: string
897 signal?: AbortSignal
898 }): Promise<{
899 summary: string
900 failedPages: Array<{ pageId: string; title: string; reason: string }>
901 }> => {
902 const layoutLibrary = (await readSessionLayoutLibrary(args.projectDir)).library
903 type PageRef = {
904 pageNumber: number
905 pageId: string
906 title: string
907 outline: string
908 layoutIntent?: OutlineItem['layoutIntent']
909 layoutId: string
910 layoutPrompt: string
911 }
912 const resolvePageRef = (page: {
913 pageNumber: number
914 pageId: string
915 title: string
916 contentOutline?: string | null
917 layoutIntent?: OutlineItem['layoutIntent']
918 }): PageRef => {
919 const layoutTemplate = resolveLayoutMasterTemplate(layoutLibrary, page.layoutIntent)
920 return {
921 pageNumber: page.pageNumber,
922 pageId: page.pageId,
923 title: page.title,
924 outline: page.contentOutline || '',
925 layoutIntent: page.layoutIntent,
926 layoutId: layoutTemplate.id,
927 layoutPrompt: formatLayoutMasterPrompt(layoutTemplate)
928 }
929 }
930 const pageRefs: PageRef[] =
931 args.pageTasks && args.pageTasks.length > 0
932 ? args.pageTasks.map(resolvePageRef)
933 : (() => {
934 const pageIds = Object.keys(args.pageFileMap || {})
935 if (pageIds.length === 0) {
936 throw new Error('pageFileMap 为空,无法建立页面任务。')
937 }
938 return args.outlineTitles.map((title, index) =>
939 resolvePageRef({
940 pageNumber: index + 1,
941 pageId: pageIds[index] || pageIds[Math.min(index, pageIds.length - 1)],
942 title,
943 contentOutline: args.outlineItems[index]?.contentOutline || '',
944 layoutIntent: args.outlineItems[index]?.layoutIntent
945 })
946 )
947 })()
948 const totalPages = pageRefs.length
949 const clampProgress = (value: number): number => Math.max(0, Math.min(100, Math.round(value)))
950 const pageSummaryMap = new Map<number, string>()
951 const useDualWorkerQueue = totalPages >= 3
952 const pageProgressMap = new Map<string, number>()
953 let renderingProgress = 0
954 const toRenderingProgress = (target: number): number => {
955 const capped = clampProgress(Math.min(90, target))
956 renderingProgress = Math.max(renderingProgress, capped)
957 return renderingProgress
958 }
959 const emitRenderingStatus = (input: {
960 label: string
961 detail?: string
962 progress: number
963 }): void => {
964 args.emit?.({
965 type: 'llm_status',
966 payload: {
967 runId: args.runId || '',
968 stage: 'rendering',
969 label: input.label,
970 detail: input.detail,
971 progress: toRenderingProgress(input.progress),
972 totalPages,
973 provider: args.provider,
974 model: args.model
975 }
976 })
977 }
978
979 const setPageProgress = (pageId: string, rawProgress: number): number => {
980 const prev = pageProgressMap.get(pageId) ?? 0
981 const bounded = Math.max(0, Math.min(100, Math.round(rawProgress)))
982 const next = Math.max(prev, bounded)
983 pageProgressMap.set(pageId, next)
984 return next
985 }
986
987 const getCompletedPageCount = (): number =>
988 pageRefs.reduce(
989 (count, page) => count + ((pageProgressMap.get(page.pageId) ?? 0) >= 100 ? 1 : 0),
990 0
991 )
992
993 const getOverallRenderProgress = (): number => {
994 const sum = pageRefs.reduce((acc, page) => acc + (pageProgressMap.get(page.pageId) ?? 0), 0)
995 const ratio = sum / Math.max(1, totalPages * 100)
996 return 10 + ratio * 80
997 }
998
999 const resolvePageProgressFromCustomStatus = (custom: DeckToolStatusChunk): number => {
1000 const label = custom.label || ''
1001 if (/读取会话上下文|Reading session context/i.test(label)) return 25
1002 if (/更新\s*page-\S+|更新单页\s+\S+|Updating\s+\S+/i.test(label)) return 60
1003 if (/验证完成状态|Verifying completion/i.test(label)) return 85
1004 if (/所有页面已填充|当前页面已填充|All pages filled|Current page filled/i.test(label)) return 95
1005 if (/生成完成|修改完成|Generation completed|Edit completed/i.test(label)) return 100
1006 if (Number.isFinite(custom.progress)) {
1007 const raw = Number(custom.progress)
1008 return Math.max(12, Math.min(96, raw))
1009 }
1010 return 50
1011 }
1012
1013 const emitPageStatus = (args: {
1014 pageId: string
1015 label: string
1016 detail?: string
1017 pageProgress: number
1018 }): void => {
1019 setPageProgress(args.pageId, args.pageProgress)
1020 emitRenderingStatus({
1021 label: args.label,
1022 detail: args.detail,
1023 progress: getOverallRenderProgress()
1024 })
1025 }
1026
1027 const renderingLabel = args.renderingLabel || progressText(args.appLocale, 'generating')
1028
1029 emitRenderingStatus({
1030 label: renderingLabel,
1031 progress: 12,
1032 detail: uiText(args.appLocale, `共 ${totalPages} 页`, `${totalPages} pages`)
1033 })
1034
1035 log.info('[deepagent] invoke deck generation', {
1036 sessionId: args.sessionId,
1037 provider: args.provider,
1038 model: args.model,
1039 temperature: args.temperature ?? null,
1040 styleId: args.styleId || '',
1041 projectDir: args.projectDir,
1042 indexPath: args.indexPath,
1043 totalPages,
1044 fixedConcurrency: useDualWorkerQueue ? 2 : 1,
1045 designContract: args.designContract
1046 ? {
1047 theme: args.designContract.theme,
1048 background: args.designContract.background,
1049 palette: args.designContract.palette,
1050 titleStyle: args.designContract.titleStyle
1051 }
1052 : null
1053 })
1054
1055 const referenceDocumentRetriever = args.sourceDocumentPaths?.length
1056 ? await createReferenceDocumentRetriever({
1057 sessionId: args.sessionId,
1058 projectDir: args.projectDir,
1059 sourceDocumentPaths: args.sourceDocumentPaths
1060 })
1061 : null
1062
1063 const generateSinglePage = async (
1064 page: PageRef,
1065 workerLabel: string,
1066 retryContext?: {
1067 attempt: number
1068 maxRetries: number
1069 previousError: string
1070 }
1071 ): Promise<string> => {
1072 if (args.signal?.aborted) {
1073 throw new Error(uiText(args.appLocale, '生成已取消', 'Generation canceled'))
1074 }
1075 const pageStartedAt = Date.now()
1076 const currentPagePath = args.pageFileMap[page.pageId]
1077 const writeToolName = args.requireTemplatePageRead
1078 ? 'update_template_page_file'
1079 : 'update_single_page_file'
1080
1081 emitPageStatus({
1082 pageId: page.pageId,
1083 label: renderingLabel,
1084 detail: `${page.pageId} · ${page.title}`,
1085 pageProgress: 5
1086 })
1087 args.emit?.({
1088 type: 'page_started',
1089 payload: {
1090 runId: args.runId || '',
1091 stage: 'rendering',
1092 label: renderingLabel,
1093 progress: getOverallRenderProgress(),
1094 currentPage: page.pageNumber,
1095 totalPages,
1096 pageNumber: page.pageNumber,
1097 pageId: page.pageId,
1098 title: page.title,
1099 htmlPath: currentPagePath
1100 }
1101 })
1102
1103 if (!currentPagePath) {
1104 throw new Error(`pageFileMap 缺少 ${page.pageId} 对应文件路径`)
1105 }
1106 const beforePageHtml = await readPageHtmlIfExists(currentPagePath)
1107 log.info('[deepagent] page generation context', {
1108 sessionId: args.sessionId,
1109 worker: workerLabel,
1110 styleId: args.styleId || '',
1111 pageId: page.pageId,
1112 pageNumber: page.pageNumber,
1113 title: page.title,
1114 pagePath: currentPagePath,
1115 outline: page.outline || '',
1116 outlineLength: (page.outline || '').length
1117 })
1118
1119 const isSectionAgendaPage = isSectionAgendaOutline(page.outline || '')
1120 const pageSourceDocumentPaths = isSectionAgendaPage ? [] : args.sourceDocumentPaths
1121 const referenceDocumentSnippets = referenceDocumentRetriever && !isSectionAgendaPage
1122 ? formatReferenceDocumentSnippets(
1123 referenceDocumentRetriever.search({
1124 pageId: page.pageId,
1125 pageTitle: page.title,
1126 pageOutline: page.outline,
1127 userMessage: args.userMessage
1128 })
1129 )
1130 : ''
1131 log.info('[deepagent] reference document snippets prepared', {
1132 sessionId: args.sessionId,
1133 pageId: page.pageId,
1134 pageNumber: page.pageNumber,
1135 title: page.title,
1136 hasSourceDocuments: Boolean(pageSourceDocumentPaths?.length),
1137 hasRetriever: Boolean(referenceDocumentRetriever),
1138 injected: referenceDocumentSnippets.trim().length > 0,
1139 injectedCharacterCount: referenceDocumentSnippets.length
1140 })
1141
1142 const deepAgent = createSessionDeckAgent({
1143 provider: args.provider,
1144 apiKey: args.apiKey,
1145 model: args.model,
1146 baseUrl: args.baseUrl,
1147 temperature: args.temperature,
1148 maxTokens: args.maxTokens,
1149 modelRuntime: args.agentManager.getSession(args.sessionId)?.modelRuntime,
1150 styleId: args.styleId,
1151 systemPromptAddendum: args.systemPromptAddendum,
1152 context: {
1153 sessionId: args.sessionId,
1154 projectDir: args.projectDir,
1155 indexPath: args.indexPath,
1156 topic: args.topic,
1157 deckTitle: args.deckTitle,
1158 styleId: args.styleId,
1159 styleSkillPrompt: args.styleSkillPrompt,
1160 styleKey: args.styleKey,
1161 styleName: args.styleName,
1162 styleVersion: args.styleVersion,
1163 slideSize: args.slideSize,
1164 appLocale: args.appLocale,
1165 animationPreferences: args.animationPreferences,
1166 designContract: args.designContract,
1167 templatePageReadRequired: args.requireTemplatePageRead,
1168 userMessage: args.userMessage,
1169 outlineTitles: [page.title],
1170 outlineItems: [
1171 {
1172 title: page.title,
1173 contentOutline: page.outline,
1174 layoutIntent: page.layoutIntent,
1175 layoutId: page.layoutId,
1176 layoutPrompt: page.layoutPrompt
1177 }
1178 ],
1179 sourceDocumentPaths: pageSourceDocumentPaths,
1180 mode: args.generationMode ?? 'generate',
1181 pageFileMap: { [page.pageId]: currentPagePath },
1182 pageNumbers: { [page.pageId]: page.pageNumber },
1183 selectedPageId: page.pageId,
1184 selectedPageNumber: page.pageNumber,
1185 existingPageIds: [page.pageId],
1186 allowedPageIds: [page.pageId]
1187 }
1188 })
1189 args.agentManager.setPageAgent(args.sessionId, page.pageId, deepAgent)
1190
1191 try {
1192 const combinedSignal = modelCallSignal(args.modelTimeoutMs, 'agent', args.signal)
1193 const stream = await deepAgent.stream(
1194 {
1195 messages: [
1196 {
1197 role: 'user',
1198 content: [
1199 args.singlePagePromptAddendum?.trim() || '',
1200 args.requireTemplatePageRead
1201 ? [
1202 'Template inspection is mandatory before writing.',
1203 `1. First call read_file(path="/${page.pageId}.html", offset=0, limit=1200) to inspect the copied template page.`,
1204 '2. Identify every template-skeleton asset and wrapper: background images, texture images, decorative images, masks, overlays, CSS background-image/url(...) references, <img src>, SVG image href, font scale, spacing rhythm, color language, and reusable structural wrappers from that file.',
1205 '3. These background/decorative assets are not old business content. Do not delete them when replacing text, metrics, logos, or content images.',
1206 '4. update_template_page_file rebuilds the page from your content fragment and rejects writes that drop template skeleton resources, so the fragment you write must explicitly include the required background/decorative layers or exact local asset references from the template page.',
1207 '5. Only after reading the file, call update_template_page_file with the new content while preserving the template visual system unless the user explicitly asks for a redesign.',
1208 '6. Do not call update_single_page_file in this template run.'
1209 ].join('\n')
1210 : '',
1211 buildSinglePageGenerationPrompt({
1212 topic: args.topic,
1213 deckTitle: args.deckTitle,
1214 pageId: page.pageId,
1215 pageNumber: page.pageNumber,
1216 pageTitle: page.title,
1217 pageOutline: page.outline,
1218 slideSize: args.slideSize,
1219 layoutIntent: page.layoutIntent,
1220 layoutId: page.layoutId,
1221 layoutPrompt: page.layoutPrompt,
1222 sourceDocumentPaths: pageSourceDocumentPaths,
1223 referenceDocumentSnippets,
1224 isRetryMode: args.generationMode === 'retry',
1225 writeToolName,
1226 retryContext
1227 })
1228 ]
1229 .filter(Boolean)
1230 .join('\n\n')
1231 }
1232 ]
1233 },
1234 {
1235 streamMode: ['updates', 'messages', 'custom'],
1236 subgraphs: true,
1237 signal: combinedSignal
1238 }
1239 )
1240
1241 // Final user-facing generation replies are built later from validated page facts.
1242 // Raw messages may be token deltas, tool-call turns, or cumulative provider chunks.
1243 await processAgentStreamCore(stream, {
1244 emit: args.emit,
1245 runId: args.runId || '',
1246 stage: 'rendering',
1247 totalPages,
1248 provider: args.provider,
1249 model: args.model,
1250 sessionId: args.sessionId,
1251 workerLabel,
1252 onCustom: (custom) => {
1253 const mappedPageProgress = resolvePageProgressFromCustomStatus(custom)
1254 const normalizedLabel = progressLabel(args.appLocale, custom.label)
1255 const normalizedDetail =
1256 /所有页面已填充|当前页面已填充|All pages filled|Current page filled/i.test(
1257 custom.label || ''
1258 )
1259 ? uiText(
1260 args.appLocale,
1261 `${page.title} · 页面内容已写入`,
1262 `${page.title} · page content written`
1263 )
1264 : custom.detail
1265 emitPageStatus({
1266 pageId: page.pageId,
1267 label:
1268 normalizedLabel === progressText(args.appLocale, 'generating')
1269 ? renderingLabel
1270 : normalizedLabel,
1271 detail: normalizedDetail,
1272 pageProgress: mappedPageProgress
1273 })
1274 },
1275 onModelThinking: (defaultProgress) => {
1276 const mappedPageProgress = Math.max(12, Math.min(96, defaultProgress))
1277 emitPageStatus({
1278 pageId: page.pageId,
1279 label: renderingLabel,
1280 detail: page.title,
1281 pageProgress: mappedPageProgress
1282 })
1283 }
1284 })
1285
1286 const afterPageHtml = await readPageHtmlIfExists(currentPagePath)
1287 if (
1288 !afterPageHtml ||
1289 afterPageHtml === beforePageHtml ||
1290 isPlaceholderPageHtml(afterPageHtml)
1291 ) {
1292 throw new Error(
1293 [
1294 `页面未写入 (${page.pageId}):模型没有成功调用 ${writeToolName} 写入目标 page 文件。`,
1295 `必须调用 ${writeToolName}(pageId="${page.pageId}", content=完整创意页面片段),不要只在最终回复里描述 HTML。`
1296 ].join(' ')
1297 )
1298 }
1299
1300 emitPageStatus({
1301 pageId: page.pageId,
1302 label: progressLabel(args.appLocale, '页面内容已写入'),
1303 detail: `${page.pageId} · ${page.title}`,
1304 pageProgress: 95
1305 })
1306
1307 await args.onPageCompleted?.({
1308 pageNumber: page.pageNumber,
1309 pageId: page.pageId,
1310 title: page.title,
1311 contentOutline: page.outline,
1312 layoutIntent: page.layoutIntent,
1313 htmlPath: currentPagePath
1314 })
1315
1316 setPageProgress(page.pageId, 100)
1317 const completedCount = getCompletedPageCount()
1318 emitRenderingStatus({
1319 label: progressText(args.appLocale, 'completed'),
1320 detail: uiText(
1321 args.appLocale,
1322 `${page.title} · 已完成 ${completedCount}/${totalPages} 页`,
1323 `${page.title} · ${completedCount}/${totalPages} pages completed`
1324 ),
1325 progress: getOverallRenderProgress()
1326 })
1327
1328 log.info('[deepagent] page generation finished', {
1329 sessionId: args.sessionId,
1330 worker: workerLabel,
1331 styleId: args.styleId || '',
1332 pageId: page.pageId,
1333 retryAttempt: retryContext?.attempt || 0,
1334 elapsedMs: Date.now() - pageStartedAt,
1335 pagePath: currentPagePath
1336 })
1337
1338 return buildLocalCompletedGenerationPageSummary({
1339 appLocale: args.appLocale || 'zh',
1340 pageTitle: page.title
1341 })
1342 } finally {
1343 args.agentManager.removePageAgent(args.sessionId, page.pageId)
1344 }
1345 }
1346
1347 // 仅重试失败页面,避免影响已成功页面。
1348 // MAX_PAGE_RETRIES=3 表示首轮失败后最多再重试 3 次。
1349 const MAX_PAGE_RETRIES = 3
1350 const RETRY_DELAY_BASE_MS = 1_000
1351 const generateSinglePageWithRetry = async (
1352 page: PageRef,
1353 workerLabel: string
1354 ): Promise<string> => {
1355 let lastError: unknown = null
1356 for (let attempt = 0; attempt <= MAX_PAGE_RETRIES; attempt++) {
1357 try {
1358 const retryContext =
1359 attempt > 0 && lastError
1360 ? {
1361 attempt,
1362 maxRetries: MAX_PAGE_RETRIES,
1363 previousError: lastError instanceof Error ? lastError.message : String(lastError)
1364 }
1365 : undefined
1366 return await generateSinglePage(page, workerLabel, retryContext)
1367 } catch (error) {
1368 lastError = error
1369 const reason = error instanceof Error ? error.message : String(error)
1370 // Write/validation errors that are truly non-retryable
1371 const isWriteError = /落盘校验|禁止的 CDN|远程资源|未知页面|不允许写入/i.test(reason)
1372 if (isWriteError || attempt >= MAX_PAGE_RETRIES) break
1373 const retryAttempt = attempt + 1
1374 const retryDelayMs = RETRY_DELAY_BASE_MS * retryAttempt
1375 emitPageStatus({
1376 pageId: page.pageId,
1377 label: progressText(args.appLocale, 'retrying'),
1378 detail: uiText(
1379 args.appLocale,
1380 `仅重试失败页:上次失败原因 ${reason}`,
1381 `Retrying only the failed page. Previous failure: ${reason}`
1382 ),
1383 pageProgress: 12
1384 })
1385 log.warn('[deepagent] page generation retry scheduled', {
1386 sessionId: args.sessionId,
1387 styleId: args.styleId || '',
1388 pageId: page.pageId,
1389 worker: workerLabel,
1390 attempt: retryAttempt,
1391 maxRetries: MAX_PAGE_RETRIES,
1392 retryDelayMs,
1393 lastErrorReason: reason,
1394 reason
1395 })
1396 await sleep(retryDelayMs, args.signal)
1397 }
1398 }
1399 throw lastError instanceof Error
1400 ? lastError
1401 : new Error(
1402 String(lastError ?? uiText(args.appLocale, '页面生成失败', 'Page generation failed'))
1403 )
1404 }
1405
1406 const workerCount = useDualWorkerQueue ? 2 : 1
1407 const PAGE_GENERATION_STAGGER_MS = 500
1408 if (useDualWorkerQueue) {
1409 emitRenderingStatus({
1410 label: renderingLabel,
1411 progress: 14,
1412 detail: uiText(args.appLocale, '创意即将正式生成..', 'Generation is about to begin.')
1413 })
1414 }
1415 const limit = pLimit(workerCount)
1416 const settled = await Promise.allSettled(
1417 pageRefs.map((page, index) =>
1418 limit(async () => {
1419 if (args.signal?.aborted)
1420 throw new Error(uiText(args.appLocale, '生成已取消', 'Generation canceled'))
1421 const workerLabel = useDualWorkerQueue ? 'limit-worker' : 'single-worker'
1422 const launchDelayMs = useDualWorkerQueue
1423 ? (index % workerCount) * PAGE_GENERATION_STAGGER_MS
1424 : 0
1425 if (launchDelayMs > 0) {
1426 log.info('[deepagent] queue stagger delay', {
1427 sessionId: args.sessionId,
1428 worker: workerLabel,
1429 styleId: args.styleId || '',
1430 pageId: page.pageId,
1431 pageNumber: page.pageNumber,
1432 delayMs: launchDelayMs
1433 })
1434 await sleep(launchDelayMs, args.signal)
1435 }
1436 if (args.signal?.aborted)
1437 throw new Error(uiText(args.appLocale, '生成已取消', 'Generation canceled'))
1438 log.info('[deepagent] queue dispatch', {
1439 sessionId: args.sessionId,
1440 worker: workerLabel,
1441 styleId: args.styleId || '',
1442 pageId: page.pageId,
1443 pageNumber: page.pageNumber,
1444 title: page.title
1445 })
1446 try {
1447 const summary = await generateSinglePageWithRetry(page, workerLabel)
1448 if (summary) {
1449 pageSummaryMap.set(
1450 page.pageNumber,
1451 uiText(
1452 args.appLocale,
1453 `第 ${page.pageNumber} 页:${summary}`,
1454 `Page ${page.pageNumber}: ${summary}`
1455 )
1456 )
1457 }
1458 } catch (error) {
1459 const reason = error instanceof Error ? error.message : String(error)
1460 args.emit?.({
1461 type: 'page_failed',
1462 payload: {
1463 runId: args.runId || '',
1464 stage: 'rendering',
1465 label: progressText(args.appLocale, 'failed'),
1466 progress: getOverallRenderProgress(),
1467 currentPage: page.pageNumber,
1468 totalPages,
1469 pageNumber: page.pageNumber,
1470 pageId: page.pageId,
1471 title: page.title,
1472 htmlPath: args.pageFileMap[page.pageId] || '',
1473 error: reason
1474 }
1475 })
1476 await args.onPageFailed?.({
1477 pageNumber: page.pageNumber,
1478 pageId: page.pageId,
1479 title: page.title,
1480 contentOutline: page.outline,
1481 layoutIntent: page.layoutIntent,
1482 htmlPath: args.pageFileMap[page.pageId] || '',
1483 reason
1484 })
1485 throw error
1486 }
1487 })
1488 )
1489 )
1490 const failedPages: Array<{ pageId: string; title: string; reason: string }> = []
1491 settled.forEach((result, index) => {
1492 if (result.status === 'rejected') {
1493 const page = pageRefs[index]
1494 const reason = result.reason instanceof Error ? result.reason.message : String(result.reason)
1495 failedPages.push({
1496 pageId: page.pageId,
1497 title: page.title,
1498 reason
1499 })
1500 log.warn('[deepagent] page generation failed', {
1501 sessionId: args.sessionId,
1502 styleId: args.styleId || '',
1503 pageId: page.pageId,
1504 reason
1505 })
1506 }
1507 })
1508 const finalAssistantText = pageRefs
1509 .map((page) => pageSummaryMap.get(page.pageNumber))
1510 .filter((item): item is string => Boolean(item))
1511 .join('\n')
1512 log.info('[deepagent] host worker queue generation completed', {
1513 sessionId: args.sessionId,
1514 styleId: args.styleId || '',
1515 totalPages,
1516 workerCount,
1517 finalAssistantPreview: finalAssistantText.slice(0, 200)
1518 })
1519 return {
1520 summary: finalAssistantText,
1521 failedPages
1522 }
1523 }
1524
1525 type RunDeepAgentEditBaseArgs = {
1526 sessionId: string
1527 provider: string
1528 apiKey: string
1529 model: string
1530 baseUrl: string
1531 temperature?: number
1532 maxTokens?: number
1533 styleId: string | null | undefined
1534 styleSkillPrompt: string
1535 styleKey?: string
1536 styleName?: string
1537 styleVersion?: string
1538 slideSize: import('@shared/slide-size').SlideSizePreset
1539 appLocale?: AppLocale
1540 modelTimeoutMs?: number
1541 topic: string
1542 deckTitle: string
1543 userMessage: string
1544 outlineTitles: string[]
1545 outlineItems: OutlineItem[]
1546 sourceDocumentPaths?: string[]
1547 projectDir: string
1548 indexPath: string
1549 pageFileMap: Record<string, string>
1550 pageNumbers?: Record<string, number>
1551 selectPageIds?: string[]
1552 designContract?: DesignContract
1553 existingPageIds?: string[]
1554 agentManager: GenerationAgentManager
1555 emit?: (chunk: GenerateChunkEvent) => void
1556 runId?: string
1557 signal?: AbortSignal
1558 }
1559
1560 type RunDeepAgentScopedEditArgs = RunDeepAgentEditBaseArgs & {
1561 editScope: DeckEditScope
1562 selectedPageId?: string
1563 selectedPageNumber?: number
1564 selectedSelector?: string
1565 elementTag?: string
1566 elementText?: string
1567 selectedElementContext?: SelectedElementRuntimeContext
1568 }
1569
1570 type RunDeepAgentPageEditArgs = RunDeepAgentEditBaseArgs & {
1571 editScope: Exclude<DeckEditScope, 'deck'>
1572 selectedPageId?: string
1573 selectedPageNumber?: number
1574 selectedSelector?: string
1575 elementTag?: string
1576 elementText?: string
1577 selectedElementContext?: SelectedElementRuntimeContext
1578 }
1579
1580 type RunDeepAgentDeckAllPageEditArgs = RunDeepAgentEditBaseArgs
1581
1582 const runDeepAgentScopedEdit = async (args: RunDeepAgentScopedEditArgs): Promise<void> => {
1583 const appliesLayoutMaster =
1584 args.editScope === 'deck' || (args.editScope === 'page' && !args.selectedSelector)
1585 const outlineItems = appliesLayoutMaster
1586 ? await resolveLayoutMasterOutlineItems(args.projectDir, args.outlineItems)
1587 : args.outlineItems
1588 const editAgent = createSessionEditAgent({
1589 provider: args.provider,
1590 apiKey: args.apiKey,
1591 model: args.model,
1592 baseUrl: args.baseUrl,
1593 temperature: args.temperature,
1594 maxTokens: args.maxTokens,
1595 modelRuntime: args.agentManager.getSession(args.sessionId)?.modelRuntime,
1596 styleId: args.styleId,
1597 context: {
1598 mode: 'edit',
1599 editScope: args.editScope,
1600 sessionId: args.sessionId,
1601 projectDir: args.projectDir,
1602 indexPath: args.indexPath,
1603 topic: args.topic,
1604 deckTitle: args.deckTitle,
1605 styleId: args.styleId,
1606 styleSkillPrompt: args.styleSkillPrompt,
1607 styleKey: args.styleKey,
1608 styleName: args.styleName,
1609 styleVersion: args.styleVersion,
1610 slideSize: args.slideSize,
1611 appLocale: args.appLocale,
1612 designContract: args.designContract,
1613 userMessage: args.userMessage,
1614 outlineTitles: args.outlineTitles,
1615 outlineItems,
1616 sourceDocumentPaths: args.sourceDocumentPaths,
1617 pageFileMap: args.pageFileMap,
1618 pageNumbers: args.pageNumbers,
1619 selectPageIds: args.selectPageIds,
1620 selectedPageId: args.selectedPageId,
1621 selectedPageNumber: args.selectedPageNumber,
1622 selectedSelector: args.selectedSelector,
1623 elementTag: args.elementTag,
1624 elementText: args.elementText,
1625 selectedElementContext: args.selectedElementContext,
1626 existingPageIds: args.existingPageIds,
1627 allowedPageIds:
1628 args.editScope === 'page' && args.selectedPageId
1629 ? [args.selectedPageId]
1630 : args.editScope === 'deck'
1631 ? Object.keys(args.pageFileMap)
1632 : undefined
1633 }
1634 })
1635 const concurrentDeckPageId =
1636 args.editScope === 'deck' && args.selectPageIds?.length === 1
1637 ? args.selectPageIds[0]
1638 : undefined
1639 if (concurrentDeckPageId) {
1640 args.agentManager.setPageAgent(args.sessionId, concurrentDeckPageId, editAgent)
1641 } else {
1642 args.agentManager.setAgent(args.sessionId, editAgent)
1643 }
1644
1645 args.emit?.({
1646 type: 'llm_status',
1647 payload: {
1648 runId: args.runId || '',
1649 stage: 'editing',
1650 label: concurrentDeckPageId
1651 ? uiText(
1652 args.appLocale,
1653 `正在启动页面 ${concurrentDeckPageId} 的编辑`,
1654 `Starting edit for page ${concurrentDeckPageId}`
1655 )
1656 : progressText(args.appLocale, 'generating'),
1657 progress: 40,
1658 totalPages: args.outlineTitles.length,
1659 provider: args.provider,
1660 model: args.model,
1661 detail:
1662 args.editScope === 'presentation-container'
1663 ? uiText(
1664 args.appLocale,
1665 '仅修改演示容器配置,不会改动 page 页面内容',
1666 'Only modifying the presentation container; page content will not be changed'
1667 )
1668 : args.editScope === 'deck'
1669 ? uiText(
1670 args.appLocale,
1671 '正在按主会话指令修改页面',
1672 'Editing pages from the main-session instruction'
1673 )
1674 : uiText(
1675 args.appLocale,
1676 '仅修改目标页面,不会重排整套内容',
1677 'Only modifying the target page; the whole deck will not be rearranged'
1678 )
1679 }
1680 })
1681
1682 log.info('[deepagent] invoke edit agent', {
1683 sessionId: args.sessionId,
1684 provider: args.provider,
1685 model: args.model,
1686 temperature: args.temperature ?? null,
1687 styleId: args.styleId || '',
1688 projectDir: args.projectDir,
1689 indexPath: args.indexPath,
1690 editScope: args.editScope,
1691 selectedPageId: args.selectedPageId,
1692 selectedPageNumber: args.selectedPageNumber,
1693 concurrentDeckPageId,
1694 selectedSelector: args.selectedSelector || '',
1695 elementTag: args.elementTag || '',
1696 elementText: args.elementText || ''
1697 })
1698
1699 const scopedEditPageIds =
1700 args.selectPageIds && args.selectPageIds.length > 0
1701 ? args.selectPageIds
1702 : args.selectedPageId
1703 ? [args.selectedPageId]
1704 : Object.keys(args.pageFileMap)
1705 const editPageNumberById = new Map(scopedEditPageIds.map((pageId, index) => [pageId, index + 1]))
1706 const totalPages = Math.max(1, scopedEditPageIds.length)
1707 let editProgress = 40
1708 const emitEditStatus = (payload: {
1709 label: string
1710 detail?: string
1711 progress?: number
1712 currentPage?: number
1713 }): void => {
1714 const bounded = Math.max(0, Math.min(100, Math.round(payload.progress ?? editProgress)))
1715 editProgress = Math.max(editProgress, bounded)
1716 args.emit?.({
1717 type: 'llm_status',
1718 payload: {
1719 runId: args.runId || '',
1720 stage: 'editing',
1721 label: payload.label,
1722 detail: payload.detail,
1723 progress: editProgress,
1724 currentPage: payload.currentPage,
1725 totalPages,
1726 provider: args.provider,
1727 model: args.model
1728 }
1729 })
1730 }
1731
1732 try {
1733 const editCombinedSignal = modelCallSignal(args.modelTimeoutMs, 'agent', args.signal)
1734 const stream = await editAgent.stream(
1735 {
1736 messages: [
1737 {
1738 role: 'user',
1739 content: buildEditUserPrompt({
1740 userMessage: args.userMessage,
1741 editScope: args.editScope,
1742 selectedPageId: args.selectedPageId,
1743 selectedPageNumber: args.selectedPageNumber,
1744 selectedSelector: args.selectedSelector,
1745 elementTag: args.elementTag,
1746 elementText: args.elementText,
1747 selectedElementContext: args.selectedElementContext,
1748 existingPageIds: args.existingPageIds
1749 })
1750 }
1751 ]
1752 },
1753 {
1754 streamMode: ['updates', 'messages', 'custom'],
1755 subgraphs: true,
1756 signal: editCombinedSignal
1757 }
1758 )
1759
1760 // Edit replies are built later from validated changed-page facts.
1761 await processAgentStreamCore(stream, {
1762 emit: args.emit,
1763 runId: args.runId || '',
1764 stage: 'editing',
1765 totalPages,
1766 provider: args.provider,
1767 model: args.model,
1768 sessionId: args.sessionId,
1769 workerLabel: concurrentDeckPageId,
1770 onCustom: (custom) => {
1771 emitEditStatus({
1772 label: progressLabel(args.appLocale, custom.label),
1773 detail: custom.detail,
1774 progress: custom.progress ?? 50,
1775 currentPage: custom.pageId ? editPageNumberById.get(custom.pageId) : undefined
1776 })
1777 },
1778 onModelThinking: (defaultProgress) => {
1779 emitEditStatus({
1780 label: concurrentDeckPageId
1781 ? uiText(
1782 args.appLocale,
1783 `正在编辑页面 ${concurrentDeckPageId}`,
1784 `Editing page ${concurrentDeckPageId}`
1785 )
1786 : progressText(args.appLocale, 'understanding'),
1787 detail: concurrentDeckPageId
1788 ? uiText(
1789 args.appLocale,
1790 '正在生成并校验当前页面',
1791 'Generating and validating the current page'
1792 )
1793 : uiText(
1794 args.appLocale,
1795 '正在规划最小改动路径',
1796 'Planning the smallest safe edit path'
1797 ),
1798 progress: defaultProgress
1799 })
1800 },
1801 })
1802 } finally {
1803 if (concurrentDeckPageId) {
1804 args.agentManager.removePageAgent(args.sessionId, concurrentDeckPageId)
1805 } else {
1806 args.agentManager.clearCachedAgent(args.sessionId)
1807 }
1808 }
1809
1810 log.info('[deepagent] edit agent completed', {
1811 sessionId: args.sessionId,
1812 styleId: args.styleId || '',
1813 concurrentDeckPageId
1814 })
1815 }
1816
1817 export const runDeepAgentEdit = async (args: RunDeepAgentPageEditArgs): Promise<void> =>
1818 runDeepAgentScopedEdit(args)
1819
1820 export const runDeepAgentDeckAllPageEdit = async (
1821 args: RunDeepAgentDeckAllPageEditArgs
1822 ): Promise<void> =>
1823 runDeepAgentScopedEdit({
1824 ...args,
1825 editScope: 'deck',
1826 selectedPageId: undefined,
1827 selectedPageNumber: undefined,
1828 selectedSelector: undefined,
1829 elementTag: undefined,
1830 elementText: undefined
1831 })
1832
1832 lines TYPESCRIPT