diff --git a/.changelog/next/added-issue-4188.md b/.changelog/next/added-issue-4188.md index a94a436c57..a803b4eed6 100644 --- a/.changelog/next/added-issue-4188.md +++ b/.changelog/next/added-issue-4188.md @@ -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. diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx new file mode 100644 index 0000000000..fc25f10120 --- /dev/null +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx @@ -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 ( +
+
+
+

Synthesize style from mood board

+

+ Distills the board's notes, captions, and item analyses into a proposed style guide. Review the diff, then adopt. +

+
+ +
+ + + {providers.length === 0 && !providersLoading ? ( +

No API providers are enabled. Add one in Settings → Providers.

+ ) : null} + + {result ? ( + + ) : null} + {result?.context?.droppedItems ? ( +

+ {result.context.droppedItems} item{result.context.droppedItems === 1 ? '' : 's'} beyond the context limit were not considered. +

+ ) : null} + +
+ + + {result ? ( + + ) : null} +
+
+ ); +} + +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 ( +
+ + Board notes + analyses → style prompt, negative prompt, style notes. + + {open ? ( + + ) : null} + +
+ ); +} diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx new file mode 100644 index 0000000000..eb3508fc9a --- /dev/null +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx @@ -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( + , +); + +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(); + 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(); + expect(screen.queryByRole('button', { name: /adopt style/i })).toBeNull(); + expect(screen.queryByText('Style guide preview')).toBeNull(); + }); +}); diff --git a/client/src/components/universeBuilder/StyleDiffPreview.jsx b/client/src/components/universeBuilder/StyleDiffPreview.jsx new file mode 100644 index 0000000000..5929c88503 --- /dev/null +++ b/client/src/components/universeBuilder/StyleDiffPreview.jsx @@ -0,0 +1,64 @@ +/** + * Style-guide diff preview — renders the `{ diff, rationale }` shape the + * server's `buildStyleReferenceDiff` produces (style-reference analysis and + * mood-board style synthesis both return it), so every "review before adopt" + * flow shows the same before/after. Extracted from UniverseStyleReferences + * (#4188 Phase 4). + */ + +function TokenDiff({ label, diff, tone }) { + if (!diff?.changed) return null; + const addedClass = tone === 'positive' ? 'text-port-success' : 'text-port-error'; + return ( +
+
{label}
+
+ {diff.removed.map((token) => ( + + {token} + + ))} + {diff.added.map((token) => ( + + + {token} + + ))} +
+
+ ); +} + +export default function StyleDiffPreview({ analysis, description = 'Review this diff before deciding whether the reference should update the universe.' }) { + const diff = analysis?.diff; + if (!diff) return null; + return ( +
+
+

Style guide preview

+

{description}

+
+ {!diff.hasChanges ? ( +

The current guidance already matches this reference.

+ ) : null} + {diff.styleNotes?.changed ? ( +
+
+
Current style notes
+

+ {diff.styleNotes.before || 'None'} +

+
+
+
Proposed style notes
+

+ {diff.styleNotes.after || 'Clear style notes'} +

+
+
+ ) : null} + + + {analysis.rationale ?

{analysis.rationale}

: null} +
+ ); +} diff --git a/client/src/components/universeBuilder/UniverseBibleTab.jsx b/client/src/components/universeBuilder/UniverseBibleTab.jsx index ba62948516..17a8f7c13c 100644 --- a/client/src/components/universeBuilder/UniverseBibleTab.jsx +++ b/client/src/components/universeBuilder/UniverseBibleTab.jsx @@ -14,6 +14,7 @@ import MoodBoardReferenceStrip from '../moodBoard/MoodBoardReferenceStrip'; import StyleProbeImage from '../universe/StyleProbeImage'; import VisionProviderPicker from '../universe/VisionProviderPicker'; import InfluenceChipsInput from './InfluenceChipsInput'; +import MoodBoardStyleSynthesis from './MoodBoardStyleSynthesis'; import UniverseStyleReferences from './UniverseStyleReferences'; function LockButton({ field, locked, onToggle, label }) { @@ -111,6 +112,7 @@ export default function BibleTab({ saved = false, onPersistStyleReference, onRemoveStyleReference, + onAdoptStyleGuide, }) { const { providers, providerModels, providerLabel, activeProviderId } = llm; const { @@ -176,6 +178,21 @@ export default function BibleTab({ newBoardName={draft.name?.trim() || ''} /> + {/* Board → style synthesis (#4188 Phase 4): distill the linked board + into the style guide. Adoption goes through the draft hook + (adoptStyleGuideFromBoard), which runs the server-side queued + write AND the same saved-snapshot/watermark bookkeeping as an + art-reference adopt — so styleProbeDirty clears correctly. */} + +
- +
diff --git a/client/src/hooks/useUniverseDraft.js b/client/src/hooks/useUniverseDraft.js index b3afde840c..724e1699bd 100644 --- a/client/src/hooks/useUniverseDraft.js +++ b/client/src/hooks/useUniverseDraft.js @@ -3,6 +3,7 @@ import useMounted from './useMounted'; import toast from '../components/ui/Toast'; import { addUniverseStyleReference, + adoptUniverseStyleGuide, createUniverse, deleteUniverse, getProviders, @@ -496,6 +497,32 @@ export default function useUniverseDraft({ selectedId, goToWorld }) { return true; }, [applyStyleReferenceResult, draft, selectedId]); + // Adopt a board-synthesized style guide (#4188 Phase 4) — the reference-less + // sibling of the persistStyleReference adopt path. Routes through the same + // applyStyleReferenceResult bookkeeping so the saved snapshot + update + // watermark advance and styleProbeDirty clears, exactly as an art-reference + // adopt does; the server re-checks field locks in its queued write. + const adoptStyleGuideFromBoard = useCallback(async (proposed) => { + if (!selectedId) return false; + const targetId = selectedId; + const current = draftRef.current || draft; + const capturedStyle = { + styleNotes: current.styleNotes || '', + influences: ensureInfluences(current.influences), + }; + const updated = await adoptUniverseStyleGuide(targetId, { + styleNotes: proposed?.styleNotes || '', + influences: ensureInfluences(proposed?.influences), + }, { silent: true }).catch((error) => { + toast.error(`Adopting the style guide failed: ${error.message}`); + return null; + }); + if (!updated) return false; + applyStyleReferenceResult(targetId, updated, capturedStyle); + toast.success('Style guide adopted from mood board'); + return true; + }, [applyStyleReferenceResult, draft, selectedId]); + // Remove one art reference by id. Also a delta, so back-to-back removals need // no client-side queue — the server's record write queue serializes them. const removeStyleReference = useCallback(async (referenceId) => { @@ -719,6 +746,7 @@ export default function useUniverseDraft({ selectedId, goToWorld }) { // Exposed so a consumer can retry the catalog/settings load after a failed // refresh instead of forcing a full page reload. refresh, + adoptStyleGuideFromBoard, persistStyleReference, removeCategory, removeStyleReference, diff --git a/client/src/services/apiMoodBoard.js b/client/src/services/apiMoodBoard.js index afcc3943d6..e86828dc7b 100644 --- a/client/src/services/apiMoodBoard.js +++ b/client/src/services/apiMoodBoard.js @@ -46,6 +46,19 @@ export const removeMoodBoardItem = (id, itemId, options) => ...options, }); +// Board → universe style synthesis (#4188 Phase 4). Stateless review step: +// sends the universe's CURRENT style context (styleNotes/influences/locked) +// plus the chosen LLM; resolves to `{ proposed, diff, rationale, llm }`. +// Adoption goes through adoptUniverseStyleGuide (apiUniverseBuilder.js). +export const synthesizeMoodBoardStyle = (id, { + styleNotes, influences, locked, providerId, model, +} = {}, options = {}) => + request(`/mood-boards/${encodeURIComponent(id)}/synthesize-style`, { + method: 'POST', + body: JSON.stringify({ styleNotes, influences, locked, providerId, model }), + ...options, + }); + // Pinterest importer: link a board to a Pinterest board URL, unlink, and run a // manual "Sync now" that pulls new pins (download + dedupe) server-side. export const linkMoodBoardPinterest = (id, url, options) => diff --git a/client/src/services/apiUniverseBuilder.js b/client/src/services/apiUniverseBuilder.js index 560a019eba..68eb21da1c 100644 --- a/client/src/services/apiUniverseBuilder.js +++ b/client/src/services/apiUniverseBuilder.js @@ -61,6 +61,15 @@ export const removeUniverseStyleReference = (id, referenceId, options = {}) => r { method: 'DELETE', ...options }, ); +// Adopt a proposed style guide with no reference record attached (#4188 +// Phase 4 — mood-board synthesis). Server-side queued write; locks are +// re-checked against the freshest persisted record. Resolves with the full +// updated universe. +export const adoptUniverseStyleGuide = (id, { styleNotes, influences } = {}, options = {}) => request( + `/universe-builder/${encodeURIComponent(id)}/adopt-style`, + { method: 'POST', body: JSON.stringify({ styleNotes, influences }), ...options }, +); + export const expandUniverse = ({ starterPrompt, influences, preservedVariations, preservedCompositeSheets, diff --git a/server/routes/moodBoard.js b/server/routes/moodBoard.js index 8615a67acb..33948ace2d 100644 --- a/server/routes/moodBoard.js +++ b/server/routes/moodBoard.js @@ -8,6 +8,7 @@ */ import { Router } from 'express'; +import { z } from 'zod'; import { asyncHandler, ServerError } from '../lib/errorHandler.js'; import { validateRequest, @@ -19,6 +20,9 @@ import { isPaginationRequested, paginateArray, } from '../lib/validation.js'; +import { influencesSchema, lockedSchema } from './universeBuilder/shared.js'; +import { synthesizeBoardStyle } from '../services/moodBoardStyleSynthesis.js'; +import { STYLE_NOTES_MAX } from '../services/universeBuilder.js'; import { listBoards, getBoard, @@ -87,6 +91,25 @@ router.delete('/:id/items/:itemId', asyncHandler(async (req, res) => { res.json(board); })); +// Board → universe style synthesis (#4188 Phase 4). Stateless like +// /analyze-style-reference: the client sends the universe's CURRENT style +// context (draft values — possibly unsaved), the server reads the board and +// returns a proposal + diff. Persistence happens only through the universe's +// queued-write adopt endpoint after the user reviews the diff. +const synthesizeStyleSchema = z.object({ + styleNotes: z.string().trim().max(STYLE_NOTES_MAX).optional().default(''), + influences: influencesSchema.optional().default({ embrace: [], avoid: [] }), + locked: lockedSchema.optional().default({}), + providerId: z.string().trim().max(80).optional(), + model: z.string().trim().max(200).optional(), +}).strict(); +router.post('/:id/synthesize-style', asyncHandler(async (req, res) => { + const body = validateRequest(synthesizeStyleSchema, req.body ?? {}); + const board = await getBoard(req.params.id); + if (!board) throw new ServerError('Mood board not found', { status: 404, code: 'NOT_FOUND' }); + res.json(await synthesizeBoardStyle({ board, ...body })); +})); + // Link the board to a public Pinterest board's RSS feed. router.put('/:id/pinterest', asyncHandler(async (req, res) => { const data = validateRequest(moodBoardPinterestLinkSchema, req.body); diff --git a/server/routes/moodBoard.test.js b/server/routes/moodBoard.test.js index 362d4980ee..39e5ccc323 100644 --- a/server/routes/moodBoard.test.js +++ b/server/routes/moodBoard.test.js @@ -19,7 +19,14 @@ vi.mock('../services/moodBoard/index.js', () => ({ syncPinterestBoard: vi.fn(), })); +// The synthesis service pulls the aiProvider/promptRunner stack — stub it so +// this stays a routing test (service behavior is covered in its own suite). +vi.mock('../services/moodBoardStyleSynthesis.js', () => ({ + synthesizeBoardStyle: vi.fn(), +})); + import * as svc from '../services/moodBoard/index.js'; +import { synthesizeBoardStyle } from '../services/moodBoardStyleSynthesis.js'; import moodBoardRoutes from './moodBoard.js'; const makeApp = () => { @@ -70,4 +77,43 @@ describe('mood-board routes', () => { expect(res.body.id).toBe('mb-1'); }); }); + + describe('POST /:id/synthesize-style (#4188 Phase 4)', () => { + it('404s when the board is missing', async () => { + svc.getBoard.mockResolvedValueOnce(null); + const res = await request(makeApp()).post('/api/mood-boards/nope/synthesize-style').send({}); + expect(res.status).toBe(404); + expect(synthesizeBoardStyle).not.toHaveBeenCalled(); + }); + + it('passes the board and the validated style context to the service', async () => { + const board = { id: 'mb-1', name: 'A', items: [] }; + svc.getBoard.mockResolvedValueOnce(board); + synthesizeBoardStyle.mockResolvedValueOnce({ proposed: {}, diff: { hasChanges: false } }); + const res = await request(makeApp()).post('/api/mood-boards/mb-1/synthesize-style').send({ + styleNotes: 'current', + influences: { embrace: ['a'], avoid: [] }, + locked: { influencesAvoid: true }, + providerId: 'ollama', + model: 'qwen', + }); + expect(res.status).toBe(200); + expect(synthesizeBoardStyle).toHaveBeenCalledWith({ + board, + styleNotes: 'current', + influences: { embrace: ['a'], avoid: [] }, + locked: { influencesAvoid: true }, + providerId: 'ollama', + model: 'qwen', + }); + }); + + it('400s on an unknown body key (strict schema)', async () => { + const res = await request(makeApp()).post('/api/mood-boards/mb-1/synthesize-style').send({ + universeId: 'w-1', + }); + expect(res.status).toBe(400); + expect(synthesizeBoardStyle).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/routes/universeBuilder/styleReferences.js b/server/routes/universeBuilder/styleReferences.js index 1c4efcdb3e..d147beb828 100644 --- a/server/routes/universeBuilder/styleReferences.js +++ b/server/routes/universeBuilder/styleReferences.js @@ -75,6 +75,22 @@ router.post('/:id/style-references', asyncHandler(async (req, res) => { res.json(w); })); +// Adopt a style guide with NO reference record attached (#4188 Phase 4: the +// mood-board synthesis flow proposes styleNotes + influences from board +// content, not from a single image). Same queued-write semantics as the +// `adopt` half of the add-reference endpoint; locks are re-checked server-side +// against the freshest persisted record. +const adoptStyleSchema = z.object({ + styleNotes: z.string().trim().max(svc.STYLE_NOTES_MAX).optional().default(''), + influences: influencesSchema.optional().default({ embrace: [], avoid: [] }), +}).strict(); +router.post('/:id/adopt-style', asyncHandler(async (req, res) => { + const body = validateRequest(adoptStyleSchema, req.body ?? {}); + const w = await svc.adoptStyleGuide(req.params.id, body) + .catch((err) => { throw mapServiceError(err); }); + res.json(w); +})); + // The id is only ever compared against stored reference ids (never used as a // path/SQL operand), but validate it anyway so an absurdly long param is a 400 // here rather than a silent no-op deeper in. diff --git a/server/services/moodBoardStyleSynthesis.js b/server/services/moodBoardStyleSynthesis.js new file mode 100644 index 0000000000..2b060ee046 --- /dev/null +++ b/server/services/moodBoardStyleSynthesis.js @@ -0,0 +1,193 @@ +/** + * Mood board → universe style synthesis (#4188 Phase 4). + * + * Stateless, mirroring `analyzeUniverseStyleReference`: one text LLM run over + * the board's collected content (description, text notes, captions, and the + * per-item prompt-from-media analyses Phase 3 persists), proposing + * `{ styleNotes, influences: { embrace, avoid } }` shaped for the universe + * style guide, plus the same diff the style-reference review step renders. + * Nothing is persisted here — the client previews the diff and adoption goes + * through the universe's queued-write adopt endpoint. + */ + +import { parseLLMJSON, resolveAPIProvider } from '../lib/aiProvider.js'; +import { ServerError } from '../lib/errorHandler.js'; +import { assertProvider, runPromptThroughProvider } from '../lib/promptRunner.js'; +import { trimTo } from '../lib/storyBible.js'; +import { + sanitizeInfluences, + sanitizeLocked, + STYLE_NOTES_MAX, +} from './universeBuilder.js'; +import { buildStyleReferenceDiff } from './universeStyleReference.js'; + +// Context bounds: a board caps at 500 items, but the synthesis context must +// stay well inside a chat-completion window. Items are taken in board order +// (the user's curation order); fragments beyond either cap are dropped and +// the count is reported in the result so the UI can say so. The aggregate +// character budget is the load-bearing one — 60 items can each carry four +// 600-char fields, far past what a small local model's window fits — and it +// is a FIXED conservative budget (≈6k tokens) rather than model-aware: +// provider window metadata isn't reliably known here, and a proposal +// synthesized from the first N curated items beats a request the model +// truncates or rejects. +const CONTEXT_ITEMS_MAX = 60; +const CONTEXT_FIELD_MAX = 600; +const CONTEXT_TOTAL_CHARS_MAX = 24000; +const RATIONALE_MAX = 1000; + +/** + * Reduce a board to the style-relevant text fragments the LLM sees. An item + * contributes only what it actually carries: a text note, a caption, and/or a + * persisted analysis (prompt + negative + rationale). Media items without any + * of those contribute nothing — synthesis reads text, not pixels (analyzing + * an item is Phase 3's explicit per-item vision step). + */ +export function collectBoardStyleContext(board) { + const items = Array.isArray(board?.items) ? board.items : []; + const fragments = []; + let dropped = 0; + let totalChars = 0; + for (const it of items) { + if (!it || typeof it !== 'object') continue; + const entry = {}; + if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) { + entry.note = trimTo(it.text, CONTEXT_FIELD_MAX); + } + if (typeof it.caption === 'string' && it.caption.trim()) { + entry.caption = trimTo(it.caption, CONTEXT_FIELD_MAX); + } + const analysis = it.analysis; + if (analysis && typeof analysis === 'object' && typeof analysis.prompt === 'string' && analysis.prompt.trim()) { + entry.analyzedPrompt = trimTo(analysis.prompt, CONTEXT_FIELD_MAX); + if (typeof analysis.negativePrompt === 'string' && analysis.negativePrompt.trim()) { + entry.analyzedNegative = trimTo(analysis.negativePrompt, CONTEXT_FIELD_MAX); + } + if (typeof analysis.rationale === 'string' && analysis.rationale.trim()) { + entry.analysisRationale = trimTo(analysis.rationale, CONTEXT_FIELD_MAX); + } + } + if (!Object.keys(entry).length) continue; + const entrySize = Object.values(entry).reduce((sum, v) => sum + v.length, 0); + if (fragments.length >= CONTEXT_ITEMS_MAX || totalChars + entrySize > CONTEXT_TOTAL_CHARS_MAX) { + dropped += 1; + continue; + } + totalChars += entrySize; + fragments.push({ kind: it.type, ...entry }); + } + return { + name: trimTo(board?.name, 200) || null, + description: trimTo(board?.description, 2000) || null, + items: fragments, + droppedItems: dropped, + }; +} + +export function buildBoardStyleSynthesisPrompt({ context, styleNotes, influences, locked }) { + const payload = JSON.stringify({ + board: context, + currentStyleNotes: trimTo(styleNotes, STYLE_NOTES_MAX), + currentGuidance: sanitizeInfluences(influences), + locked: sanitizeLocked(locked), + }); + return `Synthesize a UNIVERSE VISUAL STYLE GUIDE from the mood board below. The board collects a user's curated inspiration: text notes, image/video captions, and per-item AI analyses (render prompts reverse-engineered from the pinned media). + +Distill the board into renderable visual style guidance: medium, line or brush treatment, shapes, texture, palette, lighting, composition, era, mood, and finish. Find the through-line across the items — what this board consistently embraces and what it consistently avoids — rather than describing any single item. Do not invent story facts, named characters, locations, or copyrighted-artist attribution. + +Mood board and current universe context: +${payload} + +Return JSON only: +{ + "styleNotes": "complete proposed replacement for currentStyleNotes, prose", + "influences": { + "embrace": ["complete ordered positive style-token list"], + "avoid": ["complete ordered negative style-token list"] + }, + "rationale": "one concise explanation of the synthesized direction" +} + +Honor every locked field: styleNotes, influencesEmbrace, or influencesAvoid must remain equivalent to the corresponding current value when locked. Preserve useful current guidance that does not conflict with the board. An empty array is a valid intentional recommendation.`; +} + +export async function synthesizeBoardStyle({ + board, + styleNotes, + influences, + locked, + providerId, + model, +} = {}) { + const context = collectBoardStyleContext(board); + if (!context.description && !context.items.length) { + throw new ServerError( + 'This board has nothing to synthesize from yet — analyze some items, add captions or notes, or give the board a description first.', + { status: 400, code: 'NOTHING_TO_SYNTHESIZE' }, + ); + } + + const provider = await resolveAPIProvider(providerId); + assertProvider(provider, { + message: 'Synthesizing a style guide needs an API-based provider. Configure one under Settings → Providers.', + code: 'NO_API_PROVIDER', + status: 503, + }); + + const result = await runPromptThroughProvider({ + provider, + prompt: buildBoardStyleSynthesisPrompt({ context, styleNotes, influences, locked }), + source: 'mood-board-style-synthesis', + model: model || undefined, + }); + let parsed; + try { + parsed = parseLLMJSON(result.text || ''); + } catch (error) { + throw new ServerError(`The model returned invalid style synthesis: ${error.message}`, { + status: 502, + code: 'SYNTHESIS_BAD_JSON', + }); + } + + const currentInfluences = sanitizeInfluences(influences); + const safeLocked = sanitizeLocked(locked); + const parsedInfluences = parsed?.influences && typeof parsed.influences === 'object' + ? parsed.influences + : {}; + // Locked fields keep their current value verbatim regardless of what the + // model proposed — the same belt the style-reference analyzer wears; the + // adopt write re-checks locks against the freshest persisted record. + const proposed = { + styleNotes: safeLocked.styleNotes + ? trimTo(styleNotes, STYLE_NOTES_MAX) + : (typeof parsed?.styleNotes === 'string' + ? trimTo(parsed.styleNotes, STYLE_NOTES_MAX) + : trimTo(styleNotes, STYLE_NOTES_MAX)), + influences: { + embrace: safeLocked.influencesEmbrace + ? currentInfluences.embrace + : (Array.isArray(parsedInfluences.embrace) + ? sanitizeInfluences({ embrace: parsedInfluences.embrace }).embrace + : currentInfluences.embrace), + avoid: safeLocked.influencesAvoid + ? currentInfluences.avoid + : (Array.isArray(parsedInfluences.avoid) + ? sanitizeInfluences({ avoid: parsedInfluences.avoid }).avoid + : currentInfluences.avoid), + }, + }; + + return { + proposed, + diff: buildStyleReferenceDiff({ styleNotes, influences: currentInfluences }, proposed), + rationale: trimTo(parsed?.rationale, RATIONALE_MAX), + context: { items: context.items.length, droppedItems: context.droppedItems }, + llm: { + provider: result.provider?.id || provider.id, + model: result.model || null, + }, + }; +} + +export const __testing = { collectBoardStyleContext, buildBoardStyleSynthesisPrompt }; diff --git a/server/services/moodBoardStyleSynthesis.test.js b/server/services/moodBoardStyleSynthesis.test.js new file mode 100644 index 0000000000..fa66e6b55b --- /dev/null +++ b/server/services/moodBoardStyleSynthesis.test.js @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/aiProvider.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, resolveAPIProvider: vi.fn() }; +}); +vi.mock('../lib/promptRunner.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, runPromptThroughProvider: vi.fn() }; +}); + +const aiProvider = await import('../lib/aiProvider.js'); +const promptRunner = await import('../lib/promptRunner.js'); +const { + collectBoardStyleContext, + buildBoardStyleSynthesisPrompt, + synthesizeBoardStyle, +} = await import('./moodBoardStyleSynthesis.js'); + +const apiProvider = { id: 'ollama', type: 'api', defaultModel: 'qwen' }; +const synthesisText = JSON.stringify({ + styleNotes: 'Tactile ink-wash science fiction with restrained, dusty light.', + influences: { + embrace: ['granular ink wash', 'muted ochre'], + avoid: ['glossy 3D', 'neon'], + }, + rationale: 'The board consistently trades polish for tactile marks.', +}); + +const boardWith = (items) => ({ + id: 'mb-1', + name: 'Universe refs', + description: 'Dusty painted sci-fi.', + items, +}); + +const analyzedItem = { + id: 'i1', + type: 'image', + mediaKey: 'image:ref.png', + caption: 'palette anchor', + analysis: { + prompt: 'a weathered foundry in granular ink wash', + negativePrompt: 'gloss, neon', + rationale: 'muted, tactile look', + analyzedAt: '2026-08-14T00:00:00.000Z', + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + aiProvider.resolveAPIProvider.mockResolvedValue(apiProvider); + promptRunner.runPromptThroughProvider.mockResolvedValue({ text: synthesisText, model: 'qwen' }); +}); + +describe('collectBoardStyleContext', () => { + it('gathers notes, captions, and persisted analyses; skips media items with neither', () => { + const ctx = collectBoardStyleContext(boardWith([ + analyzedItem, + { id: 'i2', type: 'text', text: 'lean grim and spiritual', caption: null }, + { id: 'i3', type: 'video', mediaKey: 'video:clip.mp4', caption: null, analysis: null }, + ])); + expect(ctx.description).toBe('Dusty painted sci-fi.'); + expect(ctx.items).toHaveLength(2); + expect(ctx.items[0]).toMatchObject({ + kind: 'image', + caption: 'palette anchor', + analyzedPrompt: expect.stringContaining('granular ink wash'), + analyzedNegative: 'gloss, neon', + }); + expect(ctx.items[1]).toMatchObject({ kind: 'text', note: 'lean grim and spiritual' }); + expect(ctx.droppedItems).toBe(0); + }); + + it('caps the fragment list and reports the overflow', () => { + const many = Array.from({ length: 70 }, (_, i) => ({ id: `t${i}`, type: 'text', text: `note ${i}` })); + const ctx = collectBoardStyleContext(boardWith(many)); + expect(ctx.items).toHaveLength(60); + expect(ctx.droppedItems).toBe(10); + }); + + it('bounds the AGGREGATE character budget, not just the fragment count', () => { + // 50 items × ~600 chars each ≈ 30k chars — under the 60-item cap but past + // the 24k aggregate budget, so the tail must be dropped. + const big = Array.from({ length: 50 }, (_, i) => ({ id: `t${i}`, type: 'text', text: 'x'.repeat(600) })); + const ctx = collectBoardStyleContext(boardWith(big)); + expect(ctx.items.length).toBeLessThan(50); + expect(ctx.items.length).toBeGreaterThan(0); + expect(ctx.droppedItems).toBe(50 - ctx.items.length); + const total = ctx.items.reduce((sum, it) => sum + it.note.length, 0); + expect(total).toBeLessThanOrEqual(24000); + }); +}); + +describe('synthesizeBoardStyle', () => { + it('400s a board with no synthesizable content', async () => { + await expect(synthesizeBoardStyle({ + board: { id: 'mb-1', name: 'Empty', description: '', items: [{ id: 'i1', type: 'image', mediaKey: 'image:a.png' }] }, + providerId: 'ollama', + })).rejects.toMatchObject({ code: 'NOTHING_TO_SYNTHESIZE', status: 400 }); + expect(promptRunner.runPromptThroughProvider).not.toHaveBeenCalled(); + }); + + it('synthesizes a proposal with a reviewable diff and feeds the board content to the prompt', async () => { + const result = await synthesizeBoardStyle({ + board: boardWith([analyzedItem]), + styleNotes: 'Clean vector art', + influences: { embrace: ['clean vectors'], avoid: ['grain'] }, + providerId: 'ollama', + model: 'qwen', + }); + + const sentPrompt = promptRunner.runPromptThroughProvider.mock.calls[0][0].prompt; + expect(sentPrompt).toContain('granular ink wash'); + expect(sentPrompt).toContain('Dusty painted sci-fi.'); + + expect(result.proposed.styleNotes).toContain('Tactile ink-wash'); + expect(result.proposed.influences.embrace).toEqual(['granular ink wash', 'muted ochre']); + expect(result.diff.hasChanges).toBe(true); + expect(result.diff.influences.embrace.added).toContain('granular ink wash'); + expect(result.diff.influences.embrace.removed).toContain('clean vectors'); + expect(result.rationale).toContain('tactile marks'); + expect(result.llm).toMatchObject({ provider: 'ollama' }); + expect(result.context).toEqual({ items: 1, droppedItems: 0 }); + }); + + it('keeps locked fields at their current values regardless of the model output', async () => { + const result = await synthesizeBoardStyle({ + board: boardWith([analyzedItem]), + styleNotes: 'Locked prose', + influences: { embrace: ['keep me'], avoid: [] }, + locked: { styleNotes: true, influencesEmbrace: true }, + providerId: 'ollama', + }); + expect(result.proposed.styleNotes).toBe('Locked prose'); + expect(result.proposed.influences.embrace).toEqual(['keep me']); + // The avoid list is unlocked, so the model's proposal applies. + expect(result.proposed.influences.avoid).toEqual(['glossy 3D', 'neon']); + }); + + it('502s when the model returns unparseable JSON', async () => { + promptRunner.runPromptThroughProvider.mockResolvedValue({ text: 'not json at all' }); + await expect(synthesizeBoardStyle({ + board: boardWith([analyzedItem]), + providerId: 'ollama', + })).rejects.toMatchObject({ code: 'SYNTHESIS_BAD_JSON', status: 502 }); + }); + + it('503s when no API provider is configured', async () => { + aiProvider.resolveAPIProvider.mockResolvedValue(null); + await expect(synthesizeBoardStyle({ + board: boardWith([analyzedItem]), + })).rejects.toMatchObject({ code: 'NO_API_PROVIDER', status: 503 }); + }); +}); + +describe('buildBoardStyleSynthesisPrompt', () => { + it('embeds current guidance and lock state so the model can honor them', () => { + const prompt = buildBoardStyleSynthesisPrompt({ + context: { name: 'B', description: 'd', items: [], droppedItems: 0 }, + styleNotes: 'current notes', + influences: { embrace: ['a'], avoid: ['b'] }, + locked: { influencesAvoid: true }, + }); + expect(prompt).toContain('current notes'); + expect(prompt).toContain('influencesAvoid'); + expect(prompt).toContain('Return JSON only'); + }); +}); diff --git a/server/services/universeBuilder.test.js b/server/services/universeBuilder.test.js index 02c3468645..fa24f2e454 100644 --- a/server/services/universeBuilder.test.js +++ b/server/services/universeBuilder.test.js @@ -448,6 +448,36 @@ describe("universeBuilder service", () => { expect(fresh.styleReferences).toHaveLength(1); }); + it("adoptStyleGuide writes the proposed guide with no reference attached (#4188 Phase 4)", async () => { + const w = await svc.createUniverse({ name: "Adopt Guide", influences: { embrace: ["old"], avoid: [] } }); + const updated = await svc.adoptStyleGuide(w.id, { + styleNotes: "Board-synthesized prose", + influences: { embrace: ["ink wash"], avoid: ["gloss"] }, + }); + expect(updated.styleNotes).toBe("Board-synthesized prose"); + expect(updated.influences).toEqual({ embrace: ["ink wash"], avoid: ["gloss"] }); + const fresh = await svc.getUniverse(w.id); + expect(fresh.styleNotes).toBe("Board-synthesized prose"); + expect(fresh.styleReferences ?? []).toHaveLength(0); + }); + + it("adoptStyleGuide re-checks locks against the freshest persisted record", async () => { + const w = await svc.createUniverse({ + name: "Adopt Locked", + styleNotes: "Locked prose", + influences: { embrace: ["keep me"], avoid: ["old bad"] }, + locked: { styleNotes: true, influencesEmbrace: true }, + }); + const updated = await svc.adoptStyleGuide(w.id, { + styleNotes: "Should not land", + influences: { embrace: ["should not land"], avoid: ["new bad"] }, + }); + // Locked fields keep the persisted values; the unlocked avoid list adopts. + expect(updated.styleNotes).toBe("Locked prose"); + expect(updated.influences.embrace).toEqual(["keep me"]); + expect(updated.influences.avoid).toEqual(["new bad"]); + }); + it("addStyleReference is idempotent on a re-sent id and rejects an invalid reference", async () => { const reference = { id: "style-ref-once", diff --git a/server/services/universeBuilder/crud.js b/server/services/universeBuilder/crud.js index 36b77798c7..e4e8dc934c 100644 --- a/server/services/universeBuilder/crud.js +++ b/server/services/universeBuilder/crud.js @@ -18,7 +18,7 @@ import { import { store } from './storeFacade.js'; import { sanitizeTemplate, sanitizeRun, sanitizeImageRefFilename, resolveInfluences, - sanitizeStyleReference, + sanitizeStyleReference, sanitizeInfluences, sanitizeLocked, mergeInfluencesWithLocks, makeErr, UNIVERSE_ID_RE, ERR_NOT_FOUND, ERR_VALIDATION, ERR_DUPLICATE, ERR_HAS_LIVE_SERIES, NAME_MAX_LENGTH, CURRENT_SCHEMA_VERSION, ENTRY_REF_KIND, IMAGE_REFS_PER_ENTRY_MAX, @@ -751,6 +751,31 @@ export async function addStyleReference(id, reference, { adopt = null } = {}) { }, { touchesCanon: false }); } +/** + * Adopt a proposed style guide (styleNotes + influences) as one queued write — + * the standalone counterpart to `addStyleReference`'s `adopt` half, for flows + * with no reference record to add (mood-board style synthesis, #4188 Phase 4). + * Locks are enforced HERE against the freshest persisted record, not just at + * proposal time: a field the user locked after the proposal was generated + * keeps its current value (`mergeInfluencesWithLocks` for the lists; a locked + * styleNotes keeps the persisted prose). + */ +export async function adoptStyleGuide(id, { styleNotes, influences } = {}) { + return updateUniverse(id, (cur) => { + const locked = sanitizeLocked(cur.locked); + return { + styleNotes: locked.styleNotes + ? trimTo(cur.styleNotes, STYLE_NOTES_MAX) + : trimTo(styleNotes, STYLE_NOTES_MAX), + influences: mergeInfluencesWithLocks( + locked, + sanitizeInfluences(influences), + resolveInfluences(cur), + ), + }; + }, { touchesCanon: false }); +} + /** * Remove one art reference by id — the delta counterpart to * `addStyleReference`, for the same reason (see its doc comment). Resolves with