Merge pull request #16 from Yiling-J/feat-sticky-headers-and-settings-redesign-8421614059187835189
Make sticky headers transparent with separator
This commit is contained in:
6 files changed
+407
-270
No files matched your search
@@ -99,6 +99,8 @@ We use `bun:test` for testing.
|
||||
- Fixes must be iterated until **no check causes any other check to fail**.
|
||||
- Do **not** submit intermediate states where some checks pass and others fail, even temporarily.
|
||||
|
||||
- **Cleanup Requirement**: Remove all verification related files (scripts, screenshots, `verification/` folder) before submit.
|
||||
|
||||
## Prisma Configuration & Migrations
|
||||
|
||||
- Schema: `prisma/schema.prisma`
|
||||
@@ -113,6 +115,7 @@ Whenever you modify the `prisma/schema.prisma` file, you **must** generate a cor
|
||||
**Do not create migration SQL files manually.**
|
||||
|
||||
Instead, follow this workflow:
|
||||
|
||||
1. Ensure you have a local SQLite database configured (e.g., via `.env` setting `DATABASE_URL="file:./dev.db"`).
|
||||
2. Run the migration command:
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ArrowDown, BoxSelect, FileSearch, Image as ImageIcon, User, Wand2 } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
|
||||
interface ExtractionFlowDesignerProps {
|
||||
settings: Record<string, string>
|
||||
openaiTextModels: string[]
|
||||
openaiImageModels: string[]
|
||||
googleTextModels: string[]
|
||||
googleImageModels: string[]
|
||||
onSelect: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
export function ExtractionFlowDesigner({
|
||||
settings,
|
||||
openaiTextModels,
|
||||
openaiImageModels,
|
||||
googleTextModels,
|
||||
googleImageModels,
|
||||
onSelect,
|
||||
}: ExtractionFlowDesignerProps) {
|
||||
const steps = [
|
||||
{
|
||||
id: 'analyze',
|
||||
key: 'step_analyze_model',
|
||||
title: 'Analyze Image',
|
||||
description: 'Extracts visual features and descriptions from the input image.',
|
||||
icon: <FileSearch className="w-5 h-5 text-cyan-600" />,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: 'texture',
|
||||
key: 'step_texture_model',
|
||||
title: 'Generate Texture',
|
||||
description: 'Creates a texture sheet based on the analysis.',
|
||||
icon: <ImageIcon className="w-5 h-5 text-purple-600" />,
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
id: 'bbox',
|
||||
key: 'step_bounding_box_model',
|
||||
title: 'Detect Bounding Boxes',
|
||||
description: 'Identifies regions for individual equipment pieces.',
|
||||
icon: <BoxSelect className="w-5 h-5 text-amber-600" />,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: 'refine',
|
||||
key: 'step_refine_model',
|
||||
title: 'Refine Asset',
|
||||
description: 'Polishes and isolates each equipment piece.',
|
||||
icon: <Wand2 className="w-5 h-5 text-emerald-600" />,
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
id: 'generation',
|
||||
key: 'step_generation_model',
|
||||
title: 'Character Generation',
|
||||
description: 'Generates the final character using the equipment.',
|
||||
icon: <User className="w-5 h-5 text-blue-600" />,
|
||||
type: 'image',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative py-8">
|
||||
{/* Vertical Line */}
|
||||
<div className="absolute left-6 top-8 bottom-8 w-0.5 bg-gradient-to-b from-cyan-500/20 via-blue-500/20 to-purple-500/20 z-0" />
|
||||
|
||||
<div className="flex flex-col gap-6 relative z-10">
|
||||
{steps.map((step, index) => {
|
||||
const textModels =
|
||||
step.type === 'text'
|
||||
? { openai: openaiTextModels, google: googleTextModels }
|
||||
: { openai: openaiImageModels, google: googleImageModels }
|
||||
|
||||
return (
|
||||
<div key={step.id} className="group relative flex gap-6 items-start">
|
||||
{/* Icon Bubble */}
|
||||
<div className="w-12 h-12 rounded-xl bg-white border border-slate-200 shadow-sm flex items-center justify-center shrink-0 z-10 transition-transform group-hover:scale-110 group-hover:border-cyan-400">
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="flex-1 bg-white rounded-xl border border-slate-200 p-4 shadow-sm transition-all hover:shadow-md hover:border-cyan-200">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="text-slate-400 font-mono text-xs">0{index + 1} //</span>
|
||||
{step.title}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500 mt-1">{step.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64">
|
||||
<ModelSelect
|
||||
value={settings[step.key]}
|
||||
openaiModels={textModels.openai}
|
||||
googleModels={textModels.google}
|
||||
onSelect={(val) => onSelect(step.key, val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Connection (except last) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="absolute left-6 -bottom-6 transform -translate-x-1/2 text-slate-300">
|
||||
<ArrowDown size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSelect({
|
||||
value,
|
||||
openaiModels,
|
||||
googleModels,
|
||||
onSelect,
|
||||
}: {
|
||||
value?: string
|
||||
openaiModels: string[]
|
||||
googleModels: string[]
|
||||
onSelect: (val: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onSelect}>
|
||||
<SelectTrigger className="w-full bg-slate-50 border-slate-200">
|
||||
<SelectValue placeholder="Select Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openaiModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs font-mono uppercase tracking-wider text-slate-400">
|
||||
OpenAI
|
||||
</SelectLabel>
|
||||
{openaiModels.map((m) => (
|
||||
<SelectItem key={`openai:${m}`} value={`openai:${m}`}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{googleModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs font-mono uppercase tracking-wider text-slate-400">
|
||||
Google
|
||||
</SelectLabel>
|
||||
{googleModels.map((m) => (
|
||||
<SelectItem key={`google:${m}`} value={`google:${m}`}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Hexagon } from 'lucide-react'
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
sticky?: boolean
|
||||
topOffset?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
sticky = true,
|
||||
topOffset = '4rem', // Default to 16 (4rem) below global header
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex flex-col gap-4 pb-4 pt-2 transition-all duration-300',
|
||||
sticky &&
|
||||
'sticky z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60',
|
||||
className,
|
||||
)}
|
||||
style={sticky ? { top: topOffset } : undefined}
|
||||
>
|
||||
<div className="flex justify-between items-end animate-fade-in-down">
|
||||
<div>
|
||||
{subtitle && (
|
||||
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
|
||||
<Hexagon size={12} className="animate-spin-slow" />
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
|
||||
{title}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-4">{actions}</div>}
|
||||
</div>
|
||||
{children && <div className="animate-fade-in-down delay-100">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-17
@@ -1,9 +1,10 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Hexagon, Plus } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { client } from '../client'
|
||||
import { CreateCharacterDialog } from '../components/CreateCharacterDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { VibeCard } from '../components/VibeCard'
|
||||
import { Button } from '../components/ui/button'
|
||||
|
||||
@@ -60,25 +61,17 @@ export default function Characters() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
|
||||
<div className="mb-8 animate-fade-in-down flex justify-between items-end">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
|
||||
<Hexagon size={12} className="animate-spin-slow" />
|
||||
System // Character_List
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
|
||||
Characters
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
|
||||
<PageHeader
|
||||
title="Characters"
|
||||
subtitle="System // Character_List"
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Character
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
|
||||
+22
-27
@@ -1,11 +1,12 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Hexagon, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
|
||||
import { client } from '../client'
|
||||
import { CreateOutfitDialog } from '../components/CreateOutfitDialog'
|
||||
import { ExtractorDialog } from '../components/ExtractorDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { VibeCard } from '../components/VibeCard'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Button } from '../components/ui/button'
|
||||
@@ -108,40 +109,35 @@ export default function Equipments() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
|
||||
<div className="mb-8 animate-fade-in-down flex justify-between items-end">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
|
||||
<Hexagon size={12} className="animate-spin-slow" />
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
|
||||
<PageHeader
|
||||
title={viewMode === 'equipments' ? 'Equipments' : 'Outfits'}
|
||||
subtitle={
|
||||
<>
|
||||
System //{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
'cursor-pointer hover:text-cyan-500 transition-colors',
|
||||
viewMode === 'equipments' ? '' : 'text-muted-foreground/80',
|
||||
)}
|
||||
onClick={() => setViewMode('equipments')}
|
||||
>
|
||||
Equipment_List
|
||||
</span>{' '}
|
||||
<span>|</span>
|
||||
<span>|</span>{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
'cursor-pointer hover:text-cyan-500 transition-colors',
|
||||
viewMode === 'outfits' ? '' : 'text-muted-foreground/80',
|
||||
)}
|
||||
onClick={() => setViewMode('outfits')}
|
||||
>
|
||||
Outfit_List
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
|
||||
{viewMode === 'equipments' ? 'Equipments' : 'Outfits'}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{viewMode === 'equipments' ? (
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
viewMode === 'equipments' ? (
|
||||
<Button onClick={() => setExtractorOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Extract Equipment
|
||||
</Button>
|
||||
@@ -149,13 +145,12 @@ export default function Equipments() {
|
||||
<Button onClick={() => setCreateOutfitOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Outfit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{viewMode === 'equipments' && (
|
||||
<div className="mb-8 animate-fade-in-down delay-100">
|
||||
)
|
||||
}
|
||||
className="mb-8"
|
||||
>
|
||||
{/* Category Filter - Sticky inside PageHeader */}
|
||||
{viewMode === 'equipments' && (
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<div className="flex w-max space-x-2 p-1">
|
||||
{EQUIPMENT_CATEGORIES.map((cat) => {
|
||||
@@ -179,8 +174,8 @@ export default function Equipments() {
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
|
||||
+147
-226
@@ -1,20 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { client } from '@/ui/client'
|
||||
import { ExtractionFlowDesigner } from '@/ui/components/ExtractionFlowDesigner'
|
||||
import { PageHeader } from '@/ui/components/PageHeader'
|
||||
import { Button } from '@/ui/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/components/ui/card'
|
||||
import { Input } from '@/ui/components/ui/input'
|
||||
import { Label } from '@/ui/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/components/ui/card'
|
||||
import { client } from '@/ui/client'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
} from '@/ui/components/ui/select'
|
||||
import { useToast } from '@/ui/hooks/use-toast'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function Settings() {
|
||||
const { toast } = useToast()
|
||||
@@ -121,178 +114,154 @@ export default function Settings() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<div className="flex h-full items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative space-y-8">
|
||||
<PageHeader title="Settings" subtitle="System // Configuration" />
|
||||
|
||||
{/* OpenAI Provider */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>OpenAI Provider</CardTitle>
|
||||
<CardDescription>Configure OpenAI API settings and models.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="openai_api_key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="openai_api_key"
|
||||
type="password"
|
||||
value={settings['openai_api_key'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, openai_api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('openai_api_key', settings['openai_api_key'] || '')}
|
||||
disabled={saving['openai_api_key']}
|
||||
>
|
||||
{saving['openai_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<div className="max-w-4xl mx-auto w-full grid gap-8">
|
||||
{/* OpenAI Provider */}
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>OpenAI Provider</CardTitle>
|
||||
<CardDescription>Configure OpenAI API settings and models.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="openai_api_key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="openai_api_key"
|
||||
type="password"
|
||||
value={settings['openai_api_key'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, openai_api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('openai_api_key', settings['openai_api_key'] || '')}
|
||||
disabled={saving['openai_api_key']}
|
||||
>
|
||||
{saving['openai_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="openai_base_url">Base URL (Optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="openai_base_url"
|
||||
value={settings['openai_base_url'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, openai_base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('openai_base_url', settings['openai_base_url'] || '')}
|
||||
disabled={saving['openai_base_url']}
|
||||
>
|
||||
{saving['openai_base_url'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="openai_base_url">Base URL (Optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="openai_base_url"
|
||||
value={settings['openai_base_url'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, openai_base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('openai_base_url', settings['openai_base_url'] || '')}
|
||||
disabled={saving['openai_base_url']}
|
||||
>
|
||||
{saving['openai_base_url'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelList
|
||||
title="Text Models"
|
||||
models={openaiTextModels}
|
||||
onAdd={(m) =>
|
||||
addModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
|
||||
}
|
||||
/>
|
||||
<ModelList
|
||||
title="Image Models"
|
||||
models={openaiImageModels}
|
||||
onAdd={(m) =>
|
||||
addModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Provider */}
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Google Provider</CardTitle>
|
||||
<CardDescription>Configure Google Gemini API settings and models.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="google_api_key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="google_api_key"
|
||||
type="password"
|
||||
value={settings['google_api_key'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, google_api_key: e.target.value })}
|
||||
placeholder="AIza..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('google_api_key', settings['google_api_key'] || '')}
|
||||
disabled={saving['google_api_key']}
|
||||
>
|
||||
{saving['google_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelList
|
||||
title="Text Models"
|
||||
models={googleTextModels}
|
||||
onAdd={(m) =>
|
||||
addModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
|
||||
}
|
||||
/>
|
||||
<ModelList
|
||||
title="Image Models"
|
||||
models={googleImageModels}
|
||||
onAdd={(m) =>
|
||||
addModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extraction Flow Defaults */}
|
||||
<div>
|
||||
<div className="mb-4 ml-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Extraction Flow Defaults</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Configure the AI models used for each step of the asset generation pipeline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelList
|
||||
title="Text Models"
|
||||
models={openaiTextModels}
|
||||
onAdd={(m) => addModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)}
|
||||
onRemove={(m) =>
|
||||
removeModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
|
||||
}
|
||||
<ExtractionFlowDesigner
|
||||
settings={settings}
|
||||
openaiTextModels={openaiTextModels}
|
||||
openaiImageModels={openaiImageModels}
|
||||
googleTextModels={googleTextModels}
|
||||
googleImageModels={googleImageModels}
|
||||
onSelect={(key, val) => saveSetting(key, val)}
|
||||
/>
|
||||
<ModelList
|
||||
title="Image Models"
|
||||
models={openaiImageModels}
|
||||
onAdd={(m) =>
|
||||
addModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Provider */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Google Provider</CardTitle>
|
||||
<CardDescription>Configure Google Gemini API settings and models.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="google_api_key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="google_api_key"
|
||||
type="password"
|
||||
value={settings['google_api_key'] || ''}
|
||||
onChange={(e) => setSettings({ ...settings, google_api_key: e.target.value })}
|
||||
placeholder="AIza..."
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSetting('google_api_key', settings['google_api_key'] || '')}
|
||||
disabled={saving['google_api_key']}
|
||||
>
|
||||
{saving['google_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelList
|
||||
title="Text Models"
|
||||
models={googleTextModels}
|
||||
onAdd={(m) => addModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)}
|
||||
onRemove={(m) =>
|
||||
removeModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
|
||||
}
|
||||
/>
|
||||
<ModelList
|
||||
title="Image Models"
|
||||
models={googleImageModels}
|
||||
onAdd={(m) =>
|
||||
addModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
|
||||
}
|
||||
onRemove={(m) =>
|
||||
removeModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extraction Flow Defaults */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Extraction Flow Defaults</CardTitle>
|
||||
<CardDescription>
|
||||
Select default models for each step of the extraction process.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Step 1: Analyze Image (Text) */}
|
||||
<StepSelector
|
||||
label="Step 1: Analyze Image (Text)"
|
||||
value={settings['step_analyze_model']}
|
||||
openaiModels={openaiTextModels}
|
||||
googleModels={googleTextModels}
|
||||
onSelect={(val) => saveSetting('step_analyze_model', val)}
|
||||
/>
|
||||
{/* Step 2: Texture Sheet (Image) */}
|
||||
<StepSelector
|
||||
label="Step 2: Generate Texture Sheet (Image)"
|
||||
value={settings['step_texture_model']}
|
||||
openaiModels={openaiImageModels}
|
||||
googleModels={googleImageModels}
|
||||
onSelect={(val) => saveSetting('step_texture_model', val)}
|
||||
/>
|
||||
{/* Step 3: Bounding Box (Text) */}
|
||||
<StepSelector
|
||||
label="Step 3: Detect Bounding Boxes (Text)"
|
||||
value={settings['step_bounding_box_model']}
|
||||
openaiModels={openaiTextModels}
|
||||
googleModels={googleTextModels}
|
||||
onSelect={(val) => saveSetting('step_bounding_box_model', val)}
|
||||
/>
|
||||
{/* Step 4: Refine Asset (Image) */}
|
||||
<StepSelector
|
||||
label="Step 4: Refine Asset (Image)"
|
||||
value={settings['step_refine_model']}
|
||||
openaiModels={openaiImageModels}
|
||||
googleModels={googleImageModels}
|
||||
onSelect={(val) => saveSetting('step_refine_model', val)}
|
||||
/>
|
||||
{/* Step: Generation (Image) */}
|
||||
<StepSelector
|
||||
label="Step: Character Generation (Image)"
|
||||
value={settings['step_generation_model']}
|
||||
openaiModels={openaiImageModels}
|
||||
googleModels={googleImageModels}
|
||||
onSelect={(val) => saveSetting('step_generation_model', val)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -318,7 +287,7 @@ function ModelList({
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder="Add model (e.g., gpt-4o)"
|
||||
className="max-w-xs"
|
||||
className="max-w-xs font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -336,69 +305,21 @@ function ModelList({
|
||||
{models.map((model) => (
|
||||
<div
|
||||
key={model}
|
||||
className="flex items-center gap-1 rounded-md border bg-secondary px-2 py-1 text-sm"
|
||||
className="flex items-center gap-1 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-sm font-mono text-slate-700"
|
||||
>
|
||||
<span>{model}</span>
|
||||
<button
|
||||
onClick={() => onRemove(model)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{models.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">No models configured.</span>
|
||||
<span className="text-sm text-slate-400 italic">No models configured.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepSelector({
|
||||
label,
|
||||
value,
|
||||
openaiModels,
|
||||
googleModels,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
openaiModels: string[]
|
||||
googleModels: string[]
|
||||
onSelect: (val: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Select value={value} onValueChange={onSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openaiModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>OpenAI</SelectLabel>
|
||||
{openaiModels.map((m) => (
|
||||
<SelectItem key={`openai:${m}`} value={`openai:${m}`}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{googleModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Google</SelectLabel>
|
||||
{googleModels.map((m) => (
|
||||
<SelectItem key={`google:${m}`} value={`google:${m}`}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user