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