| 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 |