From 39aa2b9ca215a179292a807a1d8b0ad2488fb035 Mon Sep 17 00:00:00 2001 From: Spicy_Marinara Date: Mon, 10 Aug 2026 08:02:39 +0200 Subject: [PATCH 1/7] chore: start assigned issue sweep --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e188800c..55234f0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This file is the release-notes source of truth for Marinara Engine. Reuse these ### Added +- Added a Gallery image-agent picker that can run any active custom image-producing agent alongside the base Illustrator (#4846). - Added an editable Storyboard Agent shot-planner stage that inspects each generated keyframe before video generation, persists its suitability classification, and falls back to the planned motion when image-aware refinement is unavailable or invalid. The Storyboard Agent page now explains the four-stage prompt workflow and orders its shared prompt editors from illustration through image-aware grounding to video generation (#4839, Pasta-Devs/Marinara-Agents#296). - Added batch selection to Character and Persona image galleries so selected images can be downloaded or deleted together after confirmation (#4832). - Added a persisted Auto, Low, Medium, or High output-quality choice to GPT Image generation connections (#4831). @@ -46,6 +47,10 @@ This file is the release-notes source of truth for Marinara Engine. Reuse these ### Fixed +- Restored intuitive mobile swipe navigation for Conversation transcripts while preserving Roleplay swipes and interactive controls (#4841). +- Made Memory Recall re-vectorization exclusive with background chunking so embedding-model changes cannot leave mixed vector dimensions (#4843). +- Saved pending Lorebook vector settings before vectorization and surfaced provider or eligibility failures instead of reporting a misleading zero-vector success (#4844). +- Recovered expected sharded chat history from its preserved pre-shard backup when a restored profile contains no message shards (#4845). - Kept Professor Mari's Home navigator enabled by default and visible with reduced effects, reset her to the default position when re-enabled, centered her drag handle, contained compact Field Notes and Community actions, and presented iPad sidebars as full-width overlays (#4826, #4827, #4829, #4830). - Sharpened Professor Mari's read-only guardrail so a "how do I…" question is answered or offered rather than performed, even when it names the change as its goal (for example "how do I make X have Y") — while a plainly-worded request to make that change, including a polite question form like "can you set X to Y", is still carried out — so she keys on intent rather than grammar and no longer edits without a clear instruction (#4838). - Reduced background autonomous-message polling to a lightweight candidate-id lookup instead of re-fetching the full chat list every 30 seconds (#4715). From 263f1d8baeabb83da054f50147e6409547d6e999 Mon Sep 17 00:00:00 2001 From: Spicy_Marinara Date: Mon, 10 Aug 2026 08:33:45 +0200 Subject: [PATCH 2/7] fix: resolve assigned chat and vectorization issues --- e2e/core-flows.e2e.ts | 276 ++++++++++++++++++ package.json | 2 +- .../client/src/components/chat/ChatArea.tsx | 13 +- .../components/chat/ChatCommonOverlays.tsx | 3 + .../src/components/chat/ChatGallery.tsx | 91 ++++-- .../src/components/chat/ChatGalleryDrawer.tsx | 36 ++- .../components/chat/ChatRoleplaySurface.tsx | 3 + .../components/chat/ChatSettingsDrawer.tsx | 35 ++- .../src/components/chat/ConversationView.tsx | 1 + .../src/components/game/GameSurface.tsx | 3 + .../components/lorebooks/LorebookEditor.tsx | 11 +- .../client/src/localization/locales/en.json | 8 + packages/server/src/db/file-backed-store.ts | 50 +++- .../server/src/routes/lorebooks.routes.ts | 9 +- packages/server/src/services/memory-recall.ts | 47 ++- .../memory-recall-revectorize.regression.ts | 81 +++++ .../message-sharding.regression.ts | 67 +++++ 17 files changed, 686 insertions(+), 50 deletions(-) create mode 100644 scripts/regressions/memory-recall-revectorize.regression.ts diff --git a/e2e/core-flows.e2e.ts b/e2e/core-flows.e2e.ts index 21d68edfe..8a52b7446 100644 --- a/e2e/core-flows.e2e.ts +++ b/e2e/core-flows.e2e.ts @@ -4033,6 +4033,89 @@ test("empty focused chat composers keep keyboard swipe navigation", async ({ pag } }); +test("mobile transcript swipes navigate Conversation and Roleplay alternatives", async ({ page, request }, testInfo) => { + test.skip(!testInfo.project.name.includes("mobile"), "Touch swipe navigation is covered on mobile."); + + const fixtures: Array<{ chatId: string; messageId: string; first: string; second: string }> = []; + for (const mode of ["conversation", "roleplay"] as const) { + const chatResponse = await request.post("/api/chats", { + data: { name: `${mode} Touch Swipe Navigation`, mode, characterIds: [] }, + }); + expect(chatResponse.ok()).toBeTruthy(); + const chat = (await chatResponse.json()) as { id: string }; + const first = `${mode} first touch swipe.`; + const second = `${mode} second touch swipe.`; + const messageResponse = await request.post(`/api/chats/${chat.id}/messages`, { + data: { role: "assistant", content: first }, + }); + expect(messageResponse.ok()).toBeTruthy(); + const message = (await messageResponse.json()) as { id: string }; + const swipeResponse = await request.post(`/api/chats/${chat.id}/messages/${message.id}/swipes`, { + data: { content: second, silent: true }, + }); + expect(swipeResponse.ok()).toBeTruthy(); + fixtures.push({ chatId: chat.id, messageId: message.id, first, second }); + } + + const dispatchSwipe = async (target: Locator, direction: "left" | "right") => { + await target.evaluate((element, swipeDirection) => { + const startX = swipeDirection === "left" ? 180 : 40; + const endX = swipeDirection === "left" ? 40 : 180; + const touch = (clientX: number) => ({ identifier: 1, target: element, clientX, clientY: 120 }); + const touchList = (items: ReturnType[]) => + Object.assign(items, { item: (index: number) => items[index] ?? null }); + const start = new Event("touchstart", { bubbles: true, cancelable: true }); + Object.defineProperties(start, { + touches: { value: touchList([touch(startX)]) }, + changedTouches: { value: touchList([touch(startX)]) }, + }); + element.dispatchEvent(start); + const end = new Event("touchend", { bubbles: true, cancelable: true }); + Object.defineProperties(end, { + touches: { value: touchList([]) }, + changedTouches: { value: touchList([touch(endX)]) }, + }); + window.dispatchEvent(end); + }, direction); + }; + + try { + await page.addInitScript(() => { + localStorage.setItem( + "marinara-engine-ui", + JSON.stringify({ + state: { + intuitiveSwipeNavigation: true, + intuitiveSwipeRerollLatest: false, + }, + version: 87, + }), + ); + }); + + for (const fixture of fixtures) { + await page.goto("/"); + await page.evaluate((chatId) => localStorage.setItem("marinara-active-chat-id", chatId), fixture.chatId); + await page.reload(); + + const messageRow = page.locator(`[data-message-id="${fixture.messageId}"]`); + await expect(messageRow).toContainText(fixture.first); + await dispatchSwipe(messageRow, "left"); + await expect(messageRow).toContainText(fixture.second); + + const composer = page.locator('[data-chat-composer="true"]:visible'); + await composer.fill("Touching the composer must not navigate"); + await dispatchSwipe(composer, "right"); + await expect(messageRow).toContainText(fixture.second); + + await dispatchSwipe(messageRow, "right"); + await expect(messageRow).toContainText(fixture.first); + } + } finally { + await Promise.allSettled(fixtures.map((fixture) => request.delete(`/api/chats/${fixture.chatId}`))); + } +}); + test("typographic quotes do not pull the Roleplay caret behind later text", async ({ page }, testInfo) => { test.skip(!testInfo.project.name.includes("desktop"), "Roleplay quote caret behavior is covered on desktop."); @@ -5560,6 +5643,127 @@ test("Roleplay Active Context shows rich lorebook activation provenance", async } }); +test("Gallery Illustrate offers active custom image agents", async ({ page, request }, testInfo) => { + test.skip(!testInfo.project.name.includes("desktop"), "Gallery image-agent selection is covered on desktop."); + + const suffix = Date.now().toString(36); + const activeAgentName = `Gallery Image Agent ${suffix}`; + const inactiveAgentName = `Inactive Gallery Agent ${suffix}`; + const createdAgentIds: string[] = []; + let chatId: string | null = null; + + try { + const agents: Array<{ id: string; type: string; name: string }> = []; + for (const [name, type] of [ + [activeAgentName, `gallery-image-agent-${suffix}`], + [inactiveAgentName, `gallery-inactive-agent-${suffix}`], + ] as const) { + const response = await request.post("/api/agents", { + data: { + type, + name, + description: "Gallery image-agent selector regression fixture.", + phase: "post_processing", + connectionId: null, + promptTemplate: "Return an image prompt.", + settings: { + resultType: "image_prompt", + customCapabilities: { trigger_image_generation: true }, + }, + }, + }); + expect(response.ok()).toBeTruthy(); + const agent = (await response.json()) as { id: string; type: string; name: string }; + createdAgentIds.push(agent.id); + agents.push(agent); + } + + const chatResponse = await request.post("/api/chats", { + data: { name: `Gallery Image Agent Smoke ${suffix}`, mode: "roleplay", characterIds: [] }, + }); + expect(chatResponse.ok()).toBeTruthy(); + const chat = (await chatResponse.json()) as { id: string }; + chatId = chat.id; + const metadataResponse = await request.patch(`/api/chats/${chat.id}/metadata`, { + data: { enableAgents: true, activeAgentIds: ["illustrator", agents[0]!.type] }, + }); + expect(metadataResponse.ok()).toBeTruthy(); + + await page.route("**/api/capability-packages/installed", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: "illustrator", + version: "1.0.0", + manifest: { + schemaVersion: 1, + id: "illustrator", + name: "Illustrator", + version: "1.0.0", + description: "Gallery image generation fixture.", + engine: { min: "2.0.0", maxExclusive: "3.0.0" }, + kind: ["agent"], + entrypoints: { agents: "agents.json" }, + files: [{ path: "agents.json", sha256: "0".repeat(64), bytes: 1 }], + permissions: ["agent-runtime"], + restartRequired: false, + }, + installedAt: "2026-01-01T00:00:00.000Z", + status: "active", + error: null, + readiness: "ready", + readinessError: null, + legacy: false, + }, + ]), + }); + }); + await page.route("**/api/capability-packages/agents", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: "illustrator", + name: "Illustrator", + description: "Generates visual scene prompts and images.", + author: "Pasta Devs", + phase: "post_processing", + execution: "feature", + enabledByDefault: false, + category: "misc", + modeAllowlist: ["roleplay", "game"], + defaultPromptTemplate: "Return a scene image prompt.", + }, + ]), + }); + }); + + await page.addInitScript((activeChatId) => { + localStorage.setItem("marinara-active-chat-id", activeChatId); + }, chat.id); + await page.goto("/"); + + const galleryButton = page.getByRole("button", { name: "Gallery", exact: true }).filter({ visible: true }); + await galleryButton.click(); + const drawer = page.locator(".mari-chat-gallery-drawer"); + const illustrateButton = drawer.getByRole("button", { name: "Illustrate", exact: true }); + await expect(illustrateButton).toBeVisible(); + await expect(illustrateButton).toHaveAttribute("aria-haspopup", "menu"); + await illustrateButton.click(); + + const menu = drawer.getByRole("menu", { name: "Choose an image agent" }); + await expect(menu.getByRole("menuitem", { name: "Illustrator", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: activeAgentName, exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: inactiveAgentName, exact: true })).toHaveCount(0); + } finally { + if (chatId) await request.delete(`/api/chats/${chatId}`).catch(() => undefined); + await Promise.all(createdAgentIds.map((agentId) => request.delete(`/api/agents/${agentId}`).catch(() => undefined))); + } +}); + test("chat toolbar panels close when their trigger is clicked again across modes", async ({ page, request, @@ -12307,6 +12511,78 @@ test("Professor Mari dependency and sensitive-file reviews stay explicit across await expect.poll(() => window.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true); }); +test("Lorebook vectorization saves pending eligibility settings first", async ({ page, request }, testInfo) => { + test.skip(testInfo.project.name.includes("mobile"), "Desktop Lorebook vector controls are covered here."); + + const suffix = Date.now().toString(36); + const lorebookName = `Lorebook vector save ${suffix}`; + const connectionName = `Lorebook embedding ${suffix}`; + let lorebookId: string | null = null; + let connectionId: string | null = null; + let excludedAtVectorization: boolean | null = null; + + try { + const connectionResponse = await request.post("/api/connections", { + data: { + name: connectionName, + provider: "custom", + baseUrl: "http://127.0.0.1:1/v1", + embeddingModel: "e2e-embedding-model", + }, + }); + expect(connectionResponse.ok()).toBeTruthy(); + const connection = (await connectionResponse.json()) as { id: string }; + connectionId = connection.id; + + const lorebookResponse = await request.post("/api/lorebooks", { + data: { + name: lorebookName, + description: "Pending vector eligibility regression fixture.", + category: "world", + enabled: true, + excludeFromVectorization: true, + }, + }); + expect(lorebookResponse.ok()).toBeTruthy(); + const lorebook = (await lorebookResponse.json()) as { id: string }; + lorebookId = lorebook.id; + + const entryResponse = await request.post(`/api/lorebooks/${lorebook.id}/entries`, { + data: { name: "Vector entry", content: "A vector-ready archive entry.", keys: ["archive"] }, + }); + expect(entryResponse.ok()).toBeTruthy(); + + await page.route(`**/api/lorebooks/${lorebook.id}/vectorize`, async (route) => { + const savedResponse = await request.get(`/api/lorebooks/${lorebook.id}`); + expect(savedResponse.ok()).toBeTruthy(); + excludedAtVectorization = ((await savedResponse.json()) as { excludeFromVectorization?: boolean }) + .excludeFromVectorization ?? null; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ vectorized: 1, total: 1, skipped: 0 }), + }); + }); + + await page.goto("/"); + await page.locator('[data-tour="panel-lorebooks"]').click(); + await page.getByText(lorebookName, { exact: true }).click(); + await page.getByRole("checkbox", { name: "Enable lorebook vectors" }).evaluate((element) => { + (element as HTMLInputElement).click(); + }); + + const vectorPanel = page.locator(".mari-editor-panel").filter({ hasText: "Semantic Search (Embeddings)" }); + await vectorPanel.locator("select").selectOption(connection.id); + await vectorPanel.getByRole("button", { name: "Vectorize 1 missing", exact: true }).click(); + + await expect.poll(() => excludedAtVectorization).toBe(false); + await expect(vectorPanel.getByText("Vectorized 1 missing entries", { exact: true })).toBeVisible(); + } finally { + if (lorebookId) await request.delete(`/api/lorebooks/${lorebookId}`).catch(() => undefined); + if (connectionId) await request.delete(`/api/connections/${connectionId}`).catch(() => undefined); + } +}); + test("Lorebook Save keeps Overview stable while the updated detail cache settles", async ({ page }, testInfo) => { test.skip(testInfo.project.name.includes("mobile"), "Desktop editor regression"); diff --git a/package.json b/package.json index a45a62286..85ad55417 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "regression:agent-registry-hydration": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-registry-hydration.regression.ts", "regression:mari-review-durability": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/mari-db-review-durability.regression.ts", "regression:mari-preset-granular": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/mari-preset-granular.regression.ts", - "regression:issues": "pnpm regression:card-library-search && pnpm regression:chat-resource-drop && pnpm regression:avatar-delete && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/chat-html-newlines.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/open-issues.regression.ts && pnpm regression:mari-review-durability && pnpm regression:mari-preset-granular && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/bot-browser-route.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/janny-character-import.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-activation.regression.ts && pnpm regression:agent-registry-hydration && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/profile-import-noodle.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/notification-sound.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-catalog-kind-badges.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/file-backed-shutdown.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/autonomous-scheduler-gate.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/message-sharding.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/env-watcher.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/extension-ipc.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/capability-package-lifecycle.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/capability-client-version-refresh.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/message-page-cache.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/chat-branch-lineage.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/conversation-game-slash.regression.ts && node ./scripts/regressions/launcher-env.regression.mjs && node ./scripts/regressions/launcher-update.regression.mjs && node ./scripts/regressions/launcher-format-guard.regression.mjs && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/scene-video-range.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-prompt-context.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-documentation.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-fetch-tiers.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/entity-embedding-store.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-fetch-integration.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-relevance-names.regression.ts", + "regression:issues": "pnpm regression:card-library-search && pnpm regression:chat-resource-drop && pnpm regression:avatar-delete && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/chat-html-newlines.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/open-issues.regression.ts && pnpm regression:mari-review-durability && pnpm regression:mari-preset-granular && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/bot-browser-route.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/janny-character-import.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-activation.regression.ts && pnpm regression:agent-registry-hydration && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/profile-import-noodle.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/notification-sound.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-catalog-kind-badges.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/file-backed-shutdown.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/autonomous-scheduler-gate.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/message-sharding.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/memory-recall-revectorize.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/env-watcher.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/extension-ipc.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/capability-package-lifecycle.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/capability-client-version-refresh.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/message-page-cache.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/chat-branch-lineage.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/conversation-game-slash.regression.ts && node ./scripts/regressions/launcher-env.regression.mjs && node ./scripts/regressions/launcher-update.regression.mjs && node ./scripts/regressions/launcher-format-guard.regression.mjs && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/scene-video-range.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-prompt-context.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-documentation.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-fetch-tiers.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/entity-embedding-store.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-fetch-integration.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/professor-mari-relevance-names.regression.ts", "regression:card-library-search": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/card-library-search.regression.ts", "regression:chat-resource-drop": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/chat-resource-drop.regression.ts", "regression:gallery-delete": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/gallery-cascade-deletion.regression.ts", diff --git a/packages/client/src/components/chat/ChatArea.tsx b/packages/client/src/components/chat/ChatArea.tsx index dfe80e6b5..d1b471aef 100644 --- a/packages/client/src/components/chat/ChatArea.tsx +++ b/packages/client/src/components/chat/ChatArea.tsx @@ -295,6 +295,12 @@ const shouldIgnoreIntuitiveSwipeTarget = ( ); }; +function closestChatScrollSurface(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Node)) return null; + const element = target instanceof Element ? target : target.parentElement; + return element?.closest("[data-chat-scroll]") ?? null; +} + type AgentInjectionReviewItem = { agentType: string; agentName: string; @@ -2266,13 +2272,11 @@ export const ChatArea = memo(function ChatArea() { if (!intuitiveSwipeNavigation || intuitiveSwipeBlocked) return; const handleTouchStart = (event: TouchEvent) => { - const surface = scrollRef.current; const target = event.target; + const surface = closestChatScrollSurface(target); if ( event.touches.length !== 1 || !surface || - !(target instanceof Node) || - !surface.contains(target) || shouldIgnoreIntuitiveSwipeTarget(target) ) { intuitiveTouchStartRef.current = null; @@ -3111,6 +3115,9 @@ export const ChatArea = memo(function ChatArea() { illustratorRetryTargets: ["illustration"], }) } + onIllustrateWithAgent={async (agentType) => { + await retryAgents(activeChatId, [agentType], { forceImageGeneration: true }); + }} onGenerateBackground={handleGenerateRoleplayBackground} onGenerateVideo={() => handleGenerateRoleplaySceneVideo()} onAnimateImage={(image) => handleGenerateRoleplaySceneVideo({ galleryImageId: image.id })} diff --git a/packages/client/src/components/chat/ChatCommonOverlays.tsx b/packages/client/src/components/chat/ChatCommonOverlays.tsx index 4604ef197..5598d4022 100644 --- a/packages/client/src/components/chat/ChatCommonOverlays.tsx +++ b/packages/client/src/components/chat/ChatCommonOverlays.tsx @@ -253,6 +253,7 @@ type ChatCommonOverlaysProps = { onOpenScheduleEditor?: (characterId: string, options?: { initialDay?: string | null }) => void; /** Manually trigger the Illustrator agent */ onIllustrate?: () => void; + onIllustrateWithAgent?: (agentType: string) => void | Promise; /** Generate an on-demand Conversation selfie. */ onGenerateSelfie?: (characterId?: string) => void | Promise; selfieCharacters?: Array<{ id: string; name: string }>; @@ -299,6 +300,7 @@ export function ChatCommonOverlays({ onCloseGallery, onOpenScheduleEditor, onIllustrate, + onIllustrateWithAgent, onGenerateSelfie, selfieCharacters, onGenerateBackground, @@ -348,6 +350,7 @@ export function ChatCommonOverlays({ onClose={onCloseGallery} anchor={galleryAnchor} onIllustrate={onIllustrate} + onIllustrateWithAgent={onIllustrateWithAgent} onGenerateSelfie={onGenerateSelfie} selfieCharacters={selfieCharacters} onGenerateStoryboard={onGenerateStoryboard} diff --git a/packages/client/src/components/chat/ChatGallery.tsx b/packages/client/src/components/chat/ChatGallery.tsx index b01c72388..bb88461dc 100644 --- a/packages/client/src/components/chat/ChatGallery.tsx +++ b/packages/client/src/components/chat/ChatGallery.tsx @@ -20,6 +20,8 @@ import { PanelsTopLeft, Copy, Check, + Bot, + ChevronDown, } from "lucide-react"; import { useChatAssetBrowser, @@ -51,6 +53,8 @@ interface ChatGalleryProps { mode?: string; /** Manually trigger the Illustrator agent */ onIllustrate?: () => void | Promise; + illustrateAgents?: Array<{ id: string; name: string }>; + onIllustrateWithAgent?: (agentType: string) => void | Promise; /** Generate an on-demand Conversation selfie. */ onGenerateSelfie?: (characterId?: string) => void | Promise; selfieCharacters?: Array<{ id: string; name: string }>; @@ -86,6 +90,8 @@ export function ChatGallery({ chatId, mode, onIllustrate, + illustrateAgents = [], + onIllustrateWithAgent, onGenerateSelfie, selfieCharacters = [], onGenerateBackground, @@ -110,6 +116,7 @@ export function ChatGallery({ const [copiedPromptImageId, setCopiedPromptImageId] = useState(null); const [activeTab, setActiveTab] = useState("images"); const [selectedSelfieCharacterId, setSelectedSelfieCharacterId] = useState(""); + const [illustrateMenuOpen, setIllustrateMenuOpen] = useState(false); const copyResetTimerRef = useRef(null); const isIllustrating = useGalleryStore((s) => s.illustratingChatIds.has(chatId)); const isGeneratingSelfie = useGalleryStore((s) => s.selfieGeneratingChatIds.has(chatId)); @@ -184,12 +191,14 @@ export function ChatGallery({ }); }; - const handleIllustrate = async () => { - if (!onIllustrate || useGalleryStore.getState().illustratingChatIds.has(chatId)) return; + const handleIllustrate = async (agentType?: string) => { + const illustrate = agentType ? () => onIllustrateWithAgent?.(agentType) : onIllustrate; + if (!illustrate || useGalleryStore.getState().illustratingChatIds.has(chatId)) return; + setIllustrateMenuOpen(false); setChatIllustrating(chatId, true); try { - await onIllustrate(); + await illustrate(); } catch (error) { toast.error(error instanceof Error ? error.message :localizeUi("ui.chat.chatgallery.imageGenerationFailed")); } finally { @@ -357,8 +366,9 @@ export function ChatGallery({ [chatId, localizeUi], ); + const canIllustrate = Boolean(onIllustrate || (onIllustrateWithAgent && illustrateAgents.length > 0)); const actionCount = [ - onIllustrate, + canIllustrate, onGenerateSelfie, onGenerateStoryboard, sceneVideosEnabled && onGenerateVideo, @@ -387,27 +397,68 @@ export function ChatGallery({ return ( <>
- {(onIllustrate || + {(canIllustrate || onGenerateSelfie || onGenerateStoryboard || (sceneVideosEnabled && onGenerateVideo) || onGenerateBackground) && (
- {onIllustrate && ( - + {illustrateMenuOpen && ( +
+ {onIllustrate ? ( + + ) : null} + {illustrateAgents.map((agent) => ( + + ))} +
)} - {isIllustrating ?localizeUi("ui.chat.summarypopover.generating") :localizeUi("ui.chat.chatgallery.illustrate")} - +
)} {onGenerateSelfie && (
@@ -672,7 +723,7 @@ export function ChatGallery({

{localizeUi("ui.chat.chatgallery.noImagesYet")}

- {onIllustrate + {canIllustrate ?localizeUi("ui.chat.chatgallery.uploadImagesOrGenerateIllustrationsToBuildYourGallery") :localizeUi("ui.chat.chatgallery.uploadImagesToBuildYourGallery")}

diff --git a/packages/client/src/components/chat/ChatGalleryDrawer.tsx b/packages/client/src/components/chat/ChatGalleryDrawer.tsx index 8557054c0..162697f34 100644 --- a/packages/client/src/components/chat/ChatGalleryDrawer.tsx +++ b/packages/client/src/components/chat/ChatGalleryDrawer.tsx @@ -13,9 +13,16 @@ import { ROLEPLAY_POPOVER_SHELL, ROLEPLAY_POPOVER_TITLE, } from "./roleplay-popover-styles"; -import type { Chat } from "@marinara-engine/shared"; +import { + BUILT_IN_AGENTS, + customAgentHasCapability, + isAgentConfigDeleted, + parseAgentSettingsRecord, + type Chat, +} from "@marinara-engine/shared"; import type { ChatImage } from "../../hooks/use-gallery"; -import { useInstalledCapabilityPackages } from "../../hooks/use-capability-packages"; +import { useAgentConfigs } from "../../hooks/use-agents"; +import { useCapabilityAgentRegistry, useInstalledCapabilityPackages } from "../../hooks/use-capability-packages"; import { isDesktopShellNavigationTarget } from "../../lib/chat-floating-ui-events"; import { parseChatMetadata } from "../../lib/chat-display"; import { @@ -32,6 +39,8 @@ interface ChatGalleryDrawerProps { anchor?: ChatToolbarFloatingPanelAnchor; /** Manually trigger the Illustrator agent */ onIllustrate?: () => void | Promise; + /** Manually trigger an active custom image-generation agent. */ + onIllustrateWithAgent?: (agentType: string) => void | Promise; /** Generate an on-demand Conversation selfie. */ onGenerateSelfie?: (characterId?: string) => void | Promise; selfieCharacters?: Array<{ id: string; name: string }>; @@ -58,6 +67,7 @@ export function ChatGalleryDrawer({ onClose, anchor, onIllustrate, + onIllustrateWithAgent, onGenerateSelfie, selfieCharacters, onGenerateBackground, @@ -73,6 +83,8 @@ export function ChatGalleryDrawer({ [chat.metadata], ); const { data: installedCapabilities = [] } = useInstalledCapabilityPackages(open); + const { data: capabilityAgents = [] } = useCapabilityAgentRegistry(); + const { data: agentConfigs = [] } = useAgentConfigs(open); const illustratorInstalled = installedCapabilities.some( (item) => item.id === "illustrator" && item.status === "active", ); @@ -89,6 +101,24 @@ export function ChatGalleryDrawer({ : chatMetadata.enableAgents === true && chatMetadata.activeAgentIds?.includes("illustrator"); const illustratorAvailable = illustratorInstalled && illustratorEnabledForChat; + const customImageAgents = useMemo(() => { + if (chatMetadata.enableAgents !== true || !onIllustrateWithAgent) return []; + const activeAgentIds = new Set(chatMetadata.activeAgentIds ?? []); + const reservedAgentIds = new Set([ + ...BUILT_IN_AGENTS.map((agent) => agent.id), + ...capabilityAgents.map((agent) => agent.id), + ]); + return agentConfigs + .filter( + (agent) => + activeAgentIds.has(agent.type) && + !reservedAgentIds.has(agent.type) && + !isAgentConfigDeleted(agent.settings) && + customAgentHasCapability(parseAgentSettingsRecord(agent.settings), "trigger_image_generation"), + ) + .map((agent) => ({ id: agent.type, name: agent.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [agentConfigs, capabilityAgents, chatMetadata.activeAgentIds, chatMetadata.enableAgents, onIllustrateWithAgent]); useEffect(() => { if (!open || typeof document === "undefined") return; @@ -147,6 +177,8 @@ export function ChatGalleryDrawer({ chatId={chat.id} mode={chat.mode} onIllustrate={illustratorAvailable ? onIllustrate : undefined} + illustrateAgents={customImageAgents} + onIllustrateWithAgent={onIllustrateWithAgent} onGenerateSelfie={illustratorAvailable ? onGenerateSelfie : undefined} selfieCharacters={selfieCharacters} onGenerateStoryboard={onGenerateStoryboard} diff --git a/packages/client/src/components/chat/ChatRoleplaySurface.tsx b/packages/client/src/components/chat/ChatRoleplaySurface.tsx index eb68745b5..e721421e1 100644 --- a/packages/client/src/components/chat/ChatRoleplaySurface.tsx +++ b/packages/client/src/components/chat/ChatRoleplaySurface.tsx @@ -1207,6 +1207,7 @@ type RoleplaySurfaceProps = { onCloseSettings: () => void; onCloseGallery: () => void; onIllustrate?: () => void; + onIllustrateWithAgent?: (agentType: string) => void | Promise; onGenerateBackground?: () => void | Promise; onGenerateVideo?: () => void | Promise; onAnimateImage?: (image: ChatImage) => void | Promise; @@ -1322,6 +1323,7 @@ export function ChatRoleplaySurface({ onCloseSettings, onCloseGallery, onIllustrate, + onIllustrateWithAgent, onGenerateBackground, onGenerateVideo, onAnimateImage, @@ -2368,6 +2370,7 @@ export function ChatRoleplaySurface({ onCloseGallery={onCloseGallery} onOpenScheduleEditor={onOpenScheduleEditor} onIllustrate={onIllustrate} + onIllustrateWithAgent={onIllustrateWithAgent} onGenerateStoryboard={ storyboardAgentActive && latestStoryboardMessage && !generateRoleplayStoryboard.isPending ? handleGenerateRoleplayStoryboard diff --git a/packages/client/src/components/chat/ChatSettingsDrawer.tsx b/packages/client/src/components/chat/ChatSettingsDrawer.tsx index 776c9cd3c..3c5656e1c 100644 --- a/packages/client/src/components/chat/ChatSettingsDrawer.tsx +++ b/packages/client/src/components/chat/ChatSettingsDrawer.tsx @@ -9674,6 +9674,33 @@ function MemoryRecallMemoriesModal({ if (ok) clearMemories.mutate(); }; + const handleRevectorize = async () => { + if (memories.length > 0) { + const confirmed = await showConfirmDialog({ + title: localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizeAllMemories"), + message: localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizeAllMemoriesDescription"), + confirmLabel: localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizeAll"), + tone: "default", + }); + if (!confirmed) return; + } + + try { + const result = await refreshMemories.mutateAsync(); + toast.success( + localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizedValue1MemoryChunks", { + value1: result.rebuilt, + }), + ); + } catch (error) { + toast.error( + error instanceof Error + ? localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizationFailedValue1", { value1: error.message }) + : localizeUi("ui.chat.memoryrecallmemoriesmodal.reVectorizationFailed"), + ); + } + }; + return (
)} {/* Header */}
-
@@ -2882,7 +2892,6 @@ function VectorizeSection({ const handleVectorize = async (mode: "missing" | "all") => { if (!selectedConnectionId) return; if (mode === "missing" && missingCount === 0) return; - if (hasUnsavedChanges && !(await onBeforeVectorize())) return; const conn = embeddingConnections.find((c) => c.id === selectedConnectionId); if (mode === "all" && storedVectorCount > 0) { const confirmed = await showConfirmDialog({ @@ -2897,6 +2906,7 @@ function VectorizeSection({ }); if (!confirmed) return; } + if (hasUnsavedChanges && !(await onBeforeVectorize())) return; setVectorizingMode(mode); setResult(null); From 3262e336bb72824132518e56d4833d5157c00880 Mon Sep 17 00:00:00 2001 From: Spicy_Marinara Date: Mon, 10 Aug 2026 09:19:51 +0200 Subject: [PATCH 7/7] fix: serialize Lorebook vectorization flow --- e2e/core-flows.e2e.ts | 2 + .../components/lorebooks/LorebookEditor.tsx | 75 +++++++++++-------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/e2e/core-flows.e2e.ts b/e2e/core-flows.e2e.ts index 48df710a2..be5c99eba 100644 --- a/e2e/core-flows.e2e.ts +++ b/e2e/core-flows.e2e.ts @@ -12646,11 +12646,13 @@ test("Lorebook vectorization saves pending eligibility settings first", async ({ await expect(revectorizeButton).toBeVisible(); await vectorPanel.locator("label").filter({ hasText: "Query Messages" }).locator("input").fill("9"); const saveCountBeforeCancel = saveRequestCount; + const vectorizeCountBeforeCancel = vectorizeRequestCount; await revectorizeButton.click(); const revectorizeDialog = page.getByRole("dialog").filter({ hasText: "Re-vectorize All Entries" }); await expect(revectorizeDialog).toBeVisible(); await revectorizeDialog.getByRole("button", { name: "Cancel", exact: true }).click(); expect(saveRequestCount).toBe(saveCountBeforeCancel); + expect(vectorizeRequestCount).toBe(vectorizeCountBeforeCancel); await vectorPanel.locator("label").filter({ hasText: "Query Messages" }).locator("input").fill("8"); await page.locator(".mari-editor-header").getByRole("button").first().click(); diff --git a/packages/client/src/components/lorebooks/LorebookEditor.tsx b/packages/client/src/components/lorebooks/LorebookEditor.tsx index 8c5208c64..e14d995a0 100644 --- a/packages/client/src/components/lorebooks/LorebookEditor.tsx +++ b/packages/client/src/components/lorebooks/LorebookEditor.tsx @@ -2828,6 +2828,7 @@ function VectorizeSection({ ); const [selectedConnectionId, setSelectedConnectionId] = useState(""); const [vectorizingMode, setVectorizingMode] = useState<"missing" | "all" | null>(null); + const vectorizeInFlightRef = useRef(false); const [clearingVectors, setClearingVectors] = useState(false); const [result, setResult] = useState<{ success: boolean; message: string } | null>(null); const excludedCount = excludeFromVectorization @@ -2892,41 +2893,49 @@ function VectorizeSection({ const handleVectorize = async (mode: "missing" | "all") => { if (!selectedConnectionId) return; if (mode === "missing" && missingCount === 0) return; - const conn = embeddingConnections.find((c) => c.id === selectedConnectionId); - if (mode === "all" && storedVectorCount > 0) { - const confirmed = await showConfirmDialog({ - title:localizeUi("ui.lorebooks.vectorizesection.reVectorizeAllEntries"), - message: localizeUi("ui.lorebooks.vectorizesection.reVectorizeAllEntriesWithConnection", { - count: vectorizableEntryCount, - connection: conn?.name ?? localizeUi("ui.lorebooks.vectorizesection.theSelectedConnection"), - }), - confirmLabel:localizeUi("ui.lorebooks.vectorizesection.reVectorizeAll"), - cancelLabel: "Cancel", - tone: "default", - }); - if (!confirmed) return; - } - if (hasUnsavedChanges && !(await onBeforeVectorize())) return; - - setVectorizingMode(mode); - setResult(null); + if (vectorizeInFlightRef.current) return; + vectorizeInFlightRef.current = true; try { - const res = await api.post(`/lorebooks/${lorebookId}/vectorize`, { - connectionId: selectedConnectionId, - model: conn?.embeddingModel ?? "", - onlyMissing: mode === "missing", - }); - const data = res as { vectorized: number; total?: number; skipped?: number }; - await queryClient.invalidateQueries({ queryKey: lorebookKeys.entries(lorebookId) }); - setResult({ - success: true, - message: - mode === "all" ? `Re-vectorized ${data.vectorized} entries` : `Vectorized ${data.vectorized} missing entries`, - }); - } catch (err) { - setResult({ success: false, message: err instanceof Error ? err.message : "Vectorization failed" }); + const conn = embeddingConnections.find((c) => c.id === selectedConnectionId); + if (mode === "all" && storedVectorCount > 0) { + const confirmed = await showConfirmDialog({ + title:localizeUi("ui.lorebooks.vectorizesection.reVectorizeAllEntries"), + message: localizeUi("ui.lorebooks.vectorizesection.reVectorizeAllEntriesWithConnection", { + count: vectorizableEntryCount, + connection: conn?.name ?? localizeUi("ui.lorebooks.vectorizesection.theSelectedConnection"), + }), + confirmLabel:localizeUi("ui.lorebooks.vectorizesection.reVectorizeAll"), + cancelLabel: "Cancel", + tone: "default", + }); + if (!confirmed) return; + } + if (hasUnsavedChanges && !(await onBeforeVectorize())) return; + + setVectorizingMode(mode); + setResult(null); + try { + const res = await api.post(`/lorebooks/${lorebookId}/vectorize`, { + connectionId: selectedConnectionId, + model: conn?.embeddingModel ?? "", + onlyMissing: mode === "missing", + }); + const data = res as { vectorized: number; total?: number; skipped?: number }; + await queryClient.invalidateQueries({ queryKey: lorebookKeys.entries(lorebookId) }); + setResult({ + success: true, + message: + mode === "all" + ? `Re-vectorized ${data.vectorized} entries` + : `Vectorized ${data.vectorized} missing entries`, + }); + } catch (err) { + setResult({ success: false, message: err instanceof Error ? err.message : "Vectorization failed" }); + } finally { + setVectorizingMode(null); + } } finally { - setVectorizingMode(null); + vectorizeInFlightRef.current = false; } };