diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef42..2c63c085 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,2 @@ -{} +{ +} diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index 7e190fa2..13561ca6 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -1,5 +1,9 @@ /** * SQLite database schema definitions and migrations (sql.js version). + * + * Uses named migrations instead of sequential version numbers to avoid + * conflicts when multiple branches define migrations independently. + * Each migration has a unique string ID and an idempotent apply function. */ import type { Database as SqlJsDatabase } from "sql.js"; @@ -7,9 +11,87 @@ import type { Database as SqlJsDatabase } from "sql.js"; const DEFAULT_PER_EXECUTION_LIMIT = 10.0; const DEFAULT_DAILY_LIMIT = 100.0; +/** Map old numeric versions to the named migration IDs they correspond to. */ +const LEGACY_VERSION_MAP: Record = { + 1: ["001_initial_schema"], + 2: ["001_initial_schema", "002_add_templates"], + 3: ["001_initial_schema", "002_add_templates"], +}; + +interface NamedMigration { + id: string; + apply: (db: SqlJsDatabase) => void; +} + +/** + * All migrations in order. Each `apply` MUST be idempotent so that + * re-running a migration on a database that already has the change + * is safe (e.g. use IF NOT EXISTS, check columns before ALTER). + */ +const migrations: NamedMigration[] = [ + { + id: "001_initial_schema", + apply: (_db: SqlJsDatabase) => { + // Handled by initializeSchema — listed here for completeness. + }, + }, + { + id: "002_add_templates", + apply: (db: SqlJsDatabase) => { + console.log("[Schema] Applying migration: 002_add_templates"); + db.run(`CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + tags TEXT, + type TEXT NOT NULL CHECK (type IN ('public', 'custom')), + template_type TEXT NOT NULL CHECK (template_type IN ('playground', 'workflow')), + is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + author TEXT, + use_count INTEGER NOT NULL DEFAULT 0, + thumbnail TEXT, + playground_data TEXT, + workflow_data TEXT + )`); + + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)", + ); + }, + }, + { + id: "003_add_search_text", + apply: (db: SqlJsDatabase) => { + console.log("[Schema] Applying migration: 003_add_search_text"); + const cols = db.exec("PRAGMA table_info(templates)"); + const hasColumn = cols[0]?.values?.some( + (row) => row[1] === "search_text", + ); + if (!hasColumn) { + db.run("ALTER TABLE templates ADD COLUMN search_text TEXT"); + } + }, + }, +]; + export function initializeSchema(db: SqlJsDatabase): void { - db.run(`CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, + // Use the new named migrations table from the start + db.run(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )`); @@ -91,7 +173,8 @@ export function initializeSchema(db: SqlJsDatabase): void { use_count INTEGER NOT NULL DEFAULT 0, thumbnail TEXT, playground_data TEXT, - workflow_data TEXT + workflow_data TEXT, + search_text TEXT )`); // Indexes @@ -144,64 +227,73 @@ export function initializeSchema(db: SqlJsDatabase): void { db.run( "INSERT OR IGNORE INTO api_keys (id, wavespeed_key, llm_key) VALUES (1, NULL, NULL)", ); - db.run("INSERT OR IGNORE INTO schema_version (version) VALUES (1)"); + + // Mark all migrations as applied for a fresh database + for (const m of migrations) { + db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [m.id]); + } } export function runMigrations(db: SqlJsDatabase): void { - const result = db.exec("SELECT MAX(version) as version FROM schema_version"); - const currentVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0; - - const migrations: Array<{ - version: number; - apply: (db: SqlJsDatabase) => void; - }> = [ - // Migration 2: Add templates table - { - version: 2, - apply: (db: SqlJsDatabase) => { - console.log("[Schema] Applying migration 2: Add templates table"); - - db.run(`CREATE TABLE IF NOT EXISTS templates ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - tags TEXT, - type TEXT NOT NULL CHECK (type IN ('public', 'custom')), - template_type TEXT NOT NULL CHECK (template_type IN ('playground', 'workflow')), - is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)), - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - author TEXT, - use_count INTEGER NOT NULL DEFAULT 0, - thumbnail TEXT, - playground_data TEXT, - workflow_data TEXT - )`); - - db.run( - "CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)", - ); - db.run( - "CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)", - ); - db.run( - "CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)", - ); - db.run( - "CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)", - ); - db.run( - "CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)", - ); - - db.run("INSERT INTO schema_version (version) VALUES (2)"); - }, - }, - ]; + // --- Step 1: Detect and upgrade from legacy numeric schema_version --- + const tables = db.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('schema_version', 'schema_migrations')", + ); + const tableNames = tables[0]?.values?.map((r) => r[0] as string) ?? []; + const hasLegacyTable = tableNames.includes("schema_version"); + const hasNewTable = tableNames.includes("schema_migrations"); + + if (hasLegacyTable && !hasNewTable) { + // Migrate from old numeric system to named migrations + console.log( + "[Schema] Upgrading from legacy schema_version to named migrations", + ); + + const result = db.exec( + "SELECT MAX(version) as version FROM schema_version", + ); + const legacyVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0; + + // Create the new table + db.run(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + // Map old version number to known migration IDs + const knownApplied = LEGACY_VERSION_MAP[legacyVersion] ?? [ + "001_initial_schema", + ]; + for (const id of knownApplied) { + db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [id]); + } + + // Drop the old table + db.run("DROP TABLE IF EXISTS schema_version"); + } + + if (!hasLegacyTable && !hasNewTable) { + // No migration tracking at all — treat as version 1 (initial schema exists) + db.run(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [ + "001_initial_schema", + ]); + } + + // --- Step 2: Run any missing migrations --- + const applied = db.exec("SELECT id FROM schema_migrations"); + const appliedSet = new Set( + applied[0]?.values?.map((r) => r[0] as string) ?? [], + ); for (const m of migrations) { - if (m.version > currentVersion) { + if (!appliedSet.has(m.id)) { + console.log(`[Schema] Running migration: ${m.id}`); m.apply(db); + db.run("INSERT INTO schema_migrations (id) VALUES (?)", [m.id]); } } } diff --git a/electron/workflow/db/template.repo.ts b/electron/workflow/db/template.repo.ts index 694a4637..59d6a4e6 100644 --- a/electron/workflow/db/template.repo.ts +++ b/electron/workflow/db/template.repo.ts @@ -28,8 +28,8 @@ export function createTemplate(input: CreateTemplateInput): Template { `INSERT INTO templates ( id, name, description, tags, type, template_type, is_favorite, created_at, updated_at, author, use_count, thumbnail, - playground_data, workflow_data - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + playground_data, workflow_data, search_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, input.name, @@ -45,6 +45,7 @@ export function createTemplate(input: CreateTemplateInput): Template { input.thumbnail || null, playgroundData, workflowData, + input._searchText || null, ], ); @@ -57,7 +58,7 @@ export function getTemplateById(id: string): Template | null { const result = db.exec( `SELECT id, name, description, tags, type, template_type, is_favorite, created_at, updated_at, author, use_count, thumbnail, - playground_data, workflow_data + playground_data, workflow_data, search_text FROM templates WHERE id = ?`, [id], ); @@ -87,14 +88,23 @@ export function queryTemplates(filter?: TemplateFilter): Template[] { } if (filter?.category && filter.templateType === "workflow") { - conditions.push("json_extract(workflow_data, '$.category') = ?"); - params.push(filter.category); + conditions.push("workflow_data LIKE ?"); + params.push(`%"category":"${filter.category}"%`); } if (filter?.search) { const searchPattern = `%${filter.search}%`; - conditions.push("(name LIKE ? OR description LIKE ? OR tags LIKE ?)"); - params.push(searchPattern, searchPattern, searchPattern); + conditions.push( + "(name LIKE ? OR description LIKE ? OR tags LIKE ? OR playground_data LIKE ? OR workflow_data LIKE ? OR search_text LIKE ?)", + ); + params.push( + searchPattern, + searchPattern, + searchPattern, + searchPattern, + searchPattern, + searchPattern, + ); } const whereClause = @@ -106,7 +116,7 @@ export function queryTemplates(filter?: TemplateFilter): Template[] { const query = `SELECT id, name, description, tags, type, template_type, is_favorite, created_at, updated_at, author, use_count, thumbnail, - playground_data, workflow_data + playground_data, workflow_data, search_text FROM templates ${whereClause} ${orderBy}`; const result = db.exec(query, params); @@ -192,6 +202,19 @@ export function deleteTemplates(ids: string[]): void { persistDatabase(); } +export function queryTemplateNames(templateType?: string): string[] { + const db = getDatabase(); + let query = "SELECT name FROM templates"; + const params: any[] = []; + if (templateType) { + query += " WHERE template_type = ?"; + params.push(templateType); + } + const result = db.exec(query, params); + if (!result.length) return []; + return result[0].values.map((row: any[]) => row[0] as string); +} + function rowToTemplate(row: any[]): Template { const tags = row[3] ? JSON.parse(row[3] as string) : []; const playgroundData = row[12] ? JSON.parse(row[12] as string) : null; @@ -212,5 +235,6 @@ function rowToTemplate(row: any[]): Template { thumbnail: row[11] as string | null, playgroundData, workflowData, + _searchText: (row[14] as string) || undefined, }; } diff --git a/electron/workflow/db/workflow.repo.ts b/electron/workflow/db/workflow.repo.ts index c52ba7c8..4b38a625 100644 --- a/electron/workflow/db/workflow.repo.ts +++ b/electron/workflow/db/workflow.repo.ts @@ -155,8 +155,23 @@ export function updateWorkflow( try { db.run("DELETE FROM nodes WHERE workflow_id = ?", [id]); db.run("DELETE FROM edges WHERE workflow_id = ?", [id]); - for (const node of graphDefinition.nodes) { - // Insert with NULL first (safe), then restore outputId + + // Deduplicate by id — guards against React Flow state containing + // duplicate entries after undo/paste/rapid operations. + const seenNodeIds = new Set(); + const uniqueNodes = graphDefinition.nodes.filter((n) => { + if (seenNodeIds.has(n.id)) return false; + seenNodeIds.add(n.id); + return true; + }); + const seenEdgeIds = new Set(); + const uniqueEdges = graphDefinition.edges.filter((e) => { + if (seenEdgeIds.has(e.id)) return false; + seenEdgeIds.add(e.id); + return true; + }); + + for (const node of uniqueNodes) { db.run( `INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id) VALUES (?, ?, ?, ?, ?, ?, NULL)`, [ @@ -169,7 +184,7 @@ export function updateWorkflow( ], ); } - for (const edge of graphDefinition.edges) { + for (const edge of uniqueEdges) { db.run( `INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key) VALUES (?, ?, ?, ?, ?, ?)`, [ @@ -183,7 +198,7 @@ export function updateWorkflow( ); } // Restore currentOutputId where the execution record still exists - for (const node of graphDefinition.nodes) { + for (const node of uniqueNodes) { const outputId = node.currentOutputId ?? existingOutputIds.get(node.id) ?? null; if (outputId) { diff --git a/electron/workflow/ipc/template.ipc.ts b/electron/workflow/ipc/template.ipc.ts index 48e08aec..e8fda64d 100644 --- a/electron/workflow/ipc/template.ipc.ts +++ b/electron/workflow/ipc/template.ipc.ts @@ -1,4 +1,6 @@ -import { ipcMain } from "electron"; +import { ipcMain, dialog, BrowserWindow } from "electron"; +import { writeFileSync, existsSync, readFileSync } from "fs"; +import { join } from "path"; import * as templateRepo from "../db/template.repo"; import { migrateTemplatesSync } from "../services/template-migration"; import { @@ -12,6 +14,24 @@ import type { TemplateExport, } from "../../../src/types/template"; +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*]/g, "_").trim() || "template"; +} + +function getUniqueFilePath(dir: string, baseName: string, ext: string): string { + let candidate = join(dir, `${baseName}${ext}`); + if (!existsSync(candidate)) return candidate; + let counter = 1; + while (existsSync(join(dir, `${baseName} (${counter})${ext}`))) counter++; + return join(dir, `${baseName} (${counter})${ext}`); +} + +function getTemplateById(id: string): Template | null { + return id.startsWith("file-") + ? getFileTemplateById(id) + : templateRepo.getTemplateById(id); +} + export function registerTemplateIpc(): void { ipcMain.handle( "template:migrate", @@ -52,7 +72,10 @@ export function registerTemplateIpc(): void { // Deduplicate: file templates take priority over DB templates with the same name const fileNames = new Set(fileTemps.map((t) => t.name)); const dedupedDb = dbTemplates.filter((t) => !fileNames.has(t.name)); - return [...fileTemps, ...dedupedDb]; + // Sort: file (public) first, then DB public, then custom last + const dbPublic = dedupedDb.filter((t) => t.type === "public"); + const dbCustom = dedupedDb.filter((t) => t.type !== "public"); + return [...fileTemps, ...dbPublic, ...dbCustom]; }, ); @@ -97,14 +120,31 @@ export function registerTemplateIpc(): void { }, ); + ipcMain.handle( + "template:queryNames", + async (_event, args?: { templateType?: string }): Promise => { + const dbNames = templateRepo.queryTemplateNames(args?.templateType); + const fileTemps = getFileTemplates( + args?.templateType + ? { templateType: args.templateType as "playground" | "workflow" } + : undefined, + ); + const fileNames = fileTemps.map((t) => t.name); + return [...new Set([...fileNames, ...dbNames])]; + }, + ); + ipcMain.handle( "template:export", async (_event, args: { ids?: string[] }): Promise => { - const templates = args.ids - ? (args.ids - .map((id) => templateRepo.getTemplateById(id)) - .filter(Boolean) as Template[]) - : templateRepo.queryTemplates(); + let templates: Template[]; + if (args.ids) { + templates = args.ids + .map((id) => getTemplateById(id)) + .filter(Boolean) as Template[]; + } else { + templates = templateRepo.queryTemplates(); + } return { version: "1.0", @@ -114,47 +154,227 @@ export function registerTemplateIpc(): void { }, ); + // Single template export — save dialog + ipcMain.handle( + "template:exportSingle", + async ( + _event, + args: { id: string; defaultName: string }, + ): Promise<{ success: boolean; filePath?: string; canceled?: boolean }> => { + const template = getTemplateById(args.id); + if (!template) throw new Error(`Template ${args.id} not found`); + + const win = BrowserWindow.getFocusedWindow(); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${sanitizeFilename(args.defaultName)}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (result.canceled || !result.filePath) { + return { success: false, canceled: true }; + } + + const data: TemplateExport = { + version: "1.0", + exportedAt: new Date().toISOString(), + templates: [template], + }; + writeFileSync(result.filePath, JSON.stringify(data, null, 2), "utf-8"); + return { success: true, filePath: result.filePath }; + }, + ); + + // Batch template export — folder picker, one file per template + ipcMain.handle( + "template:exportBatch", + async ( + _event, + args: { ids: string[] }, + ): Promise<{ + success: boolean; + count?: number; + folderPath?: string; + canceled?: boolean; + }> => { + const templates = args.ids + .map((id) => getTemplateById(id)) + .filter(Boolean) as Template[]; + if (templates.length === 0) throw new Error("No templates found"); + + const win = BrowserWindow.getFocusedWindow(); + const result = await dialog.showOpenDialog(win!, { + properties: ["openDirectory", "createDirectory"], + }); + + if (result.canceled || !result.filePaths.length) { + return { success: false, canceled: true }; + } + + const folder = result.filePaths[0]; + const now = new Date().toISOString(); + let count = 0; + + for (const tpl of templates) { + const baseName = sanitizeFilename(tpl.name); + const filePath = getUniqueFilePath(folder, baseName, ".json"); + const data: TemplateExport = { + version: "1.0", + exportedAt: now, + templates: [tpl], + }; + writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); + count++; + } + + return { success: true, count, folderPath: folder }; + }, + ); + + // Export all templates merged into one file — save dialog + ipcMain.handle( + "template:exportMerged", + async ( + _event, + args: { ids: string[]; defaultName: string }, + ): Promise<{ success: boolean; filePath?: string; canceled?: boolean }> => { + const templates = args.ids + .map((id) => getTemplateById(id)) + .filter(Boolean) as Template[]; + if (templates.length === 0) throw new Error("No templates found"); + + const win = BrowserWindow.getFocusedWindow(); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${sanitizeFilename(args.defaultName)}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (result.canceled || !result.filePath) { + return { success: false, canceled: true }; + } + + const data: TemplateExport = { + version: "1.0", + exportedAt: new Date().toISOString(), + templates, + }; + writeFileSync(result.filePath, JSON.stringify(data, null, 2), "utf-8"); + return { success: true, filePath: result.filePath }; + }, + ); + + // Import picker — multi-file selection, returns parsed data + ipcMain.handle( + "template:importPick", + async (): Promise<{ + canceled: boolean; + templates?: TemplateExport[]; + }> => { + const win = BrowserWindow.getFocusedWindow(); + const result = await dialog.showOpenDialog(win!, { + properties: ["openFile", "multiSelections"], + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (result.canceled || !result.filePaths.length) { + return { canceled: true }; + } + + const templates: TemplateExport[] = []; + for (const filePath of result.filePaths) { + const content = readFileSync(filePath, "utf-8"); + const data = JSON.parse(content) as TemplateExport; + templates.push(data); + } + + return { canceled: false, templates }; + }, + ); + ipcMain.handle( "template:import", async ( _event, - args: { data: TemplateExport; mode: "merge" | "replace" }, - ): Promise<{ imported: number; skipped: number }> => { + args: { data: TemplateExport; mode: "merge" | "replace" | "rename" }, + ): Promise<{ imported: number; skipped: number; replaced: number }> => { validateImportData(args.data); + let replaced = 0; + if (args.mode === "replace") { - const existing = templateRepo.queryTemplates({ type: "custom" }); - templateRepo.deleteTemplates(existing.map((t) => t.id)); + // Replace: delete existing custom templates that have the same name+type as imports + const importedTypes = new Set( + args.data.templates.map((t) => t.templateType), + ); + for (const tplType of importedTypes) { + const existing = templateRepo.queryTemplates({ + templateType: tplType as "playground" | "workflow", + type: "custom", + }); + const importNames = new Set( + args.data.templates + .filter((t) => t.templateType === tplType) + .map((t) => t.name), + ); + const toDelete = existing.filter((t) => importNames.has(t.name)); + if (toDelete.length > 0) { + templateRepo.deleteTemplates(toDelete.map((t) => t.id)); + replaced += toDelete.length; + } + } } let imported = 0; let skipped = 0; - const existingKeys = new Set( - templateRepo.queryTemplates().map((t) => `${t.templateType}:${t.name}`), - ); + // Build a live set of existing names (per templateType) for dedup / rename + const existingNamesByType: Record> = {}; + for (const t of templateRepo.queryTemplates()) { + if (!existingNamesByType[t.templateType]) + existingNamesByType[t.templateType] = new Set(); + existingNamesByType[t.templateType].add(t.name); + } + // Also include file template names + for (const t of getFileTemplates()) { + if (!existingNamesByType[t.templateType]) + existingNamesByType[t.templateType] = new Set(); + existingNamesByType[t.templateType].add(t.name); + } for (const template of args.data.templates) { - const key = `${template.templateType}:${template.name}`; - if (args.mode === "merge" && existingKeys.has(key)) { - skipped++; - } else { - templateRepo.createTemplate({ - name: template.name, - description: template.description, - tags: template.tags, - type: "custom", - templateType: template.templateType, - author: template.author, - thumbnail: template.thumbnail, - playgroundData: template.playgroundData, - workflowData: template.workflowData, - }); - imported++; + const typeNames = + existingNamesByType[template.templateType] ?? new Set(); + let finalName = template.name; + + if (typeNames.has(template.name)) { + if (args.mode === "merge") { + skipped++; + continue; + } + if (args.mode === "rename") { + // Auto-rename: append (2), (3), etc. + let counter = 2; + while (typeNames.has(`${template.name} (${counter})`)) counter++; + finalName = `${template.name} (${counter})`; + } + // For "replace" mode, conflicting ones were already deleted above, so name is free } + + templateRepo.createTemplate({ + name: finalName, + description: template.description, + tags: template.tags, + type: "custom", + templateType: template.templateType, + author: template.author, + thumbnail: template.thumbnail, + playgroundData: template.playgroundData, + workflowData: template.workflowData, + }); + typeNames.add(finalName); + imported++; } - return { imported, skipped }; + return { imported, skipped, replaced }; }, ); } diff --git a/electron/workflow/services/template-init.ts b/electron/workflow/services/template-init.ts index 2a33921c..2c1c0a7f 100644 --- a/electron/workflow/services/template-init.ts +++ b/electron/workflow/services/template-init.ts @@ -53,16 +53,15 @@ export function initializeDefaultTemplates(): void { */ function cleanupOldPublicTemplates(): void { try { - const fileNames = new Set(fileTemplates.map((t) => t.name)); + // Remove ALL old public workflow templates from DB — they are now file-based const dbPublic = templateRepo.queryTemplates({ type: "public", templateType: "workflow", }); - const toDelete = dbPublic.filter((t) => fileNames.has(t.name)); - if (toDelete.length > 0) { - templateRepo.deleteTemplates(toDelete.map((t) => t.id)); + if (dbPublic.length > 0) { + templateRepo.deleteTemplates(dbPublic.map((t) => t.id)); console.log( - `[TemplateInit] Cleaned up ${toDelete.length} old public templates from DB: [${toDelete.map((t) => t.name).join(", ")}]`, + `[TemplateInit] Cleaned up ${dbPublic.length} old public templates from DB: [${dbPublic.map((t) => t.name).join(", ")}]`, ); } } catch (error) { diff --git a/package.json b/package.json index 2f1a809c..8e06148f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wavespeed-desktop", - "version": "2.0.22", + "version": "2.0.23", "description": "WaveSpeedAI Desktop Application - A playground for AI models", "main": "./out/main/index.js", "author": { diff --git a/src/components/playground/FeaturedModelsPanel.tsx b/src/components/playground/FeaturedModelsPanel.tsx index e35044c6..ec5d7605 100644 --- a/src/components/playground/FeaturedModelsPanel.tsx +++ b/src/components/playground/FeaturedModelsPanel.tsx @@ -4,6 +4,17 @@ import { Badge } from "@/components/ui/badge"; import type { Model } from "@/types/model"; const FEATURED_MODEL_FAMILIES = [ + // ── Top row: poster cards (3:4) ── + { + name: "Nano Banana 2", + provider: "google", + description: "Next-gen text-to-image with improved quality and speed", + poster: + "https://static.wavespeed.ai/models/google/nano-banana-2/text-to-image/1772126604028089269_JhJR3cly.png", + primaryVariant: "google/nano-banana-2/text-to-image", + tags: ["Text-to-Image"], + ratio: "poster" as const, + }, { name: "Nano Banana Pro", provider: "google", @@ -15,15 +26,17 @@ const FEATURED_MODEL_FAMILIES = [ ratio: "poster" as const, }, { - name: "InfiniteTalk", - provider: "wavespeed-ai", - description: "Natural talking-head video from a single portrait photo", + name: "Seedream 4.5", + provider: "bytedance", + description: + "Ultra-realistic image generation with stunning detail and accuracy", poster: - "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766575571686877852_Sckigeck.png", - primaryVariant: "wavespeed-ai/infinitetalk", - tags: ["Talking Head"], + "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1764761216479761378_Yy864da9.png", + primaryVariant: "bytedance/seedream-v4.5", + tags: ["Photorealistic", "High Detail"], ratio: "poster" as const, }, + // ── Bottom row: square cards (1:1) ── { name: "Wan Spicy", provider: "wavespeed-ai", @@ -32,28 +45,26 @@ const FEATURED_MODEL_FAMILIES = [ "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766298334453523753_f975da96.png", primaryVariant: "wavespeed-ai/wan-2.2-spicy/image-to-video", tags: ["Artistic", "Soft", "Paint"], - ratio: "poster" as const, + ratio: "square" as const, }, { - name: "Seedream 4.5", - provider: "bytedance", - description: - "Ultra-realistic image generation with stunning detail and accuracy", + name: "InfiniteTalk", + provider: "wavespeed-ai", + description: "Natural talking-head video from a single portrait photo", poster: - "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1764761216479761378_Yy864da9.png", - primaryVariant: "bytedance/seedream-v4.5", - tags: ["Photorealistic", "High Detail"], - isNew: true, + "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766575571686877852_Sckigeck.png", + primaryVariant: "wavespeed-ai/infinitetalk", + tags: ["Talking Head"], ratio: "square" as const, }, { - name: "Seedance 1.5 Pro", - provider: "bytedance", - description: "Cinematic video creation with breathtaking sci-fi aesthetics", + name: "Wan Animate", + provider: "wavespeed-ai", + description: "Bring characters to life with smooth animation", poster: - "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766494048998434655_qEMLsAI0.png", - primaryVariant: "bytedance/seedance-v1.5-pro/image-to-video", - tags: ["Sci-Fi", "Neon", "Future"], + "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1758433474532574441_SkTQLIEA.jpeg", + primaryVariant: "wavespeed-ai/wan-2.2/animate", + tags: ["Animation"], ratio: "square" as const, }, { @@ -66,16 +77,6 @@ const FEATURED_MODEL_FAMILIES = [ tags: ["Motion", "Control"], ratio: "square" as const, }, - { - name: "Wan Animate", - provider: "wavespeed-ai", - description: "Bring characters to life with smooth animation", - poster: - "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1758433474532574441_SkTQLIEA.jpeg", - primaryVariant: "wavespeed-ai/wan-2.2/animate", - tags: ["Animation"], - ratio: "square" as const, - }, ]; const TAG_COLORS = [ diff --git a/src/components/playground/TemplatesPanel.tsx b/src/components/playground/TemplatesPanel.tsx index 7ad83258..d94bef1b 100644 --- a/src/components/playground/TemplatesPanel.tsx +++ b/src/components/playground/TemplatesPanel.tsx @@ -1,12 +1,20 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { TemplateBrowser } from "@/components/templates/TemplateBrowser"; import { TemplateDialog, type TemplateFormData, } from "@/components/templates/TemplateDialog"; import { useTemplateStore } from "@/stores/templateStore"; import { toast } from "@/hooks/useToast"; +import { + Search, + Pencil, + Trash2, + Download, + FolderOpen, + ChevronRight, + ChevronDown, +} from "lucide-react"; import type { Template } from "@/types/template"; interface TemplatesPanelProps { @@ -15,28 +23,140 @@ interface TemplatesPanelProps { export function TemplatesPanel({ onUseTemplate }: TemplatesPanelProps) { const { t } = useTranslation(); - const { updateTemplate, deleteTemplate, exportTemplates } = + const { loadTemplates, updateTemplate, deleteTemplate, exportTemplates } = useTemplateStore(); const [editingTemplate, setEditingTemplate] = useState