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