diff --git a/.github/plans/noodl-split/noodler-access-and-onboarding.md b/.github/plans/noodl-split/noodler-access-and-onboarding.md new file mode 100644 index 0000000000..74d1de1c89 --- /dev/null +++ b/.github/plans/noodl-split/noodler-access-and-onboarding.md @@ -0,0 +1,1517 @@ +# Slice 8f — Access Model, Onboarding, and Scheduling Overhaul + +Detail design document for Slice 8f. The +[NoodleR PR-Split Living Plan](./noodler-pr-split-living-plan.md) remains the planning +authority for slice ordering and product decisions; this file owns 8f's design detail +because it does not fit the Living Plan's per-slice section length. + +**Status as of 2026-07-31: 8f-1, 8f-2, 8f-3, and 8f-4 are implemented on branch +`big-chungus-1` and unmerged; 8f-5 and 8f-6 have not started.** The design below is +therefore a record of what shipped for those four units, not a forward plan. Open questions +are listed at the bottom and are part of the document, not a defect in it. Update the +changelog when decisions land. + +**Reviewer note:** the scheduling decision is settled. Slice 8f-3 uses front-loaded +generation with a private scheduled-post reserve. The former platform-day-plan/catch-up and +gap-backfill candidates are retired. Everything outside that section is settled unless +marked otherwise. + +8f ships as **six independent units, 8f-1 through 8f-6** — see "Shipping units" under +Rough plan. This document designs all six; it is not one PR. + +## Problem + +**Access confusion.** Follow, Subscribe, and PPV sit side by side without making clear +what depends on what. The per-creator `subscriptionIncludesPpv` switch makes it worse: it +silently redefines what `subscriber` and `ppv` mean for that creator. Users cannot reason +about it, and the generating model faces a three-way enum whose meaning depends on a +separate account-level boolean. + +**Onboarding.** After the age gate the user lands in an empty NoodleR home with no next +step. Two user groups exist — those who want it to just work, and those who want precise +control — and one system has to support the other so users can grow from the first into +the second instead of being stuck in a mode. + +**Scheduling.** NoodleR's automatic posting is `now + 24h/intensity ± 25% jitter` +(`noodle-autopost-cadence.ts`). It only works while the server is running, cannot show a +reliable future shape, can clump, and has no notion of what a character is plausibly doing +at publication time. Generating missed work on restart either creates a provider burst or +fabricates a past. Slice 8f-3 instead prepares future posts while Marinara is already +running and publishes those prepared items at their assigned times. + +## The access model + +### A profile has two levels + +- **Follow** — decides whether the creator appears in your NoodleR feed at all. + Follow is attention. +- **Subscribe** — decides whether you can read that creator's locked posts. + Subscribe is access. + +**Subscribing implies following.** Being subscribed to a creator who does not appear +anywhere is a state nobody wants to create on purpose. Unfollowing while subscribed +remains possible, but only as a deliberate extra action. + +**This is not in tension with "no auto-follow", and the sheet says so out loud.** Someone +who subscribes to a service wants the service delivered: a subscription that did not put +the creator in your feed would be a subscription to nothing. The rule the no-auto-follow +decision protects is that *the system* never follows on the user's behalf — bulk-creating +40 creators must not manufacture 40 follows. Subscribing is a deliberate user act, so the +follow it carries is one the user performed. The Unlock sheet's Subscribe row states the +consequence in its own label rather than leaving it silent. + +### The feed has two tabs + +Today: `feedTab: "all" | "subscribed"` — "All creators" and "Subscribed". This becomes: + +- **Following** — the curated feed. +- **All creators** — the discovery surface. + +The Subscribed tab is dropped. Whether you are subscribed is visible on the post itself +by the absence of a lock; it does not need its own tab. Two tabs, two jobs. + +**Decided: no auto-follow. Following is the default tab; onboarding lands on All creators +because Following starts empty.** These are two separate facts, not one. Bulk creation does +not follow the creators it makes, because a follow the user never performed is a curation +decision made on their behalf, and Follow only carries meaning if the user did it. The tab +the UI opens to on every ordinary visit is Following — that is the curated feed and the +point of the product. But immediately after onboarding, Following has nothing in it, so the +wizard's completion step selects All creators instead, one time, so the user does not land +on an empty screen. An almost-empty Following tab is not a defect to engineer around; public +Noodle starts that way too — but "starts empty" is not the same claim as "opens by default." + +### A post has three states + +**public**, **locked**, and — after unlocking — **unlocked**. + +Posts no longer carry a PPV button or a Subscribe button. They carry a single **Unlock** +button, whose sheet offers exactly two choices: + +- `Unlock this post` — just this one post +- `Subscribe` — everything from this creator + +**No prices are shown — in the UI. There is a real cost underneath.** Decided 2026-07-29: +pricing is coming, so the two actions carry fixed coin costs from the start rather than +being retrofitted onto a product built around their absence: + +| Action | Cost | +| --- | --- | +| Unlock this post | 1 coin | +| Subscribe | 5 coins | + +Every user starts with a balance of **999999**. Costs are charged and the balance is +decremented, but neither the price nor the balance is rendered anywhere in 8f. The two rows +still distinguish themselves by *reach*, not by price, because price is not visible yet. + +**Be honest about what this is.** A 999999 starting balance means nothing is gated in +practice: the ledger runs, the arithmetic is real, and no user reaches zero. The value is +that `unlockPost` and `subscribe` become genuinely charging operations now, so making prices +visible later is a UI change and a balance change rather than a data-model change. It is +scaffolding with the load-bearing part already in place, not an economy. + +This supersedes the earlier no-prices-ever framing, which rested on Slice 9b — a slice in +the band marked as possibly never shipping. 9b's support points remain a separate +non-spendable *score* and do not become currency; coins are the spendable axis and points +are not. Two different numbers, deliberately. + +### Coin storage and charging + +**No new table, no ledger.** The balance is a typed leaf on the viewer's existing account +settings, which the storage normalizer already rebuilds field by field +(`noodle.storage.ts:243`). It therefore inherits the "migrates for free" property this +document already documents for settings fields: a persisted key that appears with a +default needs no data step, and existing users pick up the default on first read. + +```ts +interface NoodleWalletSettings { + coins: number; // integer >= 0; default 999999 +} +``` + +Storage has `normalizePersistedBoolean` (`noodle.storage.ts:172`) but **no integer +equivalent** — 8f-2 adds one beside it, `normalizePersistedInteger`, returning `undefined` +for anything non-finite, non-integer, or negative. Then `normalizePersistedInteger(raw.coins) +?? 999999`, so a missing, corrupt, or negative stored value resolves to the default rather +than to zero. Falling back to zero would lock a user out of their own content on one bad +write; the balance is not a security boundary, so the forgiving default is the correct one. + +That helper is not single-use: 8f-3 needs the same normalization for `postsPerDay` (integer, +1..24, default 4). 8f-2 adds it, 8f-3 reuses it with its own bounds. + +**Which account holds it.** The viewer's Noodle **persona** account — the same axis follows +and subscriptions already use. Both `subscribe()` and `unlockPost()` require +`viewer.kind === "persona" && viewer.platform === "noodle"` and already load that row inside +their transaction, so the balance is in hand with no extra read. One persona's spending does +not affect another's, matching how `followingAccountIds` and subscriptions are already scoped. +Creator accounts have no balance and receive nothing: there is no earning path in 8f. + +**Where the charge goes.** Both operations are already a single `db.transaction` that +early-returns the existing row when one is present. Put the debit on the **insert path only**, +in that same transaction: + +1. resolve viewer/creator/post and run the existing validity checks — unchanged; +2. if a subscription or unlock row already exists, return it **without charging**; +3. otherwise check `coins >= cost`; if not, return `null`; +4. write the debit and insert the row in the same transaction. + +Step 2 is what makes charging idempotent, and it is already written — re-subscribing cannot +double-charge because it never reaches the insert. Step 3 reuses each function's existing +`null` return, so no new failure channel, route contract, or error type is introduced. The +unique-constraint retry both functions already have stays as the crash-safety net. + +**Insufficient balance is unreachable in 8f** at a 999999 start with no spending path, so +step 3 is a guard rather than a user-facing state. It is specified anyway because the code +needs an answer, and because the moment prices become real it is the branch that matters. +No dedicated empty-wallet UI belongs in 8f. + + + +**Proof for the coin path:** subscribing twice charges 5 once; unlocking the same post twice +charges 1 once; a viewer at 0 coins is refused and no row is written; a corrupt or absent +stored balance reads as 999999; and two personas on the same user spend independently. + +### Not every post is locked + +Automatic posts still default to locked, but generation may deliberately produce public +teaser posts. A brand-new creator that is nothing but a wall of padlocks gives a +non-subscriber no reason to care. + +Public teasers are visible to non-followers in the **All creators** tab. That is what the +discovery tab is for: you see a public post from a creator you do not follow, it +interests you, you follow. Without this the teaser is decoration; with it, it has a job. + +## Scheduling rework + +**Decision: front-loaded generation with a scheduled-post reserve.** The two earlier +candidates, a platform day plan with startup catch-up and gap backfill with chosen past +timestamps, are replaced. Both started from the assumption that content must be generated +when it becomes due. NoodleR instead prepares ordinary scheduled posts while Marinara is +already running, then publishes those prepared posts when their assigned times pass. + +This matches the product fiction better. A creator can prepare and schedule content ahead +of time, as creators on the service NoodleR draws from do. The user returns to an ordinary +chronological feed containing posts at their planned publication times. The audience feed +gets no special return-time surface and no startup batch pretending that newly generated +content existed earlier. + +### Product contract + +1. **Both user populations get useful behaviour.** Always-on users see posts publish as + their times arrive. Start-and-quit users can still receive activity from the reserve + prepared during their previous session. +2. **One user-set number produces explicit automatic cost ceilings.** The user chooses N + automatic posts per day. Automatic text-generation attempts, including failed paid + attempts, cannot exceed N in any rolling 24 hours. If automatic images are enabled, a + separate derived image-attempt ceiling is also N, so combined automatic provider load is + at most 2N attempts. The UI states both numbers. +3. **One post remains one text-provider call.** NoodleR generation is per creator because + stage identity, disclosure, redaction, and operation locking are per creator. +4. **Publication times are real commitments.** A post is generated before its publish time. + NoodleR never generates a post after that time and then assigns it a fabricated earlier + time. +5. **Per-stage-profile enablement survives.** A creator can be removed from automatic + posting without disabling the product or other creators. +6. **The process may be offline without owing work.** A depleted reserve means fewer posts, + not catch-up debt, a later burst, or an invented history. +7. **Foreground work wins.** Reserve preparation is sequential, low priority, and must not + begin while Marinara has an active foreground request on the same configured connection. + An already-running provider call is never preempted. + +The user-facing wording is therefore **"up to N automatic posts per day"**. N is both the +maximum publication density the planner targets and the automatic text-attempt ceiling. +Provider failures, an empty reserve, ineligible creators, or the process being offline +longer than the reserve horizon may deliver fewer. + +### Generation and publication are separate operations + +A prepared item has two relevant times: + +- **generated at**: internal operational metadata recording when provider work finished; +- **publish at**: the future time chosen before generation and later used as the feed + post's creation/publication time. + +Generation performs the expensive work. Publication is a local, idempotent database +transition with no provider call. While the server is running, a timer publishes due +items near their assigned times. After a restart, reconciliation publishes every valid due +item from the existing reserve using its already-persisted publish time. + +This is not backfill. A due item already existed, with its content and publication time, +before the gap. Reconciliation merely makes the time-based state visible after the process +returns. + +### The rolling reserve + +The first release maintains a fixed rolling reserve horizon of **24 hours**. Do not add a +second onboarding choice for reserve length. If real use shows that one day is too short, +an advanced 1/3/7-day control can be designed later with its storage and provider-cost +consequences visible. + +The planner: + +1. Reads the future publication times already covered by valid prepared items. +2. Distributes uncovered times across the next 24 hours using the same windowed random + placement principle as public Noodle, constrained by night quiet and character + schedules. +3. For the earliest uncovered time, chooses one eligible creator, generates one post, and + stores it as prepared. +4. Repeats gradually while Marinara remains running, never exceeding concurrency 1 or the + rolling automatic-attempt budget. +5. Stops when the horizon is covered, the budget is exhausted, foreground provider work + needs the connection, or no creator is eligible. + +The reserve is allowed to be incomplete. Initial activation warms it gradually rather +than launching N calls. The existing explicit first-post action may create visible posts +now; it is not silently repurposed into a reserve-filling burst. If a future "Prepare +offline activity now" action is added, it must show the exact estimated text and image call +count and require an explicit user action. + +Changing `postsPerDay` reconciles only future coverage. Raising it adds uncovered future +times gradually. Lowering it discards the latest excess prepared items and cleans up their +owned media, but does not release attempt claims already made; preparation waits until +rolling usage falls below the new limit. Neither direction publishes an item immediately +or creates historical work. + +**The old default of 24 posts per day is not carried forward.** **Decided 2026-07-29: the +default is 4, with a 1..24 validation range.** Four is deliberately low — it is a visible +day's activity for a small library, it caps automatic load at 4 text attempts plus at most 4 +image attempts per rolling 24 hours, and it is a number a user raises once they want more +rather than one they discover by being billed. It ships as the default rather than blocking +8f-3 behind provider measurement; the two real runs (one paid provider, one local model) +remain worth doing to tune it, but they are no longer a gate. + +### Foreground provider work has admission priority + +Concurrency 1 inside the reserve is not enough: chat, Guide, or another creator can use the +same local connection outside the reserve scheduler. Add one narrow connection-scoped +admission seam shared by foreground and background model operations: + +- foreground operations register active use of the configured connection; +- background preparation may acquire one background lease only when no foreground use is + active and the connection has been idle for 30 seconds; +- a foreground request arriving after a background call started does not cancel or preempt + that call, but no further background call starts; +- failure to acquire the lease leaves the reserve incomplete and retries later with normal + backoff. + +This is admission priority, not a general provider job queue. It coordinates only +Marinara-owned work on the same configured connection and cannot detect another external +program using a local-model server. + +### Creator selection and plausible timing + +A future publication time is chosen first. The creator is then selected from accounts that +will plausibly be available at that time: + +- NoodleR and the global automatic schedule are enabled; +- the stage profile's auto-posting switch is enabled; +- the account and its source resolve; +- the character is not asleep or busy according to its weekly schedule; +- characters without a schedule pass the configured night-quiet window. + +If nobody is eligible, that time is left uncovered. It is not moved into the morning and +does not become debt. + +Use a NoodleR-specific selector. Public Noodle's +chooseNoodleParticipantAccounts() also contains invitation, follow, random-user, priority, +and recent-activity semantics that are not the promised NoodleR rule. The private selector +orders by least recent published activity while also considering already prepared future +items, then uses a deterministic stable tie-break. Otherwise one quiet creator can own the +whole reserve before any of those prepared posts have published. + +The generation prompt receives the intended publication time and the permitted schedule +context so that the content fits the time it will appear. It must not claim knowledge of +events created after generation. + +Front-loading deliberately trades some freshness for offline continuity. Prepared +automatic posts are standalone snapshots: they cannot answer a viewer interaction that +has not happened yet or depend on another future post. Slice 8g replies remain reactive +work generated from the interaction path, not reserve content. The 24-hour horizon bounds +how stale an otherwise valid prepared post can become. + +### State belongs in a NoodleR-owned outbox + +Prepared content must not be placed into the ordinary post table with only a future +createdAt value. Existing projections and queries assume rows in that table are published, +so doing that risks exposing locked future content or making it reachable through an +unrelated endpoint. + +Use a capability-owned NoodleR outbox. Each item needs, conceptually: + +- a durable item ID and creator account ID; +- generatedAt and publishAt; +- the complete validated NoodleR-post payload; +- ownership of any prepared private media; +- a source/policy/schedule fingerprint sufficient to detect invalidation; +- a typed state of prepared, published, or discarded; provider failures belong in the + attempt ledger, not the outbox. + +Publication atomically inserts the ordinary post with createdAt equal to publishAt and +marks the outbox item published. A unique link from the post to the outbox item makes the +transition idempotent across crashes and restarts. Provider and image work remain outside +the publication transaction. + +The outbox is not raw settings JSON. It owns generated content, lifecycle state, and +possibly media, so it needs the same transactional and cleanup guarantees as other private +content. Typed settings remain under the capability-owned `scheduler.creatorPosts` leaf; +the generated outbox records remain separate from settings. + +Persist a bounded attempt ledger with a durable ID, kind (`text` or `image`), `claimedAt`, +and terminal outcome. Immediately before provider work, after connection admission, claim +an attempt in the same transaction that verifies fewer than the allowed attempts exist in +the preceding 24 hours. The provider call starts only after that transaction commits. +Completed calls and potentially billable failures both keep their claims. A crash or +ambiguous outcome after claiming also keeps the claim conservatively; never release it and +risk paying twice. Prune claims only after they leave the rolling window. + +Use a persisted last-observed budget time so a backward wall-clock change cannot create a +fresh allowance. The proof must cover backward and forward clock changes. A forward jump +may expire old claims normally; a later rollback must not grant extra capacity. + +Prepared payloads are never returned through audience-facing projections or ordinary post +endpoints. Operator status exposes counts and publication times, not unpublished bodies or +private media. + +### Revalidation and invalidation + +A prepared post is not permission to ignore later user changes. Immediately before +publication, revalidate that: + +- the product and global automatic schedule are enabled; +- the creator still exists and auto-posting remains enabled; +- the source still resolves; +- disclosure, access, and private-media policy still match the prepared item. + +A creator being disabled or deleted discards that creator's prepared items and cleans up +outbox-owned media. A stage-profile, source-identity, disclosure, or access-policy change +invalidates affected prepared items so future output cannot publish under stale or weaker +policy. Weekly schedule edits, night-quiet changes, and timezone changes also invalidate +affected future items because both creator selection and prompt context depended on them. +The publication path checks all of this defensively in case an import or migration bypassed +an ordinary mutation hook. + +Invalid items are discarded. Do not regenerate synchronously at publication time. Normal +reserve preparation may replace them later while the server is running and budget remains. + +The global schedule switch stops publication immediately without disabling NoodleR. +Prepared future items may remain dormant. When the schedule is re-enabled, expired items +are discarded and still-future valid items may remain; nothing accumulated during the +pause publishes as a burst. + +### Startup and long absences + +If the global automatic schedule is enabled, startup does two cheap things in order: + +1. reconcile valid prepared items whose publishAt has passed; +2. discard invalid prepared items. + +If the schedule is disabled, startup performs neither publication nor preparation. It may +clean up items made invalid by deletion or policy changes, but otherwise preserves still- +future prepared items until the user re-enables the schedule. + +Startup returns after that local work. Only after normal server startup completes and the +configured connection satisfies the ordinary 30-second idle rule may the background +scheduler begin preparing future coverage. Startup itself performs **no provider +generation**, whether called catch-up, refill, or anything else. There is no missed-slot +counter, plannedThrough cursor, min(missedSlots, N), chosen historical timestamp, or +startup concurrency burst. + +If Marinara was closed longer than the reserve horizon, NoodleR eventually goes quiet. +That is the explicit bounded failure mode. On return, due prepared posts still appear at +their real planned times, and new preparation covers the future rather than manufacturing +the uncovered gap. + +### Cost and image generation + +Automatic text attempts are durably claimed immediately before preparation calls the +provider, because that is when cost begins. Failed or ambiguous attempts keep their claim. +Publication itself is free. + +Images remain per creator and per prepared post. If automatic images are enabled, at most N +automatic low-level image-provider attempts may be claimed in any rolling 24 hours, derived +from `postsPerDay`; retries consume another claim and stop when the budget is exhausted. +This is the number the wizard states. A retry helper may improve success rate but may not +silently exceed it. + +Prepared media stays in the private NoodleR namespace, owned by the outbox until +publication. Image failure or budget exhaustion leaves a valid prepared text post under +the existing text-survives-image-failure policy. + +When prompt review is enabled, preparation stores the private text item and its pending +image prompt without blocking the publication time. Approval before publishAt may generate +the image under the image-attempt budget. If the prompt remains pending, is rejected, or +image generation fails when publishAt arrives, publish the text-only post and expire and +clean up the pending prompt. Publication never waits for image work: it atomically closes +the attachment opportunity before publishing. An approved image call still in flight at +that moment may finish, but its result sees the closed item, is cleaned up, and never +mutates the published text-only post. Approval after publication does not retroactively +attach an image; that would be a separate post-edit feature. + +Steady state approaches one preparation call for each published post. The initial reserve +takes time to warm because the automatic ceiling still applies. An explicit user-triggered +generation action may exceed the automatic ceiling only when its estimated call count is +shown before confirmation. + +### User controls and inspectability + +NoodleR-wide settings contain: + +- automatic schedule on/off; +- up to N posts per day; +- automatic text-attempt usage in the last 24 hours; +- reserve status in operational language, for example "6 posts prepared through tomorrow + 18:20". + +Reserve status belongs in Settings or another operator surface, not in the audience feed. +The ordinary feed does not explain that content was prepared in advance. + +Per creator, retain: + +- automatic posting enabled; +- automatic images enabled; +- read-only next prepared publication time when one exists. + +Remove intensity, nextRunAt, and the per-creator recurring reschedule API. The first +version's reserve schedule is inspectable but not individually reschedulable. Editing +future times is not needed to prove the product contract and would require rechecking +schedule eligibility and content fit after every move. + +### Ad-hoc posting is not a second schedule + +Slice 7's creator composer remains the explicit "post now" path. A successful immediate +post makes that creator recently active. For the first release, discard every prepared item +for the same creator where `manualCreatedAt < publishAt <= manualCreatedAt + 60 minutes`. +Sixty minutes is one internal constant, not another user setting. The item is not silently +rewritten or published immediately; normal reserve preparation may replace it later. + +The global "Refresh NoodleR now" action remains explicit user-authorized work. It shows its +estimated call count, uses bounded concurrency appropriate to the configured provider, and +does not masquerade as scheduler recovery. + +Scheduling a user-authored one-off for later remains a separate feature. Do not widen the +automatic reserve into a general-purpose post composer scheduler in 8f-3. + +### Consequence for later fan activity and projects + +Creator-post preparation and fan activity keep separate capability state. Slice 9a may +later prepare fan events against a prepared post or reconcile them after publication, but +8f-3 does not fabricate reactions for an offline gap and does not make fan work a condition +of publishing a prepared creator post. + +Slice 12 projects supply content context when a reserve item is prepared. Because +generation now precedes publication, a project beat must be reserved with the outbox item +and finalized only when that item publishes; discarding the item releases the reservation. +Slice 8f-3 does not implement projects, but it must leave that ownership seam rather than +assuming generation and publication are the same moment. + +### Migration from Slice 8 + +The existing per-account autoPosting.enabled and imagesEnabled values survive. Intensity +and nextRunAt are removed from the type and normalizer; stale persisted keys disappear on a +later write as already documented. + +Initialize an empty reserve, empty attempt ledger, the budget clock watermark at migration +time, and `preparationNotBefore = migrationAt + 24 hours`. The old scheduler has no durable +attempt ledger, so the 24-hour hold is the only way to guarantee its recent calls plus new +automatic calls cannot exceed the new rolling ceiling. Do not translate overdue nextRunAt +values into prepared posts, historical publications, or guessed claims. After the hold, +existing users begin warming the reserve only through normal post-start idle preparation. +Explicit user-triggered generation remains available during the hold and is not presented +as automatic reserve work. + +### Proof required for 8f-3 + +Use controlled-clock/provider regression coverage for: + +- windowed reserve planning and least-recent creator selection; +- atomic pre-provider text/image attempt claims, including paid failures and crashes; +- sequential preparation, connection-scoped foreground admission, and the 30-second idle rule; +- due publication with the preassigned timestamp; +- restart/crash idempotence at the publication transaction; +- startup with due items making zero provider calls; +- schedule pause/re-enable without a release burst; +- creator disable/delete and source/disclosure/access/schedule/timezone invalidation; +- prompt-review approval, expiry, and text-only publication at the fixed deadline; +- the exact 60-minute manual-post invalidation boundary; +- outbox media cleanup; +- an exhausted 24-hour reserve producing no catch-up debt; +- rolling-budget expiry, wall-clock changes, timer shutdown, and active-work draining. + +### This revises Slice 8 + +**Survives:** the global product kill switch, the automatic-schedule switch, per-stage- +profile enablement, per-creator image preference, the shared NoodleR-post generation core, +same-account exclusion, and explicit manual generation through the immediate-publish +operation. + +**Removed:** per-creator cadence, intensity, nextRunAt, recurring per-creator rescheduling, +refresh-times-creators multiplication, startup catch-up, gap backfill, and generated-after- +the-fact historical timestamps. + +**Added:** a private scheduled-post outbox, a rolling future reserve, separate generation +and publication operations, automatic-attempt accounting, policy invalidation, and +idempotent due-item publication. + +## Onboarding + +> **Decided: a real wizard, four steps, front and center.** The "one screen with one +> button" alternative is dropped. We cannot decide everything for the user, but we can make +> every step answerable by pressing Continue — defaults are applied, visible, and +> changeable in place. Disclosure stays in the wizard (see below); it cannot move to the +> creator page, because the creator does not exist yet. + +### One wizard, two densities + +The wizard modal asks **Simple or Advanced** — but these are not two wizards. They are +the same wizard at two densities. + +Simple shows the same steps with defaults applied, collapsed, but with the choices +visible: "All 8 characters · hinted · up to N posts/day · images off". Each line has a *change* +affordance +that expands the corresponding advanced control in place. Advanced is therefore not a +separate mode; it is what happens when you click a Simple line. The learning path is +built in, and there is one code path that can drift instead of two. + +### Wizard steps + +The wizard opens directly after the age gate — there is no separate tutorial screen before +it. Step 1 carries the tutorial content inline, so the wizard is the on-ramp and the lesson +in one place instead of two sequential things that each claim to come first. + +All four are visible as lines in Simple: + +1. **Characters, with the tutorial folded in** — the step opens with the same three concepts + Mari's post used to cover up front (why creators appear in the feed, why some posts are + locked, that characters post on their own), directly above the character list, then every + eligible character, individually selectable, pre-checked **only up to a threshold**. + **Decided 2026-07-29: the threshold is 8.** Above it nothing is pre-checked and "select + all" is an explicit action, because one click on Continue at a 40-character library is 40 + creator creations and 40 stage-profile drafts — 40 provider calls — for a user whose + intent was "let me try this". Eight bounds the worst-case first run at 8 text calls, is + still enough creators for the feed to feel populated rather than empty, and matches the + number the Simple-mode summary line already uses in its example below. Uses + `/noodler/eligible-accounts` and the Slice 8c bulk creation path. +2. **Disclosure** — stays here, because a stage profile cannot be generated without it and + bulk creation happens in this wizard. Asked in product language, not jargon. **Decided + 2026-07-29: the recognition-test phrasing.** The question is *"How openly do your + characters deal with being here?"* and each answer anchors to an imaginable scene rather + than to a property of the profile: + + | Copy | Maps to | + | --- | --- | + | **Openly themselves** — "A friend scrolling past would recognise them instantly. Same name, same face." | `open` | + | **An open secret** — "A friend scrolling past might do a double-take. Different name, but the resemblance is there." | `hinted` | + | **Nobody knows** — "A friend scrolling past would never guess. Nothing connects this profile to them." | `secret` | + + The earlier draft described the *mechanism* ("an alter ego with a different name that a + close look still connects"), which has no referent for a user who has not yet seen a stage + profile — the exact risk the outside review flagged. "A friend scrolling past" supplies + that referent: it is a scene the user can picture before any profile exists, and the three + outcomes differ in one visible way. `hinted` is the hardest of the three to convey and is + the one the double-take line exists for. + + The character list expands underneath for individual exceptions. One concept, one line, + still changeable. +3. **Activity** — one plain number: "up to N automatic posts per day". Explain that N is + also the daily automatic text-attempt ceiling and that Marinara prepares future posts + while it is running. Do not expose reserve horizon, slot multiplication, catch-up, or + provider-concurrency concepts in onboarding. +4. **Images on/off** — whether automatic posts get generated images (Slice 8b). A large + cost and impact factor, so it belongs in the visible flow. + +Plus the night-quiet question for characters without a schedule. + +### Two modes, one button + +The wizard **opens by itself** the first time, immediately after the age gate, from both +paths including Skip. It is not behind a button on first run: the failure this whole slice +exists to fix is landing on an empty page with no next action, and a button the user has +to notice does not fix that. + +Afterwards there is one wizard button in the NoodleR header, reading as "add creators". +The second run is character selection only — the global values are not per-creator and +must not be re-asked or silently overwritten. Existing creators are never overwritten by +a later run. + +### First posts + +The wizard's final step asks whether to generate first posts now, rather than deciding +silently. This is honest about the cost, and it avoids the current failure mode where a +freshly configured NoodleR shows an empty page for hours. It reuses the existing global +"Refresh NoodleR now" path. + +**The prompt states concrete call counts, not just a yes/no.** "Generate now?" alone lets a +user believe they are only configuring a feature. The step must show the actual estimated +provider calls for the choices already made in this wizard run, split by when they happen: +one-time setup ("Create N profiles: about N text requests"), and recurring load ("Automatic +activity: up to P text-provider attempts and P image-provider attempts in any 24 hours" if +images are on, with retries included in those ceilings). Numbers come from this run's own +selections — character count, posts per day, images on/off — not a generic disclaimer. + +**Declining, or a failure, must not leave "All creators has content" as a silent lie.** The +wizard's completion step needs an explicit outcome for each case, not just the happy path: + +- **Declined** — land on All creators with the wizard's own summary state ("first posts not + generated yet — trigger Refresh NoodleR now whenever you're ready"), not a bare empty tab. +- **Generation failed** (partial or total) — show which creators succeeded and which did not, + with a retry action scoped to only the failed ones, not a re-run of the whole batch. +- **Zero eligible characters, or zero selected** — state that plainly and point at the "add + creators" entry point; do not imply a feed that has nothing in it is broken. + +The emulated Mari post in step 1 is a rendered illustration, not creator content, and does +not substitute for any of these states. + +### Tutorial: an emulated post inside the wizard + +There is no explanatory overlay and no coach-mark tour, and no separate tutorial screen +before the wizard — the wizard opens straight after the age gate, and its first step +carries the three concepts inline (see "Wizard steps" above). The same three things are +otherwise misunderstood: why some creators appear in the feed, why some posts are locked, +and that the characters post on their own. + +**Decided 2026-07-29: the teaching post is emulated inside the wizard, not seeded into the +feed.** Step 1 renders a **mock post from Professor Mari** using the real post-card +component — hand-written copy, in her voice, showing a locked example so the padlock and +the Unlock control are explained by a thing the user is looking at rather than by a +paragraph. It is a rendered illustration, not a row in the post table. + +This avoids the plumbing the seeded version quietly required. Mari has no NoodleR account +today; `allowProfessorMari` is a *public Noodle* participation flag, so a real feed post +would have meant minting a `platform: "noodler"` account for her and then answering whether +she is followable, subscribable, deletable, and whether she ever posts again — a creator in +All creators that is not one of the user's characters. None of that is needed to teach three +concepts. A mock card needs no account, no post row, no migration, and no cleanup. + +It also removes the ordering problem the seeded version had: with no auto-follow, Following +is empty on first run, so a post placed there would be invisible at exactly the moment it is +meant to be read. + +Explanatory text in a product like this does not get read; a post does, because reading +posts is the activity — and an emulated post keeps that property. What is lost is the +"scroll back to it later" reference. Accepted: the wizard is reachable again from its header +button, and the three concepts are also what the empty and locked states say in place. + +**Why this is not the thing the "no fabricated posts" rule forbids.** Onboarding content +must never show a user's own character saying something the user did not approve. Mari is a +shipped in-world support voice, not one of the user's characters; the copy is hand-written +rather than generated; and it is now explicitly a mock rather than a stored post, so nothing +is put in anyone's mouth and nothing enters the user's data. + +## User flow + +### First run + +1. User enables NoodleR in settings, passes the age gate (either path, including Skip). +2. **Wizard**, Simple by default, opens immediately — no separate tutorial screen precedes it: + - characters, pre-checked only up to the threshold (see Wizard steps), with the + three-concept tutorial folded into this step + - disclosure, one plain-language question + - up to N automatic posts per day + - images on/off + - night quiet for characters without a schedule + - "generate first posts now?" — shown with estimated call counts for this run's choices +3. Creators are created in bulk and the future-post reserve begins warming gradually under + the automatic daily attempt ceiling. **No auto-follow** — a follow + the user never performed is a curation decision made for them, and Follow only means + something if the user did it. Public Noodle is also near-empty at first; this is normal, + not a defect to engineer around. +4. User lands on **All creators** — the wizard's completion step selects it once, since + Following starts empty. The three concepts were taught by the emulated Mari post in step + 1; nothing is seeded into the feed. If first-post generation was declined or failed, the completion state + says so explicitly instead of implying an empty tab is broken (see "First posts" above). +5. Following fills as the user follows people; from then on Following is the tab the UI + opens to by default. + +### Returning user + +1. Opens NoodleR. Valid prepared posts whose publication times passed are already ordinary + chronological feed posts. Startup performs no provider catch-up and presents no special + absence or synchronization surface. +2. **Following** shows the curated feed; locked posts show a lock and an Unlock button. +3. **All creators** shows public teasers, including from creators not yet followed. + +### Unlocking + +1. User taps **Unlock** on a locked post. +2. Sheet offers "Unlock this post" or "Subscribe — everything from this creator". +3. Either choice reveals content immediately. Subscribing also establishes a follow. + +### Adding creators later + +1. Same wizard button, now reading as "add creators". +2. Existing creators are shown as already present and are left untouched. +3. Only newly selected characters are created. + +## Watching is two thirds of the product + +NoodleR's charter is to show sides of a character that ordinary conversation does not +expose. Measured against that, roughly two thirds of real use is **watching** — opening +NoodleR to see what your characters have been up to — and one third is directing them. + +The slice history has been the other way round: composer, Guide, control plane, cadence, +access tiers, fan simulation, projects. Almost all authoring and machinery. The watching +experience has never had a slice of its own, and it shows in three concrete gaps. + +### The feed is an audience surface + +Authoring lives on the creator's own page — composer, automation state, source-change and +source-missing notices. The feed carries no operator controls. Every operator button in the feed is a +reminder that the whole thing is scenery, which is exactly what the watching mode should +not be reminded of. This continues Slice 7's direction of removing the main-timeline +stage-profile picker. + +Ordering stays strictly chronological, newest first. No interest ranking: a feed whose +order the user cannot predict is a feed in which they quietly miss things. + +### The creator page keeps its two roles apart + +Slice 7 requires one profile surface rather than separate viewer and management modes, +and that stands. But 8f-6 gathers the operator controls into one clearly delimited area +instead of being interleaved with the audience view: the profile reads as a profile — +image, bio, Subscribe, posts — and composer, automation toggle, and the source-change or +source-missing notice sit together below it. + +Same single page, same Slice 7 contract, without Subscribe and Delete sitting side by +side as if they were the same kind of act. + +### What is new since last time + +Two mechanisms, deliberately both: + +- a **counter on the NoodleR entry point**, so there is a reason to come back at all; +- a **divider in the feed** reading "new since your last visit", so returning users can + see where they stopped instead of guessing while scrolling. + +The divider needs one stored timestamp **per viewer persona**, not one per user and not +per-post read state. NoodleR follows and locked-post access are already persona-scoped +(`followingAccountIds`, subscriptions), so a single account-wide timestamp would let +visiting as one persona silently clear another persona's counter and divider despite them +having different Following feeds. The timestamp advances only after that persona's feed has +actually been shown, not merely on app entry. + +### The creator never answers you — 8g + +Viewer interactions are stored (`POST /noodler/posts/:id/interactions`), but no generation +path makes a creator respond to them. The generation service has no notion of replies at +all: it produces posts and nothing else. + +For a product whose payoff is a character addressing you from a role the chat does not +show, this is the missing centre. Five planned slices make *invented* fans talk; nobody +planned for the figure to talk to the *real* user. + +This becomes its own slice, **8g**, after this overhaul and before the fan work. It is +worth more than synthetic fan ambience, and it reuses the interaction plumbing 9a would +otherwise build first. + +## Source drift + +A stage profile is a **snapshot**. The linked Noodle account stores its own name and +handle (see commit 9d3adc21e), and the stage profile's display name, handle, bio, and +`stagePersonality` are drafted once at creation and never re-read. Editing the underlying +character afterwards changes nothing. + +That is mostly the right default — the stage profile is a curated work, not a mirror — +but three consequences need handling. + +### The identity-protection hole + +`protectNoodlerGeneratedIdentity()` redacts exactly two strings: the *stored* account +`displayName` and `handle`. Meanwhile `generateNoodlerStageProfileDraft()` feeds +`sourceText(source.data)` from the **live** character card, including its current `Name:`. + +So for a renamed character with a `hinted` profile: the prompt carries the new name, the +disclosure rule is handed the old one, and output redaction scrubs only the old one. The +new real name can land in the stage profile unredacted. It fires when a stage profile is +re-drafted after a rename. + +This breaks the disclosure promise the Living Plan states as a product guarantee, so 8f +fixes it: redaction must protect the current source identity as well as the stored +snapshot, with a regression covering the rename-then-redraft path. + +#### The fix + +**Two functions are blind, not one.** Both key off the same +`type PublicIdentity = { displayName: string; handle: string }` +(`noodle-noodler-generation.service.ts:55`), built in +`generateNoodlerStageProfileDraft()` as +`{ displayName: publicAccount.displayName, handle: publicAccount.handle }` — the **stored** +account row: + +- `protectNoodlerGeneratedIdentity()` (`:87`) — the redactor, applied to the current draft, + to every line of the source context, to generated output, and to image prompts; +- `stageProfileContainsPublicIdentity()` (`:76`) — the *validator*, the gate at + `noodle-stage-profile-draft.service.ts:190` and `noodle.routes.ts:725,781,826`. + +The validator matters as much as the redactor. It is what refuses a draft that leaked, so a +validator blind to the new name will pass output the redactor also missed. Fixing only the +redactor leaves the gate open. + +**One change at the identity, not seven at the call sites.** Widen the protected set from a +single stored pair to the union of stored **and** current source identifiers. Every consumer +already routes through these two functions, so this is one edit rather than a guard at each +of the seven call sites. The redactor is already list-shaped internally — it builds +`protectedValues`, dedupes, and sorts longest-first — so extra identifiers cost nothing and +the longest-first ordering that makes overlapping names redact correctly is preserved for +free. `stageProfileContainsPublicIdentity()` needs the same widening: `some()` over the set +rather than over the two named fields. + +**What goes into the set** for a character-backed creator: the stored `displayName` and +`handle`, plus the live `name` from the character card — the same `source.data` that +`sourceText()` reads, which is precisely why the two can disagree. Empty and duplicate +entries are already filtered, so a nameless or unchanged source degrades to today's +behaviour. `open` stays exempt by design: it is meant to show the linked identity. + +**Bounded on purpose.** This fixes *identity*, not general drift. A renamed character stops +leaking; a recoloured one is still described by a stale stage profile, because that is +8f-6's source-changed notice, not a redaction bug. Do not widen 8f-1 into drift detection — +it ships alone precisely so it does not wait on that. + + + +**Regression:** create a character-backed creator with a `hinted` profile, rename the source +character, re-draft, and assert the new name appears in neither the provider request nor the +stored draft. Repeat for `secret`. Assert `open` still shows the linked identity, so the fix +cannot pass by over-redacting everything. The existing stub-provider pattern from the Slice 7 +proof already returns deliberately-leaking output; reuse it rather than building a new +harness. + +### Drift is not only about names + +The user-visible cases are broader than renames — "the character now has blonde hair +instead of black" is the same class of problem. Appearance drift additionally affects +generated images, because `noodle-noodler-images.service.ts` builds image prompts from +character appearance while the stage profile still describes the old look. + +### One mechanism: a source-changed notice + +Nothing is applied automatically. Instead the creator page carries one affordance stating +that the source has changed since this creator was created, showing what changed, with +explicit actions: + +- **adopt name and handle** — offered for `open` profiles, which are meant to show the + linked identity. Not offered for `hinted` and `secret`, whose different name is the + entire point. +- **re-draft the stage profile** — feeds the current character card through the existing + draft flow. The user decides whether an edit propagates; hand-written + `stagePersonality` is never silently overwritten. +- **dismiss** — accept the drift. + +Detection should be cheap: persist a snapshot or hash of the source fields used at draft +time on the noodler account, and compare when the creator page is read. No polling, no +background job. + +### Orphaned creators are the same notice + +A character can vanish from a creator's perspective in two ways: deletion, or a profile +import that mints fresh character IDs while the character still exists. Slice 8's merged +fix contains the damage by filtering unresolvable accounts out of generation, but the +user never learns why a creator went quiet. + +8f surfaces it as the same source-changed affordance in a "source missing" variant, with +actions to **relink** the creator to an existing character or **delete** it. Relinking is +always an explicit user choice — never guessed from a matching name, because guessing +wrong would bind the wrong character to an 18+ stage profile. + +## What the pre-8f code provided + +Most of this existed before 8f and mainly needed consolidating. Retired access values in +this inventory describe that migration source, not the current contract. + +| Concept | Where it lived before 8f | +| --- | --- | +| Follow | `followingAccountIds` on the account, `packages/shared/src/types/noodle.ts` | +| Subscribe | `noodle_account_subscriptions` table | +| Feed tabs | `feedTab: "all" \| "subscribed"` in `NoodlerHome.tsx` | +| Post access | `access: "public" \| "subscriber" \| "ppv"` plus `ppvPrice`; unlocks in `noodle_post_unlocks` | +| Gate logic | **one** function: `canViewNoodlerPost()` in `noodler-access.ts` | +| Locked projection | server already returns `locked: true`, nulls content, keeps counts as a teaser | +| Windowed time placement | `noodle-refresh-schedule.ts` — reuse the narrow planning principle, not its persisted refresh state | +| NoodleR cadence | `noodle-autopost-cadence.ts` — the model to replace | +| Per-creator enable | `autoPosting.enabled` — already the eligibility flag | +| Character schedules | `WeekSchedule` in `conversation-presence.ts`, opt-in via `includeCharacterSchedules` | +| Per-creator wizard | `source → disclosure → draft → automatic` in `NoodlerHome.tsx` | +| Age gate | `NoodlerAgeGate.tsx`, ends in `enableNoodler()` — and then nothing | +| Bulk creation | `POST /noodler/accounts/bulk` and `NoodlerBulkCreatePanel` (Slice 8c) | +| Generate now | global "Refresh NoodleR now" from Slice 8 | + +Before 8f-2, `unlockPost` charged nothing and `ppvPrice` was display-only. 8f-2 changes the +first of those: `unlockPost` and `subscribe` become charging operations against a new coin +balance (1 and 5, default 999999, nothing rendered). `ppvPrice` is deleted rather than +repurposed — it was a per-post display number, while coin costs are two fixed constants. + +## Rough plan + +Deliberately without file and line lists — those come once the design settles. + +### Shipping units + +8f is **six units, not one slice**. They share no code and no risk profile, and bundled +they guarantee the outcome the Outside review predicts: the watching work is what gets cut +when the slice runs long. + +| Unit | Steps below | Depends on | +| --- | --- | --- | +| **8f-1** identity-redaction fix | Source drift § "The identity-protection hole" | nothing | +| **8f-2** access collapse | A, B, C, D, H | 8e | +| **8f-3** scheduling | E | 8f-2 | +| **8f-4** onboarding | F, G | 8f-2, 8f-3, 8c | +| **8f-5** watching surface | "What is new since last time" | 8f-2 | +| **8f-6** creator operator area and source drift | I and J | nothing beyond 8f-1 | + +8f-1 is a defect, not a design change: a `hinted` or `secret` profile can emit the real +name after a rename. It ships on its own and must not wait behind a scheduler rewrite. + +8f-5 is the cheapest item in this document — one stored timestamp, no provider budget — and +is split out precisely so a scope cut cannot eat it. + +**Slice 8g sequences after 8f-2 and before 8f-3.** It needs the settled access and +interaction model, which is 8f-2; it needs nothing from scheduling, onboarding, or the +divider. Putting it behind all of 8f defers what this document itself calls the missing +centre behind two units it does not depend on. + +### Steps + +**A. Consolidate access.** `access` collapses to `public | locked`. `ppvPrice` and +`subscriptionIncludesPpv` are removed. Gate rule: public is visible, otherwise subscribed +**or** individually unlocked. Every reader routes through one function, so this is a +single edit plus a migration, following the pattern Slice 8e establishes in +`noodle-platform-migration.ts` (on staging since #4129). +See **Migration** below — the data step is not optional and the read-normalizer's fallback +is a security decision. + +**B. Follow as curation.** Feed tabs become Following / All creators. Subscribe +establishes a follow. The viewer scope filters by follow for the Following tab. + +**C. Simplify generation.** The model decides locked yes/no, and may choose public for +teasers. Affects the structured response format and prompt fragments. + +**D. Post UI.** One Unlock button; behind it a sheet with two options. + +**E. Scheduling.** Replace interval cadence with a 24-hour rolling reserve of private, +pre-generated scheduled posts. Separate low-priority sequential preparation from +idempotent publication, store prepared content in a capability-owned outbox, enforce one +automatic text-attempt ceiling, and invalidate stale items when creator or policy state +changes. Delete `intensity` and `nextRunAt`, and replace **four** touch points, not the one +file earlier drafts named (verified against staging 2026-07-29; last touched by `273d70fc8`, +the 8e rename): + +| Touch point | What 8f-3 does to it | +| --- | --- | +| `services/noodle/noodle-autopost-scheduler.service.ts` | the historical per-creator poll loop — claimed a due run by advancing `nextRunAt` before provider work. This was the rewrite's primary target. It shipped `MAX_CONCURRENT_AUTOPOSTS = 2`, which the reserve's concurrency-1 rule replaced. | +| `services/noodle/noodle-autopost-cadence.ts` | delete; the `now + 24h/intensity ± jitter` helper has no successor | +| `app.ts:42` | `startNoodleAutoPostScheduler` registration — repointed at the reserve scheduler | +| `services/storage/noodle.storage.ts:56` | imports `nextAutoPostRunAt` from the cadence helper; the import and its call sites go with it | + +Startup +materializes valid due items without provider work and resumes preparing only future +coverage. Night quiet and character schedules constrain future publication planning. + +**F. Setup wizard.** Opens once after activation from both age-gate paths; afterwards +from a header button in "add creators" mode. Mostly a shell over existing endpoints. + +**G. Tutorial.** Folded into the wizard's first step (three concepts), taught through an +emulated Professor Mari post rendered with the real post card — no separate tutorial screen, +no seeded feed post, and no NoodleR account for Mari. + +**H. Docs.** `docs/noodle/overview.md` and `docs/noodle/settings.md` describe the old +three-level model and `subscriptionIncludesPpv`. Translated packs on `docs-i18n` must +follow, or a `[docs-i18n]` follow-up issue must be opened. + +**I. Creator-page role separation.** Gather composer, automation toggle, and the +source-change or source-missing notice into one delimited operator area below the profile, +per "The creator page keeps its two roles apart". Still one surface, per Slice 7. 8f-6 owns +the area because its notice states and actions determine the complete operator layout. + +**J. Source-changed notice.** Snapshot the source card fields used at draft time on the +noodler account, compare on creator-page read, and surface one notice with adopt +name/handle (`open` only), re-draft, and dismiss — plus the "source missing" variant with +relink or delete. Per "Source drift"; the redaction hole in that section is 8f-1 and ships +separately. + +## Migration + +Three of the four removals need no data step at all, and the fourth needs a careful one. + +### Which unit owns which step + +This section is one analysis but **two separately shipped migrations**, and the ordering +rule below is not the property of whichever one goes first: + +| Step | Owner | +| --- | --- | +| `posts.access` → `public \| locked`, drop `ppvPrice`, fail-closed normalizer | **8f-2** | +| Drop `subscriptionIncludesPpv` from the settings type and normalizer | **8f-2** | +| Drop `intensity` and `nextRunAt` from the settings type and normalizer | **8f-3** | +| Initialize the outbox, attempt ledger, budget watermark, and the `migrationAt + 24 hours` preparation hold | **8f-3** | + +**The rule that a data step runs before the normalizer that reads it applies to both units, +not only to 8f-2.** 8f-2's is the dangerous one — an unmigrated row read by a collapsed +normalizer becomes world-readable — but 8f-3's settings removals must still land in the +order described here, and 8f-3's own initialization must be transactional in the same way. +Neither unit may assume the other has already run: 8f-3 ships after 8f-2, but a user may +upgrade across both at once, so each migration states its own preconditions and is +idempotent on re-run. + +### What migrates for free + +Account settings are rebuilt field by field on read (`noodle.storage.ts:243`, +`normalizePersistedBoolean(rawAccess.subscriptionIncludesPpv) ?? false`). A field that the +normalizer stops reading is simply not carried into the object and disappears on the next +write. So `subscriptionIncludesPpv`, `intensity`, and `nextRunAt` need **no** migration — +deleting them from the type and the normalizer is the migration. Stale keys may linger in +stored JSON until the row is next written, which is harmless because nothing reads them. + +`noodle_post_unlocks` rows also survive untouched: individual unlock stays a real concept +under the new model, so no row is orphaned and no cleanup is needed. + +### What does not: `posts.access` + +Post access is normalized on read at `noodle.storage.ts:510`: + +```ts + access: row.access === "public" ? "public" : "locked", +``` + +**Anything unrecognized becomes `public`.** If the type collapses to `public | locked` and +this line is rewritten to recognize `"locked"` before the stored rows are migrated, every +existing `subscriber` and `ppv` post in the database silently becomes world-readable. That is +the exact failure Slice 9e prohibits, arriving through a normalizer default rather than +through a leak in the projection. + +Two consequences, both mandatory: + +1. The data migration (`subscriber` → `locked`, `ppv` → `locked`, `ppvPrice` → dropped) runs + **before** the normalizer changes, in one transaction, in the 8e migration pattern. +2. The new normalizer must **fail closed**: unknown value → `locked`, not `public`. An + unreadable access value is not evidence that a post is public. This also makes the + migration order forgiving instead of catastrophic if the two land out of step. + +### The one real behaviour change + +The collapse is not purely cosmetic. The pre-8f-2 gate (`noodler-access.ts:12-15`) said a `ppv` +post is visible only if individually unlocked, *or* if the viewer subscribes **and** the +creator has `subscriptionIncludesPpv` enabled. For a creator with that flag **off**, a +subscriber could not see `ppv` posts at all. After the collapse the rule is +`subscribed || unlocked`, so those posts become visible to that subscriber. + +Migration therefore *reveals* previously hidden posts — it never hides a visible one. That is +acceptable here, and worth stating rather than discovering: NoodleR is single-user and local, +the "subscriber" is the person who owns the character, and nobody loses access they paid for +because nothing was ever paid. It is also irreversible, so the migration is forward-only by +design; a rollback cannot reconstruct which locked posts used to be `ppv`. + +If that reveal is ever unacceptable, the alternative is migrating `ppv` → `locked` **plus** +seeding an unlock row for nothing — i.e. keeping them hidden from subscribers, which +contradicts the whole point of the two-level model. Not recommended; named so the choice is +visible. + +### Code paths that reference `ppv` directly + +`ppvPrice`/`subscriptionIncludesPpv` appear in **14 files**, not the two the earlier draft +of this section named and not the 12 the 2026-07-28 pass counted. Re-verified 2026-07-29 +against staging: + +| Layer | Files | +| --- | --- | +| server | `services/storage/noodle.storage.ts`, `routes/noodle.routes.ts`, `services/noodle/noodler-access.ts`, `services/noodle/noodle-noodler-post.operation.ts`, `services/noodle/noodle-noodler-generation.service.ts`, `db/schema/noodle.ts` | +| shared | `schemas/noodle.schema.ts`, `types/noodle.ts` | +| client | `NoodlerHome.tsx`, `hooks/use-noodle.ts` | +| i18n | `locales/en.json`, `locales/ko.json` | +| regressions | `scripts/regressions/noodle-prompt.regression.ts`, `scripts/regressions/noodle-settings.regression.ts` | + +Note the paths: `noodle.storage.ts` lives under `services/storage/`, not `services/noodle/`. +The two operation/generation filenames carry the post-8e `noodler` spelling. + +Known line-level anchors, corrected against staging: + +| Anchor | What is there | +| --- | --- | +| `noodle.storage.ts:510-511` | the post projection — the fail-closed normalizer and `ppvPrice` | +| `noodle.storage.ts:797,813` | the poll-vote transaction's own `access === "ppv"` branch and `subscriptionIncludesPpv` read | +| `noodle.storage.ts:1588` | `ppvPrice: input.access === "ppv" ? ... : null` on write | +| `noodle.storage.ts:2412` | the public-persona viewer rule keyed on `access !== "ppv"` | +| `noodle.routes.ts:335,409` | `subscriptionIncludesPpv` passed **into** `canViewNoodlerPost` | +| `noodle.routes.ts:364` | `ppvPrice` returned on the post response | +| `noodle.routes.ts:654` | an unlock-path guard keyed on `access !== "ppv"` | + +Two different edits hide behind that list, and conflating them is how the "single edit" +framing went wrong. `routes:335,409` and `storage:813` disappear because +`canViewNoodlerPost`'s `subscriptionIncludesPpv` **parameter** disappears. `routes:364` and +`storage:511` disappear because the `ppvPrice` **field** disappears. The rest are `ppv` +branches that stop having a value to match. + +**The generation service and the shared schema are on this list, which the rough plan's +"single edit plus a migration" framing understates.** `noodle-noodler-generation.service.ts` +holds the structured response format the model fills in, so step C is not optional +polish — the model cannot emit an access value the schema no longer has. + +**Slice 10 is merged and its contract names PPV.** Slice 10 shipped the composer's +`ppvPrice` field and states that literal Post publishes "title, body, image, poll, access, +and PPV values". Removing `ppvPrice` therefore changes merged, shipped behavior: the price +field and the PPV access option come out of the composer. Recorded in the Living Plan's +Slice 10 section as well, so the change is not discovered during implementation. + +**Localization is only partly translated.** The three `ppv` keys exist in `en.json`; `ko.json` +carries two of them and the other ten locales carry none. Deleting the keys is safe in all +twelve, but the replacement Unlock-sheet strings land untranslated everywhere except English — +that is the existing state for NoodleR strings, not a regression this slice introduces. + +### Regression to write + +Extend the 8e migration regression: a fixture with one `public`, one `subscriber`, one `ppv` +with `ppvPrice`, and one `ppv` with an existing unlock row, against a creator with +`subscriptionIncludesPpv` false. Assert after migration that all three non-public posts are +`locked`, `ppvPrice` is gone, the unlock row still grants access, and — the important one — +that a **non**-subscriber still sees none of them. + +### Regressions to update — not optional, and not discovered later + +Two shipped suites already encode the enum being deleted, so they stop compiling the moment +the type collapses. Both are on the standing validation list, so 8f-2 turns them red on day +one unless it updates them in the same change: + +- `scripts/regressions/noodle-prompt.regression.ts` — ten references, including a + `subscriptionIncludesPpv: true` case at `:548` that exists **specifically** to cover the + subscriber-sees-ppv branch 8f-2 removes. That case does not get rewritten; it gets deleted, + and the reveal it used to protect is the accepted behaviour change described above. +- `scripts/regressions/noodle-settings.regression.ts` — six references: four + `subscriptionIncludesPpv: false` settings fixtures and two `ppvPrice: 5` post fixtures. + +`pnpm regression:prompt` and the Noodle regression suite are both named in the standing +workflow rules, so this is a build break, not a follow-up. + +## Relationship to the Living Plan + +**Confirmed unchanged:** + +- Slice 9b's support events still hold and map one-to-one onto the new sheet: "Joined the + inner circle" is the Subscribe row, "Unlocked a post" is the single-post row. +- Slice 9e's rule that a locked post's existence must never leak becomes easier to honor, + because there is one locked projection instead of two. +- The two-level control plane stands. The wizard is explicitly **not a third place where + settings live** — it is an on-ramp onto the two existing levels. + +**Changed by this slice:** + +- Slice 8's "automatic posts default to `subscriber` access" becomes "default to + `locked`". Same meaning, new name. +- `subscriptionIncludesPpv`, from Slice 6, is removed with no replacement. +- Slice 8's per-creator interval cadence is replaced by a rolling NoodleR reserve of + pre-generated posts with fixed future publication times. Per-creator enable/disable + survives; `intensity`, per-creator `nextRunAt`, recurring rescheduling, startup catch-up, + and generated-after-the-fact historical timestamps are removed. +- **Slice 11's rule that source-character schedules guide content but never own publication + timing is narrowed rather than reversed.** A sleeping or busy character is not selected + for a future publication time. Schedules constrain eligibility and prompt context, but + never determine how often a creator posts. + +## Known limits, deliberately accepted + +Choosing between a single unlock and a subscription is not yet a real choice. Subscribe +costs 5 and unlock costs 1, but with a 999999 starting balance and no prices rendered, no +user experiences the trade-off: subscribing is still strictly better and effectively free. +The value of this slice is comprehensibility, not balance. + +What changed as of 2026-07-29 is that the mechanism is no longer hypothetical. `unlockPost` +and `subscribe` are the only two mutation points that move a balance, and they now actually +move it. Making the economy real later is a matter of showing the numbers and lowering the +starting balance — not of retrofitting a ledger through the access path. + +## Outside review — 2026-07-27 + +An outside read of this document against four lenses: onboarding, usability, server cost, +and the daily user experience. Recorded as a review, not as product decisions. Two of its +onboarding proposals were weighed and rejected and are kept here so they are not +re-proposed. Scheduler observations in this dated review describe the retired day-plan +candidate; the current Scheduling rework section supersedes them. + +### What a user expects, and what 8f delivers + +After the age gate the expectation is roughly *"my characters have an Instagram, I check it +daily, they post on their own, and they react to me."* Against that: + +| Expectation | 8f | +| --- | --- | +| See something immediately | Wizard's "generate first posts now?" covers it | +| Something new after the server was closed | Prepared scheduled posts cover the reserve horizon; no startup generation or invented history | +| She answers me | **8g. Explicitly not in 8f.** | +| Understand why something is locked | The Unlock sheet covers it | + +The reply path is the largest gap between what the product promises and what this slice +ships, and this document already says so ("the missing centre"). Worth carrying as a stated +consequence of the slice boundary rather than as a footnote: 8f makes NoodleR coherent, not +responsive. + +### Weighting + +This document states that watching is two thirds of the product and then spends roughly two +thirds of its length on scheduling internals and migration. The section on what a user does +daily is half a page. That is a fair reflection of where the *risk* sits, not of where the +*value* sits — worth knowing when the slice is cut into PRs, so the watching work does not +become the part that gets dropped when the slice runs long. + +### Weighed and rejected: subscribing the owner by default + +The proposal was that creating a creator also subscribes to them, leaving the lock as a +teaser device toward non-followers only — on the grounds that with a free subscription the +Unlock sheet is an extra tap before a one-time click that nothing gates, as "Known limits" +already concedes. + +**Rejected.** The Unlock sheet stays as specified: the platform fiction is the product, and +a feed in which nothing is ever locked to the viewer is not the thing being simulated. +Recorded so this is not re-litigated as a fresh idea. + +### Weighed and rejected: a one-screen wizard + +The proposal was one screen (character selection plus "generate first posts"), with activity +and images left to the settings that already exist, and disclosure defaulted to `hinted` and +changed on the creator page where the generated name sits next to the real one. + +**Rejected.** Four steps at two densities stay. Two risks from the review remain live and +should be watched while building rather than argued again: + +- prefilled lines still have to be read and judged, so "Simple" is only simpler if the lines + are genuinely skimmable — if a Simple line needs a sentence to explain, that step has + failed its own premise; +- disclosure is asked before the user has seen a single stage profile, so the plain-language + phrasing carries the entire burden of making the choice meaningful. + +### Advertised frequency versus delivered frequency + +"Up to N automatic posts per day" remains a ceiling, not a promise. Schedule eligibility, +provider failures, an incomplete reserve, and an absence longer than the reserve horizon +may deliver fewer. Settings therefore show both automatic attempts used in the last 24 +hours and how far the prepared reserve currently reaches; the audience feed carries neither +operator detail. + +### Cost observations + +- **Pre-selecting every character** was listed under Open questions as something to + "consider". The review rated it as a hard limit rather than a judgement call: one click on + Continue at a 40-character library is 40 creator creations and 40 stage-profile drafts, + i.e. 40 provider calls, for a user whose intent was "let me try this". **Adopted** — see + wizard step 1: no pre-checking above a threshold, "select all" as an explicit action. +- **The images switch** roughly doubles or halves total spend and is the fourth line of a + four-line wizard, i.e. the one most likely to be clicked past. It is the single most + expensive answer in the flow and should not read like the least important one. + +### Cheapest real win in the document + +The "new since your last visit" divider plus the entry-point counter costs one stored +timestamp per viewer persona and no provider budget, and it is what gives a user a reason to +come back at all. It currently sits as a sub-point of a section whose remaining content is +deferred. If any part of the watching work survives a scope cut, it should be this one. + +## Open questions + +- ~~**Default `postsPerDay`.**~~ Resolved 2026-07-29 — **4**, range 1..24. Tuning it from a + real paid-provider run and a real local-model run (latency, noise, output quality, image + cost) is still worthwhile, but no longer blocks 8f-3. +- ~~**The plain-language disclosure phrasing.**~~ Resolved 2026-07-29 — the recognition-test + wording, written out in Wizard step 2. One comprehension check on a real person is still + worth doing before 8f-4 ships, but the words exist now and no longer block the unit. + `hinted` remains the option most likely to be misread; if the check finds a problem, it + will be there. +- ~~**The pre-check threshold number.**~~ Resolved 2026-07-29 — **8**, bounding the worst-case + first run at 8 provider calls. + +### Decided, kept for the reasoning + +- ~~**Auto-follow and which tab opens first.**~~ No auto-follow. Following is the tab the UI + opens to on every ordinary visit; onboarding's completion step selects All creators once, + because Following starts empty right after setup. A follow the user never performed is + curation done for them, and public Noodle is near-empty at first too. +- ~~**The wizard's shape.**~~ A real wizard, four steps, front and center, every step + answerable with Continue. The one-screen alternative is dropped. It opens automatically on + first run; later runs are character selection only. +- ~~**Does disclosure belong in the wizard?**~~ Yes, and it cannot move: bulk creation + happens in the wizard and a stage profile cannot be generated without it. The wording is + settled too, as of 2026-07-29 — the recognition-test copy in Wizard step 2. +- ~~**Pre-selecting every character.**~~ Pre-check only up to a threshold of **8**; "select + all" is an explicit action above it. +- ~~**Discovery beyond teasers.**~~ Both, but not both now. All creators is the surface; + browse/search is its own work and earns itself once a library is large enough to lose a + creator in. Deferred with a trigger, not open. +- ~~**Scheduler shape.**~~ Front-loaded generation with a private scheduled-post reserve. + The day-plan/catch-up and gap-backfill candidates are retired. Startup performs no + provider generation and posts are never generated after the fact with historical times. +- ~~**Migration.**~~ Resolved — see the Migration section. Old `subscriber` and `ppv` posts + do render identically, and the acceptable part is not the rendering but the reveal: a + subscriber to a creator with `subscriptionIncludesPpv` off gains access to posts they + previously could not see. Accepted, because the subscriber is the owner. + +## Changelog + +- **2026-07-31** — Corrected F11/F12 planning ownership and current terminology. 8f-5 now + owns only the new-since-last-visit divider and entry-point counter; 8f-6 owns the complete + creator-page operator area, including source-change and source-missing states and actions. + Current-contract prose now uses `public | locked`, NoodleR symbols, and scheduler-pass + wording; dated migration and shipped-contract references retain the retired terms they + document. Refreshed the branch status date; the Living Plan records the current diff stats. +- **2026-07-29** — Specced the 8f-1 fix, not just the diagnosis. Traced against staging and + found the defect is wider than recorded: `stageProfileContainsPublicIdentity()`, the + validator gating drafts at `noodle-stage-profile-draft.service.ts:190` and + `noodle.routes.ts:725,781,826`, reads the same stored-only `PublicIdentity` as the redactor + and is equally blind to a renamed source — so fixing only `protectNoodlerGeneratedIdentity()` + would have left the gate that refuses leaked drafts open. The fix is one widening of + `PublicIdentity` to the union of stored and live source identifiers, which repairs both + functions and all seven call sites at once; the redactor already dedupes and sorts + longest-first, so extra identifiers preserve overlapping-name behaviour for free. Scope + bounded to identity: appearance and personality drift stay 8f-6's notice. Regression covers + rename-then-redraft for `hinted` and `secret`, and asserts `open` still shows the identity + so the fix cannot pass by over-redacting. +- **2026-07-29** — Closed the last two open questions, so 8f-4 is no longer blocked on + wording. **Disclosure** uses recognition-test copy — "a friend scrolling past would + recognise them instantly / might do a double-take / would never guess" — replacing the + mechanism-describing draft that had no referent for a user who has not yet seen a stage + profile. **The pre-check threshold is 8**, bounding a first run at 8 provider calls while + still filling a feed, and matching the number the Simple-mode summary line already used as + its example. A comprehension check on `hinted` is still worth running before 8f-4 ships, + but is no longer a gate. Also specced coin storage for 8f-2: a settings leaf on the viewer + persona rather than a table, debited on the insert path of the existing `subscribe` and + `unlockPost` transactions so idempotency is structural, with `normalizePersistedInteger` + added beside the existing boolean normalizer and reused by 8f-3 for `postsPerDay`. +- **2026-07-29** — Accuracy pass against staging, plus one product decision. **Pricing is no + longer deferred:** Unlock costs 1 coin, Subscribe costs 5, every user starts at 999999, and + the balance is charged for real while no price or balance is rendered in 8f. This replaces + the no-prices-ever rule, which had rested on Slice 9b — a slice in the may-never-ship band. + Coins and 9b's support points stay separate axes. Corrected the knock-on claims that + NoodleR "has no currency" in the 9-band rationale, which the coin decision would otherwise + have silently falsified. Fact-fixes, no design change: the `ppv` inventory is **14** files + not 12 — it was missing `noodle-prompt.regression.ts` and `noodle-settings.regression.ts`, + both of which encode the deleted enum and break at compile time, so 8f-2 owns updating + them. Re-anchored every line reference (`:510-511`, `:797`, `:813`, `:1588`, `:2412`, + `routes:335/409/364/654`) and separated the two edits hiding behind them: the + `subscriptionIncludesPpv` *parameter* disappearing versus the `ppvPrice` *field* + disappearing. Corrected two pre-8e filenames and the `services/storage/` path. 8f-3 now + names all four scheduler touch points rather than only the cadence helper — the real + rewrite target is `noodle-autopost-scheduler.service.ts`, whose + `MAX_CONCURRENT_AUTOPOSTS = 2` is what concurrency-1 replaces. Confirmed the scheduling + design itself is unchanged from its original planning commit. +- **2026-07-29** — Coherence pass before implementation. Fixed a self-contradiction: the + User flow said "all pre-checked" while Wizard steps said pre-check only up to a threshold, + which would have shipped the 40-provider-call behaviour the threshold exists to prevent. + Mari's welcome post became an **emulated post inside wizard step 1** rather than a seeded + feed post — she has no NoodleR account, and with no auto-follow a real post would have + landed in an empty Following tab. Recorded why subscribe-implies-follow does not conflict + with no-auto-follow (the system never follows for you; subscribing is the user acting) and + required the Unlock sheet to say so. Set `postsPerDay` default to **4**, range 1..24, so + 8f-3 is no longer gated on provider measurement. Split the Migration section's ownership + across 8f-2 and 8f-3 and stated that the data-step-before-normalizer rule binds both. +- **2026-07-29** — Factual refresh against current staging, no design change. Slice 8e is + merged (#4129), so `noodle-platform-migration.ts` is on staging and 8f-2 is unblocked. + Code references updated for the rename: `protectPrivateGeneratedIdentity` → + `protectNoodlerGeneratedIdentity`, `generateNoodlePrivatePost` → + `generateAndApplyNoodlerPost`. The access normalizer this document's fail-closed argument + points at moved to `noodle.storage.ts:510`. +- **2026-07-30** — Replaced both scheduler candidates with the decided front-loaded + scheduled-post reserve. NoodleR now prepares future posts gradually while Marinara is + running, stores them in a private capability-owned outbox, and publishes due items + idempotently at their preassigned times. Startup performs no provider catch-up; a depleted + 24-hour reserve produces a quiet gap rather than a burst or fabricated history. Collapsed + activity to one "up to N posts/day" setting and automatic text-attempt ceiling, required + concurrency 1 and foreground-provider precedence, added policy/source invalidation and + private-media ownership, and rewrote onboarding, user flow, migration, Slice 12 seams, + proof, and Living Plan references around the new contract. The ordinary feed carries no + absence recap or synchronization surface. Architecture closure added atomic rolling + text/image attempt claims, conservative crash accounting, connection-scoped admission + after 30 foreground-idle seconds, a strict no-provider-work startup boundary, image + prompt-review expiry to text-only publication, schedule/night-quiet/timezone invalidation, + and the exact 60-minute immediate-post conflict rule. +- **2026-07-29** — Scheduling section now carries **two candidate designs for review** rather + than one asserted design. Option A is the existing platform day plan, unchanged but + labelled. Option B is new: backfill the gap since the last pass and stamp each post with a + plausible instant inside it, on the premise that a local single-user app's user cannot + observe a posting time they were absent for. B collapses the settings to one number, needs + one ISO timestamp of state, serves both user populations through one code path, and + dissolves the open post-versus-run cap question; its costs are a second scheduling pattern + in the codebase and a commitment to chosen `createdAt` values. Added a comparison table, a + stated recommendation (B), and the shared requirements either must satisfy. Recorded that + fan activity is a different schedule shape under either option — anchored to a post, not a + clock — that it is where Noodle's batched call actually fits, and that under B backfilled + posts need backfilled reactions. +- **2026-07-29** — Coherence pass. Two designed features had no shipping unit and would have + been discovered as scope during implementation: the source-changed/orphaned-creator notice + is now step J and unit **8f-6**, and the creator-page operator-control grouping is now step + I, folded into 8f-5 as the same watching-side concern. Five units became six. Removed the + stale "All creators opens first" line from Decided, which contradicted the same day's + clarification that Following is the default tab and All creators is onboarding's one-time + exception. Still open and deliberately not resolved here: the catch-up cap says it bounds + posts while `min(missedSlots, 3)` bounds refreshes, and its "a creator cannot post twice" + claim does not hold for small libraries where the selection sort re-picks the same + creators. +- **2026-07-29** — Closed five items from an internal usability review. Tutorial no longer + has its own screen: it is folded into the wizard's first step, then reinforced as Mari's + post afterward. "Generate first posts now?" now shows estimated call counts, and the + wizard's completion state defines explicit outcomes for declined, failed, zero-eligible, + and zero-selected cases instead of assuming All creators always has content. The + new-since-last-visit timestamp is per viewer persona, not one per account. Clarified that + Following is the default tab on every ordinary visit; onboarding's one-time exception is + landing on All creators because Following starts empty. Left open: the catch-up + post-vs-refresh cap wording, the Unlock/Subscribe false-choice framing, and whether "up to + N posts/day" should become a live actual-count. Creator-reply UX (8g) scoping deferred to + its own task. +- **2026-07-28** — Split into five shipping units (8f-1..8f-5) rather than one slice, so the + redaction fix ships immediately and the watching surface cannot be cut with the scope. 8g + re-sequenced to after 8f-2 instead of after all of 8f. Replaced the two-file `ppv` code-path + list with the verified 12-file list, which adds the shared schema, the generation service, + both client files, and two locale files. Recorded that Slice 10 is merged and its shipped + contract names PPV values, so removing `ppvPrice` changes shipped composer behavior. +- **2026-07-27** — Onboarding and access questions closed. No auto-follow, All creators + opens first. A real four-step wizard, opening automatically on first run, one-screen + alternative dropped. Disclosure stays in the wizard (the creator does not exist yet); + only its wording is open. Character pre-check capped at a threshold. Discovery deferred + with a trigger. Startup catch-up stays `min(missedSlots, 3)` — a single catch-up post + was proposed and rejected, reasoning recorded. Added why Mari's welcome post does not + violate the no-fabricated-posts rule. Post quality at 12 × 2 demoted from an open + question to a test task. Reviewer's accessibility concern about the age-gate dodging + checkbox checked against `NoodlerAgeGate.tsx:315-328` and found already handled + (reduced-motion, coarse pointer, and keyboard all skip the dodge) — no change made. +- **2026-07-26** — First draft: idea, code inventory, rough plan, relationship to the + Living Plan, open questions. +- **2026-07-26** — Added access decisions (Follow as curation, subscribe implies follow, + Following/All tabs with All first, public teasers visible to non-followers), the + scheduling rework (platform day plan, bounded catch-up, character-schedule slot + shifting, ad-hoc trigger instead of a second clock), the source-drift policy including + the identity-protection defect, and the full onboarding and user flow. +- **2026-07-26** — Usability pass. Scheduling reduced to plain round-robin with no weights + and no `intensity`; activity expressed per creator so slots scale with library size. + Added the watching-side analysis: feed as audience surface, chronological order, + new-since-last-visit divider and counter, operator controls gathered on the creator + page, and Slice 8g for creators replying to real viewers. Tutorial replaced by a seeded + Professor Mari welcome post. +- **2026-07-26** — Scheduling simplified after reading how public Noodle actually does it. + A slot is now a **refresh** rather than one creator's turn, which is Noodle's own model + (`noodle-refresh-scheduler.service.ts:128`). That deletes round-robin state, slot + hand-over, position preservation, the ad-hoc slot-burning rule and the catch-up burst + case; selection reuses the existing ordering in `chooseNoodleParticipantAccounts()` and + the fan-out in `refreshAllNoodlerCreatorsNow()`. Recorded that NoodleR generation stays + one provider call per post — batching is not portable, because disclosure and identity + redaction are per creator. Reversed the per-creator activity scaling in favour of one + stated ceiling, N × k. Character schedules became an eligibility filter instead of a + re-timing mechanism. Added what the creator page shows in place of `nextRunAt`. +- **2026-07-26** — Activity became two plain numbers, refreshes per day and creators per + refresh, mirroring public Noodle's existing `refreshesPerDay`/`participantMin`/ + `participantMax` settings; the three named levels are dropped, along with the need to test + invented level names for comprehension. Defaults 12 × 2. The wizard's shape moved to Open + questions rather than staying asserted — one screen versus four collapsed steps, and + whether disclosure belongs in it at all — together with the pre-selection cost at large + libraries and the second-run rule. +- **2026-07-26** — Migration worked out and the open question closed. Key findings: settings + removals (`subscriptionIncludesPpv`, `intensity`, `nextRunAt`) need no data step because the + storage normalizer rebuilds settings field by field; `posts.access` does, and the existing + normalizer at `noodle.storage.ts:510` falls back to `public`, so an unmigrated or unknown + value would turn every locked post world-readable. The new normalizer must fail closed to + `locked`. Also recorded the one real behaviour change: the collapse reveals `ppv` posts to + subscribers of creators that had `subscriptionIncludesPpv` off. +- **2026-07-27** — Outside review appended as its own section. One change to the design: + startup catch-up runs `min(missedSlots, N)` refreshes instead of collapsing every missed + slot into one, because for a Marinara that is started for an evening the collapse is the + normal case, not a rare repair, and it would deliver k posts where the settings promise a + day's worth. Two review proposals were weighed and rejected — subscribing the owner by + default, and shrinking the wizard to one screen — and are recorded as rejected so they are + not re-proposed. The remaining findings are observations; the open questions below are + unchanged. diff --git a/.github/plans/noodl-split/noodler-pr-split-living-plan.md b/.github/plans/noodl-split/noodler-pr-split-living-plan.md index a910ffb6dd..3a07ef333e 100644 --- a/.github/plans/noodl-split/noodler-pr-split-living-plan.md +++ b/.github/plans/noodl-split/noodler-pr-split-living-plan.md @@ -1,6 +1,6 @@ # NoodleR PR-Split — Living Plan -Authoritative repository plan as of 2026-07-23. Update the status table and merged list as +Authoritative repository plan as of 2026-07-31. Update the status table and merged list as work lands. Historical slice numbers are retained where useful, but the order below is the current intended order. @@ -8,6 +8,10 @@ This Living Plan replaces the historical repository v2 plan. Kickoff prompts rem self-contained; this file is the planning authority unless product direction explicitly replaces it. +Slice 8f additionally has a detail design document, +[NoodleR Access Model and Onboarding Overhaul](./noodler-access-and-onboarding.md). This +file stays authoritative for ordering and decisions; that file owns 8f's design detail. + ## Terminology — "private" is retired Earlier slices called the NoodleR side of the split **private**. That word is gone from the @@ -26,9 +30,11 @@ current vocabulary**: | `privatePostGuide` | `noodlerPostGuide` | | `privateGenerationGuidance` | `noodlerGenerationGuidance` | -The one thing that keeps the word: post-level `access: "public" | "subscriber" | "ppv"` -(`noodler-access.ts`) is the actual paywall/visibility control and is deliberately unchanged. -`platform` says *which simulated app an account lives on*; `access` says *who may read a post*. +The current post-level access enum is `access: "public" | "locked"` +(`noodler-access.ts`). `subscriber`, `ppv`, `ppvPrice`, and `subscriptionIncludesPpv` are +retired access-model terms retained below only where a historical shipped contract or the +8f-2 migration needs to name them. `platform` says *which simulated app an account lives +on*; `access` says *who may read a post*. ## Product charter @@ -64,6 +70,12 @@ The product experience therefore needs both agency and life: | 6 | Subscriptions & access (subscriber posts, PPV unlocks, hidden-from, viewer-persona scoping) | #3856 | | 6b | Shell/feed parity and private interactions (real component reuse, pink theming, merged feed, access-gated interactions, coin popover, mode toggle) | #3888 | | 7 | Roleplay authoring and creator-profile parity (literal Post, optional Guide, titles, unified profiles, stage creation) | #3969 | +| 8 | Text-only automatic creator posting and control plane | #3984 | +| 10 | Composer media parity (one uploaded image, optional poll, private media storage) | #3981 | +| 8b | Access-protected generated creator images | #4001 | +| 8c | Bulk creator operations, creation-flow hardening, noodle-blue → noodle-accent migration | #4047 | +| 8d | NoodleR shell/profile UI pass (pink shell, profile return navigation, viewer scope, reply-manage override) | #4063 | +| 8e | `private` → `noodler` rename across code, data, and UI | #4129 | ## Current status and intended order @@ -71,16 +83,39 @@ The product experience therefore needs both agency and life: | --- | --- | --- | --- | | 0 | Slice 6b stabilization and browser proof | Integrated Playwright proof passed with a Guided-output coverage gap; bare staging proof not isolated | Merged 1A–6b | | 1 | Slice 7 — roleplay authoring and creator-profile parity | Merged through #3969 | 4, 5, 6, 6b | -| 2 | Slice 8 — toggleable text-only automatic creator posting and control plane | **Release-candidate requirement** | 7 authoring operation and stabilization gate | -| 3 | Slice 8b — access-protected generated creator images | Local branch implements image generation on the manual Guide and automatic-post paths (NoodleR-owned enablement + single-image-per-post `imagesEnabled` policy on the autoPosting subtree), identity-protected image prompts, private-media namespace served through an access-checked endpoint, prompt-review lease path, and delete cleanup; `pnpm check` + `regression:noodle` pass; real-provider image smoke remains; not merged | 7 and 8 posting paths | -| 4 | Slice 9a — quiet synthetic fan engagement | Planned after auto-posting | 6, 6b, 8 | -| 5 | Slice 9c — persona-first named superfans and non-economic visible moments | Roleplay-first, compute-bounded follow-up | 9a | -| 6 | Slice 9d — opt-in real-character named fans | Planned later | 9c | -| 7 | Slice 9b — support points and visible economic events | Optional low-priority fun addition | 9a; defer if scope grows | -| 8 | Slice 9e — named-fan profiles and access-filtered history | Ambient identity follow-up | 9c; extended by 9d | -| 9 | Slice 10 — composer media parity | Implemented in draft PR #3981; automated gate passed, manual UI proof pending | 6b, 7 authoring UI | -| 10 | Slice 11 — cross-mode integration | Blocked on product contract | manual/automatic posting paths | -| 11 | Slice 12 — creator projects/milestones | Last | Explicit prerequisites to be defined | +| 2 | Slices 8, 8b, 8c, 8d, 10 | Merged; see the table above | — | +| 3 | Slice 8e — `private` → `noodler` rename across code, data, and UI | Merged through #4129 (2026-07-27), with `noodle-platform-migration.ts` and its regression | none beyond merged staging | +| 4 | Slice 8f-1 — identity-redaction fix (rename-then-redraft) | Implemented on `big-chungus-1`, unmerged; `buildNoodlerPublicIdentity()` widens the identity union, covered by `noodle-prompt.regression.ts` | none | +| 5 | Slice 8f-2 — access collapse to `public \| locked` + migration | Implemented on `big-chungus-1`, unmerged; see [detail document](./noodler-access-and-onboarding.md) | 8e (same files) | +| 6 | Slice 8g — creators reply to the viewer | Implemented on `big-chungus-1`, unmerged; `noodle-noodler-creator-reply.operation.ts` + `noodle-noodler-reply-generation.service.ts`, covered by `noodle-creator-reply.regression.ts` | 8f-2 access model only | +| 7 | Slice 8f-3 — front-loaded scheduled-post reserve | Implemented on `big-chungus-1`, unmerged; `noodle-noodler-reserve.operation.ts`, `noodle-autopost-cadence.ts` deleted | 8f-2 | +| 8 | Slice 8f-4 — setup wizard with an emulated Professor Mari teaching post | Implemented on `big-chungus-1`, unmerged; `noodler-onboarding.ts` + wizard screens | 8f-2, 8f-3, 8c bulk creation | +| 9 | Slice 8f-5 — watching surface: new-since-last-visit divider and entry-point counter | Planning; cheapest win in the design, split out so a scope cut cannot eat it | 8f-2 | +| 9+ | Slice 8f-6 — creator-page operator area, including source-changed and source-missing notices | Planning; independent of the access and scheduling work, so it may land any time after 8f-1 | 8f-1 | +| 10 | Slice 9a — quiet synthetic fan engagement | Later, and **optional** rather than the road ahead — see the note below | 6, 6b, 8, 8f-2, 8g | +| 11 | Slice 9c — persona-first named superfans and non-economic visible moments | Roleplay-first, compute-bounded follow-up | 9a | +| 12 | Slice 9d — opt-in real-character named fans | Planned later | 9c | +| 13 | Slice 9b — support points and visible economic events | Optional low-priority fun addition | 9a; defer if scope grows | +| 14 | Slice 9e — named-fan profiles and access-filtered history | Ambient identity follow-up | 9c; extended by 9d | +| 15 | Slice 11 — cross-mode integration | Blocked on product contract | manual/automatic posting paths | +| 16 | Slice 12 — creator projects/milestones | Last | Explicit prerequisites to be defined | + +Order note: after Slice 8b the actual work ran 8c → 8d — UX consolidation rather than the +originally planned jump to fan simulation. That deviation is deliberate and is recorded +rather than reversed. Slices 8e and 8f continue it, and both precede 9a on purpose: +Slices 9a, 9b, and 9e depend on the settled `public | locked` model, so simplifying access +after the fan work would have meant writing those slices twice. + +**The 9 band is optional, not the road ahead.** Slices 9a–9e and 12 simulate an audience +economy — invented fans, named superfans, support points, fan profiles, milestones — and +NoodleR has no economy for them to be an economy of. Coins exist as of 8f-2 and are charged, +but at a 999999 starting balance with no prices rendered, nothing is scarce and no user +reaches zero — so subscribing is still effectively free. They are scenery for scenery, and +every one of them spends provider budget on ambience. If coins are ever made scarce, revisit +this judgement; until then it stands. What actually carries the product is watching (the 8 band's audience surface) and +being addressed (8g). They are listed but deliberately kept thin — see "The 9 band" below — +because a slice that may never ship should not carry a design that must be maintained. +Reassess after 8g is real. ## Product and UX principles @@ -114,10 +149,11 @@ A release candidate must support roleplay autonomy: at minimum, required Slice 7 authoring/profile shape plus toggleable per-character automatic posting and a user- approved way to guide and control the experience in Slice 8. -### Stabilization gate — NEXT +### Stabilization gate — passed on the integrated branch, see the receipt below -No speculative product work belongs in this gate. Use the current merged behavior -and fix only reproduced blockers. +Historical. No speculative product work belonged in this gate; it used the merged behavior +and fixed only reproduced blockers. Retained because the residual gap it names — an +isolated run of bare `origin/staging` — was never closed. Required proof: @@ -152,13 +188,11 @@ provider smoke remains separate from the deterministic local-provider proof. non-null `imagePrompt`, while its stub returned `poll: null`. It therefore did not assert Slice 7's text-only output constraint. A real Guided run subsequently reproduced one composite post containing a title, body, poll, and image prompt. -Static tracing confirms that the inherited private generator asks for, validates, -persists, and displays those fields together. This does not invalidate the access, -persistence, responsive-layout, or identity-redaction results above, but it blocks -Slice 7 readiness until the output policy conforms to the slice boundary. A local -correction now requests only title/body and defensively persists neither poll nor -image-prompt output; server typechecking and build pass, while provider/browser -regression proof remains required. +Static tracing confirmed that the inherited private generator asked for, validated, +persisted, and displayed those fields together. This did not invalidate the access, +persistence, responsive-layout, or identity-redaction results above. It was corrected +before #3969 merged: generation requests only title/body and defensively persists neither +poll nor image-prompt output. Passing this gate proves the merged foundation and unblocks Slice 7. It does **not** make 6b a release candidate. @@ -183,8 +217,9 @@ The currently specified minimum control-plane proposal is: “pause all” feature; - a global **Refresh NoodleR now** action over automation-enabled creators; - per-stage-profile automatic-posting enable/disable; -- per-stage-profile Low/Medium/High cadence; -- next-run status and schedule editing/rescheduling; +- one rolling NoodleR reserve of pre-generated posts with fixed future publication times + (Slice 8f; replaces per-creator cadence, per-creator next-run status, and startup + catch-up); - editable stage profile identity/personality as durable generation guidance; - Slice 7's optional one-shot guide for user-triggered posts. @@ -194,13 +229,14 @@ Settings and creator-specific controls on the creator page. They still use separ typed capability leaves and server-owned timestamps; shared UI placement does not permit one raw settings object or one shared schedule. -Automatic posts default to `subscriber` access: in the current no-currency product, -this is the follow/subscribe-to-see path. Slice 8 does not automatically create PPV -posts or add currency. +Slice 8 originally defaulted automatic posts to `subscriber` and excluded automatic PPV +posts and currency. The current default is `locked`; coins now exist under the 8f-2 hidden +balance contract, and automatic posting still does not create a distinct higher access tier. -There is no separate quiet-hours field. Users control timing through the NoodleR -schedule and can reschedule planned/next runs, following Noodle's existing schedule -UX within the per-creator `nextRunAt` model. +Quiet time constrains future publication planning for creators without a weekly schedule +of their own and is asked once in the Slice 8f wizard. It is not a per-creator field. +Character schedules and night quiet decide whether a creator is plausible at a proposed +future publication time; they do not create another clock. “Automatic-post creative brief” would mean persistent per-creator instructions used only for scheduled posts, for example “focus on backstage updates; avoid spoilers.” @@ -218,15 +254,15 @@ sequence of posts. - **The user manually triggers AI generation from direction text.** This already exists on merged staging. - **The user triggers AI generation without a guide.** The server contract already - permits an omitted `privatePostGuide`, but the merged composer currently requires + permits an omitted `noodlerPostGuide`, but the merged composer at that point required non-empty direction text. Slice 7 must expose this as the normal unguided path and make guidance optional. - **The user's literal text is published without an LLM.** This does not exist on staging or on the reference `noodl-split-7-post-as-character` branch. Product direction explicitly selected this behavior for Slice 7: **Post** publishes the draft literally, while - **Guide** sends the current draft through the existing private generation pipeline. + **Guide** sends the current draft through the existing NoodleR generation pipeline. -Literal authoring therefore lands in Slice 7 through a strict private-post input, +Literal authoring therefore landed in Slice 7 through a strict NoodleR-post input, stage-profile author scope, existing access choices, one server operation, and one client mutation. It does not need images, scheduling, fan activity, or a separate generation path. @@ -282,15 +318,11 @@ follows the user's selected persona instead of a second local picker. **Depends on:** merged Slices 4, 5, 6, and 6b plus the stabilization proof. The old fan branch is unrelated prior art. The reference branch is behavior reference only. -**Current implementation status (2026-07-23):** implemented on branch -`noodl-split-7-roleplay-authoring`; not yet merged. The current local diff adds the -title/body-only Guided correction and the unified creator-profile layout described -below. Shared/server/client focused lint or type checks, the client production build, -`regression:noodle`, and `regression:prompt` pass. Seeded desktop/mobile Playwright -verification passes for the unified layout and subscriber-list contract. The earlier -provider/browser coverage gap for a real Guided request remains: a successful -real-external-provider title/body-only generation smoke and human usability pass are -still required before readiness is claimed. +**Implementation status: merged through PR #3969.** The section below is retained as the +shipped contract. It shipped the title/body-only Guided correction and the unified +creator-profile layout described below, with focused lint/type checks, the client +production build, `regression:noodle`, `regression:prompt`, and seeded desktop/mobile +Playwright verification of the unified layout and subscriber-list contract. **Unified-profile proof receipt (2026-07-23):** a seeded Playwright Chromium pass opened a managed profile as its linked persona and as a different viewer persona. @@ -307,22 +339,22 @@ No temporary browser artifacts remain. ### Commit 1 — Private post contracts, operations, and deterministic author identity - Put connection resolution, per-stage-profile operation locking, and - `generatePrivatePost()` behind one typed application operation used by every + `generateNoodlerPost()` behind one typed application operation used by every authoring entry point. Preserve Slice 6 access/interaction policy; authoring does not grant viewing or interaction access. -- Add a separate typed manual-private-post command. **Post** stores the normalized +- Add a separate typed manual NoodleR-post command. **Post** stores the normalized optional title and body literally with `source: "manual"`; it never disguises user - text as `privatePostGuide` or invokes the provider. -- Treat `privatePostGuide` as optional end to end. Omit it for unguided generation; + text as `noodlerPostGuide` or invokes the provider. +- Treat `noodlerPostGuide` as optional end to end. Omit it for unguided generation; do not send an empty-string pseudo-guide or create a parallel generation path. -- Add `title: string | null` as a first-class private-post field across storage, - shared post/view DTOs, generated private-post output, private create/update +- Add `title: string | null` as a first-class NoodleR-post field across storage, + shared post/view DTOs, generated NoodleR-post output, NoodleR create/update validation, and post-card presentation. Keep public Noodle create/update inputs unchanged; public posts have no title. - **Guide** submits the current title/body draft as one-shot guidance through the generated-post operation and persists the generated optional title/body result. Apply disclosure/identity protection to both generated title and body. -- If Guide is invoked with no title/body direction, omit `privatePostGuide` and perform +- If Guide is invoked with no title/body direction, omit `noodlerPostGuide` and perform ordinary unguided generation; never send an empty-string pseudo-guide. - Normalize a whitespace-only title to `null` and use one shared bounded title limit. Legacy rows project `null`. Locked subscriber/PPV views return `title: null`. @@ -391,7 +423,7 @@ No temporary browser artifacts remain. profile, show a small, unobtrusive create-stage-profile action on its Noodle profile. - Route into the existing typed creation flow with the source account preselected. - Hide or replace the action when a stage profile already exists; never create a - second private account for the same `publicAccountId`. + second NoodleR account for the same `noodleAccountId`. - This commit may land as a later Slice 7 follow-up, but the capability is required and remains tracked until merged. @@ -413,16 +445,26 @@ No temporary browser artifacts remain. request; absent an enabled request capability, both outputs must be `null` and must not be persisted. Do not introduce persistent defaults or infer whether these request-scoped toggles land in Slice 7 until the maintainer confirms their timing. -- **Reproduced blocker and local remedy (2026-07-23):** the inherited private-generation contract - currently permits a single Guided post to contain title, body, poll, and image - prompt together without explicit selection. The minimum safe correction is to - generate title/body only by default and suppress poll/image output unless the - request explicitly opts into those capabilities. The current local fix narrows - both the prompt and strict response format to title/body and writes `imagePrompt: - null` with empty metadata even if a non-strict provider returns extra fields. +- **Reproduced blocker, fixed in #3969:** the inherited private-generation contract + permitted a single Guided post to contain title, body, poll, and image prompt together + without explicit selection. The shipped correction generates title/body only by default + and suppresses poll/image output unless the request explicitly opts into those + capabilities: it narrows both the prompt and the strict response format to title/body and + writes `imagePrompt: null` with empty metadata even if a non-strict provider returns extra + fields. ## Slice 8 — Text-only automatic creator posting +Implementation status: merged through PR #3984. The section below is retained as the +shipped contract. + +**Amended by 8f-3.** Slice 8 shipped a per-creator cadence model (`intensity`, `nextRunAt`, +`noodle-autopost-cadence.ts`). 8f-3 replaces it with front-loaded generation into one +rolling private scheduled-post reserve; the per-account `enabled` flag survives. The +settings block and reserve/publication proof bullets below are therefore stated in their +**post-8f-3** form, marked where they differ from what shipped. Per-stage-profile +enable/disable is unchanged, so the release-candidate condition still holds. + **Goal:** make characters autonomously produce content while Marinara is running, with per-character toggles and an accepted user control plane, using the same private- generation pipeline as user-triggered posts. @@ -433,16 +475,24 @@ or on fan activity. ### Service boundary -The route currently owns connection resolution and per-account in-flight -coordination around `generatePrivatePost()`. In a behavior-preserving first commit, -move that orchestration behind one typed private creator-post application operation: +The route ownership at Slice 8 kickoff included connection resolution and per-account +in-flight coordination around `generateNoodlerPost()`. Move that orchestration behind +capability-owned operations: -- HTTP guided generation and the scheduler call the same operation directly. -- Keep `generatePrivatePost()` as the capability-owned generation core. +- HTTP guided generation uses an immediate-publish operation. +- Reserve preparation uses a prepare-for-later operation whose terminal write is the + NoodleR-owned outbox rather than the ordinary post table. +- Both operations share `generateNoodlerPost()` as the capability-owned generation core + and share policy, redaction, media, and typed-outcome logic; they do not duplicate the + prompt pipeline. - Schedulers never import routes or call `app.inject()`. -- Use one in-process operation lock per private account. Different creators remain +- Use one in-process operation lock per NoodleR account. Different creators remain independently runnable. -- Revalidate mutable stage/disclosure policy after provider work before persistence. +- Foreground and background model operations also share a narrow connection-scoped + admission seam. Background work may start only after the configured connection has been + foreground-idle for 30 seconds; an already-running call is never preempted. +- Revalidate mutable stage/disclosure policy after provider work before either immediate + persistence or outbox persistence. Due publication revalidates again. This is the extension seam later projects/chat commands may call; do not prebuild those features. @@ -451,48 +501,64 @@ those features. Use one per-account model: +Slice 8 shipped a per-creator model (`intensity`, `nextRunAt`, +`noodle-autopost-cadence.ts`). Slice 8f replaces it with one NoodleR-wide reserve and +automatic attempt budget; the per-account enable flag survives: + ```ts interface NoodleAutoPostingSettings { enabled: boolean; - intensity: 1 | 3 | 6; - nextRunAt: string | null; + imagesEnabled: boolean; } interface NoodleAccountSchedulerSettings { autoPosting?: NoodleAutoPostingSettings; } + +interface NoodlerCreatorPostScheduleSettings { + enabled: boolean; + postsPerDay: number; // integer, 1..24; default 4 +} ``` -- Default projection: disabled, intensity 1, `nextRunAt: null`. -- `nextRunAt` is server-owned and excluded from client-editable patches. -- `enableNoodler` is sufficient as the global feature kill switch. Do not add a - competing global posts-per-day schedule or `lastAutomaticPostAt`. +- Default projection: disabled. +- `enableNoodler` remains the product kill switch. The separate automatic-schedule switch + and `postsPerDay` are capability controls, not competing product enablement. - The NoodleR Settings schedule toggle may disable automatic posting globally without disabling the NoodleR product. This is the schedule's enabled state, not a second “pause all” concept. -- Intensity means at most 1/3/6 automatic posts per day for that profile. -- Enabling or changing intensity clears `nextRunAt` transactionally; the scheduler - seeds a future first run. -- Claim a due run by advancing `nextRunAt` before provider work. -- After downtime, run at most once; never replay every missed interval. -- Expected skips/provider failures move to a bounded future cadence and cannot - hot-loop. -- Shutdown clears timers and awaits the active poll before storage closes. - -The first automatic-post slice is text-only. Scheduler and any confirmed manual- -refresh posts use `subscriber` access by default. Do not generate automatic PPV posts -or add a currency/access-default settings system in this slice. +- `postsPerDay` is both the targeted maximum publication density and the automatic + text-attempt ceiling across any rolling 24 hours. Failed potentially billable + attempts count. Atomically persist an attempt claim before provider work; crashes and + ambiguous outcomes keep the claim. A bounded rolling ledger and last-observed budget time + prevent midnight, timezone, or backward-clock resets. +- Automatic image-provider attempts use the same claim mechanism with a separate kind and + the same derived N ceiling, for at most 2N combined attempts; retries consume claims. + With prompt review enabled, publication atomically closes pending or in-flight image work + at `publishAt`, publishes text-only, and cleans any late result without attaching it. +- Maintain a NoodleR-owned 24-hour rolling reserve of validated prepared posts. Preparation is + low priority and concurrency 1; active foreground provider work wins. +- Publication is a separate idempotent local transaction with no provider call. Startup + materializes valid due items at their preassigned times and returns. Future preparation + begins only through the normal post-start 30-second idle scheduler. +- Expected skips/provider failures leave reserve coverage incomplete and cannot hot-loop. +- Shutdown clears timers and awaits the active scheduler pass before storage closes. + +Slice 8 first shipped text-only automatic posts with `subscriber` access. In the post-8f +state, automatic posts use `locked` by default and the already-merged Slice 8b generated- +image preference survives into prepared items. 8f-3 does not create a second image pipeline +or add a currency/access-default settings system. ### UI and proof - Add a dedicated NoodleR section to application Settings for the global kill switch and approved global automation controls, including automatic schedule on/off and the confirmed Generate/Refresh-now scope. -- Put per-profile automatic-posting toggle, Low/Medium/High intensity, and next-run - status on that creator's Slice 7 profile page rather than hiding them only in the - profile manager. -- Let the user inspect and reschedule that creator's next planned run. Do not add a - separate quiet-hours setting when the schedule itself provides timing control. +- Put the per-profile automatic-posting toggle on that creator's Slice 7 profile page + rather than hiding it only in the profile manager. +- Let the user inspect automatic attempts used in the last 24 hours and how far the NoodleR reserve + currently reaches. The creator page may show its next prepared publication time. + Individual recurring rescheduling is removed; the first reserve version is inspect-only. - Define and prove automation precedence before adding a global default plus creator override. The global kill switch always wins; do not infer whether other automatic- posting fields are defaults, hard limits, or bulk actions. @@ -500,29 +566,31 @@ or add a currency/access-default settings system in this slice. not imply that Slice 7's one-shot guide controls future scheduled posts. - Slice 7's per-creator composer already supplies single-creator generate-now. Slice 8 adds a global **Refresh NoodleR now** over automation-enabled creators. Prioritize - creators scheduled in the near future, then process the remaining enabled creators. - Call the same creator-post operation with bounded concurrency and per-creator typed + least-recently-active creators, then process the remaining enabled creators. + Call the immediate-publish operation with bounded concurrency and per-creator typed outcomes; one creator's failure must not roll back successful creators. - Each successful selected creator contributes one visible feed action/post so the manual refresh rewards the user immediately. -- A successful manual refresh consumes a creator's near-future scheduled slot and - advances `nextRunAt` using that creator's configured cadence/schedule policy. Keep - the spirit of an explicitly set schedule rather than resetting to an unrelated - default or allowing an immediate duplicate automatic post. -- Enabling autopost without a user-chosen schedule uses a sensible randomized default - cadence. It is not a second scheduler mode. Exact jitter/distribution is an - implementation choice bounded by the selected intensity and anti-burst rules. +- Ad-hoc posting is not a second schedule. After a successful immediate post, discard any + prepared item for that creator where + `manualCreatedAt < publishAt <= manualCreatedAt + 60 minutes`; normal low-priority + preparation may replace it later. - Preserve mobile layout, themes, loading/disabled states, and actionable errors. - Prove strict schema/default normalization and atomic config/timestamp updates. -- Use a temporary controlled-clock/provider proof for future first run, 1/3/6 - cadence, claim-before-generation, no catch-up burst, simultaneous polls, same- - account exclusion, different-account independence, provider failure, and shutdown. +- Use a temporary controlled-clock/provider proof for overlapping scheduler passes, same-account + exclusion, different-account independence, provider failure, and shutdown. +- **8f-3 addition, not shipped in Slice 8:** controlled-clock/provider proof covers + rolling-horizon preparation, atomic text/image attempt claims, connection-scoped + foreground admission, due publication, startup-with-zero-provider-work, crash + idempotence, pause/re-enable, source/policy/schedule/timezone invalidation, prompt-review + expiry, the 60-minute manual boundary, private-media cleanup, and reserve exhaustion. - Remove temporary proof artifacts before handoff. -**Explicit non-scope:** private images, user uploads, polls, fan actions, named fans, -support points/events, active/passive posting policy, automatic PPV, a separate -automatic-post creative brief, cross-mode publication, projects, and new profile/ -navigation destinations. +**Explicit non-scope:** new image capabilities, user uploads, polls, fan actions, named +fans, support points/events, active/passive posting policy, automatic PPV, a separate +automatic-post creative brief, cross-mode publication, project implementation, and new +profile/navigation destinations. Existing Slice 8b private generated images must remain +safe through outbox ownership, publication, invalidation, and cleanup. ## Slice 8b — Access-protected generated creator images @@ -558,177 +626,204 @@ This slice enables the approved **image** output choice only when the full path exists. It does not add image upload, gallery attachment, polls, or a second posting operation. Slice 10 still owns user-uploaded media and polls. -## Slice 9a — Quiet synthetic fan engagement - -**Goal:** make existing creator content receive access-valid synthetic engagement -without bundling economics, moments, or real-character identity sourcing. - -**Depends on:** Slices 6/6b and preferably Slice 8 so creator content exists. - -- Synthetic fans are scoped to a creator and do not borrow real character state. -- Start with routine likes/replies/reposts against posts the synthetic actor may - actually view. Subscriber/PPV content remains unavailable until the later economic - slice grants access. -- One LLM call may propose engagement; deterministic quotas, target validation, - deduplication, and anti-spam guards apply afterward. -- Access gating occurs before prompt target selection and again transactionally - before persistence. -- Introduce a fan-identity provider seam even if the first provider only supplies - synthetic identities. -- Routine reactions appear quietly on posts. No viral/big-spender moment layer. -- Use dedicated generated-audience commands; never widen persona-facing interaction, - subscribe, or unlock APIs to arbitrary generated actors. -- Extend the established control-plane surfaces: NoodleR-wide fan controls belong in - the NoodleR Settings section, while creator-specific fan controls belong on that - creator's profile page. -- Global fan settings provide defaults and creator pages may override them. Include a - configurable audience-archetype mix rather than assuming one generic fan mass: - ordinary fans, eccentric fans, cross-fandom visitors, raiders, organic discovery, - and audiences suited to non-adult/free-resource creators are approved directions. - Exact labels, weights, and schema remain Slice 9 implementation design. Do not reuse - auto-post settings, timestamps, or enablement merely because the controls share - screens. - -Fan activity may reuse Slice 8's scheduler infrastructure and pure cadence helper, -but it owns a separate `scheduler.fanActivity` leaf, `nextRunAt`, transactional claim, -service, retry behavior, and enable/disable state. One shared timestamp would couple -independent capabilities and is prohibited. - -## Slice 9c — Named superfans and visible moments - -Add persistent named synthetic superfans with personality/relationship continuity, -then visible non-economic moments such as a recurring fan becoming a superfan or a -post receiving an unusual burst of attention. Keep this above the quiet engine and -fan-identity provider rather than mixing it into baseline target/access logic. - -This is persona-first, not one expensive named-fan simulation per character-backed -creator. Default 9c eligibility means a stage profile linked through `publicAccountId` -to a public Noodle account of kind `persona`. Character-backed stage profiles still -receive the cheaper access-valid quiet activity from 9a. - -Compute rules: - -- Generate a named synthetic superfan once, persist it, and reuse its identity/ - personality; do not regenerate the fan on every activity tick. -- Derive ordinary visible moments deterministically from persisted engagement where - possible. Do not make one LLM call per creator merely to decide whether a moment - appears. -- Apply a global/per-tick quota before any optional LLM work. -- Keep 9a engagement batching bounded; neither 9a nor 9c scales provider calls - linearly with every character in the library. - -One presentation decision remains. Normal likes/replies/reposts stay quiet. A **fan -moment** is an extra prominent story event, such as recurring Mina becoming a superfan -or one post receiving an unusual burst of attention. Before 9c implementation, decide: - -- which moments ship first; -- whether they appear as a compact feed card, creator-profile activity item, or both; -- the deterministic activity threshold that triggers each one. - -Recommended smallest answer: **Superfan formed** and **Post taking off**, shown as -compact feed and creator-profile activity cards. Let implementation choose conservative -deterministic starting thresholds and tune them later from actual behavior, rather than -adding threshold sliders. - -Selected character-backed creators may opt into 9c through an explicit per-creator -setting. The setting is off by default; persona-backed creators remain the default 9c -eligible group. This is creator eligibility only and does not change fan identity -sourcing. - -This slice deliberately precedes economics so roleplay payoff can be tested without a -ledger. Big-spender, paid-unlock, and earnings moments remain Slice 9b scope. - -## Slice 9d — Opt-in real-character named fans - -Swap the named-superfan provider to optionally borrow approved character identity: - -- Opt-in per character. -- Read-only name/avatar/persona flavor. -- No writeback into character chat, memory, relationship, or state. -- Subscribing/unlocking as a fan never mutates the borrowed character. - -Depends on the synthetic named-superfan engine being proven first. - -9c creator eligibility and 9d fan identity sourcing are separate axes. A persona- -backed creator can have a synthetic or later real-character fan; opting a real -character into being a fan does not make every character-backed creator eligible for -9c processing. - -## Slice 9e — Named-fan profiles and access-filtered history - -Durable faux profiles belong only to actors whose identity continuity matters: - -- persistent synthetic named superfans from 9c; -- opted-in borrowed real-character named fans from 9d; and -- any later ambient fan explicitly promoted into the named-superfan layer. - -A named-fan profile contains its stable display name/handle, deterministic local avatar -or approved borrowed avatar, short persona/relationship bio, creator-scoped “following -since” date, and visible activity with that creator. Do not spend an image-provider call -on every fan avatar. Public history reuses existing access-filtered post/interaction -projections: show replies, reposts, visible moments, and support events only when the -current viewer may see their targets. Never reveal a locked post's title, body, image, -prompt, or existence through profile history. - -The ordinary anonymous fan mass receives no account row, clickable route, follower -graph, or cross-post identity. Represent it through aggregate counts and compact -ambient phrases such as “24 people liked this.” When an anonymous generated reply must -show an author, persist an event-local display snapshot and deterministic placeholder -avatar with that interaction so reloads remain stable, but do not turn it into a fan -profile. This is ambience, not another social network. - -Reuse shared profile/post presentation and the fan-identity provider. Do not create a -third post model or allow a fan profile to bypass creator/viewer access policy. - -## Slice 9b — Support points and visible economic events - -**Status:** accepted as a low-priority fun addition, not a release requirement. Defer -it if the scope grows materially. - -Treat “Coins or Points” as a score for now, not currency: +## Slice 8e — `private` → `noodler` rename -- no spendable balance, transfer ledger, reversal system, or cash-like claims; -- no subscription tiers; -- support/economic events are available across creator profiles, not only persona- - backed creators; -- events are visible to all viewers of the creator profile; -- points and events are persisted/idempotent so reloads or retries do not double-count - them. +**Goal:** remove the last naming split between the internal `private` vocabulary and the +user-facing NoodleR product name, across code, stored data, and UI. + +**Status:** merged through PR #4129 (2026-07-27), including +`packages/server/src/db/noodle-platform-migration.ts` and +`scripts/regressions/noodle-platform-migration.regression.ts`. The retired vocabulary is +recorded in "Terminology — `private` is retired" above; 8f-2's migration follows the +pattern this slice established. -Keep the first version to two idempotent state-transition events: +Mechanical but wide. It renames services, storage helpers, operation locks, media paths, +shared types, and schemas, and migrates existing rows. -- **Joined the inner circle**: the fan successfully subscribes for the first time; - add **10 support points**. -- **Unlocked a post**: the fan successfully unlocks one previously locked post; add - **5 support points** once for that fan/post pair. +**Non-scope:** no behavior change whatsoever. Nothing about access, generation, +scheduling, or presentation may change under cover of the rename. Any behavior fix found +along the way belongs in its own change. -The wording may adapt to the creator's theme, but the stored event kinds and weights -remain stable. Do not add tips, renewals, tiers, balances, transfers, spending, or -exchange-rate semantics merely to make the score look economic. +Sequence it before 8f: 8f edits many of the same files, and doing the mechanical rename +first avoids conflicts that teach nobody anything. -### Roleplay example +## Slice 8f — Access model, onboarding, and scheduling overhaul -1. A creator posts a subscriber-only backstage entry. -2. A recurring synthetic superfan, Mina, chooses to subscribe. Every viewer of the - creator profile can see a small in-world event: “Mina joined the inner circle.” -3. That access lets Mina read the entry and leave an in-character reply that continues - her established relationship with the creator. -4. A later support/unlock event adds to the creator's points score and appears in the - public profile activity. +**Goal:** collapse the Follow/Subscribe/PPV confusion into two profile levels and three +post states, give first-time users a working entry ramp instead of an empty NoodleR home, +and replace per-creator interval cadence with an inspectable reserve of posts generated +before their future publication times. -The fun is visible support, recognition, relationship progress, and changed access; -the score is feedback rather than spendable money. Events may use named or anonymous -synthetic fans, so 9b does not require 9c identity continuity merely to record support. +**Design detail:** [NoodleR Access Model and Onboarding Overhaul](./noodler-access-and-onboarding.md). +That document owns the design and its open questions; this section records only the slice +boundary. -Access-changing actions must commit before later interactions in the same generated -batch and all targets must be revalidated transactionally. +In short: a profile has **Follow** (does this creator appear in my feed) and **Subscribe** +(may I read their locked posts); subscribing implies following. The feed's tabs become +Following and All creators. A post is **public**, **locked**, or **unlocked**, and carries +one **Unlock** button whose sheet offers "unlock this post" or "subscribe to unlock +everything", without prices. `access` collapses to `public | locked`; `ppvPrice` and +`subscriptionIncludesPpv` are removed. Onboarding gets one wizard at two densities — +Simple is the same wizard with defaults applied and collapsed, and each Simple line +expands its advanced control in place, so Advanced is a click rather than a separate mode +or a separate code path. Scheduling front-loads provider work into a private 24-hour +reserve, then publishes prepared items locally at their fixed times. The ordinary feed +shows ordinary chronological posts, with no absence recap or startup-generated history. + +**Non-scope:** no *visible* prices or balance, no top-up/purchase path, no earning path, and +no fan activity; no new settings surface — the wizard is an on-ramp onto the existing +two-level control plane, not a third place where settings live. Coins themselves are in +scope: a balance field defaulting to 999999, charged 1 on unlock and 5 on subscribe, with +nothing rendered. + +### 8f ships as six units, not one slice + +Access collapse, scheduler rewrite, onboarding, the redaction fix, the watching surface, +and the source-drift notice share no code and no risk profile. Bundled, the watching work +is what gets cut when the slice runs long. Each unit below is independently shippable: + +| Unit | Content | Depends on | +| --- | --- | --- | +| 8f-1 | Widen `PublicIdentity` to the union of stored **and** live source identifiers, fixing both `protectNoodlerGeneratedIdentity()` (the redactor) and `stageProfileContainsPublicIdentity()` (the validator, which is equally blind); regression covers rename-then-redraft for `hinted` and `secret`, and asserts `open` still shows the identity | nothing | +| 8f-2 | `access` → `public \| locked`, remove `ppvPrice`/`subscriptionIncludesPpv`, Unlock sheet, Following/All tabs, forward-only fail-closed migration, and a hidden coin balance (default 999999) charged 1 by `unlockPost` and 5 by `subscribe`. Also updates `noodle-prompt.regression.ts` and `noodle-settings.regression.ts`, which encode the deleted enum and break at compile time | 8e | +| 8f-3 | Front-loaded scheduled-post reserve: NoodleR-owned outbox, 24-hour rolling horizon, one rolling `postsPerDay` automatic-attempt ceiling, concurrency-1 low-priority preparation, idempotent due publication, policy invalidation, no startup generation or after-the-fact historical timestamps. Replaces the historical `noodle-autopost-scheduler.service.ts` poll loop (`MAX_CONCURRENT_AUTOPOSTS = 2`), deletes `noodle-autopost-cadence.ts`/`intensity`/`nextRunAt`, and repoints `app.ts:42` plus the `nextAutoPostRunAt` import at `noodle.storage.ts:56` | 8f-2 | +| 8f-4 | Four-step wizard at two densities, emulated Professor Mari teaching post in step 1, character pre-check threshold 8, recognition-test disclosure copy | 8f-2, 8f-3, 8c | +| 8f-5 | New-since-last-visit divider plus entry-point counter (one stored timestamp per viewer persona) | 8f-2 | +| 8f-6 | Creator-page operator area containing the composer, automation controls, and source-change handling: field snapshot and compare, adopt name/handle (`open` only), re-draft, dismiss, and the source-missing relink/delete variant | nothing beyond 8f-1 | + +8f-1 is a disclosure-guarantee defect, not a design change. It must not wait on a +scheduling rewrite to ship. 8g needs only 8f-2's settled access model, so it sequences +ahead of 8f-3/4/5/6 rather than behind all of 8f. + +### Implementation status — 8f-5 and 8f-6 remain + +**As of 2026-07-31, four of the six units plus 8g are implemented on branch +`big-chungus-1` and unmerged: 8f-1, 8f-2, 8f-3, 8f-4, 8g.** `pnpm check`, +`pnpm regression:noodle`, `pnpm regression:localization`, and `pnpm version:check` pass on +that branch. Browser proof has not been run. + +**Remaining: 8f-5** (new-since-last-visit divider and entry-point counter) and **8f-6** +(creator-page operator area, including source-changed / source-missing notices). Neither has +any code on the branch — no `lastVisit`-style field exists in +`packages/shared/src/types/noodle.ts`, and no source-snapshot compare exists. + +The six units were specified to ship independently; in practice five landed on one branch. +That is recorded rather than reversed, but it makes the branch a large review unit — see +"Splitting `big-chungus-1` for review" below. + +Two items remain recorded as non-blocking rather than dropped: a comprehension check on the +`hinted` disclosure wording, and real paid/local provider runs to tune `postsPerDay` away +from 4. + +### Splitting `big-chungus-1` for review + +The committed branch diff against `origin/staging` is 8,524 insertions and 4,799 deletions +across 97 files. Unrelated unstaged code changes are present in the worktree; they are not +part of this plan update or the committed branch count. 8f-1 is separable and worth +extracting as +its own PR: it is a live disclosure-guarantee fix with a standalone regression, and it should +not sit behind review of a scheduler rewrite. 8f-2/8f-3/8f-4/8g touch overlapping storage, +schema, and settings surfaces and are expensive to unpick after the fact, so they are +reviewable as one integrated PR. + +Before implementation on any unit, per `CONTRIBUTING.md` and `CLAUDE.md`: confirm or open a +GitHub issue, check for an existing issue-linked branch or PR so two agents do not duplicate +the work, open a draft PR immediately so the board shows it in progress, and identify the +owner on the issue. + +## Slice 8g — Creators reply to the viewer + +**Goal:** let a creator respond, in their stage persona, to the interactions a real viewer +leaves on their posts. + +Viewer interactions are already stored through `POST /noodler/posts/:id/interactions`, but +no generation path consumes them. The generation service has no notion of replies at all; +it produces posts and nothing else. So today the feed is a one-way street: you can comment +on your own character's post and she never answers. + +For a product whose stated purpose is exposing sides of a character that ordinary +conversation does not, this is the missing centre. It is the one moment where the figure +addresses the user directly from a role the chat does not show. It ranks above synthetic +fan ambience: Slices 9a–9e invest heavily in making *invented* fans talk, while the +*real* user is never spoken to. + +Sequenced after **8f-2** — the settled access and interaction model is all it needs — and +before 9a, because it reuses interaction plumbing that fan work would otherwise build first +with a different shape. It needs nothing from 8f-3/4/5/6, so it does not wait behind them. +Nothing in 8f depends on it either: 8f ships without a reply path. + +**Cost requirement, to be designed with the slice, not after it.** The trigger is the user, +so the load is unbounded by construction: ten comments would mean ten provider calls. 8g +needs a stated daily reply ceiling in the same spirit as the posting plan's +refreshes × creators-per-refresh, rather than one call per interaction. This is the third +generation path in NoodleR after posts and images, and the only one whose rate a user can +drive directly. + +**Non-scope:** synthetic fans, named fans, economics. Disclosure and access policy apply +to replies exactly as they do to posts. + +## The 9 band — audience simulation, specified thin on purpose + +**These may never be built.** They simulate an audience economy for a product that does not +yet have one: coins are charged as of 8f-2, but a 999999 starting balance and hidden prices +mean nothing is scarce and subscribing stays effectively free. Every one of them spends +provider budget on ambience. What carries the product is watching (the 8 band) and being addressed (8g). Kept +listed so the ideas are not lost, deliberately not specified in depth — a slice that may +never ship should not carry a design that must be maintained. Reassess after 8g is real. +If one is picked up, it gets its own detail document then. + +**9a — quiet synthetic fan engagement.** Creator-scoped synthetic fans leave access-valid +likes/replies/reposts on posts they may actually view. One LLM call may propose engagement; +deterministic quotas, target validation, deduplication and anti-spam apply afterward. +Access is gated before target selection and again transactionally before persistence. A +fan-identity provider seam is introduced even if the first provider is synthetic-only. Fan +activity gets its own platform day plan and its own `scheduler.fanActivity` leaf; shared +scheduler construction never means shared schedule state. Fan controls extend the existing +two levels — NoodleR Settings and the creator page — with a configurable audience-archetype +mix rather than one generic fan mass. + +**9c — named superfans and visible moments.** Persistent named synthetic superfans with +personality and relationship continuity, plus non-economic visible moments. Generate a fan +once and persist it; derive moments deterministically from persisted engagement; apply a +quota before any optional LLM work. Neither 9a nor 9c may scale provider calls linearly +with library size. Default eligibility is persona-backed creators; character-backed +creators may opt in explicitly, off by default. Recommended first moments: **Superfan +formed** and **Post taking off**, as compact feed and profile activity cards on +conservative deterministic thresholds. + +**9d — opt-in real-character named fans.** Swap the named-fan provider to optionally borrow +approved character identity: opt-in per character, read-only name/avatar/persona flavor, no +writeback into chat, memory, relationship, or state. Creator eligibility (9c) and fan +identity sourcing (9d) are separate axes. + +**9e — named-fan profiles and access-filtered history.** Durable faux profiles only for +identity-continuity actors (9c superfans, 9d borrowed fans, later promotions). Stable +name/handle, deterministic local avatar, short bio, creator-scoped "following since", +and activity filtered through the current viewer's post-access projection — a locked post's +title, body, image, prompt, or **existence** must never leak through profile history. The +anonymous fan mass gets no account row, route, or follower graph: aggregate counts and +ambient phrases, with an event-local display snapshot when an anonymous reply needs an +author. No image-provider call per fan avatar. + +**9b — support points and visible economic events.** Low priority; defer if scope grows. +Points are a score, not currency: no spendable balance, ledger, reversals, tiers, or +cash-like claims. Two idempotent state-transition events: **Joined the inner circle** +(+10, first subscription) and **Unlocked a post** (+5, once per fan/post pair). Wording may +adapt to the creator's theme; stored event kinds and weights stay stable. Access-changing +actions commit before later interactions in the same generated batch, and all targets are +revalidated transactionally. ## Slice 10 — Composer media parity -Implementation status: draft PR #3981 contains the complete code and documentation -path. `pnpm check`, the Noodle regression suite, the installer-artifact guard, and a -focused upload/access/persistence/deletion proof pass. Final browser and usability -verification remains manual. +Implementation status: merged through PR #3981. `pnpm check`, the Noodle regression +suite, the installer-artifact guard, and a focused upload/access/persistence/deletion +proof passed. The section below is retained as the shipped contract. + +**Amended by 8f-2.** Slice 10 shipped before the access collapse, so its contract below +still says Post and Guide carry "PPV values". Under 8f-2 there is no `ppvPrice` and +`access` is `public | locked`: the composer's PPV price field and the PPV access option +are removed, and Guide preserves image, poll, and the two-state access as before. This is +the only place merged Slice 10 behavior changes; everything else in the section stands. Add one user-uploaded image and one optional two-to-four-option poll through real schema, private storage, mutation, projection, voting, and cleanup plumbing. Enable @@ -758,13 +853,13 @@ Slice 10 does not reopen Slice 8b's generation, disclosure, or prompt-review con ## Slice 11 — Cross-mode integration Global persona, slash commands, and roleplay/chat posting may create NoodleR posts -through the typed private-post operation. NoodleR posts never mirror, leak, or appear +through the typed NoodleR-post operation. NoodleR posts never mirror, leak, or appear on the public Noodle timeline. A NoodleR post with `access: "public"` means free to view inside NoodleR; it does not become a public-Noodle post. The controller can explicitly choose Free/Public for an individual manual, Guided, or project-planned NoodleR post through the existing access input. Automatic posts remain -subscriber by default and must not silently widen access. If public-Noodle posting is +`locked` by default and must not silently widen access. If public-Noodle posting is ever desired, it is a separate explicit action through Noodle's own posting operation, with no shared post identity or automatic mirroring. @@ -789,9 +884,13 @@ current source context without copying it or writing back to the character. Keep this behind one context-provider/adapter boundary, not direct reads scattered through generation. Lorebook and schedule context are supplementary; stage-profile identity/personality and an explicit one-shot Guide are more specific instructions, -while safety and access policy always win. A source-character schedule guides content -only—what the character may be doing—not publication timing. Slice 8's per-creator -`nextRunAt` remains the sole timing authority. Do not add a global enable-all toggle. +while safety and access policy always win. Do not add a global enable-all toggle. + +Timing note, narrowed by Slice 8f: a source-character schedule primarily guides content, +what the character may be doing, and it additionally makes a sleeping or busy character +ineligible for a proposed future publication time. It never owns a clock and never +determines how often a creator posts. The reserve planner remains the sole publication +authority. ## Slice 12 — Creator projects and milestones @@ -808,13 +907,14 @@ resume, or end the project. A failed/skipped generation does not consume a beat. literal manual post does not consume the project unless the controller explicitly attaches it to the project. -Slice 8's per-creator scheduler remains the only publication clock. An active project -supplies content context to the same generated private-post operation when that -creator's normal automatic slot or explicit Guide runs; it owns no `nextRunAt`, polling -loop, or route self-call. When the source-character schedule-context toggle is enabled, -the current schedule may inform what a project post depicts, but never when it is -published. On success, associate the post, advance the beat/count, and complete the -project when exhausted in the smallest transaction after policy revalidation. +The reserve planner remains the only publication clock. An active project supplies content +context through the shared NoodleR-post generation core when the prepare-for-later +operation or an explicit Guide runs; it owns no schedule state, polling loop, or route +self-call. When the +source-character schedule-context toggle is enabled, the current schedule may inform what +a project post depicts, but never when it is published. Because generation now precedes +publication, reserve a project beat with the outbox item and finalize it when that item +publishes; discarding the item releases the reservation. The `noooooods` project implementation is reference-only and materially overstates this contract: do not port its `startsAt`/`endsAt`, milestone `dueAt`/`notBefore`, @@ -830,8 +930,8 @@ defaults and per-beat media controls remain open rather than inferred. - Noodle and NoodleR remain two capabilities on one social-data substrate. - Share contracts, storage invariants, narrow pure helpers, provider mechanics, - operation locks, cadence infrastructure, and capability-based presentation. -- Keep public refresh, private generation, automatic posting, fan engagement, + operation locks, scheduling infrastructure, and capability-based presentation. +- Keep public refresh, NoodleR generation, automatic posting, fan engagement, economics, projects, prompts, projections, and schedule state separate when their actors, access, output, or persistence policy differs. - Routes are HTTP adapters. Schedulers/projects/commands call application services, @@ -889,18 +989,194 @@ defaults and per-beat media controls remain open rather than inferred. explicitly accepted user guidance/control plane. - NoodleR controls use two levels: a global NoodleR Settings section and per-creator controls on creator pages. Capability state remains independently typed. -- Slice 8 automatic/manual-refresh posts default to subscriber access. Automatic PPV - and currency are excluded. -- Slice 8 uses schedule enable/disable and schedule rescheduling; it does not add - separate pause-all or quiet-hours fields. +- Slice 8 originally defaulted automatic/manual-refresh posts to `subscriber` and excluded + automatic PPV and currency. Slice 8f-2 replaced that enum with `public | locked` and added + hidden coin charging; automatic posts now default to `locked`. +- After Slice 8b the work deviated from the planned jump to fan simulation and ran + 8c → 8d instead, consolidating bulk creation, the control plane, and the NoodleR UI. + The deviation is accepted and recorded. Slices 8e and 8f continue that consolidation. +- New work is numbered inside the 8 band (8e, 8f) rather than renumbering the 9 band. + 9a–9e are referenced throughout this file and in merged PR descriptions; reusing those + labels would silently repoint existing references. The 8 band means platform + coherence, the 9 band means audience simulation. +- Slice 8e is a pure rename of `private` to `noodler` across code, data, and UI, with no + behavior change, sequenced before 8f because both touch the same files. +- Slice 8f collapses post access to `public | locked`, removes `ppvPrice` and Slice 6's + `subscriptionIncludesPpv`, and gives every locked post one Unlock control offering + either that post or a subscription. +- **Access migration fails closed.** The stored-post normalizer currently falls back to + `public` for any unrecognized access value (`noodle.storage.ts:510`), so collapsing the + type before migrating the rows would make every locked post world-readable — the failure + Slice 9e prohibits, arriving through a default rather than a leak. The data step runs + first, and the new normalizer maps unknown → `locked`. Settings removals need no data step: + the settings normalizer rebuilds field by field, so a field it stops reading disappears. +- The collapse reveals content in exactly one case, accepted deliberately: a subscriber to a + creator with `subscriptionIncludesPpv` off could not see that creator's `ppv` posts and now + can. Nobody loses access, nothing was paid for, and the subscriber is the owner. Forward-only. +- **Coins exist and are charged; prices stay hidden in 8f** (decided 2026-07-29, replacing + the earlier no-prices-ever rule). Unlock costs **1 coin**, Subscribe costs **5 coins**, and + every user starts at **999999**. The balance is decremented for real, but no price or + balance is rendered, so Unlock options still read as distinguished by reach rather than + price. `unlockPost` and `subscribe` are the only two mutation points that touch a balance. + With a 999999 start nothing is gated in practice — this buys the data model now so that + revealing prices later is a UI and balance change, not a schema migration through the + access path. The prior framing rested on Slice 9b, which sits in the may-never-ship band. +- **Coins and support points are different numbers.** Slice 9b's support points remain a + non-spendable score and never become currency; coins are the spendable axis. Do not merge + them. +- **The coin balance is a settings leaf on the viewer persona, not a table or a ledger.** + It lives on the viewer's Noodle persona account settings, which the normalizer already + rebuilds field by field, so it needs no data migration and existing users pick up 999999 on + first read. An absent or corrupt value normalizes to the default, never to zero. `subscribe` + and `unlockPost` are already single transactions that early-return on an existing row, so + the debit goes on the insert path only and idempotency comes free — re-subscribing cannot + double-charge. Insufficient balance reuses each function's existing `null` return rather + than adding a failure channel. No ledger, history, or reversals until coins are scarce + enough that a user needs to ask where theirs went. +- Slice 8f's onboarding is one wizard at two densities, not two wizards. Simple is the + same flow with defaults applied and collapsed; each Simple line expands its advanced + control in place. The wizard is an on-ramp onto the existing two-level control plane + and never becomes a third home for settings. +- **The wizard pre-checks at most 8 characters** (decided 2026-07-29). One Continue click + costs one provider call per checked character, so the threshold bounds a first run at 8 + text calls; "select all" above it is an explicit act. Eight still fills a feed, and matches + the Simple-mode summary line's own example. +- **Disclosure uses recognition-test copy** (decided 2026-07-29): "A friend scrolling past + would recognise them instantly" / "might do a double-take" / "would never guess", mapping + to `open`/`hinted`/`secret`. The earlier draft described the mechanism, which has no + referent for a user who has not yet seen a stage profile. `hinted` is the one most likely + to be misread; a comprehension check on a real person before 8f-4 ships is still worth + doing, but no longer blocks the unit. +- There is no tutorial overlay or coach-mark tour, and no separate tutorial screen before + the wizard. The wizard opens immediately after the age gate, and its first step carries + the tutorial content inline (why creators appear, why posts are locked, that characters + post on their own). Those concepts are taught through an **emulated, hand-written post + from Professor Mari** rendered inside that step with the real post card, showing a locked + example so the padlock explains itself. **Decided 2026-07-29:** it is a mock, not a seeded + feed post. Mari has no NoodleR account — `allowProfessorMari` is a public-Noodle flag — so + a real post would have required minting one for her and answering whether she is + followable, subscribable, and deletable; and with no auto-follow it would have landed in an + empty Following tab, invisible exactly when it should be read. Hand-written rather than + generated because the first impression cannot tolerate a wobbly provider response. +- The NoodleR feed stays strictly chronological. No interest ranking: an order the user + cannot predict is an order in which they quietly miss things. +- Slice 8f-6 keeps the creator page as one surface per Slice 7, but gathers operator controls + into one delimited area rather than interleaving them with the audience view, so Subscribe + and Delete no longer sit side by side as if they were the same kind of act. +- Follow means feed curation and Subscribe means access; subscribing implies following. + The NoodleR feed's tabs become Following and All creators, and the Subscribed tab is + dropped because a post's own lock already shows subscription state. Bulk creation does + not auto-follow the creators it makes. **Decided:** Following is the tab the UI opens to + by default; onboarding's completion step is the one exception, selecting All creators + once because Following starts empty right after setup. +- **Subscribe-implies-follow does not contradict no-auto-follow** (decided 2026-07-29). + Someone who subscribes wants the service delivered; a subscription that left the creator + out of the feed would be a subscription to nothing. The rule no-auto-follow protects is + that the *system* never follows on the user's behalf — bulk-creating 40 creators must not + mint 40 follows. The Unlock sheet's Subscribe row states the follow in its label. +- **`postsPerDay` defaults to 4**, validated 1..24 (decided 2026-07-29). Deliberately low: + it caps automatic load at 4 text plus at most 4 image attempts per rolling 24 hours and is + raised on purpose rather than discovered through a bill. Real paid and local-model runs + remain worth doing to tune it, but no longer gate 8f-3. +- Automatic posts stay locked by default, but generation may produce public teaser posts, + and those are visible to non-followers in the All-creators tab. Discovery is the + teaser's job; without it a new creator is only a wall of padlocks. +- Slice 8f replaces NoodleR's per-creator interval cadence with **front-loaded + generation into a private scheduled-post reserve**. Provider work happens before the + assigned publication time. Due publication is a local idempotent transition with no + provider call. +- The reserve covers a rolling 24-hour horizon. Windowed time placement may reuse the + narrow planning principle from public Noodle, but NoodleR does not reuse public + refresh-state semantics or put unpublished content in the ordinary post table. +- **NoodleR generation stays one provider call per post.** `generateAndApplyNoodlerPost()` + remains per account because disclosure, identity redaction, media policy, and operation + locking differ per creator. +- **Daily automatic load has one user-set number and two explicit ceilings.** + `postsPerDay = N` is the targeted maximum publication density and rolling text-attempt + ceiling; when automatic images are enabled, the derived rolling image-attempt ceiling is + also N, for at most 2N combined automatic provider attempts. Failed potentially billable + attempts count. Text and image attempts are atomically claimed before provider work in + separate ledger kinds; image retries consume the image ceiling rather than exceeding it. +- Preparation is low priority and concurrency 1. A connection-scoped admission lease starts + it only after 30 foreground-idle seconds; an already-running call is never preempted. +- The capability-owned NoodleR outbox stores validated payload, creator, generated and + publication times, private-media ownership, policy/source/schedule fingerprint, and lifecycle + state. Publication atomically creates the ordinary post and marks the item published; + a unique link makes restart reconciliation idempotent. +- **Revision of Slice 8's scheduling contract.** Per-stage-profile enable/disable survives, + so the release-candidate condition still holds. Removed: `intensity`, + `noodle-autopost-cadence.ts`, `noodle-autopost-scheduler.service.ts`'s poll-and-claim loop, + per-creator `nextRunAt`, recurring rescheduling, startup catch-up, and + generated-after-the-fact historical timestamps. +- Ad-hoc "post now" is not a second schedule. After a successful immediate post, discard + prepared items for that creator where + `manualCreatedAt < publishAt <= manualCreatedAt + 60 minutes`; normal preparation may + replace them later. Future-dated user-authored posts remain out of scope. +- Startup publishes only valid due items that were already prepared with fixed times, + discards invalid items, and returns. Future preparation starts only through the normal + post-start idle scheduler. If an absence exceeds the reserve horizon, NoodleR goes quiet + rather than creating a burst or invented history. +- **Narrowing of the Slice 11 rule, not a reversal:** character schedules and night quiet + constrain which creator is plausible at a proposed future publication time and may guide + that post's content. They never own a clock or determine frequency. If nobody is eligible, + that time remains uncovered rather than becoming debt. +- Creator selection uses a NoodleR-specific least-recently-active ordering that also + considers already prepared future items. Public Noodle's participant selector carries + invitation, follow, random-user, and priority semantics that do not port. +- Prepared automatic posts are standalone snapshots. They cannot answer a future viewer + interaction or depend on another unpublished post; Slice 8g replies remain reactive work + generated from the interaction path. +- Schedule, night-quiet, timezone, source, stage, disclosure, access, and media-policy + changes invalidate affected prepared items. With image prompt review enabled, an + unapproved or still-running image expires at `publishAt` and the post publishes + text-only; late image results are cleaned up and cannot mutate it. +- Roughly two thirds of real NoodleR use is watching rather than directing. The feed is + therefore an audience surface with no operator controls; authoring lives on the + creator's own page, continuing Slice 7's removal of the main-timeline picker. +- NoodleR gets a "new since your last visit" divider in the feed plus a counter on the + NoodleR entry point. The divider needs one stored timestamp **per viewer persona**, not + one per user and not per-post read + state. +- **8f is six units, not five.** The source-changed/orphaned-creator notice and the creator + page's delimited operator area ship together in 8f-6. 8f-5 is limited to the feed divider + and entry-point counter. +- **Merged slices are amended in place with an explicit banner**, never rewritten silently. + Slice 10 carries "Amended by 8f-2" and Slice 8 carries "Amended by 8f-3"; without one, the + shipped contract becomes unrecoverable from this document. +- **Slice 8g is added between 8f and 9a:** creators reply to real viewer interactions. + Interactions are already persisted but no generation path consumes them, so the feed is + currently one-way. This outranks synthetic fan ambience because it is the moment the + character addresses the user directly. +- A stage profile is a curated snapshot, not a mirror of its source character. Editing + the character never changes the creator automatically. Instead the creator page carries + one **source-changed** notice covering renames, appearance edits, and personality edits, + offering to adopt name/handle (for `open` profiles only), re-draft the stage profile, or + dismiss. Drift is detected by snapshotting exactly the card fields that feed the draft + and image prompts, and comparing when the creator page is read. +- Orphaned creators — deleted characters, and profile imports that mint fresh character + IDs — surface through the same notice in a "source missing" variant, offering explicit + relink or delete. Relinking is never guessed from a matching name; guessing wrong would + bind the wrong character to an 18+ stage profile. +- **Identity-protection defect to fix in 8f:** `protectNoodlerGeneratedIdentity()` redacts + the stored account name and handle, while the stage-profile draft feeds the live + character card including its current name. After a rename, a `hinted` or `secret` draft + can therefore emit the new real name unredacted. Redaction must protect the current + source identity as well as the stored snapshot, with a regression covering + rename-then-redraft. +- **8f-1 fixes the identity, not the call sites.** `stageProfileContainsPublicIdentity()` — + the validator gating drafts at `noodle-stage-profile-draft.service.ts:190` and + `noodle.routes.ts:725,781,826` — reads the same stored-only `PublicIdentity` and is + therefore equally blind; fixing only the redactor leaves the gate open. Widen the type to + the union of stored and live source identifiers and both are fixed at once, rather than + guarding seven call sites. The redactor already dedupes and sorts longest-first, so extra + identifiers preserve correct overlapping-name behaviour for free. Scope stays identity + only: appearance and personality drift are 8f-6's notice, not a redaction bug. +- NoodleR has schedule enable/disable, `postsPerDay`, rolling automatic-attempt usage, and reserve + reach; it does not add a separate pause-all field. The first reserve version is + inspect-only rather than individually reschedulable. - Global **Refresh NoodleR now** runs automation-enabled creators, prioritizing those - scheduled soon and then the remaining enabled creators. Slice 7's composer owns the + least recently active and then the remaining enabled creators. Slice 7's composer owns the single-creator path. -- A successful global/manual refresh consumes a near-future automatic slot and - advances the creator's `nextRunAt` under the existing cadence/schedule policy, - preserving explicit schedule intent and preventing an immediate duplicate run. -- Autopost enabled without a user-authored schedule uses a sensible randomized - default cadence; exact jitter is implementation detail, not a separate scheduler. - Global fan policy values are defaults with per-creator overrides. Fan policy may shape the audience archetype mix, including ordinary, eccentric, cross-fandom, raider, organic-discovery, and non-adult/free-resource audiences. Slice 9 defines @@ -920,18 +1196,19 @@ defaults and per-beat media controls remain open rather than inferred. mass stays aggregate; anonymous replies retain only an event-local display snapshot. All fan history is filtered through the current viewer's post-access projection. - NoodleR posts never mirror into public Noodle. `access: "public"` means Free/Public - inside NoodleR, selected explicitly per post; automatic posts stay subscriber by + inside NoodleR, selected explicitly per post; automatic posts stay `locked` by default and cannot silently widen access. - A creator project is an editable bounded content arc over the next 1–20 successful - generated posts (default 5), with at most one active project per creator. It reuses - Slice 8 timing and the private-post operation; source schedules may guide content but - never publication time. Exact project-level media controls remain undecided. + generated posts (default 5), with at most one active project per creator. It reuses the + Slice 8f reserve planner and prepare-for-later operation; source schedules may guide + content but never own publication timing. A project beat is reserved with a prepared + item and finalized on publication. Exact project-level media controls remain undecided. - There is no permanent auto-post-only creative brief. Durable stage identity, one-post Guide, and bounded Projects are the complete instruction layers. - Slice 7 guidance is a button/action over the current composer draft, not a required always-on mode. **Post** publishes the draft literally; **Guide** transforms it - through the existing private generation pipeline. -- Literal non-LLM private posting is required product behavior, not merely a fallback + through the existing NoodleR generation pipeline. +- Literal non-LLM NoodleR posting is required product behavior, not merely a fallback or implementation convenience. - Guided generation ultimately exposes four independent output choices: **enable title**, **enable text**, **enable image**, and **enable poll**. The image and poll diff --git a/.github/workflows/owner-approval-review-signal.yml b/.github/workflows/owner-approval-review-signal.yml new file mode 100644 index 0000000000..3f912a9d26 --- /dev/null +++ b/.github/workflows/owner-approval-review-signal.yml @@ -0,0 +1,15 @@ +name: Owner Approval Review Signal + +on: + pull_request_review: + types: [submitted, edited, dismissed] + +permissions: {} + +jobs: + signal: + name: Signal trusted owner approval refresh + runs-on: ubuntu-latest + steps: + - name: Record review event + run: echo "A trusted workflow will re-evaluate the staging owner-approval status." diff --git a/.github/workflows/owner-approval-review.yml b/.github/workflows/owner-approval-review.yml new file mode 100644 index 0000000000..9ac2a5c44a --- /dev/null +++ b/.github/workflows/owner-approval-review.yml @@ -0,0 +1,73 @@ +name: Owner Approval Review Evaluator + +on: + workflow_run: + workflows: [Owner Approval Review Signal] + types: [completed] + +concurrency: + group: owner-approval-review-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_sha }} + cancel-in-progress: true + +jobs: + evaluate: + name: Re-evaluate staging owner approval + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + pull-requests: read + statuses: write + steps: + - name: Resolve pull request + id: pull-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + let pullRequests = context.payload.workflow_run.pull_requests || []; + if (pullRequests.length === 0) { + const response = await github.request( + 'GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls', + { + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.workflow_run.head_sha, + }, + ); + pullRequests = response.data; + } + + const pullNumbers = [...new Set(pullRequests.map((pullRequest) => pullRequest.number))]; + if (pullNumbers.length === 0) { + core.notice('The review signal is not associated with a pull request.'); + return; + } + if (pullNumbers.length !== 1) { + core.setFailed('The review signal is associated with more than one pull request.'); + return; + } + + core.setOutput('number', String(pullNumbers[0])); + + - name: Checkout trusted default branch + if: steps.pull-request.outputs.number != '' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + if: steps.pull-request.outputs.number != '' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: Publish owner approval status + if: steps.pull-request.outputs.number != '' + env: + GITHUB_TOKEN: ${{ github.token }} + MEMBERS_TOKEN_CONFIGURED: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN != '' }} + PASTA_DEVS_MEMBERS_TOKEN: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN }} + PR_NUMBER: ${{ steps.pull-request.outputs.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node scripts/evaluate-owner-approval.mjs diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index 16f66a2def..dfbae43eb4 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -36,6 +36,12 @@ jobs: - name: Run project checks run: pnpm check + - name: Validate pull request gate topology + run: node scripts/validate-pr-triage.mjs + + - name: Test owner approval evaluator + run: node scripts/test-owner-approval.mjs + - name: Check version drift run: pnpm version:check diff --git a/.github/workflows/pull-request-triage.yml b/.github/workflows/pull-request-triage.yml index b312fb9be9..cdebf1d539 100644 --- a/.github/workflows/pull-request-triage.yml +++ b/.github/workflows/pull-request-triage.yml @@ -4,8 +4,6 @@ on: pull_request_target: types: [opened, reopened, edited, synchronize, ready_for_review] branches: [staging, main] - pull_request_review: - types: [submitted, dismissed] concurrency: group: pull-request-triage-${{ github.event.pull_request.number }} @@ -14,9 +12,6 @@ concurrency: jobs: branch-policy: name: Branch policy check - if: >- - github.event_name == 'pull_request_target' && - (github.event.action != 'edited' || contains(toJSON(github.event.changes), '"base"')) runs-on: ubuntu-latest permissions: {} steps: @@ -49,107 +44,34 @@ jobs: echo "::error::Contributions must target staging. Main accepts only SpicyMarinara's same-repository staging promotion or hotfix/* PR." exit 1 - external-contributor-approval: - name: "${{ github.event.pull_request.base.ref == 'staging' && ((github.event_name == 'pull_request_target' && (github.event.action != 'edited' || contains(toJSON(github.event.changes), '\"base\"'))) || (github.event_name == 'pull_request_review' && github.event.review.user.login == 'SpicyMarinara' && (github.event.action == 'dismissed' || github.event.review.state == 'approved' || github.event.review.state == 'changes_requested' || github.event.review.state == 'commented'))) && 'Owner approval for outside contributors' || 'Ignore unrelated triage event' }}" + owner-approval: + name: Evaluate staging owner approval + if: github.event.pull_request.base.ref == 'staging' runs-on: ubuntu-latest permissions: + contents: read pull-requests: read - env: - APPROVAL_EVENT_RELEVANT: "${{ github.event.pull_request.base.ref == 'staging' && ((github.event_name == 'pull_request_target' && (github.event.action != 'edited' || contains(toJSON(github.event.changes), '\"base\"'))) || (github.event_name == 'pull_request_review' && github.event.review.user.login == 'SpicyMarinara' && (github.event.action == 'dismissed' || github.event.review.state == 'approved' || github.event.review.state == 'changes_requested' || github.event.review.state == 'commented'))) }}" + statuses: write steps: - - name: Ignore unrelated triage event - if: env.APPROVAL_EVENT_RELEVANT != 'true' - run: exit 0 - - - name: Classify pull request author - if: env.APPROVAL_EVENT_RELEVANT == 'true' - id: internal-author - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - MEMBERS_TOKEN_CONFIGURED: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN != '' }} + - name: Checkout trusted base revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - # Fine-grained PAT or GitHub App token with Pasta-Devs "Members: read". - github-token: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN || github.token }} - script: | - const organization = context.repo.owner; - const ownerLogin = 'SpicyMarinara'; - const authorLogin = context.payload.pull_request.user.login; - - if (authorLogin.toLowerCase() === ownerLogin.toLowerCase()) { - core.notice(`${authorLogin} is the repository owner; owner approval is not required.`); - core.setOutput('internal', 'true'); - return; - } + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - if (process.env.MEMBERS_TOKEN_CONFIGURED !== 'true') { - core.setFailed('PASTA_DEVS_MEMBERS_TOKEN is required to verify Pasta-Devs organization membership.'); - return; - } - - async function getOrganizationMembership(username) { - return github.request('GET /orgs/{org}/memberships/{username}', { - org: organization, - username, - }); - } - - let organizationMembership; - try { - ({ data: organizationMembership } = await getOrganizationMembership(authorLogin)); - } catch (error) { - if (error.status === 404) { - core.notice(`${authorLogin} is not a ${organization} organization member; owner approval is required.`); - core.setOutput('internal', 'false'); - return; - } - core.setFailed(`Could not verify ${authorLogin}'s ${organization} membership (${error.status ?? 'unknown status'}).`); - return; - } - - if (organizationMembership.state !== 'active') { - core.notice(`${authorLogin}'s ${organization} membership is not active; owner approval is required.`); - core.setOutput('internal', 'false'); - return; - } - - if (organizationMembership.role === 'admin' || organizationMembership.role === 'member') { - core.notice(`${authorLogin} is an active ${organization} ${organizationMembership.role}; owner approval is not required.`); - core.setOutput('internal', 'true'); - return; - } - - core.notice(`${authorLogin} does not have a trusted ${organization} organization role; owner approval is required.`); - core.setOutput('internal', 'false'); - - - name: Require owner approval for outside staging contributions - if: env.APPROVAL_EVENT_RELEVANT == 'true' && steps.internal-author.outputs.internal != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - script: | - const pullNumber = context.payload.pull_request.number; - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullNumber, - }); - - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullNumber, - per_page: 100, - }); - const ownerReviews = reviews - .filter((review) => review.user?.login?.toLowerCase() === 'spicymarinara') - .sort((left, right) => left.id - right.id); - const latestOwnerReview = ownerReviews.at(-1); - - if (latestOwnerReview?.state === 'APPROVED' && latestOwnerReview.commit_id === pr.head?.sha) { - core.notice('Outside contribution approved by SpicyMarinara.'); - return; - } + node-version: 24 - core.setFailed('Outside and first-time contributions to staging require an approving review from SpicyMarinara.'); + - name: Publish owner approval status + env: + GITHUB_TOKEN: ${{ github.token }} + MEMBERS_TOKEN_CONFIGURED: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN != '' }} + PASTA_DEVS_MEMBERS_TOKEN: ${{ secrets.PASTA_DEVS_MEMBERS_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node scripts/evaluate-owner-approval.mjs label: name: Auto-label @@ -214,16 +136,22 @@ jobs: template-check: name: Pull Request template check runs-on: ubuntu-latest - if: >- - github.event_name == 'pull_request_target' && - github.event.pull_request.author_association != 'MEMBER' && - github.event.pull_request.author_association != 'OWNER' && - github.event.pull_request.author_association != 'COLLABORATOR' permissions: contents: read pull-requests: read steps: + - name: Exempt trusted contributor + if: >- + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'OWNER' || + github.event.pull_request.author_association == 'COLLABORATOR' + run: echo "Trusted contributor; pull request template validation is not required." + - name: Check Pull Request body against template + if: >- + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'COLLABORATOR' env: PR_BODY: ${{ github.event.pull_request.body }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index c16034d295..84f04572f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ This file is the release-notes source of truth for Marinara Engine. Reuse these ## [Unreleased] +### Added + +- Added portable character-gallery image references: `card://self/gallery/` in a greeting or message resolves to whichever character is speaking, so gallery images embedded in a card keep working after export and import (character ids are regenerated on import, which broke id-based links). The character gallery gains a **Copy image reference** button that produces the portable form, editor field previews resolve it for the edited character, group-chat replies resolve `self` per speaker segment, and the native-export importer now preserves gallery filenames (sanitized, collision-safe) instead of renaming every image, which is the fix that makes the references survive the round trip. Documented in the Character Galleries and Sending & Streaming guides. +- Added the **Hindi** documentation language pack, covering all 124 in-app guides (developer docs included) in natural Hindi, with English UI control names preserved for following instructions against the interface and Hindi sidebar category labels in the docs viewer. Select it under **Settings → General → Documentation Language** via **Download & Replace** (#4471). +- Taught the docs viewer to render right-to-left documentation, ahead of the planned Arabic pack: each guide follows its served language's reading direction (untranslated English fallbacks stay left-to-right), code spans and fences keep their left-to-right order inside RTL prose, and lists/tables/panels use direction-aware styling. Three deliberate refinements are visible today: search highlights lose their slight inset so they can no longer sever cursive letter joining, sidebars whose category headers use non-Latin scripts (Japanese, Korean, Chinese, Hindi) drop the small-caps letter-spacing that misfit them, and the "Last updated" dates now follow the app's language instead of the browser's. Everything else renders identically for existing packs (#4489). + +### Fixed + +- Preserved full JannyAI definition fields by falling back to recovered page data when the original PNG is blocked, and stopped incomplete search metadata from masquerading as a complete character import (#4497). +- Matched PocketTTS's official `localhost:8000` multipart `/tts` API and built-in voice catalog while retaining automatic compatibility with existing OpenAI-style PocketTTS wrapper URLs (#4499). +- Let custom post-processing agents evaluate activation keywords against the completed assistant response, so Scan Depth 1 now sees the message the agent is meant to process (#4498). +- Refined Roleplay Chat Summary controls with a centered Backfill action, a Chat Summary/Combine prompt switcher, and one Edit path that keeps the active prompt visible above template editing (#4501). +- Sorted the **Settings → General → Language** dropdown by language code with English pinned first, matching the Documentation Language selector. The previous native-label sort used a different collation per entry, which scrambled the order across scripts (#4471). +- Sent Noodle image instructions to the timeline model, and stopped the default Noodle Post Image template from appending them to the image-generation prompt, so directions like "mention build, clothing, pose, lighting" now shape the generated image description instead of reaching ComfyUI as literal prompt text. Custom templates that still reference `{{userInstructions}}` continue to append it verbatim. Raw style tokens belong in an image style profile, which applies to every Noodle image. +- Stripped label text and language-model framing from the character personality and image-habit blocks in the Noodle image prompt, so the image model receives the descriptive values instead of sentences written for an LLM. +- Dropped the `Character appearance notes:` header from the shared illustrator appearance block, which every caller appends directly to an image prompt, so diffusion models stop receiving the label as drawable text. + +## [2.4.1] + +### Added + +- Added optional prompt-preset targeting to regex scripts and refreshed the existing character target picker so scoped regexes follow the selected preset or characters without clipped controls (#4446). + +### Changed + +- Advanced the stable release identity to v2.4.1 across the Engine, PWA manifest, Windows installer, Android bootstrap APK, update checks, Home page, and release references. Android uses `versionName` `2.4.1` with `versionCode` `42` so it updates over every previously published APK. + +### Fixed + +- Let Characters, Personas, Lorebooks, Agents, Presets, and Connections sidebar labels use the full desktop row width beneath hover actions, and made Conversation Call clip-length rows size to their panel instead of clipping labels beside fixed-width fields (#4449). +- Removed accumulated duplicate built-in **Default** settings profiles during startup normalization while preserving one stable profile and active selection per chat mode (#4442). +- Kept `/scene` chats and standalone conversions out of Conversation branch groups so original conversations remain visible in the Conversation sidebar (#4443). +- Taught Professor Mari the supported custom `image_prompt` agent configuration, including marker activation and the image-generation capability, so she creates requested image agents instead of falsely refusing them (#4444). +- Kept Google Gemini API keys in the `x-goog-api-key` header when fetching models instead of duplicating them in the URL, preventing compatible proxies from rejecting the query token with HTTP 401 (#4448). + ## [2.4.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fd41cbb99..9b2607586f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -220,6 +220,7 @@ The overlay is not a substitute for this guide. When instructions conflict, foll - Japanese: natural Japanese (polite です・ます prose with noun-phrase 体言止め headings and no "あなた" floods — Japanese drops subjects; product names stay in LATIN SCRIPT, never katakanized — "Marinara Engineでは", never "マリナーラ"; katakana loanwords use the modern trailing-ー spelling — "サーバー"/"ユーザー"/"フォルダー", never "サーバ"/"ユーザ"/"フォルダ" — with community-standard terms such as "ロアブック"; ALL Latin letters and digits stay half-width ASCII (full-width "7860" never matches a search for `7860`); no ideographic space U+3000, no non-breaking spaces, no space between Japanese and Latin/bold/code spans, text NFC-normalized; 「」 for Japanese quoting while quoted English UI strings stay byte-exact to the app; mode names Conversation/Roleplay/Game Mode stay English). - Korean: natural Korean (the 합니다체 register standard in Korean software with ~하세요 imperatives and noun-phrase headings; never "당신"; product names stay in LATIN SCRIPT — never transcribed — with phonetically correct particle attachment, "Marinara Engine은", "HUD와"; ONE transcription and ONE spacing per term — "메시지" never "메세지", "콘텐츠" never "컨텐츠", "캐릭터 카드" always spaced that way — because either split fragments the substring search; UI-label glosses match the app's shipped Korean UI strings in `ko.json` where they exist; ALL Latin letters and digits half-width ASCII; no ideographic space U+3000, no non-breaking spaces, straight ASCII quotes only (never 낫표 「」), text NFC-normalized — macOS-decomposed Hangul jamo would silently break search; mode names Conversation/Roleplay/Game Mode stay English). - Simplified Chinese (`zh-hans`): natural Simplified Chinese ("你" address, never "您"; SIMPLIFIED CHARACTERS ONLY — a stray traditional form like "個" or "說" silently breaks search for readers typing simplified; the Chinese SillyTavern community's established terms — "世界书" for lorebook, "角色卡", "提示词" for prompt, "立绘" for sprites, "智能体" for agent; product names stay in LATIN SCRIPT; full-width CJK punctuation ,。() in prose but ALL Latin letters and digits half-width ASCII — full-width "7860" never matches a search for `7860`; glosses in half-width parens tight after a bold/Latin label and full-width () inside pure Chinese prose; curly “” for Chinese-prose quoting while quoted English UI strings stay byte-exact; no ideographic space U+3000, no NBSP, text NFC-normalized; mode names Conversation/Roleplay/Game Mode stay English). + - Hindi: natural modern technical Hindi (the Google/Microsoft Hindi register — Devanagari loanwords like "फ़ाइल"/"सर्वर"/"प्रॉम्प्ट", never शुद्ध purisms like "संगणक"; "आप" address with "करें"-style imperatives, never "तू"/"तुम"; ONE transliteration per term with a fixed nukta policy — nukta kept on ज़/फ़ only, so "फ़ाइल" but "खास", because "फ़ाइल" and "फाइल" are different byte strings that split the substring search; international digits 0-9 only — Devanagari "०७८६०" never matches a search for `7860`; the danda "।" ends Hindi sentences (verbatim English strings keep their own punctuation); product names stay in LATIN SCRIPT with postpositions as separate words — "Marinara Engine में"; straight ASCII quotes; text NFC-normalized with no ZWJ/ZWNJ; mode names Conversation/Roleplay/Game Mode stay English). - After editing a pack, run `node scripts/docs-i18n/build-manifest.mjs ` to refresh hashes, then `node scripts/docs-i18n/validate-pack.mjs ` from the Engine repo root, before committing to `docs-i18n`. ## Localization diff --git a/Dockerfile b/Dockerfile index 1be056f6af..c0e376d988 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 \ libgomp1 \ libvulkan1 \ + bubblewrap \ python3 \ python3-venv \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index b48614c073..9206d2321f 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ ## Latest Release -Current stable release: **[v2.4.0](https://github.com/Pasta-Devs/Marinara-Engine/releases/tag/v2.4.0)**. +Current stable release: **[v2.4.1](https://github.com/Pasta-Devs/Marinara-Engine/releases/tag/v2.4.1)**. See [CHANGELOG.md](CHANGELOG.md) for detailed release notes. Tagged releases use the `vX.Y.Z` format and are published on the [Releases](https://github.com/Pasta-Devs/Marinara-Engine/releases) page with a Windows installer, Android bootstrap APK, and named versioned source ZIP. Android APKs are Termux bootstrap + WebView shells: they can download Termux from F-Droid, launch Android's installer, start the Termux setup flow after required permission prompts, then open the local Marinara server on the same device. @@ -199,7 +199,7 @@ The full guide library is browsable inside the app: open **Documentation** from | [docs/media/style-profiles.md](docs/media/style-profiles.md) | Image style profiles and prompt grammar | | [docs/media/tts-setup.md](docs/media/tts-setup.md) | Text to speech (TTS) setup and voices | | [docs/media/scene-video.md](docs/media/scene-video.md) | Scene-video provider setup and the Gallery animation workflow | -| [docs/game/storyboard.md](docs/game/storyboard.md) | Manual and automatic Game Mode storyboards plus inline Roleplay storyboard episodes | +| [docs/game/storyboard.md](docs/game/storyboard.md) | Storyboard Agent installation, global defaults, and Roleplay and Game Mode workflows | | [docs/game/ltx-2-3-storyboards.md](docs/game/ltx-2-3-storyboards.md) | Krea 2 first frames, local LTX 2.3 ComfyUI animation, and tested Game Mode settings | | [docs/agents/agents-overview.md](docs/agents/agents-overview.md) | Agent system overview: phases, per-chat enablement, built-in and custom agents | | [docs/extending/custom-tools.md](docs/extending/custom-tools.md) | Function calling, custom tools, webhooks, scripts, and agent tool enablement | @@ -255,3 +255,7 @@ Pasta-Devs. Ordinary truthful descriptive and nominative references to Marinara Engine remain welcome. When a reference is used to market or operate a hosted service, its operator and independent status must be clear. See the [Trademark and Branding Policy](TRADEMARKS.md) for the complete guidelines. + +_olud.ai is an independent third-party service and is not endorsed by Pasta-Devs._ + +[![olud.ai](https://olud.ai/badge.php?tool=pasta-devs-marinara-engine)](https://olud.ai/project/pasta-devs-marinara-engine.html) diff --git a/android/app/build.gradle b/android/app/build.gradle index 5d0ed48467..91e1068752 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,10 +22,10 @@ android { applicationId "com.marinara.engine" minSdk 24 targetSdk 34 - versionCode 41 - versionName "2.4.0" + versionCode 42 + versionName "2.4.1" buildConfigField "String", "MARINARA_SERVER_URL", "\"http://127.0.0.1:${marinaraPort}\"" - buildConfigField "String", "MARINARA_RELEASE_TAG", "\"v2.4.0\"" + buildConfigField "String", "MARINARA_RELEASE_TAG", "\"v2.4.1\"" } signingConfigs { diff --git a/docker-compose.yml b/docker-compose.yml index 134a770e71..2f54c76449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: - FILE_STORAGE_DIR=/app/data/storage - MARINARA_DOCKER=true - AUTO_CREATE_DEFAULT_CONNECTION=${AUTO_CREATE_DEFAULT_CONNECTION:-true} + - TRUSTED_HOSTS=${TRUSTED_HOSTS:-} + - CORS_ORIGINS=${CORS_ORIGINS:-} + - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:-} # Set BASIC_AUTH_USER/BASIC_AUTH_PASS and ADMIN_SECRET before exposing beyond localhost. # - TZ=America/New_York # IANA timezone for time-based features (e.g. character schedules) # - ENCRYPTION_KEY= # AES key for API key encryption (generate: openssl rand -hex 32) diff --git a/docs/FAQ.md b/docs/FAQ.md index c0f35356e2..89b7b502b1 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -166,13 +166,13 @@ Professor Mari can still edit ordinary Marinara source files. Dependency files, Note: on an ordinary remote address, Professor Mari's data-changing actions need both Basic Auth and an admin secret. Trusted or allowlisted network routes can use the bypasses described in [Remote Access](REMOTE_ACCESS.md). -## How do storyboard animations work? +## What is the Storyboard Agent, and how do I use it in Game Mode? -A **storyboard** turns completed story text into a short sequence of keyframe images and can add animated clips. In **Game Mode**, it follows one finished game master narration turn. In **Roleplay**, the Storyboard Agent combines newly completed exchanges into an inline episode after a configurable number of user and assistant messages. +The downloadable **Storyboard** Agent turns completed story text into an ordered sequence of keyframe images and can animate each keyframe into a short clip. In **Game Mode**, it storyboards one finished GM narration turn and displays the frames in a floating viewer or as the Game background. In **Roleplay**, it combines newly completed exchanges into an inline episode. -To make one by hand, open the **Gallery** and click **Create storyboard**. Game Mode can reopen its viewer with **View storyboard**; Roleplay displays the result below the assistant response that ends the episode. +To use it in Game Mode, install **Storyboard** from **Agents > Download Agents**. Open the Game, go to **Chat Settings > Agents**, turn on **Enable Agents** and **Enable Storyboards**, and set an image connection in the Game or the global Storyboard setup. Finish a GM narration turn, then open the **Gallery** and click **Create storyboard**. Use **View storyboard** to reopen its viewer. -To make them automatically, open **Chat Settings**, go to **Agents**, and find the **Storyboards** card. Game Mode has separate illustration and animation toggles. Roleplay offers **Manual only**, **Still images**, or **Animations**, plus **Messages per episode**, which counts both user and assistant messages. Video clips need a Video Generation connection. For both workflows, see [Storyboard Engine Guide](game/storyboard.md). +For automatic Game Storyboards, turn on **Automatic Storyboard Illustrations**. Also turn on **Automatic Storyboard Animations** and select a Video Generation connection when you want clips. The new-game wizard's **Storyboard Optimized** presentation only shapes GM narration; it does not install or activate the Agent. For Game and Roleplay setup, prompts, viewers, migration behavior, and troubleshooting, see the [Storyboard Agent Guide](game/storyboard.md). ## Can characters talk out loud in a call? @@ -182,7 +182,7 @@ If you want to talk back with your microphone and the browser's own speech recog ## Can Marinara generate images? -Yes. Add an image generation connection, for example **Pollinations** (needs no key) or a paid provider. Marinara can then create character avatars, scene art, selfies, and Game Mode storyboards. See [Connecting to an AI Provider](connections/connecting-to-a-provider.md) to add one. +Yes. Add an image generation connection, for example **Pollinations** (needs no key) or a paid provider. Marinara can then create character avatars, scene art, selfies, and Storyboard Agent keyframes in Roleplay or Game Mode. See [Connecting to an AI Provider](connections/connecting-to-a-provider.md) to add one. ## How do I read the documentation inside the app? diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index cc8221f6ee..d4b60a9e79 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -191,6 +191,7 @@ Then restart Marinara and click **Reapply Cleanup** in the sprite generation win Game Mode Storyboards turn a completed GM narration into keyframe images and optional clips. Roleplay Storyboards combine completed exchanges and display the result inline after the assistant response. +- Confirm **Storyboard** is installed from **Agents** > **Download Agents**, then turn on **Enable Agents** and **Enable Storyboards** for the chat. - For a manual scene video, generate or upload a **Gallery** image first, then use its **Video** or **Animate** action. The **Gallery** splits **Images** and **Videos** into tabs, so check the **Videos** tab. - For automatic Game Mode Storyboards, open **Chat Settings** > **Agents** > **Storyboards** and confirm **Automatic Storyboard Illustrations** is on. Turn on **Automatic Storyboard Animations** too if you also want clips. - In Roleplay, add the **Storyboard** Agent to the chat. Choose **Still images** or **Animations**, set **Messages per episode**, and select the Storyboard image connection. **Manual only** runs from **Create storyboard** in the Gallery instead. @@ -198,7 +199,7 @@ Game Mode Storyboards turn a completed GM narration into keyframe images and opt - If a custom prompt works better with all characters combined, turn off **Use NovelAI Character Prompts**. - Slow providers can hit a timeout. Raise `IMAGE_GEN_TIMEOUT_MS` or `VIDEO_GEN_TIMEOUT_MS` in `.env`, then restart Marinara. The server only reads these values at startup. -See [Storyboard Engine Guide](game/storyboard.md) for both workflows and [Game Mode: Getting Started](game/getting-started.md) for Game setup. +See the [Storyboard Agent Guide](game/storyboard.md) for both workflows and [Game Mode: Getting Started](game/getting-started.md) for Game setup. ### Game Mode world generation shows a JSON error @@ -333,7 +334,24 @@ After disabling a full page extension, reload Marinara if a toolbar item, overla ### A Server Extension says no supported sandbox is available -Server Extensions run only with macOS Seatbelt or Linux Bubblewrap. Install `bwrap` on the Linux host, then restart Marinara. Windows, Android, and other unsupported hosts deliberately refuse Server Extension execution instead of falling back to the main server process. Browser Extensions can still use their opaque-origin Worker sandbox. +Server Extensions and Professor Mari's raw shell commands run only with macOS Seatbelt or Linux Bubblewrap. Install `bwrap` on a native Linux host, then restart Marinara. The official Docker image already includes Bubblewrap, but the default container remains least-privileged and cannot create Bubblewrap's nested namespaces and mounts. Marinara detects that state and keeps OS-sandbox features disabled instead of attempting broken commands. + +If you accept the broader container privileges and need these features in Docker, save this as `docker-compose.override.yml` next to `docker-compose.yml`: + +```yaml +services: + marinara: + environment: + MARINARA_DOCKER_USER: root + cap_add: + - SYS_ADMIN + security_opt: + - apparmor=unconfined +``` + +Recreate the container after adding the override. Keeping the server process as root is necessary here so the added capability is not discarded when Marinara's entrypoint normally drops to the `node` user. Running the server as root with `SYS_ADMIN` is a broad privilege escalation, and disabling the container's AppArmor profile further weakens its outer security boundary; do not enable this merely to silence the unavailable-sandbox message. Docker's default seccomp profile adapts to added capabilities, so a blanket `seccomp=unconfined` setting should not be necessary on current Docker releases. + +Windows, Android, and other unsupported hosts deliberately refuse Server Extension execution instead of falling back to the main server process. Browser Extensions can still use their opaque-origin Worker sandbox. ## Getting more help diff --git a/docs/agents/built-in-agents.md b/docs/agents/built-in-agents.md index 8a6c750dc0..0ee4c52970 100644 --- a/docs/agents/built-in-agents.md +++ b/docs/agents/built-in-agents.md @@ -224,7 +224,8 @@ Plans still or animated visual storyboards from completed Roleplay exchanges and - **Integration**: Agent package; Game and Roleplay use the installed package's prompt templates and settings through the Engine's Storyboard host integration. - **Where it works**: Roleplay and Game. - **Key settings**: choose still or animation planners, image and video connections, keyframe count, duration, display mode, character-reference handling, Roleplay episode and style templates, and Game illustration/video templates. -- **Compatibility**: Engine `2.3.5` through before `4.0.0`. The package uses `agent-runtime`, `chat-read`, `prompt-context`, `storage`, and `ui` permissions and does not require a restart. +- **Compatibility**: Engine `2.3.5` through before `3.0.0`. The package uses `agent-runtime`, `chat-read`, `prompt-context`, `storage`, and `ui` permissions and does not require a restart. +- **Full guide**: [Storyboard Agent: Roleplay and Game Mode](../game/storyboard.md). ### Calls diff --git a/docs/characters/creating-and-editing-characters.md b/docs/characters/creating-and-editing-characters.md index 689e565a85..6d9428b731 100644 --- a/docs/characters/creating-and-editing-characters.md +++ b/docs/characters/creating-and-editing-characters.md @@ -76,6 +76,8 @@ The **Dialogue & Greetings** section sets how a chat opens and how the character - **Alternate Greetings**. Extra opening messages. When you start a chat you can pick which one to use. Use the up and down controls to reorder them, and the X to remove one. - **Example Dialogue**. Sample exchanges that teach the character's voice. Use `` to separate exchanges. Use `{{user}}` and `{{char}}` as placeholders. +Greetings and example messages can also display images from the character's Gallery; see [Character galleries → Reuse a gallery image in messages and greetings](galleries.md#reuse-a-gallery-image-in-messages-and-greetings). + A short Example Dialogue entry looks like this: ``` diff --git a/docs/characters/galleries.md b/docs/characters/galleries.md index 9dd6cd5f53..cefb06279f 100644 --- a/docs/characters/galleries.md +++ b/docs/characters/galleries.md @@ -68,6 +68,36 @@ A gallery-tagged emoji or sticker is scoped to that one character or persona. It If a gallery name matches a name in the global pool, the gallery version wins for that chat. Names are not checked for uniqueness. Pick a distinct name for each image to avoid surprises. +## Reuse a gallery image in messages and greetings + +Any image in a character's Gallery can be displayed inside chat text: a greeting, an example message, or a message the character sends. Hover a gallery image and click **Copy image reference** (the link icon). It copies a small piece of Markdown you can paste anywhere the character speaks: + +```text +![sunset selfie](card://self/gallery/k3m2xq7.png) +``` + +The one rule: **`self` means the character who is speaking that message.** When the message renders, Marinara replaces `self` with that character and shows the image from their gallery. + +Where it works: + +- **First Message**, **Alternate Greetings**, and **Example Dialogue** on the character card. The field's Markdown preview shows the image while you edit. +- Any message a character sends, in **Roleplay** and **Conversation** mode alike. +- **Group chats**: in a multi-speaker reply, `self` resolves per speaker, so each character's line shows that character's own gallery image. If the speaker's gallery does not have the file, Marinara looks it up in the other chat characters' galleries, so the right image still appears even when a reply is merged under one speaker. + +Where it does not work, by design: + +- **Your own messages**. They have no speaking character, so a `self` reference shows as a broken image. If you want to post a character's gallery image yourself, use the chat asset browser's insert (which writes the full `card://characters//...` form). +- **System messages**. They do not render Markdown image syntax at all, so a reference shows as literal text. +- **Persona galleries**. Persona images appear in your messages, which have no speaker. Use the persona form `card://personas//gallery/` instead. + +One nuance for group chats: if two characters in the chat have gallery images with the **same filename**, the speaking character's image always wins. When the speaker does not have the file, the **first match wins**: the other characters are checked in the chat's character order. Give shared-name images distinct filenames if you need a specific character's version to show from another character's line. + +### Why `self` instead of the full link + +A full link contains the character's internal id (`card://characters//gallery/`), and ids are regenerated whenever a character is imported, so full links break for anyone you share the character with. The `self` form carries no id and no server address. It survives a **native JSON export and import**: the gallery images travel inside the export and keep their filenames, so every reference keeps working on the other side. + +One honest caveat: **PNG card exports do not include the gallery**, so no gallery reference of any kind can work after a PNG-only share. Ship the native `.json` export when your character uses gallery images. + ## Related guides - [Creating and Editing Characters](creating-and-editing-characters.md) diff --git a/docs/chats/sending-and-streaming.md b/docs/chats/sending-and-streaming.md index 1f4a58bb75..5afbc09e5a 100644 --- a/docs/chats/sending-and-streaming.md +++ b/docs/chats/sending-and-streaming.md @@ -41,6 +41,20 @@ Each file must be 20 MB or smaller. A larger file is rejected with a note that s The AI can only "see" an image if the connected model supports vision. If your model is text only, turn on **Image Captioning**. This setting lives in the per chat **Chat Settings**, in the **Advanced Parameters** section, and is off by default. When on, Marinara describes each attached image in text using a connection you pick, then sends that description instead of the raw image. +## Insert a gallery image into a message + +Attachments are for the AI to *see*. Gallery references are for the reader to *see*: they display an image from a gallery inside the message text itself. + +Messages support Markdown image syntax, and Marinara resolves special `card://` links to gallery files: + +```text +![a caption](card://characters//gallery/.png) +``` + +In Roleplay Mode, the chat's asset browser can insert one of these links for you. You can also paste one anywhere text is written: messages, greetings, example dialogue. + +For images from a **character's own gallery**, prefer the portable form `card://self/gallery/`, which keeps working after the character is exported and imported. The character gallery has a **Copy image reference** button that produces it. See [Character galleries → Reuse a gallery image in messages and greetings](../characters/galleries.md#reuse-a-gallery-image-in-messages-and-greetings) for the details. + ## Streaming the reply Streaming shows the reply appearing word by word as it generates, instead of waiting for the whole reply at once. The streaming controls live in **Settings**, under the **General** tab, in the **Responses** section: diff --git a/docs/development/personal-extensions.md b/docs/development/personal-extensions.md index 5f2d685f83..fe2aaf734f 100644 --- a/docs/development/personal-extensions.md +++ b/docs/development/personal-extensions.md @@ -88,15 +88,17 @@ Capabilities are declared in the extension payload, persisted with every revisio `marinara.ui.showWindow({ title, elements, onEvent, onClose })` returns a handle with `update({ title?, elements? })` and `close()`. The worker only sends descriptors, and the trusted iframe bootstrap builds every element with DOM APIs and `textContent` (never `innerHTML`). The host reveals the otherwise-hidden sandbox iframe only while a window is open and hides it again on close. -`marinara.ui.registerContribution({ id, kind, label, description?, icon?, elements?, onActivate?, onEvent? })` returns a frozen handle with `update(patch)` and `remove()`. It supports three fixed locations: +`marinara.ui.registerContribution({ id, kind, label, description?, icon?, surface?, position?, elements?, onActivate?, onEvent? })` returns a frozen handle with `update(patch)` and `remove()`. It supports these trusted host locations: -- `button`: a compact top-bar action on larger screens and an action in the Extensions menu everywhere; +- `button`: a compact top-bar action by default, or a host-rendered action on the `chats`, `bots`, `characters`, `personas`, `lorebooks`, `presets`, `connections`, `agents`, or `settings` surface; - `menu-item`: an action in the Extensions menu; - `panel`: an entry that opens Marinara's trusted Extensions side panel. +Side-panel buttons accept `position: "header"`, `"before-content"`, or `"after-content"`. Top-bar buttons omit `position`. Icons are bounded kebab-case names from Marinara's Lucide icon catalog; unsupported names fall back to the puzzle icon. + Panel elements use the same declarative vocabulary as constrained windows: `heading`, `text`, `pre`, `button`, `input`, `select`, `toggle`, `slider`, `color`, and `spacer`. Interactive controls require unique IDs. A panel button posts `{ contributionId, elementId, values }` to `onEvent`; `values` contains the current string value of every control. `onActivate` runs inside the extension Worker when the user opens or invokes the contribution. The extension can call `handle.update(...)` to replace its label, description, icon, or panel elements after state changes. -The client independently validates every descriptor before adding it to the runtime store. Contribution kinds, icons, controls, IDs, option lists, text lengths, total panel text, element count, and per-extension contribution count are allowlisted and capped. React renders extension text as text. No extension-controlled HTML, CSS, URL, React component, or host callback is accepted. The host removes all contributions when the worker is stopped, its hash changes, or it disappears from the approved runtime response. Events are dispatched only to the worker registered for the same extension ID and content hash. +The client independently validates every descriptor before adding it to the runtime store. Contribution kinds, surfaces, positions, controls, IDs, option lists, icon-name syntax, text lengths, total panel text, element count, and per-extension contribution count are validated and capped. React renders extension text as text. No extension-controlled HTML, CSS, URL, React component, or host callback is accepted. The host removes all contributions when the worker is stopped, its hash changes, or it disappears from the approved runtime response. Events are dispatched only to the worker registered for the same extension ID and content hash. There is no DOM helper, Marinara API fetch, parent event access, or arbitrary network capability. The iframe validates and rate-limits messages. A heartbeat watchdog terminates an unresponsive or busy-looping worker. diff --git a/docs/extending/personal-extensions.md b/docs/extending/personal-extensions.md index ed0bea7eb8..28ca078d4f 100644 --- a/docs/extending/personal-extensions.md +++ b/docs/extending/personal-extensions.md @@ -18,7 +18,7 @@ Sandboxing reduces authority; it does not make arbitrary code trustworthy. A mal A Browser Extension runs in a dedicated Worker inside an opaque-origin sandboxed iframe. It cannot access Marinara's page, DOM, cookies, browser storage, origin APIs, or network. Its capabilities are private extension storage, logging, managed timers, cleanup registration, constrained windows, safe host contribution slots, and a read-only snapshot of the active chat and Character IDs. It can receive selected fields from the active Character cards or selected Persona only when the corresponding permissions are declared and approved. -Extensions can add top-bar actions, Extensions menu items, and persistent right-side panels with `marinara.ui.registerContribution(...)`. Marinara renders these surfaces using the active theme and a fixed set of controls: headings, text, preformatted output, buttons, text inputs, selects, toggles, sliders, color controls, and spacers. An extension supplies content and state, never HTML, CSS, URLs, React components, or host event handlers. +Extensions can add top-bar or side-panel actions, Extensions menu items, and persistent right-side panels with `marinara.ui.registerContribution(...)`. Marinara renders these surfaces using the active theme and a fixed set of controls: headings, text, preformatted output, buttons, text inputs, selects, toggles, sliders, color controls, and spacers. An extension supplies content and state, never HTML, CSS, URLs, React components, or host event handlers. These UI capabilities and rules are identical for every sandboxed Browser Extension regardless of source. An imported third-party (External) Extension uses this safe runtime unless its package explicitly requests **Full page access** or uses the pre-sandbox `marinara.extension` format described below. @@ -62,7 +62,24 @@ const panel = marinara.ui.registerContribution({ marinara.onCleanup(() => panel.remove()); ``` -Use `kind: "button"` for a compact top-bar/Extensions-menu action and `kind: "menu-item"` for a menu-only action. Both invoke `onActivate`. A `panel` invokes `onActivate` when opened; its buttons invoke `onEvent` with the current values of every panel control. The returned handle supports `update({ label?, description?, icon?, elements? })` and `remove()`. IDs may contain letters, numbers, `.`, `_`, and `-`. +Use `kind: "button"` for a compact action and `kind: "menu-item"` for an Extensions-menu action. Buttons default to `surface: "top-bar"`. They can instead target `chats`, `bots`, `characters`, `personas`, `lorebooks`, `presets`, `connections`, `agents`, or `settings`, with `position` set to `header`, `before-content`, or `after-content`. The `icon` accepts any kebab-case Lucide icon name supported by Marinara. Both action kinds invoke `onActivate`. A `panel` invokes `onActivate` when opened; its buttons invoke `onEvent` with the current values of every panel control. The returned handle supports kind-specific updates: `button` accepts `label`, `description`, `icon`, `surface`, and `position`; `menu-item` accepts `label`, `description`, and `icon`; `panel` accepts `label`, `description`, `icon`, and `elements`. All handles support `remove()`. IDs may contain letters, numbers, `.`, `_`, and `-`. + +For example, this places a native action above the Presets panel content: + +```js +marinara.ui.registerContribution({ + id: "preset-helper", + kind: "button", + label: "Preset helper", + description: "Run the preset helper", + icon: "list-sparkles", + surface: "presets", + position: "before-content", + onActivate: () => { + // Run extension behavior here. + }, +}); +``` Complex tools can build multi-step interfaces by updating the panel elements after an event. Keep application state in `marinara.storage`; do not encode it in markup. @@ -148,9 +165,12 @@ Browser Extensions are sandboxed by the browser itself, so they work everywhere. | macOS | ✅ Sandboxed | ⚠️ Explicit trust required | ✅ Sandboxed (Seatbelt) | | Linux (with Bubblewrap) | ✅ Sandboxed | ⚠️ Explicit trust required | ✅ Sandboxed (Bubblewrap) | | Linux (without `bwrap`) | ✅ Sandboxed | ⚠️ Explicit trust required | ⛔ Disabled — install `bwrap` | +| Docker (default) | ✅ Sandboxed | ⚠️ Explicit trust required | ⛔ Disabled — container privileges | | Windows | ✅ Sandboxed | ⚠️ Explicit trust required | ⛔ Disabled — use a Browser Extension | | Android | ✅ Sandboxed | ⚠️ Explicit trust required | ⛔ Disabled — use a Browser Extension | +The official Docker image contains Bubblewrap, but its least-privileged default container cannot create the nested namespaces and mounts Bubblewrap needs. Marinara tests the sandbox at runtime and leaves Server Extensions disabled when the container denies them. See [Troubleshooting](../TROUBLESHOOTING.md#a-server-extension-says-no-supported-sandbox-is-available) for the explicit Docker permission override and its security tradeoff. + On Windows and Android there is no supported OS process sandbox, so Server Extensions are unavailable by design. Use a Browser Extension instead, or run the Marinara server on macOS or Linux (with `bwrap`) if you need a Server Extension. ## External Extensions diff --git a/docs/game/getting-started.md b/docs/game/getting-started.md index 9ddea73a76..f8dd2c5a29 100644 --- a/docs/game/getting-started.md +++ b/docs/game/getting-started.md @@ -20,7 +20,8 @@ Everything else is optional and off by default. You can add these later: - **Image generation.** Game Mode has a visual layout with backgrounds and character art. To fill it, you need an image generation connection. The **Visual Generation** setting in the wizard is off by default, so you must turn it on yourself. Without it, you still get the story, state tracking, and combat, but the visual areas stay empty. - **A Local Model for scene effects.** Marinara can run a small model on your own machine, labeled **Local Model (Gemma)**. It powers background and music suggestions without extra cost. It is the default choice in the wizard. See [Local Model Setup](../connections/local-model.md). -- **A video generation connection.** This is only needed for scene videos or animated storyboards. +- **The Storyboard Agent.** Install it from **Agents > Download Agents**, then activate it for the finished Game under **Chat Settings > Agents** when you want still or animated Storyboards. +- **A video generation connection.** This is only needed for scene videos or animated Storyboards. - **Music.** The **Music DJ** agent can play game music. It needs Spotify or a local music folder, and it is off by default. ## The setup wizard @@ -34,7 +35,7 @@ The seven steps are: 3. **Party.** Pick your persona (the character you play), the **Game Master Mode**, and any party members. 4. **Goals.** Tell the GM what you want from the adventure. 5. **Lorebooks.** Attach any lorebooks whose facts the GM should treat as canon. A lorebook is a set of background world facts. See [Lorebooks](../lorebooks/overview.md). -6. **Features.** Turn on optional systems like Visual Generation, storyboards, Music DJ, and HUD widgets. +6. **Features.** Turn on optional systems like Visual Generation, Music DJ, and HUD widgets. Installable Agents can be activated from Chat Settings after the Game is created. 7. **GM.** Choose the presentation style and review advanced GM instructions before the world is built. When you finish, click **Start Game**. @@ -52,17 +53,14 @@ These are the starting values in the **World**, **Party**, and **Features** step | Language | English | All in-game text is written in this language | | Game Master Mode | Standalone GM | Standalone GM builds a GM for you; Character GM uses one of your cards as the GM | | Visual Generation | Off | Turn on for images; needs an image generation connection | -| Automatic Storyboard Illustrations | On | Only active once Visual Generation is on | -| Automatic Storyboard Animations | Off | Needs a video generation connection | -| Keyframes per Turn | 3 | Available with storyboard illustrations; range 1 to 6 | -| Game Presentation | Standard | **Storyboard Optimized** coordinates the Storyboard Game Prompt, Comic Page Animation planner, Storyboard Illustration, and Comic Page Video prompts | +| Game Presentation | Standard | **Storyboard Optimized** uses the Storyboard Game Prompt to shape the GM's narration; it does not install or activate the Storyboard Agent | | Music DJ | Off | Needs Spotify or a local music folder | | Custom HUD Widgets | On | Uses AI-made status widgets from the new world | | Start Muted | Off | Begins the game with audio muted | New to Game Mode? Leave **Game Master Mode** on **Standalone GM**. Marinara builds a fair, slightly snarky GM for you, and you can feel out the mode before writing a custom GM card. -Choose **Storyboard Optimized** on the final step when you want GM turns written as filmable visual beats. It selects the built-in **Storyboard Game Prompt**, **Comic Page Animation** planner, **Storyboard Illustration**, and **Comic Page Video** presets. Comic Page Animation uses the clip duration to limit the number of chronological panels, Storyboard Illustration formats each planned keyframe for the image model, and Comic Page Video treats those panels as ordered animation references. It does not turn image or video generation on and does not change your selected connections. The GM uses the wizard's **Keyframes per Turn** value as a target for strong visual anchor moments, but it can write fewer for a short exchange and can use more narration paragraphs when the story needs them. +Choose **Storyboard Optimized** on the final step when you want GM turns written as filmable visual beats. It selects the built-in **Storyboard Game Prompt** for GM narration. It does not install or activate the Storyboard Agent, turn image or video generation on, change your connections, or replace the Agent's planner and formatter defaults. After the Game is created, install and activate Storyboard separately and configure its keyframe, planner, image, and video settings under **Chat Settings > Agents > Storyboards**. The alternative anime single-shot combination remains available after setup: choose **Anime Episode Director** for the Animation Planner and **Anime Game Video** for the Storyboard Video Prompt. @@ -120,7 +118,7 @@ This guide gets you into a game. Each deeper topic has its own guide: - [Game Mode: Dice and Skill Checks](dice-and-skill-checks.md) covers the dice menu and skill-check rules. - [Game Mode: HUD Widgets](hud-widgets.md) covers the on-screen status widgets. - [Game Assets](game-assets.md) covers the music, sound, sprite, and background library. -- [Storyboard Engine Guide](storyboard.md) covers turning a GM turn into manga-style keyframes. +- [Storyboard Agent Guide](storyboard.md) covers installation plus Roleplay and Game Mode Storyboards. Author's Notes work the same way here as in other modes. See [Roleplay Mode: Getting Started](../roleplay/getting-started.md). @@ -151,7 +149,7 @@ Some models stay upbeat no matter the tone. You have two options. Add a clear in - [Game Mode: Dice and Skill Checks](dice-and-skill-checks.md) - [Game Mode: HUD Widgets](hud-widgets.md) - [Game Assets](game-assets.md) -- [Storyboard Engine Guide](storyboard.md) +- [Storyboard Agent Guide](storyboard.md) - [Roleplay Mode: Getting Started](../roleplay/getting-started.md) - [Connecting to an AI Provider](../connections/connecting-to-a-provider.md) - [Agents: AI Helpers for Your Chats](../agents/agents-overview.md) diff --git a/docs/game/ltx-2-3-storyboards.md b/docs/game/ltx-2-3-storyboards.md index fbc0ac0f15..a4207a9a90 100644 --- a/docs/game/ltx-2-3-storyboards.md +++ b/docs/game/ltx-2-3-storyboards.md @@ -24,7 +24,7 @@ You need: 2. The editable `ltx-director-simple` workflow, or an equivalent LTX 2.3 image-to-video graph that completes successfully inside ComfyUI. 3. Its `ltx-director-simple-api` API-format export for the Marinara connection. 4. A Marinara image-generation connection for the first-frame illustrations. -5. A Game Mode chat with Storyboard support. +5. The **Storyboard** Agent installed from **Agents > Download Agents** and activated for the Game under **Chat Settings > Agents**. The editable ComfyUI workflow and its API export are different files. Open `ltx-director-simple` in ComfyUI, install every missing custom node reported by ComfyUI Manager, and test the graph there. Import `ltx-director-simple-api` into the Marinara connection. After every node or model change, export the graph again in API format and replace the JSON stored on the connection. Do not paste the normal visual-editor workflow into Marinara. @@ -142,7 +142,7 @@ A text-only connection test cannot exercise `%reference_image_name%`. Validate i ## Configure the Game Mode chat -Open the Game Mode chat, then open **Chat Settings** and select **Agents**. +Open the Game Mode chat, then open **Chat Settings** and select **Agents**. Turn on **Enable Agents** and **Enable Storyboards** before configuring the sections below. Storyboard Optimized presentation in the new-game wizard does not activate the Agent. ### Illustrator @@ -179,7 +179,7 @@ Use this starting profile: | **Automatic Storyboard Illustrations** | On | | **Automatic Storyboard Animations** | On | | **Use NovelAI Character Prompts** | Off | -| **Keyframes per Turn** | 3; use any value from 1-6 that suits the turn and render budget | +| **Keyframes per Turn** | 3 normally; start with 1 for the first 8 GB VRAM test | | **Animation Clip Duration** | 6 seconds | | **Viewer Display** | Floating while testing | | **Illustration Planner** | **Still Keyframes**; retained as the still-only fallback | @@ -198,7 +198,7 @@ Use this starting profile: **LTX Director Video** is intentionally small. It passes the Animation Planner's completed `narrationBeat` through the universal video prompt contract without surrounding it with another scene recap. -Each keyframe creates one Krea image job and one local LTX video job. Three keyframes therefore launch three first-frame renders and three video renders. Use one keyframe for the first 8 GB validation run if you want to prove the connection before committing to the full three-shot setup. +Each keyframe creates one Krea image job and one local LTX video job. Three keyframes therefore launch three first-frame renders and three video renders. For an 8 GB VRAM GPU, start with one keyframe at 480p. After that succeeds, move toward three keyframes and higher resolutions. ## Run the first test @@ -297,7 +297,7 @@ For detailed server traces, enable debug logging and look for `[debug/game/story ## Related guides -- [Storyboard Engine Guide](storyboard.md) +- [Storyboard Agent Guide](storyboard.md) - [ComfyUI Workflow Setup](../media/comfyui.md) - [Scene Video Generation](../media/scene-video.md) - [Game Mode: Getting Started](getting-started.md) diff --git a/docs/game/storyboard.md b/docs/game/storyboard.md index 5a01151f3d..569dbc6123 100644 --- a/docs/game/storyboard.md +++ b/docs/game/storyboard.md @@ -1,243 +1,357 @@ -# Storyboard Engine Guide +# Storyboard Agent Guide -This guide explains storyboards in Marinara Engine. A storyboard turns completed story text into a short run of keyframe images and can add animated clips. Game Mode storyboards follow one finished GM turn. Roleplay storyboards combine completed exchanges into an inline episode. Conversation chats do not use Storyboards. +The downloadable **Storyboard** Agent turns completed story text into ordered keyframe images and, optionally, short image-to-video clips. It supports **Roleplay** and **Game Mode**. Conversation chats do not use Storyboard. -## What storyboards are +This is the current agent-based workflow. The Storyboard package supplies the planning prompts, defaults, and per-chat controls. Marinara Engine supplies the host integration that generates media, saves it to the Gallery, and displays it in the chat or Game viewer. -Game Mode is the chat mode where an AI Game Master (GM) narrates a turn-based adventure. When the GM finishes a narration turn, the Storyboard Engine can illustrate that single turn. In Roleplay, the Storyboard Agent reads completed user and assistant messages since its previous successful episode. +## Roleplay and Game Mode at a glance -Marinara reads the GM narration and splits it into a short run of ordered keyframes. Each keyframe is one picture of a moment in the turn. A storyboard holds 1 to 6 keyframes. The default is 3. +| | Roleplay | Game Mode | +| --- | --- | --- | +| Story source | Completed user and assistant messages since the previous successful episode | One completed GM narration turn | +| Automatic choices | **Manual only**, **Still images**, or **Animations** | Separate **Automatic Storyboard Illustrations** and **Automatic Storyboard Animations** switches | +| Manual action | **Gallery > Create storyboard** for the latest completed assistant response | **Gallery > Create storyboard** for the latest completed GM turn | +| Display | Inline below the assistant response that ends the episode | Floating viewer or Game background, synchronized to the narration | +| Planning prompts | Episode contract, visual style, optional animation addon, and output contract | Separate still and animation planners | +| Shared final prompts | Illustration image prompt and animation video prompt | Illustration image prompt and animation video prompt | + +Both modes save keyframe images to the Gallery's **Images** tab and clips to its **Videos** tab. + +## Install the Agent + +1. Open the **Agents** panel from the Sparkles icon. +2. Select **Download Agents**. +3. Open **Storyboard** and select **Install**. +4. Open a Roleplay or Game chat, then open **Chat Settings > Agents**. +5. Turn on **Enable Agents**, then turn on **Enable Storyboards** in the Storyboard card. + +Installing the package makes it available to compatible chats; it does not silently activate it in every chat. The current package does not require a Marinara restart after installation. -Each keyframe is tied to a range of the turn's text. These text ranges are called reader sections. As you read down the turn, a small viewer shows the keyframe that matches your current spot in the text. +If Storyboard is not listed in Chat Settings, confirm that the package is installed and that the chat is in Roleplay or Game Mode. -Before it plans the images, Marinara strips the turn's GM command tags. GM command tags are hidden instruction tags in a GM message, such as dice rolls or game-state updates. They are removed so they do not show up in the picture. +## Storyboard Agent settings -Keyframe still images are saved in the **Gallery**, under the **Images** tab. Keyframe clips are saved as scene videos, under the **Videos** tab. Because they are normal Gallery items, you can preview, download, pin, or copy the prompt of any keyframe on its own. +Open the **Agents** panel, select **Storyboard**, and open its setup. These values are the defaults for chats that do not have their own overrides. + +### Generation and media defaults + +| Setting | Default | Purpose | +| --- | --- | --- | +| Agent connection | Your selected Agent connection | Plans the storyboard with an LLM | +| **Image connection** | Use the Game image connection | Generates every keyframe; an image connection is required somewhere in the fallback chain | +| **Video connection** | Use the Game video connection | Generates clips when animations are enabled | +| **Automatic generation** | Still images | Chooses the starting automatic behavior for newly activated chats | +| **Keyframes per turn** | 3, range 1-6 | Sets the target number of ordered frames | +| **Clip seconds** | 6, range 1-15 | Sets the requested duration of each clip | +| **Viewer display** | Floating viewer | Sets the Game Mode viewer default; Roleplay always displays Storyboards inline | +| **Default Roleplay episode interval** | 1, range 1-100 | Sets how much new Roleplay material accumulates between automatic episodes | +| **Attach Card Appearance** | On | Adds matched character appearance details to image prompts | +| **Send Avatar References** | On | Sends matched character and persona avatars when the image provider supports references | +| **Use the final image template** | On | Formats a planned frame before it is sent to the image provider | +| **Use NovelAI character prompts** | On | Uses native per-character prompting on supported official NovelAI V4/V4.5 connections | + +### Game prompt library + +The Game library supplies two different planning lanes. The active lane is chosen by whether the Game is making stills or clips. + +| Setting | Default | Purpose | +| --- | --- | --- | +| **Still planner** | Still Keyframes | Splits one completed GM turn into finished still-image moments | +| **Animation planner** | Comic Page Animation | Creates animation-ready first frames and duration-aware motion directions | -## Roleplay storyboard episodes +The package also includes NovelAI, comic, colored manga, black-and-white manga, anime episode, and LTX-oriented planners. Planner prompt text is editable in the global Agent setup. The Game chat chooses among the still and animation options under **Chat Settings > Agents > Storyboards**. -Roleplay Storyboards are separate from Illustrator. Illustrator can keep making its usual single images while Storyboard plans one or more ordered keyframes from a completed section of the chat. +### Roleplay prompt library + +Roleplay assembles four selected prompts into one planner request. + +| Setting | Default | Purpose | +| --- | --- | --- | +| **Episode contract** | Completed Roleplay Episode | Chooses completed source-supported beats and keeps them in message order | +| **Visual style** | Normal / Anime | Defines the visual treatment of every keyframe | +| **Animation addon** | Simple Storyboard Motion | Adds motion, camera, source dialogue and sound, ambience, and an ending hold only for clips | +| **Output contract** | Roleplay Keyframe JSON | Defines the structured keyframe fields returned by the planner | + +Each selector has an editable collection below it. Use **Add option** for a custom prompt, rename it, add a short description, and edit the prompt body. The built-in options can be restored to their package defaults. + +### Shared provider formatters + +After either mode plans its frames, shared formatters create the final provider requests. + +| Setting | Default | Purpose | +| --- | --- | --- | +| **Default image prompt** | Game Scene Illustration | Formats each planned keyframe for the image provider | +| **Default video prompt** | Cinematic Scene Video | Formats the first-frame image and motion plan for the video provider | -1. Install **Storyboard** from **Agents > Download Agents**. -2. Open a Roleplay chat, then add **Storyboard** in **Chat Settings > Agents**. -3. In the Storyboard card, choose **Manual only**, **Still images**, or **Animations**. -4. Select the prompt, image, and optional video connections. The image connection is required. -5. For a manual episode, open **Gallery** and choose **Create storyboard**. Automatic episodes run after the configured number of user and assistant messages has accumulated and an assistant response completes. +The built-in image choices also include **Storyboard Illustration** and **Storyboard First Frame**. Video choices include **Anime Game Video**, **Comic Page Video**, and **LTX Director Video**. Game and Roleplay chats can select different formatters without changing the underlying shared prompt collection. -The default interval is 1, so an automatic episode can appear after every newly completed assistant response. A larger **Messages per episode** value lets dialogue and back-and-forth accumulate. User and assistant messages both advance the interval. When the interval is reached, Marinara combines the messages since the previous successful Storyboard, within a bounded recent window. Opening an existing chat does not backfill old messages, and a failed episode does not advance the successful cadence anchor. +### Global defaults and chat overrides -Roleplay keyframes render inline after the assistant response that ends the episode. Use the arrows on multi-keyframe Storyboards to move between frames. Images and clips are also saved in the Gallery. +Each chat can override the Agent defaults. Chat Settings marks inherited values as **Using agent default** and offers a reset control after you create an override. -Roleplay planning has four editable layers under global **Agents > Storyboard** settings: +Connection precedence differs slightly by mode: -- **Episode contract** selects completed story beats from the supplied messages. -- **Visual style** provides normal/anime, NovelAI, comic, colored manga, and black-and-white manga choices. -- **Animation addon** is included only for animated Storyboards. It treats the illustration as the exact T=0 frame, then describes simple action, camera behavior, source dialogue, sound effects, ambience, and an ending hold. -- **Output contract** defines the keyframe JSON returned by the planning model. +- Roleplay exposes per-chat prompt, image, and video selectors. **Use global default** inherits the Storyboard setup. +- Game Mode uses its Game-specific planning, image, and video connections when they are set, then falls back to the Storyboard Agent defaults. -These Roleplay prompts do not replace the optimized Game Mode planner library. Image and video provider formatters remain shared and selectable. The animation plan is provider-neutral, so it can use Google Gemini Omni, LTX/ComfyUI, or another configured Video Generation connection that accepts image-to-video requests. Provider capabilities and output quality still vary. +An image connection is required for stills. Animations require both a successful keyframe image and a video connection. -## Game Mode storyboards +## Roleplay Storyboards -This section explains how to configure, generate, review, and animate storyboards for Game Mode turns. +Roleplay Storyboards group completed exchanges into a visual episode and render that episode below the assistant response that finishes it. -## Before you start +### Quick start -You need a few things set up before a storyboard can render. +1. Install Storyboard and activate it for the Roleplay chat. +2. In **Chat Settings > Agents > Storyboards**, select a **Prompt connection** and **Image connection**, or leave them on **Use global default** when the global setup is complete. +3. Choose an **Automatic mode**: + - **Manual only**: no automatic episode; **Create storyboard** makes a still episode on demand. + - **Still images**: automatically makes an illustrated episode. + - **Animations**: automatically makes keyframe images and a clip for each frame; a video connection is required. +4. Set **Messages per episode** and **Keyframes per episode**. +5. Finish a new assistant response, or open the Gallery and select **Create storyboard**. -1. A Game Mode chat. The setup below is specifically for the Game Mode workflow. -2. A working image connection for the game's illustrator. Set it in either place. You only need one: - - Existing game: open **Chat Settings**, go to **Agents**, then the **Illustrator** card. Turn on **Game Illustrator** and pick an **Image Connection**. - - New game: in the setup wizard, turn on **Visual Generation** and pick an **Image Generation Connection**. -3. A strong, recent image model is recommended. The app suggests a state-of-the-art image model, or something equivalent to Google Nano Banana 2 Lite. +Use the arrows on a multi-keyframe Storyboard to move between frames. An animated frame shows its playable clip inline and falls back to its image while the clip is pending or unavailable. -For animated clips, you also need a video connection. See the animation steps below. +### How the episode interval works -If you have no image connection set, a storyboard request fails with this message: "Choose an Illustrator image connection in Game Settings first." +The interval controls how many new user and assistant messages accumulate between successful automatic Storyboards. Both message roles advance the interval, and the episode includes the new messages in chronological order. -For steady character looks across keyframes, use character cards with avatars, and turn on **Send Avatar References** in the **Illustrator** card. This sends each character's avatar as a reference image. +The default is 1, so the next newly completed assistant response can produce an episode immediately. A larger value lets more dialogue and action accumulate. The source is bounded to the most recent 20 messages and 12,000 characters so an old or very long chat cannot create an unbounded planning request. -## Quick start +The cadence anchor advances only after a complete or partial Storyboard is saved. A failed episode does not consume the source material. Opening an existing chat does not backfill old responses; automatic generation waits for a newly completed assistant response. -1. Open or create a Game Mode chat. -2. Set up the image connection as shown in the section above. -3. Play until the GM finishes a narration turn. -4. Open the **Gallery** panel. -5. Click **Create storyboard**. The button shows **Creating...** with a spinner while it runs. - - If **Expose image prompts before sending** is enabled in **Settings > Generation**, review and edit the compiled prompt for every keyframe, then confirm generation. -6. Keep reading the turn. The floating viewer appears and switches keyframes as you read. +### Roleplay prompt chain -If you close the viewer, reopen it. In the **Gallery** panel, click **View storyboard**. +Roleplay uses four planning layers before the shared provider formatters: -While a storyboard is generating, the **Gallery** shows this banner: "Storyboard generation is running. Keyframes will appear in the game storyboard viewer when ready." +1. **Episode contract** selects completed, source-supported story beats and anchors them to the supplied messages. +2. **Visual style** chooses Normal/Anime, NovelAI, Comic, Colored Manga, or B&W Manga treatment. +3. **Animation addon** is added only for animated Storyboards. It describes one achievable action, camera behavior, source-supported dialogue and sound, ambience, and an ending hold. +4. **Output contract** defines the structured keyframe result returned by the planner. -## Automatic and manual storyboards +The **Storyboard Illustration Prompt** then formats each planned first frame for the image provider. When clips are enabled, the **Storyboard Video Prompt** formats the motion plan for the video provider. -You can make storyboards by hand, or have Marinara make them for you. +The Roleplay prompt library is separate from the Game planner library. Editing a Roleplay visual style does not rewrite Game Mode's still or animation planners. -Manual is the **Create storyboard** button in the **Gallery**. It builds a storyboard for the latest finished GM narration turn, only when you ask. You can also use it to refresh or re-illustrate the current turn, even when automatic storyboards are off. +### Storyboard and Illustrator together -Automatic storyboards are set per chat. Find the controls in either place: +Storyboard is a separate Agent from Illustrator. Manual Illustrator actions and other Illustrator media remain available. When Roleplay Storyboard is set to **Still images** or **Animations**, Marinara suppresses the ordinary automatic foreground Illustrator image for that completed response so the two Agents do not generate competing post-response media. **Manual only** leaves the normal Illustrator path unchanged. -- New game: setup wizard, **Visual Generation**, then the **Storyboards** subsection. -- Existing game: **Chat Settings**, **Agents**, then the **Storyboards** card. +## Game Mode Storyboards -**Automatic Storyboard Illustrations** makes still keyframe images after each finished GM turn, with no clicks from you. This is the lower-cost path. For a new game created through the wizard, this is on by default once **Visual Generation** is enabled. It has no effect until **Game Illustrator** is set up. +Game Mode Storyboard uses exactly one completed GM narration turn as its story source. It strips hidden GM command tags, plans ordered frames, and anchors each frame to a range of readable turn sections. The viewer changes frames as the reader moves through those sections. -Automatic storyboards do not pause the completed-turn pipeline for prompt review. When **Expose image prompts before sending** is enabled, use the manual **Create storyboard** action to see and edit every final compiled keyframe prompt. Automatic runs continue without a modal so gameplay does not stall while the chat is unattended. +### Quick start -**Automatic Storyboard Animations** also makes an MP4 clip for each keyframe. This is off by default. It needs still illustrations plus a video connection. Turning animations on also turns illustrations on. Turning illustrations off turns animations off. +1. Install Storyboard. +2. Create or open a Game Mode chat. +3. Open **Chat Settings > Agents**, turn on **Enable Agents**, then turn on **Enable Storyboards**. +4. Confirm that the Game has an image connection or that the global Storyboard setup supplies one. +5. Finish a GM narration turn. +6. Open the **Gallery** and select **Create storyboard**. -To set up clips: +Select **View storyboard** in the Gallery to reopen a dismissed Game viewer. Manual generation uses the current animation setting: when **Automatic Storyboard Animations** is on, the manual Storyboard also requests clips. -1. Create a **Video Generation** connection in **Settings**, then **Connections**. -2. Select it in the wizard's **Video Generation Connection** field, or in **Chat Settings**, **Agents**, **Scene Videos**, then **Video Connection**. -3. Turn on **Automatic Storyboard Animations**. +### Automatic Game Storyboards -If you turn on animations without a video connection, the wizard warns you: "Choose a Video Generation connection below to save automatic storyboard animations." +The Storyboard card has two automation switches: -A storyboard usually creates 3 image jobs, one per keyframe. With animations on, it also creates up to 3 video jobs. The number follows **Keyframes per Turn**, so choosing 5 can mean 5 image jobs and up to 5 video jobs. Video jobs are much slower and cost more. Start with still illustrations, and add animations only for chats where the wait and cost are fine. +- **Automatic Storyboard Illustrations** creates still keyframes after a completed GM turn. +- **Automatic Storyboard Animations** also creates a clip for every keyframe. Turning animations on enables illustrations; turning illustrations off disables animations. -## Storyboard settings +Automatic generation does not run unless the Storyboard Agent is active for that Game. It also does not recreate a Storyboard for a turn that already has one. Use the manual Gallery action when you intentionally want another Storyboard for the latest turn. -All of these live in the **Storyboards** card. Open **Chat Settings**, go to **Agents**, then **Storyboards**. +If **Expose image prompts before sending** is enabled under Generation settings, a manual Game Storyboard can show the compiled image prompts for review. Automatic Storyboards continue without a review window so they do not pause gameplay. -| Setting | Default | What it does | +### Game settings + +Open **Chat Settings > Agents > Storyboards**. + +| Setting | Agent default | What it controls | | --- | --- | --- | -| **Automatic Storyboard Illustrations** | On for new wizard games with Visual Generation; else off | Makes still keyframes after each GM turn | -| **Automatic Storyboard Animations** | Off | Adds an MP4 clip per keyframe; needs a video connection | -| **Keyframes per Turn** | 3 (range 1 to 6) | How many keyframes each turn plans | -| **Animation Clip Duration** | 6 seconds (range 1 to 15) | Length of each clip | -| **Viewer Display** | Floating | Floating panel or full background | -| **Illustration Planner** | Still Keyframes | Plans finished still keyframes and their image descriptions | -| **Animation Planner** | Comic Page Animation | Plans animation-ready source images and motion directions | -| **Use Storyboard Template** | On | Formats planned scenes with the selected Storyboard Illustration Prompt. Turn it off for direct NovelAI tag prompts | -| **Storyboard Illustration Prompt** | Game Scene Illustration | Formats each planned keyframe for the image model | -| **Storyboard Video Prompt** | Same as Game Video Prompt | Motion prompt used only for storyboard keyframe clips | +| **Enable Storyboards** | Off per chat | Activates the installed Agent for this Game | +| **Automatic Storyboard Illustrations** | Derived from Automatic generation | Still keyframes after each finished GM turn | +| **Automatic Storyboard Animations** | Derived from Automatic generation | MP4 clips for every keyframe | +| **Keyframes per Turn** | 3, range 1-6 | Target number of frames; short turns may produce fewer | +| **Animation Clip Duration** | 6 seconds, range 1-15 | Requested duration for each clip; a provider may clamp it | +| **Viewer Display** | Floating | Draggable viewer or full Game background | +| **Still Planner** | Still Keyframes | Plans completed still illustrations | +| **Animation Planner** | Comic Page Animation | Plans animation-ready first frames and motion directions | +| **Use Storyboard Template** | On | Applies the selected final illustration formatter | +| **Storyboard Illustration Prompt** | Game Scene Illustration | Formats the planned frame for the image provider | +| **Storyboard Video Prompt** | Cinematic Scene Video | Formats the first frame and motion plan for the video provider | -**Keyframes per Turn** is a slider. The engine tries to plan this many keyframes. A short turn may get fewer. It never plans more than 6. +The package also supplies NovelAI, comic, manga, anime, and LTX-oriented planners. Selecting an animation planner does not enable video generation by itself; **Automatic Storyboard Animations** and a video connection are still required. -**Animation Clip Duration** is a number of seconds. It is greyed out unless **Automatic Storyboard Animations** is on. Until you set a value, it uses the 6-second default and shows a **Storyboard default** pill. Once you set your own value, a **Use storyboard default** button appears to clear it. Some video providers may clamp your value to a lower maximum, so the exact length is not promised. +### Game prompt chain -In **Background** viewer mode, each animation starts once with sound when its story beat becomes active. Narration can display while it plays, but narration auto-play waits for the clip to finish. The animation then stays paused on its final frame. The game toolbar provides replay, play/pause, and mute controls on desktop and mobile. Floating storyboard videos also play once and can be replayed instead of looping indefinitely. +Game Mode keeps separate planners for still and animated results: -The two planners create the visual plan. **Illustration Planner** is used for still storyboards. **Animation Planner** is used when videos are generated and produces both an animation-ready image description and a compact motion direction. +```text +completed GM narration + -> Still Planner or Animation Planner + -> Storyboard Illustration Prompt + -> image connection + -> optional Storyboard Video Prompt + -> video connection +``` -**Storyboard Illustration Prompt** then formats the planner's image description into the final request sent to the image model. Existing chats default to **Game Scene Illustration**. **Storyboard Illustration** keeps the planner result primary while adding character references, appearance notes, campaign art direction, and image instructions. +The planner chooses and orders the story beats. The illustration prompt is a provider-facing formatter, not another story planner. When animations are enabled, the animation planner produces both an exact first-frame description and a motion direction; the video prompt turns that motion direction into the final request. -**Storyboard Video Prompt** is separate from the general **Game Video Prompt** in the **Scene Videos** card. It combines the generated keyframe, the Animation Planner's motion direction, and the current scene context into the final request sent to the video model. Leave it on the inherited choice to reuse the general prompt, or select **Anime Game Video** for keyframe clips without changing manual Gallery or Game Assets videos. +### Revised Game Mode recipes -Select **Comic Page Animation** for the duration-aware comic source pages, then choose **Comic Page Video** to interpret those panels as ordered visual reference beats for one clip. The original **Comic Page** remains available for ordinary illustrations. The separate video choice leaves the inherited **Game Video Prompt** plus manual Gallery and Game Assets videos unchanged. +These recipes pair a package-applied Storyboard chain with the remaining Game and provider settings. Apply the named chain when your package exposes it, or reproduce the listed selections manually. -New games created with the **Storyboard Optimized** presentation select the **Storyboard Game Prompt**, **Comic Page Animation** planner, **Storyboard Illustration**, and **Comic Page Video**. You can switch that chat to the single-shot combination at any time by selecting **Still Keyframe Animation** and **Anime Game Video**. +#### Google Comic Storyboards -### LTX 2.3 image-to-video +Package-applied chain: -For a local LTX 2.3 ComfyUI workflow, start with **LTX Simple Image-to-Video** as the Animation Planner, **Storyboard First Frame** as the Storyboard Illustration Prompt, and **LTX Director Video** as the Storyboard Video Prompt. The Animation Planner creates both the natural-language T=0 image prompt and the complete motion paragraph. Storyboard First Frame passes the T=0 scene to a natural-language image provider with minimal wrapping, while LTX Director Video sends the motion paragraph to the workflow's `%prompt%` input. **LTX Director Storyboard** is the more detailed, duration-aware alternative; it uses the same video prompt and workflow contract. +- **Illustration Planner**: Still Keyframes +- **Animation Planner**: Comic Page Animation +- **Storyboard Illustration Prompt**: Game Scene Illustration +- **Storyboard Video Prompt**: Comic Page Video +- **Use Storyboard Template**: On -See [LTX 2.3 Storyboards in Game Mode](ltx-2-3-storyboards.md) for model selection, ComfyUI placeholders, the complete Game settings profile, validation steps, and troubleshooting. +Game checklist: -## Style presets +- **Visual Generation**: On +- **Image Connection**: Google/Nano Banana +- **Image Style**: Default +- Keep the setup-generated art style. +- **Automatic Storyboard Illustrations**: On +- **Automatic Storyboard Animations**: Off +- **Keyframes per Turn**: 3 +- **Video Connection**: None -The planner presets shape how each keyframe is selected and described. Two selectors pick them: +This creates ordinary still Storyboards. The saved Comic Page animation chain becomes active only if you later select a video connection and turn on **Automatic Storyboard Animations**. -- **Illustration Planner** is used when storyboards make still keyframes without videos. Default: **Still Keyframes**. -- **Animation Planner** is used when **Automatic Storyboard Animations** is on. Default: **Comic Page Animation**. +#### NovelAI Direct Tags -The two selectors have separate preset lists. Illustration presets describe finished stills and can include reader-facing comic or manga lettering. Animation presets describe a stable first frame plus duration-aware motion direction. An illustration preset never appears in the Animation Planner menu, and an animation preset never appears in the Illustration Planner menu. +Package-applied chain: -| Lane | Preset | Best for | -| --- | --- | --- | -| Illustration | **Still Keyframes** | Normal reading. Single-scene keyframes without comic panels, speech bubbles, captions, or SFX text. | -| Illustration | **NovelAI Keyframes** | Compact still-image tag prompts tuned for NovelAI V4 and V4.5. For a direct tag prompt, turn off **Use Storyboard Template**. | -| Illustration | **Comic Page** | Finished comic-page illustrations with 2-6 panels, dialogue bubbles, captions, and lettering. | -| Illustration | **Colored Manga** | Finished colored manga staging with cell shading, screentones, speech bubbles, and SFX. | -| Illustration | **B&W Manga** | Finished black-and-white manga inks, screentones, heavy blacks, speech bubbles, and SFX. | -| Animation | **Still Keyframe Animation** | Ordered single shots with an exact first frame, one main movement, simple camera behavior, environmental motion, and an ending hold. | -| Animation | **Anime Episode Director** | Broadcast-anime single shots with first-frame continuity, compact motion direction, and provider-safe staging. | -| Animation | **NovelAI Keyframe Animation** | NovelAI tag-based first frames with timing and motion kept in a separate animation direction. | -| Animation | **Comic Page Animation** | Duration-aware comic source pages whose chronological panels act as ordered visual references for one clip. | -| Animation | **Colored Manga Animation** | Text-free colored manga first frames with motion that preserves linework and cel shading. | -| Animation | **B&W Manga Animation** | Text-free monochrome first frames with motion that preserves inks and screentones. | +- **Illustration Planner**: NovelAI Keyframes +- **Storyboard Illustration Prompt**: create a custom option whose prompt contains only: + + ```text + ${scenePrompt} + ``` + +- **Use Storyboard Template**: On +- Leave the Animation Planner and Storyboard Video Prompt unchanged. + +Game checklist: + +- **Image Style**: Danbooru +- **Use Campaign Art Style**: Off +- **Attach Card Appearance**: Off +- **Send Avatar References**: Off +- **Use NovelAI Character Prompts**: Off +- **Queue media generation requests**: On +- Remove the prose **Style Text** from the Danbooru profile. +- Tune the positive, negative, and illustration tags as needed. + +The custom pass-through template sends the planner's compact NovelAI tags without wrapping them in the normal prose illustration formatter. -The **Still Keyframe Animation** preset is the style-neutral motion counterpart to **Still Keyframes**. The **Anime Episode Director** is a separate specialized option that pairs with **Anime Game Video** when you want broadcast-anime shot planning. It keeps severe violence non-graphic and stages it through anticipation, obstruction, reaction, or aftermath where possible, which can reduce provider safety rejections without changing the GM's canonical story. +#### Local Krea 2 + LTX 2.3 -The **Comic Page Animation** preset uses the animation clip duration to control page density. It defaults to 2 panels for a 6-7 second clip, allowing a third only for three simple beats with about 2 seconds each; it uses 2-3 panels for 8-10 seconds and no more than 4 for longer clips. Animation pages prioritize visual timing over comic lettering, keep each panel focused, and reserve a short ending hold. Panels follow cause and effect in reading order. **Comic Page Video** normally enters panel 1 immediately; it permits only a very brief full-page establish when doing so cannot reveal a later consequence early. +Package-applied chain: -The **NovelAI Keyframes** preset writes compact Danbooru tags. Danbooru tags are short comma-separated keyword tags that some anime image models expect. Choosing an animation, comic, or manga preset does not turn animations on by itself. You still need **Automatic Storyboard Animations** and a video connection for clips. +- **Illustration Planner**: Still Keyframes as the still-only fallback +- **Animation Planner**: LTX Simple Image-to-Video +- **Storyboard Illustration Prompt**: Storyboard First Frame +- **Storyboard Video Prompt**: LTX Director Video +- **Use Storyboard Template**: On -## Campaign art style and image style profiles +For an 8 GB VRAM GPU, start with one keyframe at 480p. After that completes successfully, move toward three keyframes and higher resolutions. See [LTX 2.3 Storyboards in Game Mode](ltx-2-3-storyboards.md) for the ComfyUI connection, placeholders, and full test procedure. -Game setup generates a campaign-level art style for visual consistency. For an existing game, open **Chat Settings > Agents > Illustrator** to see it under **Campaign art style**. You can edit it, clear it, restore the original setup-generated wording, or turn off **Use Campaign Art Style**. +### Storyboard Optimized presentation is not the Agent switch -The campaign art style and **Image Style** profile are separate prompt layers. When both are enabled, Marinara includes both. Turning off or clearing the campaign style leaves the selected Image Style profile in place. This setting applies to storyboard keyframes and the game's other generated visual assets. +The Game setup wizard's **Storyboard Optimized** presentation changes the GM narration prompt so turns contain stronger filmable visual anchors. It does not install or activate Storyboard, enable automatic media, or choose image and video connections. -With **Expose image prompts before sending** enabled in **Settings > Generation**, manual **Create storyboard** requests first show the exact compiled positive and negative prompts for all planned keyframes. Changes in that review are one-off overrides for that storyboard only; they do not replace the campaign style or Image Style profile settings. +You can use the Storyboard Agent with either Standard or Storyboard Optimized presentation. Install and activate the Agent separately. -## Editing storyboard presets +### Game viewer -The built-in presets are read-only. To make your own, open **Edit Illustration Planner Presets**, **Edit Animation Planner Presets**, **Edit Illustration Prompt Presets**, or **Edit Video Prompt Presets** inside the **Storyboards** card. Each section shows only the built-ins and custom copies for that stage. +**Floating viewer** is a draggable, resizable panel above the Game. It follows the reader's position in the GM narration and shows the corresponding frame. A video plays when ready and otherwise falls back to the frame image. -Copy a built-in into a chat-only editable template, then pick that copy in the matching selector. Illustration Planner copies cannot be selected as Animation Planners, and Animation Planner copies cannot be selected as Illustration Planners. Storyboard Illustration Prompt copies affect only storyboard images. Video prompt copies remain shared with the general Game Video Prompt so either video selector can use them. +**Game background** places the active frame behind the Game controls. This replaces the normal generated scene background while the mode is active, so the ordinary **Generate background** action is unavailable. Background clips play once and remain on their final frame; Game controls provide replay, play/pause, and mute actions. -Each custom copy has a name, a short description, and the prompt body you edit. A trash button removes a copy after a confirm dialog. These copies are stored on that one chat, not across your whole app. +Closing the floating viewer hides it for the current turn. Use **Gallery > View storyboard** to reopen it. -## The storyboard viewer +## Image prompting and character consistency -The viewer follows your reading position. It shows the keyframe whose reader section matches where you are in the turn text. It is not just "the newest Gallery image." There are two display styles, set by **Viewer Display**. +The selected planner and final image prompt do different jobs: -**Floating** is the default. A small draggable panel sits above the game. Its header reads **Storyboard**. It plays the keyframe's video when ready, and falls back to the image while a clip is pending or failed. +- The planner decides which moments to show and writes the visual content of each frame. +- The final image template adds the provider-facing structure, matched character appearance, reference handling, location context, campaign art direction, and image instructions. -The floating viewer has these controls: +When a planner already returns the exact prompt syntax the image provider should receive, use a pass-through template such as `${scenePrompt}`. Turn off **Use the final image template** only when you intentionally want to bypass the selected formatter instead. Required image instructions still apply. -- **Close storyboard viewer** hides the panel for the current turn only. It reappears when the next GM turn finishes. A page refresh also clears the hide. -- **Drag storyboard viewer** is the header handle. Drag the panel anywhere on screen. -- **Play storyboard video** and **Pause storyboard video** control clip playback. Clips start muted. -- **Mute storyboard video** and **Unmute storyboard video** show only when the keyframe has a rendered clip. -- **Change storyboard viewer size** cycles three widths: small, medium (the default), and large. -- A corner handle resizes the panel freely and overrides the size preset. +For steadier characters: -**Background** fills the whole game surface with the active keyframe instead of a floating card. The image or clip sits behind the game controls. It uses the same reading-position logic as the floating viewer. +- Keep character-card Appearance fields specific and current. +- Keep **Attach Card Appearance** on unless the selected planner already repeats all needed appearance details. +- Keep **Send Avatar References** on when the provider accepts references and the avatars match the intended look. +- Prefer a small, clearly visible cast per frame. Storyboard includes only matched visible character and persona references rather than every character in the chat. -Background mode has a trade-off. It turns off Marinara's normal generated scene location background. While it is on, the **Generate background** button in the illustrator popover is disabled. The button shows this note: "Storyboard background display is active, so scene background generation is disabled." +**Use NovelAI character prompts** only changes requests sent through supported official NovelAI V4/V4.5 connections. Other providers use the shared prompt path even when the switch is on. -## Getting better results +## Cost and performance -A storyboard is only as clear as the turn it reads. The best turns name who moves, what changes, and where the key moment is. A vague turn like "the fight continues" gives the engine less to draw than a turn with concrete action and setting details. +Every keyframe is a separate image job. Animated Storyboards add one video job per successful keyframe. A three-frame animated Storyboard can therefore make three image requests and three video requests. -For steadier results: +Start with still images and one keyframe when validating a new provider or local workflow. Increase the frame count, clip duration, and automatic cadence only after the basic path is reliable. -- Keep the game's setting, tone, and art style specific during setup. -- Use character cards with detailed avatars, and turn on **Send Avatar References**. -- Keep important outfits, wounds, props, and locations clear in the narration. -- Use image style profiles for the finish you want. -- Use **Still Keyframes** for normal reading, and a comic or manga preset when clips are on. +## Existing Games from the older Storyboard system -## NovelAI options +Storyboard is now a downloadable Agent, but existing Game chats may still contain explicit settings created by the older Engine-native Storyboard UI. Marinara preserves those values as per-chat overrides when the package is installed; it does not discard a working Game setup. -For a compact NovelAI request, choose **NovelAI Keyframes** and turn off **Use Storyboard Template** in the **Storyboards** card. This sends the planned scene prompt directly while keeping the separate appearance, reference-image, image-instruction, and style settings available. +This means an older Game can behave differently from the current Agent defaults. Open **Chat Settings > Agents > Storyboards** and use each reset control when you want that field to inherit the Storyboard Agent default again. -**Use NovelAI Character Prompts** sends each visible character through native NovelAI Add Character captions and positions. This is on by default. Important: it only takes effect for an official NovelAI connection using a V4 or V4.5 model on novelai.net. For any other provider or model, the toggle does nothing, and Marinara uses the shared legacy prompt instead. +The older settings are migration data, not a second Storyboard implementation. Current generation still requires the Storyboard package to be installed and active for the Game. ## Troubleshooting -**"Choose an Illustrator image connection in Game Settings first."** Open **Chat Settings**, **Agents**, then the **Illustrator** card. Turn on **Game Illustrator** and pick an **Image Connection**. For a new game, enable **Visual Generation** and pick an **Image Generation Connection** in the setup wizard. +### Storyboard is missing from Chat Settings -**"Storyboards can only be generated from GM narration turns."** **Create storyboard** only works on a finished GM narration turn. It does not work on your own player messages. Wait for the GM's reply to finish, then try again. +- Install **Storyboard** from **Agents > Download Agents**. +- Use a Roleplay or Game chat; Conversation is not supported. +- Confirm the package version is compatible with the installed Engine version. -**"This GM turn has no narration to storyboard."** The turn has no story text to draw. This happens when a GM turn holds only hidden command tags and no narration. Play on until the GM writes a turn with story text, then storyboard that one. +### Create storyboard is available but generation fails -**Images appear, but no videos.** Videos need both **Automatic Storyboard Animations** on and a **Video Generation** connection selected. With animations off, storyboards make still keyframes only. +- Turn on **Enable Agents** and **Enable Storyboards** for the chat. +- Select a valid image-generation connection in the Roleplay Storyboard card, Game settings, or global Storyboard setup. +- Wait for the assistant or GM response to finish before trying again. -**Automatic storyboards do not run.** Check that **Automatic Storyboard Illustrations** or **Automatic Storyboard Animations** is on. Check that the image connection is set and the GM turn has finished streaming. Marinara will not make a second storyboard for a turn that already has one. You can still remake it by hand with **Create storyboard** in the **Gallery**. +### Roleplay did not create an automatic episode -**The storyboard is partial or stuck.** This usually means one or more image or video jobs failed, timed out, or hit a provider rate limit. Prohibited content can also block a job. If a provider is slow, raise the image and video generation timeouts in your `.env` file, then restart Marinara. See the [configuration guide](../CONFIGURATION.md) for the exact variable names. +- Choose **Still images** or **Animations**, not **Manual only**. +- Wait for a newly completed assistant response. Opening a chat does not backfill old messages. +- Check **Messages per episode**. The successful cadence anchor must accumulate enough new user and assistant messages. +- A failed run does not advance the anchor, so inspect the server log for the original provider or parsing error. -For deeper diagnosis, set your log level to debug and watch the server log. The storyboard log lines are tagged `[debug/game/storyboard-illustrator]`, `[debug/game/storyboard-image-preview]`, `[debug/game/storyboard-image-assets]`, and `[debug/game/storyboard-video]`. +### Images appear but videos do not + +- In Roleplay, choose **Animations**. In Game Mode, turn on **Automatic Storyboard Animations**. +- Select a Video Generation connection. +- Confirm the video connection supports image-to-video input. +- Check the Gallery's **Videos** tab. A clip may finish after its keyframe image. +- If planning fell back after an LLM failure, Marinara can preserve fallback images while skipping videos for that run. + +### A Storyboard is partial or stuck + +One or more provider jobs may have failed, timed out, or hit a rate or content limit. Increase `IMAGE_GEN_TIMEOUT_MS` or `VIDEO_GEN_TIMEOUT_MS` in `.env` when the provider is healthy but slow, then restart Marinara because these values are read at startup. + +Enable Debug mode and search the server log for `storyboard` to inspect the planner, compiled image prompt, reference selection, and video prompt. Debug logs can contain private chat text and prompts; sanitize them before sharing. ## Related guides -- [Scene Video Generation](../media/scene-video.md) -- [Image Generation Providers](../media/image-providers.md) +- [Agents Overview](../agents/agents-overview.md) +- [Downloadable Agents Reference](../agents/built-in-agents.md) - [Game Mode: Getting Started](getting-started.md) +- [Roleplay Mode: Getting Started](../roleplay/getting-started.md) +- [Image Generation Providers](../media/image-providers.md) +- [Scene Video Generation](../media/scene-video.md) - [LTX 2.3 Storyboards in Game Mode](ltx-2-3-storyboards.md) diff --git a/docs/media/scene-video.md b/docs/media/scene-video.md index 88892f5f02..8beeaf2214 100644 --- a/docs/media/scene-video.md +++ b/docs/media/scene-video.md @@ -132,7 +132,7 @@ Each chat picks its own video connection. You set this under **Chat Settings**, - **Game Video Prompt**: the prompt template that decides how the picture animates. The built-in default is **Cinematic Scene Video**. - **Edit Video Presets**: add and edit your own copies of the video prompt template for this chat. -The **Game Video Prompt** continues to control manual Gallery and Game Assets videos in Game Mode. Roleplay Gallery animations use **Roleplay Gallery Animation Director** instead. Storyboard keyframe clips can choose a different **Storyboard Video Prompt** in **Chat Settings**, **Agents**, then **Storyboards**. If no separate storyboard choice is set, they inherit the Game Video Prompt. +The **Game Video Prompt** continues to control manual Gallery and Game Assets videos in Game Mode. Roleplay Gallery animations use **Roleplay Gallery Animation Director** instead. The installed Storyboard Agent owns a separate default **Storyboard Video Prompt**, and each Roleplay or Game chat can override it under **Chat Settings > Agents > Storyboards**. Resetting that choice returns to the Storyboard Agent default; it does not inherit a different chat's prompt. When you first create a Game Mode chat, the setup wizard also has a **Video Generation Connection** picker. It is on the **Features** step, and it appears after you turn on **Visual Generation**. @@ -150,9 +150,9 @@ The same section has an **Animated expression length** setting. That belongs to ## Storyboards -Game Mode can also build a storyboard, which is an ordered set of keyframe pictures for one game turn. When storyboard animations are turned on, Marinara animates each keyframe into a clip using your video connection and the **Storyboard Video Prompt**. It inherits the **Game Video Prompt** unless you choose a separate template. A keyframe is one still frame in that ordered set. +The downloadable Storyboard Agent can build ordered keyframe images and clips in Roleplay and Game Mode. Game Mode uses one completed GM turn; Roleplay combines completed exchanges into an inline episode. When animations are enabled, Marinara animates each successful keyframe with the selected video connection and the Agent's **Storyboard Video Prompt**. -Storyboards have their own controls and their own guide. See [Game Mode Storyboards](../game/storyboard.md) for the full setup and workflow. +Storyboards have their own controls and their own guide. See the [Storyboard Agent Guide](../game/storyboard.md) for installation and both mode workflows. ## Troubleshooting @@ -179,7 +179,7 @@ Check that the connection has a valid API key and that your account has video ac ## Related guides - [Animated Expressions](animated-expressions.md) -- [Game Mode Storyboards](../game/storyboard.md) +- [Storyboard Agent Guide](../game/storyboard.md) - [LTX 2.3 Storyboards in Game Mode](../game/ltx-2-3-storyboards.md) - [Supported AI Providers](../connections/providers-reference.md) - [Server Configuration Reference](../CONFIGURATION.md) diff --git a/docs/media/tts-setup.md b/docs/media/tts-setup.md index f04f7fc0a8..2858e3273f 100644 --- a/docs/media/tts-setup.md +++ b/docs/media/tts-setup.md @@ -39,16 +39,16 @@ The app fills in these defaults per Source: | ----------------- | ------------------------- | ---------------------- | ------------------------------- | | OpenAI-compatible | https://api.openai.com/v1 | tts-1 | alloy | | ElevenLabs | https://api.elevenlabs.io | eleven_multilingual_v2 | none (you must pick one) | -| PocketTTS | http://localhost:49112 | pocket-tts | alba | +| PocketTTS | http://localhost:8000 | pocket-tts | alba | | xAI Voice | https://api.x.ai/v1 | grok-tts | eve | For **ElevenLabs**, the **Model** field loads the speech-capable models available through your connection and always keeps the full list visible when you open it. Pick a normal speech model. Model IDs that contain `ttv` are voice-design models, not speech models, and they cannot read text out loud. If you choose one by mistake, playback fails with an error that tells you to use a speech model instead. ### PocketTTS is a separate program -PocketTTS is not built into Marinara Engine. Marinara's adapter uses the [PocketTTS OpenAI-compatible server](https://github.com/teddybear082/pocket-tts-openai_streaming_server), which exposes both the speech and voice-list endpoints Marinara needs. Install and run that server by following its instructions; Marinara does not download or manage it for you. +PocketTTS is not built into Marinara Engine. Install [the official PocketTTS server](https://github.com/kyutai-labs/pocket-tts) separately, then start it with `uvx pocket-tts serve`. Marinara does not download or manage it for you. -The compatible server uses `http://localhost:49112` by default. Leave the **Base URL** on that value unless you changed the server port. Existing custom PocketTTS URLs remain unchanged. +The official server uses `http://localhost:8000` by default. Leave the **Base URL** on that value unless you changed the host or port. Marinara detects the official multipart `/tts` API automatically. Existing custom URLs for the [OpenAI-compatible PocketTTS wrapper](https://github.com/teddybear082/pocket-tts-openai_streaming_server) remain supported. ## Step 3: Choose a voice (Voice Option) @@ -59,7 +59,7 @@ The **Voice Option** setting decides how voices are assigned: ### One voice for all characters -Pick the voice in the **All Characters Voice** field. PocketTTS shows voices returned by your server in a dropdown and keeps a text field beside it for a custom voice ID, URL, or path. +Pick the voice in the **All Characters Voice** field. The official PocketTTS server does not expose a voice-list endpoint, so Marinara shows its built-in voices and keeps a text field beside the dropdown for another built-in name or supported voice URL. Compatible wrapper servers can still return their own voice list and accept custom IDs or paths. To load the real voice list from your provider, enter the connection details and click the **Refresh voices** button (the circular-arrow icon). You can do this before enabling playback. Refresh saves the current card first, so a newly entered API key is used immediately. Before you connect, the app shows a short built-in fallback list so the field is not empty. A provider error is shown instead of silently presenting that fallback as a successful refresh. @@ -103,7 +103,8 @@ The **Audio Format** setting chooses **MP3** (the default) or **WAV**. Use WAV f The **Speed** slider controls how fast the voice talks. The allowed range depends on the Source: -- OpenAI-compatible and PocketTTS: 0.25 to 4.0 times normal speed. +- OpenAI-compatible: 0.25 to 4.0 times normal speed. +- PocketTTS: compatible wrappers can use the 0.25 to 4.0 speed setting; the official server currently controls synthesis speed itself. - ElevenLabs: 0.7 to 1.2 times. - xAI Voice: 0.7 to 1.5 times. @@ -158,7 +159,7 @@ This override is used only during Conversation audio and video calls. The regula ## Troubleshooting - Nothing speaks: confirm the **Enable TTS** switch is on. Then check the right per-mode **Auto-play** toggle, or use the per-message **Speak** button. The **Speak** button and auto-play options only appear after TTS is enabled. -- No voices in the dropdown: save the card with TTS enabled and a valid API key, then click **Refresh voices**. For PocketTTS, also verify that `/v1/voices` responds from the compatible server. +- No voices in the dropdown: save the card with TTS enabled and a valid API key, then click **Refresh voices**. The official PocketTTS server uses Marinara's built-in list because it has no voice-list endpoint. For a compatible PocketTTS wrapper, verify that `/v1/voices` responds. - ElevenLabs will not speak: make sure you selected a real voice, not the "Select an ElevenLabs voice" placeholder. Also check that the **Model** is a speech model, not a voice-design model whose ID contains `ttv`. - A self-hosted TTS server on a local address is blocked: turn on the server setting `TTS_LOCAL_URLS_ENABLED`. It lets the app reach a local or private address for OpenAI-compatible or ElevenLabs-style servers. PocketTTS does not need this setting. See [Server Configuration Reference](../CONFIGURATION.md). - Test your setup fast: click the **Preview** button in the card to play a short sample line with your current settings. diff --git a/docs/noodle/settings.md b/docs/noodle/settings.md index 4e2cd14b5c..83dbd14477 100644 --- a/docs/noodle/settings.md +++ b/docs/noodle/settings.md @@ -34,23 +34,22 @@ Use **New profile** in **Manage stage profiles** to search and choose an eligibl Each stage profile has an inline, collapsed composer for NoodleR posts. Enter an optional title and body, then select **Post** to publish those literal values without provider work. A body, image, or poll is required, so an image or a two-to-four-option poll may be posted on its own. Uploaded images stay in NoodleR's own media storage rather than the Noodle gallery. -Select **Guide** to transform the current title and body draft through the existing NoodleR generator. It preserves the image, poll, access level, and PPV price you selected, and generated output remains title/body-only; it does not generate or replace attachments. Unpublished image files and URLs stay in the current client draft until Post or Guide succeeds. If Post, Guide, or media persistence fails, the current draft remains available for correction or retry. +Select **Guide** to transform the current title and body draft through the existing NoodleR generator. It preserves the image, poll, and access level you selected, and generated output remains title/body-only; it does not generate or replace attachments. Unpublished image files and URLs stay in the current client draft until Post or Guide succeeds. If Post, Guide, or media persistence fails, the current draft remains available for correction or retry. -The post's access level protects the complete post. Locked subscriber and PPV posts do not expose their image, poll choices, or votes. A viewer who can read the post may vote once and later change that vote; the persona linked to the creator cannot vote on its own stage-profile post. +The post's access level protects the complete post. Locked posts do not expose their image, poll choices, or votes. A viewer who can read the post may vote once and later change that vote; the persona linked to the creator cannot vote on its own stage-profile post. ## Subscriptions and post access -The NoodleR hub always shows creator pages as whichever persona is currently selected globally. Subscriptions and PPV unlocks belong to that viewer persona, so switching your active persona may change which creators and posts are available. Use **Noodle Settings** > **NoodleR Access** > **Manage stage profiles** to create, edit, or delete your own stage profiles instead. +The NoodleR hub always shows creator pages as whichever persona is currently selected globally. Subscriptions and individual unlocks belong to that viewer persona, so switching your active persona may change which creators and posts are available. Use **Noodle Settings** > **NoodleR Access** > **Manage stage profiles** to create, edit, or delete your own stage profiles instead. When guiding a post, choose one access level: - **Public**: every persona that can see the stage profile can read the post. -- **Subscribers**: the post stays locked until the selected viewer persona subscribes to that stage profile. -- **PPV**: the post has a simulated price and stays locked until that viewer persona unlocks it. No real payment is processed. +- **Locked**: the post stays locked until the selected viewer persona subscribes to that stage profile or unlocks that individual post. -Each stage profile has its own **Subscriber access** settings. **Subscriptions include PPV** lets subscribers read that profile's PPV posts without unlocking each one. It is off by default. **Hidden from personas** removes the stage profile and all its posts from selected viewer personas, including direct subscribe and unlock requests. Hidden-from settings apply to the NoodleR stage profile only and do not hide its linked public Noodle account. +Each stage profile has **Viewer access** settings. **Hidden from personas** removes the stage profile and all its posts from selected viewer personas, including direct subscribe and unlock requests. Hidden-from settings apply to the NoodleR stage profile only and do not hide its linked public Noodle account. Subscribing also follows the creator so their posts appear in the viewer's Following feed. -Use **Delete profile** on a managed stage profile to remove that stage profile, all posts published under it, its subscriptions, and its PPV unlock records. The linked public Noodle account is not deleted and can be used to create a new stage profile later. +Use **Delete profile** on a managed stage profile to remove that stage profile, all posts published under it, its subscriptions, and its individual unlock records. The linked public Noodle account is not deleted and can be used to create a new stage profile later. ## Invites @@ -79,6 +78,24 @@ When **Refreshes/day** is above 0, Marinara splits the day into equal windows an Automatic refreshes run inside the Marinara server. The Noodle page does not need to stay open, but Marinara itself must be running. If a refresh fails, the schedule shows the error and retries later, waiting longer after repeated failures. If several planned times are missed, one successful catch-up refresh covers them instead of flooding the timeline. +## NoodleR automatic publishing + +This is a separate scheduler from **Refresh** above. **Refresh** drives the public Noodle timeline; this one drives NoodleR creators. It appears under **Noodle Settings** > **Publishing** once **Enable NoodleR** is on. + +Rather than posting on the hour, NoodleR prepares posts ahead of time into a small reserve and publishes each one when its planned time arrives. That is why a creator can show a next post time before the post exists. + +- **Automatic posting schedule**: a toggle, default **on**. Turn it off to stop all automatic NoodleR publishing. Prepared posts whose time passed while it was off are retired rather than published late. +- **Posts/day**: a number, from 1 to 24, default **4**. This is the per-day ceiling on automatic text attempts, and the same ceiling applies to automatic image attempts. Manual posting and **Refresh NoodleR now** are not counted against it. +- **Night quiet**: a toggle, default **on**. While it is on, creators linked to a **character** are not given planned times between 23:00 and 07:00 local time. Creators linked to a persona are unaffected, so a quiet-hours slot can still be filled by one of them. +- **Text attempts** and **Image attempts**: read-only counters showing today's used attempts against the **Posts/day** ceiling. +- **Prepared posts**: read-only, showing how many posts are in the reserve and the time the last one is planned for. +- **Refresh all now**: writes one post immediately for every creator whose **Automatic** toggle is on. Creators with **Automatic** off are not included in the run at all, so they are neither posted nor reported. A creator already running other work is reported as skipped rather than as a failure. A post written this way retires any prepared post due for that creator within the next hour, so the creator does not post twice in quick succession. +- **Per creator**: each creator row has an **Automatic** toggle and an **Images** toggle, both default **off** for a creator created outside the guided setup. Creators made through the guided setup start with whatever you chose there. Turning **Automatic** off leaves that creator manual-only. + +Automatic creator replies use a separate installation-wide limit of 10 replies per rolling 24 hours, shared across every creator, not 10 per creator. + +Automatic publishing runs inside the Marinara server, so Marinara must be running, but the NoodleR page does not need to be open. + ## Active Accounts The **Active Accounts** section sets how many eligible accounts take part in one refresh. Eligible accounts are your invited characters, folder-included characters, and random users if you turned them on. @@ -196,6 +213,12 @@ This table lists every Noodle setting with its default and range. | **Generation connection** | none | any text connection (required for refresh) | | **Professor Mari participates** | on | on or off | | **Refreshes/day** | 2 | 0 to 24 (0 turns automatic refreshes off) | +| **Automatic posting schedule** (NoodleR) | on | on or off | +| **Posts/day** (NoodleR) | 4 | 1 to 24 | +| **Night quiet** (NoodleR) | on | on or off (character creators skip 23:00-07:00) | +| **Automatic** (per NoodleR creator) | off | on or off (guided setup may turn it on) | +| **Images** (per NoodleR creator) | off | on or off (guided setup may turn it on) | +| **Automatic creator replies** | 10 per 24 hours | installation-wide, not per creator | | **Active selection** | Random range | Random range, Exact count, All invited | | **Min active** | 2 | 1 to 100 (Random range only) | | **Max active** | 5 | 1 to 100 (Random range only) | diff --git a/docs/prompts/macros.md b/docs/prompts/macros.md index 7392101e90..0dfb553cfb 100644 --- a/docs/prompts/macros.md +++ b/docs/prompts/macros.md @@ -38,7 +38,7 @@ These macros pull in the names and card fields of the person speaking and the ch | `{{user}}` / `{{userName}}` | Your current display name (or persona name). Defaults to `User` when no persona is set. | | `{{userNamePhonetic}}` | Your persona's Phonetic name, or `{{user}}` when it is empty. | | `{{char}}` / `{{charName}}` | The current character's name. Defaults to `Character`. | -| `{{<21-character-card-ID>}}` | Placeholder syntax for the name of another character card. Replace the angle-bracketed text with that card's exact 21-character ID. | +| `{{21-character-card-ID}}` | Name of another character. Replace the placeholder text with that card's exact 21-character ID to pull the card into context. | | `{{charNamePhonetic}}` | The character's Phonetic name, or `{{char}}` when it is empty. | | `{{characters}}` | Every character in the chat, joined by commas. | | `{{group}}` | Every other active character in the group chat, excluding the current responder. The persona is not part of this character roster. | @@ -68,7 +68,7 @@ In a chat with one character, these resolve against that character. In a group c The Phonetic name field has two jobs. It sets how the name is pronounced by text-to-speech. It also feeds `{{charNamePhonetic}}` and `{{userNamePhonetic}}`. You will find it in both the **Character Editor** and the **Persona Editor**. -To reference a character who is not part of the current chat, copy that card's ID and place it directly inside double braces, such as `{{V1StGXR8_Z5jdHi6B-myT}}`. Marinara replaces the macro with the card's name and adds the referenced card's character context to the system prompt. The referenced card's initial greetings and example dialogue are excluded. Enabled lorebooks attached to that card remain subject to their normal keyword, constant, filter, probability, and token-budget rules. +To reference a character who is not part of the current chat, copy that card's ID and place it directly inside double braces, such as `{{V1StGXR8_Z5jdHi6B-myT}}`. Do not include literal `<` or `>` characters. Marinara replaces the macro with the character's name and adds the referenced card's Description, Personality, Appearance, Backstory, Scenario, and Example Dialogue to the system prompt. This works in chat messages, prompt fields, and activated lorebook entries. The referenced card's initial greetings are excluded. Enabled lorebooks attached to that card remain subject to their normal keyword, constant, filter, probability, and token-budget rules. ## Conversation mode macros diff --git a/e2e/core-flows.e2e.ts b/e2e/core-flows.e2e.ts index b1bc1c5baa..4f47b94404 100644 --- a/e2e/core-flows.e2e.ts +++ b/e2e/core-flows.e2e.ts @@ -244,6 +244,60 @@ test("turning off the custom mouse pointer persists immediately and after reload .toBeNull(); }); +test("custom theme live preview batches stylesheet updates while typing", async ({ page }) => { + await page.goto("/"); + await page.locator('[data-tour="panel-settings"]').click(); + await page.getByRole("tab", { name: "Addons" }).click(); + await page.getByRole("button", { name: "Create Theme" }).click(); + + const themeCssEditor = page.getByPlaceholder("/* Enter your CSS here... */"); + await expect(themeCssEditor).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.getElementById("marinara-css-editor-preview")?.textContent?.length ?? 0)) + .toBeGreaterThan(0); + + await page.evaluate(() => { + const previewStyle = document.getElementById("marinara-css-editor-preview"); + if (!previewStyle) throw new Error("Expected the custom theme preview stylesheet"); + + const trackedWindow = window as Window & { + __themePreviewMutationCount?: number; + __themePreviewObserver?: MutationObserver; + }; + trackedWindow.__themePreviewMutationCount = 0; + trackedWindow.__themePreviewObserver = new MutationObserver(() => { + trackedWindow.__themePreviewMutationCount = (trackedWindow.__themePreviewMutationCount ?? 0) + 1; + }); + trackedWindow.__themePreviewObserver.observe(previewStyle, { + characterData: true, + childList: true, + subtree: true, + }); + }); + + const previewMarker = "\n:root { --issue-4452-preview: ready; }"; + await themeCssEditor.pressSequentially(previewMarker, { delay: 2 }); + expect( + await page.evaluate( + () => + (window as Window & { __themePreviewMutationCount?: number }).__themePreviewMutationCount ?? 0, + ), + ).toBe(0); + + await expect + .poll(() => page.evaluate(() => document.getElementById("marinara-css-editor-preview")?.textContent ?? "")) + .toContain("--issue-4452-preview: ready"); + expect( + await page.evaluate( + () => + (window as Window & { __themePreviewMutationCount?: number }).__themePreviewMutationCount ?? 0, + ), + ).toBe(1); + + await page.getByRole("button", { name: "Preview" }).click(); + await expect.poll(() => page.locator("#marinara-css-editor-preview").count()).toBe(0); +}); + test("gradient Accent Pulse keeps animating while Appearance settings are open", async ({ page }, testInfo) => { test.skip(!testInfo.project.name.includes("desktop"), "Accent Pulse preview is covered on desktop."); @@ -2241,7 +2295,7 @@ test("Character and Persona avatar actions stay separated and visually balanced" version: string, ) => { await page.locator(`[data-tour="panel-${panel}"]`).click(); - await page.getByText(resourceName, { exact: true }).first().click(); + await page.getByText(resourceName, { exact: true }).first().click({ position: { x: 2, y: 2 } }); const editor = page.locator(".mari-editor-shell"); await expect(editor).toBeVisible(); @@ -4217,7 +4271,7 @@ test("legacy browser records are cleaned while extension imports stay locked", a }; }), ) - .toEqual({ version: 87, hasExtensionRecords: false, hasCleanupFlag: false }); + .toEqual({ version: 88, hasExtensionRecords: false, hasCleanupFlag: false }); expect( await page.evaluate( @@ -4534,6 +4588,9 @@ test("Roleplay Active Context shows rich lorebook activation provenance", async const panel = page.locator('[data-component="RoleplayActiveContextPanel"]'); await expect(panel).toBeVisible(); + await expect.poll(() => panel.evaluate((element) => element.parentElement === document.body)).toBe(true); + await expect(panel).toHaveCSS("position", "fixed"); + await expect(panel).toHaveCSS("z-index", "9999"); await expect(panel.getByText("2 active • ~321 tokens", { exact: true })).toBeVisible(); await expect(panel.getByRole("region", { name: "Current location lore" })).toContainText("Northland Bank"); await expect(panel.getByText("Whispered Archive", { exact: true })).toBeVisible(); @@ -4711,6 +4768,47 @@ test("chat toolbar panels close when their trigger is clicked again across modes const summaryPanel = page.locator("[data-chat-floating-panel]").filter({ hasText: "Chat Summary" }); await summaryButton.click(); await expect(summaryPanel).toBeVisible(); + await expect.poll(() => summaryPanel.evaluate((element) => element.parentElement === document.body)).toBe(true); + await expect(summaryPanel).toHaveCSS("position", "fixed"); + await expect(summaryPanel).toHaveCSS("z-index", "9999"); + const summaryPromptCard = summaryPanel + .getByText("Summary Prompt", { exact: true }) + .locator("xpath=../../.."); + const chatSummaryPromptTab = summaryPromptCard.getByRole("tab", { name: "Chat Summary", exact: true }); + const combinePromptTab = summaryPromptCard.getByRole("tab", { name: "Combine prompt", exact: true }); + await expect(chatSummaryPromptTab).toHaveAttribute("aria-selected", "true"); + const summaryPromptViewHeight = await summaryPromptCard.locator(".h-48").first().evaluate((element) => + element.getBoundingClientRect().height, + ); + await combinePromptTab.click(); + await expect(combinePromptTab).toHaveAttribute("aria-selected", "true"); + const combinePromptViewHeight = await summaryPromptCard.locator(".h-48").first().evaluate((element) => + element.getBoundingClientRect().height, + ); + expect(combinePromptViewHeight).toBe(summaryPromptViewHeight); + + const promptEditButton = summaryPromptCard.getByRole("button", { name: "Edit", exact: true }); + await expect(promptEditButton).toBeEnabled(); + await promptEditButton.click(); + await expect(summaryPromptCard.getByRole("button", { name: "Done", exact: true })).toBeVisible(); + const combinePromptInput = summaryPromptCard.getByRole("textbox", { name: "Combine prompt", exact: true }); + const originalCombinePrompt = await combinePromptInput.inputValue(); + const updatedCombinePrompt = `${originalCombinePrompt}\nE2E save probe`; + await expect(combinePromptInput).toHaveAttribute("rows", "5"); + await combinePromptInput.fill(updatedCombinePrompt); + await summaryPromptCard.getByRole("button", { name: "Done", exact: true }).click(); + await expect(combinePromptInput).toHaveCount(0); + await summaryPromptCard.getByRole("button", { name: "Edit", exact: true }).click(); + await expect(combinePromptInput).toHaveValue(updatedCombinePrompt); + await combinePromptInput.fill(originalCombinePrompt); + await summaryPromptCard.getByRole("button", { name: "Done", exact: true }).click(); + await expect(combinePromptInput).toHaveCount(0); + + await chatSummaryPromptTab.click(); + await summaryPromptCard.getByRole("button", { name: "Edit", exact: true }).click(); + await expect(summaryPromptCard.getByRole("button", { name: "Done", exact: true })).toBeVisible(); + await summaryPromptCard.getByRole("button", { name: "Done", exact: true }).click(); + await expect(summaryPromptCard.getByRole("button", { name: "Edit", exact: true })).toBeVisible(); await summaryButton.click(); await expect(summaryPanel).toHaveCount(0); @@ -4791,6 +4889,11 @@ test("roleplay quick preset editor uses chat settings spacing and surfaces", asy await expect(quickEditorToggle).toContainText("Collapse preset editor"); await expect(drawer.locator('[data-prompt-preset-chevron="select"]')).toBeVisible(); + await quickEditor.getByRole("button", { name: "Add Section", exact: true }).click(); + await expect(quickEditor.getByRole("button", { name: "ID Macro Cards", exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(quickEditor.getByRole("button", { name: "ID Macro Cards", exact: true })).toBeHidden(); + const toolbar = quickEditor.locator(".mari-editor-toolbar"); const firstToolbarControl = toolbar.locator("button").first(); const [toolbarBox, firstToolbarControlBox] = await Promise.all([ @@ -5658,6 +5761,11 @@ test("PocketTTS discovers server voices and uses its speech endpoint", async ({ receivedPath = incoming.url ?? ""; receivedContentType = String(incoming.headers["content-type"] ?? ""); receivedBody = Buffer.concat(chunks).toString("utf8"); + if (incoming.method === "GET" && incoming.url === "/openapi.json") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ paths: { "/v1/audio/speech": {}, "/v1/voices": {} } })); + return; + } if (incoming.method === "GET" && incoming.url === "/v1/voices") { response.writeHead(200, { "Content-Type": "application/json" }); response.end( @@ -5801,6 +5909,82 @@ test("PocketTTS discovers server voices and uses its speech endpoint", async ({ } }); +test("PocketTTS uses the official multipart speech API", async ({ request }, testInfo) => { + test.skip(!testInfo.project.name.includes("desktop"), "PocketTTS routing is covered on desktop."); + + let receivedPath = ""; + let receivedContentType = ""; + let receivedBody = ""; + const pocketTts = createServer((incoming, response) => { + const chunks: Buffer[] = []; + incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + incoming.on("end", () => { + receivedPath = incoming.url ?? ""; + receivedContentType = String(incoming.headers["content-type"] ?? ""); + receivedBody = Buffer.concat(chunks).toString("utf8"); + if (incoming.method === "GET" && incoming.url === "/openapi.json") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ paths: { "/health": {}, "/tts": {} } })); + return; + } + if (incoming.method === "POST" && incoming.url === "/tts") { + response.writeHead(200, { "Content-Type": "audio/wav" }); + response.end(Buffer.from("RIFF\u0000\u0000\u0000\u0000WAVE", "binary")); + return; + } + response.writeHead(404).end(); + }); + }); + await new Promise((resolve) => pocketTts.listen(0, "127.0.0.1", resolve)); + let originalConfig: unknown; + + try { + const address = pocketTts.address(); + if (!address || typeof address === "string") throw new Error("PocketTTS mock did not bind to a TCP port"); + + const originalConfigResponse = await request.get("/api/tts/config"); + expect(originalConfigResponse.ok()).toBeTruthy(); + originalConfig = await originalConfigResponse.json(); + + const configResponse = await request.put("/api/tts/config", { + data: { + enabled: true, + source: "pockettts", + baseUrl: `http://127.0.0.1:${address.port}`, + model: "pocket-tts", + voice: "alba", + audioFormat: "wav", + }, + }); + expect(configResponse.ok()).toBeTruthy(); + + const voicesResponse = await request.get("/api/tts/voices"); + expect(voicesResponse.ok()).toBeTruthy(); + const voices = (await voicesResponse.json()) as { voices: string[]; fromProvider: boolean }; + expect(voices.fromProvider).toBe(false); + expect(voices.voices).toEqual(expect.arrayContaining(["alba", "giovanni", "lola", "estelle"])); + + const speechResponse = await request.post("/api/tts/speak", { + data: { text: "Hello from the official server." }, + }); + expect(speechResponse.ok()).toBeTruthy(); + expect(receivedPath).toBe("/tts"); + expect(receivedContentType).toContain("multipart/form-data; boundary="); + expect(receivedBody).toContain('name="text"'); + expect(receivedBody).toContain("Hello from the official server."); + expect(receivedBody).toContain('name="voice_url"'); + expect(receivedBody).toContain("alba"); + } finally { + try { + if (originalConfig !== undefined) await request.put("/api/tts/config", { data: originalConfig }); + } finally { + await new Promise((resolve, reject) => { + pocketTts.close((error) => (error ? reject(error) : resolve())); + }); + } + } +}); + test("OpenAI-compatible TTS accepts and persists a custom Kokoro voice mix", async ({ page }, testInfo) => { test.skip(!testInfo.project.name.includes("desktop"), "Custom TTS voice entry is covered on desktop."); @@ -6241,6 +6425,180 @@ test("settings search divider stays aligned with editor headers across text scal } }); +test("Storyboard Agent settings stay organized and contained at phone widths", async ({ page }, testInfo) => { + test.skip(!testInfo.project.name.includes("desktop"), "Responsive Storyboard settings are covered once."); + + const suffix = Date.now().toString(36); + const connectionResponse = await page.request.post("/api/connections", { + data: { + name: `A deliberately long Storyboard image connection name for narrow screens ${suffix}`, + provider: "image_generation", + imageGenerationSource: "openai", + }, + }); + expect(connectionResponse.ok()).toBeTruthy(); + const connection = (await connectionResponse.json()) as { id: string }; + + await page.route("**/api/capability-packages/agents", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: "storyboard", + name: "Storyboard", + description: "Plans still and animated storyboards.", + author: "Pasta Devs", + phase: "post_processing", + execution: "host", + enabledByDefault: false, + category: "misc", + modeAllowlist: ["roleplay", "game"], + defaultPromptTemplate: "Plan storyboard keyframes.", + promptTemplates: [ + { id: "still", name: "Still planner", promptTemplate: "Plan one still frame." }, + { id: "animation", name: "Animation planner", promptTemplate: "Plan an animation." }, + ], + defaultSettings: { + illustrationPlannerTemplateIds: ["still"], + animationPlannerTemplateIds: ["animation"], + illustrationTemplates: [ + { id: "image-default", name: "Image default", promptTemplate: "Format the image prompt." }, + ], + videoTemplates: [ + { id: "video-default", name: "Video default", promptTemplate: "Format the video prompt." }, + ], + roleplayEpisodeTemplates: [ + { id: "episode-default", name: "Episode default", promptTemplate: "Plan the episode." }, + ], + roleplayStyleTemplates: [ + { id: "style-default", name: "Style default", promptTemplate: "Apply the visual style." }, + ], + roleplayAnimationTemplates: [ + { id: "motion-default", name: "Motion default", promptTemplate: "Plan motion." }, + ], + roleplayOutputTemplates: [ + { id: "output-default", name: "Output default", promptTemplate: "Return structured output." }, + ], + }, + }, + ]), + }); + }); + + try { + await page.goto("/"); + await page.evaluate(async () => { + const module = await import("/src/stores/ui.store.ts"); + module.useUIStore.getState().openAgentDetail("storyboard"); + }); + + const editor = page.locator(".mari-editor-shell"); + const settingsPanel = editor.locator(".mari-editor-panel").filter({ + has: page.getByRole("heading", { name: "Storyboard settings", exact: true }), + }); + const shared = settingsPanel.locator('[data-storyboard-settings-scope="shared"]'); + const roleplay = settingsPanel.locator('[data-storyboard-settings-scope="roleplay"]'); + const game = settingsPanel.locator('[data-storyboard-settings-scope="game"]'); + + await expect(settingsPanel).toBeVisible(); + await expect(shared).toBeVisible(); + await expect(roleplay).toBeVisible(); + await expect(game).toBeVisible(); + await expect + .poll(() => + settingsPanel.evaluate((panel) => + Array.from(panel.querySelectorAll("[data-storyboard-settings-scope]")).map( + (section) => section.dataset.storyboardSettingsScope, + ), + ), + ) + .toEqual(["shared", "roleplay", "game"]); + + const gamePromptLibrary = editor.locator(".mari-editor-panel").filter({ + has: page.getByRole("heading", { name: "Game prompt library", exact: true }), + }); + await expect(gamePromptLibrary).toBeVisible(); + expect( + await settingsPanel.evaluate( + (settingsElement, promptElement) => { + return Boolean( + settingsElement.compareDocumentPosition(promptElement as Node) & Node.DOCUMENT_POSITION_FOLLOWING, + ); + }, + await gamePromptLibrary.elementHandle(), + ), + ).toBe(true); + + const imageConnection = shared.locator("select").first(); + await imageConnection.selectOption(connection.id); + await expect(imageConnection).toHaveValue(connection.id); + + const roleplayInterval = roleplay.getByLabel("Default Roleplay episode interval", { exact: true }); + await roleplayInterval.fill("7"); + await expect(roleplayInterval).toHaveValue("7"); + + const scopeToggle = (scope: Locator) => scope.locator(":scope > button"); + await scopeToggle(roleplay).click(); + await expect(scopeToggle(roleplay)).toHaveAttribute("aria-expanded", "false"); + await expect(roleplayInterval).toHaveCount(0); + await scopeToggle(roleplay).click(); + await expect(roleplay.getByLabel("Default Roleplay episode interval", { exact: true })).toHaveValue("7"); + + await scopeToggle(shared).click(); + await expect(scopeToggle(shared)).toHaveAttribute("aria-expanded", "false"); + await scopeToggle(shared).click(); + await expect(shared.locator("select").first()).toHaveValue(connection.id); + + await page.evaluate(() => { + document.documentElement.style.fontSize = "18px"; + }); + + for (const width of [320, 360, 390]) { + await page.setViewportSize({ width, height: 844 }); + await expect + .poll(() => + settingsPanel.evaluate((panel) => { + const panelRect = panel.getBoundingClientRect(); + const visibleControls = Array.from( + panel.querySelectorAll("button, input, select, textarea"), + ).filter((control) => control.getClientRects().length > 0); + const overflowingControls = visibleControls.filter((control) => { + const rect = control.getBoundingClientRect(); + return rect.left < panelRect.left - 1 || rect.right > panelRect.right + 1; + }); + const overflowingScopes = Array.from( + panel.querySelectorAll("[data-storyboard-settings-scope]"), + ).filter((scope) => scope.scrollWidth > scope.clientWidth + 1); + return { + panelFits: panel.scrollWidth <= panel.clientWidth + 1, + overflowingControls: overflowingControls.length, + overflowingScopes: overflowingScopes.length, + }; + }), + ) + .toEqual({ panelFits: true, overflowingControls: 0, overflowingScopes: 0 }); + + for (const scope of [shared, roleplay, game]) { + const box = await scopeToggle(scope).boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + + for (const control of [ + settingsPanel.locator('button[title="Restore default prompt"]').first(), + settingsPanel.locator('button[title="Remove prompt option"]').first(), + settingsPanel.getByRole("button", { name: "Expand editor" }).first(), + ]) { + const box = await control.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + } + } finally { + await page.request.delete(`/api/connections/${connection.id}`).catch(() => undefined); + } +}); + test("Backup & Export identifies the automatic backup location", async ({ page }) => { await page.goto("/"); await page.locator('[data-tour="panel-settings"]').click(); @@ -9254,6 +9612,57 @@ test("Professor Mari chat fills the mobile home viewport and keeps its composer .toBe(true); }); +test("Professor Mari history opens a loaded chat at its newest message", async ({ page }) => { + const createdChatIds: string[] = []; + + try { + const firstResponse = await page.request.get("/api/chats/internal/professor-mari"); + expect(firstResponse.ok()).toBeTruthy(); + const firstChat = (await firstResponse.json()) as { id: string }; + createdChatIds.push(firstChat.id); + for (let index = 0; index < 18; index += 1) { + const messageResponse = await page.request.post(`/api/chats/${firstChat.id}/messages`, { + data: { + role: index % 2 === 0 ? "user" : "assistant", + content: `Professor Mari history message ${index + 1}. ${"A long transcript line makes the pane overflow. ".repeat(8)}`, + }, + }); + expect(messageResponse.ok()).toBeTruthy(); + } + const secondResponse = await page.request.post("/api/chats/internal/professor-mari/restart"); + expect(secondResponse.ok()).toBeTruthy(); + const secondChat = (await secondResponse.json()) as { id: string }; + createdChatIds.push(secondChat.id); + + await page.goto("/"); + await page + .locator('[data-component="HomeProfessorMariChat.MariPanel"]') + .getByRole("button", { name: "Ask Professor Mari" }) + .click(); + + const window = page.locator('[data-component="HomeProfessorMariChat.Window"]'); + await window.getByRole("button", { name: "Chats" }).click(); + await window.locator(`[data-professor-mari-chat-id="${firstChat.id}"] button`).first().click(); + + const transcript = window.locator('[data-component="HomeProfessorMariChat.Transcript"]'); + await expect(transcript).toBeVisible(); + await expect + .poll(() => + transcript.evaluate((node) => ({ + atBottom: Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop) <= 2, + overflows: node.scrollHeight > node.clientHeight, + })), + ) + .toEqual({ atBottom: true, overflows: true }); + } finally { + await Promise.all( + createdChatIds.map((id) => + page.request.delete(`/api/chats/internal/professor-mari/chats/${id}`).catch(() => undefined), + ), + ); + } +}); + test("Professor Mari bulk chat deletion follows the active accent", async ({ page }) => { const firstResponse = await page.request.get("/api/chats/internal/professor-mari"); expect(firstResponse.ok()).toBeTruthy(); diff --git a/package.json b/package.json index d2e2282755..abc0afa093 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marinara-engine", - "version": "2.4.0", + "version": "2.4.1", "private": true, "description": "AI Chat & Roleplay Frontend — Conversation, Roleplay, Game", "author": "Marianna", @@ -35,12 +35,13 @@ "regression:spatial": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/spatial-context.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/spatial-location-reference.regression.ts", "regression:timezone": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/timezone.regression.ts", "regression:character-schedule-transfer": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/character-schedule-transfer.regression.ts", - "regression:noodle": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-scheduler.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-settings.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-context.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-platform-migration.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-mentions.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-polls.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-prompt.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-deletion.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-image-retry.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-image-claim.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-vision.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-refresh-transaction.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-autopost.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-autopost-image.regression.ts", + "regression:noodle": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-scheduler.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-settings.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-context.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-platform-migration.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-mentions.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-polls.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-prompt.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-creator-reply.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-deletion.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-image-retry.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-image-claim.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-vision.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-refresh-transaction.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-autopost.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-autopost-image.regression.ts && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/noodle-autopost-image-lifecycle.regression.ts", "regression:emoji": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/emoji-catalog.regression.ts", "regression:providers": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/provider-compat.regression.ts", "regression:jsonish": "pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/jsonish-output.regression.ts", "regression:tts": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/tts-source-persistence.regression.ts", - "regression:issues": "pnpm regression:card-library-search && pnpm regression:chat-resource-drop && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/open-issues.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/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/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/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 && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/scene-video-range.regression.ts", + "regression:agent-activation": "pnpm build:shared && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/agent-activation.regression.ts", + "regression:issues": "pnpm regression:card-library-search && pnpm regression:chat-resource-drop && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/open-issues.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 --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/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/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 && pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/scene-video-range.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/package.json b/packages/client/package.json index 82b66847ae..ff35d4e660 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "name": "@marinara-engine/client", "private": true, - "version": "2.4.0", + "version": "2.4.1", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/client/public/manifest.json b/packages/client/public/manifest.json index 62bbe3de86..6ab68a0117 100644 --- a/packages/client/public/manifest.json +++ b/packages/client/public/manifest.json @@ -2,7 +2,7 @@ "name": "Marinara Engine", "short_name": "Marinara", "description": "AI Chat & Roleplay Frontend — Conversation, Roleplay, Game", - "version": "2.4.0", + "version": "2.4.1", "start_url": "/", "display": "standalone", "background_color": "#0a0a0f", diff --git a/packages/client/public/sprites/mari/Mari_noodler_teaser_locked.webp b/packages/client/public/sprites/mari/Mari_noodler_teaser_locked.webp new file mode 100644 index 0000000000..7b115e35dc Binary files /dev/null and b/packages/client/public/sprites/mari/Mari_noodler_teaser_locked.webp differ diff --git a/packages/client/public/sprites/mari/Mari_noodler_teaser_unlocked.webp b/packages/client/public/sprites/mari/Mari_noodler_teaser_unlocked.webp new file mode 100644 index 0000000000..e04a2bff1b Binary files /dev/null and b/packages/client/public/sprites/mari/Mari_noodler_teaser_unlocked.webp differ diff --git a/packages/client/src/components/agents/AgentEditor.tsx b/packages/client/src/components/agents/AgentEditor.tsx index 27c964ea50..2865cd4f76 100644 --- a/packages/client/src/components/agents/AgentEditor.tsx +++ b/packages/client/src/components/agents/AgentEditor.tsx @@ -1835,8 +1835,8 @@ export function AgentEditor() { )} {/* ── Body ── */} -
-
+
+
{/* ── Description ── */} +
{collapsible ? ( +
+ ))} + +
+ ); +} + // ═══════════════════════════════════════════════ // Main Editor // ═══════════════════════════════════════════════ @@ -177,6 +237,7 @@ export function RegexScriptEditor() { const { data: regexScripts } = useRegexScripts(); const { data: characters } = useCharacters(); + const { data: promptPresets } = usePresets(); const updateScript = useUpdateRegexScript(); const createScript = useCreateRegexScript(); const deleteScript = useDeleteRegexScript(); @@ -200,6 +261,8 @@ export function RegexScriptEditor() { const [localApplyMode, setLocalApplyMode] = useState("display"); const [localCharacterScopeEnabled, setLocalCharacterScopeEnabled] = useState(false); const [localTargetCharacterIds, setLocalTargetCharacterIds] = useState([]); + const [localPromptPresetScopeEnabled, setLocalPromptPresetScopeEnabled] = useState(false); + const [localTargetPromptPresetIds, setLocalTargetPromptPresetIds] = useState([]); const [localOrder, setLocalOrder] = useState(0); const [localMinDepth, setLocalMinDepth] = useState(null); const [localMaxDepth, setLocalMaxDepth] = useState(null); @@ -234,6 +297,13 @@ export function RegexScriptEditor() { .filter((character): character is { id: string; name: string } => character !== null) .sort((a, b) => a.name.localeCompare(b.name)); }, [characters]); + const promptPresetOptions = useMemo( + () => + (promptPresets ?? []) + .map((preset) => ({ id: preset.id, name: preset.name })) + .sort((a, b) => a.name.localeCompare(b.name)), + [promptPresets], + ); // Populate from DB row or defaults for new useEffect(() => { @@ -258,6 +328,9 @@ export function RegexScriptEditor() { const targetCharacterIds = parseStringArray(dbRow.targetCharacterIds); setLocalTargetCharacterIds(targetCharacterIds); setLocalCharacterScopeEnabled(targetCharacterIds.length > 0); + const targetPromptPresetIds = parseStringArray(dbRow.targetPromptPresetIds); + setLocalTargetPromptPresetIds(targetPromptPresetIds); + setLocalPromptPresetScopeEnabled(targetPromptPresetIds.length > 0); setLocalOrder(dbRow.order); setLocalMinDepth(dbRow.minDepth); setLocalMaxDepth(dbRow.maxDepth); @@ -275,6 +348,8 @@ export function RegexScriptEditor() { const defaultScope = regexDetailDefaultCharacterIds ?? []; setLocalTargetCharacterIds(defaultScope); setLocalCharacterScopeEnabled(defaultScope.length > 0); + setLocalTargetPromptPresetIds([]); + setLocalPromptPresetScopeEnabled(false); setLocalOrder(0); setLocalMinDepth(null); setLocalMaxDepth(null); @@ -344,7 +419,11 @@ export function RegexScriptEditor() { if (!regexDetailId) return; setSaveError(null); if (localCharacterScopeEnabled && localTargetCharacterIds.length === 0) { - setSaveError("Choose at least one target character."); + setSaveError(localizeUi("ui.agents.regexscripteditor.chooseAtLeastOneCharacter")); + return; + } + if (localPromptPresetScopeEnabled && localTargetPromptPresetIds.length === 0) { + setSaveError(localizeUi("ui.agents.regexscripteditor.chooseAtLeastOnePromptPreset")); return; } if (blockingRegexError) { @@ -367,6 +446,7 @@ export function RegexScriptEditor() { promptOnly: localApplyMode === "prompt", applyMode: localApplyMode, targetCharacterIds: localCharacterScopeEnabled ? localTargetCharacterIds : [], + targetPromptPresetIds: localPromptPresetScopeEnabled ? localTargetPromptPresetIds : [], minDepth: localMinDepth, maxDepth: localMaxDepth, }; @@ -402,6 +482,8 @@ export function RegexScriptEditor() { localApplyMode, localCharacterScopeEnabled, localTargetCharacterIds, + localPromptPresetScopeEnabled, + localTargetPromptPresetIds, localOrder, localMinDepth, localMaxDepth, @@ -412,6 +494,7 @@ export function RegexScriptEditor() { createScript, openRegexDetail, regexDetailReturn, + localizeUi, ]); const markDirty = useCallback(() => setDirty(true), []); @@ -443,13 +526,6 @@ export function RegexScriptEditor() { markDirty(); }; - const toggleTargetCharacter = (characterId: string) => { - setLocalTargetCharacterIds((prev) => - prev.includes(characterId) ? prev.filter((id) => id !== characterId) : [...prev, characterId], - ); - markDirty(); - }; - const handleExport = () => { downloadJsonFile( { @@ -466,6 +542,7 @@ export function RegexScriptEditor() { promptOnly: localApplyMode === "prompt", applyMode: localApplyMode, targetCharacterIds: localCharacterScopeEnabled ? localTargetCharacterIds : [], + targetPromptPresetIds: localPromptPresetScopeEnabled ? localTargetPromptPresetIds : [], order: localOrder, minDepth: localMinDepth, maxDepth: localMaxDepth, @@ -485,7 +562,13 @@ export function RegexScriptEditor() { const isPending = updateScript.isPending || createScript.isPending; const characterScopeError = - localCharacterScopeEnabled && localTargetCharacterIds.length === 0 ? "Choose at least one character." : null; + localCharacterScopeEnabled && localTargetCharacterIds.length === 0 + ? localizeUi("ui.agents.regexscripteditor.chooseAtLeastOneCharacter") + : null; + const promptPresetScopeError = + localPromptPresetScopeEnabled && localTargetPromptPresetIds.length === 0 + ? localizeUi("ui.agents.regexscripteditor.chooseAtLeastOnePromptPreset") + : null; return (
@@ -523,7 +606,13 @@ export function RegexScriptEditor() { {dirty && !saveError && {localizeUi("ui.agents.agenteditor.unsaved")}}
{localCharacterScopeEnabled && ( -
- {characterOptions.length > 0 ? ( -
- {characterOptions.map((character) => { - const selected = localTargetCharacterIds.includes(character.id); - return ( - - ); - })} -
- ) : ( -
{localizeUi("ui.agents.regexscripteditor.noCharactersFound")}
- )} +
+ } + options={characterOptions} + selectedIds={localTargetCharacterIds} + addLabel={localizeUi("ui.agents.regexscripteditor.addCharacter")} + emptyLabel={localizeUi("ui.agents.regexscripteditor.noCharactersFound")} + removeLabel={(name) => + localizeUi("ui.agents.regexscripteditor.removeValue1", { value1: name }) + } + onAdd={(id) => { + setLocalTargetCharacterIds((previous) => [...previous, id]); + markDirty(); + }} + onRemove={(id) => { + setLocalTargetCharacterIds((previous) => previous.filter((value) => value !== id)); + markDirty(); + }} + /> {characterScopeError && (
{characterScopeError} @@ -756,6 +837,62 @@ export function RegexScriptEditor() {
)}
+
+
+ { + setLocalPromptPresetScopeEnabled(checked); + markDirty(); + }} + className="mt-0.5 shrink-0 p-0 hover:bg-transparent" + /> +
+
+ + {localizeUi("ui.agents.regexscripteditor.specificPromptPresets")} + +
+
+ {localPromptPresetScopeEnabled + ? localizeUi("ui.agents.regexscripteditor.value1Selected", { + value1: localTargetPromptPresetIds.length, + }) + : localizeUi("ui.agents.regexscripteditor.appliesToAllPromptPresets")} +
+
+
+ {localPromptPresetScopeEnabled && ( +
+ } + options={promptPresetOptions} + selectedIds={localTargetPromptPresetIds} + addLabel={localizeUi("ui.agents.regexscripteditor.addPromptPreset")} + emptyLabel={localizeUi("ui.agents.regexscripteditor.noPromptPresetsFound")} + removeLabel={(name) => + localizeUi("ui.agents.regexscripteditor.removeValue1", { value1: name }) + } + onAdd={(id) => { + setLocalTargetPromptPresetIds((previous) => [...previous, id]); + markDirty(); + }} + onRemove={(id) => { + setLocalTargetPromptPresetIds((previous) => previous.filter((value) => value !== id)); + markDirty(); + }} + /> + {promptPresetScopeError && ( +
+ {promptPresetScopeError} +
+ )} +
+ )} +
{/* ── Trim Strings ── */} diff --git a/packages/client/src/components/agents/StoryboardAgentSettingsPanel.tsx b/packages/client/src/components/agents/StoryboardAgentSettingsPanel.tsx index c42ea44f09..a80454be5f 100644 --- a/packages/client/src/components/agents/StoryboardAgentSettingsPanel.tsx +++ b/packages/client/src/components/agents/StoryboardAgentSettingsPanel.tsx @@ -1,4 +1,5 @@ -import { Plus, RotateCcw, Trash2, Video, ImageIcon, PanelsTopLeft } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { ChevronDown, Plus, RotateCcw, Trash2, Video, ImageIcon, PanelsTopLeft } from "lucide-react"; import { useTranslation as useUiTranslation } from "react-i18next"; import { GAME_STORYBOARD_ANIMATION_DURATION_SECONDS_MAX, @@ -76,9 +77,9 @@ function TemplateCollectionEditor({ }; return ( -
+
-
+

{title}

{description}

@@ -97,7 +98,7 @@ function TemplateCollectionEditor({ }, ]); }} - className="flex items-center gap-1.5 rounded-lg bg-[var(--background)] px-2.5 py-1.5 text-[0.6875rem] font-medium ring-1 ring-[var(--border)] hover:bg-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-40" + className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-lg bg-[var(--background)] px-3 py-2 text-[0.6875rem] font-medium ring-1 ring-[var(--border)] hover:bg-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-40" > {localizeUi("ui.agents.agenteditor.addOption")} @@ -109,7 +110,7 @@ function TemplateCollectionEditor({ return (
@@ -126,7 +127,7 @@ function TemplateCollectionEditor({ type="button" disabled={matchesDefault} onClick={() => update(template.id, { promptTemplate: defaultTemplate.promptTemplate })} - className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--muted-foreground)] hover:bg-[var(--accent)] disabled:opacity-35" + className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[var(--muted-foreground)] hover:bg-[var(--accent)] disabled:opacity-35" title={localizeUi("ui.agents.agenteditor.restoreDefaultPrompt")} > @@ -136,7 +137,7 @@ function TemplateCollectionEditor({ type="button" disabled={required && templates.length <= 1} onClick={() => onChange(templates.filter((entry) => entry.id !== template.id))} - className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--muted-foreground)] hover:bg-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-35" + className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[var(--muted-foreground)] hover:bg-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-35" title={localizeUi("ui.agents.agenteditor.removePromptOption")} > @@ -145,7 +146,7 @@ function TemplateCollectionEditor({ update(template.id, { description: event.target.value })} - className="w-full rounded-lg bg-[var(--secondary)] px-2.5 py-1.5 text-xs ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" + className="min-w-0 max-w-full w-full rounded-lg bg-[var(--secondary)] px-2.5 py-1.5 text-xs ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" placeholder={localizeUi("ui.agents.agenteditor.shortDescriptionShownInChatSettings")} /> restoreRequiredPrompt(template)} rows={7} title={template.name} - className="w-full resize-y rounded-lg bg-[var(--secondary)] px-3 py-2 font-mono text-xs leading-relaxed ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" + className="min-w-0 max-w-full w-full resize-y rounded-lg bg-[var(--secondary)] px-3 py-2 font-mono text-xs leading-relaxed ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" + wrapperClassName="min-w-0 max-w-full" + buttonClassName="flex min-h-11 min-w-11 items-center justify-center" + controlPaddingClassName="pr-12" placeholder={localizeUi("ui.agents.agenteditor.writeThePromptTemplateForThisOption")} />
@@ -177,7 +181,7 @@ function ToggleRow({ onChange: (checked: boolean) => void; }) { return ( -
@@ -350,6 +360,8 @@ export function ConversationMessageGrouped({ emojiMap={emojiMap} stickerMap={stickerMap} onImageOpen={(url) => onImageOpen(url)} + selfCharacterId={segSelfId} + galleryIndex={galleryIndex} />
@@ -366,6 +378,8 @@ export function ConversationMessageGrouped({ emojiMap={emojiMap} stickerMap={stickerMap} onImageOpen={(url) => onImageOpen(url)} + selfCharacterId={segSelfId} + galleryIndex={galleryIndex} />
))} diff --git a/packages/client/src/components/chat/ConversationMessageLine.tsx b/packages/client/src/components/chat/ConversationMessageLine.tsx index 7063b913cb..257fb8feb4 100644 --- a/packages/client/src/components/chat/ConversationMessageLine.tsx +++ b/packages/client/src/components/chat/ConversationMessageLine.tsx @@ -35,6 +35,8 @@ export function ConversationMessageLine({ ctx }: { ctx: MessageRenderContext }) avatarCornerClass, nameColor, mentionNames, + selfCharacterId, + galleryIndex, quoteFormat, renderedContent, renderedContentParts, @@ -186,14 +188,14 @@ export function ConversationMessageLine({ ctx }: { ctx: MessageRenderContext })
{renderedContentParts.map((part, i) => (
- onImageOpen(url)} /> + onImageOpen(url)} selfCharacterId={selfCharacterId} galleryIndex={galleryIndex} />
))}
) : extra.diceRollResult ? ( ) : ( - onImageOpen(url)} /> + onImageOpen(url)} selfCharacterId={selfCharacterId} galleryIndex={galleryIndex} /> )} {isStreaming && ( diff --git a/packages/client/src/components/chat/ConversationMessageShared.tsx b/packages/client/src/components/chat/ConversationMessageShared.tsx index cf14c4d52c..72c12f3186 100644 --- a/packages/client/src/components/chat/ConversationMessageShared.tsx +++ b/packages/client/src/components/chat/ConversationMessageShared.tsx @@ -13,6 +13,7 @@ import { import { cn } from "../../lib/utils"; import type { ReactionSegmentTarget } from "../../lib/reactions"; import { applyInlineMarkdown, renderMarkdownBlocks } from "../../lib/markdown"; +import { resolveSelfCardAssets, type ChatGalleryIndex } from "../../lib/card-asset-links"; import { renderInlineWithCustomEmojis } from "../../lib/custom-emoji-render"; import { renderWithStickerBlocks } from "../../lib/sticker-render"; import { applyTextareaQuoteFormat } from "../../lib/textarea-quotes"; @@ -79,6 +80,15 @@ export interface MessageRenderContext { nameColor?: string; mentionNames: string[]; charByName: Map | null; + /** Same keys as charByName, mapping normalized speaker name -> character id + * (CharInfo carries no id; grouped segments need it for card://self refs). */ + charIdByName: Map | null; + /** Speaking character of the whole message (null for user/system) — resolves + * portable card://self/gallery refs; grouped segments prefer their own speaker. */ + selfCharacterId: string | null; + /** Chat-wide gallery filename index — card://self falls back to whichever + * chat character owns the file when the speaker doesn't (group chats). */ + galleryIndex: ChatGalleryIndex | null; // content quoteFormat: QuoteFormat; renderedContent: string; @@ -365,16 +375,26 @@ export function MessageContent({ emojiMap, stickerMap, onImageOpen, + selfCharacterId, + galleryIndex, }: { content: string; mentionNames?: string[]; emojiMap?: Map; stickerMap?: Map; onImageOpen: (url: string) => void; + /** Speaking character of this content — resolves portable card://self/gallery refs. */ + selfCharacterId?: string | null; + /** Optional chat-wide filename index for any-owner fallback resolution. */ + galleryIndex?: ChatGalleryIndex | null; }) { const { t: localizeUi } = useUiTranslation(); - if (IMAGE_URL_RE.test(content.trim())) { - const url = content.trim(); + // Portable gallery refs resolve to the speaker BEFORE markdown rendering, so + // the shared renderer stays untouched and grouped segments can resolve to + // their own per-segment speaker. + const resolved = resolveSelfCardAssets(content, selfCharacterId, galleryIndex); + if (IMAGE_URL_RE.test(resolved.trim())) { + const url = resolved.trim(); return ( + + {ownerCount === 0 ? ( +

+ {localizeUi("ui.chat.chatsettingsdrawer.addCharactersToThisChatOrChooseAPersona")} +

+ ) : ownersLoading ? ( +

+ {localizeUi("ui.chat.chatsettingsdrawer.loadingSpriteOwners")} +

+ ) : owners.length > 0 ? ( +
+ {owners.map((owner) => ( +
+ + + onToggleOwner(owner.id)} /> +
+ ))} +
+ ) : choicesLoading ? ( +

+ {localizeUi("ui.chat.expressionsetupfields.checkingAddedCharactersForUploadedSprites")} +

+ ) : ( +

+ {localizeUi("ui.chat.chatsettingsdrawer.noneOfTheAddedCharactersHaveUploadedSpritesYet")} +

+ )} + +

+ {localizeUi("ui.chat.chatsettingsdrawer.onlyAddedCharactersAndTheActivePersonaWithUploaded")} +

+ + {enabledOwnerCount > 0 && ( +
+
+ + + {localizeUi("ui.chat.expressionsetupfields.spriteLayout")} + + + +
+ +
+ + + {selectedLayoutSubjectId && ( + + )} +
+ +
+ + {selectedLayoutSubjectId + ? localizeUi("ui.chat.chatsettingsdrawer.characterSide") + : localizeUi("ui.chat.chatsettingsdrawer.defaultSide")} + +
+ + +
+
+ +
+ + + + +
+ +

+ {localizeUi("ui.chat.chatsettingsdrawer.arrangeModeLetsYouDragSpritesAnywhereInThe")} +

+
+ )} + + ); +} + +function SpriteDisplayModeToggle({ + modes, + onToggle, +}: { + modes: readonly SpriteDisplayMode[]; + onToggle: (mode: SpriteDisplayMode) => void; +}) { + const { t: localizeUi } = useTranslation(); + const options: Array<{ id: SpriteDisplayMode; label: string }> = [ + { id: "expressions", label: "Expressions" }, + { id: "full-body", label: "Full-body" }, + ]; + + return ( +
+
+ + {localizeUi("ui.chat.spritedisplaymodetoggle.spriteSource")} + + + {localizeUi("ui.chat.spritedisplaymodetoggle.chooseOneOrBoth")} + +
+
+ {options.map((option, index) => { + const active = hasSpriteDisplayMode(modes, option.id); + const isLastActive = active && modes.length === 1; + return ( + + ); + })} +
+
+ ); +} + +function SpriteToggleButton({ active, onToggle }: { active: boolean; onToggle: () => void }) { + const { t: localizeUi } = useTranslation(); + return ( + + ); +} diff --git a/packages/client/src/components/chat/HapticConnectionPanel.tsx b/packages/client/src/components/chat/HapticConnectionPanel.tsx new file mode 100644 index 0000000000..26d96bef8c --- /dev/null +++ b/packages/client/src/components/chat/HapticConnectionPanel.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from "react"; +import { Vibrate } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + HAPTIC_INTIFACE_URL_STORAGE_KEY, + useHapticConnect, + useHapticDisconnect, + useHapticStartScan, + useHapticStatus, +} from "../../hooks/use-haptic"; +import { cn } from "../../lib/utils"; + +interface HapticConnectionPanelProps { + intifaceUrl?: string; + onIntifaceUrlChange: (value: string | null) => void; +} + +export function HapticConnectionPanel({ + intifaceUrl: savedIntifaceUrl, + onIntifaceUrlChange, +}: HapticConnectionPanelProps) { + const { t: localizeUi } = useTranslation(); + const { data: status, isLoading } = useHapticStatus(); + const connect = useHapticConnect(); + const disconnect = useHapticDisconnect(); + const startScan = useHapticStartScan(); + const [intifaceUrl, setIntifaceUrl] = useState( + () => savedIntifaceUrl ?? localStorage.getItem(HAPTIC_INTIFACE_URL_STORAGE_KEY) ?? "", + ); + const [autoConnectAttempted, setAutoConnectAttempted] = useState(false); + + useEffect(() => { + setIntifaceUrl(savedIntifaceUrl ?? localStorage.getItem(HAPTIC_INTIFACE_URL_STORAGE_KEY) ?? ""); + }, [savedIntifaceUrl]); + + const saveIntifaceUrl = useCallback(() => { + const trimmed = intifaceUrl.trim(); + if (trimmed) { + localStorage.setItem(HAPTIC_INTIFACE_URL_STORAGE_KEY, trimmed); + } else { + localStorage.removeItem(HAPTIC_INTIFACE_URL_STORAGE_KEY); + } + if ((savedIntifaceUrl ?? "") !== trimmed) { + onIntifaceUrlChange(trimmed || null); + } + return trimmed; + }, [intifaceUrl, onIntifaceUrlChange, savedIntifaceUrl]); + + useEffect(() => { + if (autoConnectAttempted || isLoading || !status || status.connected || connect.isPending) return; + setAutoConnectAttempted(true); + connect.mutate(intifaceUrl.trim() || undefined); + }, [autoConnectAttempted, connect, intifaceUrl, isLoading, status]); + + if (isLoading) { + return ( +
+ {localizeUi("ui.chat.hapticconnectionpanel.checkingIntifaceCentral")} +
+ ); + } + + const connected = status?.connected ?? false; + const devices = status?.devices ?? []; + const scanning = status?.scanning ?? false; + const defaultServerUrl = status?.defaultServerUrl ?? "ws://127.0.0.1:12345"; + const activeServerUrl = status?.serverUrl ?? defaultServerUrl; + + return ( +
+ + +
+
+
+ + {connect.isPending + ? localizeUi("ui.chat.hapticconnectionpanel.connectingToValue1", { + value1: intifaceUrl.trim() || defaultServerUrl, + }) + : connected + ? localizeUi("ui.chat.hapticconnectionpanel.connectedValue1", { value1: activeServerUrl }) + : localizeUi("ui.chat.hapticconnectionpanel.notConnected")} + +
+ +
+ + {connect.isError && !connected && ( +

+ {localizeUi("ui.chat.hapticconnectionpanel.couldNotConnectMakeSure")}{" "} + + {localizeUi("ui.chat.hapticconnectionpanel.intifaceCentral")} + {" "} + {localizeUi("ui.chat.hapticconnectionpanel.isRunningAndTheServerIsStarted")} +

+ )} + + {connected && ( +
+
+ + {devices.length === 0 + ? localizeUi("ui.chat.hapticconnectionpanel.noDevicesFound") + : localizeUi("ui.chat.hapticconnectionpanel.value1DeviceValue2", { + value1: devices.length, + value2: devices.length !== 1 ? localizeUi("ui.noodle.stageprofileview.s") : "", + })} + + +
+ {devices.map((device) => ( +
+ + {device.name} + {device.capabilities.join(", ")} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/client/src/components/chat/HomeCreditsModal.tsx b/packages/client/src/components/chat/HomeCreditsModal.tsx index 4b53cc5606..2913926992 100644 --- a/packages/client/src/components/chat/HomeCreditsModal.tsx +++ b/packages/client/src/components/chat/HomeCreditsModal.tsx @@ -6,16 +6,16 @@ const FONT_AWESOME_D20_SOURCE_URL = "https://github.com/FortAwesome/Font-Awesome const CC_BY_4_0_LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/"; const CONTRIBUTORS = [ - { login: "SpicyMarinara", url: "https://github.com/SpicyMarinara", contributions: 1663 }, + { login: "SpicyMarinara", url: "https://github.com/SpicyMarinara", contributions: 2152 }, { login: "cha1latte", url: "https://github.com/cha1latte", contributions: 319 }, - { login: "kolacheee", url: "https://github.com/kolacheee", contributions: 214 }, + { login: "thetopham", url: "https://github.com/thetopham", contributions: 260 }, + { login: "kolacheee", url: "https://github.com/kolacheee", contributions: 258 }, + { login: "Gunterlie", url: "https://github.com/Gunterlie", contributions: 245 }, { login: "Romuromylus", url: "https://github.com/Romuromylus", contributions: 202 }, - { login: "thetopham", url: "https://github.com/thetopham", contributions: 125 }, - { login: "Gunterlie", url: "https://github.com/Gunterlie", contributions: 90 }, + { login: "Xelvanis", url: "https://github.com/Xelvanis", contributions: 124 }, { login: "LukaTheHero", url: "https://github.com/LukaTheHero", contributions: 86 }, - { login: "Xelvanis", url: "https://github.com/Xelvanis", contributions: 86 }, { login: "TheLonelyDevil9", url: "https://github.com/TheLonelyDevil9", contributions: 69 }, - { login: "Promansis", url: "https://github.com/Promansis", contributions: 64 }, + { login: "Promansis", url: "https://github.com/Promansis", contributions: 69 }, { login: "coxde", url: "https://github.com/coxde", contributions: 60 }, { login: "munimunigamer", url: "https://github.com/munimunigamer", contributions: 31 }, { login: "Minsklatte", url: "https://github.com/Minsklatte", contributions: 16 }, @@ -27,14 +27,15 @@ const CONTRIBUTORS = [ { login: "NeoKazuya", url: "https://github.com/NeoKazuya", contributions: 7 }, { login: "felorhik", url: "https://github.com/felorhik", contributions: 6 }, { login: "bignast", url: "https://github.com/bignast", contributions: 6 }, - { login: "jake9000", url: "https://github.com/jake9000", contributions: 5 }, - { login: "JurijPietrowicz", url: "https://github.com/JurijPietrowicz", contributions: 5 }, - { login: "mm14141", url: "https://github.com/mm14141", contributions: 5 }, { login: "amauragis", url: "https://github.com/amauragis", contributions: 5 }, + { login: "mm14141", url: "https://github.com/mm14141", contributions: 5 }, + { login: "JurijPietrowicz", url: "https://github.com/JurijPietrowicz", contributions: 5 }, + { login: "jake9000", url: "https://github.com/jake9000", contributions: 5 }, { login: "marysia", url: "https://github.com/marysia", contributions: 4 }, - { login: "myaiexp", url: "https://github.com/myaiexp", contributions: 3 }, - { login: "kh0p", url: "https://github.com/kh0p", contributions: 3 }, { login: "LightD31", url: "https://github.com/LightD31", contributions: 3 }, + { login: "kh0p", url: "https://github.com/kh0p", contributions: 3 }, + { login: "mallang0723", url: "https://github.com/mallang0723", contributions: 3 }, + { login: "myaiexp", url: "https://github.com/myaiexp", contributions: 3 }, { login: "Lochalan", url: "https://github.com/Lochalan", contributions: 2 }, { login: "Lamboozled", url: "https://github.com/Lamboozled", contributions: 2 }, { login: "ailthrim", url: "https://github.com/ailthrim", contributions: 2 }, @@ -52,6 +53,7 @@ const CONTRIBUTORS = [ { login: "Yasyasyasvil", url: "https://github.com/Yasyasyasvil", contributions: 1 }, { login: "vanta-jack", url: "https://github.com/vanta-jack", contributions: 1 }, { login: "pwildani", url: "https://github.com/pwildani", contributions: 1 }, + { login: "olegpro171", url: "https://github.com/olegpro171", contributions: 1 }, { login: "Lemon-will", url: "https://github.com/Lemon-will", contributions: 1 }, { login: "kevin-ho", url: "https://github.com/kevin-ho", contributions: 1 }, { login: "KeKKERUUU", url: "https://github.com/KeKKERUUU", contributions: 1 }, diff --git a/packages/client/src/components/chat/HomeProfessorMariChat.tsx b/packages/client/src/components/chat/HomeProfessorMariChat.tsx index 5a7f9fc87c..1ab3d647ab 100644 --- a/packages/client/src/components/chat/HomeProfessorMariChat.tsx +++ b/packages/client/src/components/chat/HomeProfessorMariChat.tsx @@ -76,6 +76,7 @@ import { useAgentStore } from "../../stores/agent.store"; import { useSidecarStore } from "../../stores/sidecar.store"; import { useUIStore } from "../../stores/ui.store"; import { showLocalMessageNotification, showNativeMessageNotification } from "../../lib/local-notifications"; +import { scrollProfessorMariTranscriptToBottom } from "../../lib/professor-mari-transcript-scroll"; import { applyInlineMarkdown, renderMarkdownBlocks } from "../../lib/markdown"; import { prepareImageAttachment } from "../../lib/chat-attachment-images"; import { cn } from "../../lib/utils"; @@ -1456,6 +1457,7 @@ function CompactMariMessage({ message, thinking }: { message: Message; thinking? if (message.role === "user") { return ( {localizeUi("ui.chat.compactmarimessage.you")}} > @@ -2089,6 +2091,7 @@ export function HomeProfessorMariChat({ const [selectedSkillId, setSelectedSkillId] = useState(null); const [skillDraft, setSkillDraft] = useState({ name: "", description: "", content: "" }); const [loadingHistory, setLoadingHistory] = useState(false); + const [loadedMessagesChatId, setLoadedMessagesChatId] = useState(null); const [sending, setSending] = useState(false); const [connectionMenuOpen, setConnectionMenuOpen] = useState(false); const [faqOpenItemId, setFaqOpenItemId] = useState(null); @@ -2100,7 +2103,10 @@ export function HomeProfessorMariChat({ const [floatingPosition, setFloatingPosition] = useState<{ x: number; y: number } | null>(null); const hasLoadedRef = useRef(false); const notifiedApprovalIdsRef = useRef>(new Set()); + const activeChatIdRef = useRef(null); + const messageLoadAbortRef = useRef(null); const scrollRef = useRef(null); + const transcriptScrollFrameRef = useRef(null); const floatingSurfaceRef = useRef(null); const floatingButtonRef = useRef(null); const floatingDragRef = useRef(null); @@ -2119,6 +2125,27 @@ export function HomeProfessorMariChat({ const pendingConnectionPersistRef = useRef(null); const connectionPersistInFlightRef = useRef(false); + const setActiveChatId = useCallback((id: string) => { + activeChatIdRef.current = id; + setChatId(id); + }, []); + + const setTranscriptScrollNode = useCallback( + (node: HTMLDivElement | null) => { + if (transcriptScrollFrameRef.current !== null) { + window.cancelAnimationFrame(transcriptScrollFrameRef.current); + transcriptScrollFrameRef.current = null; + } + scrollRef.current = node; + if (!node || loadingHistory || !chatId || loadedMessagesChatId !== chatId) return; + transcriptScrollFrameRef.current = window.requestAnimationFrame(() => { + transcriptScrollFrameRef.current = null; + if (scrollRef.current === node) scrollProfessorMariTranscriptToBottom(node); + }); + }, + [chatId, loadedMessagesChatId, loadingHistory], + ); + const resizeComposer = useCallback((textarea: HTMLTextAreaElement | null) => { if (!textarea) return; textarea.style.height = "auto"; @@ -2224,9 +2251,29 @@ export function HomeProfessorMariChat({ const loadMessages = useCallback( async (id: string, options: { clearSuggestions?: boolean } = {}) => { - const items = await api.get(`/chats/${id}/messages?limit=80`); - setMessages(items.map((message) => ({ ...message, extra: toMessageExtra(message) }))); - if (options.clearSuggestions) clearMariChips(); + messageLoadAbortRef.current?.abort(); + const controller = new AbortController(); + messageLoadAbortRef.current = controller; + try { + const items = await api.get(`/chats/${id}/messages?limit=80`, { + signal: controller.signal, + }); + if ( + controller.signal.aborted || + messageLoadAbortRef.current !== controller || + activeChatIdRef.current !== id + ) { + return; + } + setMessages(items.map((message) => ({ ...message, extra: toMessageExtra(message) }))); + setLoadedMessagesChatId(id); + if (options.clearSuggestions) clearMariChips(); + } catch (error) { + if (controller.signal.aborted) return; + throw error; + } finally { + if (messageLoadAbortRef.current === controller) messageLoadAbortRef.current = null; + } }, [clearMariChips], ); @@ -2266,11 +2313,11 @@ export function HomeProfessorMariChat({ if (connectionId) params.set("connectionId", connectionId); const query = params.toString(); const chat = await api.get(`/chats/internal/professor-mari${query ? `?${query}` : ""}`); - setChatId(chat.id); + setActiveChatId(chat.id); qc.setQueryData(chatKeys.detail(chat.id), chat); return chat; }, - [qc], + [qc, setActiveChatId], ); const refreshWorkspaceStatus = useCallback(async () => { @@ -2442,7 +2489,7 @@ export function HomeProfessorMariChat({ useEffect(() => { const node = scrollRef.current; if (!node) return; - node.scrollTop = node.scrollHeight; + scrollProfessorMariTranscriptToBottom(node); }, [messages, workspaceTimeline, workspaceActivity, visiblePendingChangeReviewKey, workspaceStatus?.error]); const displayMessages = useMemo(() => [createWelcomeMessage(chatId), ...messages], [chatId, messages]); @@ -2656,7 +2703,7 @@ export function HomeProfessorMariChat({ if (effectiveConnectionId) params.set("connectionId", effectiveConnectionId); const query = params.toString(); const chat = await api.post(`/chats/internal/professor-mari/restart${query ? `?${query}` : ""}`); - setChatId(chat.id); + setActiveChatId(chat.id); qc.setQueryData(chatKeys.detail(chat.id), chat); await api.post("/professor-mari/workspace/reset", { clearHistory: true }); setMessages([]); @@ -2672,7 +2719,7 @@ export function HomeProfessorMariChat({ if (chatHistoryOpen) await loadChatHistory(); await qc.invalidateQueries({ queryKey: chatKeys.messages(chat.id) }); toast.success(localizeUi("ui.chat.homeprofessormarichat.professorMariSPreviousChatWasSaved")); - }, [chatHistoryOpen, clearMariChips, effectiveConnectionId, loadChatHistory, qc, localizeUi]); + }, [chatHistoryOpen, clearMariChips, effectiveConnectionId, loadChatHistory, qc, setActiveChatId, localizeUi]); const guidedPlan = professorMariSuggestionsEnabled && mariPlanChatId === chatId ? mariPlan : null; const guidedPlanStep = guidedPlan ? (guidedPlan[mariPlanCursor] ?? null) : null; @@ -2935,7 +2982,7 @@ export function HomeProfessorMariChat({ } try { const chat = await api.post(`/chats/internal/professor-mari/chats/${id}/activate`); - setChatId(chat.id); + setActiveChatId(chat.id); qc.setQueryData(chatKeys.detail(chat.id), chat); setSkillsMenuOpen(false); setChatHistoryOpen(false); @@ -2952,7 +2999,7 @@ export function HomeProfessorMariChat({ }); } }, - [isBusy, loadChatHistory, loadMessages, qc, localizeUi], + [isBusy, loadChatHistory, loadMessages, qc, setActiveChatId, localizeUi], ); const handleRenameProfessorChat = useCallback( @@ -2984,7 +3031,7 @@ export function HomeProfessorMariChat({ await api.delete(`/chats/internal/professor-mari/chats/${id}`); if (id === chatId) { const chat = await ensureProfessorMariChat(effectiveConnectionId); - setChatId(chat.id); + setActiveChatId(chat.id); await loadMessages(chat.id); } await loadChatHistory(); @@ -2996,7 +3043,16 @@ export function HomeProfessorMariChat({ }); } }, - [chatHistory, chatId, effectiveConnectionId, ensureProfessorMariChat, loadChatHistory, loadMessages, localizeUi], + [ + chatHistory, + chatId, + effectiveConnectionId, + ensureProfessorMariChat, + loadChatHistory, + loadMessages, + setActiveChatId, + localizeUi, + ], ); const toggleProfessorChatSelection = useCallback((id: string) => { @@ -3033,7 +3089,7 @@ export function HomeProfessorMariChat({ setSelectedChatHistoryIds(new Set()); if (chatId && deletedIds.has(chatId)) { const chat = await ensureProfessorMariChat(effectiveConnectionId); - setChatId(chat.id); + setActiveChatId(chat.id); await loadMessages(chat.id); } await loadChatHistory(); @@ -3052,6 +3108,7 @@ export function HomeProfessorMariChat({ ensureProfessorMariChat, loadChatHistory, loadMessages, + setActiveChatId, localizeUi, selectedChatHistoryIds, ]); @@ -3280,7 +3337,11 @@ export function HomeProfessorMariChat({ const renderFloatingChatBody = () => ( <> -
+
{loadingHistory ? ( ) : ( @@ -3925,7 +3986,8 @@ export function HomeProfessorMariChat({
{loadingHistory ? ( diff --git a/packages/client/src/components/chat/PeekPromptModal.tsx b/packages/client/src/components/chat/PeekPromptModal.tsx index cd904af982..48fcd9a0b4 100644 --- a/packages/client/src/components/chat/PeekPromptModal.tsx +++ b/packages/client/src/components/chat/PeekPromptModal.tsx @@ -538,7 +538,7 @@ export function PeekPromptModal({ data, onClose }: PeekPromptModalProps) { return (
(null); const triggerRef = useRef(null); + const menuRef = useRef(null); const itemRefs = useRef>([]); const pendingFocusRef = useRef<"first" | "last" | null>(null); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, ready: false }); const isDisabled = disabled || actions.length === 0; const singleAction = actions.length === 1 ? actions[0] : null; const visibleActions = actions.slice().reverse(); + const updateMenuPosition = useCallback(() => { + const trigger = triggerRef.current; + const menu = menuRef.current; + if (!trigger || !menu) return; + + const triggerRect = trigger.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + const viewportPadding = 8; + const gap = 8; + const left = Math.max( + viewportPadding, + Math.min( + triggerRect.left + triggerRect.width / 2 - menuRect.width / 2, + window.innerWidth - viewportPadding - menuRect.width, + ), + ); + const preferredTop = triggerRect.top - gap - menuRect.height; + const maxTop = window.innerHeight - viewportPadding - menuRect.height; + const top = + preferredTop >= viewportPadding + ? preferredTop + : Math.max(viewportPadding, Math.min(triggerRect.bottom + gap, maxTop)); + + setMenuPosition((current) => + current.ready && current.top === top && current.left === left ? current : { top, left, ready: true }, + ); + }, []); + const focusMenuItem = useCallback((target: "first" | "last" | { fromIndex: number; delta: 1 | -1 }) => { const focusable = itemRefs.current .map((button, index) => ({ button, index })) @@ -61,7 +95,8 @@ export function QuickReplyMenu({ actions, disabled = false }: QuickReplyMenuProp useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) { setOpen(false); } }; @@ -76,6 +111,27 @@ export function QuickReplyMenu({ actions, disabled = false }: QuickReplyMenuProp }; }, [open]); + useLayoutEffect(() => { + if (!open) return; + updateMenuPosition(); + let frame = 0; + const scrollListenerOptions = { capture: true, passive: true } as const; + const scheduleUpdate = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + updateMenuPosition(); + }); + }; + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("scroll", scheduleUpdate, scrollListenerOptions); + return () => { + if (frame) cancelAnimationFrame(frame); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("scroll", scheduleUpdate, scrollListenerOptions); + }; + }, [open, updateMenuPosition, visibleActions.length]); + useEffect(() => { if (!open || !pendingFocusRef.current) return; const target = pendingFocusRef.current; @@ -148,7 +204,10 @@ export function QuickReplyMenu({ actions, disabled = false }: QuickReplyMenuProp type="button" onClick={() => void handleSelect(singleAction)} disabled={singleDisabled} - aria-label={localizeUi("ui.chat.quickreplymenu.value1Value2", { value1: singleAction.label, value2: singleAction.description })} + aria-label={localizeUi("ui.chat.quickreplymenu.value1Value2", { + value1: singleAction.label, + value2: singleAction.description, + })} className={cn( "flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-all duration-200 focus-visible:ring-2 focus-visible:ring-foreground/20 sm:h-8 sm:w-8", !singleDisabled @@ -173,6 +232,7 @@ export function QuickReplyMenu({ actions, disabled = false }: QuickReplyMenuProp aria-label={localizeUi("settings.quickReplies.label")} aria-haspopup="menu" aria-expanded={open} + aria-controls={open ? menuId : undefined} className={cn( "flex h-11 w-11 items-center justify-center rounded-full transition-all duration-200 sm:h-8 sm:w-8", open @@ -186,81 +246,96 @@ export function QuickReplyMenu({ actions, disabled = false }: QuickReplyMenuProp - - {open && ( -
- + {open && ( +
- {visibleActions.map((action, index) => ( - { - itemRefs.current[index] = element; - }} - key={action.id} - type="button" - role="menuitem" - disabled={action.disabled} - onClick={() => void handleSelect(action)} - onKeyDown={(event) => handleItemKeyDown(event, index)} - aria-label={localizeUi("ui.chat.quickreplymenu.value1Value2", { value1: action.label, value2: action.description })} - className={cn( - "group relative flex h-11 w-11 items-center justify-center rounded-full border shadow-xl outline-none transition-colors focus-visible:ring-2 focus-visible:ring-foreground/20 sm:h-10 sm:w-10", - action.disabled - ? "cursor-not-allowed border-foreground/10 bg-[var(--card)]/75 opacity-45" - : "border-foreground/20 bg-[var(--card)] text-foreground/55 hover:bg-foreground/10 hover:text-foreground/80 active:scale-95", - )} - title={formatActionTitle(action)} - variants={{ - open: { - opacity: 1, - scale: 1, - y: 0, - filter: "blur(0px)", - transition: { - type: "spring", - stiffness: 520, - damping: 28, - mass: 0.75, - delay: index * 0.015, - }, - }, - closed: { - opacity: 0, - scale: 0.55, - y: 36 + index * 10, - filter: "blur(2px)", - transition: { duration: 0.12, ease: "easeOut" }, - }, - }} - > - + {visibleActions.map((action, index) => ( + { + itemRefs.current[index] = element; + }} + key={action.id} + type="button" + role="menuitem" + disabled={action.disabled} + onClick={() => void handleSelect(action)} + onKeyDown={(event) => handleItemKeyDown(event, index)} + aria-label={localizeUi("ui.chat.quickreplymenu.value1Value2", { + value1: action.label, + value2: action.description, + })} className={cn( - "flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-1 transition-colors", + "group relative flex h-11 w-11 items-center justify-center rounded-full border shadow-xl outline-none transition-colors focus-visible:ring-2 focus-visible:ring-foreground/20 sm:h-10 sm:w-10", action.disabled - ? "bg-foreground/5 text-foreground/40 ring-transparent" - : "bg-foreground/10 ring-foreground/15 group-hover:bg-transparent group-hover:ring-transparent", + ? "cursor-not-allowed border-foreground/10 bg-[var(--card)]/75 opacity-45" + : "border-foreground/20 bg-[var(--card)] text-foreground/55 hover:bg-foreground/10 hover:text-foreground/80 active:scale-95", )} + title={formatActionTitle(action)} + variants={{ + open: { + opacity: 1, + scale: 1, + y: 0, + filter: "blur(0px)", + transition: { + type: "spring", + stiffness: 520, + damping: 28, + mass: 0.75, + delay: index * 0.015, + }, + }, + closed: { + opacity: 0, + scale: 0.55, + y: 36 + index * 10, + filter: "blur(2px)", + transition: { duration: 0.12, ease: "easeOut" }, + }, + }} > - {action.icon} - - - ))} - -
- )} - + + {action.icon} + + + ))} +
+
+ )} +
, + document.body, + )}
); } diff --git a/packages/client/src/components/chat/SpriteOverlay.tsx b/packages/client/src/components/chat/SpriteOverlay.tsx index a515b9606c..23fac3aafd 100644 --- a/packages/client/src/components/chat/SpriteOverlay.tsx +++ b/packages/client/src/components/chat/SpriteOverlay.tsx @@ -5,7 +5,7 @@ import { useState, useEffect, useMemo, useRef, type CSSProperties, type RefObject } from "react"; import { motion, AnimatePresence, type TargetAndTransition } from "framer-motion"; import { Check } from "lucide-react"; -import type { SpritePlacement, SpriteSide } from "@marinara-engine/shared"; +import type { SpriteCharacterVisualSettings, SpritePlacement, SpriteSide } from "@marinara-engine/shared"; import { useCharacterSprites, type SpriteInfo } from "../../hooks/use-characters"; import { normalizeSpriteExpressionKey, resolveSpriteExpression } from "../../lib/sprite-expression-match"; import { @@ -39,6 +39,8 @@ interface SpriteOverlayProps { spriteExpressions?: Record; /** Saved freeform placements per character (from chat metadata) */ spritePlacements?: SpritePlacementMap; + /** Sparse display overrides keyed by character or persona ID. */ + characterVisualSettings?: Record; /** Whether the overlay is currently in drag-to-arrange mode */ editing?: boolean; /** Called when a sprite is moved (to persist it) */ @@ -72,6 +74,8 @@ interface VisibleSpriteEntry { renderMode: SpriteRenderMode; placement: SpritePlacement; zIndex: number; + spriteScale: number; + spriteOpacity: number; } function getSpritePlacementKey(characterId: string, renderMode: SpriteRenderMode) { @@ -132,6 +136,7 @@ export function SpriteOverlay({ spriteDisplayModes, spriteExpressions, spritePlacements, + characterVisualSettings, editing = false, onPlacementChange, onFinishPlacement, @@ -260,41 +265,59 @@ export function SpriteOverlay({ if (resolvedSpriteDisplayModes.includes("expressions")) modes.push("expressions"); return modes; }, [fullBodyOnly, resolvedSpriteDisplayModes]); + const resolvedExpressionSpriteScale = expressionSpriteScale ?? spriteScale; + const resolvedFullBodySpriteScale = fullBodySpriteScale ?? spriteScale; + const resolvedExpressionSpriteOpacity = expressionSpriteOpacity ?? spriteOpacity; + const resolvedFullBodySpriteOpacity = fullBodySpriteOpacity ?? spriteOpacity; const visibleSpriteEntries = useMemo(() => { const entries: VisibleSpriteEntry[] = []; const hasPairedSprites = renderModes.length > 1; for (const [index, charId] of characterIds.entries()) { + const characterSettings = characterVisualSettings?.[charId]; + const characterSide = characterSettings?.spritePosition ?? side; const basePlacement = clampSpritePlacement( - spritePlacements?.[charId] ?? getDefaultSpritePlacement(index, characterIds.length, side), + spritePlacements?.[charId] ?? getDefaultSpritePlacement(index, characterIds.length, characterSide), ); for (const [modeIndex, renderMode] of renderModes.entries()) { const placementKey = getSpritePlacementKey(charId, renderMode); const fallbackPlacement = hasPairedSprites - ? offsetPairedSpritePlacement(basePlacement, renderMode, side) + ? offsetPairedSpritePlacement(basePlacement, renderMode, characterSide) : basePlacement; + const isFullBody = renderMode === "full-body"; entries.push({ characterId: charId, placementKey, renderMode, placement: clampSpritePlacement(spritePlacements?.[placementKey] ?? fallbackPlacement), zIndex: 10 + index * 3 + modeIndex, + spriteScale: isFullBody + ? (characterSettings?.fullBodySpriteScale ?? resolvedFullBodySpriteScale) + : (characterSettings?.expressionSpriteScale ?? resolvedExpressionSpriteScale), + spriteOpacity: isFullBody + ? (characterSettings?.fullBodySpriteOpacity ?? resolvedFullBodySpriteOpacity) + : (characterSettings?.expressionSpriteOpacity ?? resolvedExpressionSpriteOpacity), }); } } return entries; - }, [characterIds, renderModes, side, spritePlacements]); + }, [ + characterIds, + characterVisualSettings, + renderModes, + resolvedExpressionSpriteOpacity, + resolvedExpressionSpriteScale, + resolvedFullBodySpriteOpacity, + resolvedFullBodySpriteScale, + side, + spritePlacements, + ]); if (visibleSpriteEntries.length === 0) return null; const stageZIndexClass = editing ? "z-[35]" : fullBodyOnly ? "z-[5]" : "z-[5] md:z-[15]"; - const resolvedExpressionSpriteScale = expressionSpriteScale ?? spriteScale; - const resolvedFullBodySpriteScale = fullBodySpriteScale ?? spriteScale; - const resolvedExpressionSpriteOpacity = expressionSpriteOpacity ?? spriteOpacity; - const resolvedFullBodySpriteOpacity = fullBodySpriteOpacity ?? spriteOpacity; - return (
{visibleSpriteEntries.map((entry) => ( @@ -314,15 +337,15 @@ export function SpriteOverlay({ onFinishPlacement={onFinishPlacement} fullBodyOnly={fullBodyOnly} spriteDisplayModes={[entry.renderMode]} - spriteScale={entry.renderMode === "full-body" ? resolvedFullBodySpriteScale : resolvedExpressionSpriteScale} - spriteOpacity={ - entry.renderMode === "full-body" ? resolvedFullBodySpriteOpacity : resolvedExpressionSpriteOpacity - } + spriteScale={entry.spriteScale} + spriteOpacity={entry.spriteOpacity} /> ))} {editing && ( -
{localizeUi("ui.chat.spriteoverlay.dragSpritesToRepositionThemUseTheCheckAbove")}
+
+ {localizeUi("ui.chat.spriteoverlay.dragSpritesToRepositionThemUseTheCheckAbove")} +
)}
); @@ -507,7 +530,9 @@ function CharacterSprite({ useEffect(() => { if (!isDragging) { - setCurrentPlacement(clampSpritePlacement(placement)); + const nextPlacement = clampSpritePlacement(placement); + currentPlacementRef.current = nextPlacement; + setCurrentPlacement(nextPlacement); } }, [isDragging, placement]); @@ -535,7 +560,9 @@ function CharacterSprite({ const dx = ((event.clientX - dragState.startX) / Math.max(stage.clientWidth, 1)) * 100; const dy = ((event.clientY - dragState.startY) / Math.max(stage.clientHeight, 1)) * 100; - setCurrentPlacement(clampSpritePlacement({ x: dragState.origin.x + dx, y: dragState.origin.y + dy })); + const nextPlacement = clampSpritePlacement({ x: dragState.origin.x + dx, y: dragState.origin.y + dy }); + currentPlacementRef.current = nextPlacement; + setCurrentPlacement(nextPlacement); }; const finishDrag = (event: PointerEvent) => { @@ -605,7 +632,9 @@ function CharacterSprite({
- {isDragging ?localizeUi("ui.chat.charactersprite.releaseToSave") :localizeUi("ui.chat.charactersprite.dragToMove")} + {isDragging + ? localizeUi("ui.chat.charactersprite.releaseToSave") + : localizeUi("ui.chat.charactersprite.dragToMove")}
)} @@ -615,7 +644,13 @@ function CharacterSprite({ & { defaultForAgents?: boolean | string | null; }; @@ -119,6 +125,7 @@ const SUMMARY_TOKEN_WARNING_THRESHOLD = 1800; const SUMMARY_HEADING_PATTERN = /^(?:#{1,6}\s*)?(?:\*\*)?([^:\n]{3,80})(?:\*\*)?:\s*$/; const SUMMARY_BULLET_PATTERN = /^[-*•]\s+/; const MOBILE_SUMMARY_PADDING = 8; +const DESKTOP_SUMMARY_WIDTH = 576; function clampSummaryMaxTokens(value: unknown): number { const parsed = Number(value); @@ -148,6 +155,22 @@ function getMobileSummaryFrame(anchor: SummaryPopoverAnchor | null | undefined) return { top, left, width, maxHeight }; } +function getDesktopSummaryFrame(anchor: SummaryPopoverAnchor | null | undefined) { + if (typeof window === "undefined") return null; + const width = Math.min(DESKTOP_SUMMARY_WIDTH, window.innerWidth - MOBILE_SUMMARY_PADDING * 2); + const rightEdge = anchor?.right ?? window.innerWidth - MOBILE_SUMMARY_PADDING; + const left = Math.max( + MOBILE_SUMMARY_PADDING, + Math.min(rightEdge - width, window.innerWidth - width - MOBILE_SUMMARY_PADDING), + ); + const top = Math.max(MOBILE_SUMMARY_PADDING, (anchor?.bottom ?? 52) + 4); + const maxHeight = Math.max( + 240, + Math.min(736, window.innerHeight - top - MOBILE_SUMMARY_PADDING), + ); + return { top, left, width, maxHeight }; +} + interface SummarySection { title: string | null; lines: string[]; @@ -312,10 +335,14 @@ export function SummaryPopover({ const [draftEntry, setDraftEntry] = useState(null); const [templateEditorOpen, setTemplateEditorOpen] = useState(false); const [templateSelectOpen, setTemplateSelectOpen] = useState(false); + const [summaryPromptView, setSummaryPromptView] = useState("summary"); + const [combinePromptEditorOpen, setCombinePromptEditorOpen] = useState(false); const [showInactiveSummaries, setShowInactiveSummaries] = useState(false); const [editingTemplateId, setEditingTemplateId] = useState(null); const [templateNameDraft, setTemplateNameDraft] = useState(""); const [templatePromptDraft, setTemplatePromptDraft] = useState(""); + const [combinePromptDraft, setCombinePromptDraft] = useState(DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT); + const [promptSettingsSaveLocked, setPromptSettingsSaveLocked] = useState(false); const summaryPopoverSettings = useUIStore((s) => s.summaryPopoverSettings); const setSummaryPopoverSettings = useUIStore((s) => s.setSummaryPopoverSettings); const persistedContextSize = summaryPopoverSettings.contextSize ?? contextSize; @@ -335,11 +362,17 @@ export function SummaryPopover({ const rangeInputFocused = useRef(false); const automaticIntervalFocused = useRef(false); const summaryMaxTokensFocused = useRef(false); + const combinePromptFocused = useRef(false); + const combinePromptDraftRef = useRef(DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT); + const combinePromptSaveRef = useRef<{ prompt: string; promise: Promise } | null>(null); + const promptSettingsSaveQueueRef = useRef>(Promise.resolve()); + const promptSettingsSaveLockedRef = useRef(false); const summaryMaxTokensSaveRef = useRef<{ key: string; promise: Promise } | null>(null); const generateSummary = useGenerateSummary(); const updateMeta = useUpdateChatMetadata(); const globalPromptSettings = useChatSummaryPromptSettings(); const updateGlobalPromptSettings = useUpdateChatSummaryPromptSettings(); + const queryClient = useQueryClient(); const { data: connectionsData } = useConnections(); const updateSummaryEntry = useUpdateSummaryEntry(); const deleteSummaryEntry = useDeleteSummaryEntry(); @@ -373,38 +406,6 @@ export function SummaryPopover({ return event.target instanceof Node && panel.contains(event.target); }, []); - // Close on outside interaction — defer by one frame so the synthesised - // pointer event from the tap that *opened* the popover doesn't immediately - // close it on touch devices (Android / iPadOS). - useEffect(() => { - const handler = (e: globalThis.PointerEvent) => { - if (eventTargetsPanel(e)) return; - if (isChatToolbarPanelTrigger(e.target, "summary")) return; - const activeElement = document.activeElement; - if (activeElement instanceof Node && panelRef.current?.contains(activeElement)) return; - if (rangeInputFocused.current || sizeInputFocused.current || automaticIntervalFocused.current) return; - if (panelRef.current) { - onClose(); - } - }; - const raf = requestAnimationFrame(() => { - document.addEventListener("pointerdown", handler); - }); - return () => { - cancelAnimationFrame(raf); - document.removeEventListener("pointerdown", handler); - }; - }, [eventTargetsPanel, onClose]); - - // Close on Escape - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [onClose]); - // Sync local size when the persisted/default context size changes externally. useEffect(() => { if (!sizeInputFocused.current) { @@ -457,6 +458,7 @@ export function SummaryPopover({ }); const globalTemplates = globalPromptSettings.data?.templates ?? []; const globalActivePromptTemplateId = globalPromptSettings.data?.activeTemplateId ?? null; + const globalCombinePrompt = globalPromptSettings.data?.combinePrompt ?? DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT; const hasGlobalPromptSettings = globalPromptSettings.data?.hasPersistedSettings === true; const sourcePromptTemplates = !globalPromptSettingsReady ? [] @@ -486,6 +488,31 @@ export function SummaryPopover({ const promptTemplateSummary = isLongTermMemoryPromptSelected ? localizeUi("chat.summary.template.longTermMemory") : activePromptTemplate?.name ?? localizeUi("ui.chat.summarypopover.builtInDefault"); + const activeSummaryPrompt = isLongTermMemoryPromptSelected + ? DEFAULT_LONG_TERM_MEMORY_CHAT_SUMMARY_PROMPT + : activePromptTemplate?.prompt ?? DEFAULT_CHAT_SUMMARY_PROMPT; + const readCurrentPromptSettings = useCallback(() => { + const cached = queryClient.getQueryData( + chatSummaryPromptKeys.settings, + ); + if (cached?.hasPersistedSettings) { + return { + templates: cached.templates, + activeTemplateId: cached.activeTemplateId?.trim() || null, + }; + } + return { + templates: cleanedPromptTemplates, + activeTemplateId: normalizedActivePromptTemplateId, + }; + }, [cleanedPromptTemplates, normalizedActivePromptTemplateId, queryClient]); + + useEffect(() => { + if (!combinePromptFocused.current) { + combinePromptDraftRef.current = globalCombinePrompt; + setCombinePromptDraft(globalCombinePrompt); + } + }, [globalCombinePrompt]); const isEditingExistingTemplate = !!editingTemplateId; const hasTemplateDraft = templateNameDraft.trim().length > 0 && templatePromptDraft.trim().length > 0; const displayEntries = useMemo( @@ -882,29 +909,123 @@ export function SummaryPopover({ ); const persistPromptTemplates = useCallback( - async (templates: ChatSummaryPromptTemplate[], activeId: string | null): Promise => { - if (!globalPromptSettingsReady) return false; - try { - await updateGlobalPromptSettings.mutateAsync({ - templates, - activeTemplateId: activeId, - }); - return true; - } catch { - toast.error(localizeUi("ui.chat.summarypopover.couldNotSaveGlobalSummaryPromptSettings")); - return false; - } + async ( + templates: ChatSummaryPromptTemplate[], + activeId: string | null, + combinePrompt = combinePromptDraft, + ): Promise => { + if (!globalPromptSettingsReady || promptSettingsSaveLockedRef.current) return false; + promptSettingsSaveLockedRef.current = true; + setPromptSettingsSaveLocked(true); + const normalizedCombinePrompt = + combinePrompt.trim().slice(0, CHAT_SUMMARY_PROMPT_MAX_LENGTH) || + DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT; + const queuedSave = promptSettingsSaveQueueRef.current.then(async () => { + try { + await updateGlobalPromptSettings.mutateAsync({ + templates, + activeTemplateId: activeId, + combinePrompt: normalizedCombinePrompt, + }); + return true; + } catch { + toast.error(localizeUi("ui.chat.summarypopover.couldNotSaveGlobalSummaryPromptSettings")); + return false; + } finally { + promptSettingsSaveLockedRef.current = false; + setPromptSettingsSaveLocked(false); + } + }); + promptSettingsSaveQueueRef.current = queuedSave.then(() => undefined); + return queuedSave; }, - [globalPromptSettingsReady, updateGlobalPromptSettings, localizeUi], + [combinePromptDraft, globalPromptSettingsReady, updateGlobalPromptSettings, localizeUi], ); + const commitCombinePromptDraft = useCallback(async (): Promise => { + combinePromptFocused.current = false; + let nextPrompt = + combinePromptDraftRef.current.trim().slice(0, CHAT_SUMMARY_PROMPT_MAX_LENGTH) || + DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT; + const activeSave = combinePromptSaveRef.current; + if (activeSave?.prompt === nextPrompt) return activeSave.promise; + if (promptSettingsSaveLockedRef.current) { + await promptSettingsSaveQueueRef.current; + nextPrompt = + combinePromptDraftRef.current.trim().slice(0, CHAT_SUMMARY_PROMPT_MAX_LENGTH) || + DEFAULT_CHAT_SUMMARY_COMBINE_PROMPT; + } + combinePromptDraftRef.current = nextPrompt; + setCombinePromptDraft(nextPrompt); + + const pendingSave = combinePromptSaveRef.current; + if (pendingSave?.prompt === nextPrompt) return pendingSave.promise; + if (!pendingSave && nextPrompt === globalCombinePrompt) return true; + + const currentSettings = readCurrentPromptSettings(); + const promise = persistPromptTemplates(currentSettings.templates, currentSettings.activeTemplateId, nextPrompt); + combinePromptSaveRef.current = { prompt: nextPrompt, promise }; + try { + return await promise; + } finally { + if (combinePromptSaveRef.current?.promise === promise) { + combinePromptSaveRef.current = null; + } + } + }, [ + globalCombinePrompt, + persistPromptTemplates, + readCurrentPromptSettings, + ]); + + const handleCombinePromptBlur = useCallback(async () => { + await commitCombinePromptDraft(); + }, [commitCombinePromptDraft]); + + const handleClose = useCallback(async () => { + if (await commitCombinePromptDraft()) onClose(); + }, [commitCombinePromptDraft, onClose]); + + // Close on outside interaction — defer by one frame so the synthesised + // pointer event from the tap that *opened* the popover doesn't immediately + // close it on touch devices (Android / iPadOS). + useEffect(() => { + const handler = (e: globalThis.PointerEvent) => { + if (eventTargetsPanel(e)) return; + if (isChatToolbarPanelTrigger(e.target, "summary")) return; + const activeElement = document.activeElement; + if (activeElement instanceof Node && panelRef.current?.contains(activeElement)) return; + if (rangeInputFocused.current || sizeInputFocused.current || automaticIntervalFocused.current) return; + if (panelRef.current) { + void handleClose(); + } + }; + const raf = requestAnimationFrame(() => { + document.addEventListener("pointerdown", handler); + }); + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("pointerdown", handler); + }; + }, [eventTargetsPanel, handleClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") void handleClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [handleClose]); + const handleSelectPromptTemplate = useCallback( async (templateId: string | null) => { - const saved = await persistPromptTemplates(cleanedPromptTemplates, templateId); + const currentSettings = readCurrentPromptSettings(); + const saved = await persistPromptTemplates(currentSettings.templates, templateId); if (!saved) return; setTemplateSelectOpen(false); }, - [cleanedPromptTemplates, persistPromptTemplates], + [persistPromptTemplates, readCurrentPromptSettings], ); const resetTemplateDraft = useCallback(() => { @@ -958,16 +1079,41 @@ export function SummaryPopover({ handleDuplicatePromptTemplate(null); }, [activePromptTemplate, handleDuplicatePromptTemplate, handleEditPromptTemplate]); + const handleEditVisiblePrompt = useCallback(() => { + if (summaryPromptView === "combine") { + if (!combinePromptEditorOpen) setCombinePromptEditorOpen(true); + return; + } + if (!templateEditorOpen) handleEditActivePrompt(); + }, [combinePromptEditorOpen, handleEditActivePrompt, summaryPromptView, templateEditorOpen]); + + const visiblePromptEditorOpen = summaryPromptView === "combine" ? combinePromptEditorOpen : templateEditorOpen; + const handleToggleVisiblePromptEditor = useCallback(async () => { + if (!visiblePromptEditorOpen) { + setTemplateSelectOpen(false); + handleEditVisiblePrompt(); + return; + } + if (summaryPromptView === "combine") { + const saved = await commitCombinePromptDraft(); + if (saved) setCombinePromptEditorOpen(false); + return; + } + setTemplateSelectOpen(false); + setTemplateEditorOpen(false); + }, [commitCombinePromptDraft, handleEditVisiblePrompt, summaryPromptView, visiblePromptEditorOpen]); + const handleSavePromptTemplate = useCallback(async () => { if (!hasTemplateDraft) return; const trimmedName = templateNameDraft.trim().slice(0, 80); const trimmedPrompt = templatePromptDraft.trim(); + const currentSettings = readCurrentPromptSettings(); const nextTemplates = isEditingExistingTemplate - ? cleanedPromptTemplates.map((template) => + ? currentSettings.templates.map((template) => template.id === editingTemplateId ? { ...template, name: trimmedName, prompt: trimmedPrompt } : template, ) : [ - ...cleanedPromptTemplates, + ...currentSettings.templates, { id: generateClientId(), name: trimmedName, @@ -975,18 +1121,17 @@ export function SummaryPopover({ }, ]; const nextActiveId = isEditingExistingTemplate - ? normalizedActivePromptTemplateId + ? currentSettings.activeTemplateId : nextTemplates[nextTemplates.length - 1]!.id; const saved = await persistPromptTemplates(nextTemplates, nextActiveId ?? null); if (!saved) return; resetTemplateDraft(); }, [ - normalizedActivePromptTemplateId, - cleanedPromptTemplates, editingTemplateId, hasTemplateDraft, isEditingExistingTemplate, persistPromptTemplates, + readCurrentPromptSettings, resetTemplateDraft, templateNameDraft, templatePromptDraft, @@ -1006,10 +1151,11 @@ export function SummaryPopover({ tone: "destructive", }); if (!confirmed) return; - const nextTemplates = cleanedPromptTemplates.filter((template) => template.id !== templateId); + const currentSettings = readCurrentPromptSettings(); + const nextTemplates = currentSettings.templates.filter((template) => template.id !== templateId); const saved = await persistPromptTemplates( nextTemplates, - normalizedActivePromptTemplateId === templateId ? null : normalizedActivePromptTemplateId, + currentSettings.activeTemplateId === templateId ? null : currentSettings.activeTemplateId, ); if (!saved) return; if (editingTemplateId === templateId) resetTemplateDraft(); @@ -1018,15 +1164,16 @@ export function SummaryPopover({ cleanedPromptTemplates, editingTemplateId, persistPromptTemplates, + readCurrentPromptSettings, resetTemplateDraft, - normalizedActivePromptTemplateId, localizeUi, + localizeUi, ], ); const isGenerating = generateSummary.isPending; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; - const mobileFrame = isMobile ? getMobileSummaryFrame(anchor) : null; + const panelFrame = isMobile ? getMobileSummaryFrame(anchor) : getDesktopSummaryFrame(anchor); const handlePanelMouseDown = useCallback((event: ReactMouseEvent) => { event.stopPropagation(); @@ -1041,25 +1188,16 @@ export function SummaryPopover({ data-chat-floating-panel onMouseDown={handlePanelMouseDown} onPointerDown={handlePanelPointerDown} - className={cn(isMobile ? "fixed z-[9999]" : "absolute right-0 top-full z-[100] mt-1")} - style={ - mobileFrame - ? { - top: mobileFrame.top, - left: mobileFrame.left, - width: mobileFrame.width, - } - : undefined - } + className="fixed z-[9999]" + style={panelFrame ? { top: panelFrame.top, left: panelFrame.left, width: panelFrame.width } : undefined} >
{/* Header */}
@@ -1080,7 +1218,7 @@ export function SummaryPopover({
)} -
+
{backfillState.status === "running" && backfillState.chatId === chatId ? (
-
- - -
+ +
+ +
+ +
-
+ + {summaryPromptView === "summary" ? ( +
+
)}
- +
+ +
+ {activeSummaryPrompt}
{templateEditorOpen && ( @@ -1364,7 +1528,8 @@ export function SummaryPopover({ active={!normalizedActivePromptTemplateId} name={localizeUi("ui.chat.summarypopover.builtInDefault")} detail={localizeUi("chat.summary.template.appDefault")} - onSelect={() => void persistPromptTemplates(cleanedPromptTemplates, null)} + disabled={promptSettingsSaveLocked} + onSelect={() => void handleSelectPromptTemplate(null)} onCopy={() => handleDuplicatePromptTemplate(null, DEFAULT_CHAT_SUMMARY_PROMPT)} /> {longTermMemorySummaryPromptAvailable && ( @@ -1372,9 +1537,8 @@ export function SummaryPopover({ active={isLongTermMemoryPromptSelected} name={localizeUi("chat.summary.template.longTermMemory")} detail={localizeUi("chat.summary.template.appDefault")} - onSelect={() => - void persistPromptTemplates(cleanedPromptTemplates, LONG_TERM_MEMORY_CHAT_SUMMARY_PROMPT_ID) - } + disabled={promptSettingsSaveLocked} + onSelect={() => void handleSelectPromptTemplate(LONG_TERM_MEMORY_CHAT_SUMMARY_PROMPT_ID)} onCopy={() => handleDuplicatePromptTemplate(null, DEFAULT_LONG_TERM_MEMORY_CHAT_SUMMARY_PROMPT) } @@ -1388,7 +1552,8 @@ export function SummaryPopover({ detail={localizeUi("chat.summary.template.tokenEstimate", { count: Math.ceil(template.prompt.length / 4), })} - onSelect={() => void persistPromptTemplates(cleanedPromptTemplates, template.id)} + disabled={promptSettingsSaveLocked} + onSelect={() => void handleSelectPromptTemplate(template.id)} onCopy={() => handleDuplicatePromptTemplate(template)} onEdit={() => handleEditPromptTemplate(template)} onDelete={() => void handleDeletePromptTemplate(template.id)} @@ -1399,39 +1564,50 @@ export function SummaryPopover({ {(templateNameDraft || templatePromptDraft) && (
+
+

+ {localizeUi("ui.chat.summarypopover.currentChatSummaryPrompt")} +

+
+ {activeSummaryPrompt} +
+
setTemplateNameDraft(event.target.value)} + disabled={promptSettingsSaveLocked} maxLength={80} placeholder={localizeUi("ui.chat.summarypopover.templateName")} - className="w-full rounded-md bg-[var(--card)] px-2 py-1 text-[0.6875rem] font-semibold text-[var(--foreground)] ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" + className="w-full rounded-md bg-[var(--card)] px-2 py-1 text-[0.6875rem] font-semibold text-[var(--foreground)] ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50" />