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
1 change: 1 addition & 0 deletions .changelog/next/added-issue-4188.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Universes can now link a mood board directly on the record: pick or create a board from the Universe Bible tab and the link survives reload, stays per-universe, and syncs to your other machines (previously the reference strip only remembered one board per browser).
- Mood boards can now pull items straight from your galleries: pick images from the image gallery, pick or upload videos, and video items play right on the board with a poster thumbnail. Uploaded videos land in the shared video gallery, so board items sync to your other machines like any other media.
- Mood board items can now be analyzed with AI: an "Analyze with AI" action on gallery-backed items runs the same Prompt-from-media flow as the video page (your choice of vision provider) and saves the resulting prompt, negative prompt, and rationale onto the item — shown with a highlighted badge, viewable/copyable/removable from the item, and synced to your other machines with the board.
- A universe's linked mood board can now be distilled into its style guide: "Synthesize style" (next to the mood-board picker in the Universe Builder) runs the board's notes, captions, and item analyses through your chosen AI provider, previews the proposed style prompt / negative prompt / style notes as a before-and-after diff, and "Adopt" applies it to the universe — respecting any fields you've locked.
219 changes: 219 additions & 0 deletions client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Board → universe style synthesis (#4188 Phase 4).
*
* "Synthesize style" on the universe's mood-board tool: runs the linked
* board's collected content (notes, captions, per-item analyses) through a
* user-picked API LLM, previews the proposed style guide as the shared
* StyleDiffPreview against the CURRENT draft values, and "Adopt" hands the
* proposal to the caller (`onAdopt` — the draft hook's queued-write adopt,
* which also advances the saved-style bookkeeping). Never a client wholesale
* `influences` PATCH. Locks are honored at proposal time and re-checked
* server-side on adopt.
*/

import { useEffect, useState } from 'react';
import { Loader2, Sparkles, Wand2, X } from 'lucide-react';
import { synthesizeMoodBoardStyle } from '../../services/api';
import useMounted from '../../hooks/useMounted';
import useProviderModels from '../../hooks/useProviderModels';
import ProviderModelSelector from '../ProviderModelSelector';
import Modal from '../ui/Modal';
import toast from '../ui/Toast';
import StyleDiffPreview from './StyleDiffPreview';

const apiProviderFilter = (p) => p.enabled && p.type === 'api';

// Inner body so the provider fetch (useProviderModels mounts it) is deferred
// until the modal actually opens. The parent keys this by universe+board, so
// switching targets remounts it and a stale proposal can never be adopted
// into a different universe.
function SynthesisBody({ boardId, styleNotes, influences, locked, onAdopt, onBusyChange, onClose }) {
const [result, setResult] = useState(null);
const [running, setRunning] = useState(false);
const [adopting, setAdopting] = useState(false);
const mountedRef = useMounted();
const {
providers,
selectedProviderId,
selectedModel,
availableModels,
setSelectedProviderId,
setSelectedModel,
loading: providersLoading,
} = useProviderModels({ filter: apiProviderFilter, silent: true });

const busy = running || adopting;

// The parent gates modal dismissal (backdrop/Escape/close button) on this,
// so a run can't be silently orphaned mid-flight. Cleared on unmount.
useEffect(() => {
onBusyChange?.(busy);
return () => onBusyChange?.(false);
}, [busy, onBusyChange]);

const synthesize = async () => {
if (!selectedProviderId || busy) return;
setRunning(true);
const data = await synthesizeMoodBoardStyle(boardId, {
styleNotes: styleNotes || '',
influences: influences || {},
locked: locked || {},
providerId: selectedProviderId,
model: selectedModel || undefined,
}, { silent: true }).catch((error) => {
if (mountedRef.current) toast.error(`Style synthesis failed: ${error.message}`);
return null;
});
if (!mountedRef.current) return;
setRunning(false);
if (data) setResult(data);
};

const adopt = async () => {
if (!result?.proposed || busy) return;
setAdopting(true);
const ok = await onAdopt?.({
styleNotes: result.proposed.styleNotes || '',
influences: result.proposed.influences || {},
});
if (!mountedRef.current) return;
setAdopting(false);
// Force-close: the parent's busy gate reads its own state, which hasn't
// re-rendered from the setAdopting(false) above yet — a plain close()
// would still see busy and refuse.
if (ok) onClose(true);
};

return (
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-white">Synthesize style from mood board</h2>
<p className="text-xs text-gray-500">
Distills the board's notes, captions, and item analyses into a proposed style guide. Review the diff, then adopt.
</p>
</div>
<button type="button" onClick={onClose} disabled={busy} className="p-1 text-gray-400 hover:text-white min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label="Close">
<X size={18} />
</button>
</div>

<ProviderModelSelector
providers={providers}
selectedProviderId={selectedProviderId}
selectedModel={selectedModel}
availableModels={availableModels}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
disabled={busy || providersLoading}
label="LLM for synthesis"
layout="stacked"
/>
{providers.length === 0 && !providersLoading ? (
<p className="text-xs text-port-warning">No API providers are enabled. Add one in Settings → Providers.</p>
) : null}

{result ? (
<StyleDiffPreview
analysis={result}
description="Review this diff before deciding whether the board's synthesized style should update the universe."
/>
) : null}
{result?.context?.droppedItems ? (
<p className="text-[11px] text-gray-500">
{result.context.droppedItems} item{result.context.droppedItems === 1 ? '' : 's'} beyond the context limit were not considered.
</p>
) : null}

<div className="flex items-center justify-end gap-2 flex-wrap">
<button type="button" onClick={onClose} disabled={busy} className="min-h-[38px] px-3 text-sm text-gray-400 hover:text-white disabled:opacity-50">
Cancel
</button>
<button
type="button"
onClick={synthesize}
disabled={busy || !selectedProviderId}
className={`inline-flex min-h-[38px] items-center gap-2 rounded px-3 py-2 text-sm disabled:opacity-50 ${
result ? 'border border-port-border text-gray-200 hover:bg-white/5' : 'bg-port-accent text-white'
}`}
>
{running ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{running ? 'Synthesizing…' : result ? 'Synthesize again' : 'Synthesize'}
</button>
{result ? (
<button
type="button"
onClick={adopt}
disabled={busy || !result.diff?.hasChanges}
title={result.diff?.hasChanges ? 'Apply the proposed style guide to the universe' : 'The current guidance already matches the proposal'}
className="inline-flex min-h-[38px] items-center gap-2 rounded bg-port-accent px-3 py-2 text-sm text-white disabled:opacity-50"
>
{adopting ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
Adopt style
</button>
) : null}
</div>
</div>
);
}

export default function MoodBoardStyleSynthesis({
boardId,
universeId,
styleNotes,
influences,
locked,
saved = false,
onAdopt,
}) {
const [open, setOpen] = useState(false);
const [bodyBusy, setBodyBusy] = useState(false);
if (!boardId) return null;
// Guarded close: backdrop, Escape (Modal routes both here), and the body's
// Cancel/X are ignored mid-run so a request can't be silently orphaned.
// The body passes `force === true` after a successful adopt, where its own
// busy flag has just cleared but this component hasn't re-rendered yet.
const close = (force) => {
if (bodyBusy && force !== true) return;
setOpen(false);
};
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOpen(true)}
disabled={!saved}
title={saved
? 'Distill the linked mood board into the universe style guide'
: 'Save the universe before synthesizing its style'}
className="inline-flex min-h-[38px] items-center gap-1.5 rounded border border-port-accent/40 px-2.5 py-1.5 text-xs text-port-accent hover:bg-port-accent/10 disabled:opacity-50"
>
<Sparkles size={14} />
Synthesize style
</button>
<span className="text-[11px] text-gray-500">Board notes + analyses → style prompt, negative prompt, style notes.</span>
<Modal
open={open}
onClose={close}
size="2xl"
closeOnBackdrop={!bodyBusy}
usePortal
panelClassName="bg-port-card border border-port-border rounded-xl max-h-[90vh] overflow-y-auto"
ariaLabel="Synthesize universe style from mood board"
>
{open ? (
<SynthesisBody
key={`${universeId || ''}:${boardId}`}
boardId={boardId}
styleNotes={styleNotes}
influences={influences}
locked={locked}
onAdopt={onAdopt}
onBusyChange={setBodyBusy}
onClose={close}
/>
) : null}
</Modal>
</div>
);
}
123 changes: 123 additions & 0 deletions client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MoodBoardStyleSynthesis from './MoodBoardStyleSynthesis';

const apiMocks = vi.hoisted(() => ({
synthesizeMoodBoardStyle: vi.fn(),
}));
vi.mock('../../services/api', () => ({ ...apiMocks }));
vi.mock('../../hooks/useProviderModels', () => ({
default: vi.fn(() => ({
providers: [{ id: 'ollama', name: 'Ollama', type: 'api', enabled: true }],
selectedProviderId: 'ollama',
selectedModel: 'qwen',
availableModels: ['qwen'],
setSelectedProviderId: vi.fn(),
setSelectedModel: vi.fn(),
loading: false,
})),
}));
vi.mock('../ui/Toast', () => ({ default: { error: vi.fn(), success: vi.fn() } }));

const synthesis = {
proposed: {
styleNotes: 'Tactile ink-wash science fiction.',
influences: { embrace: ['ink wash'], avoid: ['gloss'] },
},
diff: {
hasChanges: true,
styleNotes: { before: 'Clean vector art', after: 'Tactile ink-wash science fiction.', changed: true },
influences: {
embrace: { changed: true, added: ['ink wash'], removed: ['clean vectors'] },
avoid: { changed: true, added: ['gloss'], removed: ['grain'] },
},
},
rationale: 'The board trades polish for tactile marks.',
context: { items: 3, droppedItems: 0 },
llm: { provider: 'ollama', model: 'qwen' },
};

const baseProps = {
boardId: 'mb-1',
universeId: 'u1',
styleNotes: 'Clean vector art',
influences: { embrace: ['clean vectors'], avoid: ['grain'] },
locked: { influencesAvoid: true },
saved: true,
};

const renderPanel = (props = {}) => render(
<MoodBoardStyleSynthesis {...baseProps} {...props} />,
);

const runSynthesis = async () => {
fireEvent.click(screen.getByRole('button', { name: /synthesize style/i }));
fireEvent.click(screen.getByRole('button', { name: /^synthesize$/i }));
await waitFor(() => expect(screen.getByText('Style guide preview')).toBeInTheDocument());
};

describe('MoodBoardStyleSynthesis (#4188 Phase 4)', () => {
beforeEach(() => vi.clearAllMocks());

it('renders nothing without a linked board and disables the trigger until saved', () => {
const { container } = render(<MoodBoardStyleSynthesis boardId="" universeId="u1" saved />);
expect(container.firstChild).toBeNull();

renderPanel({ saved: false });
expect(screen.getByRole('button', { name: /synthesize style/i })).toBeDisabled();
});

it('synthesizes with the draft style context, previews the diff, and adopts through the caller', async () => {
apiMocks.synthesizeMoodBoardStyle.mockResolvedValue(synthesis);
const onAdopt = vi.fn().mockResolvedValue(true);

renderPanel({ onAdopt });
await runSynthesis();

expect(apiMocks.synthesizeMoodBoardStyle).toHaveBeenCalledWith('mb-1', {
styleNotes: 'Clean vector art',
influences: { embrace: ['clean vectors'], avoid: ['grain'] },
locked: { influencesAvoid: true },
providerId: 'ollama',
model: 'qwen',
}, { silent: true });
expect(screen.getByText('+ ink wash')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /adopt style/i }));
await waitFor(() => {
expect(onAdopt).toHaveBeenCalledWith({
styleNotes: 'Tactile ink-wash science fiction.',
influences: { embrace: ['ink wash'], avoid: ['gloss'] },
});
});
// A successful adopt closes the modal (force path — the parent's busy
// gate hasn't re-rendered yet when the body requests the close).
await waitFor(() => {
expect(screen.queryByText('Style guide preview')).toBeNull();
});
});

it('disables Adopt when the proposal matches the current guidance', async () => {
apiMocks.synthesizeMoodBoardStyle.mockResolvedValue({
...synthesis,
diff: { ...synthesis.diff, hasChanges: false },
});
renderPanel();
await runSynthesis();
expect(screen.getByRole('button', { name: /adopt style/i })).toBeDisabled();
});

it('discards a stale proposal when the target universe changes (remount by key)', async () => {
apiMocks.synthesizeMoodBoardStyle.mockResolvedValue(synthesis);
const { rerender } = renderPanel();
await runSynthesis();
expect(screen.getByRole('button', { name: /adopt style/i })).toBeInTheDocument();

// Navigating to a different universe while the modal is open must wipe
// the proposal — adopting universe A's synthesis into B would silently
// copy one universe's style into another.
rerender(<MoodBoardStyleSynthesis {...baseProps} universeId="u2" />);
expect(screen.queryByRole('button', { name: /adopt style/i })).toBeNull();
expect(screen.queryByText('Style guide preview')).toBeNull();
});
});
Loading