mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-16 05:19:50 +08:00
@openmaic/generation@0.3.8
52
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1d3f62d80b |
refactor(media): one client-side pool commit primitive; keep refused narration instead of re-billing it (#1523)
Extracts commitToPool, the single client-side sequence for storing bytes in the asset pool, writing the allocated id back, and mirroring locally; routes the media pass, narration adoption and fresh TTS through it. A store-full refusal during TTS now retains the already-billed clip so the next load adopts it with zero provider calls. Closes #1467. |
||
|
|
c605e9e6ef |
feat(persistence): turn on the server-owned asset lifecycle and release assets on course deletion (#1007 amendment, part 2) (#1473)
App wiring for the @openmaic/storage 0.31.0 lifecycle: reference tracking and document references are paired unconditionally and declared at startup, course deletion withdraws references inside the tombstone transaction, ASSET_PENDING_TTL_MS configures the pending window, and the dead client-side reclamation code is removed. |
||
|
|
4685cad341 |
fix(tts): prepare discussion segments during playback (#1435)
* fix(tts): prepare discussion segments during playback * fix(tts): preserve active playback when muting lookahead --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
85ef52d599 |
feat(media): write generated media through the asset pool under server-backed persistence (#1392)
* feat(media): store generated media in the asset pool when persistence is server-backed With server-backed persistence the document is durable and shared, but generated media stayed in the producing browser: the document kept its gen_img_* / gen_vid_* placeholder and narration kept a browser-derived audio id. Every new browser that opened such a course re-ran generation for every slide, and it never converged, because the address of the generated bytes was never written back into the document. Under server-backed persistence only, the classic generation chain now stores bytes in the asset pool first and writes the id the pool allocated into the document. - The client bootstrap configures the asset seam alongside the document and runtime seams: an HttpAssetStore over the persistence endpoint carrying the same credentials the document store carries, marked server-backed. The seam preflight now covers all three, so a failure still cannot half-configure persistence. - Image, video and TTS generation commit in one fixed order: provider, pool, document, local cache, task. A reference reaches the document only after put returned an id, so a document can never name bytes that were not stored. A failure before the write-back leaves the placeholder with the provider called exactly once; the retry happens on the next owner load. - The write-back is a per-slot rewrite through mutateDocument, which re-reads the current document under the per-stage lock, so it cannot clobber a newer scene. The open course is refreshed with the same rewrite without being marked dirty. - "Has this already been generated?" is answered by the document (the slide exists and no longer holds the placeholder) instead of by this browser's task table. - The classroom's resume effect fails closed on ownership: only a resolved owner starts generation, so a viewer opening a shared course spends nothing. - The local media and audio tables become a per-tab cache. A failed cache write costs a re-download, never the media. Browser-only mode is unchanged: every new call site sits behind the server-backed gate, the local tables stay authoritative there, and placeholders stay in the document. Rendering and export needed no changes. HttpAssetStore.resolve mints an object URL exactly as the browser store does, and the export byte resolver was already pool-first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(classroom): make the generation owner gate a three-outcome rule and apply it everywhere The gate refused everything but a resolved owner, which read a sidecar that answered "no ownership fact exists for this course" as a reason to block. That is the answer a deployment without the sidecar's server-side prerequisites gives for every course, and the answer a course with no ownership record gives: in both, there is nobody the operator's budget needs protecting from, and refusing strands the course's own author behind a question that can never be answered. Ownership is now four states over the sidecar's three outcomes. A definite answer splits into owner and not-owner. An absent record is its own answer, ownerless, and generation proceeds — the behaviour such a deployment had before the gate existed. Only the absence of an answer, a transport failure or a load that has not asked yet, stays unresolved and fails closed: "we could not ask" must never be read as "nobody owns this". One mapper turns a sidecar result into that state, and one predicate decides on it. The workbench classroom pane runs the same resume effect and had no ownership input at all, so a viewer opening a shared course there could still spend the budget. It now asks the sidecar once per course, in parallel with its load and feeding only the generation gate, so its read-only and edit behaviour is unchanged. The shared progressive-load policy carries the gate for it, with both new inputs required rather than defaulted so a future caller cannot omit them into an open budget. Its stale comment claiming ownership could not be expressed here is corrected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): make the write-back survive autosave, arrive before the scene does, and never leak Independent reviews of the write-back found three ways a durable document could still end up naming a placeholder, and two ways the gate that protects the operator's budget could be walked around. An autosave round captures the store synchronously and writes that capture, so a round already in flight when a rewrite landed wrote the placeholder straight back over the allocated id, and nothing marked the store dirty again to correct it. The rewrite now marks the units it changed, which leaves a corrective flush queued behind the stale one; re-saving a scene that already holds the id is idempotent, losing the id is not. Media is generated from outlines in parallel with scene content and usually finishes first, so the slide that will carry the placeholder does not exist yet and the write-back has nothing to rewrite. That was the ordinary path, not a tail case, and its result was discarded: the task was marked done, the scene was added afterwards with its placeholder intact, and a second pass in the same run could call the provider again. The allocation is now held under the placeholder — which also answers the skip test, so nothing pays twice — and applied when that scene is committed, before its first save. One complete pass now leaves no placeholder behind. A failed commit used to abandon what it had already allocated. A poster upload that failed threw away a stored video and sent the retry to submit the most expensive job in the system again; a rejected write-back left registry rows that name bytes nothing references, which the byte collector cannot reclaim because it only collects blobs no row names. A poster failure now costs the poster, and a write-back that reached nothing reclaims what it allocated. A partial write is left alone, because the document already names it. The ownership gate is fail-closed again. Treating the sidecar's 404 as permission was wrong: the client cannot tell "this course has no owner" from "this deployment told me nothing", so a visitor who opened a shared course could bill the operator. The root cause was the sidecar itself, which gated on the agent runtime although every persisted course has an owner regardless — the persistence route resolves one for every request. It now gates on server persistence, so the configuration that made 404 the universal answer has real ownership facts to report, and the gate can refuse everything but a named owner. Retry affordances answered to no gate at all. A viewer of a shared course with one failed image was shown a Retry button that called the provider. Both retry entry points and every surface that draws them now read one shared permission, so what is offered and what is allowed are the same value. Also: narration regeneration no longer pretends it can replace bytes behind a live id — the exclusivity proof that would allow it is refused by construction once references leave the browser, so it forks to a fresh id and says so; the "already generated" test lets a finished deck answer from the document alone, since scene order stops identifying an outline once slides are inserted or deleted; stored assets record a specific media type rather than a generic transfer type; the pane no longer asks the sidecar in browser-only mode; and the funnel's docstring now states what the per-stage lock actually guarantees, which is same-browser serialization and not a cross-browser compare-and-swap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): park allocations in the deciding turn, never reclaim on an ambiguous write A delta review of the write-back found the first-pass fix still had a window, and the reclamation it added could delete media the document already names. The allocation was parked after an awaited local cache write. A scene committed in that window reconciled against a registry that did not hold it yet, so the document kept the placeholder — and the entry recorded a moment later then answered the skip test as "already handled", so nothing could correct it. Parking now happens inside the write-back, in the same synchronous turn as the decision that nothing could take the reference; no await separates the live check from the park. Allocations parked by an earlier pass are handed to their slides at the start of the next one, before anything decides what still needs generating, so a held allocation whose scene has since arrived becomes a rewrite rather than an answer. Reclaiming on a rejected write was unsound: a rejection does not prove the server did not apply the write, so deleting the asset could break the scene that now names it. The funnel decides instead, and says so: it reclaims only when no store write was ever issued and nothing took the reference. Anything else is placed if its slide exists and parked if it does not, so the next pass reuses the bytes instead of paying for them again. When a write fails after part of it landed, the live store is brought up to the document before the error is rethrown — otherwise the next ordinary flush would overwrite the half that did land, with the ids deliberately not reclaimed. Parked allocations are now cleared with the course. Classic placeholders are reused across runs, so one surviving an interrupted run would be handed to a different slide of the next deck: the previous picture, on a slide whose provider was never asked. Both classroom surfaces clear the arriving course, the deletion cascade clears the deleted one, and clearing the database clears them all. Two more ways generation could start without asking the gate are closed. An overlapping pass — an outline retry re-enters generation with every outline while the first is still working — re-requested elements whose provider call was already in flight; a task that is not done is an answered request, not an unanswered one. And narration regeneration in the timeline editor called the TTS provider and allocated a pool asset with no ownership check at all; it now reads the same permission, which withholds both the per-line and whole-timeline controls and refuses the call. Finally, a pane opened during the stage-link availability gap recorded the sidecar's 404 for a course that was moments from existing and never asked again, leaving the real owner locked out of generation until it remounted. Ownership is re-fetched once the document becomes available; the gate stays closed until an answer arrives, so asking again can only open it for someone entitled to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): make the asset routes reachable, and stale snapshots harmless A full-branch audit found that the deployment this project documents could not store a single generated asset, and that several routes into durable storage could still write a placeholder over a reference that had already landed. The persistence route sent asset requests through the development authenticator, which refuses outright in a production build that has not explicitly opted into it — and the documented server-persistence recipe produces exactly that build. Every store and every read answered 401, so images and video failed on every slide while re-billing the provider on each retry, and a narration failure stopped the deck at its first slide. Assets live in one shared partition by design, so there was never anything per-caller for that authenticator to decide: the route now resolves the asset principal itself, alongside the owner it already resolves for documents. Runtime sessions are genuinely per-learner and keep the development authenticator until real session verification replaces it. And narration that cannot be stored no longer fails its scene: the line stays unvoiced and retryable, which is what an image that cannot be stored does to its slide. Placeholders could also come back from behind. A queued autosave's snapshot, an editor-history entry replayed by an undo, the departing save a course switch flushes — each captures content at its own moment, and any of those moments can predate a write-back. Point fixes at each producer would leave the next producer to rediscover the bug, so the check lives at the write boundary every producer passes through, and the allocation record it consults now outlives the parked queue: a placeholder whose rewrite landed long ago is exactly the case it catches. Two ways generation could be lost or repeated are closed. A pass now claims the elements it will reach and releases them however it ends, so an overlapping pass stands down while an aborted one strands nothing — previously its tasks stayed `pending` and every later pass skipped them with no retry control to recover them. And the media abort controller is aborted before being replaced, so a superseded pass stops calling providers instead of running on for a course the user has left. The remaining two are narrower. The workbench pane asks for ownership only after a document load succeeds, and after every later one, mirroring the page route: the load is what creates the ownership row the first time a course is opened, so asking beforehand asked about a course that did not exist yet and locked its author out for the mount. And the ownership gate on the timeline editor now withholds narration regeneration alone; listening back to existing narration and seeing whether a line has any spend nothing and stay available. Known limitation, unchanged and now stated plainly in the comments that used to point at it as a solution: nothing reclaims an unreferenced pool asset. The registry sweep is written but not wired up, and the byte collector only reclaims blobs no registry row names, so every narration regeneration and every abandoned allocation leaves storage behind. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): gate asset mutations, and make claims and allocation records survive a handoff Opening the asset routes opened all of them. Reads and allocations are meant to be as open as document reads and creates already are, but no authorization hook was supplied, so the handler's default admitted PUT and DELETE too — and those scope by principal key alone, which is one shared constant. Any caller who learned an id, and a document read hands out every id its slides name, could overwrite or destroy another author's media. Mutations now require the deployment's credential, which in a production build without the development-auth opt-in means they are refused outright; reads and allocations stay open. The route comment says what the posture is and what it is not: the deployment-level fence is the access code, and no per-principal quota is configured. The client's own reclaim is best effort to match — losing an argument about deleting an asset must not cost a task its retry, and the bytes are left for server-side reclamation. The pass claim could not survive the handoff it was written for. A retry aborts the live media pass and starts its replacement in the same synchronous block, long before the aborted pass's cleanup runs, so the replacement saw every element still claimed, collected nothing, and returned — leaving each unreached element at pending with nobody coming back for it and no retry control to recover it, which is the exact failure the claim was introduced to prevent. A claim now carries its pass's signal and is retired the moment that signal aborts, and a pass releases only claims it still owns, so a late unwind cannot take its replacement's work. Claims are also acquired at the single point every request passes through, so a single-task retry participates too — previously a retry awaiting its provider was invisible to a pass starting alongside it and both called it. The allocation record could outlive the bytes it named. It was written before the write-back attempted anything and survived the reclaim that followed a failure, so when the slide finally arrived the write boundary stamped a deleted id into the document — and the placeholder it replaced was gone, which reads as already generated and stops anything from retrying. The record is now written only where the allocation is retained, and forgotten wherever a reclaim removes the bytes, including the narration rollback path. The tests follow. The route test drives the real storage handler against an in-memory registry instead of a stub, so it can see what the resolved principal is then allowed to do; the handoff test performs a real abort mid-pass rather than starting from an already-aborted signal; and the guards that could only assert file layout now assert the property they care about, or have been replaced by behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): make media passes serial per course instead of tracking element ownership Three rounds of per-element claims each produced a new way to lose an element. Whole-pass reservations swallowed a Retry for an element the same pass had already failed, leaving it pending with the affordance gone. Retiring a claim by its signal freed an element whose commit was still uploading, so the replacement pass paid for it twice. A claim held for a failed element stranded its retry. The bookkeeping is the defect: every refinement of "who owns this element right now" answered the question at a moment when the answer was already stale. Passes for one course are now serial. A replacement aborts its predecessor, as before, and then waits for it to settle before collecting. That removes the question entirely: a commit already under way finishes — its bytes stored and its reference written, so the new pass sees a resolved slide and skips it — and an element the aborted pass never reached is still a placeholder and gets collected like any other. The claim set, the reservations, the signal retirement and the identity-checked release are all gone. The task table is consulted for one thing only: an element that is generating right now is a single-element retry running alongside the pass, and taking it too would pay twice. Pending is deliberately not a skip reason — it means a pass once intended to reach an element, which an abandoned pass leaves behind with nobody acting on it, and reading that as answered is what stranded elements before. A retry runs concurrently with a pass, because a pass never revisits an element it has processed, and it re-reads the task after its own await and refuses before touching it: marking first and refusing afterwards destroyed the failed state that draws the affordance. Browser-only mode is back to exactly what it was. The abort is now conditional, the waiting does not apply, and the original status-based skip is restored verbatim. Two baseline lines remain changed in each of the two files, and both are behind a server-backed fork whose else-branch is the original. Two smaller things. The allocation record becomes visible when a write goes on the wire rather than when the round trip ends, and the write boundary reconciles under the document lock rather than before it — a save queued during a write-back was otherwise captured with the placeholder and, for a course the user had left, had no corrective flush to follow. And the comments that said a refused reclaim leaves its bytes for server-side reclamation were wrong: nothing collects them, because the registry entry still names its blob and the sweep that would remove it is not wired up. They now say the bytes leak. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): make a deferred pass re-earn its right to run, and bound the commit it waits on Serializing passes moved their body out of the block that launched them, and three things followed from that. A pass now wakes when its predecessor settles, which can be after the user has left the course. It enqueued before it looked at its signal, into a task table keyed by element id alone — and placeholder ids are not unique across courses, which is why the classroom clears that table on arrival. So a departing course's pass seeded the arriving course's table with tasks carrying the wrong stage id, and a Retry routes by that id: the reference went into the wrong document. A pass now re-validates after the wait, before touching anything shared. The same lateness broke the skip test. `documentSkipIndex` answers only while the live store is on the pass's stage, and returning nothing put the collection loop on the browser-only rule — a silent demotion from "the document is the authority" to "this browser's task table is", on exactly the path where that table has just been cleared. Every element the predecessor had committed was collected again, paid for again, and its second write-back found no placeholder to rewrite, so its bytes were parked where nothing will ever reference them. In server-backed mode an unreadable document now means the pass stands down. And waiting was unbounded. A commit is uncancellable: the asset client takes no signal, and a document write cannot be half-undone. One stalled upload therefore froze the course's media generation for the session — the replacement never collected, the element sat on a skeleton that draws no Retry, and only a reload recovered. The pass's signal is now threaded into the media proxy fetch, and the commit is bounded by a deadline. The deadline is on the wait, not the work: the commit carries on, and if it lands late the document simply ends up correct, while the element becomes retryable and the queue moves on. The tests that were meant to pin the previous round were not sensitive to it. Two asserted end states where the mechanism only changes ordering, and one of them rigged the document read so the assertion held whether or not the pass had waited; a third covered half of what it claimed. They now observe the ordering directly — nothing is issued while another pass for the course is working; in browser-only mode a second pass reaches its provider immediately — and the reconciliation under the document lock has a test that fails when it moves back outside it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * revert(media): drop the commit deadline and the abortable download The deadline bought less than it cost. Abandoning a commit after two minutes makes the element retryable while the real commit is still running, so a Retry starts a second commit for the same placeholder against the first: two provider calls, two allocations, and whichever lands second stamps its result over the other's task by element id. The allocation record is keyed by placeholder, so the loser's cleanup erases the winner's record, and the write boundary then puts the raw placeholder back into the document. That is the overlap serial passes were built to remove, reopened through the one door serialization never covered. So a stalled commit holds the course's media queue until it settles or the page is reloaded, and that is written down rather than papered over. The wait is unbounded on purpose: every ceiling on it turns out to be a way of running two commits for one element. Threading the pass signal into the download was also a mistake, in the other direction. The provider call that produced the URL has already been billed, so cancelling the download throws away work that is paid for — and the shared proxy cache records a cancelled request as a transient failure against that URL, which after three of them blocks it for every consumer in the session. Browser-only mode never asked for this: it had no way to observe an abort there, which is exactly why the bytes were kept. The signal is gone from the download again, and `fetchAsBlob` is byte-for-byte what it was before this branch. The regression guard for the stranded-element rule is restored alongside the timing test that was meant to supersede it. It catches a different rule — a task left pending being read as answered — and nothing else does: making the pass skip pending leaves every other suite green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): stop asking the pool for refs it never issued, bound it, and adopt cached bytes Four things a deployment found once this was running for real. A reference this application mints itself — a generation placeholder, a derived narration key — was never in the pool, because the pool allocates every id it holds. Asking anyway used to be an IndexedDB miss; once the pool is server-backed it is a request that answers 404, one per element per load, forever on a course that still holds placeholders. Every lease and probe now checks first. The check is a negative test on shapes this application owns, not an id validator: the pool's id domain stays unconstrained, and anything that is not one of ours is still asked about. The asset store can bound how much one principal holds, and enforces it inside the write transaction, but nothing ever passed the number. It does now, with a default rather than an opt-in: allocation is reachable by any caller a deployment admits, and with one shared principal an unbounded store is unbounded database growth with no operator-visible brake. Refusing asset mutations to unauthenticated callers was not enough, because every authenticated caller resolves to that same shared principal — so authentication decided nothing, and any signed-in visitor could delete any id they learned. Since this branch began storing media the registry is the only copy a course has. Replacing and deleting are now refused to everyone, and the browser no longer tries: an entry nothing references waits for server-side reclamation instead. What a browser must still do is forget its own record of an allocation that reached nothing, or a later save would stamp an id the document has no reason to trust. And a course generated before any of this holds placeholders in its document with its bytes only in the author's browser. Those bytes are paid for, so the author's next load converts them — stored to the pool and written back through the ordinary commit path, with no provider call — instead of buying them again. A row that records only a hosted URL is treated as absent: that URL is the provider's address, not something a document may hold. One renderer expectation moved with this. An untracked placeholder used to paint as pending on first render because asking the pool left a lease in flight; it settled to disabled a moment later either way, and now says so from the start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): surface a full store as a refusal and convert legacy narration A quota refusal reached the browser as HTTP 500 with a generic message, which reads as a transient failure: the element kept a Retry that would pay a provider again and be refused again. The store raises the contract's own error and the handler maps it to 507, but the store answering a request is not always built by the same bundle as the handler -- the persistence provider is reached from the route bundle and from instrumentation, which is why its state lives on a Symbol.for global -- and `instanceof` is false across that boundary while the declared code is still right. Classify on the code as well as the class, and make the code a permanent, persisted refusal in the browser: recorded locally so it survives a reload, shown as "storage is full", and refused by the retry entry point so a stale button cannot buy a second generation. Every other storage failure stays retryable. Convert what a pre-server-backed course still holds. Generated media is adopted under either key this application has used for it -- the placeholder, and the allocated id of a course converted once and later rolled back -- instead of only the first. Narration is converted by a load-time pass over the open course's speech actions, since nothing re-enters generation for an action that already has an id: bytes to the pool, id written back through a funnel that mirrors the media one, owner-only and server-backed-only. A line whose bytes are in no browser is left alone rather than re-synthesized. Also: the pool guard is now a positive `ast_` test rather than an enumeration of the shapes we mint (imports never reach the pool, so this is safe in both modes); the slide ref collection is an exported pure function so its four lease sites are covered behaviourally; ASSET_QUOTA_BYTES treats every spelling of zero as opting out and refuses a malformed value at startup instead of falling back; the abort signal is re-checked after the cache read, before an uncancellable commit; and the unused `removeAsset` and pool `replace` surfaces are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * chore(storage): release 0.29.1 The asset HTTP handler now recognises a store refusal by the contract code it declares as well as by its class, so a quota refusal raised in another module realm answers 507 instead of 500. Same contract, stricter recognition, no API change: a patch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): make a full store recoverable and adoption course-safe Narration adoption read the local audio row by its derived key alone. That key carries no stage id and the table is keyed by id alone, so two courses can mint the same one -- a PPTX import numbers its scenes and actions deterministically, which gives every imported deck's first slide `tts_s1_speech-scene-p1`. Locally a collision only means one course plays another's clip in one browser; adopting it wrote that clip into the shared document permanently, for every device and every visitor. A row that names a course is now adopted only into that course, and a row from before that column existed only when the text it recorded is the text of the action being converted. A full asset store was made permanent last round, which was wrong three times over: it overwrote the refused bytes with an empty blob -- on the conversion path that row is a course's only copy of its own media -- it kept sending the rest of the deck to a provider against a ceiling it already knew was reached, and it left no way back once an operator raised that ceiling. A full store is neither the content's fault nor the configuration's, so it is now its own case: the bytes are kept, the pass stops at the first refusal, and the element shows the reason together with a Retry that re-attempts the upload from those bytes. Nothing retries automatically, so no one is re-billed. The narration write-back now reaches the write boundary every producer of a durable write passes through, not only the dirty mark: adoption never deletes the derived row, so a snapshot that reverts the rewrite is adopted again on the next load and allocates a fresh asset every time. Adoption is also mounted by both classroom surfaces rather than one, takes the course's abort signal, and re-validates that this browser still has the course open before each write. ASSET_QUOTA_BYTES is validated from instrumentation, where the README and the docstring already claimed it was: its only other consumer is lazy and memoised, so a malformed ceiling let the process boot and then failed every persistence request, documents and runtime included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): remember a full store per course, and never lose retained bytes A stopped pass left the elements it never reached as placeholders with no persisted record -- deliberately, since nothing was attempted for them. But that left the next load with no reason not to try: it called a provider for the next placeholder and was refused at exactly the same point, once per reload, indefinitely. A full store is not a property of any slide. It belongs to the deployment and changes for reasons the document knows nothing about, so it is now remembered once per course in the browser's device KV. A pass that finds the marker stands down before spending anything and leaves every placeholder its "storage is full" state and its Retry; the first upload that succeeds clears it and the next pass runs normally. Narration adoption latched per course so it runs once per load, and the latch outlived the abort that leaving a course performs. On a surface that stays mounted across switches -- the workbench pane is one component for every course it shows -- owner course A, visitor course B, then back to A skipped exactly the clips the abort had cut off, and nothing else converts them. The latch is released with the abort now, and a course adopts one run at a time so a re-entry cannot hand a clip a second allocation while the previous run's uncancellable tail is still settling. A quota-blocked element retried into a network error or a 500 lost the bytes that were kept for it: the retry deleted the row before attempting the upload and wrote no replacement for an error carrying no structured code, so the next retry went back to a provider for media this browser had a moment earlier. The row now survives until an upload succeeds, the failure handler keeps whatever bytes the attempt was given, and the retry asks the question a pass asks -- does this browser already hold bytes for this element -- rather than reading an error code that a second failure has already overwritten. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D * fix(media): adopt real legacy narration, queue re-entries, report attempt outcomes Narration adoption admitted a stage-less row only when the text it recorded matched the action being converted. Both of those columns were added to the local audio table by the very change that moved narration onto allocated ids, so a row still carrying a derived key has neither: the rule refused every real pre-allocation course and passed only on fixtures built from post-allocation rows. What the row cannot say, the key can. A derived key names two clips only when two courses share a scene order and an action id, and an action id repeats only when something other than the generator minted it -- an import numbers them by slide position. So a key built from a generated action id is adopted on that basis, a key an import could have reproduced still needs matching text, and a row that names another course is refused however unique its key looks. Handing a re-entering caller the adoption run already in flight undid the latch release it was paired with: that run is bound to the signal the departure just aborted, so it stops at its next clip while the caller -- which has the course open and a live signal -- is told the work is done, and an effect replayed as mount, cleanup, mount adopts nothing at all. A later caller now waits for the uncancellable tail and scans again, which costs a lookup on a course that has nothing left and finishes the clips the abort cut off on one that does. One attempt at an element now reports both facts its callers need instead of a bare boolean: whether the store refused it for room, and whether bytes actually reached the store. Leaving a course clears the task table, so a retry that landed afterwards read "no failed task" as success and deleted the row holding the only copy of the media. Nothing is inferred from that table any more. Reading the localStorage property can throw where storage is denied by policy, typeof included, so the availability check moved inside the guard: this metadata is best-effort, and a rejection here strands a generation pass that has already enqueued its tasks. A retry is never blocked by the per-course "store is full" marker, but a retry that is refused again re-sets it, and adoption now reads and writes the same marker rather than issuing one refused upload per clip on every load. The two canvas element renderers and both thumbnail renderers show the reason beside the Retry, so a full store does not look like an ordinary failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): probe a full store instead of standing down, and pair notices with a Retry Narration adoption was given both halves of the per-course "the store is full" marker last round: it stood down when the marker was set, and it set the marker when its own upload was refused for room. Those halves are only safe together if something can lift the marker, and for adoption nothing could. It has no affordance of its own, it stood down before reaching its own clear, the media pass returns before its marker gate when there is nothing to generate -- so a narration-only deck, or one whose slides are already satisfied, painted no storage-full element and offered no Retry -- and narration generated rather than adopted allocates directly rather than through the media commit. The course's cached narration was then lost for good, where before it converted on the first load after the ceiling was raised. The gate is a probe now. A marked course attempts exactly one clip per load: refused, it stops and the marker stands, which costs what standing down cost; stored, it lifts the marker and finishes the course. Adoption spends no provider money, so the whole cost of probing a store that is still full is one refused upload. Generated narration lifts the marker too. The three surfaces that gained a failure notice last round drew it for any failure with a reason, including the one refusal that is reachable without server-backed persistence, so a browser-only deck painted something it had not painted before. The notice is drawn beside a Retry and nowhere else, which is what it was added for and what leaves browser-only output unchanged. Both are now asserted through the render harness the surface matrix already had. A caller arriving while a rescan is queued shares it rather than appending another. One rescan converts whatever the run in flight left and every later one would find an allocated id on every action, so a chain bought nothing and turned a single stalled upload into a course that never adopts again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): treat a refusal for room as a fact about one clip, not the deck The asset store checks each write against the headroom it has left, so a store that refuses a long opening clip can still hold every short clip behind it. Narration adoption assumed the opposite: it broke the deck at the first refusal and then re-attempted that same first clip on every later load, because the document names it first. A deck whose longest clip exceeds current headroom therefore never converted the clips that would have fit, with no affordance to recover it -- the state the probe was introduced to remove, reached through a narrower door. An unmarked load now attempts every clip, skipping the ones that do not fit, and remembers the condition only if the load ends with clips it still could not store. A marked load spends its single upload on the smallest clip left rather than the first one named: that is the clip that answers the question the marker asks, because if the smallest does not fit nothing does. The media pass keeps stopping at its first refusal, and for a reason adoption does not share -- every element it attempts costs a provider call. A rescan several callers share took the newest caller's signal, and the newest caller is not necessarily the one still there: a surface that opened a course and closed it again would stop work a surface still showing that course was waiting for, and that surface is latched, so it would never ask again. The shared run now takes a signal that is aborted only once every caller has left. The comment claiming the shared rescan contains a stalled upload was wrong -- the rescan is chained off the run in flight, so a stalled upload leaves every caller pending exactly as a chain would. It claims the bounded queue it actually provides, and the stall is recorded as a limitation. The failed-state containers took their stacking classes unconditionally, so markup differed in browser-only mode even though nothing moved on screen. Those classes are applied only when there is a notice to stack, and the tests assert the exact class attribute rather than a substring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): stop narration adoption writing the media pass's store-full marker The marker means "do not call a provider for this course". A path is entitled to write it only if its own refusal cost a provider call, and narration adoption's refusals cost nothing: it uploads bytes this browser already holds. The store also checks each write against the headroom it has left, so a clip that does not fit says nothing about whether a slide's image would. Adoption was writing it anyway, and one over-long narration clip was therefore enough to stand a course's entire image pass down on every later load -- on a store that had just accepted adoption's other clips. The author could still recover each element by hand, every load, for ever. Three rounds of narrowing this seam produced a finding each time, so it is removed rather than narrowed again. Gone: the marker read, the single-clip probe, the smallest-clip selection, and the up-front read of every row into an array -- which also retires a sampled-then-stale flag and the retention of a whole deck's blobs for the length of a run, and returns the loop to streaming one row at a time. Adoption's rule is now that every load attempts every clip it holds, once; any failure skips that clip and the load continues. The noise the coupling was meant to avoid does not arise, because after the first load the clips still outstanding are exactly the ones that did not fit -- normally none, or one. A successful write still clears the marker, and that is a different kind of statement: a write that went through is a fact this run established, where a refusal is an inference about what some other write would cost. For a course whose media needs nothing, adoption and generated narration are also the only paths that can establish it. The failure module still documented the deck-wide premise this contradicts. It now says what is true: the check is per write, and the media pass stops the deck as a judgement about cost rather than about certainty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): bound a full store's cost from the store's own arithmetic Removing the store-full marker from narration adoption removed its bound too, and the code then asserted the bound was unnecessary. It is, on a store with room for most of a deck. On the store the whole mechanism exists for -- the ceiling reached, nothing fitting -- the outstanding set after every load is the entire deck, so a thirty-clip course posted thirty full blobs on every load, indefinitely. Each of those is not a cheap refusal: the bytes are uploaded, the server hashes the whole payload, and only then takes a per-principal lock and sums every entry that principal owns before saying no. The bound needs no flag, no key and nothing carried between loads. The store asks whether `used + addedBytes` exceeds the ceiling, and `used` only grows while a run is uploading, so a clip refused for want of room implies every clip at least that large is refused for the rest of that run. The run keeps the smallest size it has been refused and skips anything no smaller without uploading it; a smaller clip is still attempted, because it may fit. A deck the store refuses entirely now costs one upload per successive size minimum instead of one per clip, and a deck it has room for costs nothing extra, because nothing is refused. Only a refusal for room lowers the bar: a dropped connection says nothing about how much room there is. The deck-wide certainty premise the failure module retracted last round still stood verbatim at the site that implements the stand-down. Both copies now say the same thing: the check is per write, and the pass stops the deck as a judgement about cost rather than about certainty. The comment on adoption's marker clear now names its price. Narration of a few hundred bytes fits in headroom an image does not, so a proven write can let the next pass buy one more image that is refused again -- bounded at one, and the price of the alternative being a course whose media never generates again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(media): state the adoption bound exactly, and stop three comments describing the old rule The comment introducing the in-load bound gave its cost as "at most a handful, and the first load pays the most". Neither clause is a property of the rule. A clip is skipped only when something no larger was already refused, so a fully-refused deck costs one upload per successive size minimum in document order: one when the clips grow, about ln N for an arbitrary order, and one per clip when they only shrink -- a long opener followed by terser lines is exactly that shape. And no load is cheaper than the first, because the bound resets per run and a refused clip stays outstanding. The comment now says that, and points at what would make it exactly one for any ordering: the store returning its remaining headroom in the refusal's existing details channel, which the server leaves empty today. Two other comments still described the previous rule -- "attempts every clip it holds, every load" -- one of them twenty lines above the paragraph that introduces the bound, in the same block. Both now say what the code does. The bound's soundness is worth stating where a maintainer will look for it: quota is charged at full length with no discount for a duplicate, the sum it is checked against joins entries to blobs so the collector cannot lower it, the check takes a per-principal lock before summing, and replace and delete are refused to every browser. Nothing a run can do makes room appear inside it. One test installed a row implementation and replaced it wholesale a few lines later, so the first was dead and the survivor dropped the text the first clip's import-shaped key needs for the ownership rule -- it passed on the coincidence that the fixture's default text is the action's. Merged into one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): keep a Retry from re-buying parked media, and state the store seam once Five findings from an inline review. The standalone classroom route asked the ownership sidecar once per load and recorded only stage ownership when that ask failed. Every non-answer fails closed, so one transient 5xx left the genuine author with no resume, no Retry affordance and no legacy narration converted for the rest of the load, with nothing to change it short of a reload. The failure now records the fail-closed answer explicitly -- an answer an earlier load established must not outlive the failure that replaced it -- and an unresolved answer is asked for again, a few times over a few seconds. A real answer, however unwelcome, is final. A Retry could pay a provider for media the pool already held. When the bytes are stored and only the write-back fails in a way that keeps the allocation, it is parked and no local row exists, because that row is written only after a successful write-back. Retry now reads the parked queue exactly as the pass does and re-attempts the write-back: it re-keys the task done when the document takes it, leaves the entry parked when the slide still does not exist, and stays failed and retryable when the document refuses again. Object URLs a parked allocation owns are revoked when the entry is dropped. The commit path leaves them alone while the entry is parked, because it is then the only thing holding bytes this tab can render, so a course switch or a stage deletion was pinning the whole blob for the life of the tab. An entry a slide has already taken is left alone: the task table is displaying those URLs. The fallback lookup for cached bytes is a stage-scoped scan, and the keyed lookup misses for every row the commit path writes, so a pass was materializing and sorting the course's whole media table once per element. One scan per pass now, built on the first miss. It is sound and not merely cheaper: an element asks only for its own placeholder, and every row a pass writes carries the placeholder of the element that wrote it. "The store accepted a write, so it is not out of room" was enforced at three call sites under slightly different conditions, which made it a convention the next pool write path could silently break. It is stated once, in putAsset, for the course whose bytes it just stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
dfebbcf33f |
perf(classroom): speed up classroom loading (media hydration, sidebar thumbnails, media range requests) (#1276)
* perf(classroom): defer non-priority media blob hydration off the load path Entering a classroom awaited the full mediaFiles restore — one object URL per restored image/video blob — before the loading gate opened. For video-heavy courses that is hundreds of MB of IndexedDB materialization blocking first paint. Split the restore into two phases: - The awaited phase now builds metadata-complete task entries but only creates object URLs for failed rows (none needed) and for media referenced by the scene the classroom opens on (persisted cursor, else the first scene). buildRestoredMediaTasks gains an optional shouldHydrateBlob predicate, defaulting to eager hydration so existing callers are unchanged. - Remaining blob-backed records hydrate in the background, chunked over requestIdleCallback (setTimeout fallback). Each deferred task stays 'done' — so generation resume never re-runs it — but carries no objectUrl yet, which the media resolution state machine already renders as a pending skeleton until the URL lands. Background hydration is guarded per record: a task that was replaced (classroom switch, regeneration, retry) or that belongs to another stage is skipped and its freshly minted URLs are revoked immediately. * perf(classroom): lazy-render slide thumbnails in the playback sidebar The playback scene sidebar mounted a full SlideCanvas for every slide scene the moment the classroom opened (and every off-screen video element opened a preload="metadata" fetch), because SlideThumbnail's existing visible prop was never passed. Extract the editor nav rail's near-viewport IntersectionObserver hook to lib/hooks/use-near-viewport.ts and gate the sidebar's slide thumbnails through it: only scenes within 200px of the viewport render the live canvas; the rest show SlideThumbnail's existing placeholder until scrolled near. The placeholder keeps the same box size, so gating never shifts layout. The shared hook now starts hidden instead of eager: with the previous eager-initial state, opening a long deck still mounted every canvas for a frame before the observer could flip off-screen items off. The observer's guaranteed initial delivery flips near-viewport items within a frame; the no-IntersectionObserver fallback (e.g. jsdom) defers to a microtask so the effect never synchronously re-renders. * perf(media): lazy image decoding and HTTP Range support for classroom media Renderer: BaseImageElement's <img> now carries loading="lazy" and decoding="async", so thumbnail-heavy surfaces (playback sidebar, editor nav rail, course cards) no longer fetch and decode every slide image up front. In-viewport images are unaffected — the browser fetches them immediately. slideToPng forces eager loading inside its permanently off-screen snapshot tree, where lazy images would otherwise never fetch and exports would capture blank slides. Server: GET /api/classroom-media/[classroomId]/[...path] now answers single byte-range requests with 206 Partial Content (Content-Range, Accept-Ranges, correct Content-Length), enabling progressive playback and seeking for hosted video/audio instead of downloading whole files. Suffix ranges are supported; unsatisfiable ranges get 416 with the full size; unsupported units or multi-range sets fall back to the plain 200 full-body response, which is always a legal answer. Existing caching headers are kept on every response shape. * chore(renderer): bump version to 0.1.4 for image lazy-loading change * fix(classroom): guard deferred hydration against restarted tasks and bound idle waits A deferred record is only ever 'done' without an objectUrl; a task that regeneration or retry restarted passes through pending/generating, so skip those instead of attaching stale persisted bytes. Also give the idle scheduling a timeout so a busy main thread cannot starve hydration. * fix(classroom): stop superseded deferred hydration before minting URLs A restore epoch captured at apply time now gates each idle chunk: loading another classroom or reloading the same one invalidates any older hydration loop, so it neither keeps scheduling work for an abandoned classroom nor attaches bytes read by an older load to a newer load's tasks. * fix(classroom): keep 416s uncached and classify element-keyed media as priority A 416 with public immutable caching can poison the media URL for later valid requests, so range errors now send Cache-Control: no-store. Priority classification also collects the opening scene's media element ids, since task lookup binds records keyed stage:<elementId> even when the slide slot carries a different opaque ref. * fix(classroom): tie deferred hydration liveness to the classroom load token The restore epoch only advanced when the next load reached apply, so an abandoned classroom kept hydrating during the next load's storage/network phase. Compose the epoch with the load's isCurrent (load token plus effect cleanup) so navigation stops the loop at the next idle boundary. * fix(classroom): classify legacy-recovered media before deferring it Legacy singleton video recovery assigns a placeholderRef only at the end of the task build, so a record keyed by an allocated id without placeholderRef was deferred even when it backs the opening scene's gen_vid_* element. Run a metadata-only build first to learn each record's effective ref and classify against it, keeping the first visible page's legacy video eager. * fix(action): wait for deferred video bytes before starting play_video executePlayVideo treated status done as immediately playable, but a deferred restore is done without an objectUrl: the renderer shows a skeleton, no <video> exists, and the later hydration never retriggers play, leaving the action stuck until the safety timeout. Readiness now follows the renderer's contract (only done-with-bytes is playable) at the initial check, the subscription exit, and the post-subscription recheck; the failed skip applies whether or not a wait happened. * fix(classroom): include the stage whiteboard in priority media refs The stage-level whiteboard stays open across standalone classroom switches, so its media can be visible before any scene is. Classifying it as deferred left visible whiteboard media pending behind idle hydration chunks. |
||
|
|
04621578de |
release: OpenMAIC 1.0.0 — the agent workbench (#1228)
* feat(storage): add an agent-session store with PG backend and layered contracts (#1163) * feat(storage): add agent-session store with PG backend and layered contracts * test(storage): avoid BigInt literals for pre-ES2020 root typecheck * fix(storage): close agent-session store review findings * docs(storage): align hook ordering and contention-probe claims with the code * ci: run on the agent-workbench integration branch * chore(storage): bump to 0.5.0 for the agent-session store * fix(storage): carry replay compaction across page boundaries * feat(agent): add the driver model contract and stage route dialect (#1165) * feat(agent): add the driver model contract and stage route dialect * fix(agent): validate route context windows and clarify dialect precedence * feat(agent): adapt the agent-session store and runtime foundations (#1167) * feat(agent): adapt the agent-session store and runtime foundations * feat(agent): resolve request owner identity via an anonymous cookie * docs(agent): document the opt-in compaction default and harden edge cases * feat(agent): add the background session runner (#1169) * feat(agent): add the background session runner * feat(agent): wire the runner into startup behind feature flags * fix(agent): stop clean interruptions from consuming the attempt budget * fix(storage): charge the attempt budget for abandoned leases but not clean parks * docs(storage): document the attempt-charging contract and decouple its tests * feat(agent): add agent session and owner event streams (#1170) * feat(agent): add agent session and owner event streams * fix(agent): close the session-existence oracle and document the owner seam * feat(agent): add agent session lifecycle routes (#1171) * feat(agent): add agent session lifecycle routes * fix(agent): validate session-create input and preserve the owner cookie on errors * refactor(storage): drop the unused active-stage API from the agent-session contract (#1174) * refactor(storage): drop the unused active-stage API from the agent-session contract Tools address stages explicitly on every call, so the store keeps no mutable session-level stage pointer. Removes resolveActiveStage and setActiveStage from the store interface, their PG implementations, the active_stage_changed lifecycle event, the session_active_stage owner event variant, and the contract tests pinning them. The active_stage_id column and the DDL check constraint stay untouched for schema compatibility. * chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal * docs: document the agent runtime configuration surface (#1176) * fix(agent): repair orphaned and late tool results across interruption boundaries (#1180) * fix(agent): repair orphaned and late tool results across interruption boundaries A crash, shutdown, or provider failure can leave the durable transcript with tool calls that have no result, or with results ordered illegally for the provider. Three failure modes were fixed: - Orphaned tool calls: a run that died between an assistant tool-call frame and its result left a dangling call in the entry tree. Resume no longer synthesizes and persists receipts for it: interrupted results are a read-time provider view owned by a shared read-boundary repair, which returns the original array for a healthy transcript and never mutates the tree. - Late parallel results: a parallel tool can finish while pi unwinds an aborted assistant frame, leaving result(A), assistant(aborted), result(B) in durable order. Strict providers reject non-contiguous results, so the read-boundary repair moves existing results next to their owning assistant frame (in call order), omits incomplete unwind frames, and synthesizes receipts only for genuinely missing calls. - Interrupted calls at the write boundary: a call still in flight when the run winds down (shutdown, lease loss, cancellation, provider failure) had no receipt at all. The runner now tracks in-flight calls from their assistant frames and, before the terminal flush, appends an interrupted-result receipt for each still-orphaned call through the same attempt-fenced write chain, so a lease-stealing zombie never writes and the next claim sees a provider-safe transcript. * test(agent): pin the runner wiring for interruption-boundary tool repair * feat(agent): add neutral tool foundation libraries (#1184) * feat(agent): register a web_search tool on the session runner (#1185) * feat(storage): add a per-session URL trust gate (#1186) * feat(agent): add the skills system (#1189) * feat(agent): add the skills system (builtin directories and durable user skills) * fix(storage): serialize the user-skill quota check-and-insert per owner Two concurrent creates at the 50-skill boundary both counted 49 rows and both inserted (READ COMMITTED, no lock), overshooting the quota contract. The create transaction now takes a per-owner pg_advisory_xact_lock first, and the same-name idempotency check runs before the count check so an at-least-once retry of the create that committed as the owner's 50th row still returns its durable receipt instead of a quota error. The 23505 backstop is retained for writes that do not take the lock. * fix(agent): share unstorable-character validation and align skill lookup * feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190) * feat(agent): add session materials and a fetch_url tool behind the URL trust gate * fix(agent): harden session material fetching * feat(storage): add an ownership scope to stage documents (#1191) * feat(agent): add material read and search tools (#1192) * feat(agent): add stage read and patch tools (#1194) * feat(agent): add page generation and deck editing tools (#1198) * test(storage): keep the PG contract suite order-independent (#1200) * fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199) * fix(storage): revoke deleted session URL authority * fix(ssrf): reject private ISATAP endpoints in strict fetches * chore(storage): bump to 0.11.1 for the session-URL authority fix * feat(agent): add roster and voice registration tools (#1201) * feat(agent): add folder organisation tools (#1202) * feat(api): add stage and material HTTP routes (#1203) * feat(workbench): add the client data layer (#1204) * feat(workbench): add the client data layer * docs(workbench): write the ported comments in English * chore(edit): remove the in-editor agent panel (#1210) * chore(edit): remove the in-editor agent panel * style: apply prettier formatting * fix(agent): report the runtime as unusable without a database (#1207) * fix(agent): report the runtime as unusable without a database * style: apply prettier formatting * feat(agent): add image, video and pptx import tools (#1211) * feat(workbench): add the agent chat surface (#1205) * feat(workbench): add the agent chat surface * docs(workbench): write the ported comments in English * fix(workbench): label the folder and rename tools on the timeline * fix(workbench): label the roster and voice tools on the timeline The reconciliation test iterates every tool the runner registers and requires a display label of its own. The roster and voice-clone tools (list_voices, set_roster, clip_audio, register_voice) reached the integration base with the roster/voice-registration tools but never gained presentation rows, so they fell through to the default branch and rendered their wire names. Port their rows from the reference implementation (labels and i18n keys verbatim) and extend the reconciliation allowlist with ROSTER_TOOL_NAMES and VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product without a label. * feat(agent): add the material extraction lifecycle (#1212) * feat(storage): add material extraction lifecycle * feat(agent): execute queued material extraction * style: apply prettier formatting * style: satisfy prefer-const in the extraction runner * test: give material fixtures the extraction lifecycle fields The media-tools slice and the extraction lifecycle slice were each green in isolation but never compiled together: the lifecycle made derivedFrom and extraction required on AgentSessionMaterial while the media-tool fixtures predate them. * chore: remove stray task notes * fix(workbench): label the extraction lifecycle tools on the timeline * feat(workbench): add the workspace shell (#1206) * feat(workbench): add the workspace shell * docs(workbench): write the ported comments in English * i18n(workbench): align workspace keys across locales * fix(workbench): adopt the landed data layer and label the extraction tools - replace the sibling-slice seam stubs with the real data-layer modules - drop ambient declarations now shadowed by landed files - port timeline labels for the extraction lifecycle tools from the reference - align the new i18n keys across all locales * ci: retrigger * feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215) * fix(storage): restore capability-based stage access * fix(api): bind document access to request owner * fix(agent): restore three-state stage access on the tool layer Port probeStageAccess and the three-state StageAccess (owned / foreign / missing / tombstoned) and gate every stageId-bearing stage tool on an owned probe, mirroring the reference per tool: - move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage with the single not-yours message before touching the store. - The course/DSL toolset and the roster toolset are wrapped by withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every writer refuse a foreign stage with the same message and refusal shape. - Scene preview keeps its own probe and its own refusal text, and is registered beside the course toolset (never double-gated). - The runner injects one probe factory at the three call sites. Tests: the dsl cross-owner test premise (a foreign stage is readable by id) encoded an invented capability-read policy that the reference does not have at the tool layer; it now asserts foreign read/patch/grep are all refused while the owner still reads. Curriculum cross-owner assertions were already the reference's and now pass with the probes in place. * docs: correct per-file test counts in the fidelity report * test: fix type errors in stage-access fidelity test * test: adapt media-tool and gate suites to the owner-scoped store seam * feat(api): add owner-scoped course-folder HTTP routes Port the reference implementation's /api/folders family (list, create, rename, delete with ungroup/remove modes, and folder membership) onto the owner-bound document store, replacing its provider-based auth with the existing withRequestOwnerId / owner-scoped store seams. The storage package's folder store grows the pieces the routes need: DocumentFolder.order (schema column + max+1 assignment + ordering), renameFolder, deleteFolder(mode) with captured member ids, and setStageFolder(stageId, folderId | null) with idempotent un-filing. FolderNameError moves into folder-name-validation.ts (stage-storage re-exports it, keeping import sites intact). Every route gates on the configured agent runtime (plain 404 when off or unconfigured), keeps the reference's machine codes and envelopes, and is covered by gate tests plus a behavior suite. * feat(api): add stage-meta viewer surfaces for the classroom Port the reference implementation's viewer-facing stage state — can-edit / collected / published / generation-complete — on top of the stage-access base (stage_meta + tombstones). stage_meta gains published_at and generation_complete columns plus a stage_bookmarks table; the reference's deployment-specific origin/claimed_at columns are stripped. New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status, POST generation-complete / publish / unpublish (owner-only), POST /api/bookmarks. The resolver lives in lib/server/stage-access.ts. Wiring: a fetchStageMeta client with the reference's three-outcome contract, stage-store isOwner/isBookmarked/readOnly fields (upstream single-user defaults, no-op until the sidecar answers) plus setViewerAccess, the classroom apply path computing readOnly = !(isOwner || isBookmarked), the Stage editability gate, and a sidecar probe after each classroom load. A sidecar 'absent' answer keeps the editable default here because the classroom also serves local-only courses; server writes stay owner-enforced. * feat(api): port the reference material upload contract Rewrite POST /api/materials to the reference implementation's upload shape so the workbench uploader (uploadWorkbenchMaterial, which posts no session id and expects a flat 201 view) works unchanged: owner-scoped upload with mime normalization/validation (415), per-class size caps checked on the declared content-length and the streamed body (413), empty body (400), quota (429), sha256 reserve->store->finalize lifecycle with abandon on failure, flat { materialId, originalName, bytes, mime, extraction } 201, and an x-request-id echo. Adds the owner-scoped material library (owner_material table + quota + 24h lazy sweep, bytes in the host's asset registry as the neutral replacement for the reference's object-storage byte path) and the material cap configuration. The session-scoped GET list is left as-is; the reference's owner-material extraction worker is not ported (the branch's session-material extraction lifecycle already covers extraction). Gate tests now cover all 23 persistence routes across the three runtime env states; the materials behavior suite pins the new contract. * feat(media): add an optional local ffmpeg media extractor (#1213) Adds a local ffmpeg/ffprobe pipeline as a second media extraction provider behind the extractor registry, ported faithfully from the reference implementation: duration probing, keyframe-safe chunking, per-chunk ASR with timeout and deadline budgets, and timestamped transcript assembly. - Availability probing feeds the registry's candidate selection: the provider simply is not a candidate when ffmpeg/ffprobe are absent. - With neither ffmpeg nor a cloud provider configured, extraction fails with an actionable message naming both enablement paths. - Media materials route through the same extraction lifecycle and lease fence as documents; no parallel queue. - Tests inject the executable resolver so the missing-ffmpeg path is the default-tested one; the real pipeline test is skip-if-unavailable. - @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material lifecycle surface). * feat(storage): per-scene monotonic revisions via database triggers (#1214) * feat(storage): per-scene monotonic revisions via database triggers Restore the reference implementation's freshness granularity: a per-scene monotonic revision maintained by database triggers, so every writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without application cooperation. - Companion revision tables + trigger functions in the storage package's idempotent schema bootstrap, with the lock-order invariant, pg_notify wakeup and the suppression switch for batch writers. - ensureDocumentSchema gained a dollar-quote-aware statement splitter. - The freshness and manifest routes serve per-scene revisions. - Mutation-verified: dropping the triggers turns the revision tests red. - @openmaic/storage 0.13.0 -> 0.14.0. * fix: forward the freshness manifest through the owner-bound store * feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208) * feat(workbench): add the Pro entry points * feat(workbench): preserve Pro mode transition semantics * fix(workbench): drop ambient declarations shadowed by landed slices * fix(workbench): drop ambient declarations shadowed by the landed shell * feat: port workspace shell sibling modules Port the 16 leaf modules the Pro workspace shell imports but that were only ambient-declared, replacing the compile-time bridge with real implementations adapted from the sibling-slice reference: pure workbench helpers (session title, rail tab, course-chat bootstrap, created-course tabs, course-tabs memory, workspace navigation, pane navigation, pro-edit sizing, existing-course minting, first-message session), the neutral brand context and course-rename server API, the server-action session delete, the home discovery hook, the classroom pane host with its load-policy leaf, the theme toggle and floating-layer owner, plus the floating-layer-owner wiring the dialog/dropdown/tooltip portals stamp. Also add the workbench-shell locale copy for all 12 locales, port the reference tests for the ported modules, and drop types/workbench-sibling-slices.d.ts now that every declaration has a real implementation. * docs: keep ported comments in English and deployment-neutral * docs: announce 1.0.0 and refresh the feature overview (#1216) * docs: announce 1.0.0 and refresh the feature overview * docs: finalize 1.0.0 README after feature merge * fix(agent): control-plane routes answer 404, not 500, without a database The agent control-plane routes gated only on the runtime flag, so an enabled-but-unconfigured deployment (flag on, DATABASE_URL empty) answered 500 from a store that cannot connect. Gate them on the configured check instead, matching the stage/material routes: the whole surface is cleanly absent until both the flag and the database are present. The status probe keeps reporting both bits. * test: mock both runtime gate exports in the control-plane route suites * fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217) The generate_tts / scene-tts path checked the runner's AbortSignal between actions but never created the provider HTTP requests with it, so a session cancel left a hung synthesis fetch in flight until a restart repaired the tool result. Thread the signal end-to-end: TTSModelConfig carries an optional signal, generateTTS combines it with a per-request timeout (TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure, glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao, elevenlabs, lemonade) is created with that signal. A timeout now fails the tool call with TTSRequestTimeoutError (a clear retryable error) instead of wedging the session; a caller cancel propagates as the interruption so the runner settles the session as cancelled without a restart. Tests: hung-provider simulation rejects at the timeout with the retryable error; abort mid-flight aborts the captured request signal and surfaces the interrupted shape; removing the signal wiring makes the abort tests fail (red), restoring them turns green. * fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218) Finding 1: with server persistence on, listStages resolved to the generic GET /api/persistence/documents listing, which the capability model deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings owner-only). The home/workspace library now lists through the owner-scoped GET /api/stages surface (same anonymous-owner cookie the workbench uses) when server persistence is enabled; the server-side 403 is untouched. Finding 2: a run interrupted (session_interrupted) and repaired (session_resumed) that ends cancelled before agent_end stranded its pending classroom sightings, so the timeline's terminal card lost the course the answer produced. session_end (cancelled) now flushes the pending sightings into the same course card set agent_end paints, before the stopped caption. * chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219) * chore(classroom): remove the bookmark ('collected') concept entirely The stage-meta viewer port introduced a bookmark surface (stage_bookmarks table, POST /api/bookmarks, the isBookmarked sidecar field, and a readOnly rule that let a saved course stay editable). The product has no such concept, so remove it as a closure: - delete the /api/bookmarks route and the stage_bookmarks table plus its query helpers from the persistence bootstrap - drop isBookmarked from GET /api/stage-meta/[stageId] - simplify the classroom read-only rule to readOnly = !isOwner across the sidecar client, ownership signal, classroom load, stage store and the classroom page - keep publish/unpublish, generation-complete, isOwner and isPublic exactly as they were - update the gate and stage-meta route suites and the README mentions The workspace rail's Bookmark glyphs and comments describe the upstream saved-courses (favorites) section, which is driven by isOwner and renders no collect affordance; they are kept as unrelated homonyms. * chore(workbench): remove the saved-courses drawer UI The first pass removed the bookmark data model but kept the rail's "Saved courses" drawer, judging it a separate surface driven by `isOwner === false`. The home/workspace listing is owner-scoped, so that flag can never occur: `allSaved` is permanently empty and the drawer (plus the collapsed-rail Bookmark mini-button) is a dead affordance. Remove it: the SavedDrawer component and its mount, the savedOpen / savedSection state, the allSaved / matchedSaved derivations, the 'saved' variant of the course-list renderers, the mini Bookmark glyph, the drawer-only CSS, and the drawer's i18n keys from all 12 locales. The courses tab is now exactly one folders tree. The authored/favorites split in workspace-tree.ts goes with it; the tree module no longer reads `isOwner`. The discovery course type keeps the field — the shell still reads it for read-only gating. Upstream has no collect concept; the drawer could only ever render empty here. The reference implementation HAS this drawer (its favorites come from its account system), so this removal is a deliberate upstream product decision, not a fidelity bug. * fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221) * fix(workbench): restore the composer attach entry by gating it on the live runtime The AttachButton's rollout probe read a `materialsEnabled` field that this branch's /api/agent/runtime never answers (the materials routes gate on the runtime itself, like the stages), so the gate could never pass and the attach button never rendered — the Pro launch and chat composers showed only the @-mention and enhance glyphs. Substitute the field with the runtime's `enabled` value, which IS the upload action's precondition: POST /api/materials answers 404 whenever it is false, so the render condition now equals the action precondition (no dead button). The button's label (`proMode.attach`) is a user-visible string that becomes visible again; port the reference implementation's own translations verbatim into the 11 locales that still carried the Chinese copy. * feat(workbench): add the settings entry to the rail's bottom-left cluster The reference's rail foot carries a cluster of utilities (its saved-courses drawer, the language switcher, the display toggle). This branch removed the drawer — it could only ever render empty here — and the product decision is to fill that freed spot with the settings entry. Add a settings trigger to the foot cluster (expanded rail, beside the language and display toggles, and on the collapsed strip) and mount the model/provider SettingsDialog in the rail, wired to the trigger. It is the same dialog the classic home opens from its header pill; the workspace had no settings entry of its own, so nothing is duplicated within a surface. * test(workbench): pin the restored upload, attach, and settings entry points Covers the three restored entry points: - the courses-tab upload control: rendered beside the course name filter, wired to the discovery hook's ZIP import trigger, disabled while an import runs, and gated by the same condition as its action (the courses tab); - the composer attach control: an actual render of AttachButton under both probe answers (visible when the runtime says the upload path is live, hidden otherwise), its mounts in the launch and chat composers, the branch's runtime-field substitution in the probe, and the reference's own `proMode.attach` copy in all 12 locales; - the settings entry: the trigger in the rail's foot cluster (expanded and collapsed), beside the language and display toggles, opening the SettingsDialog the rail mounts. * chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223) A workbench build without the editor toggle has no way to edit a course: enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle. The workbench IS Pro mode, so its flag now implies the editor gate; the standalone flag remains for deployments that want the classroom editor without the workbench. Documents both flags in .env.example. * fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222) The Pro workbench chat did not stream: the session/owner SSE routes polled the durable event log on a 5s/30s clock with no wakeup, so message_update deltas (written at 150ms cadence) reached the browser in poll-sized blocks and the thinking strip only mounted after the whole reasoning text had accumulated. Port the reference's LISTEN/NOTIFY delta path: - storage: add in-transaction wake hooks (onSessionEventAppended, onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in the same transaction as the durable append; align readEventsAfterForReplay to rank the bounded page so the first delta after the cursor is always kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0. - app: port the process-wide event-notify bus (dedicated LISTEN client, self-check probe, reconnect backoff; notify through the storage transaction surface), wire the store hooks, subscribe both SSE routes before the initial read with the reference's initializing gate, and give the runner one {kind:'session'} subscription whose wake runs the cancel check and the message drain. Polls stay as the lossy-NOTIFY backstop. - lifecycle: start/stop the bus from instrumentation. Tests: storage hook + compaction contract; route wakeup latency; runner wakeup wiring with a fake agent; bus unit tests; PG contracts proving a real append wakes the routes and a live SSE route forwards a message_update on the wakeup, and that a rolled-back append never wakes. Also fix the pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against newer FK tables). * fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225) * fix(storage): refuse the non-transactional byte-write deadlock configuration A byte store whose plain write() runs on its own pooled connection cannot be invoked from inside a registry write transaction: after the transaction has claimed the blob-row lock, that write blocks on the lock the transaction just took while the transaction waits on the write - a self-deadlock PostgreSQL cannot detect (one side is idle in transaction). There is no lock-safe ordering for such a writer: bytes must be written after the row claim (writing before it lets the collector delete the bytes while the upsert waits), and any second-connection write after the claim is the deadlock. The configuration is therefore detected and refused: - AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the layer's plain byte operations cannot contend for the registry's row locks. - PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite) when the byte store has no writeWith and does not declare the flag, throwing a clear configuration error before any row is claimed. - The collector mirrors the guard on its delete path (deleteWith or a declared out-of-registry layer, else a configuration error). - The object store declares the flag (its out-of-transaction write remains legitimate); the in-registry PostgreSQL byte column provides writeWith / deleteWith instead. - Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so any future lock-contention variant fails loudly instead of hanging. Bumps @openmaic/storage to 0.18.0. * fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper The no-bucket case of lazyAssetByteStore returned a bare { write, read, delete } and dropped writeWith/readWith even though the underlying PgAssetByteStore has them. The registry's hasTransactionalWriter duck check then failed and put() fell back to the byte store's own pooled connection, which blocks forever on the blob-row lock the registry transaction just took when the bytes live in the same PostgreSQL - the production self-deadlock. The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned methods are forwarded eagerly (typed against the real signatures via PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no transactional writer exists there, the signed-URL method stays absent or lazy exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase so the registry may run the plain write inside its transaction. New tests pin the wrapper's transactional capability red-to-green and assert put()/resolve() route byte traffic through the transaction-pinned queryable. * fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget The classic home flow's Generate click drained in-flight ingests for the full 15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as a UX bound) and reuse the existing timeout fallback: sources that miss the cap proceed on the legacy byte path and each late-resolving id is released. * chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0 * fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226) * fix(agent): bound every tool call with a global timeout and settle it on cancel A tool await that neither resolves nor rejects wedges the session forever: the lease keeps heartbeating and the driver never reaches its next cancel checkpoint. Race every tool execution (in buildAgent) against a hard budget (OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for known long runners) and against the caller's AbortSignal, so even a signal-ignoring await cannot keep a cancelled session running. On timeout the call rejects with AgentToolTimeoutError; the agent loop turns the rejection into a structured error tool-result the agent can retry or proceed from, and the abort signal is delivered to the tool's in-flight work through a derived controller. Zombie-tool updates after settlement are dropped. * fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim The claim scan treated a session with cancel_requested_at set as a normal claim candidate: after a restart it re-leased the same session for attempt N+1 and resumed generating despite the pending cancel. claimNextSession now settles such candidates as cancelled under the claim lock (status cancelled, attempt reset, lease and cancel request cleared, terminal session_end event and owner projection) instead of leasing them, then keeps scanning. Bump @openmaic/storage to 0.18.0. * docs: takeaway-style 1.0.0 announcement with bilingual guide links The 1.0.0 head is now a short takeaway block — badge links to the official user guides (English and Chinese), five one-line highlights, and pointers into Features and the workbench setup section — instead of six dense paragraphs. The detailed provider-neutrality and freshness notes move into the Features workbench section, phrased database- neutrally (the announcement no longer names a specific database). Release date corrected to August 27. * fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229) * fix(workbench): wire workspace folder routes * fix(editor): restore reference workbench chrome * fix(workbench): persist composer materials and course refs * fix(workbench): preserve live reasoning frames * fix(persistence): back off failed streaming saves * chore(workbench): retire stale slice seams * test(editor): cover element pin layer * chore(storage): bump to 0.21.0 for the user-message ref/material fields * chore(editor): translate ported code comments to English * fix(agent): fence durable tool writes and consume cancel requests atomically (#1230) * fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231) * fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232) * fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233) * fix(workbench): send the opening session message exactly once with refs intact (#1234) * feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235) * fix(media): restore the reference classic media chain (#1236) * fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237) * fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238) * test(providers): reconcile the provider-config vendor-token debt count after the main merge The integration line's AK/SK fallback for the managed document provider adds occurrences that main's allowlist snapshot predates. Same mixed-composition debt category the group already documents; no new vendor behavior. * test(providers): reconcile vendor-token debt counts with the integration line The main-merge brought main's neutrality-guard snapshot next to integration features it predates (media-extractor fallback chain, local voice-profile deletion semantics, the enabled-TTS helper). Same debt categories the guard already documents; counts updated to the guard's own tally and two grouped entries added. No new vendor behavior. * fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239) * feat(skills): add Feynman and spiral curriculum methods (#1240) * feat(agent): port missing reference tools and skills (parity audit) (#1241) * feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242) * fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243) * feat(settings): skill management with real list, download, delete, and upload (#1244) * feat(settings): skill management section with real list, detail, and zip download * feat(skills): owner skill delete and upload across storage, API, and settings * fixup! feat(settings): skill management section with real list, detail, and zip download chore: neutralize a reference note in the settings header comment * fix(media): persist origin-independent classroom-media references from the agent runtime (#1245) * feat(editor): float the insert toolbar in the outer frame with collapse (#1246) The insert strip was bounded to the slide card, so it could only ever sit on top of slide content: the card's overflow clipped it and it could not be parked in the padding beside the slide. Move it into the studio frame the element picker's panel already roams (CanvasOverlayPortal + the frame selector), so both canvas overlays share one bounding container and their handles behave the same. While picking, the strip rises over the picker and goes inert, which is the z-order CANVAS_OVERLAY_Z already documents. Add a fold beside the grip: the chevron collapses the strip to that grip row and back, with the buttons unmounted rather than hidden. The fold is session-local state owned by EditShell, next to the drag offset, so a surface swap keeps it; nothing is persisted. Expanding a strip parked at the bottom edge re-clamps through the same bounds rule the keyboard move uses. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(workbench): align the chat timeline's left edge with the composer (#1247) * fix(agent): fence session claims while an ask_user question is outstanding (#1248) * fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249) * fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250) * docs(readme): surface the 1.0.0 user guide badges at the top (#1253) * fix(workbench): show newly created folders in the sidebar without reload (#1254) * docs(readme): add the release version prefix and drop the opt-in framing * fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255) The transcript and the composer each established their own column: their own `px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal padding values were never enough, because the two columns are centered inside different containing blocks — the transcript's is a scroll container, whose content box is narrower than the composer footer's by the scrollbar's width: transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2 composer box left = pad + (pane - 2*pad - measure) / 2 The padding cancels out of the difference and what remains is `-scrollbar/2` at every padding value, so the transcript sat half a scrollbar to the left of the composer and tuning the two paddings against each other could not move it. The column is now established once, by the nearest common ancestor of both (`chatColumn`), and the scroll viewport and the composer footer are siblings inside it that add no horizontal inset of their own. The cap carries the gutter on top of the 760px reading measure, so the text column keeps its width. The handed-over question row drops the padding that indented it past the agent's prose; framed rows keep their own inner padding, which is what a card's border sitting on the column edge means. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(workbench): lock pane-embedded classroom to edit mode (#1256) The workspace right pane painted the full learning chrome — speed control, play button, learner avatars, mic bar — for a course the agent had just created, then flipped to edit once the first scene landed. resolveStageChromeMode treated playback as the DEFAULT branch for a hosted classroom, so every shortfall fell into it: a course whose tab opens at stage_link time has no scenes yet, so currentSceneId is null and isHostedSceneEditable is false. A folded pane parked the playback root behind the fold and cross-faded it out over the pane on unfold, and a failed editor chunk dropped into playback permanently. Lock it at the pane instead of defaulting per entry path: - WorkbenchPanelProvider — the single element that mounts a classroom into the workspace — publishes editPinned (visible && !playback). Every entry path passes through it, so none of them decides. - The hosted resolution can no longer degrade to playback. Start Learning (workbenchLearning, new input, split out from pane visibility) is the one door; everything else resolves between the neutral loading shell and edit. - Stage's chrome dispatch is exhaustive on chromeMode, so the playback root is no longer the else-branch of a condition about the current scene. No flicker: chromeMode is resolved during render, and preloadEditor now answers synchronously (isEditorPreloaded) so a remount with the chunk already registered paints edit on the first frame. A failed import is no longer cached forever, so the lock cannot strand the pane. Standalone classrooms keep their stored mode unchanged. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b8fbac1a54 |
feat(providers): uniform capability force-off and consistent missing-key contract (#1181)
* feat(providers): uniform capability force-off and consistent missing-key contract * fix(providers): close force-off bypasses found in review |
||
|
|
e3fd494658 |
feat(tts): add Qwen TTS voice cloning (#1160)
* feat(tts): add Qwen TTS voice cloning via the registration adapter * feat(settings): add Qwen TTS clone-voice manager * fix(tts): harden Qwen voice cloning integration * fix(tts): enforce voice-bound Qwen synthesis * fix(tts): close Qwen voice clone tail issues * fix(tts): close final voice clone review issues * fix(agents): harden voice token parsing * fix(settings): hide the voice-clone model from manual selection * fix(agents): pin the narrator voice to the user's selected voice * fix(agents): keep narrator fallback alive when the pinned voice is unusable * fix(i18n): correct the narration fallback notice copy * fix(tts): bound the narrator voice retry to a single fallback hop * fix(tts): tolerate transient vendor errors in the voice existence precheck |
||
|
|
3b4567076f |
refactor(generation): consume the package everywhere and delete lib/generation (#1090)
Part D of #1057 — the switch. All consumers import @openmaic/generation; lib/generation deleted (net -11,900 lines). PBL wiring preserved (single-call -> classified fallback -> injected loop), streaming outline route deduplicated onto buildOutlinePrompt, serverExternalPackages wired, and the change verified by a real-model four-kind end-to-end run against the production build with a rendered-classroom screenshot check. Closes #1057. |
||
|
|
ceb05adb41 |
feat(media): allocate generated assets through the registry (#1007 part 2, step b) (#1039)
* feat(media): establish shared asset ownership primitives Introduce global browser asset-pool ownership, asset-reference collection, stage reclamation planning, and lease-based URL access. Carry allocated media identity through storage and generation boundaries with regression coverage. * fix(media): enforce safe resolution across every consumer Route image, video, thumbnail, presentation, and video-export consumers through one resolution state machine. Prevent opaque allocated or generated references from reaching render and export sinks, with fallback and ownership tests. * fix(media): protect document-owned assets across mutations Allocate pool bytes before compatibility writes and document commits, then roll back uncommitted generations safely. Preserve document ownership across edits, retries, imports, speech generation, scene changes, and stage deletion. * fix(media): scope retries and tighten ownership guard Scope retries to the target scene and slide and refuse ambiguous shared-reference mutations. Expand retry rendering coverage and keep direct pool URL resolution behind the shared lease owner. * fix(storage): avoid nested lock during stage cleanup Execute prepared reclamation plans against an explicitly deleted document so the compatibility cascade cannot re-enter the per-document lock. Cover deletion of stage-owned media rows even when the document has no references. * refactor(media): confine reclamation to stage deletion Remove inline pool and compatibility-row cleanup from element, speech, scene, and audio replacement flows. Keep whole-stage reclamation behind explicit stage ownership, preserve stage-less legacy audio rows, and document deferred document-truth sweeping. * fix(media): close retry and resolution gaps Restore shared source tasks after successful forks, scope retries across both whiteboard locations, and wait for parallel TTS workers before rollback. Resolve background media through import, rendering, and PPTX export paths while keeping retry controls visible over last-good bytes. * test(media): execute the consumer safety matrix Replace source-substring checks with resolver seam execution across all six UI consumers, stage hydration, and both exporters. Pin each rollback layer independently and harden the ownership guard against aliased pool imports. * chore(packages): publish the additive DSL field Bump the DSL patch version for the optional speech-action field. Keep the transitional reclamation policy app-owned and document the legitimate transaction rollback removals there. * fix(media): make retry rollback task-safe Delay allocation task re-keying until final document reconciliation succeeds, restore shared source tasks on failed forks, and require exact stage-whiteboard targets before falling through from a missed scene. * fix(media): clear private assets and refresh leases Delete the asset-pool database during the confirmed local-data wipe. Notify the app lease layer after same-id replacement so mounted consumers re-resolve current bytes without reaching into storage internals. * test(media): pin closure safety guards Exercise the CSS allocation boundary, unfiltered legacy-row ownership, delete-and-undo byte survival, and real distinct consumer seams. Restore the video-only manifest overwrite condition and share the direct video resolution hook across both element variants. * test(media): narrow rollback element assertion Narrow the reconciled slide element to an image before checking its source so the rollback regression remains type-safe under the full root compiler configuration. * fix(media): close lease refresh races Gate the first batch publication by unique resolved refs, then publish every replacement snapshot without mutating the prior React state object. Serialize invalidation behind pending releases, evict rejected refreshes, and register replacement observation at the pool boundary. * fix(media): reopen pool after clear failures Always evict the singleton once its store has been closed, including blocked and failed database deletion paths. Report blocked deletion as deferred and prove a later write uses a fresh live store. * fix(media): isolate shared retry progress Track forked regeneration under the selected element until it receives a fresh asset identity, leaving the shared source task and bytes untouched. Surface targeted failures through renderer task lookup and skip the redundant fork reconciliation lock. * fix(media): close final asset retry gaps Keep blocked asset clears fail-loud until a successful retry, with actionable settings guidance. Clear failed shared-fork state across durable and live key spaces, and pin renderer lookups, lease publication identity, and committed-ref rollback protection. * fix: preserve actionable retry failures Localize the blocked cache-clear recovery hint across every supported locale and pin the deferred-error mapping. Retain durable fork failure rows while retries run, deleting them only after successful generation so unstructured failures survive reload. * fix(media): hydrate legacy stored video thumbnails Home-page recent-video thumbnails regressed for legacy Dexie mediaFiles rows keyed by gen_vid placeholders: the reworked hydration resolved the row's bytes through the sealed resolver but never surfaced the stored blob (and its poster) as object URLs for the preview card, so the CI recent-video-thumbnail e2e specs found no visible element. Hydration now materializes legacy stored video rows into blob URLs for both the element src and poster while keeping the resolver invariants: opaque refs still never reach a DOM src, and concrete addresses are never blanked. Unit pins cover the seam so the vitest suite catches this class without a browser. * fix(media): resolve sole restored legacy video Classroom playback restored tasks by exact document media references. Legacy gen_vid references can outlive the key used by the one persisted video row, leaving the player on a placeholder even though bytes were restored. Select the sole completed stage video only for legacy sequential refs after exact and reconciled matches. Keep exact failures authoritative, refuse ambiguous candidates, and cover success, ambiguity, and failure precedence in unit tests. * fix(media): scope legacy video recovery Decide restored legacy video recovery once from the complete document and record it through the shared task lookup consumed by playback, editing, and resolved slides. Keep ambiguous documents as placeholders, apply the same decision to thumbnail hydration, and preserve exact failure precedence. * fix(media): exclude claimed video recovery tasks Model restored legacy recovery as a two-pass match across document video elements and task rows. Remove tasks claimed by exact, targeted, or placeholder lookup before applying the sole-candidate fallback, and cover the ownership/cardinality matrix. * fix(media): unify video element resolution Centralize source, task, poster, and legacy recovery decisions for every video consumer. Ensure direct URLs win over opaque refs and play_video waits on element-targeted retry tasks. * refactor(media): route legacy recovery through resolver Let document-aware consumers request legacy video recovery through the unified element binding API, keeping thumbnail hydration on a single decision path. * fix(media): preserve import refs and prefer pool bytes Recognize unambiguous extensionless relative media addresses during classroom import. Resolve allocated export and thumbnail assets from the shared pool before falling back to lagging compatibility rows. * fix(export): preserve concrete video sources Route PPTX video elements through the shared media binding resolver so an unresolved opaque reference cannot replace a playable source. Complete browser and persisted-store cleanup when asset-pool deletion is deferred, while retaining distinct hard-failure behavior. * fix(media): fork retries without exclusive ownership Enumerate logical asset owners across every persisted document before allowing global pool replacement. Thread explicit targets through fresh-id rewrites and cover cross-document aliases plus unreadable ownership. * fix(media): guard global asset reclamation Share a fail-closed persisted-document liveness check between stage deletion and retry replacement. Preserve cross-document pool aliases while deleting stage-owned compatibility rows and cover enumeration failures. * fix(media): cover complete slide asset references Route slide media traversal through a shared mutable slot contract so backgrounds participate in export, thumbnail hydration, collection, and rewrite lifecycles. Snapshot complete surviving-document refs once per reclamation and preserve manifest-only owners while retaining fail-closed behavior. * fix(media): preserve exclusive retry asset ids Allow targeted retries to replace exclusively owned pool assets in place. Keep shared and unprovable ownership paths on fresh allocations, and pin production-shaped retries plus compatibility-row cleanup. * fix(media): revalidate asset bindings at completion Recheck repository-wide ownership before replacing generated media and fork scoped retries when exclusivity changed. Route video export selection through the unified resolver and keep concrete posters independent of task state. * fix(media): count unflushed owners and broadcast replacements The completion-time exclusivity proof read only the persisted document, but slide duplication updates the Zustand aggregate synchronously and schedules persistence behind a debounce. A retry finishing inside that window saw a single persisted owner and replaced the bytes behind a reference the duplicate also held. The proof now also counts owners in the live stage snapshot when that snapshot represents the stage being retried, so an unflushed duplicate forks instead. Same-id replacement notifications were realm-local, so a second tab showing the same classroom kept its lease pinned to the superseded blob URL. The notification now travels over a BroadcastChannel; each receiving realm runs its own observers against its own pool, so a spoofed message can at most force a re-resolve. A missing or failing channel never fails the replacement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): bind replacement listeners and spare shared audio rows A realm that only renders never sends a replacement, so binding the channel from the sender path left passive tabs deaf to peers. Binding now happens where the observer is registered, when the asset-pool module loads, and the receiving realm resolves its own pool lazily so a cleared or unavailable pool degrades to the next resolve instead of throwing. Observer notifications ran under Promise.all, so a rejection surfaced after BrowserAssetStore.replace had already committed and turned a durable success into a reported failure. They are settled individually now; the callback is wrapped because a synchronous throw would otherwise escape before allSettled sees the array. audioFiles rows are keyed globally by audioId, so deleting a stage removed the sole row for an id a surviving document still referenced — playback and both export paths read that table directly and cannot fall back to the preserved pool blob. Rows are now filtered against surviving references, while a failed enumeration still withholds only the irreversible pool removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): serve replaced bytes from the pool everywhere Unknown survivor liveness deleted every planned audioFiles row. Those rows are keyed globally by audioId, and playback plus both export paths read the table directly, so losing one is as irreversible for them as removing the pool entry. Unknown liveness now preserves the rows too, leaving bounded garbage for a later pass that can prove exclusivity. The earlier pool-first change covered PPTX, video collection and thumbnail hydration but missed classroom ZIP export, which still serialized the stale compatibility row after a lagged same-id replacement, shipping media the classroom no longer renders. Auditing every direct reader of the media and audio tables surfaced the same gap in playback: speech regeneration also replaces bytes under a stable id and does not roll the pool back when the compatibility write fails, so the player kept serving superseded narration. It now resolves the pool first and falls back to stored rows for legacy and imported audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(audio): replace exclusively owned speech clips in place regenerateSpeechAudio accepted the action's audioId but always passed undefined as replaceAssetId, so regenerating an exclusively owned pool-backed clip allocated a new asset every time, rewrote the action and orphaned the previous pool entry and compatibility row until stage reclamation — contradicting the stable-id path generateAndStoreTTS already implements for media. Ownership is now established before synthesis, and the rule itself moved to the shared reference module so media retries, poster replacement and speech regeneration consume one implementation instead of restating it. An exclusively owned clip keeps its id and has its bytes replaced; a shared clip, a legacy id with no pool entry, or unprovable ownership still gets a fresh allocation so other holders keep their audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(media): drop the import left behind by the ownership move Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): resolve audio pool-first and fence peer realms Stable-id TTS regeneration commits replaced narration to the pool before the audioFiles mirror write, so a failed mirror leaves the row stale. AudioPlayer already resolved pool-first, but audioObjectUrl, collectAudioFiles and the video timeline dependencies still read Dexie directly and would serve the superseded clip. All allocated-audio readers now share one resolver, with Dexie kept as the fallback for legacy and imported rows. The exclusivity proof modelled unflushed owners in the active realm only, so another tab duplicating the same asset during its save debounce could still be observed as a single owner and have its bytes replaced globally. A peer's pending state cannot be read across realms, so presence is probed instead: any realm holding the stage forces the fork path. A deferred asset-pool deletion no longer reloads the page. The database is still on disk and the guidance asks the user to close the other tab and retry, which the reload discarded. The decision moved into a shared helper so the rule is pinned rather than living inline in the component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): fail closed when presence cannot be probed The presence helper documented that an unanswerable probe must count as a peer, but every unavailable path — no BroadcastChannel, a constructor that threw, a send that failed, and the window before the pool's asynchronous binding completes — returned false, so the ownership proof cleared a single local owner and replaced globally shared bytes in place. Probing now returns present, absent or unknown, and only a probe that was actually sent and went unanswered is absent; the ownership decision treats unknown exactly like present. The pool declares its binding intent synchronously so a probe issued during the load-time window waits for the bind instead of concluding that presence is unavailable, and releases that gate if the import fails. Coverage reaches the write boundary: with presence unknown, a production-shaped targeted retry forks to a fresh id instead of calling replace, and the original bytes stay intact for a peer's unflushed owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(dsl): bump to 0.6.3 after the release dedupe took 0.6.2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(i18n): add the deferred-clear guidance to fr-FR The locale landed on main after this branch added the key, so the alignment check flagged it as the one missing translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
57eb8486a1 |
feat(generation): parallelize within-scene TTS generation (#696)
Follow-up to #660. generateTTSForScene rendered a scene's speech clips one at a time. Within a scene the speech actions are independent — each writes its own audio under its own audioId, no ordering/carry-over — so when the server opts into parallel generation (PARALLEL_SCENE_CONCURRENCY > 1, the #660 knob) render them with bounded concurrency via mapWithConcurrency. Default (0/unset) is byte-for-byte the original serial loop; the bound respects TTS providers' 429 quotas. Per-clip failures are still counted (not thrown), so one bad clip never aborts the scene. Closes #695 Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
b4c1c580f8 |
fix(generation): localize scene generation errors (#894)
Co-authored-by: wsun1 <wsun1@users.noreply.github.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
356d454ea4 |
feat(audio): persist audio duration when storing TTS (#861) (#862)
* feat(audio): persist audio duration when storing TTS (#861) Video export (#854) maps each narration clip onto a timeline segment, which needs the clip's duration. The IndexedDB AudioFileRecord already declares a `duration` field but the TTS store path never populated it, so export/import (which round-trip duration via the media manifest) had nothing to carry. Measure duration once when the client persists TTS audio instead of decoding at render time — that keeps the export manifest inspectable in tests without FFmpeg/Chrome (#854 criteria). Scope: client persistence path only. Duration is an asset property, so it lives on AudioFileRecord (matching mediaIndex.duration in export / import), not on the SpeechAction DSL contract. The server generation path has no AudioFileRecord and is left to a later slice. - Add dependency-free `measureAudioDuration` (WAV RIFF walk + MP3 Xing/CBR estimate); pure byte parsing, no DOM, returns null for unparseable input so callers degrade gracefully. - Client path (`generateAndStoreTTS`): populate AudioFileRecord.duration. - Unit tests for WAV/MP3 parsing, format dispatch, and degradation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(audio): sniff content before trusting format hint; add VBRI + ID3v1 handling Address PR #862 review: - [P2] measureAudioDuration now sniffs magic bytes first and only falls back to the reported `format` hint when the content is unrecognizable. getAudioResponseFormat defaults to `mp3` on a missing Content-Type, so a WAV clip could reach the MP3 parser and false-sync into a wrong duration; content now always wins over a mismatched hint. - [P3] Implement the VBRI frame-count path the comment already claimed. - [P3] Exclude a trailing ID3v1 tag from the CBR byte-count estimate. - Add regression tests: mismatched hint (both directions), Xing/Info frame count, VBRI frame count, trailing ID3v1. 17 passing (was 11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
b93434ae2c |
feat(agent-edit): multi-session conversation history for the AI editor (#801)
* feat(agent-edit): multi-session conversation history for the AI editor The "Edit with AI" panel kept only one thread per stage in localStorage, and "New conversation" deleted it, so previous conversations were unrecoverable. Add a per-stage session list backed by IndexedDB (Dexie v12 `agentEditSessions`): - "New conversation" now archives the current session and starts a fresh one instead of deleting it; a history popover in the panel header lists past sessions (auto-titled from the first user message) to switch back to or delete. - One-time migration of the existing single-thread localStorage entry. - The active session id is remembered per stage so a refresh restores it, including a just-cleared empty session (clean slate survives reload). - saveSession preserves the original createdAt and tombstones deleted ids so an in-flight save can't resurrect a deleted session; switching stages drops the previous thread synchronously so it can't leak into the new stage. - Sessions are pruned to a soft cap per stage and removed when a course is deleted. - i18n for all 8 locales; unit tests for title derivation and the store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(types): drop redundant Window.SpeechRecognition augmentation conflicting with @assistant-ui/core @assistant-ui/core's speech adapter declares Window.SpeechRecognition and webkitSpeechRecognition globally as SpeechRecognitionConstructor. Three files (prompt-input, use-audio-recorder, use-browser-asr) re-declared them as `any`, which conflicts (TS2717) once that adapter's types are pulled into the program, and the constructed instance then lacks the event handlers the code sets (TS2339/2551). Rely on the global declaration and cast the instance to the local rich shape. Takes `tsc --noEmit` back to zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
243e9f3a48 |
feat: auto-retry transient failures during scene generation (#788)
Adds automatic retry with exponential backoff for scene generation (content/actions/TTS): shared withGenerationRetry helper at the fetch boundary, smart error classification (retries 429/5xx/timeouts/network, fails fast on 401/403/404), AI SDK RetryError unwrapping, abort-aware backoff, per-route thinking config preserved, and upstream HTTP status surfaced for client retry classification. Fixes #755. |
||
|
|
0f44e9a46f |
Add PBL v2 runtime APIs and classroom UI (#799)
* feat: add PBL v2 runtime UI * fix: ship PBL v2 instructor avatar * fix: ship PBL v2 workspace logo * fix: prevent scenario PBL legacy fallback * chore: restore package lint comments * test: cover ordinary PBL legacy fallback * fix: front-load PBL single-call validation * fix: preserve PBL v2 content when assembling scenes --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
7cb129183d |
chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778) Prepares the @openmaic/{dsl,renderer,importer} family for its first npm publish, and moves the SDK packages onto the @openmaic scope. Why the scope move: the @maic org name is unavailable on npm (an unscoped `maic` package already holds the name), so @maic/* is not claimable. @openmaic matches the project name, the scope is free, and the repo already ships an @openmaic/docs package — so the SDK family now lines up with that convention. - rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the workspace glob, the package dir, and all import sites; lockfile regenerated - renderer: add publishConfig (public, registry.npmjs.org) — was missing, so a scoped publish would default to the wrong registry / restricted access - importer: add a files allowlist (dist, README, LICENSE) and drop the fragile .npmignore blacklist that shipped src; add an exports map so ESM consumers resolve dist/index.js instead of falling back to the .cjs main - all three: add a prepublishOnly build (+ test/typecheck) guard so a publish can never ship a stale or empty dist - add a tag-triggered publish workflow with npm provenance, pinned by name to the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs) are never published Refs #778, #720 (Phase 1). * fix(packages): address cross-review on the @openmaic publish prep Cross-review (Claude /code-review + codex) on this PR surfaced: - renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external and imports a runtime enum from it, but dsl is ESM-only (no `require` condition), so `require('@openmaic/renderer')` would throw ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs` rollup output, `main` now points at the ESM build, and the `require` conditions are removed from `exports`. (importer is unaffected — it bundles dsl, so its CJS build still works.) - prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky test after dsl had already published gave a non-atomic partial release. Reduce prepublishOnly to a build-only guard (never ship stale/empty dist) and move the real test/typecheck gate into the workflow, before any publish. - document that an @openmaic/* tag publishes the whole family via `pnpm -r` (pnpm skips already-published versions); the tag is a release marker, not a per-package gate. Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs), all exports entries resolve; `npm pack` ships dist + README + LICENSE with no src leak; frozen-lockfile passes. Refs #778. * style: reflow @openmaic/dsl type imports past print-width after rename The @maic -> @openmaic rename lengthened two single-line type imports past prettier's 100-col width; prettier --check flagged them. Pure formatting. Refs #778. * docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation) codex cross-review flagged that the published @openmaic/importer throws `XMLHttpRequest is not a constructor` when loaded in a pure Node process — its rollup build is browser-targeted (`nodeResolve({browser:true})` + a browser pdf.js build). The app only consumes it client-side ('use client'), so this is by design. Document it as an accepted limitation: prominent browser-only note in the README and a `browser` field in the manifest. Refs #778. |
||
|
|
1d1ce80e04 |
feat(maic-agent): editor-agent line (v0) — Pro-mode "Edit with AI" (#777)
Promote the editor-agent line to main: read-then-act editor agent (read_scene_content, regenerate_scene, regenerate_scene_actions, edit_interactive_html), a routable maic-agent model stage, the agent sidebar UI, interactive-scene runtime-error capture, and the timeline/script editor. Cross-reviewed and smoke-verified; see #777. |
||
|
|
13b42206f6 |
fix(generation): don't regenerate a deleted slide on a finished deck (#769)
Deleting a slide in Pro/Edit mode removed it from `scenes` but left its entry in the persisted `outlines` plan. On the next classroom mount the resume-generation pass treats the now-missing `order` as a pending outline and regenerates the slide, appending it as the last page. Rather than prune outlines by `order` (which is rebalanced by Pro-mode insert/duplicate/reorder, so it is not a stable key and would mis-target or break delete-undo), gate resume on a persisted `generationComplete` flag: - The flag is recorded (alongside outlines, in the stageOutlines record) when generation finishes — both generateRemaining completion paths, the retry-to-completion path (markGenerationCompleteIfDone), and the already-materialized-at-mount path — and restored on load. It is written only after a verified scene flush, so it never outruns the deck's scenes. - A finished deck is frozen for editing: loadFromStorage no longer derives pending placeholders from orphaned outlines, the classroom resume effect skips generateRemaining, media resume is filtered to materialized outlines, and isCourseComplete honours the flag — so a deleted slide neither regenerates nor leaves a phantom pending page. - Decks generated before the flag existed self-heal on load (every outline has a scene ⇒ inferred complete). deleteScene additionally records completion when deleting from a deck that is complete right now, so the "Course complete" end page and resume-suppression survive even when the flag was never persisted (e.g. edited without a reload). `outlines` are never pruned, so order-rebalancing and delete-undo are unaffected. Genuinely interrupted generations (not complete) still resume. Fixes #768 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4dce1f7414 |
refactor(types): import the slide DSL directly from @maic/dsl; drop the shim (#738)
#707 left lib/types/slides.ts as a thin `export * from '@maic/dsl'` shim so the ~100 existing `@/lib/types/slides` import sites kept working unchanged. Point them at `@maic/dsl` directly and delete the shim, so the app consumes the package contract with no indirection and the legacy module path is gone. Pure module-specifier migration — every symbol imported from `@/lib/types/slides` was already a re-export of the same `@maic/dsl` symbol, so behavior is identical. Also redirects the two `lib/types` relative importers (`./slides`), the e2e fixture's relative import, and the dynamic `import('../types/slides').Slide` type alias in stage-storage; merges the now same-module import pair in slide-edit-elements. Verified: tsc --noEmit clean, eslint clean, vitest 804/804, next build OK, e2e (Playwright) 19/19. Part of #720 (Phase 1). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e83db6a531 |
fix(agents): persist AgentBar voice picks and mode/selection across reloads (#723)
* feat(audio): let resolveAgentVoice honor per-agent voice overrides * feat(settings): persist per-agent voice overrides * fix(agents): persist AgentBar voice picks via settings overrides * fix(classroom): keep user's agent mode and selection across loads * fix(agents): only carry user-set agent selection across classrooms Review follow-ups: stage-derived defaults written by a classroom load were re-read as a user choice on the next load, so visiting a preset classroom permanently downgraded auto classrooms to preset agents. Track the origin with a persisted agentSelectionIsUserSet flag (set by AgentBar interactions, cleared by fallback writes). Also: voice overrides are now clearable (undefined deletes), the override type is single-sourced in voice-resolver, and persist rehydration of the new fields is pinned by tests. * fix(agents): don't mark stage-derived selections as user choices Round-2 review: clicking the already-active mode tab no longer flags the selection as user-set (a visual no-op must not promote stage defaults to a choice), generation-preview clears the flag when it writes the generated roster, and tests now pin that setter writes actually reach storage. * fix(agents): restore generated agent selection when switching back to Auto Review follow-up (#723): handleModeChange('auto') left the preset ids selected, so the UI said Auto while discussion playback kept using preset agents, and the persisted user-set auto pair could never validate on restore. Switching to Auto now selects the loaded classroom's generated agents (empty when none are loaded, falling back to stage defaults on the next classroom load). |
||
|
|
3c30e9397f |
fix(audio): gate SpeechButton on ASR availability (#711)
* fix(audio): gate SpeechButton on ASR availability SpeechButton only honored the `disabled` prop and never read ASR state, so each call site had to wire it and the home page passed nothing — the mic button stayed clickable even when ASR was turned off. Add a `useASRAvailable()` hook as the single source of truth (the `asrEnabled` toggle + whether the selected provider is usable + Web Speech API support for browser-native) and consume it inside SpeechButton, so every call site is gated uniformly. The external `disabled` prop still composes on top, so existing call-site conditions keep working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(audio): address review — trim API key, require custom-provider models - Treat whitespace-only API keys as unconfigured: the recorder only sends keys after trim(), so an untrimmed check enabled the button and then failed server-side as missing credentials (review P3). - Custom ASR providers now count as unavailable until at least one model is configured, mirroring the media-popover rule that hides model-less custom providers (review P2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
3ccd5da6da |
feat(generation): opt-in parallel scene-content generation (#660)
* feat(generation): opt-in parallel scene-content generation Scene generation was strictly serial — content -> actions -> TTS, one outline at a time — so an N-scene classroom paid N x (content + actions + TTS) of wall-clock latency, which dominates the post-outline wait. Add an opt-in hybrid two-phase path (#572): - Phase 1 fetches scene *content* concurrently (bounded). Content is the only per-scene step independent of cross-scene state, so it is safe to parallelise; a content failure marks just that outline and does not pause the batch. - Phase 2 keeps the existing in-order actions + TTS loop, so previousSpeeches threading, ordered addScene, the abort/epoch guards, and the pause-on-failure UX are all unchanged. Gated by a server-side PARALLEL_SCENE_CONCURRENCY (default 0 = off, clamped to 10), surfaced to the client through the existing /api/server-providers response. With it unset the content map is null and the loop is byte-for-byte the original serial path, so out-of-box behaviour is unchanged. The bounded worker pool is extracted to lib/utils/concurrency.ts (mapWithConcurrency, with an early-stop hook for abort/epoch) and unit-tested; getParallelSceneConcurrency env parsing/clamping is tested too. Closes #572 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(generation): pipeline content fetches instead of a barrier Address @wyuc's review on #660. The previous version awaited the whole bounded content pool before Phase 2, so for N > C scenes the user saw the first scene, then a stall on a single page for the entire content phase, then a burst — perceived as worse than serial despite the wall-clock win (the gap scales with ceil((N-1)/C)). Pipeline instead: lazyBoundedMap starts the content fetches (<= C in flight) and returns one promise per outline immediately; the serial loop awaits them in order, so each resolves as soon as its own content is ready (usually already done, hidden behind the previous scene's actions/TTS). The first scene now paints after content(1)+actions(1)+TTS(1) — same as serial — with the full concurrency benefit and no stall. Also from the review: - a content fetch can no longer take its siblings down: an unexpected throw is caught and returned as a failure result, routed through the same mark-failed path as the serial loop (symmetric); - drop the cosmetic Phase-1 setCurrentGeneratingOrder — the loop already sets it per outline; - note the intentional belt-and-suspenders clamp; - concurrency.test.ts asserts <= limit (not == limit), and gains lazyBoundedMap tests including the no-barrier property. mapWithConcurrency is kept as a thin await-all wrapper over lazyBoundedMap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
d7c0d80afe |
feat(tts): per-agent auto-voice quality + register-once timbre stability (#670) (#672)
* feat(tts): voxcpm voice-design types + deterministic voice id helpers (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): emit + persist per-agent voiceDesign descriptor (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): voxcpm voice registration backend client (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): voxcpm-voice ensure/register endpoint (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): client auto-voice registration + reference-clip cache (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): reference registered voice id in vLLM-Omni speech (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): thread voiceDesign + backend through tts call sites (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): match vLLM-Omni voice registration contract (#670) Live e2e against the real backend revealed the assumed multipart contract was wrong: POST /v1/audio/voices needs name + consent + audio_sample (not voice_id/file), and there is no per-name GET (405) — existence must list /v1/audio/voices and check membership. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(tts): make auto-voice register-once pattern provider-neutral (#670) The descriptor + register-once/reference-by-id pattern is not VoxCPM-specific. De-couple it into a provider-neutral seam so other registration-capable providers (ElevenLabs/MiniMax/Doubao voice cloning, …) can plug in: - lib/audio/voice-design.ts: VoiceDesign + buildVoiceDesignPrompt / normalizeVoiceDesign / getDeterministicVoiceId (neutral 'auto-<hash>' id, namespaced by providerId). - lib/audio/voice-registration.ts: VoiceRegistrationAdapter interface + providerId->adapter registry + supportsVoiceRegistration. - lib/audio/voice-registration-client.ts: neutral ensureRegisteredVoice() + IndexedDB clip cache. - app/api/generate/voice (replaces .../voxcpm-voice): dispatches by providerId. - voxcpm-registration.ts becomes the VoxCPM adapter (sole registered provider). - AgentConfig.voiceDesign + DB cache table renamed neutral. Behavior-preserving; VoxCPM-specific bits (inline (prompt)text, backend kinds, capability gate) stay in the voxcpm modules. Full suite green (674). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): build the voice clip Blob from an inline Uint8Array (#670) A factored-out helper returning Uint8Array widened to Uint8Array<ArrayBufferLike>, which next build (stricter than bare tsc) rejects as a BlobPart. Inline the buffer like the rest of the repo does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): managed TTS providers resolve model server-side (#670) A server-managed TTS provider's model was still client-driven, but the managed-provider settings UI hides the model field — so a backend whose model id isn't the client default (e.g. VoxCPM/vLLM-Omni serving the full model path) always 500'd. resolveTTSModel() makes the model authoritative from server config (${PREFIX}_MODELS, first entry) for managed providers, like key/baseUrl; unmanaged/unconfigured providers keep the client model unchanged. Used by the tts and voice routes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): single agent-voice resolver + stable teacher narration voice (#670) Route all TTS voice resolution (narration, discussion, preview) through one resolveAgentVoiceOptions(agent, ...) that reads the agent profile and registers + references the voice by id. Eagerly warm up generated agents' voices on save. Critically fixes the teacher narration drifting (male/female jumps): the registry is always seeded with DEFAULT_AGENTS, so the old narration lookup find(role==='teacher') returned the default teacher (no voiceDesign) instead of the generated one — so narration never registered a voice and fell back to the inline prompt. pickNarratorAgent() now prefers the teacher carrying a voiceDesign. Also: drop language from the deterministic voice id (descriptor already encodes it) so narration (directive) and discussion (locale) resolve the same id; log the effective registeredVoiceId. Regression tests for pickNarratorAgent. Verified e2e: agent-profiles -> 1 voice registration -> 16 narration TTS all referencing the same registeredVoiceId; voice present on the backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tts): fall back to persona as the voice seed for agents without a voiceDesign (#670) Preset/default agents carry no LLM voiceDesign. Rather than add a static field, derive the bootstrap descriptor from the agent's persona when voiceDesign is absent, so they still register one stable reference voice (stable-but-generic; persona is not a vocal spec). Generated agents keep their LLM voiceDesign. Also hardens replay: stage snapshots drop voiceDesign but keep persona. Unit-tested: resolveAgentVoiceOptions uses real voiceDesign when present, persona otherwise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): match Auto Voice by its localized label in the agent voice picker (#670) The picker filtered on voice.name ('Auto Voice'), but Auto Voice is shown via its localized label (自动音色). Searching '自动' returned '没有匹配音色'. Match the localized label for the Auto Voice option so it's findable in any language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): address code-review round 1 (#670) - strip parentheses in the voice-design prompt so a paren in the descriptor/ persona can't corrupt the (prompt)text bootstrap delimiter - gate POST /api/generate/voice on isServerTTSProviderDisabled (#665): a force-disabled provider was off for the tts route but not this sibling - check voiceExists before re-registering a client-cached clip, so a cached voice that's still live isn't needlessly re-uploaded every session - dedup concurrent ensureRegisteredVoice calls via an in-flight promise map (eager warm-up + first utterance no longer double bootstrap/register) - warm up only the narrator (teacher), not every generated agent, to avoid synthesizing voices at save time for agents that may never speak - collapse the duplicated vLLM-Omni speech payload into one object Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(tts): prettier formatting (#670) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): key auto-voice memo by (voiceId, backend) so a base-URL change re-registers (#670) Addresses review (cosarah): registeredThisSession/inFlight were keyed by voiceId alone, so switching the VoxCPM base URL mid-session made ensureRegisteredVoice short-circuit and return an id registered only on the old backend — TTS then sent that stale registeredVoiceId and skipped the inline fallback, failing with voice-not-found on the new backend. Memo key now includes the base URL; the IndexedDB clip cache stays keyed by voiceId (the reference clip is backend-independent and reused to re-register elsewhere). Regression test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tts): include API key in the auto-voice memo key (#670) Addresses review (cosarah, round 2): memoKeyFor was keyed by (voiceId, baseUrl) but not the API key. Registration/existence checks and speech calls are auth-scoped, so switching account/key on the same base URL could reuse a registeredVoiceId from the old credentials and skip re-validation. Memo key now includes the API key (in-memory only, never persisted/logged). Regression test covers the key-switch case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
3e1e9f083b |
feat(tts): unify provider-enablement model, default browser-native off (#665) (#666)
A single TTS provider-enablement model honored everywhere (picker,
auto-assignment, fallback, lecture/playback, server).
Model
- lib/audio/provider-enablement.ts: isTTSProviderConfigured /
isTTSProviderEnabled / listEnabledTTSProviderIds. Availability drops the
registry defaultBaseUrl fallback (fixes Lemonade's always-shown voices),
honors the per-provider `enabled` flag, server-disable overrides (server
precedence).
- voice-resolver: getEnabledProvidersWithVoices (enabled server/custom
providers) and getSelectableProvidersWithVoices (the ONE client source of
truth = enabled providers + opt-in browser-native when the browser exposes
voices). resolveAgentVoice picks deterministically among the given providers,
honors a voiceConfig only while its provider is present.
- Picker + discussion build their list from getSelectableProvidersWithVoices, so
the AgentBar and the live discussion agree on selectable voices (fixes
student agents going silent in auto mode). Students resolve via
resolveAgentVoice; the teacher uses the global lecture voice verbatim (same
voice the pre-generated lecture sends), so lecture and discussion never
diverge.
- Voice assignment vs fallback: an agent's voice is bound at generation (the
agent-profile LLM picks from availableVoices; preset uses the user's pick).
The fallback for a missing/invalid/unavailable voice is applied only at the
live TTS calls, not baked into the saved agent — so it reflects current
provider availability.
Defaults / opt-in
- Browser-native: first-class, default OFF, opt-in. Built-in providers default
enabled:true (only surface once configured).
- TTS master toggle defaults OFF; first server-sync auto-enables it when a
provider is configured (mirrors image/video).
- Persist v3->v4 migration normalizes the never-user-editable flags.
Server/admin disable (server precedence): server-providers.yml `enabled:false`
and env `TTS_<P>_ENABLED=false` (incl. browser-native). Surfaced as a per-provider
`disabled` flag on /api/server-providers; merged as serverDisabled; enforced at
the /api/generate/tts trust boundary, classroom media generation, and /api/health.
UI
- Settings TTS panel: per-provider enable toggle, disabled (with a hint) when the
provider is unavailable, locked off (with a note) when server-disabled.
- AgentBar voice pills render disabled with a "No voice" placeholder when no
provider is enabled. The toolbar media-popover TTS tab keeps its existing
on/off toggle (no picker/CTA).
Tests: predicates, resolver determinism + unified selectable list, server
force-disable, store merge + migration + TTS auto-enable, i18n parity. Verified
in a real browser (Playwright): settings toggle states + a full Gemini classroom
generation whose TTS requests used the picked voice.
Found on main @
|
||
|
|
47d28145b4 |
feat(maic-editor): slide surface — MAIC Editor v0 (epic #562) (#615)
* feat(maic-editor): framework primitives + edit StageMode (#564)
* feat(maic-editor): framework primitives + edit StageMode
Phase 1 framework foundation for the MAIC Editor (RFC #547,
tracking #560). Plumbing only — no UI consumers ship in this
sub-PR; the EditShell chrome and slide surface registration
land in follow-ups.
- StageMode gains 'edit' alongside 'autonomous' | 'playback';
setMode resets canvas selection when leaving 'edit'.
- <Stage> auto-exits 'edit' whenever the current scene becomes
uneditable (no scenes / pending generation / no current
scene) so a follow-up Pro toggle can never strand the user
in an empty edit shell.
- SceneEditorSurface contract + tiny registry under lib/edit/
so each SceneType plugs in a surface without the shell
importing surfaces directly. Surfaces declare CanvasComponent,
useSurfaceState(), insert palette items, floating actions,
commands, and (reserved for AI) inline coach hints.
- Slide kernel (lib/edit/slide-ops.ts): immutable, history-aware
operations covering slide-update, element add / update /
updateMany / delete / deleteMany / reorder / duplicate / align /
removeProps, and text content edit.
- Slide element factories (lib/edit/slide-edit-elements.ts) for
default text / shape / image elements + HTML <-> plain-text
helpers.
- i18n: stage.editCourse + stage.doneEditing across all 6
locales, consumed by the header toggle in the next sub-PR.
- Vitest coverage for the slide kernel (operations + history)
and the edit-mode store transition (entry + canvas reset on
exit).
PBLRenderer's mode prop is widened from the literal pair to
StageMode so <SceneRenderer> (which already passes StageMode)
type-checks. The prop is unused inside the renderer.
* style(maic-editor): apply prettier to lib/edit + tests/edit
CI runs on PRs to main only, so the prettier check did not fire
for this PR's target branch — applying formatting locally before
the merge train reaches main avoids a follow-up style commit.
* ci: also run on PRs targeting feat/maic-editor-v0
The MAIC Editor lands as a series of stacked sub-PRs against
the long-lived feat/maic-editor-v0 branch. Without this entry,
none of those sub-PRs get a CI gate — style/lint/type/test
regressions only surface when feat/maic-editor-v0 finally
merges back to main, at which point fixing them is a lot more
disruptive than catching them per sub-PR.
Push trigger is intentionally left main-only: nobody pushes
directly to feat/maic-editor-v0, every change arrives through
a PR that now runs the gate.
* fix(maic-editor): address kernel review on #564
Addresses cosarah's review against #561 scope:
Important:
- element.align now uses the canonical lib/utils/element.ts geometry
helper instead of a forked copy. The local fork ignored
PPTLineElement start/end and rotation, so bounds were wrong for
lines and rotated elements.
- Cap slide-edit history at MAX_HISTORY = 50; drop oldest on overflow.
- Narrow slide.update patch to Partial<Omit<Slide, 'elements' |
'animations'>> via a new SlideMetaPatch alias, so element /
animation collections can only be mutated through their dedicated
ops.
- element.add throws on id collision; element.duplicate throws when
idMap is missing entries or when new ids would collide with
existing elements.
- scene-editor-registry dev-warns on overwriting a *different*
surface for the same SceneType (HMR re-register of the same
instance stays silent); add unregister() for HMR cleanup and tests.
Minor:
- Unify on structuredClone over JSON.parse(JSON.stringify(...));
inside immer's produce, un-proxy with current() first.
- Skip history push when produce returns the same content reference
(true no-op detection). element.delete / deleteMany pre-check
membership so their unconditional filter assignments don't break
the ref-equality signal.
- Drop redundant cloneSlideContent calls in undo/redo/push paths;
immer's structural sharing already guarantees immutability of the
produced output. createSlideEditHistory keeps its defensive clone
since the initial value comes from outside immer.
Coverage:
- Extract auto-exit predicate into lib/edit/stage-mode.ts so the
policy can be unit-tested without rendering <Stage>.
- New tests: every align direction, line / rotated element align,
no-op paths for update/delete/reorder/removeProps/text/align,
element.add index clamping + id collision, element.duplicate
default offset + contract errors, history future cleared after
branching, history capped, registry register/unregister/HMR-safe
re-register, and the auto-exit predicate.
371 vitest tests pass (was 335). tsc/lint/prettier/i18n/build all
green locally.
* fix(maic-editor): close kernel escape hatches (subagent CR follow-up)
Two defense-in-depth fixes flagged by independent review after the
prior commit:
- element.duplicate now deep-clones the source via
structuredClone(current(element)). The previous shallow spread
shared nested mutable references (start/end tuples, outline,
points) with the source; immer's COW would have handled most
mutations but ops that operate on nested arrays in place
(sort/reverse/splice) would silently leak between source and
duplicate. The deep clone keeps the kernel's invariants
independent of how downstream op consumers write their recipes.
- slide.update gains a runtime guard that throws when patch
contains elements / animations. The type-level SlideMetaPatch
narrowing already forbids these keys, but the runtime guard
closes the `as any` escape hatch for callers that might bypass
the type system.
New tests cover both paths: meta-only slide.update succeeds, an
elements-containing patch throws, and a duplicated line element's
start/end/points tuples are independent from the source.
* feat(maic-editor): EditShell chrome and Pro mode toggle (#565)
* feat(maic-editor): EditShell chrome and Pro mode toggle
Adds the scene-type-agnostic editor chrome (EditShell + CommandBar +
FloatingToolbar + HintRail), an edit-mode sidebar, and the header Pro
toggle that flips into the 'edit' StageMode from #561.
No scene editor surfaces are registered yet — the next sub-PR wires up
the slide surface. In this PR every scene type falls through to the
i18n unsupportedScene placeholder, which is the verifiable visible
behavior.
- canEdit gating reuses the canonical isCurrentSceneEditable predicate
shipped in #561 so the toggle and the auto-exit effect are in
lock-step.
- handleToggleEditMode tears down live session / engine / TTS before
entering edit mode.
- ChatArea slides out in edit mode for a full-width canvas.
- reorderScene extracted from EditModeSidebar with unit tests; the
positional-order preservation is the part worth a guard test.
- i18n scoped to keys this PR's components actually reference;
surface-specific keys deferred to the slide-surface PR.
* test(reorder-scenes): single-element + reference-inequality cases; zh-CN newSlide distinct from addSlide
CR follow-ups:
- reorderScene tests now cover a 1-element array (both directions
return null) and explicitly assert the returned array is a new
reference, not the input.
- zh-CN edit.sidebar.newSlide was duplicating the addSlide label
("新建幻灯片" both); using "未命名幻灯片" for the default new-slide
title to match the English Add slide / New slide distinction.
* refactor(maic-editor): drop EditModeSidebar; clean Pro mode chrome (#568)
Course-correct on #565. EditModeSidebar was rejected by the design
owner as inappropriate for Pro mode (#560 wording is "minimal top
bar + slide thumbnail rail", not a file-list panel). #565 also left
the playback chrome wrapped around the editor — Header / sidebar /
Roundtable / ChatArea all stayed mounted with only the sidebar
swapped, and EditShell's CommandBar/FloatingToolbar/HintRail were
never visible since no surface registers yet.
Drop EditModeSidebar + reorder-scenes helper + tests + the edit.sidebar
i18n block (8 keys x 6 locales) + the CommandBar sidebar-toggle.
Stage keeps Header mounted in both modes — it owns the global Pro
toggle Switch, which is the entry AND exit affordance (closing the
Switch exits; no separate Done-editing button). In edit mode:
SceneSidebar / Roundtable / ChatArea are not mounted, and the canvas
slot renders <EditShell scene> instead of <CanvasArea>. EditShell
internally resolves the surface via sceneEditorRegistry; when none
is registered it falls through to edit.unsupportedScene. With no
surfaces registered, every scene type lands on that placeholder —
the visible v0 behavior.
New optional EditShell.leftRail slot reserves the spot for a
redesigned slide-navigation surface; v0 ships with the slot empty.
SceneRenderer is now playback-only — the mode === 'edit' branch and
its sidebarCollapsed / onToggleSidebar props moved up to EditShell /
Stage.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): enablement infrastructure (pre-slide-surface) (#571)
* feat(maic-editor): enablement infrastructure (pre-slide-surface)
Pre-requisite for the slide surface (#562). Ships the safety
infrastructure so each subsequent surface PR is small and
recoverable:
1. Feature flag NEXT_PUBLIC_MAIC_EDITOR_ENABLED, default OFF —
gates the Pro toggle in Header. StageMode unchanged.
2. SlideContent.schemaVersion + pure idempotent migrateSlideContent
/ migrateScene; setScenes / addScene funnel legacy data through
the migrate at the store boundary.
3. tests/edit/round-trip/ harness: apply ops -> buildPptxBlob ->
JSZip parse -> assert content survived. No PPTX -> Slide reimport
exists in the codebase, so the full reimport-diff shape isn't
doable; per-op assertions extend the harness in #562.
buildPptxBlob is now exported (hook is still the only runtime
caller).
4. Per-scene slide-history persistence helpers (persist / load /
has / clear, keyed maic-editor:slide-history:${sceneId}, swallow
storage failures) + standalone SlideHistoryRestorePrompt dialog
+ 4 new i18n strings x 6 locales. Stage wiring deferred to #562.
5. Concurrency guards: isSceneEditLocked predicate (defensive; no
current call path structurally hits it); localStorage-backed
multi-tab edit lock with tryAcquire / refresh / release / heldByOther,
stale-lock takeover after 3x heartbeat; standalone
MultiTabEditConflictPrompt + 3 new i18n strings x 6 locales.
Stage wiring deferred to #562.
The slide-surface PR owns the edit-entry effect machinery (where
the history-state lifecycle and per-tab tabId ref naturally live),
so shipping half-wired dialogs here would speculatively build Stage
state we know we'll restructure on contact with the surface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): migrateSlideContent forward-compat — no silent downgrade
CR follow-up: previously, content with schemaVersion newer than
CURRENT (e.g. v2 written by a future client) was silently truncated
back to the current version. Now: if schemaVersion >= CURRENT, return
the content untouched. The slide may not render correctly on an older
client, but its on-disk shape stays intact for the next compatible
client to read.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): slide surface skeleton + #571 wiring + geometry (#562) (#579)
PR1 of the slide-surface work (infra-first slice). Registers the slide
SceneEditorSurface so EditShell lights up Pro mode for slide scenes.
- SceneEditorSurface impl + sceneEditorRegistry registration; the surface
owns a SlideEditHistory via the #564 kernel.
- Reuse the unmodified slide renderer Canvas through a surface-owned
scene context; geometry drag/resize/rotate commits funnel into
element.update ops (scene-edit bridge), one gesture = one undo step.
- Geometry numeric x/y/w/h/rotate popover as the precise fallback; gated
off for line elements (PPTLineElement omits height/rotate).
- Wire #571 infra: cross-tab edit lock + conflict prompt, slide-history
persistence + restore prompt, regen-lock guard.
- Renderer-commit classification: a real geometry gesture commits
synchronously inside a pointer interaction; the renderer's
ResizeObserver text-normalization commits with none, so it is folded
into the baseline (no undo step / no persist / no spurious restore
prompt on entry) instead of being staged as a user edit.
- Per-op round-trip test for element.update geometry; bridge + session
unit tests; edit.geometry i18n across all 6 locales.
Upstream-shared changes are kept minimal and additive: an optional
`controller` prop on SceneProvider (uncontrolled/playback path
unchanged) so staged edits don't write through to the live stage store,
and a FloatingToolbar trigger-nesting fix (it wrapped PopoverTrigger
around <Tooltip>, a provider, so no popoverContent action could open).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* test(maic-editor): lock data-URL image PPTX round-trip (PR2 R1 gate)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): insert palette — text box + image (data-URL/URL)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): address Task 1 review — spy cleanup, popover-only comment, ImagePicker error log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): drop PR1 debug geometry toolbar; element-aware floating bar
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): drop redundant PPTTextElement cast (Task 2 review)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): additive ProseMirror command bridge for the property bar (C1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): exhaustiveness guard + tidy C1 adapter (Task 3 review)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): refresh property-bar attrs on caret/keyboard selection (C2)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(maic-editor): satisfy no-explicit-any in PR2 test stubs (Task 1+4 review)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): compact text property bar in the reused floating slot
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): Task 5 review — uniform selection-guard, Lucide icons, JSX, memo
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* i18n(maic-editor): edit.text.* + edit.insert.* across 6 locales
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(maic-editor): round-trip gate for formatted text + inserts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(maic-editor): clarify remote-URL image round-trip scope (Task 7 review)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): drop stale scaffolding comment + orphaned geometry i18n keys
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(maic-editor): prettier --write PR2 files (pre-push check)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(maic-editor): make no-explicit-any suppression prettier-robust
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): CommandBar insert popover never opened (PopoverTrigger wrapped a Tooltip provider)
Insert→Image was unreachable: InsertButton wrapped <PopoverTrigger asChild>
around <Tooltip> (a context provider, no DOM node), so Radix's Slot bound
no element. Chain both triggers onto the real <button>, exactly mirroring
the PR1 fix already in FloatingToolbar's ActionButton. PR2's insert-image
is the first popoverContent InsertButton consumer to exercise this path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): text property bar no longer clips/overflows in the floating popover
The ~450px single-row bar was jammed into FloatingToolbar's fixed w-72
(288px) PopoverContent and clipped. Let the popover size to content
(w-auto, max-w-[92vw], Radix handles edge collision) and harden the bar
row (w-max + no child shrink, fixed-width font select) so it renders as
one clean line. Chrome/surface layout only — no renderer change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): property bar stays open across consecutive formatting steps
execCommand refocuses the editor after every command; the uncontrolled
Radix popover treated that focus-shift as focus-outside and dismissed,
forcing a re-open of the Text bar for each format action. Prevent
onOpenAutoFocus (don't steal the canvas selection on open) and
onFocusOutside (editor refocus must not dismiss); Escape and pointer-down
truly outside still close it. Chrome-only, no renderer change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): editor canvas now resolves gen_img_* media placeholders
The editor's interactive ImageElement rendered elementInfo.src raw,
so entering Pro mode on any slide whose image was a generation
placeholder showed a broken-image icon (while playback's read-only
BaseImageElement correctly resolved the placeholder to the generated
objectUrl). Extract the resolution into a shared useResolvedImageSrc
hook so both variants stay aligned. Strictly additive: for any
non-placeholder src (legacy / direct URL / data URL) resolvedSrc ===
elementInfo.src and the media store is not subscribed to. Pre-existing
upstream gap surfaced by PR2 as the first real-user editor consumer —
same shape as the CommandBar popover-trigger fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(maic-editor): unit-test gen_img placeholder resolution (9 cases)
Splits useResolvedImageSrc into a pure resolveImageSrc function (no
hooks) wrapped by the hook, so the resolution logic can be unit-tested
in vitest's plain node environment (no jsdom/RTL needed in this repo).
Covers: done→objectUrl; no task→raw; pending/generating/failed→raw;
done with no objectUrl→raw; cross-stage isolation; no-stageId path;
non-placeholder src passes through (the additive contract).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(maic-editor): auto-save edits to stage store, drop staging UX
Reverses PR1's "staged edits don't write through to the live lesson"
design. The slide-edit-session now writes through every history move
(applyOp / user commit / ResizeObserver normalization / undo / redo)
to useStageStore.updateScene as the canonical source of truth, which
Dexie already auto-persists. The renderer reads from the stage store
via the controller's getSnapshot.
Removes the entire staging surface that has no place in a modern
editor (Figma/Notion/Google Docs have no "unsaved changes" concept):
- DEL lib/edit/slide-history-persistence.ts (localStorage layer)
- DEL tests/edit/slide-history-persistence.test.ts
- DEL components/edit/SlideHistoryRestorePrompt.tsx (restore dialog)
- DROP pendingRestore field + restore() action from slide-edit-session
- DROP restorePrompt branch + handlers from useSlideCanvasController
- DROP edit.history.restore.* keys across all 6 locales
Edits now flow: user input → renderer onUpdate → controller.updateSceneData
→ slide-edit-session.commitContent → writeThrough(useStageStore.updateScene)
→ Dexie. There is nothing "unsaved" to restore, by design.
The session retains its in-memory undo/redo history (per Pro session)
and the user-vs-ResizeObserver gesture classification (so reflow
doesn't push undo steps).
Test suite rewritten to assert write-through on every history move and
no write-through on seed (the stage already has that content).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): address PR review — element delete affordance + cross-platform fonts
Two issues from review on #586:
1. A selected image/text element couldn't be deleted — the renderer's
delete lives only in a right-click menu, undiscoverable in Pro mode.
Add a Delete button to the FloatingToolbar for any single selected
element (text or image), dispatching the existing element.delete op.
Button-only, consistent with #560's keyboard-shortcuts deferral.
2. Switching fonts had no effect on macOS Chrome — the property bar's
font list was a hardcoded SimSun/SimHei set (Windows-only system
fonts the renderer never loads). Use OpenMAIC's canonical FONTS
registry (configs/font.ts) — the web fonts the renderer actually
loads, so a pick renders identically on every platform.
Adds edit.delete × 6 locales + the parity-test key; floating-actions
unit tests for the delete action.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): selection-anchored text editing for the slide surface (#590)
* feat(maic-editor): add resolveEditingElementId text-editing policy
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): drop text-format floating action (moves to anchored bar)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): surface hooks to derive and sync editingElementId
Add useResolvedSlideContent / useEditingTextElementId / useSyncEditingElementId.
Realign the PR2 buildFloatingActions tests with the new behavior (text
formatting moved off the FloatingToolbar) and co-locate the editing-state test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(ui): export PopoverAnchor from the popover wrapper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): add useTrackedRect for element screen-rect tracking
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): add AnchoredTextBar selection-anchored format bar
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): wire anchored text bar + editing flag into SlideCanvas
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): draw a clean solid frame for the text element being edited
Gated on the canvas store's editingElementId (default ""), so the dashed
select frame is unchanged for multi-select and for any consumer that never
sets the flag. Editor-path only; playback never renders Operate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): drop the editor focus ring so text editing shows one frame
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(maic-editor): prettier-format the editing-state test import
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): anchor the bar to the text element node, not the wrapper
Code review caught that #editable-element-{id} is a zero-size absolute
wrapper — measuring it would pin the bar to the canvas origin. Measure the
.editable-element-text child, which carries the real geometry. Also correct
the dismiss-behavior comment: the bar is purely selection-driven.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): modernize the text format bar UI
Replace the native <select> font picker with the design-system Select,
rebuild the size control as one cohesive stepper pill, swap the color "A"
for a swatch chip, and unify every control to a single height and hover/
active language (violet accent, matching the editor's Pro-mode accent).
Behavior and the text commands are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): curate the font picker to fonts the app actually loads
configs/font.ts listed 29 fonts but the app only ever loads Inter (via
next/font); the other 28 had no @font-face or bundled file, so picking them
silently fell back with no visible effect — and nothing but the format bar
even imports the registry. Trim it to what genuinely renders; the file's
comment records how to restore the rest (wire up font loading first).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): load the picker fonts via @fontsource
The font registry listed 29 fonts the app never loaded. Wire up a curated
set that genuinely renders — 思源黑/宋, 霞鹜文楷, 站酷快乐体, and 9 Latin
families — via @fontsource packages (npm-managed, no font binaries in the
repo; CJK faces are unicode-range-subsetted so they download lazily per
glyph range). app/editor-fonts.ts registers the @font-face CSS from the
root layout; configs/font.ts is now the real, honest 14-entry list.
The ~14 commercial decorative Chinese fonts are intentionally left out —
they need self-hosting + subsetting + a licensing review, separate work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): quote font-family names so spaced/numeric ones work
Picking a font whose family name has spaces or a trailing digit (e.g.
"Source Sans 3") threw `Failed to execute 'check' on 'FontFaceSet'` —
`document.fonts.check(\`16px ${name}\`)` needs the family quoted — and the
fontname mark's toDOM emitted an invalid unquoted `font-family`, so the
font silently never applied. Quote the family in both spots; the mark's
parseDOM already strips quotes, so the attr still round-trips clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): make the editing frame pointer-events-none
The clean editing frame is a purely visual full-size overlay, but it was
pointer-events: auto — so it masked the text element's own move cursor,
text cursor, click-to-place-caret and drag-to-move; only a thin uncovered
sliver at the edges still triggered them. The dashed BorderLines it
replaced are thin edge lines, so they never had this problem. Mark the
frame pointer-events-none; the resize/rotate handles are separate and
keep their own pointer events.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): drop the "font is loading" toast
With @fontsource fonts and font-display: swap, a picked font swaps in
smoothly on its own — the "Font is loading, please wait..." toast was
noise (and fired on most CJK picks while a unicode-range chunk loaded).
Remove it along with the now-unused document.fonts.check and toast import.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): move the delete action onto the anchored text bar
A text element's contextual actions now sit together on the anchored bar —
format controls + delete, hugging the element — instead of delete sitting
alone in the top-center FloatingToolbar. buildFloatingActions returns
nothing for text (its FloatingToolbar then renders null); non-text
elements still get their delete there. Delete logic is shared via a new
deleteSlideElement helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): anchor the delete action for image elements
A selected image element now gets a selection-anchored bar hugging it —
just a delete button (image replace/crop/flip stay in a later sub-PR) —
the same way text elements do. The anchoring shell is extracted out of
AnchoredTextBar into a reusable AnchoredBar, and the delete button into a
shared DeleteButton; AnchoredTextBar and the new AnchoredImageBar are thin
wrappers. useTrackedRect now measures .editable-element-text or
.editable-element-image. buildFloatingActions returns nothing for image
elements too (other element types still get their delete there).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(maic-editor): tighten the anchored bar padding (p-2 → p-1)
p-2 left a chunky white margin around the content — most visible on the
image bar, a lone delete button in an oversized box. p-1 (4px, the value
the FloatingToolbar used) makes both bars sit snug to their controls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): anchor the delete bar for every element type
The selection-anchored delete bar now covers all non-text element types
(shape, line, table, chart, …), not just image — so every element's
editing chrome is anchored uniformly. AnchoredImageBar becomes the
type-agnostic AnchoredDeleteBar; useTrackedRect matches any
.editable-element-{type} content root; buildFloatingActions is dropped —
the surface no longer contributes top-center FloatingToolbar actions,
everything is on an anchored bar.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): show legacy font names in the picker trigger
When a text element's `fontname` was a value not in the curated FONTS
registry (e.g. `Microsoft YaHei`, `PingFang SC`, theme defaults), the
Select couldn't match it and `<SelectValue/>` rendered a blank trigger —
both reviewers (cosarah Important, xuyuanwei678 #1) caught this. Add a
placeholder fallback so the raw family name surfaces in the trigger.
Also clean up the dead `'默认字体'` label that `text-format-bar.tsx`
overrode unconditionally: introduce an optional `labelKey` field on
`FontEntry`, use it for the default entry, and let the picker prefer
the i18n key when present — no more by-value special case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): address cr minors
- `marks.ts` fontname `toDOM` rejects `"` or `\` instead of interpolating
them: a hand-crafted mark with `fontname: 'X"; background:url(...);'`
could otherwise close the quoted string and inject arbitrary CSS.
- `AnchoredBar` gains `onOpenChange` (clears the canvas selection on
Radix-initiated dismiss): silences the controlled-without-handler dev
warning, and brings back Esc / SR dismissal that our focus-outside
hardening had cut off.
- `useSyncEditingElementId` folds two `useLayoutEffect`s into one with
a cleanup; the previous unmount-only effect was structural noise.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: pin body padding-right so popovers don't reflow the page
Radix Select / Popover wrap with `react-remove-scroll`, which adds a
compensation `padding-right` to <body> when they open. Our <html>
already reserves the scrollbar gutter (`scrollbar-gutter: stable` +
`overflow-y: scroll`), so the compensation added a visible ~15px shift
on every dropdown open. Pin body's padding-right with `!important` so
the page stays still. (xuyuanwei678 review #2.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): surface legacy font names via SelectValue children
The earlier placeholder approach didn't work — Radix's `placeholder` only
fires for an empty `value`, not for an unmatched non-empty one. So an
element with a legacy fontname (e.g. `Microsoft YaHei`, `PingFang SC`,
theme defaults) outside the curated FONTS registry still rendered a blank
trigger. Render the trigger text via `SelectValue` children instead — the
new `currentFontLabel` helper covers all three cases: matched → entry's
i18n / fallback label, unmatched non-empty → the raw family name, empty
→ the default-font label. Unit tests cover each case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): preventDefault on pointer-down-outside so drag/resize work
The onOpenChange handler added to silence the Radix dev warning + restore
Esc dismissal also fired on pointer-down-outside — i.e. on every mousedown
on the selected element to drag it or grab a resize handle. That cleared
the selection before the drag could start, so nothing on the canvas could
be moved or resized. preventDefault on `onPointerDownOutside` (matching
the existing `onFocusOutside` hardening) keeps the bar selection-driven
while leaving Esc as the legitimate onOpenChange path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): arm-and-place insertion for text boxes
Replaces the "auto-insert at a hidden default position" UX. Click
`Text box` → arms text-insertion: the button takes the violet active
style, and the renderer's existing ElementCreateSelection overlay turns
the canvas cursor into a crosshair. On the canvas:
- click → 300×60 box at the click point
- drag → a box at the dragged rect
Either way the new box is auto-selected (addElement defaults that on),
and the surface's existing useEditingTextElementId picks it up so the
AnchoredTextBar opens on it. Esc disarms; clicking the armed button
again disarms (toggle).
Completes the text branch in the renderer's `useInsertFromCreateSelection`
(pptist scaffolding left it TODO) and bypasses the 200² square fallback
in `ElementCreateSelection` for the text type (a square wouldn't suit a
text box). `InsertPaletteItem` gains an `active?` field so `CommandBar`
can render the armed style.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): render list bullets in slide text
Tailwind's preflight resets `list-style` to none, so the format bar's
`bulletList` toggle wrapped selected text in `<ul><li>` but no marker
ever appeared — the button looked inert. Scope a list-style restoration
to `.editable-element-text ul/ol/li` so bullets / numbers render in the
slide text without leaking into the rest of the app.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): editable font-size input in the text format bar
The size was a read-only `<span>` between the −/+ steppers. Replace with
an `<input type=text>` that mirrors `attrs.fontsize` locally, commits on
Enter / blur (clamped to [8, 96]; non-numeric reverts), and reverts on
Escape. Adds the `edit.text.fontSize` aria-label key in all 6 locales.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): force list markers visible (defeat preflight specificity)
The earlier list CSS didn't survive Tailwind's preflight (which also
resets `padding: 0` on `<ul>`/`<ol>`, so with `list-style-position: outside`
the markers had no room to render). Add `!important` on `list-style` and
`padding-inline-start`, and broaden to also match `.prosemirror-editor ul`/
`ol`/`li` in case the markup ever nests differently than expected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): reset richTextAttrs when the editing element changes
`richTextAttrs` is a single shared store updated by whichever ProseMirror
was last focused. Switching from one text element to another visibly
carried the previous element's toggle states (bold / italic / alignment /
list) on the format bar for a moment — until the new element's
ProseMirror took focus and repopulated the attrs. `useSyncEditingElementId`
now resets the attrs to defaults whenever the editing id changes, so the
bar shows a neutral state during the transition instead of stale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): replace OS color dialog with a curated palette popover
Clicking the text-color swatch opened the browser's native `<input type=color>`
dialog — off-brand and inconsistent across platforms. Swap it for a
`ColorPicker` popover: a 12-swatch grid covering the common slide-text needs
(4 neutrals + warm + cool) plus a hex input for anything else. Closes on
pick. Selected swatch gets the violet outline; hex input commits on Enter /
blur (reverts if invalid).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(maic-editor): replace flat swatch popover with a real color picker
The previous popover was a chunky 12-swatch grid plus a hex input nobody
types into. Rebuild on `react-colorful` (3KB, well-tested):
- SV pad + hue slider for free-form picking, with scoped CSS overrides to
keep the picker tight (128px pad height) and rounded — not stock.
- OS eyedropper via the EyeDropper API, feature-detected (Chrome / Edge;
hidden on Safari / Firefox).
- Row of 10 small (18px) common colors at the foot for one-click reach.
- Current-color preview + read-only hex display.
- Hex input dropped entirely — picking is meant to be tactile.
Live preview while dragging; the popover closes on a swatch / eyedropper
commit (not on drag).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): keep the color popover open while dragging the picker
Each SV-pad / hue-slider drag tick fires onChange → dispatches the color
command → `editorView.focus()` pulls focus out of the popover into
ProseMirror. Radix's default onFocusOutside path was treating that as a
dismiss, so the popover closed the instant a drag started — clicking
anywhere on the picker shut it. preventDefault on `onFocusOutside`
(mirrors the AnchoredBar hardening) keeps it open; the popover still
closes on swatch / eyedropper commits and on outside-click / Esc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): scope body padding override + gate ColorPicker mid-drag sync
Two follow-ups from a self-CR on the branch:
- `body { padding-right: 0 !important }` was global, overriding Radix's
`react-remove-scroll` compensation for every Dialog / Sheet / Select /
Popover across the app. Scope it to a `body[data-maic-editor='true']`
selector; `SlideCanvas` sets the attribute while mounted. Non-editor
pages get Radix's default behavior back.
- `ColorPicker`'s `useEffect(() => setColor(value), [value])` mirror
could race a stale `value` against the user's current pointer position
mid-drag — a single late round-trip would snap the picker back. Gate
the re-sync on `isDragging.current` (cleared on `pointerup`); external
commits (swatch / eyedropper) still sync immediately because they fire
while no drag is in flight.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): polish from self-CR
- Gate the `richTextAttrs` reset in `useSyncEditingElementId` to only
fire on element-to-element transitions (track previous editing id via
a ref). The unconditional reset on the first selection briefly flashed
neutral defaults (color #000, fontsize 16px) before the focusing
ProseMirror repopulated the real values.
- Doc-comment the text-insertion add-element asymmetry: text uses the
renderer's `addElement` (because the rect math lives there and we get
auto-select for free), image uses surface-side `applyOp` (its source
is the ImagePicker, not a canvas gesture). Both commit through the
same store, but the text lane doesn't show as a typed `element.add`
op in the session history — acceptable, now explicit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): listen to every gesture-end channel in ColorPicker
CR round-2 residual nit: the single `pointerup` listener that clears the
drag-gate would silently keep the gate stuck on any browser / emulator
that only emits the older mouse/touch families. Listen on all four
(`mouseup`, `touchend`, `pointerup`, `pointercancel`) — belt-and-suspenders,
no behavior change on the common path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(maic-editor): preserve image aspect ratio on insert
`createDefaultImageElement` hardcoded the new image's box to 360×220, so
anything not ~1.6:1 (which is almost everything users upload — photos,
screenshots, logos) ended up squashed or stretched the moment it landed
on the slide. Wrap the factory in `insertImageElement` that measures the
source via `new Image()`, then dispatches `element.add` with dimensions
scaled to fit MAX 600×400 while preserving the natural ratio. Load
failure falls back to the factory default so insertion always succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): drop the now-dead addElement helper
`addElement` was only ever used by the inline image-insert which became
`insertImageElement`; text uses `armText` (toggle). PPTElement-typed
parameter was already unused after the text refactor — removing the dead
helper resolves the lint warning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(maic-editor): drop now-unused PPTElement import in use-slide-surface
After `addElement` was dropped (
|
||
|
|
35d36905d1 |
Make server-configured providers admin-managed, not client-overridable (#620) (#624)
* refactor(providers): make server-configured providers admin-managed, not client-overridable A server-configured provider is now authoritative: the operator owns its API key and base URL, and the client cannot override either. This collapses the tri-state (server key / client base URL / client key) that was the root of a recurring "missing API key" bug class — when the settings UI echoed a base URL with an empty key, routes that treated "any base URL ⇒ fully client-configured" dropped the server key. #533 patched that per-route via canUseServerApiKeyForBaseUrl; this removes the guard entirely by fixing the design. Server: - resolve*ApiKey/BaseUrl ignore client values when the provider is server- configured; add isServerConfiguredProvider(section, id); drop canUseServerApiKeyForBaseUrl + normalizeProviderBaseUrl. - getServer*Providers() no longer return base URLs — only allowed models (LLM/image) and the managed flag (presence) — so an internal gateway/proxy endpoint is never exposed to the client. - routes (chat/image/video/tts/asr/pdf + verify-*) resolve managed providers purely server-side; SSRF validation now applies only to unmanaged (client) base URLs. Client: - drop serverBaseUrl from ProviderSettings, the settings store, request-building (TTS base URL no longer echoes the server URL) and voice resolution. - settings UI renders managed providers read-only: the key/base-URL override inputs and region toggles are hidden, leaving the "managed by server" notice. Tradeoff: overriding a server provider's key with your own for the same provider is removed; add a separate custom provider instead. Closes #620. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(providers): refresh managed-provider copy, drop dead audio-settings, add #620 e2e - serverConfiguredNotice now says the provider is managed and not editable here, pointing users to add a separate provider for their own credentials (7 locales). - delete components/settings/audio-settings.tsx — unreferenced dead code. - add e2e/tests/managed-provider-620.spec.ts: managed provider hides the key/base-URL inputs; unmanaged provider keeps them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(providers): lock the model catalog for managed providers with a pinned list When the operator pins an allowed model list (MODELS env/yaml ⇒ serverModels), the model catalog is admin-managed too: the LLM provider panel shows the pinned models read-only with a "Server" badge and no add / reset / edit / delete affordances. Without a pinned list the server manages only credentials and the user keeps curating models. This removes the prior inconsistency where the settings panel let you add models to a pinned managed provider that the generation toolbar then filtered out. e2e: the managed-openai case now also asserts the catalog is locked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(providers): ignore (not reject) client overrides for managed web-search; clear legacy serverBaseUrl Addresses review feedback on #624: 1. Managed web-search providers now ignore a client-sent key/baseUrl instead of validating and rejecting it. A server-configured provider is admin-owned, so the server config is authoritative and a stale client base URL is dropped rather than 400-ing the request. The allowlist validation still runs for unmanaged providers, where the client base URL is actually used. 2. Drop the removed `serverBaseUrl` field from persisted localStorage on rehydrate (settings version 2 -> 3, stripped in both migrate and merge), so old clients don't keep a stale server URL in client state. Tests: web-search route test split into unmanaged-rejects-allowlist and managed-ignores-client-baseUrl; settings rehydrate test asserts a legacy serverBaseUrl is stripped. Full suite 381 pass; e2e 2 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
3149955548 |
fix: prevent memory leaks and silent export failures (#552)
* fix: prevent memory leaks and silent export failures - use-browser-asr: add cleanup on unmount to abort active SpeechRecognition and release microphone - use-streaming-text: use ref for onComplete callback to prevent animation restarts when parent re-renders - use-export-pptx: check resp.ok before consuming fetch bodies to avoid silent failures with corrupted images in exported slides * fix: correct bracket nesting in poster fetch resp.ok check The else block was missing its closing brace, causing Turbopack parse errors in CI (4 "Expected a semicolon" errors). * style: fix Prettier formatting for log.warn lines Log statements exceeded printWidth limit — Prettier auto-wrapped. * fix: move ref assignment into useEffect to satisfy react-hooks/refs ESLint rule ESLint error: "Cannot access refs during render" — moved `onCompleteRef.current = onComplete` into its own useEffect. Also removed now-unnecessary eslint-disable comment. * fix: restore eslint-disable for set-state-in-effect rule Removing the directive exposed a pre-existing lint error on line 72. The actual fix (ref assignment in useEffect) is preserved. --------- |
||
|
|
a5209d747e |
feat(outline-review): clickable streaming card morphs into editor (#558)
* feat: add outline review preview flow * style: refine outline review editor * fix(home): make draft cache survive round-trips through generation flows The `useDraftCache` hook initialised state via `useState`'s lazy initialiser. Under React 18+ SSR, the initialiser runs on the server (returns `undefined` because `window` is missing), the SSR'd state is sent to the client, and React reuses it on hydration without re-running the initialiser. As a result `cachedValue` was always `undefined` and nothing ever rehydrated from `localStorage`. The home page's `prevCachedRequirement` derived-state pattern then masked the symptom: it initialised `prev` to the same (always undefined) `cachedRequirement`, so the comparison never fired and the restore branch was unreachable. Switch to an effect-based read so consumers actually receive a state update after mount, and replace the home page's derived-state restore with a `useEffect` + `useRef` guard that only fills the input when it is empty. Side benefit: PBL chat draft and quiz answer caches start working too — they used the same broken pattern. * feat(outline-review): clickable streaming card morphs into editor + UX overhaul Major refresh of the outline review surface introduced in #198, applying the placement design from docs/superpowers/specs. Key shifts: Streaming card → editor morph - The streaming preview card in the generation-preview page is now an interactive surface (tilt + hover-straighten + always-visible "点击审阅" affordance) that morphs into the full editor via shared `layoutId`. - SSE keeps streaming uninterrupted into the expanded editor; the editor consumes either `session.sceneOutlines` or live `streamingOutlines` so scenes appear as they arrive and stay in sync after edits. - A header collapse button shrinks back to the preview card. Mid-stream collapse keeps SSE running; post-stream collapse re-arms the same 2.5s auto-continue timer the no-review path uses, so the card doesn't strand. Document-style editor - Replaces the bordered card + accordion + numbered rail with a Notion- feeling stack: drag handle and number share a flex left rail aligned to the title baseline; title/description are inline-editable borderless textareas with auto-resize. - ⋮ overflow menu and explicit move-up/move-down are removed; reordering is drag-only, with hover-revealed delete (popover-confirmed) next to the type pill. - Hover-revealed `+` insert dividers between scenes; faint `+` at the list edges replaces the "Add scene" bar so insertion is uniform. - Quiz config popover redesigned: stepper for question count, segmented control for difficulty, multi-select pills for question types so mixed types from the LLM round-trip correctly (the previous single-select silently truncated `questionTypes` to one element). - Streaming placeholder row pulses below the latest scene during SSE. - Scene types use a colour-coded pill that doubles as the type switcher. State + flow plumbing - Adds `previewPhase: 'review'` entry from the streaming visualizer via `outlineReviewIntentRef`; SSE-completion ORs in this intent when deciding whether to wait for confirmation. - `localStorage.removeItem('requirementDraft')` moves to after the review promise resolves so "Back to requirements" actually restores the draft. - Auto-start condition broadened so refreshes during early-review reattach to a fresh SSE rather than stalling on an empty editor. - Six new locales (en-US, zh-CN, zh-TW, ja-JP, ru-RU, ar-SA) for the expand hint, streaming progress, collapse, delete confirmation, insert hint, and quiz summary; locale coverage test asserts the new keys plus the `{{count}}` interpolation contracts. Removed: the redundant "Review outline" button + auto-continue caption under the preview card, since the card itself is now the entry point. Removed: the home toolbar "Outline" tab from #198 — placement design explicitly rejects adding another always-visible toolbar control. * fix(outline-review): address code review feedback (PR #558) Single follow-up commit batching the issues raised in code review: Critical - E2E page object's reviewOutlineButton was hunting for the removed "Review outline" button. Re-target it to the streaming card's outlineExpandHint aria-label across all six locales. Important - waitForOutlineReviewChoice now accepts an AbortSignal and rejects on abort, so goBackToHome / unmount settle the parked promise instead of leaking the awaiting startGeneration closure. The existing AbortError catch swallows the rejection cleanly. - Add CLDR plural variants for the count-bearing strings: en gets _one; ru gets _one and _few for quizConfigSummary; ar gets _one and _two for the three count-bearing strings. Bare key remains as _other fallback. (zh / zh-TW / ja have a single plural form.) - Document the mid-stream collapse intent: dropping outlineReviewIntentRef is deliberate so SSE-completion takes the no-review path. - Add keyboard reorder on the drag handle (Ctrl/⌘ + ArrowUp/ArrowDown) with aria-keyshortcuts; replace dragScene copy with dragSceneHint that surfaces the shortcut. Restores WCAG 2.1.1 reachability for reorder. - KeyPointInput no longer commits on blur — only Enter / comma. Avoids surprise chips when users click away mid-typing. Minor - Drop unused i18n keys: reviewOutlineAction, moveSceneUp/Down, dragScene, sceneDescriptionLabel, keyPointsLabel/Placeholder, quizConfigLabel, addScene. Locale coverage test pruned to match actual usage. - requestAnimationFrame-batch useAutoResize so a burst of edits doesn't thrash layout (forced reflow per keystroke). - Reset isConfirmingOutlines in the no-parked-promise fallback path of handleConfirmOutlines so state doesn't linger. * chore(outline-review): prettier format + drop redundant session check - Run prettier on outlines-editor.tsx (CI Lint job was red). - Remove `&& session` from the StepVisualizer onExpandOutline conditional; `session` is guaranteed non-null at that point by the early-return guard above (flagged by github-code-quality bot). * fix(home): silence react-hooks/set-state-in-effect on draft restore Match the existing eslint-disable convention already used in app/page.tsx for the other localStorage-hydration effects. The setState calls are deliberate: client-side state hydration from localStorage must happen in an effect to remain SSR-safe (the lazy useState initialiser runs on the server with `window` undefined). * revert(i18n): drop CLDR plural variants until key-alignment check supports them scripts/check-i18n-keys.mjs enforces strict key equality across all locales, which is incompatible with i18next's per-locale plural-suffix keys (a Russian locale needs `_one`/`_few`/`_many`/`_other`, an Arabic one adds `_two`/`_zero`, while zh / ja need none). The check script doesn't strip plural suffixes before comparing, so any partial pluralization breaks alignment. Roll back the partial fix from PR review #2 and revert to the bare keys. The original "ungrammatical for n=1" wording stays, to be addressed in a follow-up that also teaches check-i18n-keys.mjs to ignore CLDR plural suffixes when comparing key sets. * fix(outline-review): make streaming card expand-hint pill actually visible The "Tap to review" pill was rendering but invisible in practice because: - Both the bottom white gradient (z-auto) and the pill (z-10) sit inside the same parent stacking context, but motion.div applies a transform that creates per-element stacking contexts; the implicit ordering put the gradient above the pill in some browsers. - text-[9px] + bg-blue-500/95 was too thin/translucent to read against the white gradient even when on top. Make stacking explicit (gradient z-0, pill z-20), bump the pill to text-[10px] font-semibold with solid bg-blue-500, larger Maximize icon, and lift it slightly to bottom-2 so it sits above the gradient haze. |
||
|
|
be759a8252 |
feat: add lemonade to provider (#508)
* feat: add lemonade to provider * fix(lemonade): correct TTS/ASR model IDs and expand Kokoro voices - TTS model: kokoro → kokoro-v1 - ASR model: single 'whisper' → 6 Whisper variants (Base/Large-v3/Large-v3-Turbo/Medium/Small/Tiny), default Whisper-Base - Expand lemonade-tts voices to full Kokoro v1 set (54 voices across en-US/en-GB/zh-CN/ja-JP/es/fr-FR/hi/it-IT/pt-BR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lemonade): zh-TW i18n parity, real Kokoro default voice, language code consistency - zh-TW.json: add missing providerLemonadeTTS/ASR/Image keys - TTS default voice: 'default' → 'af_heart' (Kokoro expects real voice ID, not placeholder) - Voice language codes: 'es'/'hi' → 'es-ES'/'hi-IN' for consistency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(lemonade): add unit tests for image/TTS/ASR adapters and wav-utils Covers: - lib/media/adapters/lemonade-image-adapter: request shape, base URL fallback, custom model, optional Bearer auth, error paths, connectivity probe - lib/audio/tts-providers (lemonade-tts case): request body (model/voice/format), af_heart fallback, optional auth, error path - lib/audio/asr-providers (lemonade-asr case): WAV-only validation, model/language form fields, "audio is empty" graceful empty result, error propagation, default model fallback - lib/audio/wav-utils: isWavBlob detection by MIME and filename, normalizeASRUploadAudio pass-through and wav handling Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lemonade): remove text path fork and harden fallbacks * style: format lemonade tts files * i18n: add lemonade voice language labels * fix(lemonade): align image default model and harden wav detection * test(lemonade): fix File BlobPart typing * fix(lemonade): simplify tts voice selection and clear asr test state * docs: highlight Lemonade support --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
5e12ad892c |
[codex] add VoxCPM2 TTS provider (#496)
* add voxcpm2 tts provider * address voxcpm review feedback * translate voxcpm locale strings * narrow toolbar model pill * refine toolbar media controls * address voxcpm review followups |
||
|
|
e613b7578f |
[codex] add per-model thinking config (#494)
* add per-model thinking config * Refine thinking model controls --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
c02a607377 |
feat: interactive mode clean (#461)
* feat: add interactive mode * feat: add TeacherActions for interactive mode * fix: better prompt for ultra mode * feat: upgrade normal mode's interactive generation with 5 widget type and actions * feat: make interactive mode button clear * fix: remove unused TeacherOverlay * fix: resolve PR #458 review feedback - Revert unrelated infra changes (Dockerfile, docker-compose.yml) - Fix placeholder strings in generateWidgetContent - Rename ultraMode to interactiveMode consistently across codebase - Extract duplicated patchHtmlForIframe to shared utility - Fix widget store race condition by keying callbacks by sceneId - Fix teacher-action ID collisions by using descriptive IDs in prompt - Relax outline prompt constraints for flexibility - Optimize sidebar thumbnail with IntersectionObserver lazy loading - Add trailing newlines to modified files Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: update generateSceneActions call signatures The function signature changed to individual parameters instead of options object pattern after cherry-pick. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: fix prettier formatting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: removed extra language arg * style: fix prettier formatting v2 * fix: add missing i18n keys --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
470aa5e42e |
feat: inline language inference for outline and PBL generation (#412)
* feat: inline language inference in outline generation Replace manual language selection with automatic inference from user requirement text. The outline generator now produces a languageDirective that propagates through the entire generation pipeline. Key changes: - Rewrite Language Inference section in outline prompt with decision rules for foreign language learning, cross-language PDF, proxy requests, and terminology handling - Reorder pipeline: outlines before agents, so agent profiles can use the inferred languageDirective - Pass languageDirective through scene content/actions generation - Add SSE streaming of languageDirective from outline generation - Remove manual language selection UI Eval test suite: - 50 test cases covering 9 scenario types: single-language, language learning, immersive, explicit instruction, code-switching, minimal input, user profiles, cross-language PDF, locale mismatch - LLM-as-judge evaluation with configurable inference/judge models - 50/50 pass rate with gemini-3-flash-preview + gpt-4o judge - Excluded from CI (requires LLM API keys) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove useless attributes in test case * feat: pass languageDirective to PBL, remove hardcoded zh-CN/en-US - Remove pblConfig.language field, use languageDirective from outline inference instead - Delete all Chinese prompt duplicates in pbl-system-prompt.ts, agent-templates.ts; keep English templates with languageDirective injection - Remove zh-CN/en-US branches in generate-pbl.ts (initial prompt, post-process context, welcome message) - Pipe languageDirective through scene-generator → generatePBLContent → IssueboardMCP → agent templates - Remove i18n template wrapping from PBL welcome messages in pbl-renderer.tsx and use-pbl-chat.ts; use LLM-generated content directly to avoid language mismatch when UI locale ≠ course language - Update pblConfig schema in outline prompt (remove language field) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: clean up legacy language fields, pass languageDirective through buildSceneFromOutline - Add languageDirective param to buildSceneFromOutline, combining with outline.languageNote via buildLanguageText - Remove dead types: AudienceProfile, StylePreferences, LegacyUserRequirements, SceneOutline.language - Remove Stage.language and all its propagation (storage, stageInfo, prompt-builder fallback) - Remove GenerateClassroomInput.language and job store inputSummary.language - Remove unused pdfLanguageSample from outline SSE route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: persist languageDirective to IndexedDB, fix TTS preview and dead refs - Add languageDirective to StageRecord and stage-storage write path - Add DB v9 migration: convert legacy language locale codes to directives - Use voice.language for TTS preview text instead of dead localStorage key - Omit empty ## Language section in PBL agent prompts - Use JSON.parse for extractLanguageDirective unescape (\uXXXX support) - Remove dead i18n keys: toolbar.languageHint, pbl.chat.welcomeMessage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
9a0060e2f0 |
feat(audio): add custom OpenAI-compatible TTS/ASR provider support (#357) (#409)
* feat(audio): extend TTSProviderId/ASRProviderId to support custom providers * feat(audio): update provider registry helpers for custom provider support * feat(audio): route custom providers to OpenAI-compatible implementations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(audio): add custom TTS/ASR provider CRUD to settings store * feat(audio): add dialog for creating custom audio providers * feat(audio): add custom provider UI with voice management and delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(i18n): add custom audio provider translation strings * fix(audio): resolve remaining type errors for custom provider support Add `as keyof typeof` casts where TTSProviderId/ASRProviderId (which now include template literal types) index into the built-in-only registries TTS_PROVIDERS, ASR_PROVIDERS, DEFAULT_TTS_VOICES, and DEFAULT_TTS_MODELS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(audio): allow custom provider IDs to reach switch default branch The early guard `if (!provider) throw` blocked custom provider IDs from reaching the switch-case default branch where they get routed to OpenAI-compatible implementations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(audio): add default model field to dialog and polish voice list UI - Add "Default Model" input to custom audio provider dialog (fixes Kokoro needing model:"kokoro" instead of fallback "gpt-4o-mini-tts") - Redesign voice list as aligned table with column headers, hover reveal delete buttons, and empty state message - Add Enter key support to voice add inputs - Use consistent grid layout for add voice row Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): fallback to customDefaultBaseUrl when testing custom TTS provider The baseUrl field in provider config is empty by default (it's an override field). Custom providers store their default URL in customDefaultBaseUrl, which needs to be used as fallback for test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): auto-select first voice when adding to custom TTS provider When a custom provider's voice is 'default' (initial state before any voices are added), adding the first voice now automatically updates the active voice selection so test TTS works immediately. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): fallback to customDefaultBaseUrl in all TTS call sites Scene generator, discussion TTS, and agent bar voice preview all read providerConfig.baseUrl which is empty for custom providers (the default URL is stored in customDefaultBaseUrl). Add fallback chain to all 4 call sites so custom providers work during actual playback, not just in the settings test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): include custom provider voices in agent voice picker getAvailableProvidersWithVoices only iterated built-in TTS_PROVIDERS, so custom providers never appeared in the agent bar voice dropdown. Now also iterates ttsProvidersConfig for custom-tts-* entries and includes their customVoices in the picker. Also updated getServerVoiceList, findVoiceDisplayName, and resolveAgentVoice to handle custom provider voice lookups. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): fallback to customDefaultBaseUrl in all ASR call sites Same issue as TTS: audio-recorder, asr-settings, and audio-settings all read providerConfig.baseUrl which is empty for custom providers. Add customDefaultBaseUrl fallback to all 3 ASR call sites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(audio): add model list management for custom ASR providers Custom ASR providers now support adding/removing models with a table UI (same design as TTS voice list). The model selector dropdown shows custom models and auto-selects the first one. Also adds custom ASR providers to the Toolbar's ASR provider dropdown and fixes custom provider handling in audio-settings.tsx (baseUrl fallback, request URL preview, API key section visibility). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): seed customModels from defaultModel and fix ASR model label - When creating custom ASR provider with a defaultModel, auto-add it to customModels so the model list isn't empty on first visit - Fix ASR settings label from "TTS Model" to "Default Model" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): separate ASR model management from creation dialog ASR models should be managed in provider settings (add/remove from list, select default), not pre-filled at creation time. Unlike TTS where a single default model suffices, ASR users need to manage multiple models (e.g. whisper-small, whisper-large-v3). - Remove Default Model field from ASR creation dialog (keep for TTS) - Remove defaultModel parameter from addCustomASRProvider - ASR models are added via the model list UI in provider settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): simplify ASR model management — first model is default Remove the separate model selector dropdown for custom ASR providers. The first model in the list is always used as default. Users manage the model list only (add/remove), no separate "default" selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): add loading state and detailed errors to ASR test - Show spinner with "Processing..." while waiting for transcription - Disable record button during processing - Show specific error messages from API (e.g. "No transcription generated") instead of generic failure text - Handle empty transcription result as an error with helpful message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(i18n): use correct key for processing label in ASR test button * fix(audio): polish custom provider UX and type safety - Add proper type declarations for custom provider fields (customName, customDefaultBaseUrl, customVoices, etc.) eliminating 30+ unsafe casts - Show custom ASR providers in toolbar with default language list - Hide custom ASR providers without models from toolbar selection - Add no-models warning in ASR settings panel - Add delete confirmation dialog for custom TTS/ASR providers - Add duplicate voice/model ID validation with toast error - Add dedicated i18n keys for ASR model management (modelNamePlaceholder, addModel) - Fix i18n key for ASR processing label (settings.asrProcessing) - Add radio-button model selection in custom ASR provider settings - Add visible OpenAI-compatible description in provider creation dialog - Add CUSTOM_ASR_DEFAULT_LANGUAGES constant for toolbar language options Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): add isCustomTTSProvider/isCustomASRProvider to audio types mock The settings-server-sync test mocked @/lib/audio/types as an empty object, but settings.ts now imports isCustomASRProvider for provider validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(audio): add customDefaultBaseUrl fallback in all TTS/ASR call sites When a custom provider is created, the actual URL is stored in customDefaultBaseUrl while baseUrl is initialized empty. Several call sites only checked baseUrl, causing fetch failures during course generation. Add fallback to customDefaultBaseUrl in: - generation-preview/page.tsx (course generation TTS) - tts-providers.ts (getCurrentTTSConfig) - asr-providers.ts (getCurrentASRConfig) - media-popover.tsx (TTS preview) - tts-config-popover.tsx (TTS preview) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format generation-preview/page.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused ttsSpeedRange variable in media-popover Flagged by CodeQL code quality check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
2c26e3a461 |
feat: Ollama local LLM support (#94)
* feat: Ollama local LLM support - Ollama as a built-in provider (OpenAI-compatible, no API key needed) - 8 default models: llama3.3, llama3.2, qwen2.5, qwen2.5:32b, mistral, gemma3, deepseek-r1, phi4 - Keyless provider activation: providers with only a baseUrl (no API key) now activate correctly - isProviderKeyRequired() helper for consistent key validation across routes - i18n strings for Ollama (zh-CN + en-US) and missing Doubao strings - SSRF guard documentation clarifying server-configured URLs bypass safely Previously, clients could send `x-requires-api-key: false` (header) or `requiresApiKey: false` (body) to skip API key validation for ANY provider, including ones like OpenAI that require keys. This meant a client could consume server-configured credentials without authorization. Fix: the server now derives requiresApiKey exclusively from its own provider registry. The client header and body field are removed from all server-side parsing. For Ollama, the registry says `requiresApiKey: false` so it works keyless. For OpenAI, the registry says `true` so the check is enforced regardless of what a client sends. * fix: address reviewer feedback on Ollama integration - Fix env-var activation so OLLAMA_BASE_URL alone activates the provider - Move SSRF bypass comment from ssrf-guard.ts to resolve-model.ts - Add ollama and doubao provider names to i18n locale files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fixed issues from reviewer notes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format provider-config.ts with Prettier Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
cce7087f67 |
Fix action filtering logic and add safety improvements (#163)
* fix: fix 3 bugs found during code review 1. formatQuestionsForPrompt called .join() on QuizOption objects, producing "[object Object]" in AI prompts instead of readable option text (e.g. "A. Option1, B. Option2"). 2. json-repair Fix 1 regex double-escaped valid JSON escapes (\t, \n, \r, \b, \f) and couldn't handle strings with escaped quotes. Now uses a proper string-aware regex and preserves valid escapes. 3. PlaybackEngine spotlight/laser actions used synchronous recursion via processNext(), risking stack overflow with many consecutive non-speech actions. Now uses queueMicrotask() to break recursion. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: ensure allowedActions whitelist applies to non-slide scenes The early return in Step 6 (slide-only action filter) caused Step 7 (allowedActions whitelist) to be skipped for non-slide scenes. This meant hallucinated actions from agents in quiz/interactive/pbl scenes were not filtered by the role-based allowedActions whitelist. Refactored to use a mutable result variable instead of early returns so both filters always apply. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: prevent video playback hang and division by zero in CJK detection 1. executePlayVideo could hang forever if the video element was invalid or the state change was missed between playVideo() and subscribe(). Added a 5-minute safety timeout to prevent indefinite blocking. 2. CJK language detection in browser TTS divided by chunkText.length without checking for empty strings, producing NaN. Now guards against zero-length text. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: prevent ordered entry with invalid index when partial text has no remaining content When a previously-partial text item completed but all content had already been streamed as deltas (remaining is empty), the code still pushed an ordered entry pointing to textChunks.length - 1, which could be -1 or reference an unrelated chunk. This caused spurious warnings in director-graph and potential text loss. Now only pushes the ordered entry when there is actual remaining content to emit. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: validate quiz points parameter and fix parse-pdf metadata spread order 1. quiz-grade: `points` from request body was used without validation, causing NaN in score calculation and invalid JSON responses when points was undefined, negative, or non-numeric. Now validates it is a positive finite number. 2. parse-pdf: metadata spread order put the default `pageCount` before `...result.metadata`, so the spread would overwrite the fallback. Fixed by spreading metadata first, then applying defaults after. Also changed `||` to `??` so pageCount=0 is preserved correctly. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: crash in moveUpElement, dead-code in video options, wrong parens in canCombine 1. use-order-element.ts: moveUpElement crashed with TypeError when a grouped element was already at the top of the z-order. The code accessed copyOfElementList[maxLevel + 1] without bounds checking, then dereferenced .groupId on undefined. Added early return guard matching the pattern used in the non-grouped branch. 2. video-providers.ts: normalizeVideoOptions had a dead-code ternary inside an if-block that already proved the condition was false. Simplified to directly use the fallback value. 3. use-canvas-operations.ts: canCombine check had incorrect parenthesization: (el.groupId && el.groupId) === firstGroupId evaluated the && first, which is equivalent to just el.groupId. Fixed to: el.groupId && el.groupId === firstGroupId. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * style: fix prettier formatting in 3 files https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm * fix: use valid ApiErrorCode in quiz-grade route INVALID_PARAMETER is not a member of API_ERROR_CODES. Use INVALID_REQUEST instead, which is the appropriate code for invalid request parameters. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
ad9e0ee7c6 |
refactor(i18n): migrate to i18next framework (#331)
* refactor(i18n): migrate to i18next framework Replace hand-rolled i18n with i18next + react-i18next so that adding a new language only requires dropping a JSON file in lib/i18n/locales/. - Add i18next, react-i18next, i18next-browser-languagedetector deps - Generate zh-CN.json / en-US.json from existing TS translation modules - Rewrite lib/i18n/index.ts as a thin wrapper around i18n.t() - Rewrite use-i18n hook to delegate to useTranslation(); external API (locale, setLocale, t) is unchanged so consumers need no changes - SSR-safe: LanguageDetector only loaded on client side Closes #327 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(i18n): use interpolation for greeting to support natural phrasing Replace string concatenation (greeting + displayName) with two i18next keys: greetingWithName (with {{name}} interpolation) and greetingDefault (standalone, no name). This lets each locale choose natural phrasing independently: - zh-CN: "嗨,同学" / "嗨,Alice" - en-US: "Hi there" / "Hi, Alice" - Future locales can avoid gender issues by choosing genderless defaults Also widen the t() type signature to accept interpolation options. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(i18n): auto-discover locale files via dynamic import Replace hardcoded zh-CN/en-US imports with i18next-resources-to-backend and dynamic import(`./locales/${language}.json`). Bundler scans the locales/ directory at build time, so adding a new language now requires only dropping a JSON file — zero changes to existing code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(i18n): resolve hydration mismatch by deferring language detection LanguageDetector ran during i18next init(), detecting browser language before React hydrated — server rendered zh-CN while client switched to en-US immediately, causing a hydration mismatch. Fix: remove i18next-browser-languagedetector; init with a fixed lng (zh-CN) so server and client agree on the first render. Language detection is now done in I18nProvider's useEffect after hydration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(i18n): remove hardcoded locale list from language detection Replace manual locale validation and startsWith('zh') prefix matching with i18next's built-in fallback mechanism. Now changeLanguage() is called with navigator.language directly — if the exact locale has no JSON file, i18next automatically falls back to fallbackLng. Also widen Locale type from union to string so adding new languages doesn't require modifying types.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(i18n): add course language config with 11 languages and UI linkage - Add lib/i18n/course-languages.ts with curated language list (zh-CN, zh-TW, en-US, ja, ko, fr, de, es, pt, ru, ar) including native labels and English prompt names - Course language defaults to UI locale on first visit; once user explicitly picks a language, that choice persists across sessions - Replace toggle button with dropdown selector showing native labels - Widen language types from 'zh-CN'|'en-US' to string throughout - Fix hardcoded language ternaries in LLM prompt injection: - prompt-builder.ts: use getCourseLanguagePromptName() - classroom-generation.ts: remove normalizeLanguage() that forced all non-English to zh-CN - PBL system prompt, agent templates, generate-pbl: append language instruction for non-zh/en languages - quiz-grade API: add language suffix for grading feedback Closes #327 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(quiz): use course language instead of UI locale for grading quiz-view was passing the UI locale to the grading API, causing AI feedback to follow the student's answer language instead of the course language. Now reads stage.language from the store. Also strengthen the grading prompt: explicitly instruct the LLM to write comments in the course language regardless of the student's input language. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert: remove course language config and quiz fix from i18next branch Reverts |
||
|
|
3c687dd523 |
fix(tts): unify model selection to per-provider and fix ElevenLabs model_id (#326)
* fix(tts): unify model selection to per-provider and fix ElevenLabs model_id (#324) - Move model selection from global ttsModelId to per-provider ttsProvidersConfig[providerId].modelId - Remove supportsModelSelection flag, use models.length > 0 instead - Add defaultModelId to TTSProviderConfig and ASRProviderConfig - Add providerOptions field to TTSModelConfig for future extensibility - Fix ElevenLabs hardcoded model_id: 'eleven_multilingual_v2' - Fix MiniMax TTS using config.model instead of config.modelId - Update model lists from provider docs: - Qwen: add qwen3-tts-instruct-flash, qwen-tts - ElevenLabs: add eleven_flash_v2_5, eleven_flash_v2 - MiniMax: remove non-existent speech-2.8 series - Replace custom model CRUD UI with simple Select dropdown - Remove MiniMax-specific hardcoded model selector from audio-settings - Add migration for old ttsModelId/asrModelId/model fields * feat(tts): voice picker groups by provider:model, sets both model and voice Voice selection in agent bar now groups voices by model. Picking a voice from a model group sets both modelId and voiceId on the agent. TTS settings shows available models as display-only chips. - Add compatibleModels field to TTSVoiceInfo (OpenAI marin/cedar restricted to gpt-4o-mini-tts) - Add modelId to agent voiceConfig and ResolvedVoice - Add ModelVoiceGroup to ProviderWithVoices, build groups in getAvailableProvidersWithVoices - Update AgentVoicePill and TeacherVoicePill to iterate modelGroups - Thread modelId from voice selection through discussion TTS pipeline - Replace model Select with display-only chips in tts-settings * fix(tts): show provider name in voice picker group headers * fix(tts): restore MiniMax speech-01 models per official API docs * fix(tts): add MiniMax speech-2.8 models per official docs, default to speech-2.8-hd * feat(tts): expand MiniMax voice list with recommended voices * fix(tts): remove non-official MiniMax recommended voices, keep 10 from official list * fix: address CR findings - align defaults, fix test mocks, ASR modelId fallback * fix(tts): pass modelId in voice preview and classroom generation * fix(tts): voice picker highlights only the exact model:voice match --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
5adf91c6f3 |
feat(settings): add configurable model selection for TTS and ASR (#108)
Add ttsModelId/asrModelId to persisted settings, model management UI for providers that support model selection, and propagate selected model through preview, generation, and transcription flows. Co-authored-by: 刘少杰 <liushaojie@cvte.com> |
||
|
|
1735cedb43 |
fix(roundtable): prevent TTS segments from overlapping on rapid pause/resume (#286)
Null audioRef.current in server TTS ended/error/catch handlers to prevent stale Audio elements from being replayed by subsequent pause/resume calls. Add pausedRef guard before scheduling processQueue(), consistent with the browser TTS path. Closes #285 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
e3bdc4707e |
feat: keyboard shortcuts for roundtable (T/V/Escape/Space) (#256)
roundtable/index.tsx: - T: toggle text input box (calls handleToggleInput) - V: toggle voice input (calls handleToggleVoice, gated on asrEnabled) - Escape: dismiss open input/voice panels + cancel recording - Space: discussion pause/resume (existing, now part of unified handler) - All shortcuts guarded against input/textarea/contentEditable focus stage.tsx: - Space (lecture play/pause) now works outside presentation mode - Arrow keys remain presentation-only to avoid scroll interference Closes #255 |
||
|
|
2a770d0a0c |
fix(tts): delegate discussion TTS pause/resume to browser speech API (#253)
- Add currentProviderRef to track active TTS provider - Destructure pause/resume from useBrowserTTS - Delegate pause() to browserPause or audioRef.pause() based on provider - Delegate resume() to browserResume, audioRef.play(), or processQueue - Guard processQueue and onEnd against queue advancement while paused - Reset currentProviderRef in cleanup() Closes #245 |
||
|
|
815cdc22fc |
fix: presentation mode bubble polish and TTS timing improvements (#244)
* fix: use fixed width for normal mode speech bubble to prevent streaming jitter Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add click-to-pause, AudioIndicator, and play/pause icon to presentation bubbles Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: anchor ProactiveCard to agent avatar in presentation mode dock Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: bubble click events, ProactiveCard anchor timing, and dock sidebar overlap - Add pointer-events-auto to presentation bubbles so clicks pass through - Use agent avatar ref directly for ProactiveCard anchor (removes fallback) - Shift dock left when chat sidebar is open to prevent overlap Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: align presentation bubble icon with roundtable breathing bars and hover behavior Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent play/pause icon from overlapping bubble text Add pr-10 padding to text area and align icon position with roundtable style. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace AudioIndicator bars with volume icon and loading spinner in presentation bubbles Avoids visual conflict with the breathing bars play/pause icon at bottom-right. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace AudioIndicator bars with volume icon and loading spinner in roundtable bubbles Syncs with presentation bubble style for consistency. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: add breathing opacity animation to volume icon during TTS playback Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: use ripple wave animation for volume icon during TTS playback Three concentric rings expand outward from the volume icon to indicate active audio playback. Replaces the opacity breathing animation. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent blank flash between same-agent speech segments Only clear bubble text on agent_start when switching to a different agent. Same-agent continuation keeps the previous text visible until the next segment starts revealing, avoiding a lingering first bubble and blank gap. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent TTS queue from blocking text reveal across segment boundaries The shouldHold() callback checked both isPlaying and queue.length, which meant the buffer stayed stuck on segment 1's text while segment 2's TTS was generating/playing. Now shouldHold() only checks isPlaying. Also changed queueMicrotask to setTimeout(0) in audio.ended callbacks so the StreamBuffer's 30ms tick has a chance to observe isPlaying=false and release its hold before processQueue sets it back to true. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: release StreamBuffer hold per-segment instead of waiting for entire TTS queue Previously, shouldHold() returned a single boolean based on global TTS state (isPlaying || queue.length > 0). When speech was split into multiple segments by an action, the buffer would hold on Segment 1's text even after its audio finished, because Segment 2's audio started immediately. Now shouldHold() returns a segmentDone counter that increments each time a segment finishes. The buffer snapshots this counter when hold begins and releases as soon as it changes, allowing text to advance to the next segment even if its audio is already queued. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: replace ripple circles with sound wave arcs on volume icon Three SVG arcs emanate from the right side of the volume icon with staggered opacity animation, resembling speaker sound waves. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: simplify volume icon to static display without animation Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: enable scrolling in presentation bubble when text overflows Replace overflow-hidden with overflow-y-auto and scrollbar-hide to maintain proper bottom padding when content exceeds max height. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: increase bottom padding and max height for presentation bubble text area Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: pause and resume TTS audio when discussion is paused/resumed Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: defer TTS playback when paused during generation When discussion is paused while TTS is still fetching, the audio element is created but not played. Resume will start it. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: check holding flag before segmentDone counter in TTS hold logic The object returned by shouldHold is always truthy, so the falsy release path was dead code. Now checks result.holding first: if false (queue empty), release immediately. Only use segmentDone counter comparison when holding is true. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore ProactiveCard anchor fallback to dock container When agent avatar hasn't mounted yet, fall back to dock container so the card can still position and render. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: stop TTS playback and abort active SSE stream on stage unmount Previously, leaving a classroom while a discussion was active would leave TTS audio playing and the API call streaming in the background. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: abort active SSE stream and shutdown buffers on chat sessions unmount When navigating away from a classroom (e.g. clicking the back button), the chat sessions hook now aborts any active API request and shuts down all stream buffers. This prevents orphaned SSE connections and runaway agent loops after leaving the stage. Refs #236 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review feedback - Reset pausedRef and segmentDoneCounterRef in cleanup() to prevent stale state on hook reuse - Remove unused audioAgentColor prop from PresentationBubbleCard - Capture chatAreaRef.current to local variable in Stage unmount effect Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
0533adbe82 |
feat: refine presentation mode speech bubbles, input flow, and accessibility (#195)
* feat: add presentation mode with fullscreen, idle-hide, and keyboard navigation - Fullscreen via toolbar button or F11; exit via ESC/F11/button - Header auto-hides, sidebars collapse, slide fills viewport - Idle auto-hide (3s): toolbar/avatars fade out, speech bubble stays visible - Smart suspension: idle-hide pauses during typing/recording/voice input - Keyboard navigation: Arrow keys (prev/next), Space (play/pause), ESC (exit) feat: add presentation mode with fullscreen, idle-hide, and keyboard navigation - Fullscreen via toolbar button or F11; exit via ESC/F11/button - Header auto-hides, sidebars collapse, slide fills viewport - Idle auto-hide (3s): toolbar/avatars fade out, speech bubble stays visible - Smart suspension: idle-hide pauses during typing/recording/voice input - Keyboard navigation: Arrow keys (prev/next), Space (play/pause), ESC (exit) - F11 intercepted to use Fullscreen API (ESC-friendly) instead of browser native - Whiteboard hints reposition from bottom to top corners in fullscreen - i18n: fullscreen/exitFullscreen keys (zh-CN + en-US) Closes #102 .git/COMMIT_EDITMSG [unix] (14:47 22/03/2026) 1,1 Top feat: add presentation mode with fullscreen, idle-hide, and keyboard navigation - Fullscreen via toolbar button or F11; exit via ESC/F11/button - Header auto-hides, sidebars collapse, slide fills viewport - Idle auto-hide (3s): toolbar/avatars fade out, speech bubble stays visible - Smart suspension: idle-hide pauses during typing/recording/voice input - Keyboard navigation: Arrow keys (prev/next), Space (play/pause), ESC (exit) - F11 intercepted to use Fullscreen API (ESC-friendly) instead of browser native - Whiteboard hints reposition from bottom to top corners in fullscreen - i18n: fullscreen/exitFullscreen keys (zh-CN + en-US) Closes #102 .git/COMMIT_EDITMSG [unix] (14:47 22/03/2026) 1,1 Top - F11 intercepted to use Fullscreen API (ESC-friendly) instead of browser native - Whiteboard hints reposition from bottom to top corners in fullscreen - i18n: fullscreen/exitFullscreen keys (zh-CN + en-US) Closes #102 * feat: refine presentation mode speech bubbles, input flow, and accessibility - User messages now display as right-side speech bubbles for send confirmation - 'Your turn' cue is a clickable glass button respecting ASR preference - Cue properly excludes bubbleRole/thinkingState to avoid UI overlap - Right-side dock + bubble uses flexbox for dynamic stacking - Voice-to-text switch now stops recording to prevent mic leaks - Backdrop no longer blocks toolbar (bottom-14 cutoff) - User message clears immediately when agent starts responding - Dock buttons and avatar have proper aria-labels and button semantics - Voice panel mic trigger converted from div to button with aria-label - Added 5 i18n keys (zh-CN + en-US) for new accessible labels - matchesSide typed as boolean, removed unused userAvatar from left overlay - Avatar alt text localized via i18n Refs #102 * fix: address code review audit cancel ghost sends, consolidate getAgentConfig, fix a11y * fix: guard concurrent voice recording and suppress cancel error toast * style: vertically center placeholder text in presentation input bar * style: full light-mode adaptation for presentation UI, toolbar dividers, bubble opacity 60% * chore: address #129 follow-up remove orphan i18n keys, clarify waitUntilDrained pause behavior * fix: resolve spacebar conflict and address review feedback - Stage: skip Space during active QA/discussion (let Roundtable own buffer-level pause); deterministic, no listener-order dependency - Stage: wrap handlePlayPause in useCallback([playbackCompleted, currentScene]) to avoid keyboard listener re-registration - Stage: clear presentationIdleTimerRef on unmount - PresentationBubbleCard: add aria-live='polite' for screen readers - Extract shared avatar constants to roundtable/constants.ts * style: format with prettier * fix: render ProactiveCard inside fullscreen container via portalContainer prop * feat: show requesting agent avatar in dock on discussion request --------- Co-authored-by: YizukiAme <yizukiame@github.com> |
||
|
|
ddb52241fb |
feat: add Discussion TTS with per-agent voice assignment (#211)
* feat(tts): add resolveVoice() and getServerVoiceList() utilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): add AudioIndicator equalizer bars component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): add onSegmentSealed callback to StreamBuffer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): add voiceOverrides field to AgentConfig and AgentTemplate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): add useDiscussionTTS hook with audio queue and cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): add audio state indicator to Roundtable bubble
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tts): wire onSegmentSealed callback through chat sessions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): add per-agent voice dropdown to AgentBar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): integrate useDiscussionTTS in Stage and pass state to Roundtable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(tts): refine voice dropdown to pill-style selector
Replace native select styling with a compact rounded-full pill
that blends into the agent row. Remove border, use muted bg,
smaller text, and a custom chevron icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(tts): use shadcn Select for voice dropdown, link with TTS toggle
- Replace native <select> with shadcn Select component for consistent UI
- Hide voice dropdown when TTS is muted (ttsMuted)
- Compact pill-style trigger with rounded-full, no border, muted bg
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(tts): add voice label prefix and always show dropdown
Show "音色: Alloy" instead of plain "Alloy" in the voice pill.
Always show dropdown regardless of mute state (voice config is
independent of playback).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(tts): add volume icon hint in collapsed AgentBar
Show a small Volume2 icon in the collapsed pill when voice config
is available, hinting that voice settings are inside.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): fix voice dropdown layout and click handling
- Move voice pill below agent name (second line) to prevent
horizontal overflow in English
- Wrap Select in div with onPointerDown stopPropagation to fix
Radix click-through to parent row
- Add line-clamp-1 to descriptions for consistent row height
- Use items-start instead of items-center for better multi-line alignment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(tts): redesign AgentBar voice layout for compactness
- Single-line layout: checkbox · avatar · name · role · voice pill
- Remove descriptions from agent rows (saves vertical space)
- Extract AgentVoicePill component to isolate Select event handling
- Smaller avatars (size-7), tighter row padding (py-1.5)
- Voice pill uses Volume2 icon + voice name (no prefix text)
- Works in both Chinese and English without overflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): cross-provider voice selection per agent
- Change voiceConfig from per-provider lookup to explicit
{ providerId, voiceId } per agent
- Each agent can use a different TTS provider's voice
- Voice picker dropdown groups voices by provider
- useDiscussionTTS routes TTS requests per agent's provider
- resolveAgentVoice falls back to global provider if no config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): align role badge and voice pill across agent rows
Give role badge fixed width (w-14 text-right) so role text
and voice pills align vertically across all rows regardless
of agent name length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): fix role badge and voice pill alignment
Wrap role badge + voice pill in a fixed-width container
(w-[9.5rem] justify-end) so both align vertically across
all agent rows regardless of name or role text length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(tts): align role badge and voice pill across agent rows
Add min-w-[52px] text-right to role badge so it starts at a
consistent position regardless of agent name length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): use fixed w-[60px] for role badge alignment
* fix(tts): use fixed w-[88px] for voice pill alignment
* fix(tts): prevent click-outside from closing AgentBar when Select portal is open
* fix(tts): comprehensive voice picker rewrite
- Replace Radix Select with Popover + button list (fixes click issue)
- Fix getAvailableProvidersWithVoices to always include global provider
- Widen panel from w-80 to w-96 (prevents name truncation)
- Voice pill uses primary color instead of gray (more visible)
- Extract renderAgentRow helper to reduce duplication
- Popover shows voices grouped by provider with active state
- Add findVoiceDisplayName utility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): align voice provider availability with toolbar logic
* fix(tts): fallback to first available provider when global provider has no voices
* refactor(tts): remove global provider fallback from voice resolution
Voice resolution now only depends on available providers (those with
API keys or server-configured). No more globalProviderId parameter.
Fallback is first available provider, then browser-native-tts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): add browser native TTS voices to agent voice picker
Load speechSynthesis.getVoices() in AgentBar and include as a
"Browser Native" provider group in the voice popover. No API key
needed - always available if browser supports it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): simplify toolbar TTS to on/off toggle, add disabled state
Toolbar:
- Replace volume slider with simple TTS on/off toggle button
- Remove ttsMuted/ttsVolume/onVolumeChange props from CanvasToolbar
- Toggle now controls ttsEnabled (not ttsMuted)
AgentBar:
- Collapsed: show VolumeX icon when TTS disabled
- Voice pills show disabled state (gray, cursor-not-allowed, no popover)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(tts): simplify Settings TTS tab to toggle + provider config
Remove voice selection, speed slider, preview/test, Azure locale
filter from Settings TTS tab. Voice is now per-agent in AgentBar.
Keep: on/off toggle, provider selector, API key + base URL config.
Add hint text pointing to AgentBar for voice configuration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(tts): simplify media popover TTS tab to toggle only
* fix(tts): add voice config hint to media popover TTS tab
* feat(tts): add per-voice preview button in voice picker
Each voice row in the popover has a small speaker icon button.
Click to preview the voice with "欢迎来到AI课堂" / "Welcome to
AI Classroom" (follows i18n). Browser native uses Web Speech API,
server TTS calls /api/generate/tts. Click again or close popover
to stop. Shows spinner while generating.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): preview text follows course language instead of UI language
* refactor(tts): redesign AgentBar expanded panel layout
- Teacher always at top with voice pill (works in both modes)
- Mode tabs moved below teacher
- Auto mode: single compact row with shuffle icon + description
- Max turns: compact inline row with smaller input
- Preset mode: only student agents listed (teacher already above)
- Remove large shuffle animation from auto mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(tts): merge max turns into teacher row
* refactor(tts): separate teacher row and max turns, use stepper UI
- Teacher row: avatar + name + voice pill only
- Max turns: bottom row with MessageSquare icon + compact stepper
(minus/number/plus in a rounded pill)
- Remove Input component dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): increase voice pill contrast in dark mode
* fix(tts): make max turns input editable, tighten panel padding
* fix(tts): restore shuffle animation in auto mode (compact version)
* fix(tts): adjust auto mode text spacing and add voice auto-assign hint
* fix(tts): auto-close voice popover after selecting a voice
* fix(tts): increase auto mode vertical padding for better balance
* fix(tts): push auto mode text toward bottom with flex spacer
* fix(tts): reduce auto mode bottom padding
* feat(tts): wait for TTS audio to finish before next agent turn
Add waitForDrain() to useDiscussionTTS that returns a promise
resolving when the audio queue is empty. The agent loop in
useChatSessions now awaits this after buffer drain, so the next
agent's turn doesn't start until the current agent's TTS audio
finishes playing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): keep bubble visible while TTS audio is still playing
When buffer drains (text=null) but audio indicator is still active,
don't clear liveSpeech. Clear it only when audio state goes idle.
This keeps the speech bubble visible until TTS finishes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): hold discussion bubble until TTS audio finishes
When StreamBuffer fires the done signal (onLiveSpeech null), Stage
now checks if TTS is still playing. If so, it defers clearing the
bubble state. The bubble stays visible until onAllAudioEnd fires
from the TTS hook (queue empty + nothing playing), then clears.
This prevents the jarring UX where the bubble disappears while
the agent's voice is still audible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): fix bubble hold - guard onStopSession instead of onLiveSpeech
Root cause: bubble disappears because doSessionCleanup fires via
onStopSession when the agent loop ends naturally, NOT because of
onLiveSpeech(null, null).
Fix: when onStopSession fires and TTS is still playing, defer
doSessionCleanup to onAllAudioEnd callback. Manual stop (user
presses button) still cleans up immediately via handleStopDiscussion.
Use doSessionCleanupRef to avoid circular dependency between
discussionTTS hook and doSessionCleanup useCallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): guard BOTH onLiveSpeech and onStopSession for bubble hold
Two paths clear the bubble:
1. onLiveSpeech(null, null) from StreamBuffer done → clears liveSpeech
2. onStopSession → doSessionCleanup → clears all state
Both fire when agent loop ends. Path 1 fires first (tick loop),
path 2 fires after (waitUntilDrained resolves). Both must be
guarded when TTS is still playing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): hold bubble during TTS playback and respect playback speed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(tts): LLM picks voice matching agent persona during generation
- Client sends available voices (providerId + voiceId + name) to
/api/generate/agent-profiles
- LLM prompt asks to pick a voice matching each agent's personality
- Parse "providerId::voiceId" from response, save as voiceConfig
- Fallback to index-based assignment if LLM doesn't pick
- Browser native voices hidden when server providers are available
- saveGeneratedAgents accepts and persists voiceConfig
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tts): restore volume slider in classroom toolbar
Revert the toolbar simplification from
|
||
|
|
fbe8399678 |
feat(generate): server-side media & TTS generation (#75)
* feat(generate): server-side media & TTS generation in classroom pipeline When `enableImageGeneration`, `enableVideoGeneration`, or `enableTTS` flags are passed to /api/generate-classroom, the server now actually generates media files and TTS audio, persists them to disk, and replaces placeholders in the classroom JSON with serving URLs. - Add media serving API route (GET /api/classroom-media/[id]/[...path]) - Add server-side media generation utility (image, video, TTS) - Wire generation phases into classroom pipeline with progress steps - Update AudioPlayer to support URL-based playback (server-generated TTS) - Add audioUrl field to SpeechAction type - Extend /api/health with capabilities detection - Pass through feature flags in generate-classroom route - Update skill docs for optional feature configuration Closes #49 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(generate): add agentMode support for server-side classroom generation Allow callers to choose between built-in default agents ('default') or LLM-generated custom agent profiles ('generate') via a new agentMode field on GenerateClassroomInput. The resolved agents are injected into outline, scene content, and scene action prompts via teacherContext. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(media): harden download, parallelize media gen, fix TTS concatenation - Add timeout (2min) and max size (100MB) guard to downloadToBuffer - Run image and video generation in parallel via Promise.all - Only split TTS text for MP3 (frame-based); skip for WAV/OGG/AAC whose container headers break on naive byte concatenation - Remove unnecessary type assertion for speechAction.audioUrl Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(media): harden media serving and TTS generation - Add realpath check to prevent symlink-based path traversal in media route - Stream files via createReadStream instead of readFile to avoid OOM on large videos - Truncate TTS text for non-concatenable formats (WAV/OGG/AAC) when exceeding provider limit - Add 'researching' step to distinguish web search from outline generation progress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tts): split long speech actions instead of concatenating audio bytes Adopt the same strategy as client-side: split a long speech action into multiple shorter actions before TTS generation, so each sub-action gets its own audio file. This avoids the broken byte-concatenation approach that corrupted non-MP3 formats (WAV/OGG/AAC container headers). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(tts): extract shared TTS splitting logic into lib/audio/tts-utils Both client-side (use-scene-generator) and server-side (classroom-media-generation) had duplicated TTS_MAX_TEXT_LENGTH, text splitting, and action splitting logic. Extract into a shared module to keep them in sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove unused TTS_MAX_TEXT_LENGTH import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
43a0c8e39e |
feat: whiteboard history and auto-save (#40)
* feat: add whiteboard history and auto-save (closes #32) - New whiteboard-history.tsx: browseable history panel UI - New whiteboard-history.ts: Zustand store with snapshot stack, fingerprint-based deduplication, and restoredKey one-shot guard - New element-fingerprint.ts: shared fingerprint utility (id+position+size) - whiteboard-canvas.tsx: auto-snapshot effect that saves the current stable state after 2s debounce; unified fingerprint import; useMemo for elements - index.tsx: pushSnapshot before UI clear; history panel trigger button - stage.ts: i18n keys for history UI (zh/en) * fix: address audit findings for whiteboard history - engine.ts: AI wb_clear now saves snapshot before clearing (audit #3) - page.tsx: clear whiteboard history on classroom switch (audit #4) * fix: address review round 2 (P1/P2a/P2b/P3) - P1: Block restore during clear animation (race condition) - P2a: Skip no-op restores to avoid stale restoredKey - P2b: clearHistory() now also resets restoredKey - P3: Dedicated restoreError i18n key * fix: fingerprint includes semantic content per element type (P2c) * fix: address reviewer feedback (fingerprint cache, AI clear label, exhaustive check) |
||
|
|
477f976cbf |
fix(tts): fix GLM TTS silent failure due to text length limit and error swallowing (#16)
* fix: make GLM TTS generation reliable * fix: address review feedback — remove mutation, restore logging, drop redundant state --------- Co-authored-by: JokerQianwei <117466534@qq.com> |
||
|
|
843d33e5cc |
chore: enforce Prettier formatting and fix lint issues
- Add .prettierignore to exclude vendor packages, lock files, markdown, and YAML - Update .prettierrc: printWidth 100, singleQuote, trailingComma "all" - Run Prettier across all source files for consistent formatting - Fix unused imports (UserRequirements, setTTSProvider) - Fix eslint-disable comment placement after Prettier reformat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |