database.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import Dexie, { type EntityTable } from 'dexie';
  2. import type { Scene, SceneType, SceneContent, Whiteboard } from '@/lib/types/stage';
  3. import type { Action } from '@/lib/types/action';
  4. import type {
  5. SessionType,
  6. SessionStatus,
  7. SessionConfig,
  8. ToolCallRecord,
  9. ToolCallRequest,
  10. } from '@/lib/types/chat';
  11. import type { SceneOutline } from '@/lib/types/generation';
  12. import type { UIMessage } from 'ai';
  13. import { createLogger } from '@/lib/logger';
  14. const log = createLogger('Database');
  15. /**
  16. * Legacy Snapshot type for undo/redo functionality
  17. * Used by useSnapshotStore
  18. */
  19. export interface Snapshot {
  20. id?: number;
  21. index: number;
  22. slides: Scene[];
  23. }
  24. /**
  25. * MAIC Local Database
  26. *
  27. * Uses IndexedDB to store all user data locally
  28. * - Does not delete expired data; all data is stored permanently
  29. * - Uses a fixed database name
  30. * - Supports multi-course management
  31. */
  32. // ==================== Database Table Type Definitions ====================
  33. /**
  34. * Stage table - Course basic info
  35. */
  36. export interface StageRecord {
  37. id: string; // Primary key
  38. name: string;
  39. description?: string;
  40. createdAt: number; // timestamp
  41. updatedAt: number; // timestamp
  42. language?: string;
  43. style?: string;
  44. currentSceneId?: string;
  45. agentIds?: string[]; // Agent IDs selected at creation time
  46. }
  47. /**
  48. * Scene table - Scene/page data
  49. */
  50. export interface SceneRecord {
  51. id: string; // Primary key
  52. stageId: string; // Foreign key -> stages.id
  53. type: SceneType;
  54. title: string;
  55. order: number; // Display order
  56. content: SceneContent; // Stored as JSON
  57. actions?: Action[]; // Stored as JSON
  58. whiteboard?: Whiteboard[]; // Stored as JSON
  59. createdAt: number;
  60. updatedAt: number;
  61. }
  62. /**
  63. * AudioFile table - Audio files (TTS)
  64. */
  65. export interface AudioFileRecord {
  66. id: string; // Primary key (audioId)
  67. blob: Blob; // Audio binary data
  68. duration?: number; // Duration (seconds)
  69. format: string; // mp3, wav, etc.
  70. text?: string; // Corresponding text content
  71. voice?: string; // Voice used
  72. createdAt: number;
  73. ossKey?: string; // Full CDN URL for this audio blob
  74. }
  75. /**
  76. * ImageFile table - Image files
  77. */
  78. export interface ImageFileRecord {
  79. id: string; // Primary key
  80. blob: Blob; // Image binary data
  81. filename: string; // Original filename
  82. mimeType: string; // image/png, image/jpeg, etc.
  83. size: number; // File size (bytes)
  84. createdAt: number;
  85. }
  86. /**
  87. * ChatSession table - Chat session data
  88. */
  89. export interface ChatSessionRecord {
  90. id: string; // PK (session id)
  91. stageId: string; // FK -> stages.id
  92. type: SessionType;
  93. title: string;
  94. status: SessionStatus;
  95. messages: UIMessage[]; // JSON-safe serialized messages
  96. config: SessionConfig;
  97. toolCalls: ToolCallRecord[];
  98. pendingToolCalls: ToolCallRequest[];
  99. createdAt: number;
  100. updatedAt: number;
  101. sceneId?: string;
  102. lastActionIndex?: number;
  103. }
  104. /**
  105. * PlaybackState table - Playback state snapshot (at most one per stage)
  106. */
  107. export interface PlaybackStateRecord {
  108. stageId: string; // PK
  109. sceneIndex: number;
  110. actionIndex: number;
  111. consumedDiscussions: string[];
  112. updatedAt: number;
  113. }
  114. /**
  115. * StageOutlines table - Persisted outlines for resume-on-refresh
  116. */
  117. export interface StageOutlinesRecord {
  118. stageId: string; // Primary key (FK -> stages.id)
  119. outlines: SceneOutline[];
  120. createdAt: number;
  121. updatedAt: number;
  122. }
  123. /**
  124. * MediaFile table - AI-generated media files (images/videos)
  125. */
  126. export interface MediaFileRecord {
  127. id: string; // Compound key: `${stageId}:${elementId}`
  128. stageId: string; // FK → stages.id
  129. type: 'image' | 'video';
  130. blob: Blob; // Media binary
  131. mimeType: string; // image/png, video/mp4
  132. size: number;
  133. poster?: Blob; // Video thumbnail blob
  134. prompt: string; // Original prompt (for retry)
  135. params: string; // JSON-serialized generation params
  136. error?: string; // If set, this is a failed task (blob is empty placeholder)
  137. errorCode?: string; // Structured error code (e.g. 'CONTENT_SENSITIVE')
  138. ossKey?: string; // Full CDN URL for this media blob
  139. posterOssKey?: string; // Full CDN URL for the poster blob
  140. createdAt: number;
  141. }
  142. /**
  143. * GeneratedAgent table - AI-generated agent profiles
  144. */
  145. export interface GeneratedAgentRecord {
  146. id: string; // PK: agent ID (e.g. "gen-abc123")
  147. stageId: string; // FK -> stages.id
  148. name: string;
  149. role: string; // 'teacher' | 'assistant' | 'student'
  150. persona: string;
  151. avatar: string;
  152. color: string;
  153. priority: number;
  154. createdAt: number;
  155. }
  156. /** Build the compound primary key for mediaFiles: `${stageId}:${elementId}` */
  157. export function mediaFileKey(stageId: string, elementId: string): string {
  158. return `${stageId}:${elementId}`;
  159. }
  160. // ==================== Database Definition ====================
  161. const DATABASE_NAME = 'MAIC-Database';
  162. const _DATABASE_VERSION = 8;
  163. /**
  164. * MAIC Database Instance
  165. */
  166. class MAICDatabase extends Dexie {
  167. // Table definitions
  168. stages!: EntityTable<StageRecord, 'id'>;
  169. scenes!: EntityTable<SceneRecord, 'id'>;
  170. audioFiles!: EntityTable<AudioFileRecord, 'id'>;
  171. imageFiles!: EntityTable<ImageFileRecord, 'id'>;
  172. snapshots!: EntityTable<Snapshot, 'id'>; // Undo/redo snapshots (legacy)
  173. chatSessions!: EntityTable<ChatSessionRecord, 'id'>;
  174. playbackState!: EntityTable<PlaybackStateRecord, 'stageId'>;
  175. stageOutlines!: EntityTable<StageOutlinesRecord, 'stageId'>;
  176. mediaFiles!: EntityTable<MediaFileRecord, 'id'>;
  177. generatedAgents!: EntityTable<GeneratedAgentRecord, 'id'>;
  178. constructor() {
  179. super(DATABASE_NAME);
  180. // Version 1: Initial schema
  181. this.version(1).stores({
  182. stages: 'id, updatedAt',
  183. scenes: 'id, stageId, order, [stageId+order]',
  184. audioFiles: 'id, createdAt',
  185. imageFiles: 'id, createdAt',
  186. snapshots: '++id',
  187. // Previously had: messages, participants, discussions, sceneSnapshots
  188. });
  189. // Version 2: Remove unused tables
  190. this.version(2).stores({
  191. stages: 'id, updatedAt',
  192. scenes: 'id, stageId, order, [stageId+order]',
  193. audioFiles: 'id, createdAt',
  194. imageFiles: 'id, createdAt',
  195. snapshots: '++id',
  196. // Delete removed tables
  197. messages: null,
  198. participants: null,
  199. discussions: null,
  200. sceneSnapshots: null,
  201. });
  202. // Version 3: Add chatSessions and playbackState tables
  203. this.version(3).stores({
  204. stages: 'id, updatedAt',
  205. scenes: 'id, stageId, order, [stageId+order]',
  206. audioFiles: 'id, createdAt',
  207. imageFiles: 'id, createdAt',
  208. snapshots: '++id',
  209. chatSessions: 'id, stageId, [stageId+createdAt]',
  210. playbackState: 'stageId',
  211. });
  212. // Version 4: Add stageOutlines table for resume-on-refresh
  213. this.version(4).stores({
  214. stages: 'id, updatedAt',
  215. scenes: 'id, stageId, order, [stageId+order]',
  216. audioFiles: 'id, createdAt',
  217. imageFiles: 'id, createdAt',
  218. snapshots: '++id',
  219. chatSessions: 'id, stageId, [stageId+createdAt]',
  220. playbackState: 'stageId',
  221. stageOutlines: 'stageId',
  222. });
  223. // Version 5: Add mediaFiles table for async media generation
  224. this.version(5).stores({
  225. stages: 'id, updatedAt',
  226. scenes: 'id, stageId, order, [stageId+order]',
  227. audioFiles: 'id, createdAt',
  228. imageFiles: 'id, createdAt',
  229. snapshots: '++id',
  230. chatSessions: 'id, stageId, [stageId+createdAt]',
  231. playbackState: 'stageId',
  232. stageOutlines: 'stageId',
  233. mediaFiles: 'id, stageId, [stageId+type]',
  234. });
  235. // Version 6: Fix mediaFiles primary key — use compound key stageId:elementId
  236. // to prevent cross-course collisions (gen_img_1 is NOT globally unique)
  237. this.version(6)
  238. .stores({
  239. stages: 'id, updatedAt',
  240. scenes: 'id, stageId, order, [stageId+order]',
  241. audioFiles: 'id, createdAt',
  242. imageFiles: 'id, createdAt',
  243. snapshots: '++id',
  244. chatSessions: 'id, stageId, [stageId+createdAt]',
  245. playbackState: 'stageId',
  246. stageOutlines: 'stageId',
  247. mediaFiles: 'id, stageId, [stageId+type]',
  248. })
  249. .upgrade(async (tx) => {
  250. const table = tx.table('mediaFiles');
  251. const allRecords = await table.toArray();
  252. for (const rec of allRecords) {
  253. const newKey = `${rec.stageId}:${rec.id}`;
  254. // Skip if already migrated (idempotent)
  255. if (rec.id.includes(':')) continue;
  256. await table.delete(rec.id);
  257. await table.put({ ...rec, id: newKey });
  258. }
  259. });
  260. // Version 7: Add ossKey fields to mediaFiles and audioFiles for OSS storage plugin
  261. // Non-indexed optional fields — Dexie handles these transparently.
  262. this.version(7).stores({
  263. stages: 'id, updatedAt',
  264. scenes: 'id, stageId, order, [stageId+order]',
  265. audioFiles: 'id, createdAt',
  266. imageFiles: 'id, createdAt',
  267. snapshots: '++id',
  268. chatSessions: 'id, stageId, [stageId+createdAt]',
  269. playbackState: 'stageId',
  270. stageOutlines: 'stageId',
  271. mediaFiles: 'id, stageId, [stageId+type]',
  272. });
  273. // Version 8: Add generatedAgents table for AI-generated agent profiles
  274. this.version(8).stores({
  275. stages: 'id, updatedAt',
  276. scenes: 'id, stageId, order, [stageId+order]',
  277. audioFiles: 'id, createdAt',
  278. imageFiles: 'id, createdAt',
  279. snapshots: '++id',
  280. chatSessions: 'id, stageId, [stageId+createdAt]',
  281. playbackState: 'stageId',
  282. stageOutlines: 'stageId',
  283. mediaFiles: 'id, stageId, [stageId+type]',
  284. generatedAgents: 'id, stageId',
  285. });
  286. }
  287. }
  288. // Create database instance
  289. export const db = new MAICDatabase();
  290. // ==================== Helper Functions ====================
  291. /**
  292. * Initialize database
  293. * Call at application startup
  294. */
  295. export async function initDatabase(): Promise<void> {
  296. try {
  297. await db.open();
  298. // Request persistent storage to prevent browser from evicting IndexedDB
  299. // under storage pressure (large media blobs can trigger LRU cleanup)
  300. void navigator.storage?.persist?.();
  301. log.info('Database initialized successfully');
  302. } catch (error) {
  303. log.error('Failed to initialize database:', error);
  304. throw error;
  305. }
  306. }
  307. /**
  308. * Clear database (optional)
  309. * Use with caution: deletes all data
  310. */
  311. export async function clearDatabase(): Promise<void> {
  312. await db.delete();
  313. log.info('Database cleared');
  314. }
  315. /**
  316. * Export database contents (for backup)
  317. */
  318. export async function exportDatabase(): Promise<{
  319. stages: StageRecord[];
  320. scenes: SceneRecord[];
  321. chatSessions: ChatSessionRecord[];
  322. playbackState: PlaybackStateRecord[];
  323. }> {
  324. return {
  325. stages: await db.stages.toArray(),
  326. scenes: await db.scenes.toArray(),
  327. chatSessions: await db.chatSessions.toArray(),
  328. playbackState: await db.playbackState.toArray(),
  329. };
  330. }
  331. /**
  332. * Import database contents (for restoring backups)
  333. */
  334. export async function importDatabase(data: {
  335. stages?: StageRecord[];
  336. scenes?: SceneRecord[];
  337. chatSessions?: ChatSessionRecord[];
  338. playbackState?: PlaybackStateRecord[];
  339. }): Promise<void> {
  340. await db.transaction(
  341. 'rw',
  342. [db.stages, db.scenes, db.chatSessions, db.playbackState],
  343. async () => {
  344. if (data.stages) await db.stages.bulkPut(data.stages);
  345. if (data.scenes) await db.scenes.bulkPut(data.scenes);
  346. if (data.chatSessions) await db.chatSessions.bulkPut(data.chatSessions);
  347. if (data.playbackState) await db.playbackState.bulkPut(data.playbackState);
  348. },
  349. );
  350. log.info('Database imported successfully');
  351. }
  352. // ==================== Convenience Query Functions ====================
  353. /**
  354. * Get all scenes for a course
  355. */
  356. export async function getScenesByStageId(stageId: string): Promise<SceneRecord[]> {
  357. return db.scenes.where('stageId').equals(stageId).sortBy('order');
  358. }
  359. /**
  360. * Delete a course and all its related data
  361. */
  362. export async function deleteStageWithRelatedData(stageId: string): Promise<void> {
  363. await db.transaction(
  364. 'rw',
  365. [
  366. db.stages,
  367. db.scenes,
  368. db.chatSessions,
  369. db.playbackState,
  370. db.stageOutlines,
  371. db.mediaFiles,
  372. db.generatedAgents,
  373. ],
  374. async () => {
  375. await db.stages.delete(stageId);
  376. await db.scenes.where('stageId').equals(stageId).delete();
  377. await db.chatSessions.where('stageId').equals(stageId).delete();
  378. await db.playbackState.delete(stageId);
  379. await db.stageOutlines.delete(stageId);
  380. await db.mediaFiles.where('stageId').equals(stageId).delete();
  381. await db.generatedAgents.where('stageId').equals(stageId).delete();
  382. },
  383. );
  384. }
  385. /**
  386. * Get all generated agents for a course
  387. */
  388. export async function getGeneratedAgentsByStageId(
  389. stageId: string,
  390. ): Promise<GeneratedAgentRecord[]> {
  391. return db.generatedAgents.where('stageId').equals(stageId).toArray();
  392. }
  393. /**
  394. * Get database statistics
  395. */
  396. export async function getDatabaseStats() {
  397. return {
  398. stages: await db.stages.count(),
  399. scenes: await db.scenes.count(),
  400. audioFiles: await db.audioFiles.count(),
  401. imageFiles: await db.imageFiles.count(),
  402. snapshots: await db.snapshots.count(),
  403. chatSessions: await db.chatSessions.count(),
  404. playbackState: await db.playbackState.count(),
  405. stageOutlines: await db.stageOutlines.count(),
  406. mediaFiles: await db.mediaFiles.count(),
  407. generatedAgents: await db.generatedAgents.count(),
  408. };
  409. }