Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/agents/built-in-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions docs/media/illustrator-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions e2e/illustrator-manual-cadence.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
14 changes: 14 additions & 0 deletions e2e/roleplay-commands.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/components/agents/AgentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3334,6 +3334,9 @@ export function AgentEditor() {
{localizeUi("ui.agents.agenteditor.messages")}
</span>
</div>
<p className="mt-1 text-[0.625rem] text-[var(--muted-foreground)]">
{localizeUi("agents.illustrator.runIntervalChatDefaultHelp")}
</p>
<p className="mt-1 text-[0.625rem] text-[var(--muted-foreground)]">
{localizeUi("ui.agents.agenteditor.theIllustratorCanOnlyCreateANewImageOnce")}
</p>
Expand Down
13 changes: 9 additions & 4 deletions packages/client/src/components/chat/AgentAddSetupFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
const patch: Record<string, unknown> = {};
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Preserve explicit cadence when re-adding Illustrator

Location: packages/client/src/components/chat/AgentAddSetupFields.tsx:474 (RIGHT)

The add flow mistakes equality with the agent default for permission to erase a chat override. With an explicit chat interval of 0 and an agent default subsequently changed to 0, removing and re-adding Illustrator prefills 0, then writes null because the values match. A later agent-default change to 5 therefore restores automatic images in a chat whose manual-only choice was never reset.

Tip

Suggested fix: Preserve an existing explicit interval through reactivation, even when it matches the agent default; let the explicit reset control restore inheritance.

}
}
if (agentId === "haptic") {
patch.enableHapticFeedback = setup.hapticFeedbackEnabled;
Expand Down
67 changes: 59 additions & 8 deletions packages/client/src/components/chat/ChatSettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Inherited string intervals disagree with execution

Location: packages/client/src/components/chat/ChatSettingsDrawer.tsx:2015 (RIGHT)

The card invents a default: with no chat override, persisted runInterval: "0" survives the settings merge but the numeric-only normalizer displays 5, while execution treats it as manual-only. Positive numeric strings other than "5" produce the same display mismatch. Committing the apparent 5 cannot repair the discrepancy, because the unchanged-value guard suppresses that write.

Tip

Suggested fix: Give the display the runtime's interpretation of inherited intervals, including supported numeric strings; a default should describe execution, not contradict it.

const illustratorAutoBackgroundsEnabled = metadata.illustratorAutoBackgroundsEnabled === true;
const selectedIllustratorPromptConnectionMissing =
illustratorPromptConnectionId.length > 0 &&
Expand Down Expand Up @@ -2226,6 +2233,37 @@ export function ChatSettingsDrawer({
</span>
</label>
);
const renderIllustratorRunInterval = () => (
<div className="flex flex-col gap-1">
<label className="flex flex-col gap-1">
<span className="text-[0.625rem] font-medium text-[var(--foreground)]">
{localizeUi("ui.agents.agenteditor.runInterval")}
</span>
<span className="flex items-center gap-2">
<DraftNumberInput
value={illustratorRunInterval}
min={0}
max={MAX_ILLUSTRATOR_RUN_INTERVAL}
onCommit={(value) => {
// Leaving the field unchanged must not turn the agent default into a chat value.
if (value !== illustratorRunInterval) updateMeta.mutate({ id: chat.id, illustratorRunInterval: value });
}}
className="w-24 rounded-lg border border-[var(--border)] bg-[var(--background)] px-2.5 py-2 text-xs tabular-nums text-[var(--foreground)] outline-none transition-colors focus:border-[var(--primary)]/50"
/>
<span className="text-[0.625rem] text-[var(--muted-foreground)]">
{localizeUi("ui.agents.agenteditor.messages")}
</span>
</span>
<span className="text-[0.625rem] leading-snug text-[var(--muted-foreground)]">
{localizeUi("agents.illustrator.chatRunIntervalHelp")}
</span>
</label>
<AgentDefaultStatus
overridden={illustratorChatRunInterval !== null}
onReset={() => updateMeta.mutate({ id: chat.id, illustratorRunInterval: null })}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: A pending interval save can overtake the reset

Location: packages/client/src/components/chat/ChatSettingsDrawer.tsx:2263 (RIGHT)

The reset dispatches null independently of the preceding interval save; the metadata hook's ordering queue is opt-in and this drawer does not enable it. If the positive-interval request reaches storage after the reset, that older choice becomes the persisted override despite the newer interaction. With an agent default of 0, the supposed return to manual-only behavior instead restores automatic generation.

Tip

Suggested fix: Route interval commits and resets through the same per-chat save queue, so transport timing cannot choose the final cadence.

/>
</div>
);
const proseGuardianBannedWords =
typeof metadata.proseGuardianBannedWords === "string"
? metadata.proseGuardianBannedWords
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
},
}),
});
Expand Down Expand Up @@ -8630,6 +8680,7 @@ export function ChatSettingsDrawer({
/>
{renderIllustratorPromptConnectionSelect()}
{renderIllustratorImageConnectionSelect()}
{renderIllustratorRunInterval()}
<AgentSettingsToggle
label={localizeUi("ui.chat.chatsettingsdrawer.generateSceneBackgrounds")}
description={localizeUi(
Expand Down Expand Up @@ -9346,6 +9397,7 @@ export function ChatSettingsDrawer({
}
/>
{renderIllustratorPromptConnectionSelect()}
{renderIllustratorRunInterval()}
{renderIllustratorImagesPerGeneration()}
<AgentSettingsToggle
label={localizeUi("ui.chat.agentaddsetupfields.attachCardAppearance")}
Expand Down Expand Up @@ -10266,12 +10318,11 @@ export function ChatSettingsDrawer({
)}
<span className="text-[0.6875rem] text-[var(--muted-foreground)]">{agentAddIntervalMeta.unit}</span>
</div>
{agentAddPreview.agent.id === "illustrator" && (
<p className="text-[0.625rem] text-[var(--muted-foreground)]">
{localizeUi("agents.illustrator.manualOnlyIntervalHelp")}
</p>
)}
<p className="text-[0.625rem] text-[var(--muted-foreground)]">{agentAddIntervalMeta.help}</p>
<p className="text-[0.625rem] text-[var(--muted-foreground)]">
{agentAddPreview.agent.id === "illustrator"
? localizeUi("agents.illustrator.chatRunIntervalHelp")
: agentAddIntervalMeta.help}
</p>
</div>
)}

Expand Down
Loading
Loading