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). diff --git a/e2e/core-flows.e2e.ts b/e2e/core-flows.e2e.ts index 21d68edfe..be5c99eba 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,131 @@ test("Roleplay Active Context shows rich lorebook activation provenance", async } }); +test("Gallery Illustrate offers active custom image agents", async ({ page, request }, testInfo) => { + const mobile = testInfo.project.name.includes("mobile"); + 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("/"); + if (mobile) await page.getByRole("button", { name: "More options", exact: true }).click(); + + 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); + await drawer + .getByRole("searchbox", { name: "Search gallery images", exact: true }) + .dispatchEvent("pointerdown"); + await expect(menu).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 +12515,167 @@ 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; + let vectorizeRequestCount = 0; + + 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(); + const entry = (await entryResponse.json()) as { id: string }; + + const firstSaveStarted = createDeferred(); + const releaseFirstSave = createDeferred(); + const closeSaveStarted = createDeferred(); + const releaseCloseSave = createDeferred(); + let delayFirstSave = true; + let delayCloseSave = false; + let saveRequestCount = 0; + let reportStoredVector = false; + await page.route(`**/api/lorebooks/${lorebook.id}`, async (route) => { + if (route.request().method() !== "PATCH") { + await route.continue(); + return; + } + saveRequestCount += 1; + if (delayCloseSave) { + delayCloseSave = false; + closeSaveStarted.resolve(); + await releaseCloseSave.promise; + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Deliberate save failure" }), + }); + return; + } + if (delayFirstSave) { + delayFirstSave = false; + firstSaveStarted.resolve(); + await releaseFirstSave.promise; + } + await route.continue(); + }); + + await page.route(`**/api/lorebooks/${lorebook.id}/entries`, async (route) => { + if (route.request().method() !== "GET" || !reportStoredVector) { + await route.continue(); + return; + } + const response = await route.fetch(); + const entries = (await response.json()) as Array>; + await route.fulfill({ + response, + json: entries.map((candidate) => (candidate.id === entry.id ? { ...candidate, embedding: [0.1, 0.2] } : candidate)), + }); + }); + + await page.route(`**/api/lorebooks/${lorebook.id}/vectorize`, async (route) => { + vectorizeRequestCount += 1; + const savedResponse = await request.get(`/api/lorebooks/${lorebook.id}`); + expect(savedResponse.ok()).toBeTruthy(); + excludedAtVectorization = ((await savedResponse.json()) as { excludeFromVectorization?: boolean }) + .excludeFromVectorization ?? null; + reportStoredVector = true; + 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); + const vectorizeButton = vectorPanel.getByRole("button", { name: "Vectorize 1 missing", exact: true }); + const firstVectorizeAttempt = vectorizeButton.click(); + await firstSaveStarted.promise; + await vectorPanel.locator("label").filter({ hasText: "Query Messages" }).locator("input").fill("7"); + releaseFirstSave.resolve(); + await firstVectorizeAttempt; + await expect(vectorizeButton).toBeEnabled(); + expect(vectorizeRequestCount).toBe(0); + + await vectorizeButton.click(); + + await expect.poll(() => excludedAtVectorization).toBe(false); + expect(vectorizeRequestCount).toBe(1); + await expect(vectorPanel.getByText("Vectorized 1 missing entries", { exact: true })).toBeVisible(); + + const revectorizeButton = vectorPanel.getByRole("button", { name: "Re-vectorize 1 entries", exact: true }); + 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(); + const unsavedWarning = page.getByText("You have unsaved changes", { exact: true }); + await expect(unsavedWarning).toBeVisible(); + const discardCloseButton = page.getByRole("button", { name: "Discard & close", exact: true }); + const saveCloseButton = page.getByRole("button", { name: "Save & close", exact: true }); + const backButton = page.locator(".mari-editor-header").getByRole("button").first(); + delayCloseSave = true; + await saveCloseButton.click(); + await closeSaveStarted.promise; + await expect(discardCloseButton).toBeDisabled(); + await expect(saveCloseButton).toBeDisabled(); + await expect(backButton).toBeDisabled(); + releaseCloseSave.resolve(); + await expect(unsavedWarning).toBeVisible(); + await expect(page.locator(".mari-editor-header").getByText(lorebookName, { 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..37346f5eb 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,8 @@ export function ChatGallery({ const [copiedPromptImageId, setCopiedPromptImageId] = useState(null); const [activeTab, setActiveTab] = useState("images"); const [selectedSelfieCharacterId, setSelectedSelfieCharacterId] = useState(""); + const [illustrateMenuOpen, setIllustrateMenuOpen] = useState(false); + const illustrateMenuRef = useRef(null); const copyResetTimerRef = useRef(null); const isIllustrating = useGalleryStore((s) => s.illustratingChatIds.has(chatId)); const isGeneratingSelfie = useGalleryStore((s) => s.selfieGeneratingChatIds.has(chatId)); @@ -138,6 +146,19 @@ export function ChatGallery({ ), ); }, [assetItems, assetSearch]); + useEffect(() => { + if (!illustrateMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && illustrateMenuRef.current?.contains(target)) return; + setIllustrateMenuOpen(false); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [illustrateMenuOpen]); + useEffect(() => { return () => { if (copyResetTimerRef.current !== null) { @@ -184,12 +205,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 +380,9 @@ export function ChatGallery({ [chatId, localizeUi], ); + const canIllustrate = Boolean(onIllustrate || (onIllustrateWithAgent && illustrateAgents.length > 0)); const actionCount = [ - onIllustrate, + canIllustrate, onGenerateSelfie, onGenerateStoryboard, sceneVideosEnabled && onGenerateVideo, @@ -387,27 +411,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 +737,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..721a011a6 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(open); + 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 */}
-
@@ -2281,6 +2300,8 @@ export function LorebookEditor() { vectorQueryDepth={formVectorQueryDepth} vectorScoreThreshold={formVectorScoreThreshold} vectorMaxResults={formVectorMaxResults} + hasUnsavedChanges={lorebookDirty} + onBeforeVectorize={handleSaveLorebook} onVectorQueryDepthChange={(value) => { setFormVectorQueryDepth(value); markLorebookDirty(); @@ -2760,6 +2781,8 @@ function VectorizeSection({ vectorQueryDepth, vectorScoreThreshold, vectorMaxResults, + hasUnsavedChanges, + onBeforeVectorize, onVectorQueryDepthChange, onVectorScoreThresholdChange, onVectorMaxResultsChange, @@ -2770,6 +2793,8 @@ function VectorizeSection({ vectorQueryDepth: number; vectorScoreThreshold: number; vectorMaxResults: number; + hasUnsavedChanges: boolean; + onBeforeVectorize: () => Promise; onVectorQueryDepthChange: (value: number) => void; onVectorScoreThresholdChange: (value: number) => void; onVectorMaxResultsChange: (value: number) => void; @@ -2803,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 @@ -2867,40 +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; - } - - 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; } }; diff --git a/packages/client/src/hooks/use-capability-packages.ts b/packages/client/src/hooks/use-capability-packages.ts index 5077cabf2..1175f6891 100644 --- a/packages/client/src/hooks/use-capability-packages.ts +++ b/packages/client/src/hooks/use-capability-packages.ts @@ -34,7 +34,7 @@ export function useCapabilityCatalog(enabled = true) { }); } -export function useCapabilityAgentRegistry() { +export function useCapabilityAgentRegistry(enabled = true) { const query = useQuery({ queryKey: capabilityPackageKeys.agents(), queryFn: async () => { @@ -45,6 +45,7 @@ export function useCapabilityAgentRegistry() { replaceBuiltInAgentDefinitions(agents); return agents; }, + enabled, }); return query; } diff --git a/packages/client/src/localization/locales/en.json b/packages/client/src/localization/locales/en.json index fd9c0cb88..8a093232a 100644 --- a/packages/client/src/localization/locales/en.json +++ b/packages/client/src/localization/locales/en.json @@ -2479,6 +2479,8 @@ "ui.chat.chatgallery.animateIllustration": "Animate illustration", "ui.chat.chatgallery.background": "Background", "ui.chat.chatgallery.backgroundGenerationFailed": "Background generation failed.", + "ui.chat.chatgallery.baseIllustrator": "Illustrator", + "ui.chat.chatgallery.chooseImageAgent": "Choose an image agent", "ui.chat.chatgallery.clearGallerySearch": "Clear gallery search", "ui.chat.chatgallery.copyImagePrompt": "Copy image prompt", "ui.chat.chatgallery.copyPrompt": "Copy prompt", @@ -3488,6 +3490,12 @@ "ui.chat.memoryrecallmemoriesmodal.rebuildMemoriesFromCurrentChatMessages": "Rebuild memories from current chat messages", "ui.chat.memoryrecallmemoriesmodal.removeAllRecallMemoriesForThisChatThisDoes": "Remove all recall memories for this chat? This does not delete chat messages.", "ui.chat.memoryrecallmemoriesmodal.removeThisRecallMemoryFromThisChat": "Remove this recall memory from this chat?", + "ui.chat.memoryrecallmemoriesmodal.reVectorizationFailed": "Memory re-vectorization failed.", + "ui.chat.memoryrecallmemoriesmodal.reVectorizationFailedValue1": "Memory re-vectorization failed: {{value1}}", + "ui.chat.memoryrecallmemoriesmodal.reVectorizeAll": "Re-vectorize All", + "ui.chat.memoryrecallmemoriesmodal.reVectorizeAllMemories": "Re-vectorize All Memories", + "ui.chat.memoryrecallmemoriesmodal.reVectorizeAllMemoriesDescription": "Replace every generated memory vector for this chat with vectors from the currently selected embedding model? Imported memories are kept.", + "ui.chat.memoryrecallmemoriesmodal.reVectorizedValue1MemoryChunks": "Re-vectorized {{value1}} memory chunks.", "ui.chat.memoryrecallmemoriesmodal.thereAreNoRecallMemoriesToExportYet": "There are no recall memories to export yet.", "ui.chat.memoryrecallmemoriesmodal.vectorized": "Vectorized", "ui.chat.memoryrecallmemoriesmodal.waitingForVector": "Waiting for vector", diff --git a/packages/server/src/db/file-backed-store.ts b/packages/server/src/db/file-backed-store.ts index c7bcdf52b..9f94519a9 100644 --- a/packages/server/src/db/file-backed-store.ts +++ b/packages/server/src/db/file-backed-store.ts @@ -1216,14 +1216,56 @@ class FileTableStore { // migrated message shards when only the swipes migration remains (a crash // exactly between the two tables). const migrationIndex = new Map(); + let expectedTableCounts: Record = {}; + try { + expectedTableCounts = parseJsonFile(manifestPath(this.rootDir), null).value?.tables ?? {}; + } catch { + // The full loader reports manifest corruption later. Recovery here is + // intentionally limited to a trustworthy positive row count. + } for (const table of SHARDED_TABLES) { const monolithPath = tableFilePath(this.rootDir, table); const monolithBak = `${monolithPath}.bak`; - const monolithPresent = existsSync(monolithPath) || existsSync(monolithBak); + let monolithPresent = existsSync(monolithPath) || existsSync(monolithBak); const dir = shardDirPath(this.rootDir, table); const shardDirPresent = existsSync(dir); const sentinelPath = join(dir, SHARD_MIGRATION_SENTINEL); const sentinelPresent = shardDirPresent && existsSync(sentinelPath); + const shardPrimaries = shardDirPresent + ? discoverShardPrimaries( + (() => { + try { + return readdirSync(dir); + } catch { + return [] as string[]; + } + })(), + ) + : []; + const manifestRowCount = expectedTableCounts[table]; + const expectedRowCount = + typeof manifestRowCount === "number" && Number.isSafeInteger(manifestRowCount) && manifestRowCount > 0 + ? manifestRowCount + : 0; + + // A copied/restored profile can retain the byte-for-byte pre-shard + // backup while losing the shard directory itself. Recover only when the + // manifest proves rows are expected and there are zero shard files; + // partial shard sets are ambiguous and must never be auto-merged. + if (!monolithPresent && shardPrimaries.length === 0 && expectedRowCount > 0) { + const preservedSource = [`${monolithPath}.pre-shard`, `${monolithBak}.pre-shard`].find((path) => + existsSync(path), + ); + if (preservedSource) { + await copyFile(preservedSource, monolithPath); + monolithPresent = true; + logger.warn( + "[file-storage] Restoring %s from its preserved pre-shard backup because the manifest expects %d rows but no shard files exist", + table, + expectedRowCount, + ); + } + } if (!monolithPresent) { if (sentinelPresent) { @@ -1247,15 +1289,7 @@ class FileTableStore { // (mkdir before the sentinel write) — a crash there must classify as // a crashed migration, or the monolith would be quarantined in favor // of an EMPTY shard dir. - const hasShardData = discoverShardPrimaries( - (() => { - try { - return readdirSync(dir); - } catch { - return [] as string[]; - } - })(), - ).length > 0; + const hasShardData = shardPrimaries.length > 0; if (sentinelPresent || !hasShardData) { logger.warn( "[file-storage] A previous %s shard migration did not complete; retrying from the untouched monolith", diff --git a/packages/server/src/routes/lorebooks.routes.ts b/packages/server/src/routes/lorebooks.routes.ts index 249fbf91a..73cda25dc 100644 --- a/packages/server/src/routes/lorebooks.routes.ts +++ b/packages/server/src/routes/lorebooks.routes.ts @@ -1162,11 +1162,18 @@ export async function lorebooksRoutes(app: FastifyInstance) { if (!allEntries.length) return { vectorized: 0, total: 0, skipped: 0 }; const lorebook = (await storage.getById(req.params.id)) as Record | null; if (lorebook?.excludeFromVectorization === true) { - return { vectorized: 0, total: allEntries.length, skipped: allEntries.length }; + return reply.status(409).send({ + error: "Enable Lorebook vectors and save the Lorebook before vectorizing its entries.", + }); } const vectorizableEntries = allEntries.filter( (entry) => !(entry as Record).excludeFromVectorization, ); + if (vectorizableEntries.length === 0) { + return reply.status(409).send({ + error: "Every entry is excluded from vectorization. Include at least one entry before vectorizing.", + }); + } const entries = body.onlyMissing ? vectorizableEntries.filter((entry) => { const embedding = (entry as Record).embedding; diff --git a/packages/server/src/services/memory-recall.ts b/packages/server/src/services/memory-recall.ts index 57c25123c..1d9bbfbbc 100644 --- a/packages/server/src/services/memory-recall.ts +++ b/packages/server/src/services/memory-recall.ts @@ -24,6 +24,24 @@ const SIMILARITY_THRESHOLD = 0.25; /** Maximum number of recalled memories per generation. */ const DEFAULT_TOP_K = 8; +const memoryMutationTails = new Map>(); + +async function serializeMemoryMutation(chatId: string, task: () => Promise): Promise { + const previous = memoryMutationTails.get(chatId) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + memoryMutationTails.set(chatId, current); + + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + if (memoryMutationTails.get(chatId) === current) memoryMutationTails.delete(chatId); + } +} // ── Cosine similarity ── @@ -252,7 +270,7 @@ async function pruneNativeMemoryChunksAfter( * Chunk any un-chunked messages for a given chat and embed them. * Should be called after generation completes (fire-and-forget). */ -export async function chunkAndEmbedMessages( +async function chunkAndEmbedMessagesUnlocked( db: DB, chatId: string, /** Map from role → display name. Used to format "Name: content" lines. */ @@ -385,6 +403,15 @@ export async function chunkAndEmbedMessages( logger.debug("[memory-recall] Created %d chunk(s) for chat %s", embeddableChunks.length, chatId); } +export async function chunkAndEmbedMessages( + db: DB, + chatId: string, + nameMap: { userName: string; characterNames: Record }, + options: ChunkAndEmbedMessagesOptions = {}, +): Promise { + return serializeMemoryMutation(chatId, () => chunkAndEmbedMessagesUnlocked(db, chatId, nameMap, options)); +} + /** * Rebuild all memory-recall chunks for a chat from the current message log. */ @@ -396,14 +423,16 @@ export async function rebuildMemoryChunks( ): Promise { if (isLite) return 0; - await db.delete(memoryChunks).where(and(eq(memoryChunks.chatId, chatId), isNull(memoryChunks.sourceChatId))); - await chunkAndEmbedMessages(db, chatId, nameMap, options); - - const rebuilt = await db - .select({ id: memoryChunks.id }) - .from(memoryChunks) - .where(and(eq(memoryChunks.chatId, chatId), isNull(memoryChunks.sourceChatId))); - return rebuilt.length; + return serializeMemoryMutation(chatId, async () => { + await db.delete(memoryChunks).where(and(eq(memoryChunks.chatId, chatId), isNull(memoryChunks.sourceChatId))); + await chunkAndEmbedMessagesUnlocked(db, chatId, nameMap, options); + + const rebuilt = await db + .select({ id: memoryChunks.id }) + .from(memoryChunks) + .where(and(eq(memoryChunks.chatId, chatId), isNull(memoryChunks.sourceChatId))); + return rebuilt.length; + }); } /** diff --git a/scripts/regressions/memory-recall-revectorize.regression.ts b/scripts/regressions/memory-recall-revectorize.regression.ts new file mode 100644 index 000000000..a5a9518eb --- /dev/null +++ b/scripts/regressions/memory-recall-revectorize.regression.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "../../packages/server/src/db/file-query.js"; +import { createFileNativeDB } from "../../packages/server/src/db/file-backed-store.js"; +import { chats, memoryChunks, messages } from "../../packages/server/src/db/schema/index.js"; +import { chunkAndEmbedMessages, rebuildMemoryChunks } from "../../packages/server/src/services/memory-recall.js"; + +const dir = mkdtempSync(join(tmpdir(), "marinara-memory-revectorize-")); +process.env.FILE_STORAGE_DIR = dir; +const db = await createFileNativeDB(); + +try { + await db.insert(chats).values({ id: "chat-memory", name: "Memory", mode: "conversation" }); + for (let index = 0; index < 5; index += 1) { + await db.insert(messages).values({ + id: `message-${index}`, + chatId: "chat-memory", + role: index % 2 === 0 ? "user" : "assistant", + content: `Memory turn ${index}`, + createdAt: `2026-08-10T10:00:0${index}.000Z`, + }); + } + + let releaseOldEmbedding!: () => void; + const oldEmbeddingReleased = new Promise((resolve) => { + releaseOldEmbedding = resolve; + }); + let notifyOldEmbeddingStarted!: () => void; + const oldEmbeddingStarted = new Promise((resolve) => { + notifyOldEmbeddingStarted = resolve; + }); + let newEmbeddingStarted = false; + + const backgroundChunk = chunkAndEmbedMessages( + db, + "chat-memory", + { userName: "User", characterNames: {} }, + { + embeddingSource: { + label: "old-384", + async embed(texts) { + notifyOldEmbeddingStarted(); + await oldEmbeddingReleased; + return texts.map(() => Array.from({ length: 384 }, () => 0.25)); + }, + }, + }, + ); + await oldEmbeddingStarted; + + const rebuild = rebuildMemoryChunks( + db, + "chat-memory", + { userName: "User", characterNames: {} }, + { + embeddingSource: { + label: "new-768", + async embed(texts) { + newEmbeddingStarted = true; + return texts.map(() => Array.from({ length: 768 }, () => 0.5)); + }, + }, + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(newEmbeddingStarted, false, "re-vectorization waits for in-flight background chunking on the same chat"); + releaseOldEmbedding(); + await Promise.all([backgroundChunk, rebuild]); + + const stored = await db.select().from(memoryChunks).where(eq(memoryChunks.chatId, "chat-memory")); + assert.equal(stored.length, 1, "re-vectorization replaces the prior native chunk exactly once"); + assert.equal(JSON.parse(stored[0]!.embedding ?? "[]").length, 768, "only vectors from the new model remain"); +} finally { + await db._fileStore.close(); + rmSync(dir, { recursive: true, force: true }); +} + +console.log("Memory Recall re-vectorization regression checks passed."); diff --git a/scripts/regressions/message-sharding.regression.ts b/scripts/regressions/message-sharding.regression.ts index 199ef0ecd..d5f9686da 100644 --- a/scripts/regressions/message-sharding.regression.ts +++ b/scripts/regressions/message-sharding.regression.ts @@ -201,6 +201,106 @@ assert.equal( } } +// ── Restored profile: expected rows + no shards -> recover pre-shard backup ── + +{ + const dir = tempStorageDir(); + mkdirSync(join(dir, "tables"), { recursive: true }); + writeFileSync( + join(dir, "tables", "messages.json.pre-shard"), + JSON.stringify([messageRow("m-restored", "chat-restored", "history survives reinstall")]), + ); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ + version: STORAGE_VERSION, + savedAt: "2026-08-10T00:00:00.000Z", + backend: "file-native", + tables: { messages: 1 }, + shards: { messages: 1 }, + }), + ); + + const db = await createFileNativeDB(); + try { + const restored = await db.select().from(messages); + assert.equal(restored.length, 1, "an expected history is recovered when every message shard is absent"); + assert.equal(restored[0]!.content, "history survives reinstall"); + assert.ok( + existsSync(join(dir, "tables", "messages.json.pre-shard")), + "the preserved source remains available after recovery", + ); + assert.ok( + existsSync(join(dir, "tables", "messages", `${encodeShardKey("chat-restored")}.json`)), + "recovered history is written back into the current shard layout", + ); + } finally { + await db._fileStore.close(); + rmSync(dir, { recursive: true, force: true }); + } +} + +// An intentionally empty current manifest must not resurrect stale history. +{ + const dir = tempStorageDir(); + mkdirSync(join(dir, "tables"), { recursive: true }); + writeFileSync( + join(dir, "tables", "messages.json.pre-shard"), + JSON.stringify([messageRow("m-deleted", "chat-deleted", "must stay deleted")]), + ); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ + version: STORAGE_VERSION, + savedAt: "2026-08-10T00:00:00.000Z", + backend: "file-native", + tables: { messages: 0 }, + shards: { messages: 0 }, + }), + ); + + const db = await createFileNativeDB(); + try { + assert.equal((await db.select().from(messages)).length, 0, "zero expected rows never revive the old backup"); + } finally { + await db._fileStore.close(); + rmSync(dir, { recursive: true, force: true }); + } +} + +// Malformed manifest counts are not proof that a restored profile expects +// rows. Strings and fractions must not revive a stale pre-shard backup. +for (const invalidExpectedCount of ["1", 1.5]) { + const dir = tempStorageDir(); + mkdirSync(join(dir, "tables"), { recursive: true }); + writeFileSync( + join(dir, "tables", "messages.json.pre-shard"), + JSON.stringify([messageRow("m-stale", "chat-stale", "must not be restored")]), + ); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ + version: STORAGE_VERSION, + savedAt: "2026-08-10T00:00:00.000Z", + backend: "file-native", + tables: { messages: invalidExpectedCount }, + shards: { messages: 0 }, + }), + ); + + const db = await createFileNativeDB(); + try { + assert.equal( + (await db.select().from(messages)).length, + 0, + `invalid expected row count ${JSON.stringify(invalidExpectedCount)} never revives the old backup`, + ); + } finally { + await db._fileStore.close(); + rmSync(dir, { recursive: true, force: true }); + } +} + // ── Crashed migration: sentinel present -> retry from the monolith ── {