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