From d9cda66136256f57e502d54ab6cf07f18d54f136 Mon Sep 17 00:00:00 2001 From: Spicy_Marinara Date: Thu, 8 Oct 2026 13:05:03 +0200 Subject: [PATCH] feat(illustrator): set Run Interval per chat The Illustrator's Run Interval now belongs to each chat, so one chat can draw every message, another every 5 and another only when asked (0). - New chat metadata field illustratorRunInterval (0-100, null = agent default), clamped by the chat metadata route. - resolveEffectiveAgentSettings applies the chat value through applyIllustratorChatRunInterval, so automatic cadence (and the cadence status route) uses it; 0 still means manual-only. - Chat Settings Illustrator card (Roleplay and Game agent cards) gets a Run Interval field with the usual chat override / Use agent default. - The add-agent window (drawer and setup wizard) shows the chat value, saves it to the chat and no longer writes runInterval into the global Illustrator config; other agents are unchanged. - Agent Editor help says its value is the default for chats. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + docs/agents/built-in-agents.md | 4 +- docs/media/illustrator-agent.md | 8 +- e2e/illustrator-manual-cadence.e2e.ts | 46 ++++++++ e2e/roleplay-commands.e2e.ts | 14 +++ .../src/components/agents/AgentEditor.tsx | 3 + .../components/chat/AgentAddSetupFields.tsx | 13 ++- .../components/chat/ChatSettingsDrawer.tsx | 67 +++++++++-- .../src/components/chat/ChatSetupWizard.tsx | 26 +++-- .../client/src/localization/locales/en.json | 2 + packages/server/src/routes/agents.routes.ts | 14 ++- packages/server/src/routes/chats.routes.ts | 5 + .../src/services/generation/agent-cadence.ts | 13 +++ .../services/generation/agent-resolution.ts | 2 + packages/shared/src/types/chat.ts | 5 + .../src/utils/illustrator-generation-count.ts | 10 ++ .../illustrator-manual-cadence.regression.ts | 105 ++++++++++++++++++ 17 files changed, 310 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8394df9050..4854e131e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This file is the release-notes source of truth for Marinara Engine. Reuse these ## [Unreleased] +- The Illustrator's **Run Interval** is now set per chat. Its card in **Chat Settings → Agents** has a **Run Interval** field, so one chat can get an image every message, another every 5 messages and another only when you ask (0). Adding the Illustrator to a chat saves the value you pick to that chat instead of changing the agent. The **Run Interval** in the Agent Editor is now the default for chats that don't set their own, and **Use agent default** brings a chat back to it. - With **Apply preset colors** on in **Chat widget style**, HTML in chat messages, such as an Immersive HTML card, now keeps the text colors it sets itself. Before, that text took the preset text color or your **Chat Chrome Text Color**, so black text on a light card could turn white or green. Text without a color of its own still follows the preset and Chat Chrome Text Color (#7296). - Professor Mari can now read every chat. About one chat in 4096 has an ID that starts with two dashes, and for those chats her chat and message lookups failed with a usage error. Searching chats for text that starts with two dashes failed the same way, and listing the chats of a character whose ID starts with two dashes showed every chat instead (#7289). - For contributors: the nightly browser tests and the **pnpm-validate** pull request check no longer fail while installing dependencies when api.nuget.org times out. They now skip onnxruntime-node's optional CUDA download, which the tests never use. Local embeddings still use the CPU build that ships with the package. diff --git a/docs/agents/built-in-agents.md b/docs/agents/built-in-agents.md index 19bfacb3f7..9328b0dcca 100644 --- a/docs/agents/built-in-agents.md +++ b/docs/agents/built-in-agents.md @@ -16,7 +16,7 @@ Marinara groups its agents in the **Agents** panel into **Apps**, **Writer Agent A run interval means the agent runs once every few user and assistant messages instead of after every message. You can change a run interval in the agent's setup, up to 100. -Illustrator also accepts **0** for manual-only generation: it stays available for Gallery actions but never runs automatically. Other agents keep their existing positive intervals. +Illustrator's run interval is set per chat, in its card in **Chat Settings**. The value in its setup is the default for chats that don't set their own. Illustrator also accepts **0** for manual-only generation: it stays available for Gallery actions but never runs automatically. Other agents keep their existing positive intervals. ## Apps @@ -268,7 +268,7 @@ Responsible for image and video generations. It writes visual prompts for import - **Phase**: Post-Processing. - **Where it works**: Roleplay. -- **Key settings**: it runs once every 5 user and assistant messages by default. Settings include **Prompt Model**, **Image Style**, **Attach Card Appearance**, and **Send Avatar References**. For the full setup, see [Illustrator agent](../media/illustrator-agent.md). +- **Key settings**: it runs once every 5 user and assistant messages by default, and each chat can change that with **Run Interval** on its card. Settings include **Prompt Model**, **Image Style**, **Attach Card Appearance**, and **Send Avatar References**. For the full setup, see [Illustrator agent](../media/illustrator-agent.md). ### Lorebook Keeper diff --git a/docs/media/illustrator-agent.md b/docs/media/illustrator-agent.md index 570f10e315..867b595225 100644 --- a/docs/media/illustrator-agent.md +++ b/docs/media/illustrator-agent.md @@ -6,7 +6,7 @@ This guide covers the **Illustrator**, a built-in helper that draws pictures of An agent is a small AI helper that runs automatically for one chat. The **Illustrator** is a post-processing agent, which means it runs after the AI finishes each reply. It reads the latest reply and decides if the moment is worth a picture. When it is, the Illustrator writes an image prompt and sends it to your image provider. A prompt is the text description that tells an image model what to draw. -The Illustrator does not draw every message. By default, after it makes an image it waits for 5 accepted user and assistant messages before it can make another one. Swiping or regenerating the same reply does not advance that interval. If it decides a moment is not worth illustrating, it skips it and makes no image. Every image it creates is saved to the chat **Gallery**. +The Illustrator does not draw every message. By default, after it makes an image it waits for 5 accepted user and assistant messages before it can make another one. Each chat can change that number. Swiping or regenerating the same reply does not advance that interval. If it decides a moment is not worth illustrating, it skips it and makes no image. Every image it creates is saved to the chat **Gallery**. You can use the Illustrator in **Roleplay** and **Game Mode** chats, and installing it also unlocks Conversation selfies. Its short description in the app reads: "Responsible for image and video generations." The setup steps and settings in this guide are for Roleplay chats. Game Mode uses one simple switch instead, covered in the Game Mode section below. @@ -83,9 +83,11 @@ No setting is needed. The captions appear only for a NovelAI connection on its o ## More settings and running it by hand -The Illustrator card has an **Open Setup** button. It opens the agent's full setup screen, where you can set how often the agent runs and give it its own image connection. +The Illustrator card has an **Open Setup** button. It opens the agent's full setup screen, where you can set the default for how often the agent runs and give it its own image connection. -Set **Run Interval** to **0** for manual-only generation. This stops automatic Illustrator runs, including its automatic scene backgrounds, while keeping the agent installed and available for Gallery actions. The default remains **5**; set a positive interval to resume automatic runs. You can also choose 0 when adding Illustrator to a chat. +Each chat has its own **Run Interval** on the Illustrator card in **Chat Settings**. Its help text reads: "Messages between automatic images in this chat. 0 means only when you ask." A chat that hasn't set one uses the **Run Interval** from the setup screen, which is **5** unless you change it. The card shows **Using agent default** or **Chat override**. Press **Use agent default** to go back to the setup screen's value. When you add the Illustrator to a chat, the add window shows the same field and saves your choice to that chat only. + +Set **Run Interval** to **0** for manual-only generation. This stops automatic Illustrator runs, including its automatic scene backgrounds, while keeping the agent installed and available for Gallery actions. Set a positive interval to resume automatic runs. You can also make a picture on demand instead of waiting. Open **Chat Settings**, expand the **Gallery** section, and use the **Illustrate** button. The Illustrator runs once right away and the button shows **Generating...** while it works. This is useful when you want a picture of the current moment and the agent has not drawn one yet. diff --git a/e2e/illustrator-manual-cadence.e2e.ts b/e2e/illustrator-manual-cadence.e2e.ts index 22bd260f4a..2559f3989a 100644 --- a/e2e/illustrator-manual-cadence.e2e.ts +++ b/e2e/illustrator-manual-cadence.e2e.ts @@ -238,6 +238,52 @@ test("Illustrator manual-only interval saves and survives reopening and chat set await add.getByRole("button", { name: "Add", exact: true }).click(); await expect(add).toBeHidden(); await expect.poll(async () => (await readSettings()).runInterval).toBe(0); + + // Run Interval belongs to the chat: the Illustrator card and the add window save it there, never to the agent. + const readChatMetadata = async () => { + const stored = (await (await request.get(`/api/chats/${chat.id}`)).json()) as { metadata: unknown }; + return (typeof stored.metadata === "string" ? JSON.parse(stored.metadata) : stored.metadata) as Record< + string, + unknown + >; + }; + expect(await readChatMetadata()).not.toHaveProperty("illustratorRunInterval"); + const card = drawer.locator(`#chat-settings-agent-menu-${chat.id}-illustrator`); + const intervalControl = card.locator("label").filter({ hasText: "Run Interval" }).locator(".."); + const chatInterval = intervalControl.getByRole("textbox"); + await expect(chatInterval).toHaveValue("0"); + await expect(intervalControl.getByText("Using agent default", { exact: true })).toBeVisible(); + await chatInterval.fill("7"); + await chatInterval.blur(); + await expect.poll(async () => (await readChatMetadata()).illustratorRunInterval).toBe(7); + await expect(intervalControl.getByText("Chat override", { exact: true })).toBeVisible(); + expect((await readSettings()).runInterval).toBe(0); + await testInfo.attach("illustrator-chat-run-interval", { + body: await intervalControl.screenshot({ path: testInfo.outputPath("illustrator-chat-run-interval.png") }), + contentType: "image/png", + }); + + await card.getByRole("button", { name: "Remove Illustrator from chat" }).click(); + await page + .getByRole("dialog", { name: "Remove Illustrator?" }) + .getByRole("button", { name: "Remove Agent", exact: true }) + .click(); + await expect(card).toHaveCount(0); + await drawer.getByRole("button").filter({ hasText: "Illustrator" }).last().click(); + await expect(add).toBeVisible(); + await expect(addInterval).toHaveValue("7"); + await addInterval.fill("2"); + await add.getByRole("button", { name: "Add", exact: true }).click(); + await expect(add).toBeHidden(); + await expect.poll(async () => (await readChatMetadata()).illustratorRunInterval).toBe(2); + expect((await readSettings()).runInterval).toBe(0); + await expect(chatInterval).toHaveValue("2"); + + await intervalControl.getByRole("button", { name: "Use agent default", exact: true }).click(); + await expect.poll(async () => (await readChatMetadata()).illustratorRunInterval ?? null).toBeNull(); + await expect(chatInterval).toHaveValue("0"); + await expect(intervalControl.getByText("Using agent default", { exact: true })).toBeVisible(); + expect((await readSettings()).runInterval).toBe(0); } finally { await request.delete(`/api/chats/${chat.id}`); await request.delete(`/api/agents/${agent.id}`); diff --git a/e2e/roleplay-commands.e2e.ts b/e2e/roleplay-commands.e2e.ts index 0402dd9c74..e9db2ad2b6 100644 --- a/e2e/roleplay-commands.e2e.ts +++ b/e2e/roleplay-commands.e2e.ts @@ -2161,6 +2161,20 @@ test("Roleplay commands require attached agents, enforce combat audience, and fo expect(automaticIllustrations).toHaveLength(2); expect(imageRequests).toHaveLength(5); + // This chat's Run Interval wins over the agent's; clearing it goes back to the agent's. + await metadata({ illustratorRunInterval: 1 }); + await generate(alice); + expect(automaticIllustrations).toHaveLength(3); + await expect.poll(() => imageRequests.length).toBe(6); + await interval(1); + await metadata({ illustratorRunInterval: 0 }); + await generate(alice); + expect(automaticIllustrations).toHaveLength(3); + await metadata({ illustratorRunInterval: null }); + await generate(alice); + expect(automaticIllustrations).toHaveLength(4); + await expect.poll(() => imageRequests.length).toBe(7); + await page.route("**/api/capability-packages/agents", (route) => route.fulfill({ json: ["combat", "illustrator"].map((id) => ({ diff --git a/packages/client/src/components/agents/AgentEditor.tsx b/packages/client/src/components/agents/AgentEditor.tsx index f97af48272..25c162ce24 100644 --- a/packages/client/src/components/agents/AgentEditor.tsx +++ b/packages/client/src/components/agents/AgentEditor.tsx @@ -3334,6 +3334,9 @@ export function AgentEditor() { {localizeUi("ui.agents.agenteditor.messages")} +

+ {localizeUi("agents.illustrator.runIntervalChatDefaultHelp")} +

{localizeUi("ui.agents.agenteditor.theIllustratorCanOnlyCreateANewImageOnce")}

diff --git a/packages/client/src/components/chat/AgentAddSetupFields.tsx b/packages/client/src/components/chat/AgentAddSetupFields.tsx index 716bb3859b..52fcf0864d 100644 --- a/packages/client/src/components/chat/AgentAddSetupFields.tsx +++ b/packages/client/src/components/chat/AgentAddSetupFields.tsx @@ -388,7 +388,9 @@ export function buildAgentAddMetadataPatch( options?: { allowSecretPlot?: boolean; defaultPromptTemplateId?: string; - illustratorDefaults?: { includeCharacterAppearance: boolean; useAvatarReferences: boolean }; + illustratorDefaults?: { includeCharacterAppearance: boolean; useAvatarReferences: boolean; runInterval?: number }; + /** Run Interval chosen in the add window; Illustrator keeps it per chat instead of in the agent. */ + runInterval?: number | null; }, ): Record { const patch: Record = {}; @@ -455,9 +457,9 @@ export function buildAgentAddMetadataPatch( if (agentId === "illustrator") { const defaults = options?.illustratorDefaults; const applyIllustratorDefault = ( - key: "illustratorIncludeCharacterAppearance" | "illustratorUseAvatarReferences", - value: boolean, - defaultValue: boolean | undefined, + key: "illustratorIncludeCharacterAppearance" | "illustratorUseAvatarReferences" | "illustratorRunInterval", + value: boolean | number, + defaultValue: boolean | number | undefined, ) => { if (defaultValue === undefined || value !== defaultValue) patch[key] = value; else if (hasOwn(metadata, key)) patch[key] = null; @@ -468,6 +470,9 @@ export function buildAgentAddMetadataPatch( defaults?.includeCharacterAppearance, ); applyIllustratorDefault("illustratorUseAvatarReferences", setup.useAvatarReferences, defaults?.useAvatarReferences); + if (options?.runInterval != null) { + applyIllustratorDefault("illustratorRunInterval", options.runInterval, defaults?.runInterval); + } } if (agentId === "haptic") { patch.enableHapticFeedback = setup.hapticFeedbackEnabled; diff --git a/packages/client/src/components/chat/ChatSettingsDrawer.tsx b/packages/client/src/components/chat/ChatSettingsDrawer.tsx index b0d74bee9a..214a369b8c 100644 --- a/packages/client/src/components/chat/ChatSettingsDrawer.tsx +++ b/packages/client/src/components/chat/ChatSettingsDrawer.tsx @@ -272,8 +272,10 @@ import type { } from "@marinara-engine/shared"; import { MAX_ILLUSTRATOR_IMAGES_PER_GENERATION, + MAX_ILLUSTRATOR_RUN_INTERVAL, customAgentHasCapability, normalizeIllustratorImagesPerGeneration, + normalizeIllustratorRunInterval, normalizeSpotifySourceType, parseAgentSettingsRecord, } from "@marinara-engine/shared"; @@ -2006,6 +2008,11 @@ export function ChatSettingsDrawer({ const illustratorImagesPerGeneration = normalizeIllustratorImagesPerGeneration( metadata.illustratorImagesPerGeneration, ); + // This chat's Run Interval, else the Agent Editor's (which is only the default for chats). + const illustratorChatRunInterval = normalizeIllustratorRunInterval(metadata.illustratorRunInterval); + const illustratorRunInterval = + illustratorChatRunInterval ?? + normalizePositiveInteger(illustratorDefaults.runInterval, 5, MAX_ILLUSTRATOR_RUN_INTERVAL, 0); const illustratorAutoBackgroundsEnabled = metadata.illustratorAutoBackgroundsEnabled === true; const selectedIllustratorPromptConnectionMissing = illustratorPromptConnectionId.length > 0 && @@ -2226,6 +2233,37 @@ export function ChatSettingsDrawer({ ); + const renderIllustratorRunInterval = () => ( +
+ + updateMeta.mutate({ id: chat.id, illustratorRunInterval: null })} + /> +
+ ); const proseGuardianBannedWords = typeof metadata.proseGuardianBannedWords === "string" ? metadata.proseGuardianBannedWords @@ -4022,7 +4060,9 @@ export function ChatSettingsDrawer({ maxTokens: normalizeAgentMaxTokens(mergedSettings.maxTokens), runInterval: intervalMeta ? normalizePositiveInteger( - mergedSettings.runInterval, + // Illustrator's Run Interval belongs to this chat; the agent's value is only its default. + (agent.id === "illustrator" ? normalizeIllustratorRunInterval(metadata.illustratorRunInterval) : null) ?? + mergedSettings.runInterval, intervalMeta.defaultValue, intervalMeta.max, intervalMeta.min, @@ -4093,7 +4133,8 @@ export function ChatSettingsDrawer({ maxTokens: normalizedMaxTokens, }; const intervalMeta = getAgentRunIntervalMeta(agent.id, !!builtInMeta); - if (intervalMeta && runInterval != null) { + // Illustrator saves its Run Interval to this chat, so the agent's value stays the default for other chats. + if (intervalMeta && runInterval != null && agent.id !== "illustrator") { nextSettings.runInterval = runInterval; } nextSettings = applyAgentAddSetupToAgentSettings(agent.id, setup, nextSettings, { @@ -4133,9 +4174,18 @@ export function ChatSettingsDrawer({ ...buildAgentAddMetadataPatch(agent.id, setup, metadata, { allowSecretPlot: supportsNarrativeDirectorSecretPlot, defaultPromptTemplateId: resolveDefaultAgentPromptTemplateId(nextSettings), + runInterval, illustratorDefaults: { includeCharacterAppearance: nextSettings.includeCharacterAppearance === true, useAvatarReferences: nextSettings.useAvatarReferences === true, + runInterval: intervalMeta + ? normalizePositiveInteger( + nextSettings.runInterval, + intervalMeta.defaultValue, + intervalMeta.max, + intervalMeta.min, + ) + : undefined, }, }), }); @@ -8630,6 +8680,7 @@ export function ChatSettingsDrawer({ /> {renderIllustratorPromptConnectionSelect()} {renderIllustratorImageConnectionSelect()} + {renderIllustratorRunInterval()} {renderIllustratorPromptConnectionSelect()} + {renderIllustratorRunInterval()} {renderIllustratorImagesPerGeneration()} {agentAddIntervalMeta.unit} - {agentAddPreview.agent.id === "illustrator" && ( -

- {localizeUi("agents.illustrator.manualOnlyIntervalHelp")} -

- )} -

{agentAddIntervalMeta.help}

+

+ {agentAddPreview.agent.id === "illustrator" + ? localizeUi("agents.illustrator.chatRunIntervalHelp") + : agentAddIntervalMeta.help} +

)} diff --git a/packages/client/src/components/chat/ChatSetupWizard.tsx b/packages/client/src/components/chat/ChatSetupWizard.tsx index 2ebcaa7695..e673bad4c7 100644 --- a/packages/client/src/components/chat/ChatSetupWizard.tsx +++ b/packages/client/src/components/chat/ChatSetupWizard.tsx @@ -70,6 +70,7 @@ import { isRetiredBuiltInAgentId, mergeBuiltInAgentSettings, normalizeAgentPhaseForType, + normalizeIllustratorRunInterval, type AgentPhase, type Chat, type ChatMode, @@ -2605,7 +2606,9 @@ function RoleplaySetupWizard({ chat, onFinish, defaultsApplied, defaultsAction } maxTokens: normalizeAgentMaxTokens(mergedSettings.maxTokens), runInterval: intervalMeta ? normalizePositiveInteger( - mergedSettings.runInterval, + // Illustrator's Run Interval belongs to this chat; the agent's value is only its default. + (agent.id === "illustrator" ? normalizeIllustratorRunInterval(metadata.illustratorRunInterval) : null) ?? + mergedSettings.runInterval, intervalMeta.defaultValue, intervalMeta.max, intervalMeta.min, @@ -2646,7 +2649,8 @@ function RoleplaySetupWizard({ chat, onFinish, defaultsApplied, defaultsAction } maxTokens: normalizeAgentMaxTokens(maxTokens), }; const intervalMeta = getAgentRunIntervalMeta(agent.id, !!builtInMeta); - if (intervalMeta && runInterval != null) nextSettings.runInterval = runInterval; + // Illustrator saves its Run Interval to this chat, so the agent's value stays the default for other chats. + if (intervalMeta && runInterval != null && agent.id !== "illustrator") nextSettings.runInterval = runInterval; nextSettings = applyAgentAddSetupToAgentSettings(agent.id, setup, nextSettings, { allowSecretPlot: supportsNarrativeDirectorSecretPlot, }); @@ -2684,9 +2688,18 @@ function RoleplaySetupWizard({ chat, onFinish, defaultsApplied, defaultsAction } ...buildAgentAddMetadataPatch(agent.id, setup, metadata, { allowSecretPlot: supportsNarrativeDirectorSecretPlot, defaultPromptTemplateId: resolveDefaultAgentPromptTemplateId(nextSettings), + runInterval, illustratorDefaults: { includeCharacterAppearance: nextSettings.includeCharacterAppearance === true, useAvatarReferences: nextSettings.useAvatarReferences === true, + runInterval: intervalMeta + ? normalizePositiveInteger( + nextSettings.runInterval, + intervalMeta.defaultValue, + intervalMeta.max, + intervalMeta.min, + ) + : undefined, }, }), }); @@ -3341,13 +3354,10 @@ function RoleplaySetupWizard({ chat, onFinish, defaultsApplied, defaultsAction } className={WIZARD_NUMBER_INPUT_CLASS} /> - {agentAddIntervalMeta.help} + {agentAddPreview.agent.id === "illustrator" + ? localizeUi("agents.illustrator.chatRunIntervalHelp") + : agentAddIntervalMeta.help} - {agentAddPreview.agent.id === "illustrator" && ( - - {localizeUi("agents.illustrator.manualOnlyIntervalHelp")} - - )} )} diff --git a/packages/client/src/localization/locales/en.json b/packages/client/src/localization/locales/en.json index 416521068b..2e92f9fbcc 100644 --- a/packages/client/src/localization/locales/en.json +++ b/packages/client/src/localization/locales/en.json @@ -52,11 +52,13 @@ "agents.batching.label": "Share requests with other agents", "agents.context.previousOutput.description": "Read this agent's previous visible-turn output, or its private agent-context field. Also available as {{agent::TYPE}} in its prompt. Runs separately to keep context private.", "agents.context.previousOutput.label": "Previous output", + "agents.illustrator.chatRunIntervalHelp": "Messages between automatic images in this chat. 0 means only when you ask.", "agents.illustrator.decision.backgroundGenerated": "Scene background generated", "agents.illustrator.decision.invalid": "Illustrator returned no usable image decision or prompt", "agents.illustrator.decision.requested": "Illustration requested", "agents.illustrator.decision.skipped": "No illustration requested this time", "agents.illustrator.manualOnlyIntervalHelp": "Set to 0 for manual-only generation. Automatic Illustrator runs stop; Gallery actions remain available.", + "agents.illustrator.runIntervalChatDefaultHelp": "Chats use this unless they set their own in Chat Settings.", "agents.longTermMemory.embeddingRefresh.deferred": "Configuration saved. Long-Term Memory index refresh was deferred; it has not completed yet.", "agents.longTermMemory.embeddingRefresh.failed": "Configuration saved, but Long-Term Memory index refresh failed.", "agents.longTermMemory.embeddingRefresh.refreshed": "Configuration saved and Long-Term Memory indexes refreshed.", diff --git a/packages/server/src/routes/agents.routes.ts b/packages/server/src/routes/agents.routes.ts index 05fe09654c..0d9753c59e 100644 --- a/packages/server/src/routes/agents.routes.ts +++ b/packages/server/src/routes/agents.routes.ts @@ -33,6 +33,7 @@ import { createAgentsStorage } from "../services/storage/agents.storage.js"; import { createChatsStorage } from "../services/storage/chats.storage.js"; import { createConnectionsStorage } from "../services/storage/connections.storage.js"; import { withLongTermMemoryEmbeddingChange } from "../services/generation/long-term-memory-runtime.js"; +import { applyIllustratorChatRunInterval } from "../services/generation/agent-cadence.js"; import { createLLMProvider } from "../services/llm/provider-registry.js"; import { normalizeBeholderState } from "../services/agents/beholder-state.js"; import { DATA_DIR } from "../utils/data-dir.js"; @@ -434,8 +435,15 @@ export async function agentsRoutes(app: FastifyInstance) { } const fallback = normalizeRunInterval(defaults.runInterval, 1); const config = await storage.getByType(agentType); - const settings = { ...defaults, ...parseAgentSettings(config?.settings) }; - const runInterval = normalizeRunInterval(settings.runInterval, fallback); + const chat = await chats.getById(chatId); + const settings = applyIllustratorChatRunInterval( + agentType, + { ...defaults, ...parseAgentSettings(config?.settings) }, + parseAgentSettings(chat?.metadata), + ); + // Illustrator's 0 means it only runs when the user asks, matching shouldSkipAgentByMessageInterval. + const manualOnly = agentType === "illustrator" && (settings.runInterval === 0 || settings.runInterval === "0"); + const runInterval = manualOnly ? 0 : normalizeRunInterval(settings.runInterval, fallback); const lastRun = await storage.getLastSuccessfulRunByType(agentType, chatId); const messages = await chats.listMessages(chatId); @@ -462,7 +470,7 @@ export async function agentsRoutes(app: FastifyInstance) { lastSuccessfulRun: lastRun ? { messageId: lastRun.messageId, createdAt: lastRun.createdAt } : null, messagesSinceLastRun, remainingMessages, - runsNextMessage: remainingMessages === 0, + runsNextMessage: !manualOnly && remainingMessages === 0, lastRunMessageFound, }; }); diff --git a/packages/server/src/routes/chats.routes.ts b/packages/server/src/routes/chats.routes.ts index 91fe2ae81e..1efe5ec34f 100644 --- a/packages/server/src/routes/chats.routes.ts +++ b/packages/server/src/routes/chats.routes.ts @@ -52,6 +52,7 @@ import { isMessagePinnedToContext, normalizeMessageMarkPatch, normalizeGroupChatMode, + normalizeIllustratorRunInterval, readMessagePrivateNote, stripPrivateMessageNote, MESSAGE_MARK_EXTRA_KEYS, @@ -1440,6 +1441,10 @@ export async function chatsRoutes(app: FastifyInstance) { if (Object.prototype.hasOwnProperty.call(incoming, "summaryMaxTokens")) { incoming.summaryMaxTokens = clampRoleplaySummaryMaxTokens(incoming.summaryMaxTokens); } + if (Object.prototype.hasOwnProperty.call(incoming, "illustratorRunInterval")) { + // Numbers clamp to 0-100; anything else returns the chat to the agent's Run Interval. + incoming.illustratorRunInterval = normalizeIllustratorRunInterval(incoming.illustratorRunInterval); + } if ( Object.prototype.hasOwnProperty.call(incoming, "noodleTimelineContextEnabled") && typeof incoming.noodleTimelineContextEnabled !== "boolean" diff --git a/packages/server/src/services/generation/agent-cadence.ts b/packages/server/src/services/generation/agent-cadence.ts index 9ed26dcc36..f3a82c6481 100644 --- a/packages/server/src/services/generation/agent-cadence.ts +++ b/packages/server/src/services/generation/agent-cadence.ts @@ -1,3 +1,5 @@ +import { normalizeIllustratorRunInterval } from "@marinara-engine/shared"; + type AgentsStore = { getLastSuccessfulRunByType(agentType: string, chatId: string): Promise<{ messageId?: string | null } | null>; }; @@ -16,6 +18,17 @@ export function resolveAgentRunInterval(settings: unknown, fallback: number): nu return Number.isFinite(parsed) && parsed >= 1 ? Math.min(100, Math.floor(parsed)) : normalizedFallback; } +/** Illustrator settings with this chat's Run Interval (0-100) in place of the agent's; other agents pass through. */ +export function applyIllustratorChatRunInterval( + agentType: string, + settings: Record, + chatMetadata: Record | null | undefined, +): Record { + if (agentType !== "illustrator") return settings; + const runInterval = normalizeIllustratorRunInterval(chatMetadata?.illustratorRunInterval); + return runInterval === null ? settings : { ...settings, runInterval }; +} + export async function shouldSkipAgentByMessageInterval({ agentsStore, chatId, diff --git a/packages/server/src/services/generation/agent-resolution.ts b/packages/server/src/services/generation/agent-resolution.ts index 54e084a8a9..7f3ca747b7 100644 --- a/packages/server/src/services/generation/agent-resolution.ts +++ b/packages/server/src/services/generation/agent-resolution.ts @@ -38,6 +38,7 @@ import { resolveAgentConnectionParameters, type AgentGenerationParameters } from import { applyTextRewriteAgentChatSettings, normalizeProseGuardianPromptTemplate } from "./prose-guardian-settings.js"; import { applyKnowledgeAgentChatSettings } from "./knowledge-agent-settings.js"; import { applyCustomAgentImageChatSettings } from "./custom-agent-image-settings.js"; +import { applyIllustratorChatRunInterval } from "./agent-cadence.js"; import { withConnectionFallbackProvider, type FallbackConnection } from "../llm/connection-fallback-provider.js"; import type { GenerationFallbackNotifier } from "./fallback-notification.js"; @@ -195,6 +196,7 @@ export function resolveEffectiveAgentSettings(args: { settings = applyTextRewriteAgentChatSettings(agentType, settings, chatMetadata); settings = applyKnowledgeAgentChatSettings(agentType, settings, chatMetadata); settings = applyCustomAgentImageChatSettings(agentType, settings, chatMetadata); + settings = applyIllustratorChatRunInterval(agentType, settings, chatMetadata); const usesNonSpotifyMusicSource = agentType === "spotify" && diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 0cdb5c18bc..427427e87a 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -350,6 +350,11 @@ export interface ChatMetadata { illustratorImageConnectionId?: string | null; /** Number of image variants generated for each Illustrator request. */ illustratorImagesPerGeneration?: number; + /** + * Messages between automatic Illustrator runs in this chat: a whole number from 0 to 100, where 0 means + * it only runs when the user asks (Gallery or slash command). Missing/null = the agent's Run Interval. + */ + illustratorRunInterval?: number | null; /** Whether Roleplay Illustrator may generate and activate a reusable background after a scene-location change. */ illustratorAutoBackgroundsEnabled?: boolean; /** Whether Conversation selfie commands should send the matching character avatar as a reference image. */ diff --git a/packages/shared/src/utils/illustrator-generation-count.ts b/packages/shared/src/utils/illustrator-generation-count.ts index 40eac11ed9..69dadc35aa 100644 --- a/packages/shared/src/utils/illustrator-generation-count.ts +++ b/packages/shared/src/utils/illustrator-generation-count.ts @@ -5,3 +5,13 @@ export function normalizeIllustratorImagesPerGeneration(value: unknown): number if (!Number.isFinite(parsed)) return 1; return Math.max(1, Math.min(MAX_ILLUSTRATOR_IMAGES_PER_GENERATION, Math.trunc(parsed))); } + +export const MAX_ILLUSTRATOR_RUN_INTERVAL = 100; + +/** A chat's Illustrator Run Interval as a whole number from 0 to 100, or null when the chat uses the agent's own. */ +export function normalizeIllustratorRunInterval(value: unknown): number | null { + const parsed = + typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN; + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.min(MAX_ILLUSTRATOR_RUN_INTERVAL, Math.trunc(parsed))); +} diff --git a/scripts/regressions/illustrator-manual-cadence.regression.ts b/scripts/regressions/illustrator-manual-cadence.regression.ts index 4d6d7fff2e..02e3bcc411 100644 --- a/scripts/regressions/illustrator-manual-cadence.regression.ts +++ b/scripts/regressions/illustrator-manual-cadence.regression.ts @@ -7,6 +7,13 @@ import { stepCadenceValue, } from "../../packages/client/src/lib/agent-cadence.js"; import { shouldSkipAgentByMessageInterval } from "../../packages/server/src/services/generation/agent-cadence.js"; +import { resolveEffectiveAgentSettings } from "../../packages/server/src/services/generation/agent-resolution.js"; +import { normalizeIllustratorRunInterval } from "../../packages/shared/src/utils/illustrator-generation-count.js"; +import { + applyAgentAddSetupToAgentSettings, + buildAgentAddMetadataPatch, + buildInitialAgentAddSetupState, +} from "../../packages/client/src/components/chat/AgentAddSetupFields.js"; const illustrator = getAgentRunIntervalMeta("illustrator")!; assert.equal(illustrator.min, 0, "Illustrator setup must offer manual-only cadence"); @@ -82,6 +89,104 @@ const mixedCadence = { assert.equal(await shouldSkipAgentByMessageInterval({ ...mixedCadence, messages: mixedHistory.slice(0, 2) }), true); assert.equal(await shouldSkipAgentByMessageInterval({ ...mixedCadence, messages: mixedHistory }), false); +// Each chat can set its own Illustrator Run Interval; the agent's value is only the default. +for (const value of [undefined, null, "", " ", "soon", true, Number.NaN]) { + assert.equal(normalizeIllustratorRunInterval(value), null, `${String(value)} leaves the chat on the agent default`); +} +assert.equal(normalizeIllustratorRunInterval(0), 0); +assert.equal(normalizeIllustratorRunInterval("3"), 3); +assert.equal(normalizeIllustratorRunInterval(2.7), 2); +assert.equal(normalizeIllustratorRunInterval(-4), 0); +assert.equal(normalizeIllustratorRunInterval(250), 100); + +const chatSettings = (agentRunInterval: number, chatMetadata?: Record) => + resolveEffectiveAgentSettings({ + agentType: "illustrator", + settings: { runInterval: agentRunInterval }, + chatMetadata, + }); +assert.equal(chatSettings(5, { illustratorRunInterval: 2 }).runInterval, 2, "the chat value wins"); +assert.equal(chatSettings(5, {}).runInterval, 5, "a missing chat value falls back to the agent"); +assert.equal(chatSettings(5, { illustratorRunInterval: null }).runInterval, 5, "a cleared chat value falls back"); +assert.equal(chatSettings(5).runInterval, 5); +assert.equal( + resolveEffectiveAgentSettings({ + agentType: "lorebook-keeper", + settings: { runInterval: 8 }, + chatMetadata: { illustratorRunInterval: 2 }, + }).runInterval, + 8, + "other agents ignore the Illustrator chat value", +); +const sinceLastImage = { + ...base, + agentsStore: { + async getLastSuccessfulRunByType() { + return { messageId: "prior" }; + }, + }, + messages: [ + { id: "prior", role: "assistant" }, + { id: "new", role: "user" }, + ], +}; +assert.equal( + await shouldSkipAgentByMessageInterval({ + ...sinceLastImage, + settings: chatSettings(5, { illustratorRunInterval: 1 }), + }), + false, + "a chat value of 1 runs on every message even though the agent waits 5", +); +assert.equal( + await shouldSkipAgentByMessageInterval({ + ...sinceLastImage, + settings: chatSettings(5, { illustratorRunInterval: null }), + }), + true, + "without a chat value the agent's interval still applies", +); +assert.equal( + await shouldSkipAgentByMessageInterval({ ...base, settings: chatSettings(1, { illustratorRunInterval: 0 }) }), + true, + "a chat value of 0 stops automatic runs even when the agent runs every message", +); +assert.equal( + await shouldSkipAgentByMessageInterval({ ...base, settings: chatSettings(0, { illustratorRunInterval: 3 }) }), + false, + "a chat value brings back automatic runs when the agent default is manual-only", +); + +// The add window saves the interval to the chat (only when it differs) and never to the agent. +const illustratorSetup = buildInitialAgentAddSetupState({ + agentId: "illustrator", + settings: { runInterval: 5 }, + metadata: {}, + musicPlayerSource: "spotify", + roleplaySpriteScale: 1, +}); +const addPatch = (runInterval: number, metadata: Record = {}) => + buildAgentAddMetadataPatch("illustrator", illustratorSetup, metadata, { + runInterval, + illustratorDefaults: { includeCharacterAppearance: false, useAvatarReferences: false, runInterval: 5 }, + }); +assert.equal(addPatch(0).illustratorRunInterval, 0, "a different interval is saved to the chat"); +assert.equal(Object.hasOwn(addPatch(5), "illustratorRunInterval"), false, "the agent default is not copied"); +assert.equal( + addPatch(5, { illustratorRunInterval: 2 }).illustratorRunInterval, + null, + "the default clears a chat value", +); +assert.equal( + Object.hasOwn( + buildAgentAddMetadataPatch("lorebook-keeper", illustratorSetup, {}, { runInterval: 3 }), + "illustratorRunInterval", + ), + false, + "other agents never write the Illustrator chat value", +); +assert.equal(applyAgentAddSetupToAgentSettings("illustrator", illustratorSetup, { runInterval: 5 }).runInterval, 5); + // Run the production formatter without booting browser hooks in Node. const hookSource = readFileSync(new URL("../../packages/client/src/hooks/use-generate.ts", import.meta.url), "utf8"); const sourceFile = ts.createSourceFile("use-generate.ts", hookSource, ts.ScriptTarget.Latest, true);