Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
## Image generation

- **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend.
## Game

- **[issue-3177] Added a Game studio for assembling managed-app asset bundles.** Create a Game workspace, bind reusable Sprite records and Music tracks, compile an immutable versioned manifest with SHA-256 asset references, and request persisted AI feedback with any configured provider, model, and supported effort. The new `/game/:id` workspace is reachable from Create, Cmd+K, and voice navigation.

## Quota burn

Expand Down
3 changes: 3 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const CreativeDirector = lazyWithReload(() => import('./pages/CreativeDirector')
const CreativeDirectorDetail = lazyWithReload(() => import('./pages/CreativeDirectorDetail'));
const CreativeCommissions = lazyWithReload(() => import('./pages/CreativeCommissions'));
const CreativeCommissionDetail = lazyWithReload(() => import('./pages/CreativeCommissionDetail'));
const Game = lazyWithReload(() => import('./pages/Game'));
const MusicVideo = lazyWithReload(() => import('./pages/MusicVideo'));
const Sprites = lazyWithReload(() => import('./pages/Sprites'));
const MoodBoards = lazyWithReload(() => import('./pages/MoodBoards'));
Expand Down Expand Up @@ -399,6 +400,8 @@ export default function App() {
<Route path="creative-commission" element={<CreativeCommissions />} />
<Route path="creative-commission/new" element={<CreativeCommissions />} />
<Route path="creative-commission/:id" element={<CreativeCommissionDetail />} />
<Route path="game" element={<Game />} />
<Route path="game/:id" element={<Game />} />
<Route path="image-gen" element={<RedirectWithSearch to="/media/image" />} />
<Route path="video-gen" element={<RedirectWithSearch to="/media/video" />} />
<Route path="media-history" element={<RedirectWithSearch to="/media/history" />} />
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ import {
Clapperboard,
PersonStanding,
Box,
Boxes
Boxes,
Gamepad2
} from 'lucide-react';
/* global __APP_VERSION__ */
import { safeReadStorage, safeWriteStorage } from '../lib/safeStorage';
Expand Down Expand Up @@ -226,6 +227,7 @@ const navItems = [
{ to: '/creative-commission', label: 'Creative Commissions', icon: CalendarClock },
{ to: '/creative-director', label: 'Creative Director', icon: Clapperboard },
{ to: '/pipeline/editorial-checks', label: 'Editorial Checks', icon: ListChecks },
{ to: '/game', label: 'Game', icon: Gamepad2 },
{ to: '/importer', label: 'Importer', icon: FileInput },
{ to: '/media', label: 'Media Gen', icon: Layers },
{ to: '/mood-boards', label: 'Mood Boards', icon: Palette },
Expand Down
170 changes: 170 additions & 0 deletions client/src/components/games/GameBindings.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useMemo, useState } from 'react';
import { Music2, PersonStanding, Plus, Unlink } from 'lucide-react';

const selectClass = 'w-full min-h-[44px] rounded-lg border border-port-border bg-port-bg px-3 py-2 text-sm text-white';
const actionClass = 'inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg bg-port-accent px-3 py-2 text-sm font-medium text-white disabled:opacity-50';

function BindingSection({
title,
icon: Icon,
emptyText,
available,
value,
onValueChange,
onAdd,
addLabel,
disabled,
children,
}) {
const selectId = `game-${title.toLowerCase().replace(/\s+/g, '-')}-picker`;
return (
<section className="rounded-xl border border-port-border bg-port-card p-4">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-5 w-5 text-port-accent" aria-hidden="true" />
<h2 className="font-semibold text-white">{title}</h2>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
<div className="min-w-0 flex-1">
<label htmlFor={selectId} className="mb-1 block text-xs text-gray-400">
{addLabel}
</label>
<select
id={selectId}
value={value}
onChange={(event) => onValueChange(event.target.value)}
className={selectClass}
disabled={disabled || available.length === 0}
>
<option value="">{available.length ? `Select ${addLabel.toLowerCase()}…` : 'Nothing else available'}</option>
{available.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</div>
<button
type="button"
onClick={onAdd}
disabled={disabled || !value}
className={`${actionClass} sm:self-end`}
>
<Plus className="h-4 w-4" aria-hidden="true" />
Bind
</button>
</div>
{children || <p className="text-sm text-gray-500">{emptyText}</p>}
</section>
);
}

export default function GameBindings({
game,
sprites,
tracks,
busy,
onBindSprite,
onUnbindSprite,
onBindMusic,
onUnbindMusic,
}) {
const [spriteId, setSpriteId] = useState('');
const [trackId, setTrackId] = useState('');
const spriteMap = useMemo(() => new Map(sprites.map((sprite) => [sprite.id, sprite])), [sprites]);
const trackMap = useMemo(() => new Map(tracks.map((track) => [track.id, track])), [tracks]);
const boundSpriteIds = new Set(game.spriteBindings.map((binding) => binding.spriteId));
const boundTrackIds = new Set(game.musicBindings.map((binding) => binding.trackId));
const availableSprites = sprites
.filter((sprite) => !boundSpriteIds.has(sprite.id))
.map((sprite) => ({ id: sprite.id, label: `${sprite.name} · ${sprite.kind}` }));
const availableTracks = tracks
.filter((track) => !boundTrackIds.has(track.id))
.map((track) => ({ id: track.id, label: `${track.title}${track.audioFilename ? '' : ' · no audio yet'}` }));

const addSprite = async () => {
if (!spriteId) return;
if (await onBindSprite(spriteId)) setSpriteId('');
};
const addMusic = async () => {
if (!trackId) return;
if (await onBindMusic(trackId)) setTrackId('');
};

return (
<div className="grid gap-4 lg:grid-cols-2">
<BindingSection
title="Sprite assets"
icon={PersonStanding}
emptyText="No sprites are bound yet."
available={availableSprites}
value={spriteId}
onValueChange={setSpriteId}
onAdd={addSprite}
addLabel="Sprite record"
disabled={busy}
>
{game.spriteBindings.length > 0 ? (
<ul className="space-y-2">
{game.spriteBindings.map((binding) => {
const sprite = spriteMap.get(binding.spriteId);
return (
<li key={binding.spriteId} className="flex items-center justify-between gap-3 rounded-lg border border-port-border bg-port-bg/50 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white">{sprite?.name || binding.spriteId}</div>
<div className="text-xs text-gray-500">{sprite ? `${sprite.kind} · ${sprite.status}` : 'Record unavailable'}</div>
</div>
<button
type="button"
onClick={() => onUnbindSprite(binding.spriteId)}
disabled={busy}
className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-gray-400 hover:text-port-error disabled:opacity-50"
aria-label={`Unbind ${sprite?.name || binding.spriteId}`}
title="Unbind sprite"
>
<Unlink className="h-4 w-4" aria-hidden="true" />
</button>
</li>
);
})}
</ul>
) : null}
</BindingSection>

<BindingSection
title="Music assets"
icon={Music2}
emptyText="No music tracks are bound yet."
available={availableTracks}
value={trackId}
onValueChange={setTrackId}
onAdd={addMusic}
addLabel="Music track"
disabled={busy}
>
{game.musicBindings.length > 0 ? (
<ul className="space-y-2">
{game.musicBindings.map((binding) => {
const track = trackMap.get(binding.trackId);
return (
<li key={binding.id} className="flex items-center justify-between gap-3 rounded-lg border border-port-border bg-port-bg/50 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white">{track?.title || binding.trackId}</div>
<div className="text-xs text-gray-500">{track?.audioFilename ? 'Audio ready' : 'Audio render required'}</div>
</div>
<button
type="button"
onClick={() => onUnbindMusic(binding.id)}
disabled={busy}
className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-gray-400 hover:text-port-error disabled:opacity-50"
aria-label={`Unbind ${track?.title || binding.trackId}`}
title="Unbind music track"
>
<Unlink className="h-4 w-4" aria-hidden="true" />
</button>
</li>
);
})}
</ul>
) : null}
</BindingSection>
</div>
);
}
47 changes: 47 additions & 0 deletions client/src/components/games/GameCompilePanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Boxes, RefreshCw } from 'lucide-react';
import { formatDateShort } from '../../utils/formatters.js';

export default function GameCompilePanel({ game, compiling, onCompile }) {
const current = game.compiledManifest;
return (
<section className="rounded-xl border border-port-border bg-port-card p-4">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-start">
<div>
<div className="flex items-center gap-2">
<Boxes className="h-5 w-5 text-port-accent" aria-hidden="true" />
<h2 className="font-semibold text-white">Game asset bundle</h2>
</div>
<p className="mt-1 text-sm text-gray-400">
Compile immutable sprite-atlas and music references into a deterministic manifest.
</p>
</div>
<button
type="button"
onClick={onCompile}
disabled={compiling}
className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg bg-port-accent px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${compiling ? 'animate-spin' : ''}`} aria-hidden="true" />
{current ? 'Recompile' : 'Compile bundle'}
</button>
</div>

{current ? (
<dl className="mt-4 grid gap-3 rounded-lg border border-port-border bg-port-bg/50 p-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><dt className="text-xs text-gray-500">Version</dt><dd className="text-white">v{current.version}</dd></div>
<div><dt className="text-xs text-gray-500">Sprites</dt><dd className="text-white">{current.spriteCount}</dd></div>
<div><dt className="text-xs text-gray-500">Music</dt><dd className="text-white">{current.musicCount}</dd></div>
<div><dt className="text-xs text-gray-500">Built</dt><dd className="text-white">{formatDateShort(current.builtAt)}</dd></div>
<div className="min-w-0 sm:col-span-2 lg:col-span-4">
<dt className="text-xs text-gray-500">Manifest</dt>
<dd className="truncate font-mono text-xs text-gray-300" title={current.manifestPath}>{current.manifestPath}</dd>
</div>
</dl>
) : (
<p className="mt-4 rounded-lg border border-dashed border-port-border p-4 text-sm text-gray-500">
No bundle compiled yet. Bound sprites need a current runtime atlas and bound tracks need audio.
</p>
)}
</section>
);
}
106 changes: 106 additions & 0 deletions client/src/components/games/GameFeedback.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { MessageSquareText, Send } from 'lucide-react';
import ProviderModelSelector from '../ProviderModelSelector.jsx';
import EffortSelect from '../cos/EffortSelect.jsx';
import useProviderModels from '../../hooks/useProviderModels.js';
import { formatDateShort } from '../../utils/formatters.js';

export default function GameFeedback({ history, submitting, onSubmit }) {
const [prompt, setPrompt] = useState('');
const [effort, setEffort] = useState('');
const {
providers,
selectedProviderId,
selectedModel,
availableModels,
setSelectedProviderId,
setSelectedModel,
loading,
} = useProviderModels({ silent: true });
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
const promptId = 'game-feedback-prompt';

const submit = async (event) => {
event.preventDefault();
if (!prompt.trim() || !selectedProviderId) return;
const ok = await onSubmit({
providerId: selectedProviderId,
...(selectedModel ? { model: selectedModel } : {}),
...(effort ? { effort } : {}),
prompt: prompt.trim(),
});
if (ok) setPrompt('');
};

return (
<section className="rounded-xl border border-port-border bg-port-card p-4">
<div className="mb-3 flex items-center gap-2">
<MessageSquareText className="h-5 w-5 text-port-accent" aria-hidden="true" />
<h2 className="font-semibold text-white">AI feedback</h2>
</div>
<p className="mb-4 text-sm text-gray-400">
Ask any configured provider to critique asset coverage and recommend the next improvements.
</p>

<form onSubmit={submit} className="space-y-3">
<ProviderModelSelector
providers={providers}
selectedProviderId={selectedProviderId}
selectedModel={selectedModel}
availableModels={availableModels}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
disabled={loading || submitting}
layout="stacked"
/>
<EffortSelect
provider={selectedProvider}
value={effort}
onChange={setEffort}
label="Thinking effort"
disabled={submitting}
className="w-full min-h-[44px] rounded-lg border border-port-border bg-port-bg px-3 py-2 text-sm text-white"
/>
<div>
<label htmlFor={promptId} className="mb-1 block text-xs text-gray-400">Review request</label>
<textarea
id={promptId}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
maxLength={4000}
rows={3}
placeholder="What should the reviewer focus on?"
className="w-full rounded-lg border border-port-border bg-port-bg px-3 py-2 text-sm text-white placeholder:text-gray-600"
disabled={submitting}
/>
</div>
<button
type="submit"
disabled={submitting || loading || !selectedProviderId || !prompt.trim()}
className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg bg-port-accent px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
<Send className="h-4 w-4" aria-hidden="true" />
{submitting ? 'Reviewing…' : 'Request feedback'}
</button>
</form>

{history.length > 0 ? (
<div className="mt-6 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Feedback history</h3>
{[...history].reverse().map((entry) => (
<article key={entry.id} className="rounded-lg border border-port-border bg-port-bg/50 p-3">
<div className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500">
<span>{entry.providerId}</span>
{entry.model ? <span>· {entry.model}</span> : null}
{entry.effort ? <span>· {entry.effort}</span> : null}
<span>· {formatDateShort(entry.createdAt)}</span>
</div>
<p className="mb-2 text-xs italic text-gray-400">{entry.prompt}</p>
<p className="whitespace-pre-wrap text-sm leading-6 text-gray-200">{entry.text}</p>
</article>
))}
</div>
) : null}
</section>
);
}
Loading