mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
@openmaic/generation@0.3.13
53
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
56322a5e06 |
fix(generation): reject unusable interactive scripts and surface runtime errors (#1649)
Reject classic inline interactive scripts that fail to parse at generation time (extracted with parse5, checked with node:vm Script without executing), and surface iframe runtime errors on the active interactive scene. Addresses #1622 (partial: no recovery / regeneration UI). Co-authored-by: Frank-zhu0404 <Frank-zhu0404@users.noreply.github.com> |
||
|
|
35a8be5956 |
fix(pptx): preserve tab columns, text insets, and arrow rendering (#1518)
* fix(pptx): preserve tab columns, text insets, and arrow rendering * fix(pptx): address tab layout review and bump package versions * fix(pptx): preserve editable tab columns and final font metrics * fix(editor): apply list commands inside tab columns * fix(editor): preserve table paragraph spacing while editing * fix(importer): preserve saved leading in auto-fit text labels * fix(importer): preserve ordinary symbol-font text and editable default tabs * fix(importer): preserve default hyperlink underline * fix(editor): preserve Latin baselines when entering text editing * fix(importer): approximate verified clear material front-face color * fix(importer): preserve filled flowchart connector shapes * fix(editor): preserve inline formulas when editing imported text * fix(editor): keep formula caret separators inline * fix(test): narrow serialized shape before checking inverse path * fix(importer): preserve equation system delimiters * fix(importer): preserve compatibility tables and cell formulas * fix(editor): handle formatting and list splits inside inline containers * fix(editor): preserve script sizing around inline containers * fix(editor): preserve inline typography across editing and copy * fix(editor): preserve destination and nested typography contexts * fix(pptx): preserve table tabs and editor clipboard typography * fix(editor): preserve container font context when clearing formatting * fix(importer): correct Wingdings 3 upper-right triangle mapping * fix(pptx): preserve explicit text inset markers with legacy fallback * fix(editor): guard formula serialization and document layout limits * fix(editor): preserve inline font contexts through undo --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
4d0f88b4c9 |
fix: bump next to 16.3.3, patches GHSA-p293-qw3h-jr36 (#1503)
GHSA-p293-qw3h-jr36 (CVE-2026-75604, critical): unauthenticated RCE on Windows-hosted Next.js servers. Root was on 16.2.11 and packages/docs on 16.2.6 (affected: >=16.0 <16.3.3). Both lockfiles regenerated. eslint-config-next lint plugins left as-is (not the affected runtime package). Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
0b641a9403 |
fix(importer): convert Equation.3 OLE formulas via MTEF v3, surface degrade telemetry (#1411)
* fix(importer): convert Equation.3 OLE formulas via MTEF v3, surface degrade telemetry Legacy courseware stores formulas as Equation 3.0 / MathType OLE objects whose only renderable form inside the .pptx is a WMF preview picture. The importer cannot rasterize WMF, so those formulas degraded to a hardcoded 1x1 "transparent" placeholder — actually a 50%-alpha red pixel that rendered as a pink block once stretched over the formula frame, with no way for callers to notice the content loss. - Convert `Equation.3` / `MathType` OLE objects to LaTeX: detect the progId, resolve the embedding, parse the OLE compound file (cfb) and convert its `Equation Native` MTEF v3 stream (new utils/mtef.ts) — fractions, radicals, scripts, fences, big operators with per-family limit variations, embellishments, and Symbol-font local character encodings. Slot order follows the rtf2latex2e reference implementation and real MathType streams ([main, lower, upper]), not the archived spec prose. Any failure falls back to the picture path. - Fix the placeholder constant to a truly transparent pixel; keep recognizing the legacy red one (exported isPlaceholderDataUrl). - Surface degradation instead of failing silently: optional ImportPptxOptions.onWarning receives machine-coded warnings (media-unconvertible / formula-fallback-image / formula-degraded / element-dropped) at every placeholder consumption point (image, background, shape/text pattern fills, math fallback); a throwing sink is isolated so telemetry can never fail an import. A formula whose fallback picture is also a placeholder keeps its plain text as a text element. * fix(importer): address review — LSCRIPT base duplication, one-sided fences, Symbol table, depth caps Review round on #1411 (thanks @wyuc — the fuzz safety-net validation and the adversarial constructions found what the spec-conformance rounds could not): - tmLSCRIPT: stop re-emitting a script slot as the base group (isotopes rendered as {}_{6}^{12}{12}C and the output doubled per nesting level — a 221-byte stream could OOM the worker); the base is the following sibling, so the template emits only {}_{sub}^{sup}, and a leading script no longer steals the previous atom via the trailing-script lookahead. tvLSUPER writers that emit a single slot now fall back to it. - One-sided fences: render the missing side as a null delimiter (\left. / \right.) instead of an unmatched \left — piecewise-function braces (tmBRACE var 1) produced KaTeX-invalid output that silently degraded to flat text. - SYMBOL_FONT_LATEX corrected against URW StandardSymbolsPS AFM + Adobe AGL: 0x3C/0x3E/0x5B/0x5D are literal < > [ ] (≤/≥ live at 0xA3/0xB3, now mapped, along with the rest of the Symbol operator block); 0x22/0x24/0x5C are ∀/∃/∴; added Chi/vartheta/varsigma, the phi/varphi split (0x66/0x6A), and 0x5E = \perp. Unmapped font-local bytes >= 0xA0 now flag degraded instead of passing silently. - tmLIM: variation roles were inverted — spec + rtf2latex2e eqn.c say 0 = upper limit, 1 = lower limit; single-limit writers keep their limit via the same slot fallback as the big operators. - Hardening: MAX_DEPTH = 200 nesting cap and a 64 KiB LaTeX output cap, both throwing MtefParseError (deep bombs now fail loud instead of RangeError/OOM); video poster joined the placeholder warning points. - Tests: +6 — three REAL Equation Native stream fixtures (round-tripped from a legacy deck, pinning the font-local encoding and big-op slot order), tvLSUPER single-slot, tmLIM both roles, script-nesting perf guard; fence tests now assert KaTeX renderability instead of pinning broken strings. Suite: 85. - Version 0.1.5 -> 0.2.0 (new public option/type/export + Math.degraded). Lockfile re-anchored on main with pnpm@10.28.0: only the cfb additions remain, no unrelated churn. * fix(importer): address review round 2 — tmLIM function slot, full Symbol high-half, bra/ket, cap tests - tmLIM: emit the main slot FIRST followed by the limits and inject no operator name (the reference `39.1 = limit: lower, #1 #2` puts the function in the main slot — hardcoding \lim duplicated it and glued to letter-leading main slots, crashing KaTeX with an undefined control sequence). An empty main slot falls back to \lim as a neutral base. - SYMBOL_FONT_LATEX: completed the 0xA0–0xFF block from the URW AFM (~70 positions: ∫ ∑ ∏ ⟨⟩ ∂ ∇ ⇒ ⇔ ⋅ ′ ∅ ⊆ ⊇ ∈ ∉ ∪ ∩ …). Unmapped font-local codes now throw MtefParseError instead of passing through as Latin-1 (0xF7 was an integral extender rendering as ÷ — plausible but wrong math); radicalex (0x60) and C1 controls (0x80–0x9F) also throw, taking the picture fallback. - tmDIRAC: var1 renders a bra `\left\langle L\right|`, var2 a ket `\left| R\right\rangle` per the reference (was wrapping both sides). - Embellishment records now count against the record budget (a 10 MB embellishment-only stream no longer allocates 1 GB before the output cap fires). - Tests: +6 — every cap now has its own assertion (output-length width case at 15k sibling CHARs, record-count at 20k, in addition to the existing depth test), previously-dangerous Symbol codes verified from the AFM, unmapped-code rejection, embellishment budget, tmDIRAC KaTeX-validity for all variations. Fixture claims corrected (no big-op selector in the three real streams; the reading is pinned by the spec-conformance tests). Suite: 91. * test(importer): pin 0xD6/0xF3 Symbol glyphs through KaTeX, fix big-op test title Follow-up to the round-3 review: the two glyph fixes landed without a test, the BigOp test title still named the wrong slot order, and the table comment claimed the high half was complete while unlisted positions intentionally throw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(importer): correct Symbol table comments — 0xF7 is parenrightex not an integral extender, high-half coverage is ~50 of ~70 positions --------- Co-authored-by: Percy <percy@PercydeMacBook-Pro.local> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6334e9adde |
fix(deps): bump next, js-yaml, undici, nanoid, lodash, sharp for disclosed CVEs (#1357)
Advisory-driven dependency bumps for already-public HIGH CVEs. - next 16.1.2 → 16.2.11 (GHSA-6gpp-xcg3-4w24, GHSA-89xv-2m56-2m9x, GHSA-m99w-x7hq-7vfj, GHSA-p9j2-gv94-2wf4 and related) - js-yaml 4.1.1 → 4.3.0 (GHSA-52cp-r559-cp3m / CVE-2026-59869) - undici 7.22.0 → 7.29.0 (GHSA-f269-vfmq-vjvj, GHSA-v9p9-hfj2-hcw8, GHSA-vrm6-8vpv-qv8q) - nanoid 5.1.6 → 5.1.16 (GHSA-28wg-ghj8-5hjv) - lodash 4.17.23 → 4.18.1 (GHSA-r5fr-rjxr-66jc) - sharp 0.34.5 → 0.35.4 (GHSA-f88m-g3jw-g9cj) - eslint-config-next aligned to 16.2.11 Detected by osv-scanner. Lockfile-only + package.json version pins; no application code changes. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
3214cead2c |
fix(workbench): render LaTeX in assistant messages (#1309)
* fix(workbench): render LaTeX in assistant messages * fix(workbench): harden streamed math rendering * fix(workbench): preserve streamed math boundaries * refactor(workbench): use standard math syntax * fix(workbench): preserve math in course link labels * fix(workbench): disambiguate single-dollar math --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
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> |
||
|
|
97ceb11a14 |
fix(storage): asset writes self-deadlock against pooled PostgreSQL (#1227)
* 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.5.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.
* test(providers): reconcile the neutrality guard with current main and this fix
Two changes that were each green alone broke together on main: the
provider audit removed the lib/storage barrel and types entry points
and reshaped provider references while the guard still pinned the old
file list and counts. Point the file list at the surviving
lib/storage/client.ts, drop the satisfied sora debt, and update the
extract-document mineru count. The asset-byte-store pg count grows
because the wrapper now forwards the transactional byte methods of the
concrete store, which necessarily names it.
|
||
|
|
5dc023d92d |
feat(export): download narration script as Markdown or DOCX (#1144)
* feat(export): download narration script as Markdown or Word-compatible .doc Adds two export-menu entries that download the classroom's TTS narration text (SpeechAction.text per scene) as a local document for lesson prep, closing #413. The .doc is a minimal HTML document with the Word MIME type so no new runtime dependency is introduced; .md is the portable plain-text sibling. Scenes without speech are omitted, HTML is escaped in the .doc path, and file names are sanitized. Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * refactor(export): simplify narration script export hook Reads stage store state at click time instead of subscribing per render, drops unobservable exporting state (the handler is synchronous and the menu-close is the reentrancy guard), removes a redundant empty-scenes pre-check, and drops a type cast the discriminated union makes unnecessary. Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(export): apply code review findings for narration script - Skip whitespace-only speech text when collecting scene scripts, so a blank course cannot download a heading-only document with a success toast - Trim and filter empty paragraphs in the .doc serializer to match the markdown path - Strip only dangerous control ranges in the filename sanitizer so emoji and ZWJ sequences survive (the broad \p{C} class was splitting surrogate pairs) - Add unit tests for whitespace-only speech, HTML paragraph normalization, the <br> branch, emoji-preserving filenames, control-char stripping, and markdown whitespace filtering Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(export): address review feedback on narration script export - Decouple the export menu trigger from full media-readiness gating: script export (.doc/.md) now stays usable while media generation is still pending. PPTX/Resource Pack/ZIP/Video items keep the existing media-dependent gate, now with visible disabled state and a tooltip explaining why. - Document the HTML-as-.doc format as a deliberate zero-dependency tradeoff (not an oversight) directly above buildDocHtml. - Sanitize scene/stage titles before they're interpolated into Markdown headings, so embedded newlines or leading # runs can't inject extra headings or corrupt document structure. - Localize the "Slide N" fallback label via the i18n system instead of hardcoding English, adding export.slideFallback (plus export.textOnly and export.mediaPending for the new UI states) across all 12 locales. Addresses review from @wyuc on PR #1144. * refactor(export): dedupe export-readiness derivation in header controls canExport already implies canExportText once it ANDs the media-readiness check on top, so the trigger's title/aria-label/readiness ternaries had two independent copies of the same 3-way branch drifting apart. Derive canExport from canExportText, and share exportReady/exportLabel between the button's disabled, className, title, and aria-label props. * fix(export): escape leading # in markdown headings instead of deleting it Two issues found in code review: (1) a leading newline could shield a leading # from the strip regex, then trim() would re-expose it unescaped -- a real order-of-operations bug; (2) unconditionally deleting the leading # run silently mangled legitimate titles like "#1 Introduction". Escaping (\#) instead of stripping fixes both: content is preserved and the character can no longer be mistaken for heading syntax, regardless of what precedes it. * fix(review): apply review findings * fix(export): remove legacy doc script export --------- Co-authored-by: CommandCodeBot <noreply@commandcode.ai> |
||
|
|
44e1ca0ca0 |
feat(rag): add document lexical retrieval foundation (#1078)
* feat(rag): add document lexical retrieval foundation * fix(rag): align foundation contracts with review * fix(rag): handle quoted br attributes * perf(rag): stream grapheme chunk splitting * fix(rag): preserve exact replacement scope * perf(rag): avoid repeated grapheme segmentation * refactor(rag): isolate grapheme chunking * perf(rag): avoid runtime-dependent grapheme segmentation * fix(rag): complete review contract corrections * fix(rag): resolve follow-up correctness review * fix(rag): address latest review corrections --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
19a895e889 |
fix(video-export): make Cyrillic and Arabic Quiz fonts deterministic (#1114)
* feat(video-export): make Quiz script fonts deterministic * test(video-export): verify Arabic shaping visually * fix(video-export): cover Arabic extension characters --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
0a10af11cf |
feat(storage): opt-in indirect asset byte egress (#1007) (#1100)
* feat(storage): add opt-in indirect asset byte egress A deployment can now opt an asset byte GET into a 302 to a short-lived signed URL when the byte layer can sign. It is off by default, and byte-for-byte unchanged when off. The byte layer gains an optional signReadUrl capability; S3AssetByteStore implements it through the new optional @aws-sdk/s3-request-presigner peer, resolved lazily exactly like the client SDK, with a signer seam mirroring the commands seam so tests can bind doubles. The signed URL pins the contract's response headers -- the media type after the renderable allowlist, the fixed disposition, and the no-store cache posture -- via S3 response-header overrides. PgAssetStore mints the URL from the same ownership-checked transactional read a direct resolve performs, so authorization still runs per read before any URL exists, and a store whose byte layer cannot sign falls back to direct bytes. The contract documents the shape and the disclosure tradeoff: a Location into a hash-keyed byte layer names the content hash, so deployments that need the no-disclosure property keep direct egress. Refs #1007 * feat(app): wire ASSET_BYTE_EGRESS into the persistence route ASSET_BYTE_EGRESS=redirect opts asset byte GETs into the storage package's indirect egress; unset, direct, or an unrecognized value keeps the default byte-for-byte behavior, with a warning for the unrecognized case. The lazy byte-store wrapper now forwards signReadUrl and answers undefined when the resolved layer has no signer, so the PostgreSQL byte column degrades to direct bytes and the S3 layer signs without the wrapper knowing which it holds. The collector path is untouched: it builds its byte store through createAssetByteStore as before and only ever calls delete. Refs #1007 * fix(storage): let the packaged client survive redirect egress Cross-review round 1 on the indirect byte egress change found two problems. A byte response that followed a redirect carries the pinned content type but no X-Asset-Revision, and HttpAssetStore required both from the same response, so every cold resolve under redirect egress failed MALFORMED_RESPONSE. The client now detects the shape once -- a redirected byte response with no revision -- latches it, and probes HEAD before each cold GET from then on, taking the label from the probe. The probe predates the download on purpose: a replacement in between labels newer bytes with an older revision, which the next revalidation detects and corrects, while the reverse order could pin stale bytes under a fresh revision with no signal to repair it. The probe doubles as the miss check, so a redirect-mode miss costs no download. The direct path is byte-for-byte unchanged; the one double download per client lifetime on first contact is documented in the contract, which gains the client-side paragraph this behavior implements. signedUrlTtlSeconds also accepted lifetimes beyond the seven-day SigV4 presigning maximum, minting redirects the object store would reject. The option is now capped at construction. Refs #1007 * fix(storage): cap signed URL lifetime below the reclamation grace A signed URL whose lifetime exceeds the collector grace period can outlive its object: the last reference goes, the grace elapses, the collector deletes the object, and the still-valid URL errors at the object store. The previous ceiling -- the seven-day SigV4 maximum -- made that window days wide against the one-hour default grace. The cap is now fifteen minutes, and the contract text states both the ceiling and the rule for deployments that shorten their grace. Refs #1007 * fix(storage): decline signing when the optional presigner is absent An unresolvable @aws-sdk/s3-request-presigner made signReadUrl throw, which the registry surfaced as a failed asset read -- a 500 for a deployment whose only fault is a missing optional peer. The contract's answer for a byte layer that cannot sign is the capability fallback: return undefined and let the caller serve the bytes directly. Signing errors from a resolved signer still fail loud; only the missing capability declines. Refs #1007 * fix(storage): answer redirect egress as a descriptor to asking clients Following a 302 is not header-neutral: the platform fetch forwards the original request's headers to the object store's origin, stripping only Authorization, so a deployment whose credential travels in a custom header would hand it to the object store -- and the preflight the forwarded custom headers provoke commonly fails there besides. This replaces the latch-and-probe client shape with an explicit one: the client sends X-Asset-Egress: descriptor on every byte GET, a redirect-egress server answers 200 with a JSON { url, revision } body instead of a Location, and the client fetches the signed URL with no deployment headers at all. The revision comes from the descriptor, so the probe-first ordering and its one wasted download are gone too. The 302 remains the answer for consumers that did not ask; the descriptor response is marked by its own header so a JSON-media asset can never parse as one. Refs #1007 * fix(storage): negotiate the descriptor through Accept, not a custom header A custom X-Asset-Egress request header is not CORS-safelisted, so every byte GET to a cross-origin server became a preflighted request -- a regression for direct-egress deployments that never opted into anything, against servers with no reason to allow the header. The negotiation now rides Accept with a vendor media type, which is safelisted and costs no preflight, and the descriptor answer is identified by that media type as its Content-Type rather than by a marker header. Refs #1007 * fix(storage): sign outside the registry transaction and reserve the descriptor type Two review findings on the indirect egress path. resolveIndirect awaited the signer inside the coordinated read, but a signer on refreshable credentials can wait on the network, and a database connection plus the blob row's FOR SHARE lock would be held across that delay. The read now closes first -- the lock still puts the hash, label and revision in one snapshot -- and signing runs afterwards, which cannot observe anything the read did not. The descriptor media type is also reserved from renderableTypes: the client identifies a descriptor answer by that exact Content-Type, and an allowlisted asset served with it would be misread as a descriptor and fail MALFORMED_RESPONSE. The handler now rejects the configuration at construction, and the contract states the reservation. Refs #1007 * fix(storage): ship the signing deps and negotiate without ambiguity Three review findings on the indirect egress path. The app now declares @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner as runtime dependencies, and the standalone build force-includes them for the persistence route: both are reached through deliberately untraced dynamic imports, so the shipped image could not resolve them, and a deployment opting into S3 or redirect egress would have found the capability silently absent. The descriptor negotiation matches exactly on both sides: the client compares the Content-Type essence, so a longer media type that merely begins with the reserved value still serves as bytes, and the server parses Accept media ranges, so a descriptor range sent with q=0 selects the redirect instead of the descriptor it explicitly rejects. Refs #1007 * fix(app): refuse redirect egress when the collection grace is shorter A signed URL must never outlive its object: with the handler's 60-second default lifetime, a deployment that sets ASSET_COLLECTION_GRACE_MS below ten minutes lets the collector delete an object while a URL minted against it is still valid. The route now refuses the combination at handler initialization, naming both variables -- a deterministic misconfiguration, loud at startup rather than silent at read time. Refs #1007 * fix(app): ship the signing SDKs without loading them Two findings on the standalone deployment of redirect egress. The outputFileTracingIncludes globs did not follow pnpm's scoped symlinks, so the standalone image carried neither AWS package and both S3 mode and redirect egress could not resolve their SDK at runtime. The packages are now server-external and referenced from literal -- but never called -- dynamic import thunks in the persistence byte-store wiring, which gets them traced into the image while module resolution still happens only on first S3 use. Verified against a local standalone build: node_modules/@aws-sdk now contains both packages, and the route tests that pin the never-resolve-unless-configured discipline still pass. Media type comparisons are also case-insensitive now, as HTTP requires, on both the Accept parse and the descriptor recognition. Refs #1007 * fix(app): treat an empty collection grace as unset Number('') is 0, so a present-but-empty ASSET_COLLECTION_GRACE_MS failed the redirect-egress coordination check and took persistence down with it, while the collector's own parsing reads the same value as the one-hour default. The check now trims and treats empty as unset, matching durationEnv. Refs #1007 * fix(storage): accept bytes too, omit signing on the known-PG wrapper, state the full tradeoff Three review findings. The client's descriptor request now advertises both representations -- the vendor descriptor type preferred, */* accepted -- so a strict content-negotiating layer can never answer 406 to a client that does in fact consume plain bytes. The lazy byte-store wrapper no longer advertises signReadUrl when no bucket is configured: the layer is known to be PostgreSQL at construction, and carrying the method made resolveIndirect take its ownership query and blob-row lock just to decline, then repeat them in resolve, on every cold GET. With a bucket configured the lazy validation is preserved and the wrapper still declines when the resolved layer cannot sign. And the contract's disclosure tradeoff now states the second edge: object-store GETs carry ETag and Last-Modified, response overrides cannot strip them, and because deduplicated PUTs rewrite the hash-keyed object, Last-Modified moves when another principal re-uploads the same bytes -- shared-object write timing, beyond byte equality. Deployments for whom that signal is sensitive must keep direct egress. Refs #1007 * fix(app): degrade on bad grace, share the ttl invariant, cover the real boundary The latest review round, four findings. A misconfigured ASSET_COLLECTION_GRACE_MS now degrades redirect egress to direct with a warning instead of failing the shared handler's initialization -- the asset backend is optional, and its misconfiguration must never take document and runtime traffic down. The package exports assertSignedUrlTtlWithinGrace so a deployment wiring the handler and the collector separately validates the invariant once at its own boundary: the handler caps the lifetime, but only the deployment knows both numbers. The contract states that nosniff does not survive the redirect -- response-header overrides cannot set it -- and why the client-minted blob makes that safe for conforming clients. And the route gains a real-boundary test: the Fetch<->Node adapter, the real createStorageHttpHandler, and the egress wiring, exercised with an actual byte GET through the composed stack instead of mocks. Refs #1007 * fix(storage): make the unsafe egress configuration unrepresentable Three findings on the indirect egress path, all resolved by moving a rule into a place where it cannot be bypassed rather than by adding a check a consumer has to remember. The grace/TTL invariant now lives in the option shape. byteEgress takes 'direct' or { mode: 'redirect', collectionGraceMs, signedUrlTtlSeconds? }, so enabling indirect egress without declaring the reclamation window it lives inside no longer type-checks, and the handler validates both numbers at construction. The flat signedUrlTtlSeconds option, its cross-field "requires byteEgress redirect" check, and the standalone 900-second cap as the only guard all go away; the ceiling stays, but alongside the ratio rather than in place of it. This is what the earlier exported assertSignedUrlTtlWithinGrace could not do -- it protected only consumers who called it -- and it costs nothing in compatibility because the option is new in this branch. The app resolves the grace through one shared parser with the collector, so the number the handler checks is the number the collector runs with. A signed URL whose object is gone is now a miss. The client maps a 404 from the byte fetch to ASSET_NOT_FOUND, matching the direct path: the entry was owned and readable when the URL was minted, so a reclaimed object is the same physical state the direct read reports as a miss, and what a read means must not depend on the deployment's egress setting. Signing still does not probe for the object -- that would restore the round trip this feature removes and make the mint's price vary with prior presence -- so the contract instead requires the byte layer to answer 404 for an absent object, and states that S3 needs s3:ListBucket to do so. Route coverage now spans the whole egress matrix through the real composed handler rather than only the direct case: 302 for a consumer that did not ask, descriptor for one that did, direct bytes when the byte layer declines to sign, and direct bytes when a short grace degraded the configured mode. Refs #1007 * feat(storage): forward byte egress options through reference server * fix(storage): fail closed on redirects and unconfirmed 404s in indirect byte egress A signed-fetch 404 now maps to a miss only when the object store's XML body declares NoSuchKey; every other 404 from the signed fetch fails loud, so a wrong bucket, access point, or endpoint can no longer make a live asset read as absent. The descriptor byte GET is sent with redirect: 'manual' and any 3xx answer is treated as an error, so a server that ignores the descriptor negotiation can never forward the deployment's custom credential headers to a redirect target. The ASSET_BYTE_EGRESS switch is now documented in .env.example and the server-persistence deployment section, together with the object-store CORS and s3:ListBucket prerequisites it requires, and the asset descriptor media type is exported from the package root and the asset/http subpath. --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
d1eeb95e1c |
feat(video-export): add deterministic Quiz question-list scrolling (#1102)
* feat(video-export): add deterministic Quiz question-list scrolling * fix(video-export): bound Quiz layout measurement |
||
|
|
4ac70cceeb |
feat(video-export): capture self-contained static interactive HTML (#1086)
* feat(video-export): capture static interactive HTML * fix(video-export): harden interactive HTML capture diagnostics * fix(video-export): initialize runtime diagnostics in manifest * fix(video-export): close interactive capture review findings * fix(video-export): preserve packaged interactive resources * fix(video-export): address packaging review feedback * fix(video-export): harden interactive HTML packaging * fix(video-export): unify interactive asset packaging * fix(video-export): close remaining interactive packaging gaps |
||
|
|
26d280c9b7 | fix(ci): stabilize Hyperframes lint command (#1097) | ||
|
|
c38da84ef6 |
feat(renderer): decouple editor UI from the app (#1072)
* docs: define renderer editor task 1 integration * docs: plan renderer editor task 1 * feat: gate renderer slide editor * feat: adapt renderer editor intents * feat: connect renderer editor to slide surface * test: complete renderer intent shape fixture * fix: restore renderer editor action previews * fix: render editor alignment guides * perf: smooth renderer element dragging * feat: align renderer hidden element behavior * feat: add renderer canvas host commands * feat: add renderer canvas context interactions * test: cover renderer context menu interaction * fix: preserve renderer canvas interaction semantics * docs: design renderer text editing * docs: plan renderer text editing * feat(renderer): add ProseMirror text core * feat(renderer): add rich text editor controller * feat(renderer): render editable text in place * feat(renderer): enter text editing on click * feat: connect renderer rich text editing * feat: finalize renderer text edit lifecycle * fix: stabilize renderer text edit commits * fix(renderer): move elements from selected borders * docs: design renderer editing UI text toolbar * feat(renderer): publish editing UI contracts * feat(renderer): add controlled text format toolbar * style(renderer): format editing UI contracts * fix(renderer): address Task 2 review findings * fix(renderer): handle zero font sizes in toolbar * feat(renderer): add editing UI color picker * fix(renderer): address Task 3 color picker review * fix(renderer): commit native color input changes * fix(renderer): coordinate native color picker interaction * feat(renderer): anchor text toolbar to canvas elements * feat(renderer): compose editable canvas with text UI * feat(editor): use renderer text toolbar UI * fix(renderer): make editing UI tests type-safe * fix(renderer): harden editing UI integration * feat(renderer): add editable text UI * feat(renderer): add image and shape editing * feat(editor): extend renderer editing controls * feat(editor): add renderer chart insertion * docs: design renderer latex editor * docs: plan renderer latex editor * feat(renderer): add latex editor contract * feat(renderer): add latex editor dialog * feat(renderer): compose latex insert and edit UI * feat(editor): integrate renderer latex editing * fix(editor): consolidate latex toolbar actions * fix(renderer): show toolbar hover labels * test(renderer): cover localized latex tooltips * chore(renderer): hide latex sample palette * feat(renderer): add video editing controls * docs: define renderer audio editing * feat(renderer): render audio elements * feat(renderer): add audio editing UI * feat(renderer): add preview audio controls * feat(editor): add renderer element clipboard * feat(renderer): decouple editor UI from app * chore(renderer): release 0.0.6 * fix(renderer): clear editor CI gates * fix(editor): adapt renderer canvas to media resolution API * fix(i18n): complete French editor labels * fix(renderer): hide toolbar vertical overflow * chore(renderer): address review cleanup * chore(docs): remove planning artifacts from pr * feat(editor): add transactional core package * chore(editor): ignore local test cache * build(editor): publish core entrypoint * refactor(editor): route renderer changes through transactions * refactor(editor): split renderer editing into editor package * ci(editor): register editor package for release * docs(editor): document package dependencies * ci: rerun validation after editor release registration * test(editor): validate deletion against existing element * fix(editor): address transaction and package review findings * fix(editor): validate transactions and restore editor styles * fix(editor): protect required updates and react styles * fix(editor): validate required transaction fields * fix(editor): prevent insert popovers shifting canvas * refactor(editor): own renderer editing capabilities * fix(editor): harden editing transaction boundaries * refactor(editor): modularize editor UI integration * docs(editor): harden third-party integration * fix(editor): preserve video resize aspect ratio * fix(editor): discard text draft before deletion * fix(editor): preserve renderer picking and line presets * chore(editor): remove unused pick-layer import |
||
|
|
b64e787749 |
feat(generation): move scene generation and PBL single-call planning into the package (#1087)
Part C of #1057. Scene content/actions/widget generation, retry, action parsing, interactive post-processing, buildCompleteScene with injectable sceneId, and PBL single-call planning re-seated on AICallFn (pure core + kernel + package-relative planner assets; loop planner stays app-side behind the injected pblLoopFallback with provider/abort classification). Purely additive — app source untouched until Part D. Package 0.2.0. |
||
|
|
6825363de1 |
feat(storage): server asset registry over a pluggable byte layer (#1007) (#1077)
* docs(storage): specify the asset registry HTTP contract (#1007) Adds the contract document and the server-side store, byte-layer, and handler types for the asset layer, the only layer with no server backend. Runtime behaviour is unchanged: the new modules are deliberately unreferenced until the backends land. Reclamation moves offline, and that decision carries the design. `remove` deletes a registry entry and nothing else -- it never reads, counts, or deletes bytes. A separate collector takes byte rows that have had no references for a grace period. Three things follow. The hardest race in the design disappears, because no request can delete bytes another request is adopting, so no request path needs a lock on the byte row and the write path never has to reconcile taking one with never branching on whether the bytes already existed. A side channel closes, because a `remove` that only deletes a row costs the same whether or not another principal holds the same bytes. And nothing is given up: under global deduplication `remove` could never promise the bytes were destroyed, so deleting them synchronously bought far less than it appeared to. The cost is that storage grows until the collector runs, which a deployment must configure rather than inherit. Offline reclamation is also what makes the byte layer pluggable, so this keeps #1007's pluggable-backend model rather than narrowing it. With collection out of every request path, the requirement on a byte layer is an ordering rule rather than an atomicity one: bytes are written before the row that references them, deleted after the last row referencing them, and the reference count is serialized on the blob row that every implementation keeps regardless of where bytes sit. Two implementations are planned -- bytes in a column of the transactional store, and an object store keyed by content hash -- which is why the interface exists; an interface with one implementation is not a seam, and this package has removed such a seam before. The browser backend keeps no such seam, because it reclaims inline and its bytes therefore cannot leave its transaction; four comments that stated this as a storage difference now state it as a reclamation one. Metadata crosses the wire as a multipart part. It is open-ended and callers already fill it with generated-narration text and image prompts, so a query parameter would put unbounded user content into the request target and into every intermediary's logs. That also motivates a rule the other layers do not need: metadata must never appear in a URL, a response header, a log line, or an error message. Metadata gets a value domain, and the document is explicit that this is a capability reduction -- the browser backend's structured clone carries Date, Map, Set, and cycles faithfully, so a deployment moving to a server backend must audit its metadata rather than expect a quietly lossy path to tighten. Resolution yields a client-minted object URL, superseding the resolution that a server deployment resolves to a self-hosted proxy path. A media element sends only ambient credentials, so a proxy path works for cookie-session deployments and silently fails for header-authenticated ones; closing that gap means minting URL tokens, a second credential form inside a storage library. Fetching bytes over the client's own authenticated request needs none and removes a class of hash-disclosure surface. The client obligations that make it preserve the browser backend's semantics are specified, and split into the two the shared suite already pins and the four an HTTP backend must pin itself. The cost, no range requests and no progressive playback, is stated. Channels that could carry a principal or a content hash are closed structurally rather than by enumeration: the route table admits no segment that could carry either, and no route takes a query string at all. Headers are closed by not reading them -- the contract assigns no meaning to any header beyond content type and length, and a handler must not derive identity from one outside its authenticate hook. Rejecting header names by pattern was tried and removed: no pattern broad enough to catch X-Remote-User spares User-Agent, and the forwarded-identity headers such a rule would reject are exactly what an authenticating reverse proxy injects for that hook to read. Ids that cannot be a path segment are resolved by the client as misses rather than raised as errors, a deliberate divergence from the KV contract. An unknown id is a miss by this layer's id-domain rule and the shared suite pins the empty string as one, so a client that threw would fail that suite and reintroduce the shape oracle the rule prevents. Also specified: response values may not derive from the shared byte row (Last-Modified off that row is an existence oracle), a collision- resistant digest is required because writes are unconditional and an object layer keys on that digest, byte responses carry the revision and refuse Range, HEAD errors carry the code in a header since they have no body, quota exhaustion has a status and a code, and relabelling a non-renderable type is not refusal -- resolve still returns a URL. * feat(storage): add pluggable server asset store (#1007) * fix(storage): write asset bytes only after claiming the blob row (#1007) The write path wrote bytes twice: once before the registry transaction and once inside it, after the upsert that claims the blob row. Only the second one carries any safety, and the first is what a large-media deployment pays for on every upload. The ordering that matters is claim, write, reference. Claiming the blob row takes its lock, so a collector already holding that lock finishes before the write proceeds; writing bytes before the claim instead lets the collector delete them while the claim waits, leaving a fresh entry that points at nothing. Writing them before the entry keeps every surviving entry backed by bytes that were actually stored. Dropping the first write preserves both ends of that order and removes a crash window that produced orphans for no reason. For a byte layer inside the transactional store this also means a failed write now leaves nothing at all, rather than an orphan the collector had to sweep later. The four tests this changed were each pinned to the removed write: - the statement-sequence test hardcoded the old four-statement shape; it now pins claim, write, entry, and still asserts the two existence paths are identical, which is the property that matters; - the quota test asserted two writes per accepted put; - the crash-window test asserted an orphan that a transactional byte layer no longer produces. It is now two tests: a transactional layer leaves nothing, and a non-transactional one strands an object with no blob row -- which the collector deliberately cannot see, since recovering it is deployment housekeeping rather than reference counting. It also no longer depends on state left behind by earlier tests in the file; - the collector-race test drove its interleaving off the removed write. It was choreography rather than concurrency in any case: PGlite is single-connection, and the test serialized transactions, so no row lock was ever contended. It is replaced by the two invariants that actually make the race safe -- the byte write is unconditional, and bytes the collector has already removed are re-stored by an adopting put. The contended-lock case belongs to the real-PostgreSQL suite, where it can be tested rather than mimed. Reverting the unconditional write fails two of them, so they pin it. The contract said a failed write leaves no partially written bytes while its byte-layer section permits exactly that orphan; the two are reconciled, and the ordering rule now states the claim step and the unconditional write it depends on, along with the cost that follows -- the byte write happens with the registry transaction open, so an object store holds a row lock across a network upload. * test(storage): fix the real-PostgreSQL asset tests against the new write order Both were written against the removed pre-transaction byte write, and both failed once it went away -- one of them by hanging, which also timed out the next test's TRUNCATE. The lock-contention test waited for the adopting put to start writing bytes before releasing the collector. Bytes are now written after the upsert that claims the blob row, and the collector holds that lock, so the write could never start: a circular wait. It now waits for a backend to actually appear in pg_stat_activity blocked on a lock, which is the condition it meant to wait for, observed rather than signalled. Its final assertion carries real weight now -- had the bytes been written before the claim, the collector would have deleted them and the adopting entry would resolve to nothing. The orphan test asserted that a failed registry transaction strands bytes. With this byte layer inside the registry's transaction it strands nothing, so it now asserts that instead. Object storage does strand an object, and that case is covered where it belongs. All of this ran against PostgreSQL 16: 781 tests pass with 3 skipped, and those 3 pass against a real S3-compatible server. Nothing in the suite is now unverified for lack of infrastructure. * feat(storage): add asset HTTP backend (#1007) * docs(storage): separate trusting a header from reading one (#1007) The header rule said a server reads exactly Content-Type and Content-Length, while the size rules require reading Content-Encoding in order to reject it -- the two cannot both be literal, and the implementation had to pick one. What the rule means is narrower: no request header may be trusted to say who is asking, outside the authenticate hook. Reading a header to decide how to frame or refuse a request is a different thing, and the transport headers are read and acted on. * chore(storage): bump to 0.2.3 for the asset server backend (#1007) Additive: new asset registry, byte layers, collector, HTTP handler and client, and their entry points. Nothing existing is removed or changed incompatibly, so this is a patch under the pre-1.0 rule. * fix(storage): close four defects found reviewing the asset backend (#1007) **Concurrent writes could exceed a principal's logical quota.** The check ran on the pool before the write transaction, so two concurrent puts both read the old total and both passed; enough concurrency amplified it arbitrarily. It now runs inside the write transaction behind a transaction-scoped advisory lock on the principal, taken only when a quota is configured -- a branch on deployment configuration, never on data. Pinned by a real-PostgreSQL test: four concurrent six-byte writes against a ten-byte quota, of which exactly one may be accepted. Removing the lock accepts three. **A part name smuggled inside a quoted filename was read as a real part name.** `Content-Disposition` was scanned with a regex that does not understand quoted strings, so `filename="x; name=meta; y"` parsed as a `name` parameter here while an RFC-aware intermediary sees an unnamed part. That is exactly the parser differential the contract requires be rejected. Replaced with a tokenizer that honours quoted strings and escapes and requires exactly one real `name`. **Any S3 404 was reported as an absent object.** `NoSuchBucket`, a misdirected endpoint, and a revoked access point all answer 404 while the bytes still exist, so a storage outage surfaced as `404 ASSET_NOT_FOUND` and a caller clearing its reference on that would turn it into real data loss -- the same failure the contract spells out for `401`. Only key-absent codes map to a miss now; everything else propagates to `500 INTERNAL_ERROR`. **Exceeding `maxParts` answered `400` rather than `413`.** It is one of the declared multipart resource limits and now answers like the other three. Verified against PostgreSQL 16 and a real S3-compatible server: 840 tests, none skipped. * fix(storage): repair two defects the previous fix round introduced (#1007) Both were created by the fixes themselves rather than surviving them, which is the failure mode a second review round exists to catch. **An over-quota replace answered 500.** Moving the quota check inside the write transaction put it under a catch that re-threw only AssetNotFoundError, so AssetQuotaExceededError was collapsed into a generic registry failure and the handler mapped it to INTERNAL_ERROR -- losing the status and code the contract gives that condition. Both typed errors now survive the catch. **The hand-written disposition tokenizer accepted malformed input and trusted it.** The quoted-value loop never checked that it found a closing quote, so `name="meta` ran to the end of the header and returned `meta`; `name="meta"junk` returned `meta` as well; and an extended `name*` form competing with a plain one was silently ignored. Each recreates the parser differential the tokenizer was written to remove. A quoted value now requires its closing quote, rejects a dangling escape, and permits only whitespace and a separator after it, and an extended name form is refused rather than resolved. Also widened the quota lock key from `hashtext` to `hashtextextended`. The 32-bit form collides -- two unrelated principals sharing a key would block each other for the whole transaction, which spans a byte write that may be a network upload. Regression tests cover all three: an over-quota replace raises the quota error and leaves the original bytes, and each of the three malformed dispositions is refused. Verified against PostgreSQL 16 and a real S3-compatible server: 842 tests, none skipped. * fix(storage): narrow the multipart disposition surface and the error boundary (#1007) Three review rounds have each found a defect in the part-disposition parser: a regex that misread quoted strings, then a tokenizer that accepted an unterminated quote, then RFC 2231 continuation forms (`name*0*=UTF-8''bytes`) and malformed empty parameter slots. Patching it a fourth time would have been the wrong move. This contract needs exactly one parameter, so it now accepts exactly one: a part disposition is `form-data` plus a single `name` whose value is `meta` or `bytes`, and everything else is a validation failure. That removes the continuation forms, the encoded forms, the empty slots, and `filename` -- the parameter every one of these attacks travelled in -- without having to model RFC 2231 at all. That rule turned out to bind our own client too: it emitted a fixed `filename="asset"`. Nothing reads it, and the contract already forbids deriving a response disposition filename from caller data, so the client no longer sends one. Separately, `instanceof` was being used as provenance. The byte layer is pluggable, so a byte store raising a same-named `AssetQuotaExceededError` or `AssetNotFoundError` inside the transaction was re-thrown as a logical registry outcome, carrying the byte layer's own message to a direct caller. The registry's checks now raise module-private sentinels, and only those are converted -- at the boundary, into fresh public errors with this package's fixed messages. Anything else, whatever its name, collapses to the generic registry failure. Also pinned the advisory-lock key width. Reverting `hashtextextended` to the 32-bit `hashtext` left every test green, so the emitted lock statement is now asserted. A SQL-text assertion is deliberate here: the behavioural difference is a collision between two particular keys under a database-internal hash, which is not a stable thing to assert against. Verified against PostgreSQL 16 and a real S3-compatible server: 845 tests, none skipped. * fix(storage): define multipart disposition grammar (#1007) * fix(storage): delegate multipart parsing to Fetch (#1007) * fix(storage): require both multipart parts to be files (#1007) Delegating the framing to the platform parser cost two rules for a metadata part sent without a filename: such a part comes back as a string with its headers discarded, so its `application/json` type could no longer be checked, and its bytes had already been replacement-decoded -- `{"x":"<0xFF>"}" was stored as `{"x":"\uFFFD"}` instead of refused. The bytes part already had to be a file for binary safety. Metadata is now symmetric: the client sends a fixed filename on both, the server requires both to be files, and with the part preserved the media type is checked and the bytes are decoded fatally before parsing. Neither filename is read anywhere. The limits are also described honestly now. `maxParts`, `maxMetaBytes` and `maxAssetBytes` are validated after the parser has materialized every part, so they bound what is accepted rather than what is parsed, and `maxRequestBytes` is what caps memory. The contract said `maxParts` bounded parser work; it does not. Restoring that would mean adopting a streaming parser with its own grammar, which is the disagreement with intermediaries that delegating to the platform exists to remove -- so the trade is stated rather than reversed. Also corrected the media-type wording: the two branches are metadata present versus absent, and retention of an untyped replacement's prior type is a browser-backend behaviour the HTTP path cannot reproduce, because a conforming parser supplies a default type for a file part whose header omits one. Verified against PostgreSQL 16 and a real S3-compatible server: 853 tests, none skipped. * docs(storage): the asset server backend ships in this branch (#1007) The contract still described the server backend as not yet shipped, which this branch is what changes. The conformance server remains test-only. * fix(storage): make asset HEAD bytes-free and pin reads (#1007) * fix(storage): harden asset write boundaries (#1007) * chore(storage): bump to 0.2.4 for the asset server backend (#1007) main merged in a 0.2.3 of its own, so this branch's bump no longer increased the version. Additive relative to the merged base -- new asset registry, byte layers, collector, HTTP handler and client, with nothing removed or narrowed -- so a patch bump under the pre-1.0 rule. --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
7aba740a64 |
refactor(app): consume the contract's interactive and widget types (#1073)
Second of three PRs for #1061 (after #1070). WidgetType re-exports from @openmaic/dsl; the app's rich widget configs extend WidgetConfigBase; InteractiveContent aliases the contract generic; the write validator adopts the contract's html-or-url rule while staying lenient over historical widget shapes (with a primitive-config barrier matching the hydration crash class); widget-config extraction seeds the contract type; @openmaic/generation drops its WidgetType copy and gains the dsl dependency (0.1.1). |
||
|
|
9556a035b1 |
feat(generation): move outline generation into the package (#1065)
Part B of #1057. Outline primitives, buildOutlinePrompt (single-source prompt construction with golden snapshots), logger injection, JSON repair, and the bare-Node second-consumer smoke script with CI coverage. Shim-first: no app-side behavior changes; duplicated prompt assets guarded by a CI parity check until Part D. |
||
|
|
c0da724ec8 |
ci(skill): publish OpenMAIC skill to ClawHub (#1056)
* ci(skill): publish OpenMAIC skill to ClawHub * ci(skill): support manual ClawHub versions * ci(skill): validate manual ClawHub versions * ci(skill): normalize manual ClawHub versions * fix(skill): preserve build metadata in version checks * fix(skill): reject manual build metadata versions * refactor(skill): share ClawHub version validation * fix(skill): validate shared version checker inputs * test(skill): cover ClawHub version validation * test(skill): harden ClawHub version fixtures * test(skill): cover ClawHub checker edge cases * test(skill): cover ClawHub metadata contracts * ci(skill): harden ClawHub publish workflow * test(skill): cover ClawHub publish shell * test(skill): strengthen publish shell regressions * ci(skill): verify publish path on Bash 3.2 * ci(skill): harden ClawHub release guards * ci(skill): unify publish divergence handling * test(skill): bind publish divergence reasons * test(skill): bind stale tree inputs * test(skill): enforce stale guard ordering * test(skill): lock publish guard sequence * test(skill): enforce publish guard counts * test(skill): enforce publish job uniqueness |
||
|
|
335180ec9c |
feat(generation): scaffold @openmaic/generation with pipeline types and packaged prompt assets (#1063)
Part A of #1057. Package skeleton (ESM, tsc, dsl/storage pattern), pipeline type contracts, prompt loader with package-relative asset resolution, 13 templates + 7 snippets byte-identical with golden tests, allowlist boundary lint, full publish/CI wiring, installed-tarball smoke coverage. |
||
|
|
3d85525665 |
[codex] Add Amazon Bedrock LLM provider support (#538)
* Enable AWS-hosted text models without exposing ambient credentials Rebase the Bedrock provider onto current main and close the review-requested security, credential lifecycle, usage attribution, configuration, and metadata gaps. Constraint: Bedrock may use ambient AWS credentials only when explicitly enabled by the server operator Rejected: Trust client-supplied provider types | permits built-in keyless IDs to reach Bedrock credentials Confidence: high Scope-risk: moderate Directive: Keep provider ID/type validation at both request resolution and model construction boundaries Tested: targeted Bedrock/resolver/config/usage tests; TypeScript; ESLint; i18n alignment; production build Not-tested: Full suite has 27 failures in unchanged quiz/runtime and runtime/chat-storage tests * Give Bedrock a recognizable provider identity Use the official AWS architecture service icon so Bedrock no longer falls back to the generic provider cube in settings and model selection. Constraint: Preserve the AWS-provided artwork without redesigning the service mark Confidence: high Scope-risk: narrow Tested: SVG XML validation; provider unit test; TypeScript; ESLint; production build --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
90f5f3942b |
fix(release): dedupe the published dsl, close two release-path blind spots (#1019)
* fix(release): dedupe the published dsl, close two release-path blind spots
Three independent defects on the release path.
1. `@openmaic/storage`, `@openmaic/renderer` and `@openmaic/importer`
declared `"@openmaic/dsl": "workspace:*"`, which pnpm publishes as an
EXACT pin. The tarballs on the registry today say storage@0.1.1 needs
dsl 0.5.0, renderer@0.0.3 needs dsl 0.4.0 and importer@0.1.1 needs dsl
0.4.0, so installing storage together with renderer yields two copies
of the dsl. The dsl carries the schema, the validators and the version
constants, so a document produced against one copy can be validated by
the other's schema revision.
`workspace:^` publishes as `^0.5.0`. Under 0.x that admits 0.5.x, so a
dsl patch reaches dependents without republishing them while a dsl
minor still requires a deliberate dependent release. All three
packages take a patch version increase, both because
check-package-version-bumps.mjs requires one and because the corrected
range only reaches consumers through a new release.
The tarball smoke test now asserts the packed range, so an exact pin
cannot come back unnoticed.
2. The publish workflow's build-integrity guard ran `git diff --quiet --
packages/@openmaic`, which compares the worktree to the INDEX. The
steps before it run third-party package code, which needs no
credential to rewrite a tracked file and `git add` it, after which the
guard reported clean and the rewritten bytes were packed and
published. Both copies of the guard now compare against HEAD, the
commit actually being released.
3. `@openmaic/storage`'s PostgreSQL contract suites refuse to skip when
STORAGE_PG_CONTRACT_REQUIRED=1, but that refusal is a `throw` inside
the test modules, so it never fires if vitest stops collecting them.
Collection is decided by `packages/@openmaic/storage/vitest.config.ts`,
which is on the ignore list of publishable inputs, so excluding
`*.pg.test.ts` there turns both PostgreSQL backends off with a green
run and no version bump.
scripts/assert-pg-contract-suites.mjs reads vitest's json results and
requires both suite files to be present, passed and non-empty, keyed
on the file names rather than on a count. It runs in the publish
workflow's validate job and in the storage PostgreSQL contract
workflow.
Merging this publishes @openmaic/storage@0.1.2, @openmaic/renderer@0.0.4
and @openmaic/importer@0.1.2. That republish is the fix for (1), not a
side effect of it: the corrected dependency range exists only in a
tarball that has not been cut yet.
* fix(release): audit the contract database, pin the format version to a minor
Follow-up on cross-vendor review of the three release-path fixes.
1. The PostgreSQL assertion proved far less than it claimed. Vitest's
`assertionResults` are test CASES, not `expect()` calls, so one
`test('x', () => {})` per required filename satisfied it — and the
whole surface is reachable without a version bump, because `test/` is
on the same ignore list as `vitest.config.ts`. A `vi.mock('pg', ...)`
in the already-wired `test/setup.ts`, or a stubbed suite body, made it
print "ran against a real database" having checked nothing of the
sort.
The script now runs in two phases. Phase 1 keeps the file/status
checks. Phase 2 connects to PG_CONTRACT_URL from outside the vitest
process and requires all five tables the two backends own to exist and
to show inserts in pg_stat_user_tables. Nothing inside `test/` can
forge that. Cumulative insert counters rather than surviving rows,
because the suites clean up after themselves. The success message now
states only what was checked.
2. Validation packed with lifecycle scripts while publishing runs
`--ignore-scripts`, so committing `workspace:*` plus a `prepack` that
rewrites it to `workspace:^` passed the tarball assertion and then
published the exact pin. Both the smoke test and the workflow's
dry-run pack now pass `--config.ignore-scripts=true`, so validation
and publication have identical lifecycle semantics.
3. `workspace:^` unpins the serialized-format version that the exact pin
was silently holding. DSL_VERSION and RUNTIME_DSL_VERSION are
decoupled from the npm version by design, and storage compares them by
value across the package boundary. Under `^0.5.0` a dsl PATCH could
therefore hand a new format to an already-published storage: one
install resolves it and stamps the new version into document_stages,
another install pinned to the older patch reads that row and hard-
fails. Two installs of one published storage version, data-
incompatible.
check-package-version-bumps.mjs now requires a change to either
constant to carry at least a MINOR increase of the dsl package
version, since a caret does not cross a 0.x minor. The rule is
documented next to both constants. That doc edit is itself a
publishable change, so dsl takes a patch bump to 0.5.1.
4. The range and build-integrity guards ran only in the publish workflow,
so a pull request restoring `workspace:*` or leaving a stale generated
file failed at release time, after a version number had been spent.
ci.yml gains a cheap source-level range check
(scripts/check-internal-dependency-ranges.mjs) and the
`git diff --quiet HEAD` integrity check after its install step. The
tarball assertion stays where it is as the release-time proof of
actual published behaviour.
Merging this now publishes @openmaic/dsl@0.5.1 as well as
@openmaic/storage@0.1.2, @openmaic/renderer@0.0.4 and
@openmaic/importer@0.1.2.
* fix(release): fail the gates closed, and state what they actually prove
Confirmation round on the release-path fixes.
1. The format-version rule failed open if version.ts moved. It hard-coded
one source path and returned successfully when either revision lacked
it, so renaming the file, changing DSL_VERSION and bumping dsl by a
patch passed both checks. It now reports an error whenever the file is
absent at either revision while dsl has any publishable change. The
constant regex is anchored to the start of a line, so a commented-out
declaration cannot be read as the value, and a duplicate declaration is
an error rather than a coin flip.
2. "At least a MINOR" was wrong past 1.0.0: `^1.0.0` admits minors, so a
format change shipped as a minor would float into published dependents
exactly as the rule exists to prevent. The rule is now stated as what
it needs to be — an increase the dependents' caret range will NOT admit
— computed from the pre-change version: a minor while dsl is 0.x, a
major once it reaches 1.0.0. The error message and the docs in
version.ts say the same thing.
3. Internal declarations outside `dependencies` bypassed both range
checks. Keeping `dependencies` at `workspace:^` while adding an exact
`peerDependencies` or `optionalDependencies` entry published a second,
tighter constraint and passed. An owned package must now appear exactly
once, in `dependencies`; entries in the peer and optional fields are
rejected outright, in the source check and in the packed-manifest
assertion. The global count test is replaced by naming the three
dependents that must be present.
4. The format rule compared against the base tip, so an un-rebased branch
produced an inverted message. It now uses the merge base, matching the
`base...HEAD` form the rest of diff mode uses. Deliberately not applied
to the version comparison, where the tip is the correct reference.
5. Phase 2 had no baseline, so cumulative counters from an earlier run
satisfied it forever against a non-ephemeral database. It now captures
the counters before the vitest step and requires a positive delta.
Both workflows gained the capture step.
6. Phase 2 overclaimed. It proves rows were inserted into those five
tables during the run, not that the built PgDocumentStore and
PgRuntimeStore inserted them — `test/` is on the publishable-input
ignore list, so test code could write directly. The success message now
says exactly that, and the script documents the threat model: this
catches accidental silencing, which is the defect it was written for;
it is not a defence against someone who can merge changes to `test/`,
who can alter production code just as easily.
Also: scripts/openmaic-packages.mjs is now the single source for the
owned package list, consumed by all three checking scripts, and it
cross-checks itself against both the packages directory and
publish-packages.yml so a package added in one place and forgotten in
another fails rather than going quietly exempt. The workflow's own YAML
lists cannot import it, which is why they are cross-checked instead.
* fix(release): let the format-version source move without opening the gate
Failing closed on a missing version.ts is right, but on its own it made a
rename impossible to merge: the new path does not exist at the base
revision either, so the check refused both before and after the move.
DSL_VERSION_SOURCES is now an ordered candidate list, resolved per
revision against the first path that exists there. A rename lands by
prepending the new path in the same change, and the comparison still
happens across the move. Absent everywhere at either revision, while dsl
has any publishable change, remains an error.
* fix(release): report package-list drift instead of crashing on it
Every check in check-internal-dependency-ranges.mjs reads the shared
package list, so a list naming a package that does not exist threw an
ENOENT out of readManifest before any finding was printed. It failed
closed, but unreadably. The list is now validated first and fatally, with
its own headline.
* fix(release): anchor the caret to the base branch, and check the gates' own inputs
Final confirmation round.
1. The caret escape was computed from the merge base, which is the wrong
reference for it. With merge base dsl 0.5.1, an ordinary minor landing
on main taking the tip to 0.6.0, and HEAD at 0.6.1 with a changed
DSL_VERSION, the rule computed the escape from 0.5.1, got 0.6.0, and
accepted 0.6.1 — but dependents released against 0.6.0 publish
`^0.6.0`, which admits 0.6.1, so the new format reached them anyway.
0.6.1 is also the minimum the ordinary version check allows, making
that the default outcome rather than an unlucky one.
The escape is now computed from the HIGHEST dsl version reachable on
the base branch. Whether the format moved still comes from the merge
base, because that is a question about the branch. The comment on
mergeBaseWithHead now records that it is a no-op in both current CI
invocations (checkout uses the merge ref on pull_request, and the
before-SHA is an ancestor on push) rather than claiming a case that is
not exercised there.
2. The workflow cross-check matched raw text, so a commented-out trigger
path still satisfied it while silently disabling that package's
release. Full-line comments are now stripped first; the `on.push.paths`
list and every `for pkg in ...` loop are compared as exact sets, so an
unexpected entry fails as well as a missing one; and `--filter` usage
is checked more loosely, because individual steps legitimately filter
subsets. The limits of a textual check are stated where it lives.
3. `locate()` returned the first candidate format-version source that
existed, so during a half-finished rename the gate could compare a file
that is no longer the exported one. It now resolves every candidate per
revision and fails unless exactly one declares the constants.
4. INTERNAL_DEPENDENTS was checked in one direction only, so deleting an
entry exempted that package from the source check and from the packed
assertion that iterates the same map. It is now cross-checked both
ways, an owned package in another owned package's devDependencies is
rejected outright, and multiple owned dependencies no longer collapse
to whichever was declared last.
5. assertPackageListIsComplete() now also runs in release mode, which is
the gate that decides what gets published; and the PostgreSQL script
consumes flag values properly instead of treating the first non-flag
argument as the results path.
Also documents, in openmaic-packages.mjs, the threat model these gates
share: they catch mistakes, and anyone who can merge edits to scripts/
can edit production code directly, so deliberate subversion is out of
scope and cannot be closed by adding further checks.
|
||
|
|
e39c64cd9e |
feat(persistence): one-command server-backed stack (compose profile + embedded API + docs) (#982)
* feat(persistence): one-command server-backed stack — embedded API + compose postgres profile docker compose --profile server-persistence up --build runs exactly two containers: the app (persistence HTTP server embedded at /api/persistence, enabled only when DATABASE_URL is set — unset keeps today's browser-only behavior byte-for-byte) and PostgreSQL 16. Client bootstrap configures both storage seams against the same-origin route when NEXT_PUBLIC_PERSISTENCE=1, riding the existing lazy migrations so prior browser data reaches the server per-course on first open. Dev auth (bearer token + client-asserted learner header) lives in one overridable module and is loudly documented as development-grade. Verified end-to-end against the real stack: document PUT/GET and runtime session create land in Postgres; bad tokens 401. Co-authored-by: Codex <codex@openai.com> * fix(persistence): review round — structural bootstrap ordering, retryable init, honest failure surfaces The bootstrap side-effect import moves into the two storage seam entry modules, so any module graph that can resolve a store necessarily evaluates it first — no client-entry ordering luck, and the two configure calls are all-or-nothing. A failed server init clears its cached promise (next request retries instead of permanent 500s). listStages rethrows backend failures and the dashboard surfaces a persistence-unavailable error instead of rendering an empty list that reads as data loss. Docs state the build-time nature of NEXT_PUBLIC_PERSISTENCE, the real token boundary (any visitor can extract it and impersonate any learner — localhost/trusted-network only), and the correct postgres password rotation. Dev-token compare is timing-safe; the handler singleton lives on globalThis so HMR cannot leak pools. Co-authored-by: Codex <codex@openai.com> * fix(persistence): review response — no-confidentiality wording + adapter round-trip coverage The token docs now state plainly that the NEXT_PUBLIC token is compiled into the public bundle and provides no confidentiality or user isolation (its only role is keeping unrelated scanners off a trusted network). The Fetch/Node adapter — the route's most bug-prone code — gains a round-trip integration test: PUT body streaming, 201 with multi-value headers, 204 empty body, and encoded path segments reaching the handler un-decoded. --------- Co-authored-by: Codex <codex@openai.com> |
||
|
|
34448beb6c |
feat(storage): server-backed runtime — pluggable backend seam, HTTP contract, Postgres backend, reference server (#939) (#946)
* ci: run PR checks against the runtime-server-backend integration branch (#939) * feat(storage): RuntimeStore HTTP contract + HttpRuntimeStore client (#939 Part B) (#940) * feat(storage): RuntimeStore HTTP contract + HttpRuntimeStore client (#939 Part B) - documented JSON HTTP contract for every RuntimeStore operation (server-assigned seq, learnerKey derived from auth — never trusted from the request, machine-readable error codes, idempotency notes) - HttpRuntimeStore client with injected fetch + auth headers hook; session reads (including createSession responses) migrate forward on the runtime line so an older server cannot leak stale envelopes - conformance test server bridging the contract onto the browser backend; full runRuntimeStoreContract green over HTTP plus error mapping cases * fix(storage): harden HTTP runtime backend after cross-review (#939 Part B) Cross-review fixes: - appendRecord gates payloads through assertJsonValue: fail loud on values JSON cannot represent (Map, Date, NaN, nested undefined, NUL strings) instead of silently mangling them in transit; payload domain documented in the contract - appendRecord/listRecords responses validate via validateRuntimeRecord (server-assigned seq is no longer trusted verbatim) and listRecords sorts by seq like listSessions already sorted defensively - request headers no longer require an ambient Headers constructor when fetch is injected - conformance server classifies errors structurally (existence checks, DSL validators, version stamps) instead of regex-sniffing messages that interpolate caller-controlled ids - router preserves empty path segments so empty-key calls keep browser no-op semantics instead of shifting onto other routes - stray runtimeDslVersion/seq in request bodies are ignored (store- assigned wins), matching the browser reference; docs updated - loopback integration test exercises the real listening server - docs state the conformance server is test-only; auth-derived learnerKey is Part D's reference server * fix(storage): round-2 cross-review hardening for the HTTP backend (#939 Part B) - json-value guard rewritten: additionally rejects -0, symbol-keyed and non-enumerable own properties, and non-index own properties on arrays; U+2028/U+2029 are accepted again (they round-trip through JSON per RFC 8259 — rejecting them lost legitimate pasted text) - conformance server classifies racing duplicate creates as 409 via a structural post-check, gates payloads and merge learner keys as 400 - segment() rejects '.'/'..' ids the URL layer would fold away - list responses must be arrays and mergeLearner's moved must be a finite number, else a typed MALFORMED_RESPONSE error * fix(storage): round-3 cross-review hardening for the HTTP backend (#939 Part B) - json-value guard: NUL is rejected in object keys too, and unpaired UTF-16 surrogates are rejected in string values and keys (jsonb refuses both; other JSON stacks corrupt lone surrogates to U+FFFD) - typed storage errors survive non-object 200 responses instead of degrading into TypeErrors on .id access - mergeLearner's moved must be a non-negative integer - conformance server classifies missing/non-object request bodies as 400 VALIDATION_FAILED - createSession/appendRecord gate the full init envelope, not only the payload (stray Date/undefined properties, NUL in ids) * fix(storage): round-4 convergence fixes for the HTTP backend (#939 Part B) - envelope JSON gate tolerates explicitly-undefined optional anchors (sceneId: undefined behaves like omission, matching the browser) - body-carried identifiers reject '.'/'..' so nothing persists that the URL layer cannot later address; conformance server mirrors it - mergeLearner keys pass the JSON-domain gate on client and server - json-value guard v4: rejects enumerable accessors (validation/serialize TOCTOU), Array subclasses and null-proto arrays, prototype-supplied array indices; sparse-array test now constructs a genuine hole * fix(storage): round-5 convergence fixes for the HTTP backend (#939 Part B) - undefined-stripping is limited to the DSL-declared optional anchors (sceneId/actionIndex/subAnchor); any other undefined member fails the JSON gate loud instead of being dropped silently - json-value guard: array and object prototype checks are realm-agnostic (chain-shape instead of identity), so ordinary values from another realm are accepted while subclasses stay rejected; shared isLosslessJsonString predicate exported for SQL key guards - conformance server merge route validates target-key addressability * fix(storage): close the toJSON prototype channel in the JSON guard (#939) toJSON is the single channel through which a prototype can alter JSON output — prototype properties never serialize and own accessors are already rejected — so refusing any value with a callable toJSON closes prototype influence on serialization entirely, including prototypes crafted to pass the realm-agnostic chain-shape check. * fix(storage): probe toJSON via descriptor walk, not property read (#939) Reading value.toJSON would execute an inherited accessor, letting a stateful getter hide from validation and reappear at stringify time. The probe now walks own descriptors up the prototype chain without invoking any user code. Post-validation mutation of the caller's object graph is documented as out of scope — it is equally unpreventable for every other validated property. * fix(storage): toJSON probe mirrors JSON.stringify semantics exactly (#939) The own-most descriptor decides: absent is safe, a non-callable data value is an ordinary shadowing member and is safe, a callable data value is rejected, and an accessor is rejected because it cannot be inspected without invoking it. No daylight remains between the guard and the serializer on this property. * feat(storage): PgRuntimeStore — Postgres runtime backend (#939 Part C) (#941) * feat(storage): PgRuntimeStore — Postgres runtime backend over an injected queryable (#939 Part C) - PgRuntimeStore implements RuntimeStore over a minimal injected Queryable (node-postgres and PGlite both satisfy it) — the package keeps zero runtime dependencies beyond @openmaic/dsl - runtime_sessions / runtime_records schema exported as RUNTIME_PG_SCHEMA with idempotent ensureSchema() - appends serialize per session via a session-row lock; MAX(seq)+1 and the insert share one transaction, UNIQUE(session_id, seq) plus bounded retry backstop non-cooperating writers - envelope semantics mirror the browser backend: version stamping, validation gates, migrate-on-read, fail-loud on future-stamped rows - full runRuntimeStoreContract green on PGlite plus PG-specific cases (concurrent-append seq atomicity, ensureSchema and mergeLearner idempotence) * fix(storage): require pinned transactions + JSON payload gate in PG backend (#939 Part C) Cross-review fixes: - withTransaction is now required; the BEGIN/COMMIT fallback is removed (on a pg Pool it spread BEGIN/body/COMMIT across different connections — no real transaction, leaked idle-in-transaction clients, broken mergeLearner atomicity; on a shared pinned client concurrent calls interleaved transactions) - single-statement deletes no longer wrap in a transaction hook call - appendRecord gates payloads through assertJsonValue: fail loud on values JSON cannot represent (Map, Date, NaN, nested undefined, NUL strings) instead of silently persisting something different - append retry also covers 40001/40P01; READ COMMITTED assumption documented at the retry loop - ensureSchema documented as create-only; redundant stage index dropped - deterministic interleaving test proves the 23505 retry path; real PostgreSQL contract lane added (postgres:16 service workflow, pg driver suite skipped locally without PG_CONTRACT_URL) * fix(storage): round-2 cross-review hardening for the PG backend (#939 Part C) - json-value guard rewritten (shared with the HTTP backend): additionally rejects -0, symbol-keyed and non-enumerable own properties, and non-index own properties on arrays; accepts U+2028/U+2029 - storage-pg-contract workflow now triggers for the runtime-server-backend integration branch and fails loud (STORAGE_PG_CONTRACT_REQUIRED=1) when PG_CONTRACT_URL is missing instead of silently skipping - loadSession distinguishes corrupt non-object rows from absent rows so getSession fails loud instead of reporting the session missing * fix(storage): round-3 cross-review hardening for the PG backend (#939 Part C) - json-value guard (shared): NUL rejected in object keys, unpaired UTF-16 surrogates rejected in values and keys - createSession/appendRecord gate the full persisted envelope through assertJsonValue, not only the payload — stray Date/undefined properties and NUL in ids fail loud instead of silently diverging from the returned record or leaking raw 22P05 - real-PG lane covers a genuine 23505 conflict from a second connection and recovery after an aborted transaction - retry-set asymmetry and mergeLearner's unbounded lock set documented * fix(storage): round-4 convergence fixes for the PG backend (#939 Part C) - NUL/lone-surrogate lookup and delete keys resolve to absent/no-op instead of leaking 22021 driver errors; setSessionStatus reports the session missing; mergeLearner from-key moves 0 - mergeLearner destination key passes the JSON-domain gate fail-loud - envelope JSON gate tolerates explicitly-undefined optional anchors, matching the browser backend - json-value guard v4 (shared) + tests for accessors, Array subclasses, prototype-supplied indices * fix(storage): round-5 convergence fixes for the PG backend (#939 Part C) - appendRecord pre-checks the session key like every other lookup path, so a NUL/lone-surrogate sessionId reports 'no session' instead of leaking a 22021 driver error - the queryable-key predicate now structurally reuses the shared isLosslessJsonString export instead of restating the string rule - undefined-stripping limited to the DSL-declared optional anchors - json-value guard: realm-agnostic prototype checks (shared) * fix(storage): close the toJSON prototype channel in the JSON guard (#939) Shared guard change with the HTTP backend branch; adds the crafted null-proto-prototype regression test. Both final-gate reviewers independently converged on this same channel. * fix(storage): probe toJSON via descriptor walk, not property read (#939) Reading value.toJSON would execute an inherited accessor, letting a stateful getter hide from validation and reappear at stringify time. The probe now walks own descriptors up the prototype chain without invoking any user code. Post-validation mutation of the caller's object graph is documented as out of scope — it is equally unpreventable for every other validated property. * fix(storage): toJSON probe mirrors JSON.stringify semantics exactly (#939) The own-most descriptor decides: absent is safe, a non-callable data value is an ordinary shadowing member and is safe, a callable data value is rejected, and an accessor is rejected because it cannot be inspected without invoking it. No daylight remains between the guard and the serializer on this property. * fix(storage): conform HTTP/PG backends to the post-#926 RuntimeStore interface (#943) The chat cutover (#926) added deleteAllRuntime() to the RuntimeStore contract while the HTTP and Postgres backends were developed in parallel against the pre-#926 interface. Implements the method on both backends (single-statement wipe on PG via the FK cascade; DELETE /runtime on the HTTP contract, documented as an operator-gated administrative endpoint), restoring a green typecheck and contract suite on the integration branch. * feat(runtime): injectable RuntimeStore backend + learner-key provider (#939 Part A) (#944) * wip(runtime): backend injection seam — pending gate verification * fix(runtime): cross-review hardening for the storage injection seam - stage-deletion IndexedDB probe applies only to the default browser backend; an injected store always receives deleteStageRuntime - explicit kv argument takes precedence over the configured learner-key provider, matching the store seam's explicit-beats-global rule - configured learner-key resolution is latched with in-flight dedup, mirroring the store singleton; identity changes require app-level handling - factory retry semantics documented; seal errors explain the module-level bootstrap requirement; isRuntimeStorageConfigured() probe and a test-only reset added - client-bootstrap-only contract documented (SSR/HMR caveats) * fix(runtime): snapshot configuration and make the test reset complete - configureRuntimeStorage copies the option fields so mutating the caller's object after configuring cannot swap the sealed backend or identity provider - resetRuntimeStorageForTests now clears every latched consumer cache (store singleton, learner-key in-flight promise) via a reset-hook registry, so a reset-then-reconfigure test actually gets the new backend * fix(runtime): reset also clears the default learner-key caches resetRuntimeStorageForTests left defaultInFlight/defaultKv latched, so a default-path test could leak its anonymous key or KV store into the next test despite the documented full-reset promise. * feat(storage): runtime reference server — auth-derived learnerKey (#939 Part D) (#945) * wip(storage): reference server — pending deleteAllRuntime route + gates * feat(storage): runtime reference server — auth-derived learnerKey enforcement (#939 Part D) - createRuntimeHttpHandler(store, options) wires the documented HTTP contract onto any injected RuntimeStore over node http - authenticate is required; every learner-scoped operation verifies the path/body learnerKey against the authenticated principal (403 FORBIDDEN_LEARNER) — the client-supplied value is never trusted - mergeLearner requires an explicit authorizeMerge grant and admin surfaces (stage cascade, DELETE /runtime) require authorizeAdmin; both default-deny - runnable reference entry demonstrates a pg Pool withTransaction and bearer-token authentication, marked as demo-only - contract suite green through HttpRuntimeStore -> listening reference handler -> PgRuntimeStore(pglite); security matrix tested (401/403 paths, admin default-deny); threat model documented * fix(storage): cross-review hardening for the reference server (#939 Part D) - principal learnerKey is optional: admin/merge-only credentials no longer fabricate learner identity; learner-scoped routes 403 without ownership - full session/record envelopes pass the JSON-domain gate at the handler, so NUL/lone-surrogate identifiers map to 400 instead of 500 - payload validation follows the injected store's validator map (options.payloadValidators) instead of imposing DSL defaults - reference factory accepts authenticate/authorizeMerge/authorizeAdmin/ payloadValidators overrides; docs no longer claim the factory binds to localhost; demo-impersonation warning hardened - 500 responses carry a generic message; details go to the server log - ownership checks precede version checks and unowned sessions read as 404, closing existence/version oracles; cross-learner denial matrix tested per route - merge/delete concurrency documented as linearizable-equivalent with the ownership re-check narrowed to the delete call * fix(storage): align reference-server semantics with the store contract - future-stamped sessions read and delete through unchanged; 409 FUTURE_VERSION applies only to guarded writes (status, append, merge) - check-then-write races reclassify structurally via a post-failure re-fetch: missing session 404, non-active session 400, never a message-sniffed or generic 500 * fix(storage): close the reference CLI's pg pool on startup failure ensureSchema opens connections inside createReferenceRuntimeServer, so a failed schema init or occupied port left the pool holding database resources until its idle timeout. * fix(storage): close the records-route existence oracle cosarah's review point on #946: the records list answered 200 [] for an absent session but 404 for another learner's, so the 404 leaked that an id exists. Absent and foreign sessions now answer identically (404 SESSION_NOT_FOUND) and the HTTP client maps that code back to an empty list, preserving the store contract's absent-lists-as-empty semantics. Contract doc records the server MAY/SHOULD and the client MUST. |
||
|
|
d8a0081c7d |
feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865) (#931)
* feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865) Consume the VideoTimeline IR (#864/#913) and produce a self-contained Hyperframes composition project that `npx hyperframes render` turns into an MP4 (render execution itself is #866). - lib/video-export/emit-hyperframes: pure IR → Hyperframes project emitter (one flat composition, one paused GSAP timeline on window.__timelines, spotlight/laser overlays from lib/choreography descriptors). Stays under the purity boundary; emits HTML/JS strings only. - lib/video-export/subtitles: SRT/VTT serialization of the IR subtitle track. - lib/video-export-app: impure app glue (DI adapters over Dexie, slide-snapshot collection lifted from #849, streaming ZIP packaging, useExportVideo hook). - Vendor GSAP locally (public/vendor/gsap.min.js) — determinism red-line. - Export menu entry with resolution select + i18n across all 8 locales. Verified end-to-end against the real hyperframes CLI: lint passes 0 errors on a real classroom export, and a rendered slice produces a valid H.264+AAC MP4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(video-export): gate Export Video menu behind NEXT_PUBLIC_ENABLE_VIDEO_EXPORT flag The video export UI is experimental until the render pipeline (#866) lands, so hide the "Export Video" affordance behind an off-by-default feature flag. - Add isVideoExportEnabled() to lib/config/feature-flags (NEXT_PUBLIC_, so it inlines at build time for the client-side export menu). - Gate the Export Video block in header-controls on the flag. - Cover the new flag in tests/config/feature-flags.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): ossKey fallback for evicted blobs + emitter dedup Address code-review findings on #931. - collect: resolveBytes() prefers the local Dexie blob and falls back to the record's CDN ossKey (audio) / ossKey+posterOssKey (media/poster) so a live-mode classroom whose local blobs were LRU-evicted still exports a self-contained ZIP. timeline-deps presence checks widened to `blob.size > 0 || !!ossKey` so the compiler keeps these entries instead of marking them absent; video-duration probe stays local-only. - emit-hyperframes: extract one shared escapeHtml + sec into format.ts, dropping the two divergent escapers (attr vs escapeHtml) and the two sec() definitions with different rounding, plus the middle-man escapeAttr. Output is byte-identical (snapshot unchanged). - Add tests/video-export/collect.test.ts (ossKey fallback, local-first, fetch 404/throw, video+poster). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): restore evicted generated media into base-frame snapshots Address review blocker (P1) on #931: the ossKey fallback covered standalone audio/video/image/poster asset entries but not the slide base-frame path. resolveGeneratedMedia only accepted a non-empty local blob, so a live-mode record with an evicted blob + valid ossKey had its image src cleared (and video/poster not restored) before slideToPng snapshotted the slide — the frame PNG lost the generated media even though the standalone asset was fetched. - resolveGeneratedMedia now resolves bytes via resolveBytes (local blob first, then ossKey / posterOssKey) before creating the snapshot object URLs. - Add frame-collection tests: evicted image restored via ossKey, evicted video+poster restored, and image with no ossKey still cleared. The renderer snapshot is mocked so the frame path runs in plain Node. 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> |
||
|
|
ebb12b5fc1 |
feat(agent): add validated JSON Patch element edits (#927)
* feat(agent): add validated JSON Patch element edits * fix(agent): preserve JSON Patch replace semantics * fix(agent): close structured replace contract gaps * fix(agent): preserve structured add sequencing * fix(renderer): preserve existing image filter units --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
9670100363 |
feat(extraction): media (audio/video) extraction + AliDocMind provider (#887)
* feat(extraction): add AliDocMind provider + media extraction abstraction Adds AliDocMind (Aliyun Document Mind LLM version) as a new vendor alongside unpdf/MinerU, and introduces the media (audio/video) extraction layer that mirrors the document extraction one. Document side (file: pdf/docx/pptx/xlsx/images): - PDF_PROVIDERS gains an `alidocmind` entry; parseWithAliDocMind() maps layouts[] -> ParsedPdfContent. Flows through the existing document extractor registry, so AliDocMind is selectable anywhere MinerU is. - PDFParserConfig / DocumentExtractorConfig gain accessKeyId/accessKeySecret (AliDocMind uses AK/SK, not a single apiKey); env fallback via ALIDOCMIND_ACCESS_KEY_ID / ALIDOCMIND_ACCESS_KEY_SECRET. Media side (mp4/mp3/wav/... -> MediaArtifact): - New lib/media-parse/ domain mirroring lib/pdf/ (types/constants/providers). parseMedia() maps AliDocMind segments[]/audio_frames/video_frames -> MediaArtifact (transcript + keyframes). - MediaExtractorProvider interface + media registry + extractMedia() entry, symmetric to DocumentExtractorProvider / extractDocument(). - MediaArtifact and the ExtractionResult/Artifact/Error/Job envelope live in lib/document/types.ts (re-exported from @/lib/document). Shared AliDocMind SDK wrapper (lib/pdf/alidocmind-client.ts) handles the submit -> poll -> get flow for both sides via @alicloud/docmind-api20220711. Tests: env-gated smoke test (tests/document/alidocmind.smoke.test.ts) drives a real PDF and a real video through AliDocMind; media-artifact type test; extractor-registry test updated for the new provider. Part of #621 (MAIC ETL). Media extraction is the sibling to the document extraction landed in #741. * feat(extraction): wire AliDocMind AK/SK through settings UI + routes Surfaces AliDocMind in the (post-#837) Document Parsing settings panel as a peer of unpdf/MinerU — one panel, one credential entry, more supported formats. Threads Aliyun AccessKey ID/Secret from the store through the extraction routes. - Store: pdfProvidersConfig + setPDFProviderConfig gain accessKeyId/accessKeySecret; default alidocmind entry added. - pdf-settings.tsx: AliDocMind branch renders AccessKey ID + Secret inputs (secret masked with show/hide) and a Test Connection button; request-URL preview shows the DocMind endpoint. Provider auto-appears in the panel via PDF_PROVIDERS; supported-format badges come from #837's registry (ALIDOCMIND_MIMES added to lib/document/mime.ts). - verify-pdf-provider route: alidocmind branch verifies AK/SK via a lightweight authenticated probe (verifyAliDocMindCredentials) — auth-level errors fail, anything else passes. - extract-document route + generation flow (app/page.tsx, generation-preview): accessKeyId/accessKeySecret carried through session → FormData → config. - i18n: alidocmindAccessKeyId / alidocmindAccessKeySecret in 8 locales. - Icon: reuse /logos/bailian.svg (Aliyun family) instead of a missing asset. Verified end-to-end against the running app: AliDocMind panel renders, Test Connection returns "连接成功" through the real Aliyun API. Part of #886. * feat(extraction): route audio/video uploads through extractMedia() (reuse document path) Media uploads now flow through the same upload picker, /api/extract-document route, and generation pipeline as documents — no separate upload area. Only the extraction differs: media mimes dispatch to extractMedia() -> MediaArtifact, which is flattened to the text shape the generation pipeline already consumes. - mime.ts: register audio/video formats in DOCUMENT_FORMATS (accept string, extension map, badges resolve for them); add MEDIA_PROVIDER_SUPPORTED_MIME_TYPES + SUPPORTED_MEDIA_MIME_TYPES, kept separate from PROVIDER_SUPPORTED_MIME_TYPES so the document drift-guard stays document-only. mimesForProviders() folds in a provider's media mimes, so the existing upload helpers (getAcceptStringForProviders / isMimeSupportedByProviders / format badges) cover media automatically when the provider supports it. - extract-document route: media mimes dispatch to extractMedia(); MediaArtifact flattened to timestamped text (synopsis + transcript + keyframes). - Tests: 6 media cases in mime.test.ts (accept/validation/badges/normalization). Verified: uploading a real video through the route returns synopsis + timestamped transcript/keyframes as text (curl, real AliDocMind key). Part of #886. * feat(extraction): use Alibaba Cloud icon for AliDocMind provider Replace the placeholder bailian.svg with a dedicated Alibaba Cloud icon mark (the square symbol from the official wordmark, text removed) so the provider reads as Aliyun rather than Bailian. Square aspect matches the other provider icons. Source: Alibaba Cloud / Alibaba Group brand assets. * style: prettier format AliDocMind + media extraction files * fix(extraction): harden AliDocMind — SSRF guard, cred verify, env gate, table text Addresses review findings on the AliDocMind provider: - SSRF (high): the media branch of /api/extract-document now runs the same validateUrlForSSRF check on a client-supplied baseUrl as the document branch, so an audio/video upload can't point the server's Aliyun SDK at an internal host. - Credential verify (high): verifyAliDocMindCredentials now whitelists success signals instead of blacklisting auth errors. Probed against the real API: valid creds + bogus job returns a no-throw "BizIdNotExistOrResultExpired" body; invalid creds throw InvalidAccessKeyId.NotFound. Only a no-throw response or a "biz-not-found" business error counts as valid — an unreachable endpoint, a localized error, or throttling now correctly reports failure instead of a false "connection successful". - Env-fallback gate (med): resolveCredentials no longer reads ALIDOCMIND_ACCESS_KEY_ID/SECRET unconditionally. Env fallback is opt-in via allowEnvFallback, which the route sets only for a server-managed provider, so an unauthenticated client request can't silently run on the server account. - getDocParserResult error body (med): fetchResult throws on a non-200 result envelope instead of returning {} (empty text presented as success). - Media pagination (med): stop after the first page when segments[] are present — layoutNum/layoutStepSize address layout blocks, not media segments, so re-requesting would loop over the same segments to the safety cap. - Table content (med): tables/charts carry content in llmResult, not markdownContent; the layouts→text mapping now prefers llmResult for those types so table content isn't dropped (tables: true was advertised). - Dead code: remove getCurrentMediaParseConfig (referenced non-existent store fields; the single-panel UI reuses pdfProvidersConfig). - Dedup: media MIME list lives only in lib/document/mime.ts (ALIDOCMIND_MEDIA_MIMES); the media registry imports it. - Tests: smoke test asserts durationMs is in ms (>1000 for a ~52s clip) to guard a ms/s unit mismatch; uses allowEnvFallback for the env-cred path. Verified with the real key: PDF + video smoke tests pass; verify classifies valid vs invalid creds correctly. Part of #886. * feat(extraction): extract AliDocMind images to base64 (parity with unpdf/MinerU) AliDocMind embeds figure/picture image URLs inside each layout's markdownContent (markdown ``), not a dedicated field, and the URLs are short-lived OSS signed links. Previously we emitted `images: []` and left the expiring URLs inside the extracted text. Now, for figure/picture layouts we: - parse the OSS image URL out of markdownContent, - fetch it at extraction time (before the signature expires) and re-encode to PNG base64 via sharp — the same base64 `images[]` contract unpdf/MinerU produce, so downstream storeImages → IndexedDB → slide works unchanged, - populate metadata.pdfImages + imageMapping (the generation flow prefers pdfImages), - strip the remote-URL markdown from the emitted text so expiring links don't leak into the prompt. Downloads run concurrently; a failed/`sharp` image is dropped, never failing the whole parse. Also fixes the prior over-broad table handling: only `table` blocks read llmResult; `figure` is treated as an image (chart-figure llmResult still kept in text). Verified with the real key: a sample PDF yields 24 base64 images in both images[] and metadata.pdfImages, and no oss-cn-hangzhou URLs remain in text. Part of #886. * docs(test): note AliDocMind video smoke test is non-deterministic server-side * fix(extraction): correct AliDocMind pageCount (pageNum is 0-based) Verified against a real response: AliDocMind reports pageNum 0..13 and pageCountEstimate 13 for a 14-page document — both are 0-based. The metadata pageCount previously used pageCountEstimate directly, undercounting by one. Use the already-1-based maxPage, falling back to pageCountEstimate+1 only when no blocks were seen. Document the 0-based convention at the normalization site. * fix(extraction): address AliDocMind review — verify/SSRF/config/media (P1+P2) Resolves all P1/P2 findings from the cross-review on #887. P1 (blocking): 1. verifyAliDocMindCredentials now inspects the no-throw response body.code. An OSS-only key returns NoPermission without throwing; previously that was green-lit as "connection successful" then failed at extraction. Only a success/200 or the job-not-found probe code is accepted. Deterministic mocked test added (tests/document/alidocmind-verify.test.ts). 2. verify route trust boundary: managed → server-owned AK/SK + default endpoint only (ignore client values); unmanaged → client creds only, never env fallback, and the client endpoint is SSRF-validated before signing. 3. image fetch hardened: restricted to Aliyun OSS hosts, redirects disallowed, per-image byte cap, image-count cap, bounded concurrency (was unbounded Promise.all over provider-returned URLs). 4. AliDocMind is now selectable in the generation toolbar — availability recognizes the AK/SK pair, not just apiKey. P2 (correctness): 5. Explicit server-config for the AK/SK pair (applyAliDocMindFallback + resolveManagedAliDocMindCredentials); verify and extract now resolve managed/env identically instead of verify-uses-env / extract-rejects. 6. Poll loop checks body.code before status, so a body-level error (e.g. NoPermission) fails fast instead of retrying for the full 15 min. 7. Image page numbers preserved through fetch/filter — no longer hard-coded to page 1, so multi-page image→page association is correct. 8. Empty media extraction (no synopsis/transcript/keyframes) returns 422 PARSE_FAILED instead of HTTP 200 with empty text. 9. Format matrix trimmed to the official contract: images JPG/JPEG/PNG/BMP/GIF (dropped WebP/JP2), media MP4/MKV/AVI/MOV/WMV/MP3/WAV/AAC (dropped M4A). 10. A document-only provider (unpdf/mineru) uploaded with a media file now returns a clear 4xx instead of an opaque 500. P3: credential-verify failures return INVALID_CREDENTIALS 4xx (not INTERNAL_ERROR 500); formatTimestamp emits HH:MM:SS past one hour. Verified with the real key: verify classifies valid→ok and invalid→fail; PDF (24 images, correct page numbers) and MP4 extraction pass end-to-end. * fix(extraction): make AliDocMind selectable + correct analysis label for media Two UI/UX fixes found while manually testing the AliDocMind flow end-to-end: - Persisted-state backfill: add ensureBuiltInPDFProviders so a PDF/document provider added after a user's settings were persisted (AliDocMind) is backfilled into pdfProvidersConfig on rehydrate. Without it the provider never appeared in the store, so it couldn't be selected and never picked up its server-configured flag. Wired into both persist migrate() and merge(), mirroring the existing image/video/web-search backfills. - Analysis step label: getGenerationStepText showed "解析 document 文件" for audio/video (the type map fell through to the literal "document"). Documents keep their precise token (PDF/DOCX/PPTX/XLSX/images); audio/video now use a dedicated, locale-correct string (generation.analyzingMediaMaterial, added to all 8 locales) instead of forcing a format token into the "{{type}} 文件" template. Manually verified end-to-end (real key): PDF, MP4, and MP3 (audio extracted from the sample video) each extract and drive full course generation; a DocMind-restricted AK/SK (OSS-only) now correctly fails verification with NoPermission (400) instead of a false "connection successful". * fix(extraction): address 2nd-round AliDocMind review (managed creds, stream cap, empty verify) Resolves the three follow-up findings on #887: 1. [P1] YAML-managed AliDocMind creds now reach extraction. Both extract paths (document + media) previously cleared the managed AK/SK and relied on an env-only fallback, so a YAML-only deployment verified but failed to extract. They now resolve server-owned creds via the shared resolveManagedAliDocMindCredentials() (env OR YAML), matching the verifier. Regression test added for YAML-only creds with no ALIDOCMIND_* env. 2. [P1] Image download is now size-capped while streaming. Instead of buffering the whole response and checking length afterward, the body is read chunk by chunk with a cumulative byte count that aborts the moment it exceeds the cap — a missing/false Content-Length can no longer exhaust memory. Host allowlist tightened to oss-*.aliyuncs.com. Tests: non-OSS host refusal, no-Content-Length overflow abort, declared-oversize rejection. 3. [P2] Empty verification body no longer counts as success. Removed the codeStr === '' branch from the positive-signal whitelist (a working key always returns the job-not-found business code for the bogus probe id). Regression test added for empty and absent bodies. Rebased onto main (package.json: kept @openmaic/storage + AliCloud deps). Verified with the real key: PDF + MP4 extraction still pass end-to-end. * fix(extraction): merge AliDocMind AK/SK into a baseUrl-configured YAML entry Follow-up to the YAML-managed credential fix. When a YAML `pdf.alidocmind` entry also specifies `baseUrl`, the generic loadEnvSection() (pdf requires a baseUrl) creates the pdf.alidocmind entry copying only apiKey/baseUrl/models/ proxy — never AK/SK. applyAliDocMindFallback() then returned early because the entry already existed, so the provider was "managed" but had no usable credentials, and resolveManagedAliDocMindCredentials() returned undefined (verify + extract both silently lost the creds). Merge the AK/SK into the existing entry instead of returning early. Added a regression test with baseUrl + accessKeyId + accessKeySecret together (the previous test omitted baseUrl, so the generic loader skipped the entry and the bug was masked). * fix(extraction): don't mark AliDocMind managed without AK/SK; align poll code check Two edge cases from a follow-up adversarial pass: - [MED] A YAML `pdf.alidocmind` entry with `baseUrl` but no AK/SK (and no env AK/SK) made the generic loader create the entry → isServerConfigured=true (managed) → but resolveManagedAliDocMindCredentials() returned undefined, so the provider was locked out AND client-entered AK/SK were silently dropped. applyAliDocMindFallback now deletes a credential-less entry so the provider stays UNMANAGED (clients supply their own creds). Regression test added. - [LOW] verify accepted a `code: "success"` body but the extraction poll loop threw on any non-"200" code — a success-shaped status would pass verification then fail extraction. The poll loop now treats "200"/"success" as benign, matching verifyAliDocMindCredentials. --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
3b63710376 |
feat(ai): add Azure OpenAI provider (#916)
* feat(ai): add Azure OpenAI provider Add deployment-based Azure OpenAI configuration for both client and server-managed setups, normalize Azure portal endpoints, and expose the provider in settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0d3a94f-089a-470e-9987-608f1506ca6f * style: format Azure provider type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0d3a94f-089a-470e-9987-608f1506ca6f --------- Co-authored-by: MarshellOnMoon <121332425+MarshellOnMoon@users.noreply.github.com> |
||
|
|
c8a638a101 |
feat: support GPT-5.6 model family (#907)
* feat: support GPT-5.6 model family * test: cover GPT-5.6 SDK validation * fix: canonicalize GPT-5.6 Sol alias * fix: complete GPT-5.6 alias lookups --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
5627b99156 |
feat(pbl): runtime-event outbox + dual-write into the RuntimeStore (#869 Part C, step 2) (#893)
* feat(pbl): emit typed runtime events from every learner-state mutation (#869) The outbox half of the runtime split: pure operations append typed PBLRuntimeEvents to project.runtimeEvents (ring-capped at 500, dedup by id) for every learner-state mutation — messages, submissions, evaluations, status changes across project/milestone/microtask/uiPhase, handover and task-completion gates, and proficiency updates. Emission stays inside the pure mutators (no I/O), so events cross the stateless server boundary inside the project; a client-side drainer (next commit) copies them into the RuntimeStore. Zero read-side changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pbl): drain the runtime-event outbox into the RuntimeStore (#869) Client-only drainer: after each persisted project change, copy new runtimeEvents into the learner's active 'pbl' RuntimeSession (created lazily), watermarked per stage through the device-scoped KV. Mid-drain failures persist the last-success watermark and retry next tick; a watermark evicted from the ring-capped ledger redrains visible events (documented at-least-once — downstream folds dedup by event id). The whole drain is bounded and never throws into the save path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(pbl): dual-write engagement events; end-to-end outbox coverage (#869) The drainer now copies both in-project ledgers — runtimeEvents and engagementEvents — into the learner's pbl RuntimeSession, with independent watermarks that advance separately (a failure in one ledger never loses the other's progress). End-to-end test drives real operations through the reducers and drains into a real BrowserRuntimeStore over fake-indexeddb, asserting the full ordered event trail with correct anchors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pbl): harden the outbox drain per cross-review (#869) - Watermarks scope to (stageId, sceneId, learnerKey): multi-scene stages and multi-learner devices no longer cross-contaminate or redrain. - First-session creation is race-free: deterministic session id (pbl-<stageId>-<learnerKey>) with already-exists fallback plus an in-flight memo, so concurrent drains cannot split one history. - A corrupt watermark value degrades to a fresh redrain and is repaired on the next persist, instead of permanently blocking the drain. - Stage deletion clears its drain watermarks alongside the runtime rows. - resetProjectProgress emits a project_reset epoch marker so folds never resurrect pre-reset submissions/evaluations from the preserved ledger. - Normalization repairs use deterministic event ids, collapsing echoes from cloned-copy re-normalization under id-dedup. - Docs: ring-overflow latent-loss window (dual-write accepts it; the read-flip backfills from a projectV2 snapshot) and the fold-baseline contract (status 'active' / uiPhase 'hero' from design-time defaults). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pbl): carry server-minted events across the advance patch (#869) Two holes on the server-patch path: applyAdvanceProjectPatch computed status-change 'from' values after Object.assign had already replaced the milestone snapshot (suppressing every real Instructor-advance event as from===to), and events minted server-side during advanceMicrotask (e.g. handover_staged) lived only on the transient server copy — the advance patch carried no runtimeEvents, so the client ledger never drained them. Patches now carry the operation's event delta; the client appends them first, and both sides mint deterministic patch:* ids for the same transition so echoes collapse under id-dedup. Server-side facts are authoritative; client emissions remain as a compatibility fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pbl): scope deterministic event ids to the reset epoch (#869) Deterministic norm:/patch: status-event ids now lead with the count of project_reset events visible in the ledger, so a learner repeating the same transition after a reset is recorded instead of deduped against the pre-reset event. The epoch is derived from the same visible window the id-dedup scans, so ring-buffer eviction can never desynchronize the two. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(store): drain debounced saves before teardown (#869) setStageAgents schedules debouncedSave + debouncedSaveAgents; tests that finished without flushing left timers firing after environment teardown, lazily importing the settings/providers chain into a torn-down runtime (deterministic on CI once this branch grew the stage-storage import graph). Store tests now run fake timers and assert zero pending timers at teardown; the same latent race in the insert-scene-after and generation-complete suites is fixed in the same style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pbl): address review — durable epoch, id-set deltas, ordered drain, post-save signal (#869) - runtimeResetEpoch is a monotonic project field incremented on reset, never derived from the ring buffer, so evicting an old reset marker can no longer collapse post-reset transitions into pre-reset ids. - Advance patches capture their event delta by id-set difference, not array index, so cap eviction mid-operation cannot drop server events. - The drainer merges both ledgers into one timestamp-ordered stream (stable within each ledger) before appending, so RuntimeStore seq preserves global chronology instead of ledger-grouped order. - Draining now subscribes to a post-save signal emitted after saveToStorage commits, instead of firing on the in-memory update — the dual-write can no longer outrun the source-of-truth persistence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pbl): drain the persisted snapshot, decoupled from scene mounting (#869) The post-save signal now carries the PBL scenes exactly as persisted; a module-level singleton subscription drains them, replacing the component-ref hook. Navigating away before the debounced save fires no longer strands the saved scene's outbox, and an in-flight save can no longer drain a newer unsaved snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
667f3b0c51 |
feat(runtime): device-anonymous learner identity + RuntimeStore app bootstrap (#869 Part C, step 1) (#885)
* feat(runtime): device-anonymous learnerKey + app RuntimeStore singleton (#869) * feat(runtime): cascade stage deletion into the runtime store (#869) deleteStageWithRelatedData now cascades into the runtime layer after its Dexie transaction completes. The runtime data lives in a separate IndexedDB database (maic-runtime), so it cannot join the transaction, and the cascade goes through deleteStageRuntimeSafely — a helper that never throws (warns with context instead): a broken or hung runtime DB must not brick stage deletion in the main app DB. deleteStageRuntime is idempotent, so a failed cascade can simply be retried. Covered at the helper seam with a stub RuntimeStore (success + throwing); the repo has no Dexie-in-node harness for database.ts and this change does not invent one. * fix(runtime): wire the live deletion path, serialize learner-key minting (#869) Cross-review fixes on the C1 bootstrap: - The runtime cascade was wired into deleteStageWithRelatedData, which has zero callers; the UI classroom-deletion flow goes through deleteStageData in stage-storage. Wire deleteStageRuntimeSafely into that live path too (after its Dexie work, same isolation rationale), and cover the wiring by running the real deleteStageData with its module deps mocked — the repo's established pattern for database-touching code. - getLearnerKey minted twice under concurrency. Same-bundle callers now share one in-flight promise on the default path (failures are not cached), and every path re-reads after writing and returns the PERSISTED key, so a cross-tab race converges on the stored winner instead of keeping an orphaned local mint. - Guard crypto.randomUUID with the house fallback pattern. - Honest docs: the cascade comment no longer claims a retry path exists (orphaned rows are inert today; a startup sweep is deferred to Part C2), and both lib/runtime modules note they are client-only. * fix(runtime): cross-tab mint lock and bounded deletion cascade (#869) - Read-after-write alone still let a tab keep an orphaned learner key when its re-read landed before the other tab's write. Minting now runs under the Web Locks API ('maic:learner-key') where available: grants are mutually exclusive across tabs, the loser re-reads the winner's key inside its grant, and an existing key is never overwritten (so the per-tab memo stays safe). Without navigator.locks (older browsers, non-window contexts) the memo + read-after-write behavior remains, with the residual race named in a comment and accepted — it merely splits one anonymous learner's local history. - deleteStageRuntimeSafely awaited without a bound, so a hung runtime IndexedDB could block the live deletion path — the exact failure the helper exists to isolate. The cascade now races a 5s timeout: on timeout it warns and resolves (orphaned rows stay inert), and the still -pending cascade carries a swallow handler so a late rejection cannot become an unhandled rejection. * fix(runtime): probe for the runtime DB before cascading a stage deletion (#869) deleteStageRuntimeSafely reached openDb() unconditionally, and opening CREATES the maic-runtime database — so deleting a classroom on a device that never wrote runtime data paid an open-or-create of a second IndexedDB DB, and in degraded environments burned the full 5s bound for zero cleanup value. Probe first without creating: where indexedDB.databases() is available, a missing maic-runtime entry returns immediately; where the probe API is unavailable (older Firefox), fall through to the bounded cascade — skipping there would strand real cleanup once Part C2 adds writers. The probe shares the existing try/catch + timeout envelope, so a hanging databases() cannot brick deletion either. The DB name is a module const passed explicitly to BrowserRuntimeStore so probe and store can never drift. |
||
|
|
191689f98d |
feat(renderer/editing): selection + drag-to-move core (@openmaic/renderer v2, #851 Part A) (#859)
* chore(renderer): enable component tests (jsdom + testing-library, tsx include) * feat(renderer/editing): pure geometry helpers for the editing core * feat(renderer/editing): alignment snapping math (element + viewport lines) * feat(renderer/editing): single-element drag math + move intent derivation * feat(renderer/editing): presentational selection overlay + border handle * feat(renderer/editing): controlled selection + drag-to-move on EditableSlideCanvas Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): align interaction overlay with SlideCanvas centering offset Finding 1: the hit layer and SelectionOverlay sat at el.left*scale from the overlay wrapper origin, ignoring the viewportStyles.left/top offset SlideCanvas applies when letterboxing/centering the slide, so pointer-down hit-tested the wrong element. EditableSlideCanvas now computes the same viewportStyles via the exported useViewportSize on the overlay wrapper and offsets the hit divs (and a positioning container wrapping the unchanged SelectionOverlay) to match. Finding 2: window pointermove/pointerup listeners armed in onElementPointerDown were removed only on pointer-up, leaking on mid-drag unmount. Track teardown in a ref and remove it from a useEffect unmount cleanup; single-pointer re-arm kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(renderer/editing): typecheck/lint/build green for the editing core - Apply prettier formatting to editing source/test files flagged by `pnpm check`. - Replace `any`/`Partial<any>` test fixture helpers with `Partial<PPTElement>`/`Slide` typed builders cast via `as unknown as T`, clearing @typescript-eslint/no-explicit-any errors in the editing test suite. No behavior change; typecheck, full editing test suite (18/18) plus package suite (20/20), prettier, eslint (incl. #853 no-restricted-imports boundary rule), and the ./editing subpath build are all green. * fix(renderer/editing): reuse line-aware getElementRange from utils geometry.ts re-implemented getElementRange/getRectRotatedRange without line support (start/end), producing NaN alignment guides when a slide contained a PPTLineElement. Re-export the renderer's line- and rotation-aware getElementRange from utils/element so snapping.ts/drag.ts are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): auto-fit, gate hit layer, defer lines, padding-safe layout - Restore SlideCanvas auto-fit: when scale is omitted, read fitScale from the overlay's own useViewportSize and pass scale through so both measure the same box and stay aligned at auto-fit (was hard-forced to scale=1). - Gate the interactive hit layer on onElementsChange||onSelectionChange so a read-only mount does not swallow pointer events. - Defer line elements: skip type==='line' in the hit layer and SelectionOverlay (box-model update intent can't represent line moves); narrow via predicate so width/height/rotate are available without in/as fallbacks. - Move className/style to an outer wrapper with a padding-free inner relative wrapper so consumer padding can't diverge SlideCanvas vs overlay box models. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): Euclidean drag threshold + multi-pointer guard - Classify click-vs-drag by combined Math.hypot(dx,dy) so a diagonal move past the threshold on both axes isn't misread as a click. - Track the active gesture's pointerId: ignore further pointer-downs while a gesture is in flight and drop window move/up events from other pointerIds, so a second touch can't overwrite the teardown ref or drag the first element. - Doc: SnappingOptions.range is in canvas units (viewportSize space), not px. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(renderer/editing): prettier formatting for SelectionOverlay line filter Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): fill container on outer wrapper to restore auto-fit Round-1 split moved className/style to an outer wrapper that lost its dimensions, leaving it auto-height; the inner height:100% then resolved against a zero-height box so useViewportSize read clientHeight~0 -> fitScale~0 -> blank render when scale is omitted. Give the outer wrapper width/height:100% merged BEFORE ...style so consumers can still override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): select-only fallback and pointercancel teardown C2: only emit a move intent when onElementsChange exists; a drag-classified gesture on a read-only (onSelectionChange-only) host now falls back to selection instead of doing nothing on >2px jitter. C3: listen for pointercancel (filtered by active pointerId) and tear down without emitting any intent or selection change, reverting the working copy so a cancelled gesture leaves the hook ready for a new one. removeListeners (and thus the unmount cleanup) now also drops the pointercancel listener. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(renderer/editing): narrow lines in computeDragMove without casts Replace the double as-unknown-as cast (used to read box fields off the PPTElement union) with a type === 'line' guard: a line reaching computeDragMove now early-returns its current position unchanged, and the remaining code is narrowed to the non-line box variants so left/top/width/ height/rotate are directly available. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): don't allow dragging locked elements PPTBaseElement.lock was not honored by the editing overlay: a locked element still rendered an active move hit target and useEditGesture would still emit element.update after a drag on it. Skip locked elements when mounting the interactive hit layer, and defense-in-depth early-return them in onElementPointerDown so no gesture can arm even if a hit target somehow reached one. SelectionOverlay is left untouched. * fix(renderer/editing): locked elements block pointer events instead of click-through A locked element previously had no hit target at all in the interactive overlay, so a pointer-down over a locked element that visually overlapped an unlocked element fell through to the unlocked element's hit target underneath, moving/selecting the wrong element. Render an inert blocker div for locked elements (same stacking position, no data-element-id, no gesture wiring) so it consumes the pointer instead. Line elements remain deliberately click-through (bbox != stroke hit area). * fix(renderer/editing): route line pointer-downs to an inert stroke blocker A line was skipped entirely from the hit layer, so a pointer-down on a rendered line fell through to a box element underneath and moved/selected the wrong thing. Render an inert stroke-shaped blocker per line instead: a thin rotated rectangle laid along the segment from the start endpoint (left+start) to the end endpoint (left+end), with a fixed zoom-independent grab thickness. It consumes pointer-downs on the stroke (no gesture armed) while leaving the rest of the line's bbox click-through, so it never over-blocks other visible elements around a thin diagonal line. Locked blocker and box move-target behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): sync selection on interaction; no live-move on select-only hosts Keep the controlled selection in step with what the pointer acts on, and stop faking drags where nothing can commit: - Select-on-pointer-down: a gesture that starts on an element selects it (collapsing to that single element) unless it is already the sole/primary selection. Both a click and a drag then operate on — and end with — a selected element, so dragging a not-yet-selected element no longer leaves the selection stale. Selection is emitted once (on down); pointer-up emits only the move intent for a drag, and nothing extra for a click. - Select-only hosts: when there is no onElementsChange channel, the live working copy no longer follows the pointer during pointermove (it could never commit and would snap back on release). Such surfaces only select. Moving all members of a multi-selection together remains out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): mirror v1 line renderer with an SVG-path hit blocker Replace the straight start->end strip blocker with an inert SVG <path> that reuses getLineElementPath, so the hit region traces the same path the v1 renderer draws for every line shape (straight/broken/broken2/curve/cubic). pointer-events:stroke covers only the visible stroke, leaving the empty bbox click-through (P2). stroke-width is the grab band, max(10, el.width*canvasScale) in screen px (divided by canvasScale for the scaled svg), so a wide or zoomed stroke stays covered (P3). Blocker stays inert: onPointerDown only stops propagation, no gesture armed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(renderer/editing): note deferred line endpoint-marker hit coverage * fix(renderer): make line elements selectable but not draggable The line hit blocker in EditableSlideCanvas only called stopPropagation on pointer-down, which blocked fall-through to an overlapped box but also made lines unselectable (a regression from the pre-series behavior where the host delegated line selection via onElementClick). SelectionOverlay additionally filtered lines out, so even a host-controlled line selection showed no feedback. - EditableSlideCanvas: the line blocker's onPointerDown now also selects the line via onSelectionChange (on pointer-down, for parity with box elements) when a selection callback is present. No drag gesture is armed and no element.update is ever emitted (line editing stays deferred). It still consumes the pointer to block fall-through even when onSelectionChange is absent, and never selects/moves an overlapped box beneath. - SelectionOverlay: render a selection border for a selected line, computing its bounding box via getElementRange and reusing BorderLine (no rotation). Box-element rendering is unchanged. Adds regression tests (line selects on pointer-down with no update intent; blocker still consumes the pointer without a selection callback; SelectionOverlay draws a border at the line bbox) and adjusts the tests whose behavior intentionally changed. Editing suite: 41 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): correct line selection bounds and locked/idempotent line selection F1: line selection border now encloses the actual drawn path. Add a pure getLineBounds helper in editing/core/geometry.ts that spans every control point (start/end/broken/broken2/curve/cubic, each offset by left/top), and use it in SelectionOverlay instead of getElementRange, which only bounded the start/end chord (curve/broken/cubic lines got a wrong, sometimes zero-size box). F2: a locked line is now inert like a locked box — its blocker still stops propagation (blocks fall-through) but no longer selects. F3: the line blocker skips re-emitting onSelectionChange when the line is already the sole primary selection, mirroring the box alreadySolePrimary guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): derive line selection bounds from the rendered path getLineBounds min/maxed the raw line control points, but the renderer's getLineElementPath does not draw broken2 as a point — it uses only broken2[0] (horizontal) or broken2[1] (vertical). A horizontal broken2=[50,80] line renders flat at y=0, yet the bounds reported maxY=80, painting an oversized selection border. Reimplement getLineBounds to parse the coordinates out of getLineElementPath (the single source of truth for the drawn path) and min/max over them, offset by left/top. This is exact for straight, broken, broken2 (one-axis), curve (Q control point in the path) and cubic (C) lines. Guard an empty/odd match list by falling back to the element origin. Also document a deferred limitation in EditableSlideCanvas: line selection bypasses useEditGesture and thus the activePointerRef multi-pointer guard (comment only, no behavior change). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): compute tight Bézier bbox for line selection bounds getLineBounds took min/max over every number in the rendered path, which includes Bézier control points. A control point can lie outside the drawn curve, so the selection border was oversized (e.g. M0,0 Q50,80 100,0 reported maxY=80 though the quadratic only peaks at y=40). Replace it with an exact per-segment path bbox: parse the M/L/Q/C path (tracking the SVG current point and implicit command repeats) and, for Q/C, take the true Bézier extrema instead of the control points — quadratic extremum at t*=(P0-P1)/(P0-2P1+P2), cubic roots of B'(t)=0 (with the degenerate a≈0 linear case). Endpoints and offset by left/top are preserved; fall back to the element origin for empty paths. Exposed quadraticBounds/cubicBounds/getPathBounds as pure, unit-tested helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer/editing): parse exponent-form path coordinates in getPathBounds * fix(renderer/editing): inflate line selection bounds by half the stroke width * feat(renderer): add pure line-handle drag core for editing v2 Port the app's useDragLineElement math into a store-free, React-free core: computeLineDrag lifts endpoints/controls to absolute canvas coords, applies the pointer delta with axis snapping (endpoint straighten, ctrl midpoint snap), re-normalizes the bbox, and recomputes only the control field(s) the line actually carries (broken/broken2/curve/cubic). Add LineHandle type and 7 hand-computed tests. External-element adsorption is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(renderer): draggable line endpoint/control handles for v2 editor Replace a selected line's approximate bbox selection border with draggable endpoint/control handles that reshape it. Add a presentational LineHandles component and a useLineHandleGesture hook (mirroring useEditGesture: working-copy preview, one intent on pointer-up, pointerId guard, pointercancel revert) driven by the existing computeLineDrag core. Wire both into EditableSlideCanvas so the v1 canvas, the line stroke blocker, and the handles preview off the same working element. Remove the line branch from SelectionOverlay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): line selection highlight, precedence, and dead-code cleanup F1: a selected line now always shows selection chrome. Its inert blocker path takes a visible accent stroke when selected, so a locked or read-only (no callback) selected line still gets feedback; the highlight renders even when no handles do, and stays pointer-inert in a read-only mount. F2: remove the now-dead getLineBounds and its transitive-only helpers (getPathBounds, quadraticBounds, cubicBounds, quadraticAt, cubicAt, EPS) plus their tests; they had no production consumer after the line-bbox removal. F3: align LineHandles control-field precedence with computeLineDrag (broken || broken2 || curve) so the handle renders where the drag math reads it. F4: add tests for the curve ctrl-handle drag and that a handle grab never re-selects, plus the F1 highlight states. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): split line hit blocker from highlight; gate handles on editability The line overlay used a single <path> as both the interaction blocker and the visual selection chrome. On select its stroke-width switched from the fat grab band to the thin highlight width, shrinking the pointer-events:stroke hit region so a thin selected line let clicks fall through to a box beneath. - FIX B: render two paths sharing the same d/position/scale — a stable transparent blocker (grab band, pointer-events:stroke, inert onPointerDown) whose hit region never changes with selection, plus a separate accent highlight (pointer-events:none) rendered only when selected. - FIX A: gate LineHandles on onElementsChange (editability) instead of generic interactive, so a select-only mount shows the highlight but no draggable handles that could never commit. 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: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
04b70f0359 |
feat(storage): scaffold @openmaic/storage — KV + asset primitives (browser) (#857) (#858)
* feat(storage): scaffold @openmaic/storage — KV + asset primitives (browser) (#857) First executable slice of the @openmaic/storage RFC (#779, Part 1): a pure, app-agnostic persistence package depending only on @openmaic/dsl. - Add the DSL-owned `StorageProvider` asset seam to @openmaic/dsl (put(blob)->ref / resolve(ref)->url / remove), typed against a structural `BinaryBlob` so the pure DSL keeps `lib: ES2022` (no DOM). - `KVStore` (device/account scopes) + `BrowserKVStore` over localStorage. - `BrowserAssetProvider`: content-addressed (sha256) bytes in IndexedDB, resolved to object URLs; identical bytes de-duplicate. - `kvPersistStorage`: adapt a KVStore into a zustand `persist` storage (pure util; app store wiring is a follow-up). - Implementation-agnostic contract suites (KV + StorageProvider), run against the browser backends so future backends prove equivalence. - Machine-enforce the package import boundary in eslint (no `@/...`), mirroring the @openmaic/renderer boundary. Backends take their Storage/IDBFactory by injection, so the package is testable without a browser. Deferred to later Part-1 steps: wiring the app's zustand stores + ad-hoc localStorage through KVStore (needs a legacy-key compat migration), DocumentStore/RuntimeStore, and the HTTP backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(storage): address cross-review findings - BrowserAssetProvider: resolve writes on transaction.oncomplete (not request.onsuccess) so a commit-time abort (e.g. QuotaExceeded) can't be reported as durable success. - BrowserAssetProvider.openDb: don't memoize a rejected open — a transient IDB failure no longer bricks the provider; the next call retries. - BrowserAssetProvider.resolve: memoize the resolution per ref so concurrent resolves share one object URL instead of orphaning a second one. - BrowserKVStore.set: treat a value JSON can't represent (undefined / function / symbol) as a removal, instead of writing a literal "undefined" that throws on the next get (and would reject zustand rehydration). - Tests: build contract Blobs from strings (a Uint8Array BlobPart fails the root tsconfig typecheck under TS 5.7+ typed-array generics); reset the object-URL registry per test; add regression tests for set(undefined), concurrent-resolve, and actual de-dup (one stored row). - Drop @openmaic/storage from the root postinstall build chain: nothing imports it yet, so building it on every install added latency and coupled its build failures to importer/renderer/sync. It re-joins when the app consumes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(storage): don't cache a rejected resolve promise Second cross-review pass caught that the per-ref resolve memoization reintroduced the same rejected-promise-caching bug just fixed in openDb: a transient IndexedDB failure inside resolve() left the rejected promise in the `urls` map, so every later resolve(ref) replayed the rejection and never retried. Evict the entry on rejection (mirroring the null-miss path), and add a regression test that fails a resolve's IDB open once and asserts the next resolve recovers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(storage): review — coherent re-put contentType + restore bootstrap build Addresses @cosarah's review on #858: - BrowserAssetProvider.put: invalidate (revoke + drop) any cached object URL for the ref after a write, so a re-put of the same bytes with a corrected contentType is reflected by resolve() instead of a stale, cache-warmth- dependent MIME type. remove() reuses the same invalidateUrl helper. Adds a regression test (put type="" -> resolve -> put same bytes type="image/png" -> resolve now reports image/png). - Restore @openmaic/storage to the root postinstall build chain: the package publishes dist/*, so a clean install must build it or workspace consumers resolving it via exports would fail until a manual --filter build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b53f202d9d |
feat(renderer): scaffold @openmaic/renderer/editing subpath (v2 editing surface, Stage 0) (#855)
* feat(renderer): scaffold the @openmaic/renderer/editing subpath (v2 Stage 0) Adds the packaging skeleton for the renderer v2 editing surface behind a dedicated `./editing` subpath export, so the read-only entry (@openmaic/renderer) never pulls the editing bundle — packaging decision A from the editing-surface RFC. Scaffold only, no interaction logic yet: - src/editing/types.ts: the L1 edit-intent contract (EditIntent, Selection, EditableSlideCanvasProps) — the bounded canvas gesture vocabulary. The agent tool surface (L2) and the canonical change (L0, @openmaic/dsl) are out of scope here. - src/editing/EditableSlideCanvas.tsx: a shell that renders through the v1 read-only SlideCanvas and supports click-to-select only. Operate handles, snapping, ProseMirror inline editing, and onElementsChange emission land in Part A / Part B. - package.json + rollup.config.js: wire the ./editing entry (mirrors ./snapshot). Refs #851 (renderer v2 editing surface, #720 Phase 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): address editing-scaffold cross-review - Add `'use client'` to EditableSlideCanvas so the editing subpath is a proper client boundary (every v1 client component leads with it). Without it, a Next App Router server component importing the subpath would reject the function props the shell passes to the client SlideCanvas. - Drop the wrapper `<div>`: it had `height: auto`, collapsing SlideCanvas's `height: 100%` auto-fit to 0 (the slide rendered invisibly). Forward `className`/`style` straight to SlideCanvas, preserving the v1 fill contract. - Freeze `EMPTY_SELECTION` and type `Selection.elementIds` as `readonly` so the shared sentinel cannot be mutated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): add 'use client' to the public editing entry Review follow-up: put the client-boundary directive on the public `@openmaic/renderer/editing` entry (the barrel that consumers resolve), not only on EditableSlideCanvas.tsx. Note: this is necessary but not sufficient on its own — the rollup build currently drops module-level directives (the config's onwarn silences the MODULE_LEVEL_DIRECTIVE warning), so the published bundle strips `'use client'` today (the published v1 dist has none either). The effective fix is preserving directives in the build; tracked separately since it changes the whole package's published output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(renderer): preserve 'use client' in the build output The rollup build stripped module-level directives (the MODULE_LEVEL_DIRECTIVE warning is silenced in onwarn), so the published dist dropped 'use client' from every file — including the editing entry and v1's client components — which breaks Next App Router server-component consumers. Enable `preserveModules` (the `preserveModulesRoot` option was already present but inert without it) and add `rollup-plugin-preserve-directives`, so each source module's `'use client'` survives per-file into dist. Verified against the built output: - dist/editing/index.js and dist/editing/EditableSlideCanvas.js keep 'use client' - dist/SlideCanvas.js (v1) keeps it too (it was dropped before) - dist/index.js (read-only barrel) stays clean — re-exports the client module, so the read-only path/design is unchanged and still doesn't pull the editing bundle - all export entries (., ./elements, ./types, ./snapshot, ./editing) resolve Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7c19ab8f1e |
feat(dsl): JSON Schema artifacts + pure validators (#787 Part B-1) (#817)
* feat(dsl): add ACTION_TYPES set + isActionType guard A frozen set of every valid ActionType plus a pure membership guard, mirroring the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts. Used by the new validators; useful standalone for narrowing untrusted action.type values. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dsl): build-time JSON Schema codegen for stage/scene/action TS types stay the single source of truth; ts-json-schema-generator (a devDependency, build-only) emits dist/schema/{stage,scene,action}.schema.json, shipped under the new ./schema/* export. Serves non-TS / bring-your-own-validator consumers without adding a runtime dependency. JSON Schema is not generic, so schema-roots.ts provides a concrete Scene<Action, SceneContent> entry point (kept internal, not re-exported). schema.test.ts compiles the generated schema with ajv (devDep) to prove the artifact is real and self-contained on a clean checkout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dsl): add pure structural validators (stage/scene/action) validateStage / validateScene / validateAction: hand-written, zero-dependency, fail-loud structural checks layered on the existing guards. They verify discriminants, required fields, and known nested discriminants — a gate for untrusted input (LLM/agent output) and persistence boundaries. Exhaustive per-field validation is delegated to the shipped JSON Schema. Error-collecting ValidationResult reports every issue with a JSON-pointer-ish path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(dsl): document the runtime schema + validator layer Add a Runtime layer section covering the two complementary consumption modes (schema-as-data via @openmaic/dsl/schema/* and the zero-dep validate* gate), the validate.ts module row, the build:schema step, and tick the JSON Schema roadmap box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dsl): harden validators per cross-review (exhaustiveness, discriminant agreement) Addresses cross-review findings on the validator layer: - Add compile-time exhaustiveness assertions for ACTION_TYPES and SCENE_TYPES. `satisfies` only proved each entry is valid, not that the tuple covers the whole union — a newly-added Action/Scene variant could silently be rejected by the validators. The assertion now fails the build on drift. - validateScene cross-checks that scene.type agrees with content.type; a slide-typed scene carrying quiz content (or vice versa) is now rejected. - Move SCENE_TYPES + add an isSceneType guard to stage.ts, next to SceneType, matching the PPT_ELEMENT_TYPES / isPPTElementType idiom in guards.ts; validate.ts reuses them instead of re-encoding the union locally. - Make the validators' scope honest in the doc comment + README: they check the structural envelope, known discriminants, and scene/content agreement; they do NOT exhaustively check per-variant fields, and app-side interactive/pbl content is validated only at the envelope level (exhaustive checks → the JSON Schema). - Note the contract-owned content scope in schema-roots.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(dsl): reuse one schema generator + harden direct-invocation guard Addresses cross-review findings on the codegen path: - gen-schema.mjs built a fresh ts-json-schema-generator (full TS program parse) per root type; build one generator over the tsconfig program and reuse it for every createSchema call — parses once, identical per-type output. - Harden the `invoked directly` check to compare realpaths, so a symlinked invocation (bin shim / pnpm link) still runs main() instead of silently emitting no schema. - schema.test.ts generates all roots once at module scope, then only compiles per case — no repeated heavy codegen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dsl): bind scene schema discriminants + pin node-20-compatible generator Addresses round-2 cross-review (codex) findings: - scene.schema.json now ties scene `type` to the matching `content.type`. SerializedScene became a discriminated union (SlideScene | QuizScene via `Omit<Scene<…>, 'type'> & { type }`), so the generator emits a const-narrowed discriminant per branch. Previously the schema accepted e.g. { type: 'quiz', content: { type: 'slide', … } } even though validateScene rejects it — the schema is now consistent with the validator. Locked with a test. - Pin ts-json-schema-generator to ~2.4.0. ^2.3.0 resolved to 2.9.0, which declares `node >=22`, conflicting with the repo's `engines.node >=20.9.0`; 2.4.x supports node >=18, keeping the build runnable on every supported Node. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(dsl): prettier formatting + drop unused ts-expect-error for green CI - Apply repo Prettier (`pnpm check`) formatting to the new/edited files. - Remove the `@ts-expect-error` on the gen-schema.mjs import: the root `tsc --noEmit` (which compiles test files; allowJs infers the .mjs exports) flagged it as an unused directive (TS2578). Plain comment retained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(dsl): align validators + schema to the public Scene type (review) Addresses cosarah's review: the validator, the JSON Schema, and the public TS type had drifted into three different notions of a valid scene. Realign them so the TS types stay the single source of truth, and position the two runtime layers honestly. - schema-roots.ts: revert SerializedScene to the plain default Scene<Action, SceneContent>. The schema no longer encodes a stricter type/content binding that the public Scene type doesn't express (the constraint had effectively moved into a hidden internal type). - validate.ts: drop the scene.type<->content.type agreement check (the public Scene does not bind them, so neither does the validator); restrict content to the contract-owned kinds (slide/quiz), matching SceneContent and the schema, so validator and schema no longer disagree on interactive/pbl content. - Reposition docs (validate.ts header + README): the JSON Schema is the authoritative, exhaustive per-field validator (it checks variant fields like an action's elementId) for trust boundaries; validate* are a cheap, zero-dep structural pre-check and a strict subset of the schema — not a separate or stricter gate. - Tests updated to lock the realigned behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(dsl): bind scene type<->content in the public Scene contract (review) cosarah's review showed the scene type<->content agreement is a real, load-bearing invariant: consumers branch on `scene.type` and then read `scene.content` as the matching shape (e.g. `stage-api-canvas` casts `content as SlideContent` after a `scene.type === 'slide'` check; `complete-summary` counts by `scene.type` and reads `content as QuizContent`). A validator/schema that blesses mismatched pairs is unsafe. So tighten the *exported* contract rather than loosening the runtime checks to match a hole — and make the pure validators the authoritative, relied-upon boundary. DSL contract: - `Scene` becomes a distributive discriminated type binding `type` to `content['type']` (`SceneCore<TAction> & { type: TContent['type']; content: TContent }` distributed over TContent). TS itself now rejects mismatched scenes; generic widening still works. Extract `SceneCore` for the kind-independent fields. - `validateScene` re-establishes the type<->content agreement check; scene `type` is the contract's slide/quiz; `scene.schema.json` (from the spelled-out discriminated `SerializedScene`) binds `type` to content per branch. Type, validator, and schema now describe the same thing. - `validateAction` checks each variant's required fields (e.g. spotlight.elementId, discussion.topic) — closes the false-positive gap. A test pins the hand map to the generated schema (the TS-derived source of truth) so it cannot drift. - Reposition docs: `validate*` is the zero-dep in-process boundary producers rely on; the JSON Schema is the cross-language mirror + exhaustive value checker. App consumers (the sites the discriminated Scene exposes): - Add `ScenePatch` (non-distributive partial) + `makeScene(core, content)` helper to lib/types/stage.ts — one isolated cast, type derived from content. Patch-typed signatures (`updateScene`, `applyScenePatchInSync`, regenerate plan) and scene rebuilds (`stage-api-scene`, store `updateScene`, `migrateScene`, stage-storage deserialize) route through these. Closes two latent desync foot-guns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dsl): validate action field types + migrate scene.update; bidirectional drift-guard (review) Round-2 review of the contract-tightening change (codex + Claude): - validateAction now checks each variant-required field's TYPE, not just presence (`{ type:'spotlight', elementId: 123 }` is rejected — elementId must be a string). ACTION_REQUIRED_FIELDS carries a runtime kind per field. - The schema lockstep test is now bidirectional and type-aware: it asserts a fully-typed action is accepted (catches the map OVER-listing an optional field), every required field flags when missing (under-listing), and flags when present but mis-typed — all keyed off the generated schema's field names + types. - Migrate the public `stageApi.scene.update()` to ScenePatch + makeScene (the create() path was done last commit; update() still did a raw spread that could desync type<->content — masked from tsc only because StageStore.setState is typed `any`). Now both public scene-construction paths rebind type to content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(api): keep scene.create's params.type authoritative over content.type (review) cosarah's review: `CreateSceneParams` exposes an independent `type` plus `content?: Partial<SceneContent>`, and after switching create() to makeScene() (which derives the scene kind from content.type), a call like `create({ type: 'slide', content: { type: 'interactive', ... } })` would silently become an interactive scene — params.type ignored — and mix slide defaults into it. Reject a `content.type` that disagrees with `params.type`, and pin the merged content's `type` to `params.type` so a partial content override can't flip the scene's discriminant. params.type stays authoritative. Adds a test both ways. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
b1e5beee0f |
feat(dsl): promote Stage/Scene lesson skeleton into @maic/dsl (#740) (#743)
* feat(dsl): promote Stage/Scene lesson skeleton into @maic/dsl (#740) Phase 2 of the @maic SDK consumption epic (#720): move the universal lesson skeleton into the contract package, with Scene generic so app-side feature surfaces (Action, widgets, PBL) plug in via type parameters. DSL (packages/@maic/dsl): - New src/stage.ts: Stage, SceneType, StageMode, Whiteboard, VideoManifest, GeneratedAgentConfig, MultiAgentConfig, SlideContent, QuizContent, and a narrowed SceneContent (SlideContent | QuizContent). - Scene<TAction = never, TContent extends { type: SceneType } = SlideContent | QuizContent> — the contract owns only the structure + universal content kinds; TContent is constrained structurally so an app can pass a wider content union. Defaults give renderers/importers a feature-free skeleton. - Pure guards isSlideContent / isQuizContent. - Registered in index.ts barrel; README roadmap item checked off + 'Stage / Scene split' section added. - Added vitest config + test script + stage.test.ts (9 contract tests). App (lib/types/stage.ts): - Rewritten as a shim: re-exports the skeleton types from @maic/dsl, keeps InteractiveContent / PBLContent (app feature surfaces), and aliases Scene = Scene<Action, AppSceneContent> so all ~55 existing import sites keep their semantics. The generation re-export block is dropped (verified zero consumers import those five types via @/lib/types/stage). Scope B from #740 (lesson container in DSL, features plug in). Decision: generic Scene<TAction, TContent> over a registry/extension seam — explicit, isolatedModules-friendly, no declaration-merge pitfalls. * fix(dsl): address review — guard genericity, standalone tests, type pin 1. isSlideContent / isQuizContent: relax <T extends SceneContent> to <T extends { type: SceneType }> so the guards accept an app-widened content union (interactive / pbl) — the generic case Scene<TAction, TContent> is meant to support. Add a regression test exercising the widened union. 2. tests: resolve @maic/dsl to ./src/index.ts via resolve.alias in vitest.config.ts, so 'pnpm --filter @maic/dsl test' is standalone on a clean checkout (no dist build required). Verified by deleting dist and re-running. 3. Pin the TAction-defaults-to-never type test to .toEqualTypeOf<never[] | undefined> instead of the trivially-true .toMatchTypeOf<unknown[] | …>. * style: prettier-format lib/types/stage.ts (CI prettier --check) * fix(stage): value-export isSlideContent/isQuizContent from the compat shim The two discriminant guards are runtime functions but were sitting inside the shim's `export type { … }` block, which erases them: `import { isSlideContent } from '@/lib/types/stage'` would resolve to `undefined` at runtime and TS-error 'cannot be used as a value'. No call sites import the guards through the shim today (latent), but the shim's job is back-compat re-export, so move them to a plain `export { … }`. --------- Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
d403014918 |
fix(importer): correct hanging-indent bullet slot rendering (#727)
* fix(importer): correct hanging-indent bullet slot rendering Size the bullet slot to the hanging-indent amount (|indent|) instead of marL so wide marL paragraphs no longer push body text past the bullet. Reset text-indent on the inline-block slot to stop symbol bullets from drifting left onto adjacent shapes, and only pad synthesized-marL symbol bullets. Add vitest setup and serializer tests. Co-authored-by: Cursor <cursoragent@cursor.com> * style(importer): apply prettier formatting to serializer tests Co-authored-by: Cursor <cursoragent@cursor.com> * test(importer): wire up package-local vitest config and CI Add packages/@maic/importer/vitest.config.ts so the package tests in test/ are discovered (the root config only globs tests/), and add a CI step that runs them. Align vitest to a single 4.1.8 in the lockfile and move the tableSerializer setup into beforeAll so parse failures surface as test failures instead of collection errors. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
1c858960b4 |
refactor(app): consume @maic/dsl + @maic/renderer in the app (slide types + read-only thumbnails) (#707)
* refactor(app): consume @maic/dsl + @maic/renderer for slide types and read-only thumbnails
The app extracted @maic/{dsl,renderer,importer} (#668) but kept rendering its
own duplicated copies. Start dogfooding the packages back into the app:
1. lib/types/slides.ts is now a thin re-export of @maic/dsl. The hand-kept
copy was the seed for @maic/dsl, so consuming the package stops the three
former copies (app / renderer / importer) from drifting again. All
`from '@/lib/types/slides'` import sites keep working unchanged.
(ShapePathFormulasKeys / ElementTypes move from local `const enum` to the
package's regular `enum` — required across a package boundary; runtime
value usage is identical.)
2. Read-only slide thumbnails now render via @maic/renderer's SlideCanvas
through a small SlideThumbnail wrapper, replacing the in-app ThumbnailSlide
element renderers at the three read-only surfaces: playback scene sidebar,
scene thumbnail content (editor rail), and home recent-course cards. The
wrapper preserves the legacy thumbnail video treatment (muted play-badged
<video>, placeholder filtering) via the renderer's renderVideo slot. The
full-size editing canvas is untouched (renderer v1 is read-only).
Verified: tsc --noEmit clean, next build OK, e2e 19/19 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(thumbnail): resolve gen_* media placeholders so retries reflect in thumbnails
SlideThumbnail rendered slides through @maic/renderer with only a renderVideo
slot, so image (and video) `gen_img_*`/`gen_vid_*` placeholders were painted
as-is. @maic/renderer is a pure package and does not know about this app's
async media-generation store; the store also never mutates the slide's
`element.src` (it keeps the placeholder ref and tracks the generated objectUrl
in a task). Net effect inside a classroom: a thumbnail showed the broken
placeholder on first paint and — the reported bug — did NOT update when a
failed image's retry finally succeeded, because nothing re-resolved the src.
Add `useResolvedSlide`: resolve each placeholder element's `src` to its task
`objectUrl` (video `poster` too) against the media-generation store,
reactively. Unresolved (pending/failed) placeholders are blanked so the
renderer paints nothing instead of a broken-media icon. Off-classroom (no
stage context, e.g. home recent-course cards) renders raw, matching the legacy
thumbnail's behavior. Mirrors the app's existing `useResolvedImageSrc` resolver
that the full-size canvas already uses — restoring the reactive sync the
in-app ThumbnailElement had before the @maic/renderer migration.
The store subscription is a primitive signature of just this slide's
placeholder resolutions (src → done objectUrl), so unrelated task churn —
other slides' media generating or retrying — neither re-renders the thumbnail
nor invalidates the resolved-slide memo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(thumbnail): key video tasks by mediaRef; use a visible signature delimiter
Review follow-ups (#707):
- Videos' media-generation tasks are keyed by mediaRef (with gen_vid_* src as
the legacy fallback), not by element src — resolve them through
getVideoMediaRefForElement, matching BaseVideoElement's subscription, so a
mediaRef-keyed video thumbnail updates when generation or a retry completes.
A mediaRef-keyed video that already carries a real playable src keeps it
while the task is unresolved; only placeholder srcs are blanked.
- The resolution-signature delimiter was a literal NUL byte, which made git
treat the source file as binary (degrading diffs and review tooling). Use a
visible '|' delimiter instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
|
||
|
|
7bb9e864ae |
feat(packages): introduce maic-import and maic-renderer workspace packages (#668)
* feat(import): add pptxtojson-pro workspace package and stage-1 PPTX import Vendor the TypeScript pptxtojson-pro parser as a workspace package, sync its bundle to public/vendor at install time (avoids Turbopack dynamic require issues), and wire a home-page import hook that parses files for inspection. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(import): extend pptxtojson-pro with PPTX → Slide[] pipeline `importPptx` now turns a .pptx file directly into canvas `Slide[]`, optionally uploading every base64 image and blob-backed media via a caller-supplied OSS callback before resolving. - Vendor OpenMAIC slide types and shapes config into `src/openmaic/` so the bundled dist is self-contained and loadable via `/vendor/` URL under Turbopack; add stubs for `resolveFont`, `getSvgPathRange` and `parseVideoCodec` until the real PPTist helpers are ported. - Move `import-pipeline/` under `src/` and re-export `importPptx`, `parsedToSlides`, `OssUpload`, `ImportPptxOptions` and `CanvasSlide` from the package main entry. - Add `nanoid`, `katex` and `pptxtojson` as runtime deps of the package. - Update `useImportPptx` to accept `upload` / `onImported` and return the converted `Slide[]`. - Document the new API surface, error semantics and Turbopack workaround in the package README. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(slide-renderer): package skeleton (rollup + tsc + empty index) Workspace package scaffolding: package.json with peer deps (react/react-dom/motion/tailwindcss), rollup ESM+CJS config, tsconfig (strict, jsx react-jsx), and empty entrypoints under src/. Verified: pnpm --filter slide-renderer build produces dist/{index,elements/index,types/index}.{js,cjs,d.ts} (empty chunks are expected at this stage). * docs(slide-renderer): DESIGN.md + README.md * feat(slide-renderer): copy Slide/Element types + add effects types * feat(slide-renderer): add utils (geometry simplified to elements API, element ranges, cn) * feat(slide-renderer): shared element hooks (fill/outline/shadow/flip) + ElementOutline * feat(slide-renderer): BaseTextElement * feat(slide-renderer): BaseShapeElement + GradientDefs + PatternDefs * feat(slide-renderer): pure <img> BaseImageElement, decouple OpenMAIC media business The base component drops: - useMediaGenerationStore / isMediaPlaceholder (AI media generation polling) - useSettingsStore (imageGenerationEnabled toggle) - useMediaStageId / retryMediaTask (classroom-scoped retry orchestration) - useI18n (placeholder/error/disabled copy) - skeleton / error / disabled render branches Retains: clip / filter / outline / shadow / colorMask, output <img src={elementInfo.src}>. Adds a `renderImage` slot so consumers can re-inject business behavior (placeholders, retry buttons, custom src resolvers) without modifying the package. * feat(slide-renderer): Line / Table / Latex / Code base elements - Line + LinePointMarker: copied with stroke-draw animation intact - Table + StaticTable + tableUtils: copied with getTableSubThemeColor moved to package utils - Latex: katex HTML + legacy SVG fallback, no business deps - Code: shiki-based syntax highlighting (dynamic import); shiki added as optional peer dependency so consumers without code slides pay zero install cost * feat(slide-renderer): BaseChartElement + Chart + chartOption (echarts as optional peer) * feat(slide-renderer): pure <video> BaseVideoElement, decouple OpenMAIC media business Drops: - useCanvasStore (playingVideoElementId / pauseVideo) - useMediaGenerationStore / useMediaStageId / retryMediaTask / getVideoMediaRefForElement - useSettingsStore (videoGenerationEnabled) - useI18n, motion/react animate scope - skeleton / error / disabled / placeholder branches Retains <video src controls preload=metadata>. Adds a `renderVideo` slot so consumers can re-inject orchestrator-driven playback or placeholders. * feat(slide-renderer): canvas hooks (useSlideBackgroundStyle, useViewportSize simplified) - useSlideBackgroundStyle: copied as-is (no business deps) - useViewportSize: rewritten to take viewportSize/Ratio/canvasPercentage as args, drop useCanvasStore, drop editor-only canvasDragged/dragViewport, return fitScale instead of writing to a store * feat(slide-renderer): 4 effect overlays (Highlight/Spotlight/Laser/Zoom), props-driven All overlays now receive element/geometry/options through props instead of reading useCanvasStore. SpotlightOverlay uses a configurable element-id prefix (default 'slide-element-') to locate DOM nodes for measurement, matching the convention SlideElement will set in T14. HighlightEffectOptions extended with color/opacity/borderWidth/animated to surface the original visual knobs through the public API. * feat(slide-renderer): SlideElement dispatcher with renderImage/renderVideo slots * feat(slide-renderer): SlideCanvas v1 main entry (props-driven, auto-fit viewport) Mirrors ScreenCanvas structure but reads from props: slide.elements, slide.theme, props.background ?? slide.background, props.scale ?? autoFit, props.effects. Auto-fits viewport into container by default via useViewportSize+ResizeObserver; pass scale=1 to opt out and render at slide-native dimensions. * feat(slide-renderer): SlideRendererProvider + useSlideContext (props or context) SlideCanvas now reads from context as a fallback when props are omitted. Allows the wrap-then-compose pattern from spec §3.2: provider holds shared data, sibling overlays read via useSlideContext. * feat(slide-renderer): aggregate public exports Main entry exposes SlideCanvas, SlideElement, Provider/hooks, effects, hooks, utils. `slide-renderer/elements` re-exports all 9 Base*Element + shared ElementOutline/useElementXxx for granular composition. `slide-renderer/types` already lived in src/types/index.ts. * docs(slide-renderer): complete README (install / quickstart / API / effects / Tailwind setup) * feat(demo): /slide-renderer-demo route exercising the workspace package Hand-written single Slide with shapes + text demonstrates the v1 API end-to-end: - props-driven SlideCanvas - auto-fit viewport via parent container - gradient background, shape outline + text fill Wires slide-renderer as a workspace dep and registers its dist as a Tailwind @source so utility classes inside the package get picked up by the host PostCSS pipeline. * feat(pptxtojson-pro): align imported Slide JSON with renderer contract - Drive `viewportWidth` from the deck's actual pixel width (json.size.width × ratio) instead of the legacy 960 default, so 16:9 widescreen decks no longer get text element widths clamped to 4:3 dimensions (e.g. titles truncated to 842px and forced to wrap). Per-slide `viewportSize` now reports the same value, keeping transform-time and render-time aligned. - Replace the pass-through font STUB with real resolution: 22-font self-hosted whitelist + alias map (微软雅黑/等线/宋体/楷体/仿宋/Arial/etc.) → 5-category primary font mapping, so unknown PPT fonts fall back to a rendered open-source equivalent and the pipeline can surface the replacement via `replacedFonts.fallback`. - Translate cell `vAlign` from PPT-native (`up`/`mid`/`down`) to CSS-native (`top`/`middle`/`bottom`) at the importer boundary, so the renderer can transparently pass it through to `vertical-align` / `justify-content` without per-component conversion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(slide-renderer): drop Tailwind defaults, data-drive styles, ship fonts Three intertwined changes that together make the package self-contained and align it with the "slide JSON is the single source of visual truth" contract. 1. Remove Tailwind utility classes from every element renderer. - Replace `className="absolute ..."` / `className="rounded ..."` etc. with inline `style={{ position: 'absolute', borderRadius: ... }}`. - The few rules that can't be expressed inline (descendant selectors, keyframes, browser-default <p> margin reset) move to a new `src/styles.ts` injected once by `<SlideCanvas>` via `<style>`. Named animation classes (`slide-renderer-pulse` / `-ping`) replace `animate-pulse` / `animate-ping` so consumers don't need Tailwind. 2. Strip renderer-level positive defaults; surface them as data fields. - `BaseTextElement`: drop hardcoded `overflowWrap: 'break-word'` and the `paragraphSpace ?? 5` fallback. `--paragraphSpace` is only set when defined in data; CSS `var()` fallback is 0. - `StaticTable`: drop hardcoded `padding: '5px'`, `verticalAlign: 'middle'`, `wordBreak: 'break-word'`. Extend `TableCell` with `padding?: string` and `vAlign?: 'top' | 'middle' | 'bottom'` so PPT-extracted padding / vertical alignment flow through unchanged. - `PPTTableElement` gets `rowHeights?: number[]` (per-row min-height; content can still grow rows). - Reshape `<td>` to match the classroom Vue layout: outer `<td>` carries only border + textStyle; inner `slide-renderer-cell-text` <div> is a flex column with `min-height = rowHeight - 4`, `padding = cell.padding`, `line-height: 1`, `justify-content` from `cell.vAlign`. Add `.slide-renderer-cell-text p + p { margin-top: 0.4em }` to give adjacent paragraphs breathing room without breaking the reset. 3. Self-contained font shipping. - Move the 22 woff2 files into `packages/slide-renderer/fonts/`. - Ship a sibling `fonts.css` with @font-face declarations using relative `./fonts/<name>.woff2` URLs. - Expose both via `package.json` exports (`./fonts.css`, `./fonts/*`) and the `files` array. - Consumer adds one import (`import 'slide-renderer/fonts.css'`); the bundler resolves the relative URLs against the package and emits each woff2 as a static asset. Removes the dependency on consumer- owned `app/globals.css` @font-face blocks and `public/font/` copies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(demo): wire slide-renderer fonts and swap demo to a table-heavy slide - Import `slide-renderer/fonts.css` in the root layout so consumer apps pull the package's self-hosted @font-face declarations and woff2 assets through Next.js' static-asset pipeline. - Replace the hardcoded `demoSlides` array (24 mixed-element slides) in `/slide-renderer-demo` with a single 5×7 table-heavy slide so the demo exercises the new `TableCell.padding` / `TableCell.vAlign` / `PPTTableElement.rowHeights` fields and the flex-based cell layout. Convert the legacy `vAlign: 'up' | 'mid' | 'down'` values to the CSS-native `'top' | 'middle' | 'bottom'` form the renderer now expects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(slide-renderer): add slideToPng standalone snapshot util Exposes a new `slide-renderer/snapshot` sub-export with `slideToPng(slide, options)`, the foundation for the AI-driven visual diff loop (snapshot the rendered slide, compare against PowerPoint's own PNG export, decide whether a discrepancy belongs to the import algorithm or the renderer). Implementation notes - Mounts `<SlideCanvas>` into an off-screen container sized exactly to the slide's native pixels, so `useViewportSize` collapses to a 1:1 render (no scale offset). - `flushSync` for the initial commit so the snapshot doesn't fire on an empty container; `requestAnimationFrame` × 2 to let the ResizeObserver settle; then waits for `document.fonts.ready` and every `<img>` in the container (with a configurable `timeoutMs`, default 5s). - Snapshots the SlideCanvas root (`container.firstElementChild`) rather than the outer wrapper — html2canvas-pro's bounding-box math otherwise leaves white edges or drops absolutely-positioned children. - Uses `html2canvas-pro` instead of `html-to-image`: the latter's SVG `<foreignObject>` path loads embedded woff2 asynchronously, so the drawImage fires before fonts decode and text re-wraps in the fallback font. html2canvas-pro walks the DOM and inherits the parent document's font registry, keeping wrap positions identical to the on-screen render. - `onclone` hook injects a small CSS reset (`font-kerning: none`, `font-feature-settings: normal`, `font-variant-east-asian: normal`, `text-rendering: geometricPrecision`) scoped to the slide tree, to prevent html2canvas-pro's CJK text measurement from clipping full-width punctuation (e.g. `(` / `)`) past cell boundaries. - Default `pixelRatio` follows `window.devicePixelRatio` for retina-sharp output; consumers can override. - Optional `debugVisibleMs` briefly shows the off-screen container after snapshot so callers can compare what was captured vs the live render. Wiring - Sub-export added to `package.json` (`./snapshot`) and Rollup `entries` for tree-shaking — apps that don't use snapshot don't pay the `html2canvas-pro` bundle cost. - Demo route adds an "导出 PNG" button that calls `slideToPng` on the active slide and triggers a browser download. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: build slide-renderer in root postinstall Without this, fresh installs leave packages/slide-renderer/dist missing and Next.js fails to resolve the `slide-renderer` import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(parser+renderer): iteration-0601-002 fidelity fixes Parser (pptxtojson-pro): - shapeSerializer: stop painting parent fill on grpFill children; honor explicit <a:ln><a:noFill/></a:ln> over theme lnRef (master logo dots were rendering as visible green rings). - imageSerializer: detect custGeom ellipses and 8-vertex chamfered-rect photo frames; resolve to 'ellipse'/'octagon' instead of falling back to 'rect'. - textSerializer: emit text-shadow from rPr effectLst > outerShdw, and compensate the half-leading PowerPoint puts above the first line for unitless lnSpc > 1 (cover-title gap regression). - border/tableSerializer: pass rgba()/hsla() colors through ensureHex untouched so '#rgba(...)' never reaches the DOM. - transformParsedToSlides: drop the 1.1 SAFE_PADDING wrap-fudge and thread bodyPr@anchor through as vAlign. - font config: add 思源黑体/思源宋体 + Source Han aliases and a trailing region/weight suffix stripper so 'SourceHanSerif CN Light' resolves. Renderer (slide-renderer): - SlideCanvas: new chrome prop (default true) to opt out of the preview-only card shadow + rounded corners; snapshot pipeline passes false so html2canvas output matches PPT edges. - BaseTextElement: flex-column + justifyContent driven by the new vAlign field; width/height stay 100% so the box anchors correctly. - BaseImageElement: maxWidth/maxHeight 'none' to prevent host CSS from shrinking absolutely-positioned <img>. - snapshot: render with chrome=false. Docs: SKILL.md expanded with the iteration workflow + OOXML traps. * fix(parser+renderer): iteration-0605-001 fidelity fixes slide-renderer: - PPTImageElement: add `softEdge` field (feather radius that fades image alpha to transparent at every edge, per a:softEdge@rad). - TableCell: add per-side `borders` so cells with partial dividers render each side independently instead of falling back to the table-level uniform outline. - BaseTextElement: hoist `fill`/`opacity` to the outer wrapper so the background covers the full shape rectangle, matching PowerPoint behavior for short text inside tall shapes. - Misc tweaks across image/latex/shape/video base elements, StaticTable and the standalone snapshot util. pptxtojson-pro: - Parser/serializer fidelity improvements for text, math, image, shape and table (notably ~500 lines in textSerializer and ~127 in mathSerializer). - Refinements in GroupNode, MathNode and ShapeNode models; richer Theme, StyleResolver and RenderContext plumbing. - Expanded shape presets and customGeometry utilities, plus adapter and import-pipeline updates to surface the new fields. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: stop tracking local slide-renderer-demo sandbox `app/slide-renderer-demo/page.tsx` is a personal scratchpad used for ad-hoc rendering tests; it shouldn't ship with the package. Remove it from the index (file stays on disk locally) and ignore the directory so future tweaks don't show up in `git status`. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: rename packages pptxtojson-pro→maic-import and slide-renderer→maic-renderer Align workspace package names with the MAIC product family: packages/pptxtojson-pro → packages/maic-import packages/slide-renderer → packages/maic-renderer Functional references updated end to end: - Root package.json: workspace deps + postinstall/sync scripts - next.config.ts: transpilePackages - app/globals.css: Tailwind `@source` for renderer dist - app/layout.tsx: `maic-renderer/fonts.css` import - lib/import/use-import-pptx.ts: imports + runtime URL (`/vendor/maic-import/index.js`) + script comment - scripts/sync-maic-import.mjs: renamed and pointed at the new dist - .gitignore: vendor path and sync-script comment - Package package.json `name` fields and cross-link in READMEs Also re-include the gitignored `app/slide-renderer-demo/` in Tailwind's source detection via an explicit `@source` so its classes (e.g. `bg-violet-600`) keep getting generated — Tailwind v4 skips gitignored paths by default, which was making the demo's import button render white-on-white after the demo was excluded from version control. CSS class names (`slide-renderer-prose` / `slide-renderer-cell-text` / `slide-renderer-pulse` / `slide-renderer-ping` / `slide-renderer-code- cursor-blink`) and internal source comments that still reference the old package names are intentionally left untouched to keep the rename diff minimal; they can be updated separately as the codebase evolves. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(dsl): extract @maic/dsl contract package and adopt @maic/* scope Establish the SDK family's contract keystone and align package naming with the planned @maic/* scope, per PR #668 review. 1. New @maic/dsl package (packages/maic-dsl) - Pure, zero-runtime-dependency spec: the canonical slide object model, pure type-guards, and DSL version/migration scaffolding. - Seeded from lib/types/slides.ts but reconciled into a *superset* of the three previously-drifted copies (app / renderer / importer); merged fields are annotated @since-merge. README documents the divergence and the acyclic dependency arrows (renderer/importer -> dsl -> nothing). - Enums are regular (not const) so consumers compiling with isolatedModules can import them across the package boundary (TS2748). 2. Wire @maic/importer to @maic/dsl - Import all slide types from @maic/dsl; delete the vendored openmaic/types/slides.ts copy. - Eliminate the Slide drift at the root: transformParsedToSlides now fills viewportSize/viewportRatio/theme at construction and emits a complete DSL Slide, removing the partial "draft slide" + post-fill step in parsedToSlides (no DraftSlide type needed). - Add @maic/dsl to the root postinstall build chain before the importer. 3. Adopt @maic/* scope (agent-noun names) - maic-import -> @maic/importer - maic-renderer -> @maic/renderer - Update all package-name references (deps, app imports, transpilePackages, fonts.css subpath import, Tailwind @source node_modules path, READMEs). - Directory names and /vendor paths are intentionally left unchanged. Note: @maic/renderer still vendors its own slide types; wiring it to @maic/dsl is the next step. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(renderer): consume slide types from @maic/dsl, drop vendored copy Wire @maic/renderer to the @maic/dsl contract keystone, completing the de-duplication of the slide object model across the SDK family. - Delete packages/maic-renderer/src/types/slides.ts (the vendored copy) and repoint all ~30 internal imports at @maic/dsl. SlideElement's runtime `ElementTypes` value import now resolves to the DSL enum. - Re-export the DSL types from src/types/index.ts so the public `@maic/renderer/types` entry keeps exporting the full slide contract (alongside the renderer-only effect types). - Add @maic/dsl as a regular dependency and mark it external in the rollup build so consumers share a single copy (enum not inlined); the emitted .d.ts references @maic/dsl directly. @maic/dsl is a superset of the renderer's former types, so this is non-breaking; the renderer additionally gains `script` and the importer-origin fields it didn't previously declare. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(renderer): serve fonts from OSS, drop the 43.6MB bundled woff2 Move the 22 CJK fonts off the repo and onto object storage to fix the PR #668 blocking repo-weight concern. - fonts.css now points every @font-face src at https://file.maic.chat/fonts/<name>.woff2 instead of bundled local files. - Extract the CDN origin + font whitelist into fonts.config.mjs (single source of truth) and generate fonts.css from it via scripts/generate-fonts-css.mjs. `genfonts` runs first in the build, so the CSS always reflects the config; changing the domain is now a one-liner. - Delete packages/maic-renderer/fonts/*.woff2 (22 files, ~43.6MB) and drop the now-unused "./fonts/*" export and "fonts" files entry from package.json (fonts.css is kept). Font names are unchanged, so consumers and the importer font whitelist are unaffected. Note: this removes the files going forward only; purging them from git history (LFS / filter-repo) is a separate follow-up. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(home): gate the unwired PPTX import button behind a feature flag The PPTX import flow is still scaffolding — `useImportPptx` has no `onImported` consumer, so clicking it only logs the parsed slides. Hide both PPTX import entry points (and the hidden file input) behind NEXT_PUBLIC_ENABLE_PPTX_IMPORT (default off) so the UI doesn't expose a no-op. The classroom import button is unaffected. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(import): harden the runtime URL-loaded PPTX parser The PPTX parser is loaded at runtime from /vendor/maic-import/index.js — a gitignored artifact synced into public/vendor during postinstall. If a deploy skips that step the URL 404s and import() fails with an opaque SyntaxError (404 HTML parsed as JS). Add guards on both ends plus docs. - Build-time assertion: scripts/assert-vendor-maic-import.mjs verifies the bundle exists and is non-empty; wired into `build` so a misconfigured deploy fails early with an actionable message instead of shipping a broken runtime. - Runtime guard: use-import-pptx.ts HEAD-probes the URL before import() and, on a non-ok response, throws a clear error surfaced to the user via the new import.error.parserUnavailable string (added to all 6 locales). - Docs: document the postinstall deploy dependency and the pull-without-install type/runtime drift in @maic/importer's README. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: unify the @maic/* license policy to AGPL-3.0 Pick one license for the SDK family and apply it uniformly, matching the OpenMAIC root, per PR #668 review. - @maic/dsl and @maic/importer: relicense MIT -> AGPL-3.0 (package.json + full AGPL-3.0 LICENSE text). - @maic/renderer: already AGPL-3.0; add the LICENSE file its package.json `files` already referenced. @maic/importer is derived from pptxtojson (MIT); MIT permits relicensing a derivative under AGPL, and the upstream MIT attribution is retained in the package README/index.html, so this is compliant. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: rename packages/maic-import dir to maic-importer Align the importer's directory with its @maic/importer package name and the maic-* sibling dirs (maic-dsl, maic-renderer). Package name is unchanged, so imports/transpilePackages/node_modules resolution are unaffected. - git mv packages/maic-import -> packages/maic-importer (history preserved). - Rename the vendor sync/assert scripts and the runtime vendor path to match: scripts/{sync,assert-vendor}-maic-importer.mjs, public/vendor/maic-importer, URL /vendor/maic-importer/index.js. - Update all path references: root postinstall/build/sync scripts, .gitignore, use-import-pptx.ts, and doc links in the dsl/renderer/importer READMEs. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: nest the @maic packages under packages/@maic/ Mirror the @maic/* npm scope in the directory layout: move the three family packages into packages/@maic/{dsl,importer,renderer}. Package names are unchanged, so imports / transpilePackages / node_modules resolution are unaffected. - git mv packages/maic-{dsl,importer,renderer} -> packages/@maic/* (history preserved). - pnpm-workspace.yaml: add packages/@maic/* glob. - Recompute the root postinstall cd chain for the nested layout. - Update dir-path references: sync script srcDir, .gitignore comment, the dsl source comment, and the renderer/importer README/SKILL cross-links. Unchanged: the @maic/* package names, the runtime vendor path (public/vendor/maic-importer) and its sync/assert scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(renderer): clear font licenses, drop non-redistributable faces, remove importer font remapping Address the PR #668 blocking font-license-clearance review. Font licensing (renderer): - Reduce the self-hosted whitelist to 6 clearly-redistributable faces: SourceHanSans/SourceHanSerif/LXGWWenKai/ZhuQueFangSong (SIL OFL 1.1), ZcoolHappy (ZCOOL free-use), WenDingPLKaiTi = AR PL KaitiM GB (Arphic PL 1999). - Drop AlibabaPuHuiTi: free to *use* commercially but its license grants no explicit redistribution/re-host right, unlike OFL — dropped to keep public CDN serving unambiguous. - Add FONTS.md (per-face attribution/clearance record, separate from the package's AGPL LICENSE) and bundle the verbatim license texts under font-licenses/ (OFL.txt, ZcoolHappy-LICENSE.txt, ARPHIC-PL.txt); ship both via package.json `files`. Regenerate fonts.css from the trimmed config. Importer font remapping removed: - The importer no longer rewrites font-family names (alias -> category -> primary). It passes each slide's original font-family through unchanged, so it can never target a face the renderer doesn't ship. Delete configs/font.ts, the resolveFont/replaceFontFamilyInHtml helpers, and the now-unused FontReplacementBucket / ImportContext.replacedFonts plumbing. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(renderer): document the CDN font runtime dependency Add a "Fonts" section to the renderer README per PR #668: the optional `@maic/renderer/fonts.css` loads woff2 from an external host (https://file.maic.chat), which must be reachable + CORS-enabled or the browser silently falls back to system fonts. Note that the import is optional and that the origin is configurable via fonts.config.mjs + `pnpm run genfonts`. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(renderer): verify WenDingPLKaiTi == AR PL KaitiM GB from the font name table Record the embedded-name-table evidence (Family/Copyright/Trademark/Designer = Arphic, nameID 13 = the full Arphic Public License) so the WenDingPLKaiTi ⇄ AR PL KaitiM GB correspondence is provable, and correct the copyright year to 1994–1999 to match the font metadata. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: fix `pnpm check` (prettier) after merging main main added a `prettier . --check` CI step that flagged 180 files. Resolve it consistently with the existing convention (vendored/workspace packages are excluded from root Prettier): - .prettierignore: exclude `packages/@maic/` (own rollup/tsc/eslint toolchains; dist is generated, importer/src1 is a vendored oracle), matching how pptxgenjs / mathml2omml / docs are already handled. - Format the three root-level files we added/edited that root Prettier does cover: lib/import/use-import-pptx.ts and the two scripts/*-maic-importer.mjs. `pnpm check` now passes. Co-authored-by: Cursor <cursoragent@cursor.com> * style: align @maic/{dsl,renderer,importer} source to root Prettier Per review preference, the @maic packages are our own code and should follow the repo style rather than be ignored wholesale. Narrow .prettierignore to only the generated build output (packages/@maic/*/dist/) and the vendored legacy reference (packages/@maic/importer/src1/), then `prettier --write` the package sources. Formatting-only; all three packages still build. `pnpm check` passes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lint): scope ESLint to @maic source and clear all errors - eslint.config.mjs: replace blanket packages/** ignore with precise rules so @maic/{dsl,importer,renderer} source is linted, while skipping third-party packages, dist output, importer/src1 and public/vendor (the generated bundle was producing thousands of false errors in CI). - importer: replace `any` with concrete narrowings/types, fix prefer-const. - renderer: drop manual useMemo that React Compiler could not preserve. - remove leftover debug console.log + stale eslint-disable in use-import-pptx. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(i18n): add missing PPTX import keys to pt-BR pt-BR.json was missing import.{pptx,parsingPptx,pptxSuccess} and import.error.{invalidPptx,parserUnavailable}, failing the i18n key alignment check against en-US.json. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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 (
|
||
|
|
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> |
||
|
|
ea0e8126a5 |
feat: whiteboard layout quality eval harness (#425)
* feat(eval): add state manager bridging ActionEngine for eval Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): add shared types for whiteboard layout eval harness * feat(eval): add SSE chat client for whiteboard eval * feat(eval): add Playwright capture module for whiteboard screenshots * feat(eval): add VLM scorer for whiteboard layout evaluation * feat(eval): add report generator for whiteboard eval results * feat(eval): add 8 constructed scenarios for whiteboard layout eval * feat(eval): add minimal whiteboard render page for Playwright screenshots Creates app/eval/whiteboard/page.tsx — a headless client page that seeds the stageStore with a synthetic slide scene, exposes window.__setElements() for Playwright to inject PPTElement[], and renders them via ScreenElement inside a 1000×562.5px white canvas. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): add main runner for whiteboard layout eval Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): add eval:whiteboard script, install tsx, gitignore results * fix(eval): fix TS errors, lint, and prettier formatting * fix(eval): address code review — add cue_user/empty turn guards, validate VLM output, fix empty array crash * refactor(eval): replace synthetic scenarios with realistic ones - Replace 8 generic scenarios with 6 that match real usage patterns - Multi-agent discussion with short user replies (嗯, 明白了, 继续) - Include real slide scene data as initialStoreState - Generated agent configs with Chinese names and proper roles - Cover: physics, math, finance, primary school, economics, medical Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract shared agent loop from use-chat-sessions Extract the core agent loop logic into lib/chat/agent-loop.ts as a pure async function with callback injection. Both the frontend React hook and the eval harness now share the same loop — SSE parsing, exit conditions (END/cue_user/empty turns/max turns), and director state accumulation. The frontend wires StreamBuffer callbacks for UI pacing; the eval wires ActionEngine + message accumulation for headless execution. If loop logic changes in the shared module, both consumers automatically stay in sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(eval): use project LLM infrastructure, fix eval page and model config - Rewrite scorer to use resolveModel() + generateText() from AI SDK instead of raw fetch — supports all providers (OpenAI, Google, Anthropic) - Model config via env vars (EVAL_CHAT_MODEL, EVAL_SCORER_MODEL), matching the pattern from outline-language eval - Fix eval page: bootstrap store before SceneProvider mounts - Fix __dirname for tsx CJS mode - Remove --api-key/--scorer-model CLI args (use env vars instead) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): organize results by model/timestamp * fix: remove double turnCount increment in shared agent loop The extracted agent-loop.ts had both `turnCount = directorState?.turnCount ?? turnCount + 1` (line 190) and a redundant `turnCount++` (line 215), causing multi-agent scenarios to hit maxTurns at half the expected number of iterations. Also removes unused processSSEStream import from use-chat-sessions.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): organize screenshots by scenario subdirectory Results structure: results/<model>/<timestamp>/<scenario>/run0_turn1.png Report files stay at the timestamp level. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): revise scorer rubric and add rescore mode - Replace space_utilization with rendering_correctness and content_completeness - Rubric now evaluates from a teacher's perspective (empty space is normal) - readability emphasizes font size consistency - Add --rescore flag to re-score existing screenshots without re-running chat - Increase maxOutputTokens to 2000, add JSON parse error recovery - Score errors no longer abort the entire scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(eval): sharpen scorer rubric with teacher-perspective examples The rubric now catches specific classroom whiteboard failure modes: - overlap now explicitly penalizes writing over existing content when empty space is available (spatial planning failure) - rendering_correctness calls out diagram accuracy (e.g., parabola drawn as V-shape), raw subscripts (G_x), Chinese inside LaTeX math mode - content_completeness emphasizes canvas edge clipping and bare unlabeled diagrams - readability penalizes text styled as UI components (gray card backgrounds) - overall instructed to weight overlap and rendering_correctness more heavily - explicit note to ignore the "N" page UI element Also increases maxOutputTokens to 3000 since longer rubric produces longer justifications. Reporter now guards against null scores (scorer failures no longer crash report generation). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings Critical fixes: - use-chat-sessions: restore agent_end handling, currentMessageId fallback for text_delta/action events with missing messageId, and re-throw on SSE error events (previously silently pushed to buffer only). - eval runner: serialize ActionEngine executions via promise chain. void-fire-and-forget raced with ensureWhiteboardOpen's 2s delay and could insert elements out of order or before the first element was committed to the store. Important fixes: - CHAT_MODEL default: 'openai/gpt-4o-mini' -> 'openai:gpt-4o-mini' (parseModelString splits on ':', not '/'). - CheckpointResult.score is now VlmScore | null; removed the 'as unknown as' cast that hid the null contract from consumers. - Delete dead code: eval/whiteboard-layout/chat-client.ts and components/chat/process-sse-stream.ts (both unused after the shared agent loop refactor). 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> |
||
|
|
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 |
||
|
|
37e04558b6 |
test: add Playwright e2e testing framework with core scenario coverage (#229)
* chore: add Playwright e2e testing infrastructure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add e2e mock fixture data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add MockApi route interception helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add base fixture and page object models Also exclude e2e/ from ESLint to avoid react-hooks false positives on Playwright's fixture `use` callback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add home-to-generation e2e spec Adds the first Playwright spec covering the home page UI and navigation to generation-preview. Also updates playwright config to use port 3002 to avoid conflicts with other running services. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add generation-flow e2e spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add classroom-interaction e2e spec Seeds IndexedDB with a 3-scene stage by navigating to / first (so Dexie initializes the DB at v8), then writing data without re-triggering onupgradeneeded. Verifies the sidebar renders 3 scenes and that clicking a scene switches the active scene heading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: add Playwright e2e test job Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(e2e): fix review issues — data-testid selectors, shared helpers, type annotations - Add data-testid="scene-list", "scene-item", "scene-title" to scene-sidebar.tsx - Update classroom.page.ts selectors to use data-testid instead of CSS class chains - Extract createSettingsStorage() helper into e2e/fixtures/test-data/settings.ts - Update all 3 spec files to use createSettingsStorage() and import defaultTheme from scene-content - Add SceneOutline[] type annotation to mockOutlines via relative import - Add SlideTheme type annotation to defaultTheme in scene-content.ts - Remove redundant mockServerProviders() call from setupGenerationMocks() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: exclude e2e/ from tsconfig, rename CI job - Add "e2e" to tsconfig.json exclude array to prevent Next.js compilation from pulling in Playwright/Node APIs - Rename CI job from "Lint & Typecheck" to "Lint, Typecheck & Unit Tests" to reflect that it now also runs unit tests (added in PR #144) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
95cdc38902 |
test: add Vitest infrastructure with provider-config and settings-sync tests (#144)
* chore: set up Vitest testing infrastructure - Add vitest as devDependency - Create vitest.config.ts with @/ path alias and tests/ directory - Add "test" script to package.json - Add Unit Tests step to CI pipeline Ref: #79 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add action-parser tests covering parsing, fault tolerance, and post-processing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: fix describe block names and add nanoid ID assertion - Rename misleading "all layers/strategies fail" to "edge cases" - Add assertion for generated action ID from nanoid mock Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add provider config and model string parsing tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: replace low-value tests with server-sync store tests Remove parse-model-string, action-parser, and json-repair tests. Add settings-server-sync tests verifying fetchServerProviders() correctly updates provider availability and model filtering. Include 3 it.fails() cases documenting stale selection bug: when server removes a model/provider, the store's modelId/providerId should be cleared but currently persists as a stale value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add ASR_PROVIDERS to audio constants mock Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format settings-server-sync test with Prettier Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR #144 review feedback - Remove unrelated `release` script from package.json - Narrow `vi.mock('fs')` to only intercept server-providers.yml reads - Add positive test for `resolveProxy` with YAML config - Add error-path tests for `fetchServerProviders()` (HTTP error, network error) - Track stale-selection bugs as GitHub issue #226 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
5cb426780a | Merge remote-tracking branch 'origin/main' |