返回 oh-my-ppt
template-service.ts
根目录 / src / main / templates / template-service.ts
1 import fs from 'fs'
2 import os from 'os'
3 import path from 'path'
4 import crypto from 'crypto'
5 import { LRUCache } from 'lru-cache'
6 import type { IpcContext } from '../ipc/context'
7 import {
8 resolveGlobalModelTimeouts,
9 resolveModelConfigForTask
10 } from '../config/model-config-utils'
11 import { buildProjectIndexHtml, type DeckPageFile } from '../session/template-builder'
12 import { buildDesignContractWithLLM } from '../generation/agent-runner'
13 import { parseJsonObject } from '../ipc/utils'
14 import { normalizeSourcePlan } from '../generation/source-plan'
15 import { importPptxToEditableHtml, type PptxImportProgressPayload } from '../io/pptx-import'
16 import { createPptxChartRewriteHandler } from '../io/pptx-import/chart-rewrite-agent'
17 import { extractStyleFromExistingHtml } from '../styles/import/pptx'
18 import { createStyleSkill, resolveUsableStyleId } from '../styles/catalog'
19 import { recordHistoryOperationStrict } from '../history/git-history-service'
20 import { ensureMasterStyleLink } from '../presentation/html/master-link'
21 import { createSessionMasterIfMissing } from '../session/master-service'
22 import {
23 captureTemplateCoverThumbnail,
24 warmTemplateCoverThumbnails
25 } from './template-thumbnail'
26 import { copyDirExcluding } from './template-copy'
27 import { resolveTemplateDesignContract } from './template-design-contract'
28 import {
29 manifestToListItem,
30 parseTemplateManifest,
31 type TemplateListItem,
32 type TemplateManifest
33 } from './template-manifest'
34 import {
35 createLowercaseId,
36 createTemplateId,
37 ensureTemplatesRoot,
38 resolveTemplateDir,
39 resolveTemplateManifestPath,
40 resolveTemplateRelativePath
41 } from './template-paths'
42 import {
43 requireSessionSlideSize,
44 requireSlideSize,
45 requireSlideSizePreset
46 } from '@shared/slide-size'
47
48 type CacheValue = { manifest: TemplateManifest; templateDir: string }
49 type PreparedTemplatePage = {
50 id: string
51 pageNumber: number
52 pageId: string
53 title: string
54 htmlPath: string
55 sourceTemplatePageNumber: number
56 }
57
58 const templateManifestCache = new LRUCache<string, CacheValue>({
59 max: 200,
60 ttl: 30 * 1000
61 })
62
63 const templateListCache = new LRUCache<string, TemplateListItem[]>({
64 max: 20,
65 ttl: 30 * 1000
66 })
67
68 const MAX_TEMPLATE_PPTX_SIZE = 80 * 1024 * 1024
69
70 function clearTemplateCache(templatesRoot: string, templateId?: string): void {
71 templateListCache.delete(`list:${templatesRoot}`)
72 if (templateId) templateManifestCache.delete(`manifest:${templatesRoot}:${templateId}`)
73 }
74
75 function createTemplateSessionId(): string {
76 return crypto.randomUUID()
77 }
78
79 function createTemplateSessionPageId(): string {
80 return `page_${createLowercaseId()}`
81 }
82
83 function normalizeTags(value: unknown): string[] {
84 if (Array.isArray(value)) {
85 return value.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 12)
86 }
87 if (typeof value === 'string') {
88 return value
89 .split(/[,,\n]/)
90 .map((item) => item.trim())
91 .filter(Boolean)
92 .slice(0, 12)
93 }
94 return []
95 }
96
97 function resolveTemplateListPaths(templateDir: string, manifest: TemplateManifest): {
98 previewHtmlPath: string | null
99 previewPages: Array<{
100 pageNumber: number
101 pageId: string
102 title: string
103 htmlPath: string
104 }>
105 } {
106 const previewPages = manifest.pages
107 .map((page) => {
108 const htmlPath = resolveTemplateRelativePath(templateDir, page.htmlPath)
109 if (!htmlPath || !fs.existsSync(htmlPath)) return null
110 return {
111 pageNumber: page.pageNumber,
112 pageId: page.pageId,
113 title: page.title,
114 htmlPath
115 }
116 })
117 .filter((page): page is NonNullable<typeof page> => Boolean(page))
118 const previewHtmlPath = previewPages[0]?.htmlPath || null
119 return {
120 previewHtmlPath,
121 previewPages
122 }
123 }
124
125 async function attachTemplateCoverThumbnails(
126 items: TemplateListItem[],
127 delayMs = 300
128 ): Promise<TemplateListItem[]> {
129 const thumbnailMap = await warmTemplateCoverThumbnails(
130 items.map((item) => ({
131 templateId: item.id,
132 sourcePath: item.previewHtmlPath,
133 pageId: item.previewPages[0]?.pageId,
134 width: item.slideWidth,
135 height: item.slideHeight
136 })),
137 delayMs
138 )
139 return items.map((item) => ({
140 ...item,
141 thumbnailPath: thumbnailMap.get(item.id) || null
142 }))
143 }
144
145 function warmCreatedTemplateCover(templateDir: string, manifest: TemplateManifest): void {
146 const paths = resolveTemplateListPaths(templateDir, manifest)
147 void warmTemplateCoverThumbnails(
148 [
149 {
150 templateId: manifest.id,
151 sourcePath: paths.previewHtmlPath,
152 pageId: paths.previewPages[0]?.pageId,
153 width: manifest.slideWidth,
154 height: manifest.slideHeight
155 }
156 ],
157 0
158 )
159 }
160
161 async function readManifest(templatesRoot: string, templateId: string): Promise<CacheValue> {
162 const cacheKey = `manifest:${templatesRoot}:${templateId}`
163 const cached = templateManifestCache.get(cacheKey)
164 if (cached) return cached
165 const templateDir = resolveTemplateDir(templatesRoot, templateId)
166 const manifestPath = resolveTemplateManifestPath(templatesRoot, templateId)
167 const raw = await fs.promises.readFile(manifestPath, 'utf-8')
168 const manifest = parseTemplateManifest(JSON.parse(raw))
169 const value = { manifest, templateDir }
170 templateManifestCache.set(cacheKey, value)
171 return value
172 }
173
174 export async function loadTemplateManifest(
175 templateId: string
176 ): Promise<{ manifest: TemplateManifest; templateDir: string }> {
177 const templatesRoot = await ensureTemplatesRoot()
178 return readManifest(templatesRoot, templateId)
179 }
180
181 async function writeManifest(templateDir: string, manifest: TemplateManifest): Promise<void> {
182 await fs.promises.writeFile(
183 path.join(templateDir, 'manifest.json'),
184 JSON.stringify(manifest, null, 2),
185 'utf-8'
186 )
187 }
188
189 async function copyReferenceDocumentToSession(args: {
190 sourcePath: string
191 storageRoot: string
192 projectDir: string
193 }): Promise<string | null> {
194 const sourcePath = args.sourcePath.trim()
195 if (!sourcePath) return null
196 const resolvedSourcePath = path.resolve(sourcePath)
197 if (!fs.existsSync(resolvedSourcePath)) throw new Error('解析后的文档不存在,请重新解析文档')
198
199 const sourceRealPath = await fs.promises.realpath(resolvedSourcePath)
200 const relativeToStorage = path.relative(args.storageRoot, sourceRealPath)
201 if (relativeToStorage.startsWith('..') || path.isAbsolute(relativeToStorage)) {
202 throw new Error('文档路径不在用户配置目录内,请重新解析文档')
203 }
204
205 const docsDir = path.join(args.projectDir, 'docs')
206 await fs.promises.mkdir(docsDir, { recursive: true })
207 const ext = path.extname(sourceRealPath).toLowerCase() || '.md'
208 const fileName = `${Date.now()}${ext}`
209 await fs.promises.copyFile(sourceRealPath, path.join(docsDir, fileName))
210 return `/docs/${fileName}`
211 }
212
213 function pickTemplateSourcePage(
214 pages: TemplateManifest['pages'],
215 outputIndex: number,
216 totalPages: number
217 ): TemplateManifest['pages'][number] {
218 if (pages.length === 1 || totalPages === 1) return pages[0]
219 if (outputIndex === 0) return pages[0]
220 if (outputIndex === totalPages - 1) return pages[pages.length - 1]
221
222 const middlePages = pages.slice(1, -1)
223 if (middlePages.length === 0) return pages[0]
224 const middleOutputCount = Math.max(1, totalPages - 2)
225 const middleOutputIndex = outputIndex - 1
226 const sourceIndex =
227 middleOutputCount === 1
228 ? 0
229 : Math.round((middleOutputIndex * (middlePages.length - 1)) / (middleOutputCount - 1))
230 return middlePages[Math.max(0, Math.min(middlePages.length - 1, sourceIndex))]
231 }
232
233 function replacePageIdentity(html: string, oldPageId: string, nextPageId: string): string {
234 const oldId = oldPageId.trim()
235 if (!oldId || oldId === nextPageId) return html
236 const escapedOldId = oldId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
237 const boundaryPattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapedOldId}(?=$|[^A-Za-z0-9_-])`, 'g')
238 return html.replace(boundaryPattern, `$1${nextPageId}`)
239 }
240
241 function rewriteTemplatePageIdentities(
242 html: string,
243 idMap: Map<string, string>,
244 sourcePageId: string,
245 targetPageId: string
246 ): string {
247 let rewritten = html
248 for (const [oldPageId, newPageId] of idMap) {
249 rewritten = replacePageIdentity(rewritten, oldPageId, newPageId)
250 }
251 return replacePageIdentity(rewritten, sourcePageId, targetPageId)
252 }
253
254 async function prepareTemplatePagesForSession(args: {
255 manifest: TemplateManifest
256 projectDir: string
257 totalPages: number
258 }): Promise<PreparedTemplatePage[]> {
259 const templatePages = args.manifest.pages.slice().sort((a, b) => a.pageNumber - b.pageNumber)
260 if (templatePages.length === 0) throw new Error('模板没有可用页面')
261
262 const usedTargetPaths = new Set<string>()
263 const sourceHtmlPaths = new Set(templatePages.map((page) => page.htmlPath.replace(/\\/g, '/')))
264 const pagePlan = Array.from({ length: args.totalPages }, (_unused, outputIndex) => {
265 const pageNumber = outputIndex + 1
266 const sourcePage = pickTemplateSourcePage(templatePages, outputIndex, args.totalPages)
267 return {
268 pageNumber,
269 sourcePage,
270 pageId: `page-${createLowercaseId()}`,
271 id: createTemplateSessionPageId()
272 }
273 })
274 const sourceIdToFirstTargetId = new Map<string, string>()
275 for (const item of pagePlan) {
276 if (!sourceIdToFirstTargetId.has(item.sourcePage.pageId)) {
277 sourceIdToFirstTargetId.set(item.sourcePage.pageId, item.pageId)
278 }
279 }
280
281 const preparedPages: PreparedTemplatePage[] = []
282 for (const item of pagePlan) {
283 const { pageNumber, sourcePage, pageId } = item
284 const sourcePath = path.resolve(args.projectDir, sourcePage.htmlPath)
285 const relativeToProject = path.relative(args.projectDir, sourcePath)
286 if (relativeToProject.startsWith('..') || path.isAbsolute(relativeToProject)) {
287 throw new Error('模板页面路径越界')
288 }
289 if (!fs.existsSync(sourcePath)) {
290 throw new Error(`模板页面不存在:${sourcePage.htmlPath}`)
291 }
292
293 const relativeHtmlPath = `${pageId}.html`
294 const targetPath = path.resolve(args.projectDir, relativeHtmlPath)
295 const html = await fs.promises.readFile(sourcePath, 'utf-8')
296 await fs.promises.writeFile(
297 targetPath,
298 ensureMasterStyleLink(
299 rewriteTemplatePageIdentities(html, sourceIdToFirstTargetId, sourcePage.pageId, pageId)
300 ),
301 'utf-8'
302 )
303 usedTargetPaths.add(path.relative(args.projectDir, targetPath).replace(/\\/g, '/'))
304 preparedPages.push({
305 id: item.id,
306 pageNumber,
307 pageId,
308 title: `第 ${pageNumber} 页`,
309 htmlPath: targetPath,
310 sourceTemplatePageNumber: sourcePage.pageNumber
311 })
312 }
313
314 await Promise.all(
315 Array.from(sourceHtmlPaths).map(async (relativeHtmlPath) => {
316 if (usedTargetPaths.has(relativeHtmlPath)) return
317 const sourcePath = path.resolve(args.projectDir, relativeHtmlPath)
318 const relativeToProject = path.relative(args.projectDir, sourcePath)
319 if (relativeToProject.startsWith('..') || path.isAbsolute(relativeToProject)) return
320 await fs.promises.rm(sourcePath, { force: true })
321 })
322 )
323 await fs.promises.rm(path.join(args.projectDir, 'manifest.json'), { force: true })
324
325 return preparedPages
326 }
327
328 export async function listTemplates(): Promise<{ items: TemplateListItem[] }> {
329 const templatesRoot = await ensureTemplatesRoot()
330 const cacheKey = `list:${templatesRoot}`
331 const cached = templateListCache.get(cacheKey)
332 if (cached) return { items: await attachTemplateCoverThumbnails(cached) }
333
334 const entries = await fs.promises.readdir(templatesRoot, { withFileTypes: true }).catch(() => [])
335 const items: TemplateListItem[] = []
336 for (const entry of entries) {
337 if (!entry.isDirectory()) continue
338 try {
339 const { manifest, templateDir } = await readManifest(templatesRoot, entry.name)
340 items.push(manifestToListItem(manifest, resolveTemplateListPaths(templateDir, manifest)))
341 } catch {
342 // Ignore malformed template folders; they should not break the template library.
343 }
344 }
345
346 items.sort((a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt)
347 templateListCache.set(cacheKey, items)
348 return { items: await attachTemplateCoverThumbnails(items) }
349 }
350
351 export async function getTemplate(templateId: string): Promise<{
352 manifest: TemplateManifest
353 previewHtmlPath: string | null
354 }> {
355 const templatesRoot = await ensureTemplatesRoot()
356 const { manifest, templateDir } = await readManifest(templatesRoot, templateId)
357 return {
358 manifest,
359 ...resolveTemplateListPaths(templateDir, manifest)
360 }
361 }
362
363 export async function updateTemplateMetadata(payload: unknown): Promise<{
364 success: true
365 item: TemplateListItem
366 }> {
367 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
368 const templateId = typeof record.templateId === 'string' ? record.templateId.trim() : ''
369 const name = typeof record.name === 'string' ? record.name.trim() : ''
370 if (!name) throw new Error('模板名称不能为空')
371
372 const templatesRoot = await ensureTemplatesRoot()
373 const { manifest, templateDir } = await readManifest(templatesRoot, templateId)
374 const nextManifest: TemplateManifest = {
375 ...manifest,
376 name,
377 description: typeof record.description === 'string' ? record.description.trim() : '',
378 tags: normalizeTags(record.tags),
379 updatedAt: Date.now()
380 }
381 await writeManifest(templateDir, nextManifest)
382 clearTemplateCache(templatesRoot, templateId)
383 const [item] = await attachTemplateCoverThumbnails([
384 manifestToListItem(nextManifest, resolveTemplateListPaths(templateDir, nextManifest))
385 ])
386 return {
387 success: true,
388 item
389 }
390 }
391
392 export async function createTemplateFromSession(
393 ctx: IpcContext,
394 payload: unknown
395 ): Promise<{ success: true; id: string }> {
396 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
397 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
398 if (!sessionId) throw new Error('缺少 sessionId')
399
400 const session = await ctx.db.getSession(sessionId)
401 if (!session) throw new Error('Session not found')
402 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
403 const pages = (await ctx.db.listSessionPages(sessionId)).filter((page) => page.status === 'completed')
404 if (pages.length === 0) throw new Error('至少生成 1 页后才能保存为模板')
405
406 const templatesRoot = await ensureTemplatesRoot()
407 const templateId = createTemplateId()
408 const templateDir = resolveTemplateDir(templatesRoot, templateId)
409 await fs.promises.mkdir(templateDir, { recursive: true })
410 await copyDirExcluding(projectDir, templateDir)
411
412 const projectRoot = path.resolve(projectDir)
413 const templatePages = pages
414 .map((page) => {
415 const sourcePath = path.isAbsolute(page.html_path)
416 ? path.resolve(page.html_path)
417 : path.resolve(projectRoot, page.html_path)
418 if (!sourcePath || !fs.existsSync(sourcePath)) return null
419 const relativeHtmlPath = path.relative(projectRoot, sourcePath)
420 if (relativeHtmlPath.startsWith('..') || path.isAbsolute(relativeHtmlPath)) return null
421 return {
422 page,
423 htmlPath: relativeHtmlPath
424 }
425 })
426 .filter((item): item is { page: (typeof pages)[number]; htmlPath: string } => Boolean(item))
427 if (templatePages.length === 0) throw new Error('没有可保存的页面文件')
428
429 const now = Date.now()
430 const metadata = parseJsonObject(session.metadata)
431 const designContract = resolveTemplateDesignContract(session.designContract, metadata)
432 const styleId = session.styleId || null
433 const slideSize = requireSessionSlideSize(session)
434
435 const inputName = typeof record.name === 'string' ? record.name.trim() : ''
436 const inputDescription = typeof record.description === 'string' ? record.description.trim() : ''
437 const manifest: TemplateManifest = {
438 schemaVersion: 1,
439 id: templateId,
440 name: inputName || session.title || '未命名模板',
441 description: inputDescription,
442 sourceSessionId: sessionId,
443 createdAt: now,
444 updatedAt: now,
445 pageCount: templatePages.length,
446 tags: normalizeTags(record.tags),
447 styleId,
448 slideSizeId: slideSize.id,
449 slideWidth: slideSize.width,
450 slideHeight: slideSize.height,
451 designContract,
452 pages: templatePages.map(({ page, htmlPath }, index) => {
453 return {
454 pageNumber: page.page_number || index + 1,
455 pageId: page.file_slug,
456 title: page.title || `第 ${index + 1} 页`,
457 htmlPath
458 }
459 })
460 }
461
462 await writeManifest(templateDir, manifest)
463 clearTemplateCache(templatesRoot, templateId)
464 warmCreatedTemplateCover(templateDir, manifest)
465 return { success: true, id: templateId }
466 }
467
468 export async function importPptxAsTemplate(
469 ctx: IpcContext,
470 payload: unknown,
471 onProgress?: (progress: PptxImportProgressPayload) => void
472 ): Promise<{ success: true; id: string; pageCount: number; warnings: string[] }> {
473 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
474 const rawFilePath = typeof record.filePath === 'string' ? record.filePath.trim() : ''
475 const inputName = typeof record.name === 'string' ? record.name.trim() : ''
476 if (!rawFilePath) throw new Error('PPTX 文件路径不能为空')
477
478 const sourcePath = await ctx.resolveExistingFileRealPath(rawFilePath)
479 if (path.extname(sourcePath).toLowerCase() !== '.pptx') {
480 throw new Error('仅支持导入 .pptx 文件')
481 }
482 const stat = await fs.promises.stat(sourcePath)
483 if (stat.size > MAX_TEMPLATE_PPTX_SIZE) {
484 throw new Error('PPTX 文件不能超过 80MB')
485 }
486
487 const originalFileName = path.basename(sourcePath)
488 const title = inputName || path.basename(originalFileName, path.extname(originalFileName)) || '导入的 PPTX 模板'
489 const templatesRoot = await ensureTemplatesRoot()
490 const templateId = createTemplateId()
491 const templateDir = resolveTemplateDir(templatesRoot, templateId)
492 const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-template-pptx-'))
493 const modelConfigId =
494 typeof record.modelConfigId === 'string' ? record.modelConfigId.trim() : undefined
495 const activeModel = await resolveModelConfigForTask(ctx, {
496 modelConfigId,
497 purpose: 'templates:importPptx'
498 })
499 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
500
501 try {
502 await ctx.ensureSessionAssets(tempDir)
503 const imported = await importPptxToEditableHtml({
504 filePath: sourcePath,
505 projectDir: tempDir,
506 title,
507 onProgress,
508 chartRewrite: createPptxChartRewriteHandler({
509 provider: activeModel.provider,
510 apiKey: activeModel.apiKey,
511 model: activeModel.model,
512 baseUrl: activeModel.baseUrl,
513 maxTokens: activeModel.maxTokens,
514 modelRuntime: ctx.modelRuntime,
515 modelTimeoutMs: modelTimeouts.document
516 })
517 })
518 if (imported.pages.length === 0) {
519 throw new Error('PPTX 未解析出可用页面')
520 }
521
522 onProgress?.({
523 stage: 'database',
524 progress: 92,
525 label: '正在抽取模板风格',
526 totalPages: imported.pageCount
527 })
528 const styleResult = await extractStyleFromExistingHtml({
529 projectDir: tempDir,
530 pageHtmlPaths: imported.pages.map((page) => path.basename(page.htmlPath)),
531 sourceFilePath: sourcePath,
532 provider: activeModel.provider,
533 apiKey: activeModel.apiKey,
534 model: activeModel.model,
535 baseUrl: activeModel.baseUrl,
536 maxTokens: activeModel.maxTokens,
537 modelTimeoutMs: modelTimeouts.document
538 })
539 const styleId = `style-${createLowercaseId()}`
540 await createStyleSkill({
541 id: styleId,
542 label: styleResult.label,
543 description: styleResult.description,
544 category: styleResult.category,
545 aliases: styleResult.aliases,
546 prompt: styleResult.styleSkill,
547 styleCase: styleResult.styleCase
548 })
549
550 onProgress?.({
551 stage: 'database',
552 progress: 94,
553 label: '正在生成模板设计契约',
554 totalPages: imported.pageCount
555 })
556 const slideSize = requireSlideSizePreset('wide-16-9')
557 const designContract = await buildDesignContractWithLLM({
558 provider: activeModel.provider,
559 apiKey: activeModel.apiKey,
560 model: activeModel.model,
561 baseUrl: activeModel.baseUrl,
562 maxTokens: activeModel.maxTokens,
563 modelRuntime: ctx.modelRuntime,
564 styleId,
565 styleSkillPrompt: styleResult.styleSkill,
566 modelTimeoutMs: modelTimeouts.document,
567 totalPages: imported.pageCount,
568 slideSize,
569 topic: title
570 })
571
572 onProgress?.({
573 stage: 'database',
574 progress: 96,
575 label: '正在写入模板',
576 totalPages: imported.pageCount
577 })
578
579 await fs.promises.mkdir(templateDir, { recursive: true })
580 await copyDirExcluding(tempDir, templateDir)
581
582 const now = Date.now()
583 const manifest: TemplateManifest = {
584 schemaVersion: 1,
585 id: templateId,
586 name: imported.title || title,
587 description: '',
588 createdAt: now,
589 updatedAt: now,
590 pageCount: imported.pageCount,
591 tags: [],
592 styleId,
593 slideSizeId: slideSize.id,
594 slideWidth: slideSize.width,
595 slideHeight: slideSize.height,
596 designContract,
597 pages: imported.pages.map((page, index) => {
598 const relativeHtmlPath = path.relative(tempDir, page.htmlPath).split(path.sep).join('/')
599 return {
600 pageNumber: page.pageNumber || index + 1,
601 pageId: page.pageId,
602 title: page.title || `第 ${index + 1} 页`,
603 htmlPath:
604 relativeHtmlPath && !relativeHtmlPath.startsWith('..') && !path.isAbsolute(relativeHtmlPath)
605 ? relativeHtmlPath
606 : `${page.pageId}.html`
607 }
608 })
609 }
610
611 await writeManifest(templateDir, manifest)
612 clearTemplateCache(templatesRoot, templateId)
613
614 onProgress?.({
615 stage: 'database',
616 progress: 98,
617 label: '正在生成模板封面',
618 totalPages: imported.pageCount
619 })
620 const paths = resolveTemplateListPaths(templateDir, manifest)
621 await captureTemplateCoverThumbnail({
622 templateId: manifest.id,
623 sourcePath: paths.previewHtmlPath,
624 pageId: paths.previewPages[0]?.pageId,
625 width: manifest.slideWidth,
626 height: manifest.slideHeight
627 })
628
629 onProgress?.({
630 stage: 'completed',
631 progress: 100,
632 label: '模板导入完成',
633 totalPages: imported.pageCount
634 })
635
636 return { success: true, id: templateId, pageCount: imported.pageCount, warnings: imported.warnings }
637 } catch (error) {
638 await fs.promises.rm(templateDir, { recursive: true, force: true }).catch(() => undefined)
639 throw error
640 } finally {
641 await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
642 }
643 }
644
645 export async function deleteTemplate(templateId: string): Promise<{ success: true; deleted: boolean }> {
646 const templatesRoot = await ensureTemplatesRoot()
647 const templateDir = resolveTemplateDir(templatesRoot, templateId)
648 if (!fs.existsSync(templateDir)) return { success: true, deleted: false }
649 await fs.promises.rm(templateDir, { recursive: true, force: true })
650 clearTemplateCache(templatesRoot, templateId)
651 return { success: true, deleted: true }
652 }
653
654 export async function createSessionFromTemplate(
655 ctx: IpcContext,
656 payload: unknown
657 ): Promise<{ success: true; sessionId: string }> {
658 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
659 const templateId = typeof record.templateId === 'string' ? record.templateId.trim() : ''
660 const title = typeof record.title === 'string' && record.title.trim() ? record.title.trim() : ''
661 const requestedPageCount = Number(record.pageCount)
662 const pageCount = Number.isFinite(requestedPageCount)
663 ? Math.max(1, Math.min(500, Math.floor(requestedPageCount)))
664 : undefined
665 const referenceDocumentPath =
666 typeof record.referenceDocumentPath === 'string' ? record.referenceDocumentPath.trim() : ''
667 const sourcePlan = normalizeSourcePlan(record.sourcePlan)
668
669 const templatesRoot = await ensureTemplatesRoot()
670 const { manifest, templateDir } = await readManifest(templatesRoot, templateId)
671 const slideSize = requireSlideSize({
672 id: manifest.slideSizeId,
673 width: manifest.slideWidth,
674 height: manifest.slideHeight
675 })
676 if (manifest.pages.length === 0) throw new Error('模板没有可创建的页面')
677
678 const modelConfigId =
679 typeof record.modelConfigId === 'string' ? record.modelConfigId.trim() : undefined
680 const activeModel = await resolveModelConfigForTask(ctx, {
681 modelConfigId,
682 purpose: 'templates:createSession'
683 })
684 const storagePath = await ctx.resolveStoragePath()
685 const storageRoot = fs.existsSync(storagePath)
686 ? await fs.promises.realpath(storagePath)
687 : path.resolve(storagePath)
688 const sessionId = createTemplateSessionId()
689 const styleId = resolveUsableStyleId(manifest.styleId)
690 const projectDir = path.join(storagePath, sessionId)
691 const deckTitle = title || manifest.name || '从模板创建的演示'
692 const resolvedPageCount = pageCount || manifest.pageCount || manifest.pages.length
693 await fs.promises.mkdir(projectDir, { recursive: true })
694 await copyDirExcluding(templateDir, projectDir, { exclude: ['manifest.json'] })
695 await ctx.ensureSessionAssets(projectDir)
696 await createSessionMasterIfMissing(projectDir)
697 const preparedPages = await prepareTemplatePagesForSession({
698 manifest,
699 projectDir,
700 totalPages: resolvedPageCount
701 })
702 const indexPages: DeckPageFile[] = preparedPages.map((page) => ({
703 id: page.id,
704 pageNumber: page.pageNumber,
705 pageId: page.pageId,
706 title: page.title,
707 htmlPath: path.basename(page.htmlPath)
708 }))
709 const indexPath = path.join(projectDir, 'index.html')
710 await fs.promises.writeFile(
711 indexPath,
712 buildProjectIndexHtml(deckTitle, indexPages, slideSize),
713 'utf-8'
714 )
715 const userReferenceDocumentPath = await copyReferenceDocumentToSession({
716 sourcePath: referenceDocumentPath,
717 storageRoot,
718 projectDir
719 })
720 await ctx.db.createSession({
721 id: sessionId,
722 title: `PPT: ${deckTitle}`,
723 topic: deckTitle,
724 styleId,
725 pageCount: resolvedPageCount,
726 slideSizeId: slideSize.id,
727 slideWidth: slideSize.width,
728 slideHeight: slideSize.height,
729 referenceDocumentPath: userReferenceDocumentPath,
730 provider: activeModel.provider,
731 model: activeModel.model.trim()
732 })
733 ctx.agentManager.ensureSession({
734 sessionId,
735 provider: activeModel.provider,
736 model: activeModel.model,
737 baseUrl: activeModel.baseUrl,
738 projectDir,
739 modelRuntime: ctx.modelRuntime
740 })
741 if (sourcePlan && userReferenceDocumentPath) {
742 await ctx.db.replaceSourcePageSkeletons({
743 sessionId,
744 sourceDocumentPath: userReferenceDocumentPath,
745 sourceDocumentName: sourcePlan.sourceDocumentName || path.basename(userReferenceDocumentPath),
746 confidence: sourcePlan.confidence,
747 items: sourcePlan.pageSkeleton
748 })
749 }
750 const designContract = resolveTemplateDesignContract(manifest.designContract)
751 await ctx.db.updateSessionDesignContract(sessionId, designContract)
752 const projectId = await ctx.db.createProject({
753 session_id: sessionId,
754 title: deckTitle,
755 output_path: projectDir,
756 root_path: projectDir
757 })
758 for (const page of preparedPages) {
759 await ctx.db.upsertSessionPage({
760 id: page.id,
761 sessionId,
762 legacyPageId: null,
763 fileSlug: page.pageId,
764 pageNumber: page.pageNumber,
765 title: page.title,
766 htmlPath: page.htmlPath,
767 status: 'pending',
768 error: null
769 })
770 }
771
772 const metadata = {
773 source: 'template',
774 templateId,
775 createdFromTemplateAt: Date.now(),
776 indexPath,
777 projectId
778 }
779 await ctx.db.updateSessionMetadata(sessionId, metadata)
780 await ctx.db.updateProjectStatus(projectId, 'draft')
781
782 return { success: true, sessionId }
783 }
784
785 export async function createEditableSessionFromTemplate(
786 ctx: IpcContext,
787 payload: unknown
788 ): Promise<{ success: true; sessionId: string }> {
789 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
790 const templateId = typeof record.templateId === 'string' ? record.templateId.trim() : ''
791 const title = typeof record.title === 'string' && record.title.trim() ? record.title.trim() : ''
792
793 const templatesRoot = await ensureTemplatesRoot()
794 const { manifest, templateDir } = await readManifest(templatesRoot, templateId)
795 const slideSize = requireSlideSize({
796 id: manifest.slideSizeId,
797 width: manifest.slideWidth,
798 height: manifest.slideHeight
799 })
800 if (manifest.pages.length === 0) throw new Error('模板没有可创建的页面')
801
802 const storagePath = await ctx.resolveStoragePath()
803 const sessionId = createTemplateSessionId()
804 const styleId = resolveUsableStyleId(manifest.styleId)
805 const projectDir = path.join(storagePath, sessionId)
806 const deckTitle = title || manifest.name || '从模板创建的演示'
807
808 await fs.promises.mkdir(projectDir, { recursive: true })
809 await copyDirExcluding(templateDir, projectDir, { exclude: ['manifest.json'] })
810 await ctx.ensureSessionAssets(projectDir)
811 await createSessionMasterIfMissing(projectDir)
812 const preparedPages = await prepareTemplatePagesForSession({
813 manifest,
814 projectDir,
815 totalPages: manifest.pageCount || manifest.pages.length
816 })
817 const indexPages: DeckPageFile[] = preparedPages.map((page) => ({
818 id: page.id,
819 pageNumber: page.pageNumber,
820 pageId: page.pageId,
821 title: page.title,
822 htmlPath: path.basename(page.htmlPath)
823 }))
824 const indexPath = path.join(projectDir, 'index.html')
825 await fs.promises.writeFile(
826 indexPath,
827 buildProjectIndexHtml(deckTitle, indexPages, slideSize),
828 'utf-8'
829 )
830
831 await ctx.db.createSession({
832 id: sessionId,
833 title: deckTitle,
834 topic: deckTitle,
835 styleId,
836 pageCount: preparedPages.length,
837 slideSizeId: slideSize.id,
838 slideWidth: slideSize.width,
839 slideHeight: slideSize.height,
840 provider: 'import',
841 model: 'template-direct-edit'
842 })
843 const designContract = resolveTemplateDesignContract(manifest.designContract)
844 await ctx.db.updateSessionDesignContract(sessionId, designContract)
845 const projectId = await ctx.db.createProject({
846 session_id: sessionId,
847 title: deckTitle,
848 output_path: projectDir,
849 root_path: projectDir
850 })
851 const runId = await ctx.db.createGenerationRun({
852 sessionId,
853 mode: 'import',
854 totalPages: preparedPages.length,
855 metadata: {
856 source: 'template-direct-edit',
857 templateId
858 }
859 })
860 for (const page of preparedPages) {
861 await ctx.db.upsertSessionPage({
862 id: page.id,
863 sessionId,
864 legacyPageId: null,
865 fileSlug: page.pageId,
866 pageNumber: page.pageNumber,
867 title: page.title,
868 htmlPath: page.htmlPath,
869 status: 'completed',
870 error: null
871 })
872 await ctx.db.upsertGenerationPage({
873 runId,
874 sessionId,
875 pageId: page.pageId,
876 pageNumber: page.pageNumber,
877 title: page.title,
878 contentOutline: '',
879 layoutIntent: null,
880 htmlPath: page.htmlPath,
881 status: 'completed'
882 })
883 }
884
885 const metadata = {
886 source: 'template-direct-edit',
887 templateId,
888 createdFromTemplateAt: Date.now(),
889 indexPath,
890 projectId,
891 entryMode: 'direct_edit'
892 }
893 await ctx.db.updateSessionMetadata(sessionId, metadata)
894 await ctx.db.updateGenerationRunStatus(runId, 'completed')
895 await ctx.db.updateProjectStatus(projectId, 'draft')
896 await ctx.db.updateSessionStatus(sessionId, 'completed')
897 await recordHistoryOperationStrict(ctx.db, {
898 sessionId,
899 projectDir,
900 type: 'import',
901 scope: 'session',
902 prompt: `从模板直接创建:${manifest.name}`,
903 metadata: {
904 runId,
905 source: 'template-direct-edit',
906 templateId,
907 pageCount: preparedPages.length
908 }
909 })
910
911 return { success: true, sessionId }
912 }
913
913 lines TYPESCRIPT