返回 oh-my-ppt
context.ts
根目录 / src / main / generation / context.ts
1 import fs from 'fs'
2 import path from 'path'
3 import type {
4 FontSelection,
5 GenerateStartPayload,
6 SelectedElementRuntimeContext,
7 SessionPageEditPlan,
8 SourceDocumentPlan
9 } from '@shared/generation'
10 import {
11 MAX_SELECTED_PAGES,
12 MAX_STYLE_SWITCH_PAGES,
13 SELECTED_ELEMENT_CONTEXT_COMPUTED_STYLE_PROPERTIES,
14 normalizeAnimationPreferences,
15 normalizeFontSelection,
16 normalizeSessionPageEditPlan,
17 normalizeSelectPageIds
18 } from '@shared/generation'
19 import type { AnimationPreferencesPayload } from '@shared/generation'
20 import type { ModelTimeoutProfile } from '@shared/model-timeout'
21 import type { AgentManager } from '../agent-runtime/agent'
22 import type { ModelRuntimeConfig } from '../agent-runtime/model'
23 import type { GenerateChatType } from './types'
24 import type { PPTDatabase, SessionStyleSnapshotRow } from '../db/database'
25 import { requireSessionSlideSize, type SlideSizePreset } from '@shared/slide-size'
26 import type { RuntimeCredentials } from '../ipc/runtime/credentials'
27 import type { RuntimeLocalFiles } from '../ipc/runtime/local-files'
28 import type { RuntimeEmitters } from '../ipc/runtime/runtime-emitters'
29 import type { SessionProjectResolver } from '../ipc/runtime/session-project'
30 import type { SessionScaffold } from '../ipc/runtime/session-scaffold'
31 import type { SessionRunStateStore } from '../ipc/runtime/session-run-state'
32 import { JobCoordinator } from '../agent-runtime'
33 import { resolveConfiguredImageModel } from '../image-generation/model-config'
34 import { appendStyleImageGuidance } from '../agent-runtime/prompt/composers/style-image-guidance'
35
36 export { resolveSessionReferenceDocumentPath, resolveSourceDocuments } from './source-documents'
37 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
38 import {
39 ensureHistoryBaselineSafe,
40 recordHistoryOperationStrict
41 } from '../history/git-history-service'
42 import { extractOutlineTitles, parseJsonObject } from '../ipc/utils'
43 import { sourcePlanFromSkeletonRows } from './source-plan'
44
45 export type GenerationDbPort = Pick<
46 PPTDatabase,
47 | 'addMessage'
48 | 'createGenerationRun'
49 | 'createGenerationRunWithSessionJob'
50 | 'createImageFulfillmentJob'
51 | 'createProject'
52 | 'getActiveModelConfig'
53 | 'getAllSettings'
54 | 'getImageModelConfig'
55 | 'getGenerationRun'
56 | 'getImageFulfillmentJob'
57 | 'getLatestSessionJob'
58 | 'getModelConfig'
59 | 'getOrCreateSessionStyleSnapshot'
60 | 'getProject'
61 | 'getSession'
62 | 'getSetting'
63 | 'listActiveSessionJobs'
64 | 'listGenerationPages'
65 | 'listImageFulfillmentIntents'
66 | 'listLatestGenerationPageSnapshot'
67 | 'listSessionPages'
68 | 'listSourcePageSkeletons'
69 | 'updateGenerationRunStatus'
70 | 'updateProjectStatus'
71 | 'updateSessionDesignContract'
72 | 'updateSessionJobStatus'
73 | 'updateSessionMetadata'
74 | 'updateSessionStatus'
75 | 'claimImageFulfillmentJob'
76 | 'completeImageFulfillmentJob'
77 | 'transitionImageFulfillmentIntent'
78 | 'transitionImageFulfillmentJob'
79 | 'insertImageGenerationHistory'
80 | 'upsertGenerationPage'
81 | 'upsertSessionPage'
82 >
83
84 export type GenerationTuning = {
85 plannerTemperature: number
86 designContractTemperature: number
87 pageGenerationTemperature: number
88 pageEditWithSelectorTemperature: number
89 pageEditDefaultTemperature: number
90 }
91
92 export type GenerationAgentManager = Pick<
93 AgentManager,
94 | 'clearCachedAgent'
95 | 'ensureSession'
96 | 'getSession'
97 | 'removePageAgent'
98 | 'removeSession'
99 | 'setAgent'
100 | 'setPageAgent'
101 >
102
103 export type GenerationHistory = {
104 ensureBaseline(sessionId: string, projectDir: string): Promise<void>
105 recordOperation(args: Parameters<typeof recordHistoryOperationStrict>[1]): Promise<void>
106 }
107
108 /**
109 * The complete set of capabilities Generation may use. It deliberately owns no
110 * Electron objects and does not inherit the broad IPC compatibility facade.
111 */
112 export type GenerationContext = {
113 db: GenerationDbPort
114 agentManager: GenerationAgentManager
115 modelRuntime: ModelRuntimeConfig
116 sessionRuns: SessionRunStateStore
117 runtimeEmitters: Pick<
118 RuntimeEmitters,
119 | 'emitGenerateChunk'
120 | 'emitRuntimeJobStarted'
121 | 'emitRuntimeJobTerminal'
122 | 'emitSessionRunLifecycle'
123 | 'createDeckProgressEmitter'
124 >
125 sessionProject: Pick<
126 SessionProjectResolver,
127 'getPageSourceUrl' | 'resolveSessionProjectDir' | 'validateProjectIndexHtml'
128 >
129 localFiles: Pick<
130 RuntimeLocalFiles,
131 'assertPathInAllowedRoots' | 'formatImagePathsForPrompt' | 'resolveStoragePath'
132 >
133 sessionScaffold: Pick<SessionScaffold, 'ensureSessionAssets' | 'scaffoldProjectFiles'>
134 credentials: Pick<RuntimeCredentials, 'decryptApiKey'>
135 history: GenerationHistory
136 tuning: GenerationTuning
137 imageCoordinator: JobCoordinator
138 }
139
140 /**
141 * IPC composition helper. The input is structural on purpose so the setup
142 * layer can pass its compatibility facade without Generation importing it.
143 */
144 export type GenerationContextAssembly = Omit<
145 GenerationContext,
146 'history' | 'tuning' | 'imageCoordinator'
147 > & {
148 db: PPTDatabase
149 imageCoordinator?: JobCoordinator
150 PLANNER_TEMPERATURE: number
151 DESIGN_CONTRACT_TEMPERATURE: number
152 PAGE_GENERATION_TEMPERATURE: number
153 PAGE_EDIT_WITH_SELECTOR_TEMPERATURE: number
154 PAGE_EDIT_DEFAULT_TEMPERATURE: number
155 }
156
157 export const createGenerationContext = (args: GenerationContextAssembly): GenerationContext => ({
158 db: args.db,
159 agentManager: args.agentManager,
160 modelRuntime: args.modelRuntime,
161 sessionRuns: args.sessionRuns,
162 runtimeEmitters: args.runtimeEmitters,
163 sessionProject: args.sessionProject,
164 localFiles: args.localFiles,
165 sessionScaffold: args.sessionScaffold,
166 credentials: args.credentials,
167 history: {
168 ensureBaseline: (sessionId, projectDir) =>
169 ensureHistoryBaselineSafe(args.db, sessionId, projectDir),
170 recordOperation: (operation) => recordHistoryOperationStrict(args.db, operation)
171 },
172 imageCoordinator: args.imageCoordinator || new JobCoordinator(),
173 tuning: {
174 plannerTemperature: args.PLANNER_TEMPERATURE,
175 designContractTemperature: args.DESIGN_CONTRACT_TEMPERATURE,
176 pageGenerationTemperature: args.PAGE_GENERATION_TEMPERATURE,
177 pageEditWithSelectorTemperature: args.PAGE_EDIT_WITH_SELECTOR_TEMPERATURE,
178 pageEditDefaultTemperature: args.PAGE_EDIT_DEFAULT_TEMPERATURE
179 }
180 })
181
182 export type CommonGenerationContext = {
183 session: Awaited<ReturnType<GenerationDbPort['getSession']>>
184 sessionRecord: Record<string, unknown>
185 previousSessionStatus: string
186 runId: string
187 provider: string
188 apiKey: string
189 model: string
190 modelConfigId?: string
191 modelConfigName?: string
192 runModel?: string
193 providerBaseUrl: string
194 maxTokens: number
195 modelRuntime: ModelRuntimeConfig
196 modelTimeouts: Record<ModelTimeoutProfile, number>
197 projectDir: string
198 abortSignal: AbortSignal
199 styleId: string
200 styleSnapshot: SessionStyleSnapshotRow
201 styleSkill: {
202 preset: {
203 id: string
204 label: string
205 aliases: string[]
206 description: string
207 fallbackPrompt: string
208 }
209 prompt: string
210 }
211 styleSkillPrompt: string
212 imageGenerationPrompt: string
213 styleKey: string
214 styleName: string
215 styleVersion: string
216 slideSize: SlideSizePreset
217 topic: string
218 deckTitle: string
219 appLocale: 'zh' | 'en'
220 fontSelection: FontSelection
221 sourcePlan: SourceDocumentPlan | null
222 projectId: string
223 visualEnabled: boolean
224 imageModelConfigId?: string
225 }
226
227 /**
228 * Run-scoped identity and cancellation supplied by JobCoordinator. Generation
229 * resolves all expensive context only after this lease has been acquired.
230 */
231 export type RuntimeJobExecutionContext = {
232 runId: string
233 abortSignal: AbortSignal
234 }
235
236 export type NormalizedGenerateInput = {
237 sessionId: string
238 modelConfigId?: string
239 rawUserMessage: string
240 rawImagePaths: string[]
241 rawVideoPaths: string[]
242 rawDocPaths: string[]
243 requestedType?: 'deck' | 'page'
244 resetVisualStyle: boolean
245 persistUserMessage: boolean
246 clientMessageId?: string
247 selectedPageId?: string
248 selectPageIds: string[]
249 htmlPath?: string
250 selector?: string
251 elementTag?: string
252 elementText?: string
253 selectedElementContext?: SelectedElementRuntimeContext
254 chatType: GenerateChatType
255 chatPageId?: string
256 animationPreferences: AnimationPreferencesPayload | null
257 autoApply: boolean
258 approvedPlan?: SessionPageEditPlan
259 failedRunId?: string
260 }
261
262 const MAX_SELECTED_ELEMENT_CONTEXT_ENTRIES = 40
263 const MAX_SELECTED_ELEMENT_CONTEXT_CLASSES = 24
264 const MAX_SELECTED_ELEMENT_CONTEXT_VALUE_LENGTH = 480
265 const PROMPT_SAFE_COMPUTED_STYLE_PROPERTIES = new Set<string>(
266 SELECTED_ELEMENT_CONTEXT_COMPUTED_STYLE_PROPERTIES
267 )
268
269 const normalizeSelectedElementContextValue = (
270 value: unknown,
271 maxLength = MAX_SELECTED_ELEMENT_CONTEXT_VALUE_LENGTH
272 ): string =>
273 String(value ?? '')
274 .replace(/\s+/g, ' ')
275 .trim()
276 .slice(0, maxLength)
277
278 const isSelectedElementContextAttributeName = (value: string): boolean => {
279 const name = value.toLowerCase()
280 return (
281 Boolean(name) &&
282 !name.startsWith('on') &&
283 name !== 'style' &&
284 name !== 'srcdoc' &&
285 !name.startsWith('data-arcsin1-presentation-editor-')
286 )
287 }
288
289 export function normalizeSelectedElementRuntimeContext(
290 value: unknown
291 ): SelectedElementRuntimeContext | undefined {
292 if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
293 const input = value as Record<string, unknown>
294 const attributes: Record<string, string> = {}
295 if (
296 input.attributes &&
297 typeof input.attributes === 'object' &&
298 !Array.isArray(input.attributes)
299 ) {
300 for (const [key, rawValue] of Object.entries(input.attributes)) {
301 if (Object.keys(attributes).length >= MAX_SELECTED_ELEMENT_CONTEXT_ENTRIES) break
302 const name = normalizeSelectedElementContextValue(key, 100).toLowerCase()
303 if (!isSelectedElementContextAttributeName(name)) continue
304 attributes[name] = normalizeSelectedElementContextValue(rawValue)
305 }
306 }
307
308 const inlineStyle: NonNullable<SelectedElementRuntimeContext['inlineStyle']> = {}
309 if (
310 input.inlineStyle &&
311 typeof input.inlineStyle === 'object' &&
312 !Array.isArray(input.inlineStyle)
313 ) {
314 for (const [key, rawDeclaration] of Object.entries(input.inlineStyle)) {
315 if (Object.keys(inlineStyle).length >= MAX_SELECTED_ELEMENT_CONTEXT_ENTRIES) break
316 const property = normalizeSelectedElementContextValue(key, 100).toLowerCase()
317 if (!/^(?:--)?[a-z][a-z0-9-]*$/i.test(property)) continue
318 const declaration =
319 rawDeclaration && typeof rawDeclaration === 'object' && !Array.isArray(rawDeclaration)
320 ? (rawDeclaration as Record<string, unknown>)
321 : null
322 if (!declaration) continue
323 inlineStyle[property] = {
324 value: normalizeSelectedElementContextValue(declaration.value),
325 priority: declaration.priority === 'important' ? 'important' : ''
326 }
327 }
328 }
329
330 const computedStyle: Record<string, string> = {}
331 if (
332 input.computedStyle &&
333 typeof input.computedStyle === 'object' &&
334 !Array.isArray(input.computedStyle)
335 ) {
336 for (const [key, rawValue] of Object.entries(input.computedStyle)) {
337 const property = normalizeSelectedElementContextValue(key, 100).toLowerCase()
338 if (!PROMPT_SAFE_COMPUTED_STYLE_PROPERTIES.has(property)) continue
339 const normalizedValue = normalizeSelectedElementContextValue(rawValue)
340 if (normalizedValue) computedStyle[property] = normalizedValue
341 }
342 }
343
344 const classList = Array.isArray(input.classList)
345 ? input.classList
346 .map((item) => normalizeSelectedElementContextValue(item, 100))
347 .filter(
348 (item) =>
349 Boolean(item) &&
350 !item.startsWith('arcsin1-presentation-editor-') &&
351 !item.startsWith('ppt-inspector-')
352 )
353 .slice(0, MAX_SELECTED_ELEMENT_CONTEXT_CLASSES)
354 : []
355 const boundsInput =
356 input.bounds && typeof input.bounds === 'object' && !Array.isArray(input.bounds)
357 ? (input.bounds as Record<string, unknown>)
358 : null
359 const boundsValues = boundsInput
360 ? [boundsInput.x, boundsInput.y, boundsInput.width, boundsInput.height].map(Number)
361 : []
362 const bounds =
363 boundsValues.length === 4 && boundsValues.every(Number.isFinite)
364 ? {
365 x: Math.round(Math.max(-100_000, Math.min(100_000, boundsValues[0])) * 100) / 100,
366 y: Math.round(Math.max(-100_000, Math.min(100_000, boundsValues[1])) * 100) / 100,
367 width: Math.round(Math.max(0, Math.min(100_000, boundsValues[2])) * 100) / 100,
368 height: Math.round(Math.max(0, Math.min(100_000, boundsValues[3])) * 100) / 100
369 }
370 : undefined
371
372 if (
373 classList.length === 0 &&
374 Object.keys(attributes).length === 0 &&
375 Object.keys(inlineStyle).length === 0 &&
376 Object.keys(computedStyle).length === 0 &&
377 !bounds
378 ) {
379 return undefined
380 }
381 return {
382 ...(classList.length > 0 ? { classList } : {}),
383 ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
384 ...(Object.keys(inlineStyle).length > 0 ? { inlineStyle } : {}),
385 ...(Object.keys(computedStyle).length > 0 ? { computedStyle } : {}),
386 ...(bounds ? { bounds } : {})
387 }
388 }
389
390 export function normalizeGeneratePayload(payload: unknown): NormalizedGenerateInput {
391 const input = payload as GenerateStartPayload
392 const sessionId = String(input?.sessionId || '').trim()
393 const modelConfigId =
394 typeof input?.modelConfigId === 'string' && input.modelConfigId.trim().length > 0
395 ? input.modelConfigId.trim()
396 : undefined
397 const rawUserMessage = typeof input?.userMessage === 'string' ? input.userMessage : ''
398 const rawImagePaths = Array.isArray(input?.imagePaths)
399 ? input.imagePaths
400 .map((item) => String(item || '').trim())
401 .filter((item) => item.startsWith('./images/'))
402 .slice(0, 10)
403 : []
404 const rawVideoPaths = Array.isArray(input?.videoPaths)
405 ? input.videoPaths
406 .map((item) => String(item || '').trim())
407 .filter((item) => item.startsWith('./videos/'))
408 .slice(0, 10)
409 : []
410 const rawDocPaths = Array.isArray(input?.docPaths)
411 ? input.docPaths
412 .map((item) => String(item || '').trim())
413 .filter(Boolean)
414 .slice(0, 1)
415 : []
416 const requestedType =
417 input?.type === 'page' ? 'page' : input?.type === 'deck' ? 'deck' : undefined
418 const resetVisualStyle = input?.resetVisualStyle === true
419 const persistUserMessage = input?.persistUserMessage !== false
420 const rawClientMessageId =
421 typeof input?.clientMessageId === 'string' ? input.clientMessageId.trim() : ''
422 const clientMessageId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
423 rawClientMessageId
424 )
425 ? rawClientMessageId
426 : undefined
427 const selectedPageId =
428 typeof input?.selectedPageId === 'string' && input.selectedPageId.trim().length > 0
429 ? input.selectedPageId.trim()
430 : undefined
431 const selectPageIds = normalizeSelectPageIds(
432 input?.selectPageIds,
433 resetVisualStyle ? MAX_STYLE_SWITCH_PAGES : MAX_SELECTED_PAGES
434 )
435 const htmlPath = typeof input?.htmlPath === 'string' ? input.htmlPath : undefined
436 const selector =
437 typeof input?.selector === 'string' && input.selector.trim().length > 0
438 ? input.selector.trim()
439 : undefined
440 const elementTag =
441 typeof input?.elementTag === 'string' && input.elementTag.trim().length > 0
442 ? input.elementTag.trim()
443 : undefined
444 const elementText =
445 typeof input?.elementText === 'string' && input.elementText.trim().length > 0
446 ? input.elementText.trim()
447 : undefined
448 const selectedElementContext = selector
449 ? normalizeSelectedElementRuntimeContext(input?.selectedElementContext)
450 : undefined
451 const chatType: GenerateChatType = input?.chatType === 'page' ? 'page' : 'main'
452 const chatPageId =
453 chatType === 'page' &&
454 typeof input?.chatPageId === 'string' &&
455 input.chatPageId.trim().length > 0
456 ? input.chatPageId.trim()
457 : undefined
458 const animationPreferences = normalizeAnimationPreferences(input?.animationPreferences)
459 const autoApply = input?.autoApply === true
460 const approvedPlan = normalizeSessionPageEditPlan(input?.approvedPlan)
461 const failedRunIdRaw = (payload as { failedRunId?: unknown } | null)?.failedRunId
462 const failedRunId =
463 typeof failedRunIdRaw === 'string' && failedRunIdRaw.trim().length > 0
464 ? failedRunIdRaw.trim()
465 : undefined
466
467 return {
468 sessionId,
469 modelConfigId,
470 rawUserMessage,
471 rawImagePaths,
472 rawVideoPaths,
473 rawDocPaths,
474 requestedType,
475 resetVisualStyle,
476 persistUserMessage,
477 clientMessageId,
478 selectedPageId,
479 selectPageIds,
480 htmlPath,
481 selector,
482 elementTag,
483 elementText,
484 selectedElementContext,
485 chatType,
486 chatPageId,
487 animationPreferences,
488 autoApply,
489 approvedPlan,
490 failedRunId
491 }
492 }
493
494 export function buildRetryUserMessage(retrySupplementRaw: string): string {
495 const retrySupplement = retrySupplementRaw.trim()
496 return retrySupplement
497 ? [
498 '继续生成本会话中未完成的页面。页面正文、标题、图表标签必须保持与现有页面相同语言。',
499 'Continue generating the unfinished slides in this session. Keep slide text, titles, and chart labels in the same language as existing slides.',
500 'Determine the content language from the existing topic, outline, source materials, existing slides, and the user supplement; do not infer it from this instruction language.',
501 `User supplement:\n${retrySupplement}`
502 ].join('\n')
503 : [
504 '继续生成本会话中未完成的页面。页面正文、标题、图表标签必须保持与现有页面相同语言。',
505 'Continue generating the unfinished slides in this session. Keep slide text, titles, and chart labels in the same language as existing slides.',
506 'Determine the content language from the existing topic, outline, source materials, and existing slides; do not infer it from this instruction language.'
507 ].join('\n')
508 }
509
510 export function buildTotalPages(sessionRecord: Record<string, unknown>): number {
511 const total = Number(sessionRecord.page_count ?? sessionRecord.pageCount)
512 return Math.max(1, Number.isFinite(total) ? Math.floor(total) : 1)
513 }
514
515 export function buildOutlineTitles(rawUserMessage: string): string[] {
516 return extractOutlineTitles(rawUserMessage)
517 }
518
519 function parseJsonArray(value: string): string[] {
520 try {
521 const parsed = JSON.parse(value) as unknown
522 return Array.isArray(parsed) ? parsed.map((item) => String(item || '')).filter(Boolean) : []
523 } catch {
524 return []
525 }
526 }
527
528 export async function resolveCommonContext(
529 ctx: GenerationContext,
530 sessionId: string,
531 modelConfigId?: string,
532 execution?: RuntimeJobExecutionContext
533 ): Promise<CommonGenerationContext> {
534 const { db, agentManager, sessionProject, sessionScaffold } = ctx
535 if (!execution) throw new Error('Runtime job execution context is required')
536
537 const session = await db.getSession(sessionId)
538 if (!session) throw new Error('Session not found')
539 const sessionRecord = session as unknown as Record<string, unknown>
540 const sessionMetadata = parseJsonObject(sessionRecord.metadata ?? sessionRecord.metadata_json)
541 const sourcePlan = sourcePlanFromSkeletonRows(await db.listSourcePageSkeletons(sessionId))
542 const previousSessionStatus = String(sessionRecord.status || 'active')
543 const visualEnabled =
544 Number(sessionRecord.visualEnabled ?? sessionRecord.visual_enabled ?? 0) === 1
545 const imageModelConfigId = String(
546 sessionRecord.imageModelConfigId ?? sessionRecord.image_model_config_id ?? ''
547 ).trim()
548 if (visualEnabled) {
549 if (!imageModelConfigId) {
550 throw new Error('Automatic image generation requires an image model configuration.')
551 }
552 await resolveConfiguredImageModel(
553 { db, decryptApiKey: ctx.credentials.decryptApiKey },
554 imageModelConfigId
555 )
556 }
557
558 const modelConfigContext = {
559 db,
560 decryptApiKey: ctx.credentials.decryptApiKey
561 }
562 const activeModel = await resolveModelConfigForTask(modelConfigContext, {
563 modelConfigId,
564 purpose: 'generation'
565 })
566 const modelTimeouts = await resolveGlobalModelTimeouts({ db })
567 const runModel = JSON.stringify({
568 modelConfigId: activeModel.id,
569 name: activeModel.name,
570 provider: activeModel.provider,
571 model: activeModel.model,
572 baseUrl: activeModel.baseUrl || undefined,
573 maxTokens: activeModel.maxTokens
574 })
575
576 const styleSnapshot = await db.getOrCreateSessionStyleSnapshot(sessionId)
577 const styleId = styleSnapshot.styleId
578 const styleAliases = parseJsonArray(styleSnapshot.aliases)
579 const imageGenerationPrompt = styleSnapshot.imageGenerationPrompt?.trim() || ''
580 const rawStyleSkillPrompt =
581 styleSnapshot.styleSkill?.trim() ||
582 (styleSnapshot.description
583 ? `Use ${styleSnapshot.styleKey} style: ${styleSnapshot.description}`
584 : `Use ${styleSnapshot.styleKey} style.`)
585 const styleSkill = {
586 preset: {
587 id: styleSnapshot.styleId,
588 label: styleSnapshot.styleName,
589 aliases: styleAliases,
590 description: styleSnapshot.description,
591 fallbackPrompt: styleSnapshot.description
592 ? `Use ${styleSnapshot.styleKey} style: ${styleSnapshot.description}`
593 : `Use ${styleSnapshot.styleKey} style.`
594 },
595 prompt: appendStyleImageGuidance(rawStyleSkillPrompt, {
596 visualEnabled,
597 imageGenerationPrompt
598 })
599 }
600
601 const existingProject = await db.getProject(sessionId)
602 if (!existingProject) {
603 const storagePath = await ctx.localFiles.resolveStoragePath()
604 const projectDir = path.join(storagePath, sessionId)
605 if (!fs.existsSync(projectDir)) {
606 fs.mkdirSync(projectDir, { recursive: true })
607 }
608 await db.createProject({
609 session_id: sessionId,
610 title: String(sessionRecord.title || 'Untitled'),
611 output_path: projectDir,
612 root_path: projectDir
613 })
614 }
615 const projectDir = await sessionProject.resolveSessionProjectDir(sessionId)
616 if (!fs.existsSync(projectDir)) {
617 fs.mkdirSync(projectDir, { recursive: true })
618 }
619 await sessionScaffold.ensureSessionAssets(projectDir)
620
621 agentManager.ensureSession({
622 sessionId,
623 provider: activeModel.provider,
624 model: activeModel.model,
625 baseUrl: activeModel.baseUrl,
626 projectDir,
627 modelRuntime: ctx.modelRuntime
628 })
629 const settings = await db.getAllSettings()
630 const appLocale: 'zh' | 'en' = settings.locale === 'en' ? 'en' : 'zh'
631 const projectId = existingProject?.id ?? (await db.getProject(sessionId))?.id
632 if (!projectId) throw new Error('Failed to resolve project for session')
633
634 return {
635 session,
636 sessionRecord,
637 previousSessionStatus,
638 runId: execution.runId,
639 provider: activeModel.provider,
640 apiKey: activeModel.apiKey,
641 model: activeModel.model,
642 modelConfigId: activeModel.id,
643 modelConfigName: activeModel.name,
644 runModel,
645 providerBaseUrl: activeModel.baseUrl,
646 maxTokens: activeModel.maxTokens,
647 modelRuntime: ctx.modelRuntime,
648 modelTimeouts,
649 projectDir,
650 abortSignal: execution.abortSignal,
651 styleId,
652 styleSnapshot,
653 styleSkill,
654 styleSkillPrompt: styleSkill.prompt,
655 imageGenerationPrompt,
656 styleKey: styleSnapshot.styleKey,
657 styleName: styleSnapshot.styleName,
658 styleVersion: styleSnapshot.version,
659 slideSize: requireSessionSlideSize(sessionRecord),
660 topic: String(sessionRecord.topic || '当前主题'),
661 deckTitle: String(sessionRecord.title || 'OhMyPPT Preview'),
662 appLocale,
663 fontSelection: normalizeFontSelection(sessionMetadata.fontSelection),
664 sourcePlan,
665 projectId,
666 visualEnabled,
667 imageModelConfigId: visualEnabled ? imageModelConfigId : undefined
668 }
669 }
670
670 lines TYPESCRIPT