From 394842ec6fa8e5dd4a9e1d47593b9c42a612aa58 Mon Sep 17 00:00:00 2001 From: Haorui Jiang <143785706+HiramJiang@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:56:57 -0400 Subject: [PATCH 01/10] feat: update featured models - add Nano Banana 2, reorder layout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playground/FeaturedModelsPanel.tsx | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) 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 = [ From 3f1edbf3e38d1b26943cafeace26b81b4a99ef20 Mon Sep 17 00:00:00 2001 From: linqiquan <735525520@qq.com> Date: Mon, 16 Mar 2026 19:10:06 +0800 Subject: [PATCH 02/10] refactor: consolidate template UI and enhance template management - Remove standalone template components (TemplateBrowser, TemplateCard, TemplateDrawer, TemplateFilters, TemplateGallery, TemplateSearch) - Rebuild TemplatesPage as unified template manager with inline editing, multi-select, grouped list view, and collapsible sections - Add search_text column to templates schema (migration 3) for i18n search - Expand search to cover playground_data, workflow_data, and search_text - Add template:queryNames IPC for duplicate name detection - Improve import with rename mode and per-name conflict resolution - Update export to support file-based template IDs - Sort query results: file templates first, then DB public, then custom - Update TemplatesPanel, TemplatePickerDialog, and workflow TemplatePanel to align with new store API --- electron/workflow/db/schema.ts | 14 +- electron/workflow/db/template.repo.ts | 40 +- electron/workflow/ipc/template.ipc.ts | 128 ++- electron/workflow/services/template-init.ts | 9 +- src/components/playground/TemplatesPanel.tsx | 208 +++- src/components/templates/TemplateBrowser.tsx | 169 ---- src/components/templates/TemplateCard.tsx | 253 ----- src/components/templates/TemplateDialog.tsx | 181 +--- src/components/templates/TemplateDrawer.tsx | 237 ----- src/components/templates/TemplateFilters.tsx | 111 -- src/components/templates/TemplateGallery.tsx | 195 ---- .../templates/TemplatePickerDialog.tsx | 178 +++- src/components/templates/TemplateSearch.tsx | 61 -- src/i18n/locales/en.json | 10 + src/i18n/locales/zh-CN.json | 10 + src/pages/PlaygroundPage.tsx | 53 +- src/pages/TemplatesPage.tsx | 955 +++++++++++++++--- src/stores/templateStore.ts | 173 +++- src/workflow/WorkflowPage.tsx | 72 +- .../components/panels/TemplatePanel.tsx | 189 +--- 20 files changed, 1592 insertions(+), 1654 deletions(-) delete mode 100644 src/components/templates/TemplateBrowser.tsx delete mode 100644 src/components/templates/TemplateCard.tsx delete mode 100644 src/components/templates/TemplateDrawer.tsx delete mode 100644 src/components/templates/TemplateFilters.tsx delete mode 100644 src/components/templates/TemplateGallery.tsx delete mode 100644 src/components/templates/TemplateSearch.tsx diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index 7e190fa2..dd8b4a91 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -91,7 +91,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 @@ -197,6 +198,17 @@ export function runMigrations(db: SqlJsDatabase): void { db.run("INSERT INTO schema_version (version) VALUES (2)"); }, }, + // Migration 3: Add search_text column to templates for i18n search + { + version: 3, + apply: (db: SqlJsDatabase) => { + console.log( + "[Schema] Applying migration 3: Add search_text to templates", + ); + db.run("ALTER TABLE templates ADD COLUMN search_text TEXT"); + db.run("INSERT INTO schema_version (version) VALUES (3)"); + }, + }, ]; for (const m of migrations) { 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/ipc/template.ipc.ts b/electron/workflow/ipc/template.ipc.ts index 48e08aec..794b1dd5 100644 --- a/electron/workflow/ipc/template.ipc.ts +++ b/electron/workflow/ipc/template.ipc.ts @@ -52,7 +52,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 +100,35 @@ 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) => + id.startsWith("file-") + ? getFileTemplateById(id) + : templateRepo.getTemplateById(id), + ) + .filter(Boolean) as Template[]; + } else { + templates = templateRepo.queryTemplates(); + } return { version: "1.0", @@ -118,43 +142,87 @@ export function registerTemplateIpc(): void { "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/src/components/playground/TemplatesPanel.tsx b/src/components/playground/TemplatesPanel.tsx index 7ad83258..9fa3e33b 100644 --- a/src/components/playground/TemplatesPanel.tsx +++ b/src/components/playground/TemplatesPanel.tsx @@ -1,12 +1,21 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState, useEffect, useMemo } 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, + Play, + Pencil, + Trash2, + Download, + FolderOpen, + ChevronRight, + ChevronDown, +} from "lucide-react"; import type { Template } from "@/types/template"; interface TemplatesPanelProps { @@ -15,28 +24,84 @@ interface TemplatesPanelProps { export function TemplatesPanel({ onUseTemplate }: TemplatesPanelProps) { const { t } = useTranslation(); - const { updateTemplate, deleteTemplate, exportTemplates } = + const { loadTemplates, updateTemplate, deleteTemplate, exportTemplates } = useTemplateStore(); const [editingTemplate, setEditingTemplate] = useState