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