返回 oh-my-ppt
index.ts
根目录 / src / main / db / patch / index.ts
1 import type { createClient } from '@libsql/client'
2 import type { drizzle } from 'drizzle-orm/libsql'
3 import path from 'path'
4 import fs from 'fs'
5 import { nanoid } from 'nanoid'
6 import * as schema from '../schema'
7 import type { GenerationPageStatus, GenerationRunStatus } from '../schema'
8 import { defaultModelTimeoutMs } from '@shared/model-timeout'
9 import { patchModelConfigMaxTokens } from './add-model-max-tokens'
10 import { patchModelConfigDisableTemperature } from './add-model-disable-temperature'
11 import { patchModelConfigThinkingParameterMode } from './add-model-thinking-parameter-mode'
12 import { patchStylesColumns } from './add-styles-columns'
13 import { patchDesignContractFonts } from './backfill-design-contract-fonts'
14
15 type LibSqlClient = ReturnType<typeof createClient>
16 type DrizzleDb = ReturnType<typeof drizzle>
17
18 const SETTINGS_TABLE_SQL = `
19 CREATE TABLE IF NOT EXISTS settings (
20 key TEXT PRIMARY KEY,
21 value TEXT NOT NULL,
22 updated_at INTEGER NOT NULL
23 );
24 `
25
26 const MESSAGES_TABLE_SQL = `
27 CREATE TABLE IF NOT EXISTS messages (
28 id TEXT PRIMARY KEY,
29 session_id TEXT NOT NULL,
30 chat_scope TEXT NOT NULL DEFAULT 'main',
31 page_id TEXT,
32 selector TEXT,
33 image_paths TEXT,
34 video_paths TEXT,
35 role TEXT NOT NULL,
36 content TEXT NOT NULL,
37 type TEXT,
38 tool_name TEXT,
39 tool_call_id TEXT,
40 token_count INTEGER,
41 run_model TEXT,
42 created_at INTEGER NOT NULL
43 );
44
45 CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
46 CREATE INDEX IF NOT EXISTS idx_messages_session_scope ON messages(session_id, chat_scope, page_id, created_at);
47 CREATE INDEX IF NOT EXISTS idx_messages_session_only ON messages(session_id);
48 `
49
50 const MODEL_USAGE_TABLE_SQL = `
51 CREATE TABLE IF NOT EXISTS model_usage_events (
52 id TEXT PRIMARY KEY,
53 provider TEXT NOT NULL,
54 model TEXT NOT NULL,
55 model_config_id TEXT,
56 input_tokens INTEGER NOT NULL DEFAULT 0,
57 output_tokens INTEGER NOT NULL DEFAULT 0,
58 total_tokens INTEGER NOT NULL DEFAULT 0,
59 usage_source TEXT NOT NULL DEFAULT 'provider',
60 created_at INTEGER NOT NULL
61 );
62
63 CREATE INDEX IF NOT EXISTS idx_model_usage_events_created ON model_usage_events(created_at);
64 CREATE INDEX IF NOT EXISTS idx_model_usage_events_model ON model_usage_events(provider, model, created_at);
65 `
66
67 const INIT_SQL = `
68 CREATE TABLE IF NOT EXISTS sessions (
69 id TEXT PRIMARY KEY,
70 title TEXT NOT NULL,
71 topic TEXT,
72 style_id TEXT,
73 page_count INTEGER,
74 slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9',
75 slide_width INTEGER NOT NULL DEFAULT 1600,
76 slide_height INTEGER NOT NULL DEFAULT 900,
77 reference_document_path TEXT,
78 status TEXT NOT NULL DEFAULT 'active',
79 provider TEXT NOT NULL,
80 model TEXT NOT NULL,
81 created_at INTEGER NOT NULL,
82 updated_at INTEGER NOT NULL,
83 metadata TEXT,
84 design_contract TEXT,
85 current_operation_id TEXT,
86 current_commit TEXT
87 );
88
89 ${MESSAGES_TABLE_SQL}
90
91 ${MODEL_USAGE_TABLE_SQL}
92
93 CREATE TABLE IF NOT EXISTS projects (
94 id TEXT PRIMARY KEY,
95 session_id TEXT NOT NULL,
96 title TEXT NOT NULL,
97 output_path TEXT NOT NULL,
98 root_path TEXT,
99 file_count INTEGER DEFAULT 0,
100 total_size INTEGER DEFAULT 0,
101 status TEXT NOT NULL DEFAULT 'draft',
102 created_at INTEGER NOT NULL,
103 updated_at INTEGER NOT NULL
104 );
105
106 ${SETTINGS_TABLE_SQL}
107
108 CREATE TABLE IF NOT EXISTS model_configs (
109 id TEXT PRIMARY KEY,
110 name TEXT NOT NULL,
111 provider TEXT NOT NULL,
112 model TEXT NOT NULL,
113 api_key TEXT NOT NULL DEFAULT '',
114 base_url TEXT NOT NULL DEFAULT '',
115 max_tokens INTEGER NOT NULL DEFAULT 4096,
116 disable_temperature INTEGER NOT NULL DEFAULT 0,
117 thinking_parameter_mode TEXT NOT NULL DEFAULT 'auto',
118 active INTEGER NOT NULL DEFAULT 0,
119 created_at INTEGER NOT NULL,
120 updated_at INTEGER NOT NULL
121 );
122 CREATE UNIQUE INDEX IF NOT EXISTS idx_model_configs_single_active ON model_configs(active) WHERE active = 1;
123 CREATE INDEX IF NOT EXISTS idx_model_configs_updated ON model_configs(updated_at);
124
125 CREATE TABLE IF NOT EXISTS image_model_configs (
126 id TEXT PRIMARY KEY,
127 name TEXT NOT NULL,
128 provider TEXT NOT NULL,
129 model_config TEXT NOT NULL DEFAULT '{}',
130 active INTEGER NOT NULL DEFAULT 0,
131 created_at INTEGER NOT NULL,
132 updated_at INTEGER NOT NULL
133 );
134 CREATE UNIQUE INDEX IF NOT EXISTS idx_image_model_configs_single_active ON image_model_configs(active) WHERE active = 1;
135 CREATE INDEX IF NOT EXISTS idx_image_model_configs_updated ON image_model_configs(updated_at);
136
137 CREATE TABLE IF NOT EXISTS image_generation_histories (
138 id TEXT PRIMARY KEY,
139 session_id TEXT NOT NULL,
140 page_id TEXT NOT NULL,
141 prompt TEXT NOT NULL,
142 image_paths TEXT NOT NULL DEFAULT '[]',
143 model_config_id TEXT NOT NULL,
144 provider TEXT NOT NULL,
145 model TEXT NOT NULL,
146 created_at INTEGER NOT NULL
147 );
148 CREATE INDEX IF NOT EXISTS idx_image_generation_histories_session ON image_generation_histories(session_id, created_at);
149 CREATE INDEX IF NOT EXISTS idx_image_generation_histories_page ON image_generation_histories(session_id, page_id, created_at);
150
151 CREATE TABLE IF NOT EXISTS memory_summaries (
152 id TEXT PRIMARY KEY,
153 session_id TEXT NOT NULL,
154 message_range_start INTEGER NOT NULL,
155 message_range_end INTEGER NOT NULL,
156 summary TEXT NOT NULL,
157 token_count INTEGER,
158 created_at INTEGER NOT NULL
159 );
160
161 CREATE INDEX IF NOT EXISTS idx_memory_summaries_session ON memory_summaries(session_id, message_range_end);
162
163 CREATE INDEX IF NOT EXISTS idx_projects_session ON projects(session_id);
164 CREATE INDEX IF NOT EXISTS idx_memory_summaries_session_id ON memory_summaries(session_id);
165
166 CREATE TABLE IF NOT EXISTS generation_runs (
167 id TEXT PRIMARY KEY,
168 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
169 mode TEXT NOT NULL DEFAULT 'generate',
170 status TEXT NOT NULL DEFAULT 'running',
171 total_pages INTEGER NOT NULL DEFAULT 0,
172 error TEXT,
173 metadata TEXT,
174 animation_preferences TEXT,
175 model_config_id TEXT,
176 created_at INTEGER NOT NULL,
177 updated_at INTEGER NOT NULL
178 );
179 CREATE INDEX IF NOT EXISTS idx_generation_runs_session ON generation_runs(session_id, created_at);
180
181 CREATE TABLE IF NOT EXISTS session_jobs (
182 id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE,
183 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
184 kind TEXT NOT NULL,
185 previous_session_status TEXT NOT NULL,
186 target_page_id TEXT,
187 target_page_number INTEGER,
188 selector TEXT,
189 total_pages INTEGER,
190 status TEXT NOT NULL,
191 abort_reason TEXT,
192 created_at INTEGER NOT NULL,
193 activated_at INTEGER,
194 updated_at INTEGER NOT NULL,
195 finished_at INTEGER
196 );
197 CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status ON session_jobs(session_id, status, updated_at);
198 CREATE INDEX IF NOT EXISTS idx_session_jobs_status ON session_jobs(status, updated_at);
199
200 CREATE TABLE IF NOT EXISTS generation_pages (
201 id TEXT PRIMARY KEY,
202 run_id TEXT NOT NULL REFERENCES generation_runs(id) ON DELETE CASCADE,
203 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
204 page_id TEXT NOT NULL,
205 page_number INTEGER NOT NULL,
206 title TEXT NOT NULL,
207 content_outline TEXT,
208 layout_intent TEXT,
209 html_path TEXT,
210 status TEXT NOT NULL DEFAULT 'pending',
211 error TEXT,
212 retry_count INTEGER NOT NULL DEFAULT 0,
213 created_at INTEGER NOT NULL,
214 updated_at INTEGER NOT NULL
215 );
216 CREATE INDEX IF NOT EXISTS idx_generation_pages_run ON generation_pages(run_id, page_number);
217 CREATE INDEX IF NOT EXISTS idx_generation_pages_session_status ON generation_pages(session_id, status, page_number);
218
219 CREATE TABLE IF NOT EXISTS session_pages (
220 id TEXT PRIMARY KEY,
221 session_id TEXT NOT NULL,
222 legacy_page_id TEXT,
223 file_slug TEXT NOT NULL,
224 page_number INTEGER NOT NULL,
225 title TEXT NOT NULL,
226 html_path TEXT NOT NULL,
227 status TEXT NOT NULL DEFAULT 'pending',
228 error TEXT,
229 created_at INTEGER NOT NULL,
230 updated_at INTEGER NOT NULL,
231 deleted_at INTEGER
232 );
233 CREATE INDEX IF NOT EXISTS idx_session_pages_session_number ON session_pages(session_id, page_number);
234
235 CREATE TABLE IF NOT EXISTS source_page_skeletons (
236 id TEXT PRIMARY KEY,
237 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
238 page_number INTEGER NOT NULL,
239 title TEXT NOT NULL,
240 role TEXT NOT NULL DEFAULT 'content',
241 source_document_path TEXT NOT NULL,
242 source_document_name TEXT,
243 source_heading TEXT NOT NULL,
244 heading_level INTEGER NOT NULL,
245 line_start INTEGER NOT NULL,
246 line_end INTEGER NOT NULL,
247 reason TEXT,
248 confidence TEXT NOT NULL DEFAULT 'high',
249 created_at INTEGER NOT NULL,
250 updated_at INTEGER NOT NULL
251 );
252 CREATE INDEX IF NOT EXISTS idx_source_page_skeletons_session ON source_page_skeletons(session_id, page_number);
253
254 CREATE TABLE IF NOT EXISTS user_preferences (
255 key TEXT PRIMARY KEY,
256 value TEXT NOT NULL,
257 confidence REAL DEFAULT 1.0,
258 source_sessions TEXT,
259 created_at INTEGER NOT NULL,
260 updated_at INTEGER NOT NULL,
261 last_used_at INTEGER
262 );
263
264 CREATE TABLE IF NOT EXISTS styles (
265 id TEXT PRIMARY KEY,
266 style TEXT UNIQUE NOT NULL,
267 style_name TEXT NOT NULL,
268 style_name_zh TEXT NOT NULL DEFAULT '',
269 style_name_en TEXT NOT NULL DEFAULT '',
270 description TEXT NOT NULL DEFAULT '',
271 category TEXT NOT NULL DEFAULT '',
272 aliases TEXT NOT NULL DEFAULT '[]',
273 source TEXT NOT NULL DEFAULT 'custom',
274 style_skill TEXT NOT NULL DEFAULT '',
275 version TEXT NOT NULL DEFAULT '1.0.0',
276 style_case TEXT NOT NULL DEFAULT '',
277 package_dir TEXT NOT NULL DEFAULT '',
278 active INTEGER NOT NULL DEFAULT 1,
279 favorite_at INTEGER,
280 created_at INTEGER NOT NULL,
281 updated_at INTEGER NOT NULL
282 );
283 CREATE UNIQUE INDEX IF NOT EXISTS idx_styles_style ON styles(style);
284
285 CREATE TABLE IF NOT EXISTS thumbnails (
286 key TEXT PRIMARY KEY,
287 resource_type TEXT NOT NULL,
288 resource_id TEXT NOT NULL,
289 variant TEXT NOT NULL DEFAULT 'default',
290 source_path TEXT NOT NULL,
291 source_mtime_ms INTEGER NOT NULL DEFAULT 0,
292 signature TEXT NOT NULL DEFAULT '',
293 thumbnail_path TEXT NOT NULL DEFAULT '',
294 status TEXT NOT NULL DEFAULT 'queued',
295 error TEXT,
296 created_at INTEGER NOT NULL,
297 updated_at INTEGER NOT NULL
298 );
299 CREATE UNIQUE INDEX IF NOT EXISTS thumbnails_resource_variant_unique
300 ON thumbnails(resource_type, resource_id, variant);
301 CREATE INDEX IF NOT EXISTS thumbnails_status_idx ON thumbnails(status, updated_at);
302
303 CREATE TABLE IF NOT EXISTS session_style_snapshots (
304 id TEXT PRIMARY KEY,
305 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
306 style_id TEXT NOT NULL,
307 style_key TEXT NOT NULL,
308 style_name TEXT NOT NULL,
309 style_name_zh TEXT NOT NULL DEFAULT '',
310 style_name_en TEXT NOT NULL DEFAULT '',
311 description TEXT NOT NULL DEFAULT '',
312 category TEXT NOT NULL DEFAULT '',
313 aliases TEXT NOT NULL DEFAULT '[]',
314 source TEXT NOT NULL,
315 version TEXT NOT NULL DEFAULT '1.0.0',
316 style_case TEXT NOT NULL DEFAULT '',
317 package_dir TEXT NOT NULL DEFAULT '',
318 style_skill TEXT NOT NULL DEFAULT '',
319 created_at INTEGER NOT NULL
320 );
321 CREATE UNIQUE INDEX IF NOT EXISTS session_style_snapshots_session_id_unique
322 ON session_style_snapshots(session_id);
323
324 CREATE TABLE IF NOT EXISTS session_operations (
325 id TEXT PRIMARY KEY,
326 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
327 type TEXT NOT NULL,
328 status TEXT NOT NULL DEFAULT 'completed',
329 scope TEXT,
330 prompt TEXT,
331 parent_operation_id TEXT,
332 before_commit TEXT,
333 after_commit TEXT,
334 target_operation_id TEXT,
335 target_commit TEXT,
336 changed_files_json TEXT NOT NULL DEFAULT '[]',
337 changed_pages_json TEXT NOT NULL DEFAULT '[]',
338 tracked_files_json TEXT NOT NULL DEFAULT '[]',
339 metadata_json TEXT NOT NULL DEFAULT '{}',
340 created_at INTEGER NOT NULL,
341 completed_at INTEGER
342 );
343 CREATE INDEX IF NOT EXISTS idx_session_operations_session_created ON session_operations(session_id, created_at);
344 CREATE INDEX IF NOT EXISTS idx_session_operations_session_status ON session_operations(session_id, status, created_at);
345
346 CREATE TABLE IF NOT EXISTS session_operation_pages (
347 id TEXT PRIMARY KEY,
348 operation_id TEXT NOT NULL REFERENCES session_operations(id) ON DELETE CASCADE,
349 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
350 page_id TEXT NOT NULL,
351 legacy_page_id TEXT,
352 file_slug TEXT NOT NULL,
353 page_number INTEGER NOT NULL,
354 title TEXT NOT NULL,
355 html_path TEXT NOT NULL,
356 status TEXT NOT NULL DEFAULT 'pending',
357 error TEXT,
358 created_at INTEGER NOT NULL,
359 updated_at INTEGER NOT NULL
360 );
361 CREATE INDEX IF NOT EXISTS idx_session_operation_pages_order ON session_operation_pages(operation_id, page_number);
362 CREATE INDEX IF NOT EXISTS idx_session_operation_pages_session ON session_operation_pages(session_id, operation_id);
363 `
364
365 const getRowValue = (row: unknown, key: string): unknown => {
366 if (row && typeof row === 'object' && !Array.isArray(row) && key in row) {
367 return (row as Record<string, unknown>)[key]
368 }
369 return undefined
370 }
371
372 const parseJsonObject = (value: unknown): Record<string, unknown> => {
373 if (typeof value !== 'string' || value.trim().length === 0) return {}
374 try {
375 const parsed = JSON.parse(value) as unknown
376 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
377 ? (parsed as Record<string, unknown>)
378 : {}
379 } catch {
380 return {}
381 }
382 }
383
384 const toPositiveInt = (value: unknown, fallback: number): number => {
385 const number = Number(value)
386 return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback
387 }
388
389 const inferPageNumber = (page: Record<string, unknown>, fallback: number): number => {
390 const explicit = toPositiveInt(page.pageNumber ?? page.page_number, 0)
391 if (explicit > 0) return explicit
392 const rawPageId =
393 typeof (page.pageId ?? page.page_id) === 'string'
394 ? String(page.pageId ?? page.page_id).trim()
395 : ''
396 const fromPageId = toPositiveInt(rawPageId.match(/^page-(\d+)$/i)?.[1], 0)
397 return fromPageId > 0 ? fromPageId : fallback
398 }
399
400 const resolveLegacyPagePath = (
401 page: Record<string, unknown>,
402 projectDir: string,
403 pageId: string
404 ): string => {
405 const rawPath =
406 typeof (page.htmlPath ?? page.html_path) === 'string'
407 ? String(page.htmlPath ?? page.html_path).trim()
408 : ''
409 if (!rawPath) return path.join(projectDir, `${pageId}.html`)
410 return path.isAbsolute(rawPath) ? rawPath : path.join(projectDir, rawPath)
411 }
412
413 const getTableColumns = async (
414 client: LibSqlClient,
415 tableName:
416 | 'settings'
417 | 'messages'
418 | 'sessions'
419 | 'projects'
420 | 'generation_runs'
421 | 'generation_jobs'
422 | 'session_jobs'
423 | 'page_edit_jobs'
424 | 'deck_edit_jobs'
425 | 'generation_pages'
426 | 'session_pages'
427 | 'model_configs'
428 | 'image_model_configs'
429 | 'html_edit_messages'
430 ): Promise<Set<string>> => {
431 const result = await client.execute(`PRAGMA table_info(${tableName})`)
432 const rows = Array.isArray((result as { rows?: unknown[] }).rows)
433 ? ((result as { rows?: unknown[] }).rows as unknown[])
434 : []
435 const columns = new Set<string>()
436 for (const row of rows) {
437 if (row && typeof row === 'object' && 'name' in row) {
438 const name = (row as { name?: unknown }).name
439 if (typeof name === 'string' && name.trim().length > 0) {
440 columns.add(name.trim())
441 }
442 continue
443 }
444 if (Array.isArray(row) && typeof row[1] === 'string' && row[1].trim().length > 0) {
445 columns.add(row[1].trim())
446 }
447 }
448 return columns
449 }
450
451 const enforceSettingsSchema = async (client: LibSqlClient): Promise<void> => {
452 await client.execute(SETTINGS_TABLE_SQL)
453 const columns = await getTableColumns(client, 'settings')
454 if (!columns.has('value')) {
455 await client.execute(`ALTER TABLE settings ADD COLUMN value TEXT NOT NULL DEFAULT '""'`)
456 }
457 if (!columns.has('updated_at')) {
458 await client.execute('ALTER TABLE settings ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0')
459 }
460 await client.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_key ON settings(key)')
461 }
462
463 const enforceModelConfigsSchema = async (client: LibSqlClient): Promise<void> => {
464 await client.execute(`
465 CREATE TABLE IF NOT EXISTS model_configs (
466 id TEXT PRIMARY KEY,
467 name TEXT NOT NULL,
468 provider TEXT NOT NULL,
469 model TEXT NOT NULL,
470 api_key TEXT NOT NULL DEFAULT '',
471 base_url TEXT NOT NULL DEFAULT '',
472 max_tokens INTEGER NOT NULL DEFAULT 4096,
473 active INTEGER NOT NULL DEFAULT 0,
474 created_at INTEGER NOT NULL,
475 updated_at INTEGER NOT NULL
476 )
477 `)
478 await client.execute(
479 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_configs_single_active ON model_configs(active) WHERE active = 1'
480 )
481 await client.execute(
482 'CREATE INDEX IF NOT EXISTS idx_model_configs_updated ON model_configs(updated_at)'
483 )
484 }
485
486 const enforceImageModelConfigsSchema = async (client: LibSqlClient): Promise<void> => {
487 const columns = await getTableColumns(client, 'image_model_configs')
488 if (columns.size > 0 && !columns.has('model_config')) {
489 await client.execute('DROP INDEX IF EXISTS idx_image_model_configs_single_active')
490 await client.execute('DROP INDEX IF EXISTS idx_image_model_configs_updated')
491 await client.execute('DROP TABLE IF EXISTS image_model_configs')
492 }
493 await client.execute(`
494 CREATE TABLE IF NOT EXISTS image_model_configs (
495 id TEXT PRIMARY KEY,
496 name TEXT NOT NULL,
497 provider TEXT NOT NULL,
498 model_config TEXT NOT NULL DEFAULT '{}',
499 active INTEGER NOT NULL DEFAULT 0,
500 created_at INTEGER NOT NULL,
501 updated_at INTEGER NOT NULL
502 )
503 `)
504 await client.execute(
505 'CREATE UNIQUE INDEX IF NOT EXISTS idx_image_model_configs_single_active ON image_model_configs(active) WHERE active = 1'
506 )
507 await client.execute(
508 'CREATE INDEX IF NOT EXISTS idx_image_model_configs_updated ON image_model_configs(updated_at)'
509 )
510 }
511
512 const enforceSessionsSchema = async (client: LibSqlClient): Promise<void> => {
513 const columns = await getTableColumns(client, 'sessions')
514 if (!columns.has('style_id')) {
515 await client.execute('ALTER TABLE sessions ADD COLUMN style_id TEXT')
516 }
517 if (!columns.has('reference_document_path')) {
518 await client.execute('ALTER TABLE sessions ADD COLUMN reference_document_path TEXT')
519 }
520 if (!columns.has('slide_size_id')) {
521 await client.execute(
522 "ALTER TABLE sessions ADD COLUMN slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9'"
523 )
524 }
525 if (!columns.has('slide_width')) {
526 await client.execute(
527 'ALTER TABLE sessions ADD COLUMN slide_width INTEGER NOT NULL DEFAULT 1600'
528 )
529 }
530 if (!columns.has('slide_height')) {
531 await client.execute(
532 'ALTER TABLE sessions ADD COLUMN slide_height INTEGER NOT NULL DEFAULT 900'
533 )
534 }
535 await client.execute({
536 sql: `
537 UPDATE sessions
538 SET
539 slide_size_id = 'wide-16-9',
540 slide_width = 1600,
541 slide_height = 900
542 WHERE
543 slide_size_id IS NULL
544 OR TRIM(slide_size_id) = ''
545 OR slide_width IS NULL
546 OR slide_width <= 0
547 OR slide_height IS NULL
548 OR slide_height <= 0
549 `,
550 args: []
551 })
552 if (!columns.has('current_operation_id')) {
553 await client.execute('ALTER TABLE sessions ADD COLUMN current_operation_id TEXT')
554 }
555 if (!columns.has('current_commit')) {
556 await client.execute('ALTER TABLE sessions ADD COLUMN current_commit TEXT')
557 }
558 }
559
560 const enforceProjectsSchema = async (client: LibSqlClient): Promise<void> => {
561 const columns = await getTableColumns(client, 'projects')
562 if (!columns.has('root_path')) {
563 await client.execute('ALTER TABLE projects ADD COLUMN root_path TEXT')
564 }
565 }
566
567 const hasSessionHtmlFiles = (dir: string): boolean => {
568 try {
569 if (!fsExistsSafe(path.join(dir, 'index.html'))) return false
570 return fs
571 .readdirSync(dir, { withFileTypes: true })
572 .some((entry) => entry.isFile() && /^page-\d+\.html?$/i.test(entry.name))
573 } catch {
574 return false
575 }
576 }
577
578 const inferProjectRootPath = async (
579 client: LibSqlClient,
580 sessionId: string,
581 outputPath: string,
582 metadata: Record<string, unknown>,
583 resolveStoragePath: () => Promise<string>
584 ): Promise<string> => {
585 const candidateDirs: string[] = []
586 const addCandidate = (value: unknown): void => {
587 if (typeof value !== 'string' || value.trim().length === 0) return
588 const resolved = path.resolve(value.trim())
589 if (!candidateDirs.includes(resolved)) candidateDirs.push(resolved)
590 }
591
592 if (typeof metadata.indexPath === 'string' && metadata.indexPath.trim().length > 0) {
593 addCandidate(path.dirname(metadata.indexPath.trim()))
594 }
595 addCandidate(outputPath)
596
597 const generatedPages = Array.isArray(metadata.generatedPages) ? metadata.generatedPages : []
598 for (const page of generatedPages) {
599 if (!page || typeof page !== 'object' || Array.isArray(page)) continue
600 const htmlPath = (page as Record<string, unknown>).htmlPath
601 if (typeof htmlPath === 'string' && htmlPath.trim().length > 0) {
602 addCandidate(path.dirname(htmlPath.trim()))
603 }
604 }
605
606 const pageRows = await client
607 .execute({
608 sql: 'SELECT html_path FROM session_pages WHERE session_id = ?',
609 args: [sessionId]
610 })
611 .catch(() => ({ rows: [] as unknown[] }))
612 for (const row of pageRows.rows || []) {
613 const htmlPath = getRowValue(row, 'html_path')
614 if (typeof htmlPath === 'string' && fsExistsSafe(htmlPath)) {
615 addCandidate(path.dirname(htmlPath))
616 }
617 }
618
619 const storagePath = await resolveStoragePath().catch(() => '')
620 addCandidate(path.join(storagePath || process.cwd(), sessionId))
621
622 for (const dir of candidateDirs) {
623 if (hasSessionHtmlFiles(dir)) return dir
624 }
625 for (const dir of candidateDirs) {
626 if (fsExistsSafe(path.join(dir, '.git'))) return dir
627 }
628 return ''
629 }
630
631 const patchProjectRootPaths = async (args: {
632 client: LibSqlClient
633 resolveStoragePath: () => Promise<string>
634 }): Promise<void> => {
635 const { client, resolveStoragePath } = args
636 const result = await client.execute(`
637 SELECT projects.id AS project_id,
638 projects.session_id AS session_id,
639 projects.output_path AS output_path,
640 sessions.metadata AS metadata
641 FROM projects
642 LEFT JOIN sessions ON sessions.id = projects.session_id
643 WHERE projects.root_path IS NULL OR TRIM(projects.root_path) = ''
644 `)
645
646 for (const row of result.rows || []) {
647 const projectId = String(getRowValue(row, 'project_id') || '')
648 const sessionId = String(getRowValue(row, 'session_id') || '')
649 const outputPath = String(getRowValue(row, 'output_path') || '')
650 if (!projectId || !sessionId) continue
651 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
652 const rootPath = await inferProjectRootPath(
653 client,
654 sessionId,
655 outputPath,
656 metadata,
657 resolveStoragePath
658 )
659 if (!rootPath) continue
660 await client.execute({
661 sql: 'UPDATE projects SET root_path = ? WHERE id = ? AND (root_path IS NULL OR TRIM(root_path) = ?)',
662 args: [rootPath, projectId, '']
663 })
664 }
665 }
666
667 const enforceSessionOperationsSchema = async (client: LibSqlClient): Promise<void> => {
668 await client.execute(`
669 CREATE TABLE IF NOT EXISTS session_operations (
670 id TEXT PRIMARY KEY,
671 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
672 type TEXT NOT NULL,
673 status TEXT NOT NULL DEFAULT 'completed',
674 scope TEXT,
675 prompt TEXT,
676 parent_operation_id TEXT,
677 before_commit TEXT,
678 after_commit TEXT,
679 target_operation_id TEXT,
680 target_commit TEXT,
681 changed_files_json TEXT NOT NULL DEFAULT '[]',
682 changed_pages_json TEXT NOT NULL DEFAULT '[]',
683 tracked_files_json TEXT NOT NULL DEFAULT '[]',
684 metadata_json TEXT NOT NULL DEFAULT '{}',
685 created_at INTEGER NOT NULL,
686 completed_at INTEGER
687 )
688 `)
689 await client.execute(
690 'CREATE INDEX IF NOT EXISTS idx_session_operations_session_created ON session_operations(session_id, created_at)'
691 )
692 await client.execute(
693 'CREATE INDEX IF NOT EXISTS idx_session_operations_session_status ON session_operations(session_id, status, created_at)'
694 )
695 }
696
697 const enforceSessionOperationPagesSchema = async (client: LibSqlClient): Promise<void> => {
698 await client.execute(`
699 CREATE TABLE IF NOT EXISTS session_operation_pages (
700 id TEXT PRIMARY KEY,
701 operation_id TEXT NOT NULL REFERENCES session_operations(id) ON DELETE CASCADE,
702 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
703 page_id TEXT NOT NULL,
704 legacy_page_id TEXT,
705 file_slug TEXT NOT NULL,
706 page_number INTEGER NOT NULL,
707 title TEXT NOT NULL,
708 html_path TEXT NOT NULL,
709 status TEXT NOT NULL DEFAULT 'pending',
710 error TEXT,
711 created_at INTEGER NOT NULL,
712 updated_at INTEGER NOT NULL
713 )
714 `)
715 await client.execute(
716 'CREATE INDEX IF NOT EXISTS idx_session_operation_pages_order ON session_operation_pages(operation_id, page_number)'
717 )
718 await client.execute(
719 'CREATE INDEX IF NOT EXISTS idx_session_operation_pages_session ON session_operation_pages(session_id, operation_id)'
720 )
721 }
722
723 const enforceMessagesSchema = async (client: LibSqlClient): Promise<void> => {
724 await client.executeMultiple(MESSAGES_TABLE_SQL)
725 const columns = await getTableColumns(client, 'messages')
726 if (!columns.has('chat_scope')) {
727 await client.execute(`ALTER TABLE messages ADD COLUMN chat_scope TEXT NOT NULL DEFAULT 'main'`)
728 }
729 if (!columns.has('page_id')) {
730 await client.execute('ALTER TABLE messages ADD COLUMN page_id TEXT')
731 }
732 if (!columns.has('selector')) {
733 await client.execute('ALTER TABLE messages ADD COLUMN selector TEXT')
734 }
735 if (!columns.has('image_paths')) {
736 await client.execute('ALTER TABLE messages ADD COLUMN image_paths TEXT')
737 }
738 if (!columns.has('video_paths')) {
739 await client.execute('ALTER TABLE messages ADD COLUMN video_paths TEXT')
740 }
741 if (!columns.has('type')) {
742 await client.execute('ALTER TABLE messages ADD COLUMN type TEXT')
743 }
744 if (!columns.has('tool_name')) {
745 await client.execute('ALTER TABLE messages ADD COLUMN tool_name TEXT')
746 }
747 if (!columns.has('tool_call_id')) {
748 await client.execute('ALTER TABLE messages ADD COLUMN tool_call_id TEXT')
749 }
750 if (!columns.has('token_count')) {
751 await client.execute('ALTER TABLE messages ADD COLUMN token_count INTEGER')
752 }
753 if (!columns.has('run_model')) {
754 await client.execute('ALTER TABLE messages ADD COLUMN run_model TEXT')
755 }
756 await client.execute(
757 'CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at)'
758 )
759 await client.execute(
760 'CREATE INDEX IF NOT EXISTS idx_messages_session_scope ON messages(session_id, chat_scope, page_id, created_at)'
761 )
762 await client.execute(
763 'CREATE INDEX IF NOT EXISTS idx_messages_session_only ON messages(session_id)'
764 )
765 }
766
767 const enforceGenerationSchema = async (client: LibSqlClient): Promise<void> => {
768 await client.execute(`
769 CREATE TABLE IF NOT EXISTS session_jobs (
770 id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE,
771 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
772 kind TEXT NOT NULL,
773 previous_session_status TEXT NOT NULL DEFAULT 'active',
774 target_page_id TEXT,
775 target_page_number INTEGER,
776 selector TEXT,
777 total_pages INTEGER,
778 status TEXT NOT NULL,
779 abort_reason TEXT,
780 created_at INTEGER NOT NULL,
781 activated_at INTEGER,
782 updated_at INTEGER NOT NULL,
783 finished_at INTEGER
784 )
785 `)
786 const runColumns = await getTableColumns(client, 'generation_runs')
787 if (!runColumns.has('model_config_id')) {
788 await client.execute('ALTER TABLE generation_runs ADD COLUMN model_config_id TEXT')
789 }
790 if (!runColumns.has('animation_preferences')) {
791 await client.execute('ALTER TABLE generation_runs ADD COLUMN animation_preferences TEXT')
792 }
793 let legacyGenerationJobColumns = await getTableColumns(client, 'generation_jobs')
794 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('abort_reason')) {
795 await client.execute('ALTER TABLE generation_jobs ADD COLUMN abort_reason TEXT')
796 }
797 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('activated_at')) {
798 await client.execute('ALTER TABLE generation_jobs ADD COLUMN activated_at INTEGER')
799 }
800 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('finished_at')) {
801 await client.execute('ALTER TABLE generation_jobs ADD COLUMN finished_at INTEGER')
802 }
803 if (legacyGenerationJobColumns.size > 0) {
804 legacyGenerationJobColumns = await getTableColumns(client, 'generation_jobs')
805 }
806 const legacyPageEditJobColumns = await getTableColumns(client, 'page_edit_jobs')
807 const legacyDeckEditJobColumns = await getTableColumns(client, 'deck_edit_jobs')
808 // Keep every legacy table until every source has been copied. A failed migration can then
809 // be retried on the next startup without losing the original job records.
810 const migration = await client.transaction('write')
811 const upsertSessionJob = `
812 ON CONFLICT(id) DO UPDATE SET
813 session_id = excluded.session_id,
814 kind = excluded.kind,
815 previous_session_status = excluded.previous_session_status,
816 target_page_id = excluded.target_page_id,
817 target_page_number = excluded.target_page_number,
818 selector = excluded.selector,
819 total_pages = excluded.total_pages,
820 status = excluded.status,
821 abort_reason = excluded.abort_reason,
822 created_at = excluded.created_at,
823 activated_at = excluded.activated_at,
824 updated_at = excluded.updated_at,
825 finished_at = excluded.finished_at
826 `
827 try {
828 if (legacyGenerationJobColumns.size > 0) {
829 const specializedJobGuards = [
830 legacyPageEditJobColumns.size > 0
831 ? 'NOT EXISTS (SELECT 1 FROM page_edit_jobs AS page_jobs WHERE page_jobs.id = jobs.id)'
832 : '',
833 legacyDeckEditJobColumns.size > 0
834 ? 'NOT EXISTS (SELECT 1 FROM deck_edit_jobs AS deck_jobs WHERE deck_jobs.id = jobs.id)'
835 : ''
836 ].filter(Boolean)
837 await migration.execute(`
838 INSERT INTO session_jobs (
839 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
840 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
841 )
842 SELECT
843 jobs.id,
844 jobs.session_id,
845 CASE jobs.kind
846 WHEN 'template' THEN 'template'
847 WHEN 'retry' THEN 'retry'
848 WHEN 'edit' THEN 'deck-edit'
849 ELSE 'standard'
850 END,
851 COALESCE(
852 CASE
853 WHEN runs.metadata IS NOT NULL AND json_valid(runs.metadata)
854 THEN json_extract(runs.metadata, '$.previousSessionStatus')
855 END,
856 'active'
857 ),
858 NULL,
859 NULL,
860 NULL,
861 runs.total_pages,
862 jobs.status,
863 jobs.abort_reason,
864 jobs.created_at,
865 jobs.activated_at,
866 jobs.updated_at,
867 jobs.finished_at
868 FROM generation_jobs AS jobs
869 LEFT JOIN generation_runs AS runs ON runs.id = jobs.id
870 WHERE ${specializedJobGuards.join(' AND ') || '1'}
871 ${upsertSessionJob}
872 `)
873 }
874 if (legacyPageEditJobColumns.size > 0) {
875 const deckJobGuard =
876 legacyDeckEditJobColumns.size > 0
877 ? 'WHERE NOT EXISTS (SELECT 1 FROM deck_edit_jobs AS deck_jobs WHERE deck_jobs.id = page_edit_jobs.id)'
878 : 'WHERE 1'
879 await migration.execute(`
880 INSERT INTO session_jobs (
881 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
882 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
883 )
884 SELECT
885 id,
886 session_id,
887 'page-edit',
888 previous_session_status,
889 target_page_id,
890 target_page_number,
891 selector,
892 1,
893 status,
894 abort_reason,
895 created_at,
896 activated_at,
897 updated_at,
898 finished_at
899 FROM page_edit_jobs
900 ${deckJobGuard}
901 ${upsertSessionJob}
902 `)
903 }
904 if (legacyDeckEditJobColumns.size > 0) {
905 await migration.execute(`
906 INSERT INTO session_jobs (
907 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
908 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
909 )
910 SELECT
911 id,
912 session_id,
913 'deck-edit',
914 previous_session_status,
915 NULL,
916 NULL,
917 NULL,
918 total_pages,
919 status,
920 abort_reason,
921 created_at,
922 activated_at,
923 updated_at,
924 finished_at
925 FROM deck_edit_jobs
926 WHERE 1
927 ${upsertSessionJob}
928 `)
929 }
930 if (legacyGenerationJobColumns.size > 0) {
931 await migration.execute('DROP TABLE generation_jobs')
932 }
933 if (legacyPageEditJobColumns.size > 0) {
934 await migration.execute('DROP TABLE page_edit_jobs')
935 }
936 if (legacyDeckEditJobColumns.size > 0) {
937 await migration.execute('DROP TABLE deck_edit_jobs')
938 }
939 await migration.commit()
940 } catch (error) {
941 try {
942 await migration.rollback()
943 } catch {
944 // Preserve the migration error when a failed transaction has already closed itself.
945 }
946 throw error
947 }
948 const columns = await getTableColumns(client, 'generation_pages')
949 if (!columns.has('content_outline')) {
950 await client.execute('ALTER TABLE generation_pages ADD COLUMN content_outline TEXT')
951 }
952 if (!columns.has('layout_intent')) {
953 await client.execute('ALTER TABLE generation_pages ADD COLUMN layout_intent TEXT')
954 }
955 await client.execute(
956 'CREATE INDEX IF NOT EXISTS idx_generation_runs_session ON generation_runs(session_id, created_at)'
957 )
958 await client.execute(
959 'CREATE INDEX IF NOT EXISTS idx_generation_runs_model_config ON generation_runs(model_config_id)'
960 )
961 await client.execute(
962 'CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status ON session_jobs(session_id, status, updated_at)'
963 )
964 await client.execute(
965 'CREATE INDEX IF NOT EXISTS idx_session_jobs_status ON session_jobs(status, updated_at)'
966 )
967 await client.execute(
968 'CREATE INDEX IF NOT EXISTS idx_generation_pages_run ON generation_pages(run_id, page_number)'
969 )
970 await client.execute(
971 'CREATE INDEX IF NOT EXISTS idx_generation_pages_session_status ON generation_pages(session_id, status, page_number)'
972 )
973 }
974
975 const enforceSessionPagesSchema = async (client: LibSqlClient): Promise<void> => {
976 const columns = await getTableColumns(client, 'session_pages')
977 if (!columns.has('legacy_page_id')) {
978 await client.execute('ALTER TABLE session_pages ADD COLUMN legacy_page_id TEXT')
979 }
980 if (!columns.has('file_slug')) {
981 await client.execute("ALTER TABLE session_pages ADD COLUMN file_slug TEXT NOT NULL DEFAULT ''")
982 }
983 if (!columns.has('status')) {
984 await client.execute(
985 "ALTER TABLE session_pages ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'"
986 )
987 }
988 if (!columns.has('error')) {
989 await client.execute('ALTER TABLE session_pages ADD COLUMN error TEXT')
990 }
991 if (!columns.has('deleted_at')) {
992 await client.execute('ALTER TABLE session_pages ADD COLUMN deleted_at INTEGER')
993 }
994 await client.execute(
995 'CREATE INDEX IF NOT EXISTS idx_session_pages_session_number ON session_pages(session_id, page_number)'
996 )
997 }
998
999 const ensureDefaultSettings = async (client: LibSqlClient): Promise<void> => {
1000 const now = Math.floor(Date.now() / 1000)
1001 const defaults = [
1002 { key: 'theme', value: '"light"' },
1003 { key: 'locale', value: '"zh"' },
1004 { key: 'timeout_ms_planning', value: JSON.stringify(defaultModelTimeoutMs('planning')) },
1005 { key: 'timeout_ms_design', value: JSON.stringify(defaultModelTimeoutMs('design')) },
1006 { key: 'timeout_ms_agent', value: JSON.stringify(defaultModelTimeoutMs('agent')) },
1007 { key: 'timeout_ms_document', value: JSON.stringify(defaultModelTimeoutMs('document')) }
1008 ]
1009
1010 for (const { key, value } of defaults) {
1011 await client.execute({
1012 sql: 'INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
1013 args: [key, value, now]
1014 })
1015 }
1016 }
1017
1018 const resolveLegacyProjectDir = async (
1019 client: LibSqlClient,
1020 sessionId: string,
1021 metadata: Record<string, unknown>,
1022 resolveStoragePath: () => Promise<string>
1023 ): Promise<string> => {
1024 const projectResult = await client
1025 .execute({
1026 sql: 'SELECT root_path, output_path FROM projects WHERE session_id = ? LIMIT 1',
1027 args: [sessionId]
1028 })
1029 .catch(() => ({ rows: [] as unknown[] }))
1030 const rootPath = getRowValue(projectResult.rows?.[0], 'root_path')
1031 if (typeof rootPath === 'string' && rootPath.trim().length > 0) {
1032 return rootPath.trim()
1033 }
1034 const outputPath = getRowValue(projectResult.rows?.[0], 'output_path')
1035 const metadataProjectDir =
1036 typeof metadata.indexPath === 'string' && metadata.indexPath.trim().length > 0
1037 ? path.dirname(metadata.indexPath.trim())
1038 : ''
1039 if (
1040 metadataProjectDir &&
1041 fsExistsSafe(String(metadata.indexPath)) &&
1042 (!(typeof outputPath === 'string') ||
1043 outputPath.trim().length === 0 ||
1044 !fsExistsSafe(path.join(outputPath.trim(), 'index.html')))
1045 ) {
1046 return metadataProjectDir
1047 }
1048 if (typeof outputPath === 'string' && outputPath.trim().length > 0) {
1049 return outputPath.trim()
1050 }
1051 if (metadataProjectDir) return metadataProjectDir
1052 const storagePath = await resolveStoragePath().catch(() => '')
1053 return path.join(storagePath || process.cwd(), sessionId)
1054 }
1055
1056 const upsertPatchedGenerationPage = async (
1057 db: DrizzleDb,
1058 data: {
1059 runId: string
1060 sessionId: string
1061 pageId: string
1062 pageNumber: number
1063 title: string
1064 contentOutline?: string | null
1065 layoutIntent?: string | null
1066 htmlPath?: string | null
1067 status: GenerationPageStatus
1068 error?: string | null
1069 retryCount?: number
1070 }
1071 ): Promise<void> => {
1072 const now = Math.floor(Date.now() / 1000)
1073 const id = `${data.runId}:${data.pageId}`
1074 const values = {
1075 id,
1076 runId: data.runId,
1077 sessionId: data.sessionId,
1078 pageId: data.pageId,
1079 pageNumber: data.pageNumber,
1080 title: data.title,
1081 contentOutline: data.contentOutline || null,
1082 layoutIntent: data.layoutIntent || null,
1083 htmlPath: data.htmlPath || null,
1084 status: data.status,
1085 error: data.error || null,
1086 retryCount: Math.max(0, Math.floor(data.retryCount || 0)),
1087 createdAt: now,
1088 updatedAt: now
1089 }
1090 await db
1091 .insert(schema.generationPages)
1092 .values(values)
1093 .onConflictDoUpdate({
1094 target: schema.generationPages.id,
1095 set: {
1096 pageNumber: values.pageNumber,
1097 title: values.title,
1098 contentOutline: values.contentOutline,
1099 layoutIntent: values.layoutIntent,
1100 htmlPath: values.htmlPath,
1101 status: values.status,
1102 error: values.error,
1103 retryCount: values.retryCount,
1104 updatedAt: now
1105 }
1106 })
1107 .run()
1108 }
1109
1110 const patchGenerationRecordsFromMetadata = async (args: {
1111 client: LibSqlClient
1112 db: DrizzleDb
1113 resolveStoragePath: () => Promise<string>
1114 }): Promise<void> => {
1115 const { client, db, resolveStoragePath } = args
1116 const sessions = await client.execute(`
1117 SELECT id, page_count, status, metadata, updated_at
1118 FROM sessions
1119 WHERE metadata IS NOT NULL
1120 AND TRIM(metadata) <> ''
1121 AND NOT EXISTS (
1122 SELECT 1 FROM generation_runs WHERE generation_runs.session_id = sessions.id
1123 )
1124 ORDER BY updated_at DESC
1125 `)
1126
1127 for (const row of sessions.rows || []) {
1128 const sessionId = String(getRowValue(row, 'id') || '')
1129 if (!sessionId) continue
1130 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
1131 const generatedPages = Array.isArray(metadata.generatedPages)
1132 ? metadata.generatedPages.filter(
1133 (page): page is Record<string, unknown> =>
1134 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1135 )
1136 : []
1137 const failedPages = Array.isArray(metadata.failedPages)
1138 ? metadata.failedPages.filter(
1139 (page): page is Record<string, unknown> =>
1140 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1141 )
1142 : []
1143 if (generatedPages.length === 0 && failedPages.length === 0) continue
1144
1145 const projectDir = await resolveLegacyProjectDir(
1146 client,
1147 sessionId,
1148 metadata,
1149 resolveStoragePath
1150 )
1151 const pageMap = new Map<
1152 string,
1153 {
1154 pageId: string
1155 pageNumber: number
1156 title: string
1157 contentOutline: string
1158 layoutIntent: string | null
1159 htmlPath: string
1160 status: GenerationPageStatus
1161 error: string | null
1162 retryCount: number
1163 }
1164 >()
1165
1166 generatedPages.forEach((page, index) => {
1167 const pageNumber = inferPageNumber(page, index + 1)
1168 const rawPageId =
1169 typeof (page.pageId ?? page.page_id) === 'string'
1170 ? String(page.pageId ?? page.page_id).trim()
1171 : ''
1172 const pageId = rawPageId || `page-${pageNumber}`
1173 pageMap.set(pageId, {
1174 pageId,
1175 pageNumber,
1176 title: String(page.title || `第 ${pageNumber} 页`),
1177 contentOutline: String(page.contentOutline ?? page.content_outline ?? ''),
1178 layoutIntent:
1179 typeof (page.layoutIntent ?? page.layout_intent) === 'string'
1180 ? String(page.layoutIntent ?? page.layout_intent)
1181 : null,
1182 htmlPath: resolveLegacyPagePath(page, projectDir, pageId),
1183 status: 'completed',
1184 error: null,
1185 retryCount: toPositiveInt(page.retryCount ?? page.retry_count, 0)
1186 })
1187 })
1188
1189 failedPages.forEach((page, index) => {
1190 const pageNumber = inferPageNumber(page, generatedPages.length + index + 1)
1191 const rawPageId =
1192 typeof (page.pageId ?? page.page_id) === 'string'
1193 ? String(page.pageId ?? page.page_id).trim()
1194 : ''
1195 const pageId = rawPageId || `page-${pageNumber}`
1196 pageMap.set(pageId, {
1197 pageId,
1198 pageNumber,
1199 title: String(page.title || `第 ${pageNumber} 页`),
1200 contentOutline: String(page.contentOutline ?? page.content_outline ?? ''),
1201 layoutIntent:
1202 typeof (page.layoutIntent ?? page.layout_intent) === 'string'
1203 ? String(page.layoutIntent ?? page.layout_intent)
1204 : null,
1205 htmlPath: resolveLegacyPagePath(page, projectDir, pageId),
1206 status: 'failed',
1207 error: String(page.reason || page.error || '旧 metadata 记录的失败页'),
1208 retryCount: toPositiveInt(page.retryCount ?? page.retry_count, 0)
1209 })
1210 })
1211
1212 const pages = Array.from(pageMap.values()).sort((a, b) => a.pageNumber - b.pageNumber)
1213 if (pages.length === 0) continue
1214
1215 const generatedCount = pages.filter((page) => page.status === 'completed').length
1216 const failedCount = pages.filter((page) => page.status === 'failed').length
1217 const totalPages = Math.max(toPositiveInt(getRowValue(row, 'page_count'), 0), pages.length)
1218 const runStatus: GenerationRunStatus =
1219 failedCount > 0 ? (generatedCount > 0 ? 'partial' : 'failed') : 'completed'
1220 const runId = `patch-${sessionId}`
1221 const updatedAt = toPositiveInt(getRowValue(row, 'updated_at'), Math.floor(Date.now() / 1000))
1222
1223 await db
1224 .insert(schema.generationRuns)
1225 .values({
1226 id: runId,
1227 sessionId,
1228 mode: 'generate',
1229 status: runStatus,
1230 totalPages,
1231 error: failedCount > 0 ? `${failedCount} page(s) failed in legacy metadata` : null,
1232 metadata: JSON.stringify({
1233 source: 'metadata_patch',
1234 generatedCount,
1235 failedCount,
1236 patchedAt: new Date().toISOString()
1237 }),
1238 createdAt: updatedAt,
1239 updatedAt: Math.floor(Date.now() / 1000)
1240 })
1241 .onConflictDoNothing()
1242 .run()
1243
1244 for (const page of pages) {
1245 await upsertPatchedGenerationPage(db, {
1246 runId,
1247 sessionId,
1248 pageId: page.pageId,
1249 pageNumber: page.pageNumber,
1250 title: page.title,
1251 contentOutline: page.contentOutline,
1252 layoutIntent: page.layoutIntent,
1253 htmlPath: page.htmlPath,
1254 status: page.status,
1255 error: page.error,
1256 retryCount: page.retryCount
1257 })
1258 }
1259 }
1260 }
1261
1262 const patchSessionPagesFromLegacy = async (args: {
1263 client: LibSqlClient
1264 db: DrizzleDb
1265 resolveStoragePath: () => Promise<string>
1266 }): Promise<void> => {
1267 const { client, db, resolveStoragePath } = args
1268 const sessions = await client.execute(`
1269 SELECT id, metadata, updated_at
1270 FROM sessions
1271 WHERE metadata IS NOT NULL
1272 AND TRIM(metadata) <> ''
1273 AND NOT EXISTS (
1274 SELECT 1 FROM session_pages WHERE session_pages.session_id = sessions.id
1275 )
1276 ORDER BY updated_at DESC
1277 `)
1278
1279 for (const row of sessions.rows || []) {
1280 const sessionId = String(getRowValue(row, 'id') || '')
1281 if (!sessionId) continue
1282 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
1283 const generatedPages = Array.isArray(metadata.generatedPages)
1284 ? metadata.generatedPages.filter(
1285 (page): page is Record<string, unknown> =>
1286 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1287 )
1288 : []
1289 const failedPages = Array.isArray(metadata.failedPages)
1290 ? metadata.failedPages.filter(
1291 (page): page is Record<string, unknown> =>
1292 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1293 )
1294 : []
1295 if (generatedPages.length === 0 && failedPages.length === 0) continue
1296
1297 const pageMap = new Map<
1298 string,
1299 {
1300 pageNumber: number
1301 fileSlug: string
1302 title: string
1303 htmlPath: string
1304 status: 'completed' | 'failed'
1305 error: string | null
1306 }
1307 >()
1308 const failedById = new Map<string, string>()
1309 for (const failed of failedPages) {
1310 const pageNumber = inferPageNumber(failed, generatedPages.length + failedById.size + 1)
1311 const failedPageId =
1312 typeof (failed.pageId ?? failed.page_id) === 'string'
1313 ? String(failed.pageId ?? failed.page_id).trim()
1314 : ''
1315 const fileSlug = failedPageId || `page-${pageNumber}`
1316 failedById.set(fileSlug, String(failed.reason || failed.error || '页面生成失败'))
1317 pageMap.set(fileSlug, {
1318 pageNumber,
1319 fileSlug,
1320 title: String(failed.title || `第 ${pageNumber} 页`),
1321 htmlPath: '',
1322 status: 'failed',
1323 error: String(failed.reason || failed.error || '页面生成失败')
1324 })
1325 }
1326
1327 const projectDir = await resolveLegacyProjectDir(
1328 client,
1329 sessionId,
1330 metadata,
1331 resolveStoragePath
1332 )
1333 const now = Math.floor(Date.now() / 1000)
1334
1335 for (let index = 0; index < generatedPages.length; index += 1) {
1336 const page = generatedPages[index]
1337 const pageNumber = inferPageNumber(page, index + 1)
1338 const rawPageId =
1339 typeof (page.pageId ?? page.page_id) === 'string'
1340 ? String(page.pageId ?? page.page_id).trim()
1341 : ''
1342 const fileSlug = rawPageId || `page-${pageNumber}`
1343 const htmlPath = resolveLegacyPagePath(page, projectDir, fileSlug)
1344 const title = String(page.title || `第 ${pageNumber} 页`)
1345 const failedReason = failedById.get(fileSlug)
1346 const status = failedReason
1347 ? ('failed' as const)
1348 : fsExistsSafe(htmlPath)
1349 ? ('completed' as const)
1350 : ('failed' as const)
1351 const error = failedReason || (status === 'failed' ? '页面文件不存在' : null)
1352 pageMap.set(fileSlug, {
1353 pageNumber,
1354 fileSlug,
1355 title,
1356 htmlPath,
1357 status,
1358 error
1359 })
1360 }
1361
1362 for (const page of pageMap.values()) {
1363 if (!page.htmlPath) {
1364 page.htmlPath = path.join(projectDir, `${page.fileSlug}.html`)
1365 }
1366 await db
1367 .insert(schema.sessionPages)
1368 .values({
1369 id: nanoid(),
1370 sessionId,
1371 legacyPageId: /^page-\d+$/i.test(page.fileSlug) ? page.fileSlug : null,
1372 fileSlug: page.fileSlug,
1373 pageNumber: page.pageNumber,
1374 title: page.title,
1375 htmlPath: page.htmlPath,
1376 status: page.status,
1377 error: page.error,
1378 createdAt: now,
1379 updatedAt: now,
1380 deletedAt: null
1381 })
1382 .onConflictDoNothing()
1383 .run()
1384 }
1385 }
1386 }
1387
1388 const patchSessionPagesFromGenerationPages = async (args: {
1389 client: LibSqlClient
1390 db: DrizzleDb
1391 resolveStoragePath: () => Promise<string>
1392 }): Promise<void> => {
1393 const { client, db, resolveStoragePath } = args
1394 const rows = await client.execute(`
1395 SELECT
1396 sessions.id AS session_id,
1397 sessions.metadata AS metadata,
1398 sessions.updated_at AS session_updated_at,
1399 generation_pages.page_id AS page_id,
1400 generation_pages.page_number AS page_number,
1401 generation_pages.title AS title,
1402 generation_pages.html_path AS html_path,
1403 generation_pages.status AS status,
1404 generation_pages.error AS error,
1405 generation_pages.created_at AS created_at,
1406 generation_pages.updated_at AS updated_at
1407 FROM sessions
1408 INNER JOIN generation_pages ON generation_pages.session_id = sessions.id
1409 WHERE NOT EXISTS (
1410 SELECT 1 FROM session_pages WHERE session_pages.session_id = sessions.id
1411 )
1412 ORDER BY sessions.updated_at DESC, generation_pages.page_number ASC, generation_pages.updated_at DESC
1413 `)
1414
1415 const bySession = new Map<string, unknown[]>()
1416 for (const row of rows.rows || []) {
1417 const sessionId = String(getRowValue(row, 'session_id') || '')
1418 if (!sessionId) continue
1419 const list = bySession.get(sessionId) || []
1420 list.push(row)
1421 bySession.set(sessionId, list)
1422 }
1423
1424 for (const [sessionId, sessionRows] of bySession.entries()) {
1425 const firstRow = sessionRows[0]
1426 const metadata = parseJsonObject(getRowValue(firstRow, 'metadata'))
1427 const projectDir = await resolveLegacyProjectDir(
1428 client,
1429 sessionId,
1430 metadata,
1431 resolveStoragePath
1432 )
1433 const latestBySlug = new Map<string, Record<string, unknown>>()
1434 for (const row of sessionRows) {
1435 const pageId = String(getRowValue(row, 'page_id') || '').trim()
1436 if (!pageId || latestBySlug.has(pageId)) continue
1437 latestBySlug.set(pageId, row as Record<string, unknown>)
1438 }
1439
1440 const now = Math.floor(Date.now() / 1000)
1441 const pages = Array.from(latestBySlug.values()).sort((a, b) => {
1442 const aNumber = toPositiveInt(getRowValue(a, 'page_number'), 0)
1443 const bNumber = toPositiveInt(getRowValue(b, 'page_number'), 0)
1444 return aNumber - bNumber
1445 })
1446
1447 for (let index = 0; index < pages.length; index += 1) {
1448 const row = pages[index]
1449 const fileSlug = String(getRowValue(row, 'page_id') || `page-${index + 1}`).trim()
1450 const pageNumber = toPositiveInt(getRowValue(row, 'page_number'), index + 1)
1451 const rawHtmlPath = String(getRowValue(row, 'html_path') || '').trim()
1452 const htmlPath = rawHtmlPath
1453 ? path.isAbsolute(rawHtmlPath)
1454 ? rawHtmlPath
1455 : path.join(projectDir, rawHtmlPath)
1456 : path.join(projectDir, `${fileSlug}.html`)
1457 const rawStatus = String(getRowValue(row, 'status') || '').trim()
1458 const status =
1459 rawStatus === 'completed' && fsExistsSafe(htmlPath)
1460 ? ('completed' as const)
1461 : ('failed' as const)
1462 const error =
1463 status === 'failed'
1464 ? String(
1465 getRowValue(row, 'error') ||
1466 (fsExistsSafe(htmlPath) ? '页面生成失败' : '页面文件不存在')
1467 )
1468 : null
1469
1470 await db
1471 .insert(schema.sessionPages)
1472 .values({
1473 id: nanoid(),
1474 sessionId,
1475 legacyPageId: /^page-\d+$/i.test(fileSlug) ? fileSlug : null,
1476 fileSlug,
1477 pageNumber,
1478 title: String(getRowValue(row, 'title') || `第 ${pageNumber} 页`),
1479 htmlPath,
1480 status,
1481 error,
1482 createdAt: toPositiveInt(getRowValue(row, 'created_at'), now),
1483 updatedAt: now,
1484 deletedAt: null
1485 })
1486 .onConflictDoNothing()
1487 .run()
1488 }
1489 }
1490 }
1491
1492 const fsExistsSafe = (filePath: string): boolean => {
1493 try {
1494 return fs.existsSync(filePath)
1495 } catch {
1496 return false
1497 }
1498 }
1499
1500 const HTML_EDITOR_DOCS_TABLE_SQL = `
1501 CREATE TABLE IF NOT EXISTS html_edit_documents (
1502 id TEXT PRIMARY KEY,
1503 title TEXT NOT NULL DEFAULT '',
1504 source_path TEXT,
1505 html_path TEXT NOT NULL,
1506 design_width INTEGER NOT NULL DEFAULT 1280,
1507 created_at INTEGER NOT NULL,
1508 updated_at INTEGER NOT NULL
1509 );
1510 `
1511
1512 const HTML_EDITOR_MESSAGES_TABLE_SQL = `
1513 CREATE TABLE IF NOT EXISTS html_edit_messages (
1514 id TEXT PRIMARY KEY,
1515 doc_id TEXT NOT NULL REFERENCES html_edit_documents(id) ON DELETE CASCADE,
1516 role TEXT NOT NULL,
1517 content TEXT NOT NULL,
1518 intent TEXT,
1519 plan_json TEXT,
1520 requires_confirmation INTEGER NOT NULL DEFAULT 0,
1521 selected_selector TEXT,
1522 selected_label TEXT,
1523 selected_element_tag TEXT,
1524 selected_element_text TEXT,
1525 created_at INTEGER NOT NULL
1526 );
1527 `
1528
1529 const HTML_EDITOR_VERSIONS_TABLE_SQL = `
1530 CREATE TABLE IF NOT EXISTS html_edit_versions (
1531 id TEXT PRIMARY KEY,
1532 doc_id TEXT NOT NULL REFERENCES html_edit_documents(id) ON DELETE CASCADE,
1533 commit_sha TEXT NOT NULL,
1534 message TEXT NOT NULL DEFAULT '',
1535 created_at INTEGER NOT NULL
1536 );
1537 `
1538
1539 const enforceHtmlEditorSchema = async (client: LibSqlClient): Promise<void> => {
1540 await client.execute(HTML_EDITOR_DOCS_TABLE_SQL)
1541 await client.execute(HTML_EDITOR_MESSAGES_TABLE_SQL)
1542 await client.execute(HTML_EDITOR_VERSIONS_TABLE_SQL)
1543 const messageColumns = await getTableColumns(client, 'html_edit_messages')
1544 if (!messageColumns.has('selected_selector')) {
1545 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_selector TEXT')
1546 }
1547 if (!messageColumns.has('selected_label')) {
1548 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_label TEXT')
1549 }
1550 if (!messageColumns.has('selected_element_tag')) {
1551 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_element_tag TEXT')
1552 }
1553 if (!messageColumns.has('selected_element_text')) {
1554 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_element_text TEXT')
1555 }
1556 await client.execute(
1557 'CREATE INDEX IF NOT EXISTS idx_html_edit_messages_doc ON html_edit_messages(doc_id, created_at);'
1558 )
1559 await client.execute(
1560 'CREATE INDEX IF NOT EXISTS idx_html_edit_versions_doc ON html_edit_versions(doc_id, created_at);'
1561 )
1562 }
1563
1564 export const runDatabasePatches = async (args: {
1565 client: LibSqlClient
1566 db: DrizzleDb
1567 resolveStoragePath: () => Promise<string>
1568 }): Promise<void> => {
1569 const { client, db, resolveStoragePath } = args
1570 await client.executeMultiple(INIT_SQL)
1571 await enforceSessionsSchema(client)
1572 await enforceProjectsSchema(client)
1573 await enforceSettingsSchema(client)
1574 await enforceModelConfigsSchema(client)
1575 await enforceImageModelConfigsSchema(client)
1576 await enforceMessagesSchema(client)
1577 await client.executeMultiple(MODEL_USAGE_TABLE_SQL)
1578 await enforceGenerationSchema(client)
1579 await enforceSessionPagesSchema(client)
1580 await enforceSessionOperationsSchema(client)
1581 await enforceSessionOperationPagesSchema(client)
1582 await enforceHtmlEditorSchema(client)
1583 await patchStylesColumns(client)
1584 await client.execute('PRAGMA foreign_keys = ON;')
1585 await ensureDefaultSettings(client)
1586 await patchProjectRootPaths({ client, resolveStoragePath })
1587 await patchDesignContractFonts(client)
1588 await patchGenerationRecordsFromMetadata({ client, db, resolveStoragePath })
1589 await patchSessionPagesFromLegacy({ client, db, resolveStoragePath })
1590 await patchSessionPagesFromGenerationPages({ client, db, resolveStoragePath })
1591 await patchModelConfigMaxTokens(client)
1592 await patchModelConfigDisableTemperature(client)
1593 await patchModelConfigThinkingParameterMode(client)
1594 }
1595
1595 lines TYPESCRIPT