From 1bec5bc88fbfe8cee9ce887097deef9b5a54a0b2 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 14 Aug 2026 19:59:09 -0700 Subject: [PATCH 1/2] feat([issue-4188]): synthesize a universe style guide from its mood board (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New stateless POST /api/mood-boards/:id/synthesize-style runs the board's collected content (text notes, captions, and the per-item prompt-from-media analyses) through a user-picked API LLM and returns a proposed styleNotes/influences guide plus the same reviewable diff the art-reference analyzer produces. The Universe Builder gains a Synthesize style flow next to the mood-board picker: preview the diff, then Adopt persists via a new server-side queued write (adoptStyleGuide / POST /:id/adopt-style) that re-checks field locks against the freshest persisted record — never a client wholesale influences PATCH. The StyleDiff preview is extracted from UniverseStyleReferences into a shared component so both adopt flows render identically. --- .changelog/next/added-issue-4188.md | 1 + .../MoodBoardStyleSynthesis.jsx | 197 ++++++++++++++++++ .../MoodBoardStyleSynthesis.test.jsx | 110 ++++++++++ .../universeBuilder/StyleDiffPreview.jsx | 64 ++++++ .../universeBuilder/UniverseBibleTab.jsx | 17 ++ .../UniverseStyleReferences.jsx | 62 +----- client/src/services/apiMoodBoard.js | 13 ++ client/src/services/apiUniverseBuilder.js | 9 + server/routes/moodBoard.js | 23 ++ server/routes/moodBoard.test.js | 46 ++++ .../routes/universeBuilder/styleReferences.js | 16 ++ server/services/moodBoardStyleSynthesis.js | 183 ++++++++++++++++ .../services/moodBoardStyleSynthesis.test.js | 157 ++++++++++++++ server/services/universeBuilder.test.js | 30 +++ server/services/universeBuilder/crud.js | 27 ++- 15 files changed, 894 insertions(+), 61 deletions(-) create mode 100644 client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx create mode 100644 client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx create mode 100644 client/src/components/universeBuilder/StyleDiffPreview.jsx create mode 100644 server/services/moodBoardStyleSynthesis.js create mode 100644 server/services/moodBoardStyleSynthesis.test.js 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..7b80507a23 --- /dev/null +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx @@ -0,0 +1,197 @@ +/** + * 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" persists it + * via the universe's server-side queued write (never a client wholesale + * `influences` PATCH). Locks are honored at proposal time and re-checked + * server-side on adopt. + */ + +import { useState } from 'react'; +import { Loader2, Sparkles, Wand2, X } from 'lucide-react'; +import { synthesizeMoodBoardStyle, adoptUniverseStyleGuide } from '../../services/api'; +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. +function SynthesisBody({ boardId, universeId, styleNotes, influences, locked, onAdopted, onClose }) { + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [adopting, setAdopting] = useState(false); + const { + providers, + selectedProviderId, + selectedModel, + availableModels, + setSelectedProviderId, + setSelectedModel, + loading: providersLoading, + } = useProviderModels({ filter: apiProviderFilter, silent: true }); + + const busy = running || adopting; + + 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) => { + toast.error(`Style synthesis failed: ${error.message}`); + return null; + }); + setRunning(false); + if (data) setResult(data); + }; + + const adopt = async () => { + if (!result?.proposed || busy) return; + setAdopting(true); + const updated = await adoptUniverseStyleGuide(universeId, { + styleNotes: result.proposed.styleNotes || '', + influences: result.proposed.influences || {}, + }, { silent: true }).catch((error) => { + toast.error(`Adopting the style guide failed: ${error.message}`); + return null; + }); + setAdopting(false); + if (!updated) return; + onAdopted?.(updated); + toast.success('Style guide adopted'); + onClose(); + }; + + 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, + onAdopted, +}) { + const [open, setOpen] = useState(false); + if (!boardId) return null; + return ( +
+ + Board notes + analyses → style prompt, negative prompt, style notes. + setOpen(false)} + size="2xl" + 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 ? ( + setOpen(false)} + /> + ) : 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..5a83b4a028 --- /dev/null +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx @@ -0,0 +1,110 @@ +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(), + adoptUniverseStyleGuide: 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 renderPanel = (props = {}) => render( + , +); + +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 via the queued write', async () => { + apiMocks.synthesizeMoodBoardStyle.mockResolvedValue(synthesis); + const adopted = { id: 'u1', styleNotes: 'Tactile ink-wash science fiction.', influences: synthesis.proposed.influences }; + apiMocks.adoptUniverseStyleGuide.mockResolvedValue(adopted); + const onAdopted = vi.fn(); + + renderPanel({ onAdopted }); + fireEvent.click(screen.getByRole('button', { name: /synthesize style/i })); + fireEvent.click(screen.getByRole('button', { name: /^synthesize$/i })); + + await waitFor(() => { + 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 }); + }); + + // Diff preview renders the proposal. + expect(screen.getByText('Style guide preview')).toBeInTheDocument(); + expect(screen.getByText('+ ink wash')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /adopt style/i })); + await waitFor(() => { + expect(apiMocks.adoptUniverseStyleGuide).toHaveBeenCalledWith('u1', { + styleNotes: 'Tactile ink-wash science fiction.', + influences: { embrace: ['ink wash'], avoid: ['gloss'] }, + }, { silent: true }); + }); + expect(onAdopted).toHaveBeenCalledWith(adopted); + }); + + it('disables Adopt when the proposal matches the current guidance', async () => { + apiMocks.synthesizeMoodBoardStyle.mockResolvedValue({ + ...synthesis, + diff: { ...synthesis.diff, hasChanges: false }, + }); + renderPanel(); + fireEvent.click(screen.getByRole('button', { name: /synthesize style/i })); + fireEvent.click(screen.getByRole('button', { name: /^synthesize$/i })); + await waitFor(() => { + expect(screen.getByRole('button', { name: /adopt style/i })).toBeDisabled(); + }); + }); +}); 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..9cbe4746b8 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 }) { @@ -176,6 +177,22 @@ export default function BibleTab({ newBoardName={draft.name?.trim() || ''} /> + {/* Board → style synthesis (#4188 Phase 4): distill the linked board + into the style guide; adoption is a server-side queued write and + the adopted values are merged back into the draft here. */} + updateDraft({ + styleNotes: updated?.styleNotes ?? '', + influences: ensureInfluences(updated?.influences), + })} + /> +
- ); -} - -function StyleDiff({ analysis }) { - const diff = analysis?.diff; - if (!diff) return null; - return ( -
-
-

Style guide preview

-

- Review this diff before deciding whether the reference should update the universe. -

-
- {!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} -
- ); -} - export default function UniverseStyleReferences({ universe, saved, @@ -244,7 +186,7 @@ export default function UniverseStyleReferences({ - +
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..fe134b1d70 --- /dev/null +++ b/server/services/moodBoardStyleSynthesis.js @@ -0,0 +1,183 @@ +/** + * 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 the cap are dropped and the +// count is reported in the result so the UI can say so. +const CONTEXT_ITEMS_MAX = 60; +const CONTEXT_FIELD_MAX = 600; +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; + 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; + if (fragments.length >= CONTEXT_ITEMS_MAX) { + dropped += 1; + continue; + } + 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..62af0f5bf8 --- /dev/null +++ b/server/services/moodBoardStyleSynthesis.test.js @@ -0,0 +1,157 @@ +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); + }); +}); + +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 From bbb2add4aa53a56a29d7f5c0f10147798bce79cb Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 14 Aug 2026 20:21:42 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix([issue-4188]):=20codex=20review=20?= =?UTF-8?q?=E2=80=94=20stale-proposal=20guard,=20adopt=20bookkeeping,=20co?= =?UTF-8?q?ntext=20budget,=20busy-modal=20dismissal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthesis body now remounts (keyed by universe+board) when the target changes, so a proposal generated for one universe can never be adopted into another; a mounted-ref guard drops in-flight resolutions after unmount. Adoption routes through the draft hook's applyStyleReferenceResult bookkeeping (new adoptStyleGuideFromBoard) instead of a bare updateDraft, so the saved-style snapshot and update watermark advance and styleProbeDirty clears — exactly as an art-reference adopt. The board context collector now bounds the AGGREGATE character budget (24k chars), not just the fragment count, so a large board degrades to fewer items instead of a truncated or rejected request. Modal backdrop/Escape dismissal is gated on the run state (with a force path for the post-adopt close, whose busy flag clears in the same tick). --- .../MoodBoardStyleSynthesis.jsx | 64 +++++++++----- .../MoodBoardStyleSynthesis.test.jsx | 87 +++++++++++-------- .../universeBuilder/UniverseBibleTab.jsx | 12 +-- .../universeBuilder/UniverseBuilderPage.jsx | 2 + client/src/hooks/useUniverseDraft.js | 28 ++++++ server/services/moodBoardStyleSynthesis.js | 16 +++- .../services/moodBoardStyleSynthesis.test.js | 12 +++ 7 files changed, 154 insertions(+), 67 deletions(-) diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx index 7b80507a23..fc25f10120 100644 --- a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx @@ -4,15 +4,17 @@ * "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" persists it - * via the universe's server-side queued write (never a client wholesale - * `influences` PATCH). Locks are honored at proposal time and re-checked + * 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 { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Loader2, Sparkles, Wand2, X } from 'lucide-react'; -import { synthesizeMoodBoardStyle, adoptUniverseStyleGuide } from '../../services/api'; +import { synthesizeMoodBoardStyle } from '../../services/api'; +import useMounted from '../../hooks/useMounted'; import useProviderModels from '../../hooks/useProviderModels'; import ProviderModelSelector from '../ProviderModelSelector'; import Modal from '../ui/Modal'; @@ -22,11 +24,14 @@ 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. -function SynthesisBody({ boardId, universeId, styleNotes, influences, locked, onAdopted, onClose }) { +// 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, @@ -39,6 +44,13 @@ function SynthesisBody({ boardId, universeId, styleNotes, influences, locked, on 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); @@ -49,9 +61,10 @@ function SynthesisBody({ boardId, universeId, styleNotes, influences, locked, on providerId: selectedProviderId, model: selectedModel || undefined, }, { silent: true }).catch((error) => { - toast.error(`Style synthesis failed: ${error.message}`); + if (mountedRef.current) toast.error(`Style synthesis failed: ${error.message}`); return null; }); + if (!mountedRef.current) return; setRunning(false); if (data) setResult(data); }; @@ -59,18 +72,16 @@ function SynthesisBody({ boardId, universeId, styleNotes, influences, locked, on const adopt = async () => { if (!result?.proposed || busy) return; setAdopting(true); - const updated = await adoptUniverseStyleGuide(universeId, { + const ok = await onAdopt?.({ styleNotes: result.proposed.styleNotes || '', influences: result.proposed.influences || {}, - }, { silent: true }).catch((error) => { - toast.error(`Adopting the style guide failed: ${error.message}`); - return null; }); + if (!mountedRef.current) return; setAdopting(false); - if (!updated) return; - onAdopted?.(updated); - toast.success('Style guide adopted'); - onClose(); + // 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 ( @@ -153,10 +164,19 @@ export default function MoodBoardStyleSynthesis({ influences, locked, saved = false, - onAdopted, + 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 (