返回 oh-my-ppt
handlers.ts
根目录 / src / main / session / handlers.ts
1 import { ipcMain } from 'electron'
2 import log from 'electron-log/main.js'
3 import path from 'path'
4 import fs from 'fs'
5 import crypto from 'crypto'
6 import { normalizeSession, normalizeMessage } from '../ipc/utils'
7 import { getStyleDetail, hasStyleSkill } from '../styles/catalog'
8 import type { IpcContext } from '../ipc/context'
9 import { resolveModelConfigForTask } from '../config/model-config-utils'
10 import { readAppLocale, uiText } from '../config/locale-utils'
11 import { normalizeFontSelection } from '@shared/generation'
12 import { requireSlideSizePreset } from '@shared/slide-size'
13 import { normalizeSourcePlan } from '../generation/source-plan'
14 import { ensureSessionRuntimeCompatible } from './runtime-assets'
15 import { GitHistoryService } from '../history/git-history-service'
16 import { allowLocalAssetRoot } from '../io/local-asset-roots'
17 import { resolveOutlinesForPages } from './page-outline-utils'
18 import {
19 normalizeIndexTransitionConfig,
20 parseIndexTransitionConfig,
21 patchIndexTransitionConfig,
22 validateIndexShellHtml
23 } from './index-transition'
24 import { warmSessionFirstPageThumbnails } from './session-thumbnail'
25 import { createSessionMasterIfMissing } from './master-service'
26
27 const THINKING_ID_RE = /^[a-zA-Z0-9_-]{6,32}$/
28 const THINKING_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
29 const THINKING_REFERENCE_SOURCE_EXTENSIONS = new Set(['.md', '.txt', '.text', '.csv'])
30 const THINKING_REFERENCE_THINKING_MD_LINE_OFFSET = 6
31 const MAX_PAGE_COUNT = 500
32
33 const normalizeRequestedPageCount = (value: unknown): number | undefined => {
34 if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) {
35 return undefined
36 }
37 const numberValue = Number(value)
38 if (!Number.isFinite(numberValue)) return undefined
39 return Math.max(1, Math.min(MAX_PAGE_COUNT, Math.floor(numberValue)))
40 }
41
42 const isPathInside = (candidate: string, root: string): boolean => {
43 const relative = path.relative(root, candidate)
44 return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
45 }
46
47 const toSafeAssetName = (value: string): string =>
48 value.replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, '-').replace(/^-+|-+$/g, '') || 'image'
49
50 const detectThinkingWorkspaceDir = (storageRoot: string, referencePath: string): string | null => {
51 if (path.basename(referencePath) !== 'thinking.md') return null
52 const thinkingRoot = path.join(storageRoot, 'thinking')
53 const dir = path.dirname(referencePath)
54 if (!isPathInside(dir, thinkingRoot)) return null
55 const thinkingId = path.basename(dir)
56 return THINKING_ID_RE.test(thinkingId) ? dir : null
57 }
58
59 const copyThinkingAssetsToSession = async (
60 thinkingDir: string,
61 projectDir: string
62 ): Promise<Array<{ fileName: string; sourcePath: string; targetPath: string; publicPath: string }>> => {
63 const assetsDir = path.join(thinkingDir, 'assets')
64 if (!fs.existsSync(assetsDir)) return []
65 const imagesDir = path.join(projectDir, 'images')
66 await fs.promises.mkdir(imagesDir, { recursive: true })
67 allowLocalAssetRoot(imagesDir)
68
69 const entries = await fs.promises.readdir(assetsDir, { withFileTypes: true })
70 const copied: Array<{ fileName: string; sourcePath: string; targetPath: string; publicPath: string }> = []
71 for (const entry of entries) {
72 if (!entry.isFile()) continue
73 const ext = path.extname(entry.name).toLowerCase()
74 if (!THINKING_IMAGE_EXTENSIONS.has(ext)) continue
75 const sourcePath = path.join(assetsDir, entry.name)
76 const fileName = toSafeAssetName(entry.name)
77 const targetPath = path.join(imagesDir, fileName)
78 await fs.promises.copyFile(sourcePath, targetPath)
79 copied.push({
80 fileName,
81 sourcePath,
82 targetPath,
83 publicPath: `./images/${fileName}`
84 })
85 }
86 return copied
87 }
88
89 const rewriteThinkingSourceForSession = (
90 content: string,
91 copiedAssets: Array<{ fileName: string; sourcePath: string; targetPath: string; publicPath: string }>
92 ): string => {
93 let rewritten = content
94 for (const asset of copiedAssets) {
95 rewritten = rewritten
96 .split(asset.sourcePath)
97 .join(asset.targetPath)
98 .split(`thinkingPublicPath: assets/${asset.fileName}`)
99 .join(`publicPath: ${asset.publicPath}`)
100 .split('- sessionAssetPath: (set during generation copy)')
101 .join(`- sessionAssetPath: ${asset.targetPath}`)
102 .split('- publicPath: (set during generation copy)')
103 .join(`- publicPath: ${asset.publicPath}`)
104 }
105 return rewritten
106 }
107
108 const rewriteThinkingWorkspaceArchiveContent = (
109 content: string,
110 thinkingDir: string,
111 archivedThinkingDir: string
112 ): string =>
113 content
114 .split(path.resolve(thinkingDir))
115 .join(path.resolve(archivedThinkingDir))
116 .split(path.join(thinkingDir, 'assets'))
117 .join(path.join(archivedThinkingDir, 'assets'))
118 .split(path.join(thinkingDir, 'sources'))
119 .join(path.join(archivedThinkingDir, 'sources'))
120
121 const copyDirectoryIfExists = async (sourceDir: string, targetDir: string): Promise<void> => {
122 if (!fs.existsSync(sourceDir)) return
123 await fs.promises.mkdir(targetDir, { recursive: true })
124 const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true })
125 for (const entry of entries) {
126 const sourcePath = path.join(sourceDir, entry.name)
127 const targetPath = path.join(targetDir, entry.name)
128 if (entry.isDirectory()) {
129 await copyDirectoryIfExists(sourcePath, targetPath)
130 } else if (entry.isFile()) {
131 await fs.promises.copyFile(sourcePath, targetPath)
132 }
133 }
134 }
135
136 const isRewriteableThinkingArchiveFile = (filePath: string): boolean => {
137 const ext = path.extname(filePath).toLowerCase()
138 return new Set(['.md', '.txt', '.text', '.csv', '.json']).has(ext)
139 }
140
141 const rewriteThinkingWorkspaceArchivePaths = async (
142 archiveDir: string,
143 thinkingDir: string,
144 archivedThinkingDir: string
145 ): Promise<void> => {
146 if (!fs.existsSync(archiveDir)) return
147 const entries = await fs.promises.readdir(archiveDir, { withFileTypes: true })
148 await Promise.all(
149 entries.map(async (entry) => {
150 const filePath = path.join(archiveDir, entry.name)
151 if (entry.isDirectory()) {
152 await rewriteThinkingWorkspaceArchivePaths(filePath, thinkingDir, archivedThinkingDir)
153 return
154 }
155 if (!entry.isFile() || !isRewriteableThinkingArchiveFile(filePath)) return
156 const content = await fs.promises.readFile(filePath, 'utf-8')
157 const rewritten = rewriteThinkingWorkspaceArchiveContent(content, thinkingDir, archivedThinkingDir)
158 if (rewritten !== content) {
159 await fs.promises.writeFile(filePath, rewritten, 'utf-8')
160 }
161 })
162 )
163 }
164
165 const copyThinkingWorkspaceToSession = async (thinkingDir: string, projectDir: string): Promise<void> => {
166 const targetDir = path.join(projectDir, 'thinking')
167 if (fs.existsSync(targetDir)) {
168 await fs.promises.rm(targetDir, { recursive: true, force: true })
169 }
170 await fs.promises.mkdir(targetDir, { recursive: true })
171 await copyDirectoryIfExists(thinkingDir, targetDir)
172 await rewriteThinkingWorkspaceArchivePaths(targetDir, thinkingDir, targetDir)
173 }
174
175 const offsetSourcePlanLineRanges = (
176 items: Array<{
177 pageNumber: number
178 title: string
179 role: 'chapter-divider' | 'content'
180 sourceHeading: string
181 headingLevel: number
182 lineStart: number
183 lineEnd: number
184 reason?: string | null
185 }>,
186 offset: number
187 ): typeof items =>
188 items.map((item) => ({
189 ...item,
190 lineStart: item.lineStart + offset,
191 lineEnd: item.lineEnd + offset
192 }))
193
194 const createThinkingReferenceDocument = async (args: {
195 thinkingDir: string
196 projectDir: string
197 docsDir: string
198 thinkingMdPath: string
199 }): Promise<string> => {
200 const thinkingMd = await fs.promises.readFile(args.thinkingMdPath, 'utf-8')
201 await copyThinkingWorkspaceToSession(args.thinkingDir, args.projectDir)
202 const copiedAssets = await copyThinkingAssetsToSession(args.thinkingDir, args.projectDir)
203
204 // Inline all source content so the generation agent gets everything in one read
205 const sourceSections: string[] = []
206 const sourcesDir = path.join(args.thinkingDir, 'sources')
207 if (fs.existsSync(sourcesDir)) {
208 const entries = await fs.promises.readdir(sourcesDir, { withFileTypes: true })
209 for (const entry of entries) {
210 const ext = path.extname(entry.name).toLowerCase()
211 if (!entry.isFile() || !THINKING_REFERENCE_SOURCE_EXTENSIONS.has(ext)) continue
212 const sourcePath = path.join(sourcesDir, entry.name)
213 const content = await fs.promises.readFile(sourcePath, 'utf-8')
214 sourceSections.push(
215 [`## Source: ${entry.name}`, '', rewriteThinkingSourceForSession(content, copiedAssets)].join('\n')
216 )
217 }
218 }
219
220 const assetSection =
221 copiedAssets.length > 0
222 ? [
223 '## Available Image Assets',
224 '',
225 'These images are available as an asset library. Use them only when the page brief needs an uploaded image. Do not infer style, palette, layout, or visual direction from these assets; the deck style must follow the selected system style preset.',
226 '',
227 ...copiedAssets.map(
228 (asset, index) =>
229 `${index + 1}. ${asset.publicPath}\n - sessionAssetPath: ${asset.targetPath}`
230 )
231 ].join('\n')
232 : ''
233
234 const referenceContent = [
235 '# Thinking Reference',
236 '',
237 'This file was prepared from the exploration workspace. Use the page text as the generation brief. Use available image assets as a library when relevant, but keep visual style governed by the selected system style preset.',
238 '',
239 '## Final Thinking Document',
240 '',
241 thinkingMd,
242 '',
243 assetSection,
244 '',
245 sourceSections.length > 0 ? '# Source Notes' : '',
246 '',
247 ...sourceSections
248 ]
249 .filter((part) => part.trim().length > 0)
250 .join('\n\n')
251
252 const targetPath = path.join(args.docsDir, 'thinking-reference.md')
253 await fs.promises.writeFile(targetPath, referenceContent, 'utf-8')
254 return '/docs/thinking-reference.md'
255 }
256
257 export function registerSessionHandlers(ctx: IpcContext): void {
258 const {
259 db,
260 agentManager,
261 resolveStoragePath,
262 ensureSessionAssets,
263 buildSessionGenerationSnapshot,
264 getPageSourceUrl,
265 resolveSessionProjectDir
266 } = ctx
267
268 const resolvePageHtmlPath = (
269 projectDir: string,
270 fileSlug: string,
271 candidatePath?: string | null
272 ): string => {
273 const projectRoot = path.resolve(projectDir)
274 const fallbackPath = path.resolve(projectRoot, `${fileSlug}.html`)
275 const rawCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''
276 if (!rawCandidate) return fallbackPath
277 const resolvedCandidate = path.isAbsolute(rawCandidate)
278 ? path.resolve(rawCandidate)
279 : path.resolve(projectRoot, rawCandidate)
280 const relative = path.relative(projectRoot, resolvedCandidate)
281 if (relative.startsWith('..') || path.isAbsolute(relative)) return fallbackPath
282 return fs.existsSync(resolvedCandidate) ? resolvedCandidate : fallbackPath
283 }
284
285 ipcMain.handle('session:getIndexTransition', async (_event, payload: unknown) => {
286 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
287 const sessionId =
288 typeof record.sessionId === 'string' && record.sessionId.trim().length > 0
289 ? record.sessionId.trim()
290 : ''
291 if (!sessionId) throw new Error('缺少 sessionId')
292 const projectDir = await resolveSessionProjectDir(sessionId)
293 const indexPath = path.join(projectDir, 'index.html')
294 if (!fs.existsSync(indexPath)) return parseIndexTransitionConfig('')
295 const html = await fs.promises.readFile(indexPath, 'utf-8')
296 return parseIndexTransitionConfig(html)
297 })
298
299 ipcMain.handle('session:setIndexTransition', async (_event, payload: unknown) => {
300 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
301 const sessionId =
302 typeof record.sessionId === 'string' && record.sessionId.trim().length > 0
303 ? record.sessionId.trim()
304 : ''
305 if (!sessionId) throw new Error('缺少 sessionId')
306 const session = await db.getSession(sessionId)
307 if (!session) throw new Error('会话不存在或已被删除')
308 const projectDir = await resolveSessionProjectDir(sessionId)
309 const indexPath = path.join(projectDir, 'index.html')
310 if (!fs.existsSync(indexPath)) throw new Error(`index.html 缺失:${indexPath}`)
311
312 await new GitHistoryService(db).ensureBaseline(sessionId, projectDir).catch((error) => {
313 log.warn('[session:setIndexTransition] ensure history baseline failed', {
314 sessionId,
315 message: error instanceof Error ? error.message : String(error)
316 })
317 })
318 await ensureSessionRuntimeCompatible(ctx, projectDir)
319 const config = normalizeIndexTransitionConfig({
320 type: record.type,
321 durationMs: record.durationMs
322 })
323 const current = await fs.promises.readFile(indexPath, 'utf-8')
324 const next = patchIndexTransitionConfig(current, config)
325 const indexErrors = validateIndexShellHtml(next)
326 if (indexErrors.length > 0) {
327 throw new Error(`index.html 验证失败: ${indexErrors.join('; ')}`)
328 }
329 if (next !== current) {
330 await fs.promises.writeFile(indexPath, next, 'utf-8')
331 await new GitHistoryService(db).recordOperation({
332 sessionId,
333 projectDir,
334 type: 'edit',
335 scope: 'shell',
336 prompt:
337 config.type === 'none'
338 ? '关闭切页动画'
339 : `配置切页动画:${config.type} ${config.durationMs}ms`,
340 metadata: {
341 transition: config,
342 action: 'setIndexTransition'
343 }
344 })
345 }
346 return { ok: true, transition: config }
347 })
348
349 ipcMain.handle('session:create', async (_event, payload) => {
350 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
351 const { topic, styleId } = record
352 const pageCount = normalizeRequestedPageCount(record.pageCount)
353 const slideSize = requireSlideSizePreset(record.slideSizeId)
354 const fontSelection = normalizeFontSelection(record.fontSelection)
355 const sourcePlan = normalizeSourcePlan(record.sourcePlan)
356 const referenceDocumentPath =
357 typeof record.referenceDocumentPath === 'string' ? record.referenceDocumentPath.trim() : ''
358 const locale = await readAppLocale(ctx)
359 const storagePath = await resolveStoragePath()
360 const modelConfigId =
361 typeof record.modelConfigId === 'string' ? record.modelConfigId.trim() : undefined
362 const activeModel = await resolveModelConfigForTask(ctx, {
363 modelConfigId,
364 purpose: 'session:create'
365 })
366 const { provider, model } = activeModel
367 const baseUrl = activeModel.baseUrl
368 const normalizedTopic = typeof topic === 'string' && topic.trim() ? topic.trim() : 'Untitled'
369 const normalizedStyleId = typeof styleId === 'string' ? styleId.trim() : ''
370 if (!normalizedStyleId) {
371 throw new Error(
372 uiText(
373 locale,
374 '创建会话失败:styleId 不能为空。',
375 'Failed to create session: styleId is required.'
376 )
377 )
378 }
379 if (!hasStyleSkill(normalizedStyleId)) {
380 throw new Error(
381 uiText(
382 locale,
383 `创建会话失败:styleId 不存在 ${normalizedStyleId}`,
384 `Failed to create session: styleId does not exist: ${normalizedStyleId}`
385 )
386 )
387 }
388 let validatedReferenceSourcePath: string | null = null
389 const storageRoot = fs.existsSync(storagePath)
390 ? await fs.promises.realpath(storagePath)
391 : path.resolve(storagePath)
392 if (referenceDocumentPath) {
393 const sourcePath = path.resolve(referenceDocumentPath)
394 if (!fs.existsSync(sourcePath)) {
395 throw new Error(
396 uiText(
397 locale,
398 '解析后的文档不存在,请重新解析文档',
399 'The parsed document no longer exists. Parse the document again.'
400 )
401 )
402 }
403 const sourceRealPath = await fs.promises.realpath(sourcePath)
404 const relativeToStorage = path.relative(storageRoot, sourceRealPath)
405 if (relativeToStorage.startsWith('..') || path.isAbsolute(relativeToStorage)) {
406 throw new Error(
407 uiText(
408 locale,
409 '文档路径不在用户配置目录内,请重新解析文档',
410 'The document path is outside the configured storage folder. Parse the document again.'
411 )
412 )
413 }
414 validatedReferenceSourcePath = sourceRealPath
415 }
416 const sessionId = crypto.randomUUID()
417 const projectDir = path.join(storagePath, sessionId)
418
419 if (!fs.existsSync(projectDir)) {
420 fs.mkdirSync(projectDir, { recursive: true })
421 }
422 await ensureSessionAssets(projectDir)
423 await createSessionMasterIfMissing(projectDir)
424 let isThinkingSource = false
425 const copyReferenceDocumentToSession = async (): Promise<string | null> => {
426 if (!validatedReferenceSourcePath) return null
427 const docsDir = path.join(projectDir, 'docs')
428 await fs.promises.mkdir(docsDir, { recursive: true })
429 const thinkingDir = detectThinkingWorkspaceDir(storageRoot, validatedReferenceSourcePath)
430 if (thinkingDir) {
431 isThinkingSource = true
432 return createThinkingReferenceDocument({
433 thinkingDir,
434 projectDir,
435 docsDir,
436 thinkingMdPath: validatedReferenceSourcePath
437 })
438 }
439 const ext = path.extname(validatedReferenceSourcePath).toLowerCase() || '.md'
440 const fileName = `${Date.now()}${ext}`
441 const targetPath = path.join(docsDir, fileName)
442 await fs.promises.copyFile(validatedReferenceSourcePath, targetPath)
443 return `/docs/${fileName}`
444 }
445 const sessionReferenceDocumentPath = await copyReferenceDocumentToSession()
446
447 const styleDetail = getStyleDetail(normalizedStyleId)
448 log.info('[session:create] style selected', {
449 sessionId,
450 styleId: normalizedStyleId,
451 styleKey: styleDetail.styleKey,
452 styleLabel: styleDetail.label
453 })
454
455 await db.createSession({
456 id: sessionId,
457 title: `PPT: ${normalizedTopic}`,
458 topic: normalizedTopic,
459 styleId: normalizedStyleId,
460 pageCount,
461 slideSizeId: slideSize.id,
462 slideWidth: slideSize.width,
463 slideHeight: slideSize.height,
464 referenceDocumentPath: sessionReferenceDocumentPath,
465 provider,
466 model: model.trim()
467 })
468 agentManager.ensureSession({
469 sessionId,
470 provider,
471 model,
472 baseUrl,
473 projectDir,
474 modelRuntime: ctx.modelRuntime
475 })
476 if (sourcePlan && sessionReferenceDocumentPath) {
477 const sourcePlanItems = isThinkingSource
478 ? offsetSourcePlanLineRanges(
479 sourcePlan.pageSkeleton,
480 THINKING_REFERENCE_THINKING_MD_LINE_OFFSET
481 )
482 : sourcePlan.pageSkeleton
483 await db.replaceSourcePageSkeletons({
484 sessionId,
485 sourceDocumentPath: sessionReferenceDocumentPath,
486 sourceDocumentName: isThinkingSource
487 ? path.basename(sessionReferenceDocumentPath)
488 : sourcePlan.sourceDocumentName || path.basename(sessionReferenceDocumentPath),
489 confidence: sourcePlan.confidence,
490 items: sourcePlanItems
491 })
492 }
493 await db.updateSessionMetadata(sessionId, {
494 fontSelection,
495 ...(isThinkingSource ? { source: 'thinking' } : {})
496 })
497
498 await db.createProject({
499 session_id: sessionId,
500 title: normalizedTopic,
501 output_path: projectDir,
502 root_path: projectDir
503 })
504
505 return { sessionId }
506 })
507
508 ipcMain.handle('session:list', async () => {
509 const sessions = await db.listSessions()
510 const snapshots = await Promise.all(
511 sessions.map(async (session) => ({
512 session,
513 snapshot: await buildSessionGenerationSnapshot(
514 session as unknown as Record<string, unknown>,
515 {
516 includeHtml: false
517 }
518 )
519 }))
520 )
521 const thumbnailMap = await warmSessionFirstPageThumbnails(
522 snapshots.map(({ session, snapshot }) => ({
523 sessionId: session.id,
524 pageId: snapshot.pages[0]?.pageId,
525 sourcePath: snapshot.pages[0]?.htmlPath,
526 width: session.slideWidth,
527 height: session.slideHeight
528 }))
529 )
530 const enrichedSessions = await Promise.all(
531 snapshots.map(async ({ session, snapshot }) => {
532 const enriched = snapshot.session || (session as unknown as Record<string, unknown>)
533 enriched.thumbnailPath = thumbnailMap.get(session.id) ?? null
534 const run = await db.getLatestGenerationRun(session.id)
535 if (run && run.updated_at > run.created_at) {
536 enriched.generation_duration_sec = run.updated_at - run.created_at
537 }
538 return enriched
539 })
540 )
541 return enrichedSessions.map((session) =>
542 normalizeSession(session as unknown as Record<string, unknown>)
543 )
544 })
545
546 ipcMain.handle('session:updateTitle', async (_event, payload: unknown) => {
547 const locale = await readAppLocale(ctx)
548 const record =
549 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
550 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
551 const title = typeof record.title === 'string' ? record.title.trim() : ''
552 if (!sessionId) throw new Error(uiText(locale, '会话 ID 不能为空', 'Session ID is required.'))
553 if (!title) throw new Error(uiText(locale, '会话名称不能为空', 'Session title is required.'))
554 if (title.length > 120) {
555 throw new Error(
556 uiText(locale, '会话名称不能超过 120 个字符', 'Session title cannot exceed 120 characters.')
557 )
558 }
559 const existingSession = await db.getSession(sessionId)
560 if (!existingSession) {
561 throw new Error(
562 uiText(locale, '会话不存在或已被删除', 'The session does not exist or has been deleted.')
563 )
564 }
565 await db.updateSessionTitle(sessionId, title)
566 return { ok: true }
567 })
568
569 ipcMain.handle('session:get', async (_event, sessionId) => {
570 const session = await db.getSession(sessionId)
571 if (!session) {
572 return {
573 session: normalizeSession(undefined),
574 messages: [],
575 generatedPages: []
576 }
577 }
578 const messages = await db.getSessionMessages(sessionId, { chatScope: 'main' })
579 const generatedPages: Array<{
580 id: string
581 pageNumber: number
582 title: string
583 contentOutline?: string | null
584 html: string
585 htmlPath?: string
586 pageId?: string
587 sourceUrl?: string
588 status?: string
589 error?: string | null
590 }> = []
591 const sessionPages = await db.listSessionPages(sessionId)
592 if (sessionPages.length === 0) {
593 return {
594 session: normalizeSession({
595 ...(session as unknown as Record<string, unknown>),
596 page_count: 0,
597 generated_count: 0,
598 failed_count: 0
599 }),
600 messages: messages.map((message) =>
601 normalizeMessage(message as unknown as Record<string, unknown>)
602 ),
603 generatedPages: []
604 }
605 }
606 const projectDir = await resolveSessionProjectDir(sessionId)
607 allowLocalAssetRoot(projectDir)
608 await ensureSessionRuntimeCompatible(ctx, projectDir)
609 const outlineBySessionPageId = await resolveOutlinesForPages(db, sessionId, sessionPages)
610 if (!(await db.hasAnyOperationPageSnapshots(sessionId))) {
611 await new GitHistoryService(db).ensureBaseline(sessionId, projectDir).catch((error) => {
612 log.warn('[session:get] ensure history baseline failed', {
613 sessionId,
614 message: error instanceof Error ? error.message : String(error)
615 })
616 })
617 }
618 for (const sp of sessionPages) {
619 const htmlPath = resolvePageHtmlPath(projectDir, sp.file_slug, sp.html_path)
620 let html = ''
621 try {
622 if (htmlPath && fs.existsSync(htmlPath)) {
623 html = fs.readFileSync(htmlPath, 'utf-8')
624 }
625 } catch {
626 html = ''
627 }
628 generatedPages.push({
629 id: sp.id,
630 pageNumber: sp.page_number,
631 title: sp.title,
632 contentOutline: outlineBySessionPageId.get(sp.id) || null,
633 html,
634 htmlPath,
635 pageId: sp.file_slug,
636 sourceUrl: getPageSourceUrl(htmlPath),
637 status: sp.status,
638 error: sp.error
639 })
640 }
641 const completedCount = generatedPages.filter((page) => page.status === 'completed').length
642 const failedCount = generatedPages.filter((page) => page.status === 'failed').length
643
644 return {
645 session: normalizeSession({
646 ...(session as unknown as Record<string, unknown>),
647 page_count: generatedPages.length,
648 generated_count: completedCount,
649 failed_count: failedCount
650 }),
651 messages: messages.map((message) =>
652 normalizeMessage(message as unknown as Record<string, unknown>)
653 ),
654 generatedPages
655 }
656 })
657
658 ipcMain.handle(
659 'session:getMessages',
660 async (_event, payload: { sessionId: string; chatType?: 'main' | 'page'; pageId?: string }) => {
661 const chatType = payload?.chatType === 'page' ? 'page' : 'main'
662 const pageId =
663 chatType === 'page' &&
664 typeof payload?.pageId === 'string' &&
665 payload.pageId.trim().length > 0
666 ? payload.pageId.trim()
667 : undefined
668 const messages = await db.getSessionMessages(payload.sessionId, {
669 chatScope: chatType,
670 pageId
671 })
672 return messages.map((message) =>
673 normalizeMessage(message as unknown as Record<string, unknown>)
674 )
675 }
676 )
677
678 ipcMain.handle('session:delete', async (_event, sessionId) => {
679 await db.deleteSession(sessionId)
680 return { success: true }
681 })
682 }
683
683 lines TYPESCRIPT