Commit Graph
43 Commits
Author SHA1 Message Date
wyuc 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
2026-08-05 22:35:38 -04:00
wyuc 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.
2026-08-05 22:22:54 -04:00
littlebullGitandwyuc 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>
2026-08-03 23:10:50 -04:00
wyucandClaude Opus 5 cc95851dc5 chore(packages): publish storage and enforce version bumps (#998)
* chore(packages): publish storage and enforce version bumps

* fix(packages): cover all publish inputs and tarballs

* fix(packages): scope version guard to release inputs

* fix(packages): fail closed on new package inputs

* docs(packages): clarify version guard boundaries

* fix(packages): harden release guard edge cases

* fix(packages): align smoke peers and git inputs

* docs(packages): make release smoke policy explicit

* fix(packages): anchor version guard at repository root

* fix(packages): report version guard setup errors

* fix(packages): harden release validation

* fix(ci): validate complete main pushes

* test(storage): pin the exported PostgreSQL schemas

DOCUMENT_PG_SCHEMA and RUNTIME_PG_SCHEMA are public API. A deployment that
provisions these tables with its own migration tooling has to reproduce the
DDL exactly for ensureDocumentSchema() / ensureSchema() to stay the intended
no-op against an already-provisioned database.

Nothing guarded that today: every statement is CREATE ... IF NOT EXISTS, so
PostgreSQL silently accepts whatever table already exists under the name. A
column type, a nullability, an index or a FK action can drift apart from a
downstream migration without an error, and the first symptom is a store query
failing in production or succeeding against the wrong types.

Pin both constants verbatim, and assert every statement stays IF NOT EXISTS
guarded so the ensure functions remain idempotent. The pin does not judge the
DDL; it makes changing it impossible to do by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): gate publishing on the version check

The version check ran only in ci.yml. publish-packages.yml is a separate
workflow, so it could reach `pnpm publish` on a main push, a matching tag, or
a manual run with dry_run=false even when CI was red, skipped, or never ran.

Run the validation inside the publish job, before publishing, in a new release
mode that works for every trigger. A push range only exists for branch pushes,
so release mode judges each package against the registry instead: a version
that is not published yet is a release, and a version that is already
published is accepted only when the package source has not moved since that
release. `pnpm publish` skips an already-published version, so without that
second half a drifted package releases as a silent no-op.

The anchor for "since that release" is the @openmaic/<name>@<version> tag the
job now writes after every successful publish, falling back to the push range
for packages released before those tags existed. With neither anchor the
release stops instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): make the release gate prove only what it can

Three independent reviews of the previous revision converged on one root
cause: it used git tags and a push range as the record of which tree produced
a published version. Neither is trustworthy. Tags are mutable and can be
created or moved by hand, so "the tag resolves" was being read as "the tag is
authentic"; pnpm records no gitHead; and a push range describes one push, not
the origin of a release. Packages released before the scheme existed had no
anchor at all, which made the tag and manual triggers fail closed from the
first run with no way to self-heal, dry runs included.

Drop the anchor machinery. Drift is prevented where it is provable, in diff
mode at merge time, which sees every commit before it can be released. The
pre-publish gate is now limited to claims a release can establish: every
version it publishes is new and moves forward, an already-published version is
reported and left alone, and any registry answer that is not a definitive
404 stops the release.

Around it, close what the reviews found in the workflow:

- real publishes only from a commit contained in main, so a tag or manual run
  cannot ship an unreviewed ref while holding NPM_TOKEN
- one repository-wide concurrency group, so a main push and a tag push cannot
  each decide the same version is unpublished
- publish and mark each package individually, so a partial failure stays
  retryable instead of leaving published packages unrecorded
- fail if the build rewrites tracked package files, which would otherwise
  publish content that is not in the released commit
- compare against every published version when ordering, and refuse to guess
  when the registry holds versions this check cannot order

The schema pin also asserted the constants without asserting what the ensure
functions execute, so those could diverge. Both are now run against a
recording queryable and their exact statement sequence is asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): refuse to release from a tree behind the registry

Simplifying the gate introduced a hole that a mutation check caught: a package
whose local version is already published took the "already published, skip"
path before any ordering check, so a tree rolled back to an older published
version passed silently. That is not a harmless no-op, because pnpm publish
rewrites each workspace dependency to the version in the tree, and a sibling
released alongside it would be published declaring a dependency on the older
package.

Order every package against the registry first, then decide: at the highest
published version it is a legitimate skip, below it the release stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): couple publishing to a green CI run for the same commit

Second review round, on the reworked design. The headline defect was a hard
blocker: `git fetch --depth=0` is not valid git (that is an actions/checkout
convention), so under `set -e` every real publish aborted before the gate ever
ran. Remove the depth argument; the checkout already has full history.

The rest closes the gap both reviewers reached independently, that being on
main is not the same as having passed the gate:

- wait for this exact commit's CI conclusion and require success. ci.yml runs
  concurrently with this workflow on a push to main and blocks nothing, so an
  unpublished version whose source drifted could be released while its own
  version check was still running or already red.
- require the commit on main's FIRST-PARENT history. Plain reachability also
  accepts every intermediate commit of every branch merged with a merge commit.
  A pull request that adds a bad tree and reverts it in the next commit leaves
  that tree an ancestor of main forever, and a tag pointing at it published it.
- do not cancel CI runs on main. Each run validates only its own push range, so
  cancelling one drops the range that contained the change, and its replacement
  compares against the cancelled tip and sees nothing.
- run the version check on every main push. A branch creation or force push
  reports an unusable `before`, which used to skip the gate entirely; fall back
  to the first parent instead.
- publish with --ignore-scripts. Every package's prepublishOnly reruns the
  build, deleting and regenerating the dist that was just verified and
  smoke-tested, so npm could receive bytes nothing had checked.
- run storage's PostgreSQL contract suites before publishing it. They skip
  themselves without a database, so the one backend that needs a real
  PostgreSQL was shipping unexercised.
- order registry versions with real semver, prereleases included. Rejecting
  every non-x.y.z version meant one historical prerelease anywhere in one
  package's history would refuse every future release of every package.
- warn when a release marker exists but points at another commit.

Note what is NOT enforceable here: a workflow_dispatch or tag run executes the
workflow definition from the selected ref, so a branch that edits this file can
delete these checks. The boundary has to be a GitHub Environment holding
NPM_TOKEN behind a main-only deployment branch rule. The job now declares that
environment and the header states the required setup; the in-file checks are
defence in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): stop the first-parent check failing on SIGPIPE

Third review round. The blocker this time: `git rev-list --first-parent | grep
-qx` under `set -o pipefail`. grep exits at the first match, rev-list then dies
of SIGPIPE, and the pipeline returns 141, so a commit that IS on main is read
as "not on main" and every real publish is refused. It does not reproduce in a
shallow clone, where main's history fits in the pipe buffer; with the full
history this job checks out it always will. Count matches instead, which reads
the whole stream.

Also from this round:

- identify the CI run by workflow file and triggering event rather than by a
  check-run display name. Names are not unique, so any other workflow or app
  publishing a check called "Lint, Typecheck & Unit Tests" could stand in for a
  red version gate.
- fail the main-push version check when the push range is unusable instead of
  substituting HEAD^. A force push can replace many commits at once, so the
  previous commit is not the range that needs checking, and a green run here is
  what publishing depends on.
- correct the stated limitation. It claimed the package-directory input model
  was exact for dsl and storage; it is not. Their dist is whatever the
  lockfile's TypeScript emits, and dsl's shipped JSON schema comes from the
  lockfile's ts-json-schema-generator, so a toolchain bump can change any of the
  four tarballs with no diff under the package directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(packages): make a merged version bump the only release input

The tag trigger was incoherent. Its own documentation admitted that any
@openmaic/* tag republishes the whole family and that which packages actually
go out is decided by the manifests, so the tag name never participated in the
decision. It was a second entry point to one decision function, it bypassed
review entirely because a tag is not a diff and can be created by anyone with
write access on any commit, and it cannot be guarded: environment deployment
rules match GITHUB_REF, so refs/tags/* and a main-only rule are mutually
exclusive.

Drop it. A version bump that landed on main is now the only release input, and
`@openmaic/<name>@<version>` tags are an output written after a package
reaches the registry. Nothing is lost: a manual dispatch from main still
republishes without a new commit, and that path is inside the protected
environment.

Split the workflow so the token has a boundary. `validate` holds everything
that does not need NPM_TOKEN and runs from any ref, which keeps dry runs
useful to contributors. `publish` is the only job declaring the `release`
environment, so the token is never attached to a run that is merely
validating. It rebuilds rather than sharing state, which is the right trade
for a release.

Also add the `actions: read` permission the CI-conclusion query needs; it was
switched from the checks API to the Actions API without updating the scope,
which would have failed with 403 on every real publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(contributing): explain when a package version must be bumped

CONTRIBUTING said nothing about versioning or releases, while this branch adds
a CI check that fails a contributor's PR with "publishable package inputs
changed but version did not increase". A gate that rejects work without
telling anyone the rule is the kind that ends up disabled.

State the rule, the exact failure message, which files are exempt, and how to
choose the number, with a specific warning for @openmaic/dsl: it is the
contract the other packages validate against, so narrowing what an existing
document may contain is breaking even when the diff is small.

Also state what contributors do NOT do: publishing is automatic once a bump
lands on main, and the release tag is a marker written afterwards rather than
something to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): run renderer's tests before publishing renderer

The pre-publish gate ran renderer's typecheck but never its test suite, so the
one package whose behaviour is hardest to typecheck was the one shipping
unexercised. It has 25 test files and 242 tests; they just were not in the
filter list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): keep the git credential away from package code

Review finding: the publish job held `contents: write` while
actions/checkout had persisted that credential, and it then ran
`pnpm install` and the package builds. Any dependency or build script
executed with a credentialed git remote in reach and a write scope to use
it, which is a wider blast radius than publishing needs.

Separate the two capabilities so no job holds both:

- `validate` and `publish` run package code. Both now check out with
  `persist-credentials: false`, `validate` is pinned to `contents: read`,
  and `publish` drops write scope entirely, keeping only the `actions: read`
  it needs for the CI conclusion and `id-token: write` for provenance.
- a new `mark` job is the only holder of `contents: write` and a git
  credential, and it installs nothing and builds nothing.

`mark` also reconciles instead of only recording this run. A marker whose
push failed previously could never be repaired before, because the release
plan excludes versions already on the registry and the marker logic sat
behind that plan. It now marks any registry version whose tag is missing,
and leaves an existing tag alone rather than moving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:54:23 -04:00
wyucandCodex 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>
2026-07-23 16:28:03 +08:00
wyucandClaude Fable 5 04acb17c1a release: v0.3.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:25:32 -04:00
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>
2026-07-15 18:22:59 +08:00
wyucand杨慎 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>
2026-07-15 14:20:00 +08:00
Yanpeng Wangandwyuc 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 `![](oss-url)`), 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>
2026-07-14 18:12:31 +08:00
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>
2026-07-13 23:12:23 +08:00
wyucand杨慎 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>
2026-07-13 18:19:40 +08:00
wyucandClaude Fable 5 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>
2026-07-11 00:12:53 +08:00
wyuc 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.
2026-07-09 14:54:21 +08:00
wyucandClaude Opus 4.8 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>
2026-07-06 17:12:10 +08:00
wyuc da0b394b81 release: v0.3.0 2026-06-28 22:34:18 -04:00
杨慎andzlnn23 8f77d61fa4 test: add PBL v2 planner eval harness (#803)
Co-authored-by: zlnn23 <zlnn23@mails.tsinghua.edu.cn>
2026-06-26 13:56:40 +08:00
wyuc 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.
2026-06-24 15:12:23 +08:00
wyuc 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.
2026-06-23 16:56:37 +08:00
wyucandClaude Opus 4.8 beac93ab3d chore: relicense from AGPL-3.0 to MIT
Switch the OpenMAIC root and the in-house @maic/* SDK packages
(@maic/dsl, @maic/importer, @maic/renderer) from AGPL-3.0 to the MIT
License. Updates LICENSE files, package.json license fields, README
badges and license sections (EN/ZH), CONTRIBUTING, and renderer FONTS
note.

Third-party vendored packages are left untouched: packages/mathml2omml
remains LGPL-3.0, packages/pptxgenjs remains MIT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:00:00 -04:00
612a1471e7 fix(agent): respond to the user's turn before lecturing (+ answer-content eval) (#699)
* test(orchestration): add answer-content eval for agent first-reply answering

The existing director question-answering eval only checks that the director
ROUTES to the teacher; it never generates the dispatched agent's reply, so it
cannot catch the case where routing is correct but the agent's first sentence
drifts (greets / opens a lecture / pivots) and only a later turn answers.

This adds an answer-content eval that runs the real agent-generate inputs
(buildStructuredPrompt + convertMessagesToOpenAI), parses the structured output
with the app's runtime parser, and uses an LLM judge for two signals:
leads_with_answer (first sentence addresses the literal ask) and
answered_anywhere (addressed at all). Their gap quantifies "drift-then-answer".

A/B mirrors the answering-runner rule-13 strip: baseline removes the
"Answering the User's Question" section from agent-system, with_rule is as-shipped.
Scenarios are synthetic/anonymized (opening-lecture override, ignored
format/capability/navigation request, ignored correction, frustration re-ask,
adjacent pivot, vague-clarify) plus clean controls; no real user data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(orchestration): give answer-content scenarios live-shaped classroom context

The minimal store state (empty scenes, null stage, no profile) made the
assembled agent prompt far thinner than the live agent-generate path and
suppressed the slide-narration "lecture reflex" that drives opener drift.

Each scenario now carries a topical slide scene (title + body + latex elements
with ids), a stage (name/description/languageDirective), and a student profile,
so buildStructuredPrompt emits a "# Current State", "# Student Profile" and
"# Language" of the same shape and bulk as production. The runner reads this
context from the scenario instead of a hardcoded empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent): respond to the user's turn before continuing the lecture

Generalize the agent-system "Answering the User's Question" section into
"Responding to the User's Turn": the user's latest message always takes priority
over continuing the planned lecture. Beyond factual questions it now explicitly
covers the interaction requests agents were ignoring — navigation/pacing
("skip to next page"), format/language ("explain in Chinese"), capability
requests it cannot fulfill ("make a video" -> say so + offer an alternative),
corrections, frustration re-asks, and vague asks (ask one clarifying question).

Strengthen the answer-content eval that measures this:
- the navigation case now uses a 3-slide deck so "skip to next page" is well-posed;
  runner supports multi-scene state and strips the renamed section for the A/B;
- judge is fairer to legitimate behavior: explaining primarily in the requested
  language while keeping technical terms standard counts; a verbal transition to
  the next slide counts as honoring navigation (no page-turn action exists).

Result (gemini-3-flash-preview, n=10): mean leads-with-answer rises from 42%
(section stripped) to 93% (as-shipped); all 12 scenarios pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent): don't over-respond to bare acknowledgements; guard empty eval set

Code-review follow-ups on the "Responding to the User's Turn" change:

- Prompt: the absolute "every user message needs a direct response / treat
  continuing the lecture as a failure" wording had no carve-out for bare
  acknowledgements ("ok" / "嗯" / "good" / "thanks"), risking the agent halting
  the lesson to manufacture a reply, and conflicted with agent-initiated
  discussions where no user turn exists. Add a bare-acknowledgement carve-out,
  allow a brief "好的"/"Sure" before the answer, and scope the override to actual
  requests (and to "if there is a user turn to respond to").

- Eval: answer-content-runner exited 0 with a PASS verdict and NaN% aggregates
  when the scenario set was empty (e.g. an EVAL_SCENARIO typo). Guard it: error
  and exit 1 when no scenario matches.

Re-run unchanged otherwise (gemini-3-flash-preview, n=10): mean leads-with-answer
42% (stripped) -> 94% (as-shipped), 11/12 scenarios pass; the chem opener is
variance-prone around the 70% bar and not over-tuned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(eval): cr-loop cleanups on answer-content-runner

- ScenarioAgentSpec extends the shared ScenarioAgent (./types) instead of
  re-declaring id/name/role/priority.
- Drop the dead expectedPreFix plumbing from ScenarioResult (it was copied in
  but never read; it stays as an informational annotation in the scenarios).
- extractTexts: replace the hand-rolled JSON fallback with the app's
  finalizeParser, which recovers plain-prose / unclosed-array output the same
  way the runtime does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: tighten acknowledgement carve-out; don't surface action-JSON as text (cr round 2)

Round-2 review follow-ups.

Prompt: the bare-acknowledgement carve-out was too loose — it could swallow
ack+request hybrids ("嗯,那为什么X?") as acks (re-introducing drift), double-listed
"好的" as both an allowed lead-in and a no-response ack, and listed pacing words
("继续"/"go on") that collide with the navigation bullet. Restrict it to a
STANDALONE acknowledgement with nothing else attached; route an embedded
question/request to that request; route pacing words to the navigation bullet.

Eval: extractTexts gated finalizeParser recovery on !state.jsonStarted, so an
actions-only / unclosed array yields "no text" (the judge's intended empty case)
instead of finalizeParser's raw-buffer fallback surfacing action JSON as fake speech.

Re-run (gemini-3-flash-preview, n=5): no regression — 11/12 pass, meta-requests
100/100/80/100; the chem opener remains variance-prone around the 70% bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address review — honest slide-nav, strict judge booleans, errors fail (cr)

Review follow-ups from @cosarah:

- Navigation (P2): agents have no slide-navigation action, so "do it" + a verbal
  transition let "skip to next page" pass the eval while the UI stayed on the
  same slide. Reframe prompt, judge, and scenario so a correct reply ACKNOWLEDGES
  the request and is HONEST that it cannot change the slide itself (continue
  verbally / tell the user how to navigate); pretending it flipped the slide no
  longer counts.
- Judge booleans (P2): answer-content-judge coerced fields with Boolean(...), so a
  string "false" became true. Parse strictly (real bool or "true"/"false");
  malformed output is flagged as an error.
- Error accounting (P3): aggregate excluded errored samples from the denominator,
  letting a scenario pass on one good sample. Errors now count as failures
  (denominator = all requested samples).

Re-run (gemini-3-flash-preview, n=10): navigation 0% -> 100% under the honest
framing; aggregate leads-with-answer ~93%, no regression. Opener scenarios
(math/chem) remain variance-prone near the 70% bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(eval): judge first-sentence lead; use canonical teacher actions (cr round 2)

Second review round from @cosarah:

- leads_with_answer was judged against texts[0] (the whole first JSON text
  block), so "Today we'll discuss parabolas. The formula is x=-b/(2a)." counted
  as leading even though the first sentence drifted. Now extract the first ~2
  sentences as the "opening" and have the judge make the call: a brief
  acknowledgement of the user before the answer counts as leading, but a greeting
  / self-intro / topic preamble before the answer does not. (Two sentences +
  semantic judge handles "Sorry, let me clarify. m is inertial mass." vs
  "Welcome! …" without a brittle keyword list.)

- The runner hardcoded TEACHER_ACTIONS, omitting canonical teacher actions
  (play_video, wb_draw_chart). Use getActionsForRole('teacher') so the available-
  actions section matches production.

Re-run (gemini-3-flash-preview agent, n=10): 11/12 pass, aggregate leads ~90%.
The chem opener now correctly fails (~10%): its "Welcome! Today we…" persona
greets first, so at sentence granularity it is genuine first-sentence drift
(answered-anywhere ~90%) — exactly what the stricter lead measurement should
catch.

Note: the judge model is deepseek-v4-pro in this run because the anthropic
gateway is currently returning Bad Request in our environment; the eval is
judge-model-agnostic via EVAL_JUDGE_MODEL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(eval): judge the opening across all text blocks, not just texts[0] (cr round 3)

Review follow-up from @cosarah: leadFromTexts only looked inside the first
text block, so a valid multi-block reply like
[{"type":"text","content":"Sure."},{"type":"text","content":"The derivative is 2x."}]
was judged on "Sure." alone and failed leads_with_answer despite following the
allowed acknowledgement-then-answer pattern. Build the opening from the first ~2
speech sentences across ALL ordered text chunks.

Re-run (gemini-3-flash-preview, n=10, deepseek judge): 11/12 pass, aggregate
leads ~93%. The chem opener stays sub-threshold (~50%) — its "Welcome! Today
we…" persona greets before answering about half the time, a genuine
first-sentence drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-15 23:55:30 +08:00
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>
2026-06-15 12:16:09 +08:00
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>
2026-06-15 11:51:10 +08:00
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>
2026-06-09 03:25:05 -04:00
wyuc 46a2497deb release: v0.2.2 2026-06-02 05:18:14 -04:00
wyucandClaude Opus 4.8 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 (55a9a71), the `PPTElement` type import has
no remaining consumers in this file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): nav rail, scene management, Pro mode chrome rework (#601)

* feat(maic-editor): slide nav rail + scene management (3/3)

PR3a Phase 1 ships the Pro mode left rail + slide-level management, the
last user-visible block of #562. Closes the gap where Pro mode locked
the user on the current scene with no way to navigate or manage the
deck.

SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar`
visually — index badge + title above an aspect-video thumbnail card —
so the two sidebars read as the same component family across mode
toggle):

- Vertical thumbnail strip via `motion.dev` `Reorder.Group` with
  drag-to-reorder. `Reorder.Item layout="position"` keeps the layout
  animation on y-axis only; width changes from rail resize don't fight.
- Drag-to-resize handle on the right edge writes `style.width`
  directly on the DOM during the gesture and commits to settings store
  only on mouse-up; matches playback drag feel exactly and skips the
  per-frame `persist` serialization that would otherwise burn the
  frame budget at 60 Hz.
- Collapsed and expanded modes; width and collapsed flag persist in
  `useSettingsStore` (`editRailWidth`, `editRailCollapsed`).
- All scene types are first-class — slides render a live
  `ThumbnailSlide` (now with optional `size` prop → self-measures via
  `ResizeObserver` when omitted, so the rail width is the single source
  of truth), non-slide scenes render the same stylised mockups
  playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`).

Slide management:

- `+ Add` in the rail header inserts a blank slide after the current
  scene; new store action `useStageStore.insertSceneAfter` validates
  stage id, migrates the scene, splices, rebalances `order`, and
  triggers `debouncedSave`.
- Three-dot menu per tile: Rename / Duplicate / Delete. Rename also
  reachable via double-click on the title; Enter commits, Escape
  cancels, blur commits, empty input reverts.
- Duplicate deep-clones slide content with fresh element IDs (avoid
  React key collisions) and a `(copy)` title suffix.
- Delete uses a toast with Undo action; deleted scene is held in a
  small `useDeletedSceneRecycle` zustand store and re-inserted at its
  original index on Undo. Deck-empty guard at the rail layer.
- Inter-thumb `InsertionZone` reveals a violet `+` badge on hover,
  right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`)
  + drop shadow + `z-20` so it lifts above the active tile's violet
  ring. Zero layout shift.

Chrome bar:

- `HeaderControls` (settings pill + Pro Switch) extracted from
  `Header` so Pro mode can mount it in the CommandBar's trailing slot
  — single top chrome bar in Pro mode instead of stacking Header +
  CommandBar.
- Back-to-home button in CommandBar mirrors the playback Header's
  leftmost button.

i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP
/ ar-SA / ru-RU.

Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`,
`createBlankSlideScene` / `duplicateSlideScene`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): split Stage chrome into mode-specific roots

Bug-driven architectural rework. Two symptoms motivated this:

1. Switching from a slide scene to a non-slide one (interactive / quiz
   / pbl) flickered the entire edit chrome — CommandBar and SlideNavRail
   remounted along with the canvas. Root cause: EditShell returned a
   different component type (EditShellWithSurface vs EditShellReadOnly)
   based on whether a SceneEditorSurface was registered for the scene
   type, so React reconciled the change as an unmount/remount of the
   whole subtree.

2. `components/stage.tsx` had grown to 1391 lines — playback engine
   state, chat / TTS / discussion wiring, presentation/fullscreen,
   keyboard handling, AND the edit-mode dispatcher all in one place.
   Any change to mode coordination meant touching this god component.

Changes:

- New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op
  SceneEditorSurface used as a fallback when a scene type has no
  registered editor surface. `SurfaceState.history` is now optional so
  read-only surfaces can omit undo/redo cleanly. EditShell falls back
  to NOOP for unregistered types.

- EditShell now mounts a single Frame across all scene types. Surface
  state is published from a child `SurfaceStateRunner` keyed by
  `scene.type` (so it remounts only when the runner's hook signature
  changes — rules-of-hooks compliant), with a custom shallow
  equality so the chrome doesn't re-render every render cycle for
  reference-fresh state objects. Result: slide ↔ interactive no longer
  remounts the CommandBar or the leftRail.

- `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock
  coordination + Pro-Switch toggle wiring + multi-tab conflict prompt
  only. Everything else moved into one of two new components:

  - `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`):
    owns the entire playback / autonomous chrome — PlaybackEngine,
    chat, discussion TTS, presentation mode, keyboard shortcuts,
    SceneSidebar, Header, CanvasArea, Roundtable, ChatArea,
    AlertDialog. Exposes `teardown()` via forwardRef so the toggle can
    `await` SSE / engine / TTS shutdown before unmounting it.

  - `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro
    mode chrome wrapper — EditShell + SlideNavRail + HeaderControls
    trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from
    SlideCanvas so it covers read-only Pro-mode scene types too).

- New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named-
  slot layout shell with top / left / center / right / bottom areas
  for the Pro mode chrome. Future right panel (properties / AI) and
  bottom timeline plug in as props with no structural code change.
  EditShell's Frame now uses StageGrid internally.

- Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from
  the original A3 transition plan that this rework supersedes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): cross-fade chrome roots on Pro mode toggle

Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a
180 ms opacity fade-out / fade-in. The outgoing root fully exits before
the incoming one mounts, so:

- The single-canvasStore-writer guarantee from the chrome split is
  preserved (ScreenCanvas and Editor/Canvas never coexist).
- Mode toggle reads as a smooth fade instead of a hard cut.

Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900`
background so neither root reveals raw page colour while it passes
through opacity 0. `initial={false}` skips the entry animation on first
mount so the initial playback render is instant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drawer-style mode swap transition

Pro toggle was a hard cut — playback chrome vanished, edit chrome
popped into place. Now wraps the swap in `AnimatePresence` with the
two chrome roots layered via `absolute inset-0` so they coexist for
~280ms:

- Edit chrome enters from above (`translateY: -32 → 0`) + fades in,
  giving a "drawer drops down" feel that matches the inner
  CommandBar/leftRail stagger choreography.
- Playback chrome cross-fades opacity-only; no transform so its
  active slide canvas stays put underneath while edit drops over it.

Both roots keep rendering during the overlap, so `canvasStore`'s
scale writer doesn't briefly read zero and snap the slide to a stale
size when one root exits ahead of the other. Duration 280ms /
`CHROME_EASE` matches the inner Frame timing source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): Pro Switch as a shared layout element across modes

The Pro Switch is the click anchor for the mode swap, but it lives in
two different positions: the 80px playback Header (top-right) vs the
56px edit CommandBar trailing slot (also top-right but at a different
y and with different padding). After the click the switch "jumped" —
it visibly moved + restyled — which felt unsmooth even though the
chrome itself was cross-fading.

Tag the Pro Switch label (and the settings pill) with `motion.layoutId`
so motion treats them as shared elements across the AnimatePresence
swap. During the ~280ms transition, motion measures both instances
and morphs position + size between them — the user's click target
slides into its new home instead of teleporting. Same easing source
as the chrome cross-fade so the two animations stay locked together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): unify chrome shell across modes

Pro Switch + settings pill + download icon are the user's mode-toggle
"anchors" — they need to sit at the same screen pixel across
playback ↔ edit. They didn't, because the two chromes had different
shapes:

  Playback: SceneSidebar (left, full height) | Header (h-20, 80px)
            on top of CanvasArea + Roundtable
  Edit:     CommandBar (h-14, 56px, FULL width) on top of a row of
            (SlideNavRail | content), so the rail sat *below* the bar

So when the user clicked Pro, the bar collapsed by 24px AND the rail
shifted down by 56px AND the right-side controls re-styled (compact
variant) — three simultaneous moves. layoutId masked some of it but
the underlying structure was wrong.

Unify the shells:

- `StageGrid` template flipped from `top top top / left center right /
  bottom bottom bottom` to `left top top / left center right / left
  bottom bottom`. The left column now spans all rows so the sidebar
  always reaches the absolute top edge, matching playback exactly.
- `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback
  Header.
- `EditChromeRoot` drops the `variant="compact"` flag on
  `HeaderControls` so the settings pill renders at the same h-9 pill
  it does in playback.
- `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC
  logo (click → home), matching `SceneSidebar`'s shape so the sidebar
  top reads as the same component family in both modes.
- Download / Export dropdown moves out of `Header` and into
  `HeaderControls` so it's present in both playback and edit chrome at
  the same right-cluster position (was previously playback-only).

`Header.tsx` slimmed accordingly. Net effect: the right-edge cluster
(EN, theme, settings, download, Pro Switch) lives at the same screen
pixel across modes; the cross-fade transition only animates the
*contents* inside the bars + sidebar lists, not the bar/rail
positions themselves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drop sidebar header + button, add insert-before-first zone

The header `+` was a duplicate affordance — every gap between thumbs
already has its own `InsertionZone`. Remove the header button; insert
flows entirely through the gap zones now (with hover-popup + and
right-anchored visual).

Add one extra `InsertionZone` rendered BEFORE the first thumb so the
top padding of the rail is also clickable / hoverable. Insert-before-
first is implemented inline via `setScenes([blank, ...scenes])`
because the `insertSceneAfter` store API only handles insertion after
an existing anchor.

`PlusCircle` import dropped (no longer used).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): move Download out of settings pill, place right of Pro Switch

Download isn't a settings function (it's an export/share action), so
it shouldn't sit inside the pill that hosts language/theme/settings.
Move it back to a standalone button on the right side of the Pro
Switch — both in playback and edit chrome. Right cluster now reads:

  [ EN · theme · settings ]  [ PRO switch ]  [ Download ]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): floating insert toolbar above canvas (collapsible)

Text box / Image / future shape buttons no longer share CommandBar
with global stage controls (back / undo / redo / title / settings /
Pro Switch / Download). Insert is a content-creation action, not a
stage-navigation one — mixing them blurred the chrome's role.

Lift insert items into a new `FloatingInsertToolbar` that floats
centered ~12px above the slide canvas card. Default expanded; collapse
arrow tucks it into a small chevron handle at the same anchor. State
persists in `settings.editInsertToolbarCollapsed`. Reuses the existing
`InsertButton` (extracted from CommandBar into a sibling module so
both surfaces — the now-removed CommandBar slot and the floating bar —
can share styling).

CommandBar drops its `insertItems` prop / middle slot entirely;
right-side controls collapse to a single `flex shrink-0` cluster
matching playback Header's shape.

i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): auto-focus text element after toolbar insert

Inserting a Text box via the FloatingInsertToolbar + click/drag on
the canvas left the user one click short — the new element was
selected and the AnchoredTextBar opened, but the ProseMirror editor
never received focus, so the first keystroke went nowhere and the user
had to click inside the element again before typing.

`useEditingTextElementId` already mirrors the surface's editing-target
choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor`
watch that flag in an effect: whenever its own elementId becomes the
editing target (insert, programmatic selection, etc.) and it doesn't
already have focus, push focus into the view. `hasFocus()` guard keeps
this from re-focusing on every re-render of an already-active editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): prettier + drop ThumbItem rename sync effect

- prettier --write on three files touched by the recent edits.
- ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })`
  external-title sync that tripped `react-hooks/set-state-in-effect`.
  Idle display now reads from `scene.title` directly (derived rather
  than mirrored); `startRename` seeds `draft` at session start and
  `cancelRename` resets it so the next session starts clean. Rename
  e2e still passes the menu + double-click + Escape paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): CR-loop pass — pointer capture, stage-scoped recycle, equality docs

PR #601 reviewer feedback.

**Drag handle uses Pointer Events with `setPointerCapture`** so the rail
no longer gets stuck in "still dragging" state when the cursor leaves
the window, the OS reclaims focus, or a tab interrupt suppresses the
mouseup that the old document-bound mousemove/mouseup pair relied on.
The handle's onPointerMove/Up/Cancel are now bound directly on the
element; capture guarantees event delivery for the lifetime of the
gesture. Drag tracking e2e still PASS (1 px cursor lock).

**Toast Undo guards stage identity** before re-inserting the deleted
scene. If the user navigated to a different stage while the toast was
up, the recycle entry belongs to the previous stage and
`insertSceneAfter` would reject it on stage-id mismatch — silently
losing the deleted scene. New check drops the undo cleanly when stage
ids don't match. The `stageId` field was already captured on
RecycleEntry; just wasn't consulted.

**`surfaceStateEqual` extended** to compare per-item `id` / `disabled`
/ `label` on `floatingActions` (was length-only) and per-item
`id`/`severity`/`message` on `hints` (was length-only). Today's slide
surface returns `floatingActions: []` and `hints: []` so this is
dormant, but PR3b's z-order actions land in `floatingActions` — pinning
the equality semantics now keeps a future state field from silently
going stale in the chrome. SurfaceState gets a maintenance note
cross-linking to the equality function.

**Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard
around the title block isn't dead — it covers the ~280ms
AnimatePresence exit window where playback chrome is still rendering
its exit animation while mode has flipped to 'edit'. Without the
guard, this title would briefly stack on top of the incoming
EditChromeRoot's CommandBar title during the cross-fade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(maic-editor): CR-loop round 2 minors — sharpen JSDocs

Round-2 reviewer flagged two doc-only refinements:

- `surfaceStateEqual`: clarify that callback identity (`onInvoke`,
  `popoverContent`) is intentionally NOT compared, and that today's
  safety comes from slide-surface returning `floatingActions: []`
  rather than the per-item compare covering callbacks. A future
  surface that emits closure-capturing actions must fold its own
  change signal into the comparison or the stale callback fires at
  click time.
- `setPointerCapture` catch: spell out that this is paranoia, not a
  real fallback — if capture genuinely fails the gesture still
  tracks for in-window moves but out-of-window `pointerup` won't
  route here. Acceptable degradation; the catch exists only because
  the spec permits an `InvalidPointerId` throw that browsers we ship
  to don't actually emit on same-pointer `pointerdown`.

No functional changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): undo restore at index 0, reset mode on classroom load

Two issues from PR #601 manual-verification review:

**Undo of the first slide restored it as the second.** The toast undo
handler clamps `entry.index - 1` to 0 then calls
`insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at
position 1 instead of position 0 — no scene exists before scenes[0] to
anchor on. Fall back to `setScenes([entry.scene, ...live])` when
`entry.index === 0` (or when the deck is empty). The store's existing
non-rebalancing `deleteScene` keeps the surviving scenes at orders
2..N, so the prepended entry's original order=1 lines up naturally;
StageGrid auto-selects the restored scene as current.

**`mode` survived SPA navigation between classrooms.** Refresh reset
mode to 'playback' via the initial store value, but switching
classrooms via Next.js navigation kept the zustand singleton intact;
entering Pro mode in A and then opening B left B in edit mode.
`loadFromStorage` and the server-side classroom-load path both now set
`mode: 'playback'` on every classroom load, normalising the SPA path
to match the refresh path. Mode stays transient UI state, not
persisted with the stage.

e2e: delete Slide 1 → Undo → restored to position 1 (was position 2
before fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): gate slide scene creation until inserted scenes are playable (#612)

* feat(maic-editor): gate slide scene creation until inserted scenes are playable

Editor-created slide scenes (blank insert + duplicate) ship without
playback actions, so the playback engine gives them zero dwell and skips
straight past them — a freshly inserted slide is effectively unplayable.
Seeding default actions on new scenes is a separate change; until then,
hide the two scene-creation entry points so the editor stays coherent as
an in-place "fine-tune the generated deck" tool.

- add lib/edit/scene-creation-enabled.ts (SCENE_CREATION_ENABLED=false)
- hide inter-thumb "+" insertion zones (SlideNavRail)
- hide per-slide Duplicate menu item (ThumbItem)
- keep reorder / delete / rename, which are playback-safe

Re-enable by flipping the flag once new scenes get default actions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): e2e guard for slide scene-creation gate

Adds an e2e that generates a classroom (mocked), enters Pro mode, and
asserts the slide rail exposes no insertion "+" zones and the per-slide
overflow menu has only Rename + Delete (no Duplicate). Fails if
SCENE_CREATION_ENABLED is flipped back on without removing the gate.

Two stable test ids support locale-independent assertions:
- slide-nav-insert  (InsertionZone button)
- slide-nav-more    (ThumbItem overflow trigger)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): enable editor flag for the e2e webServer

The scene-creation gate e2e needs the Pro Switch, which only renders when
NEXT_PUBLIC_MAIC_EDITOR_ENABLED is on. It's a build-time NEXT_PUBLIC_* flag,
so set it in the Playwright webServer env (applies to `pnpm build` in CI and
`pnpm dev` locally). Fixes the e2e failure on CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): attach gate screenshot to report instead of fixed path

CR: e2e-artifacts/ is not gitignored, so writing the screenshot to a fixed
path left an untracked file that could be committed by accident. Use
testInfo.attach so the image lands in the (ignored) Playwright report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(maic-editor): add pt-BR translations for edit.* / stage.* keys

pt-BR locale (added on main post-stack) lacked the 51 editor keys, so
check:i18n-keys failed after rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): defer editor-only side effects behind Pro mode mount

editor-fonts (~23 @fontsource CSS tables) and slide-surface registration
were top-level static imports in app/layout.tsx and components/stage.tsx,
so flag-off classroom/playback users paid the font-face CSS + slide-edit
module-init cost on every page load.

Move both to a dynamic import in EditChromeRoot (mounts only when
mode==='edit', which requires NEXT_PUBLIC_MAIC_EDITOR_ENABLED). Hold the
EditShell render until the slide surface registers to avoid a NOOP/
read-only flash on first Pro mode paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): CR-loop correctness fixes (redo/groupId/edit-lock)

Three confirmed issues from the rebase code-review pass:

- slide-edit-session: a non-user (ResizeObserver auto-height) commit
  updated history.present while preserving a now-stale future, so a
  redo after undo silently resurrected pre-undo content. Clear future
  on the non-user path (present has diverged from the redo branch);
  past is left untouched so no spurious undo step is created.
- slide-defaults: duplicateSlideScene reassigned element ids inline,
  leaving grouped elements pointing at the source slide's groupId.
  Use the existing createElementIdMap so clones get a new shared
  groupId. (Path is gated off today via SCENE_CREATION_ENABLED; fixes
  a latent defect.)
- stage: wrap playback teardown on Pro-mode entry in try/catch and
  release the just-acquired cross-tab lock on failure, so a rejected
  teardown can't strand the lock with the UI stuck in playback.

Adds regression tests for the redo-stale and groupId cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): scope editor list-marker CSS to Pro mode

The .editable-element-text ul/ol/li rules used a bare selector, but
that class is the playback text wrapper rendered for every classroom
user — so the !important list-style overrides leaked into normal
playback. Scope them to body[data-maic-editor='true'] (set only while
Pro mode is mounted) so flag-off playback rendering stays unchanged;
markers still show while editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): smooth Pro mode transition and stabilize header controls

The Pro mode enter/exit animation janked and the right-side header
controls (settings pill + Pro Switch) drifted in width/position across
the swap. Three causes, all addressed:

- The flag-gating dynamic import gated EditShell behind surfaceReady, so
  the chrome animated in empty and content popped in once the slide-surface
  chunk loaded. Preload the editor chunk (fonts + surface registration) in
  the Pro Switch handler BEFORE flipping mode (lib/edit/preload-editor.ts),
  and drop the render gate — content is present when the animation starts.
- Mode-swap layers and EditShell chrome layers used translateY/translateX
  slides; with backdrop-blur on the rail and pills that forced a per-frame
  backdrop-filter recompute (dropped frames) and, as transform ancestors,
  distorted the layoutId measurement. Switched all chrome enter animations
  to pure opacity fades.
- HeaderControls rendered a fragment whose children were spaced by the
  host's flex gap (Header gap-4 vs CommandBar trailing gap-2), so the
  control cluster changed width/anchor between modes. Wrapped it in a
  self-contained gap-4 container and dropped the cross-bar layoutId morph
  so the cluster is pixel-stable across the swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): address review — image insert race, scene migration, i18n staleness

Review feedback from @cosarah on the integration PR:

- Image insert resolved size via Image.onload then applied the op to
  whatever slide session was current at callback time, so switching
  slides before the image loaded inserted it into the wrong slide. Bind
  the insert to the scene active at click time and drop the op if the
  session changed before onload fires.
- Classroom scenes loaded from IndexedDB (loadFromStorage) and from the
  server API (classroom page) bypassed migrateScene, so legacy slide
  content was not normalized with schemaVersion. Both load paths now
  migrate on the way in, matching setScenes/addScene.
- surfaceStateEqual compared insert-item/command id/active/disabled but
  not label/tooltip, so the Pro-mode insert toolbar text stayed stale
  after a language switch. Compare label/tooltip too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:54:04 +08:00
wyuc f064590a5e test+fix(orchestration): rip maxTurns; restore answer-the-user's-question behavior (#598 follow-up) (#599)
* test(orchestration): add premature-END regression eval (#598 follow-up)

Adds eval/orchestration/ following the outline-language pattern: a runner
that A/B-tests the director against the same scenario with two prompt
variants — current main (post-#554) and a synthesised pre-#554 baseline
that strips the role-aware summary labels AND the new system.md rules
10/11/12 together.

Pass criterion is now framed as a regression guard rather than a fixture
discrimination test: every scenario's post-fix END rate must stay below
EVAL_END_THRESHOLD (default 20%). The pre-vs-post Δ is reported as
informational data, since #554's reviewer feedback was that earlier
fixtures didn't discriminate.

Empirical finding across 7 shipped model configs (gpt-4.1-mini,
gpt-4o-mini, gpt-5.4-nano, qwen-plus, qwen3.5-flash, deepseek-chat,
deepseek-v4-flash, gemini-2.5-flash, claude-haiku-4-5) with 5 scenarios
modelled on #511 (incl. the exact Tiananmen-3D objection trace, soft
pushback after a long resolved-looking discussion, topic pivot, brief
acknowledgement, and explicit teacher-signals-end-then-user-objects):
no scenario produced a non-zero END rate in either variant. Useful
data for the #554/#598 discussion — the prompt-layer rules don't
measurably change behavior on these shipped models with these prompts,
but the eval is now wired so future regressions show up.

* test(orchestration): add director question-answering eval (#598 follow-up)

Adds an A/B eval probing whether the director routes correctly when the
conversation contains an unanswered user question — both in the first-turn
drift case (agents started answering but drifted onto adjacent topics,
no frustration yet) and in the escalated frustration case (user has
already complained multiple times).

The prod symptom this targets ("答非所问"): agents reply on adjacent
topics rather than answering the literal question, and the director
keeps picking peer agents for "variety" instead of routing back to
the teacher to actually answer.

Scenarios: 5 synthesized cases across math, biology, English grammar,
physics, and calculus. 3 are first-turn drift (no frustration yet),
2 include frustration signals. Each captures: user asks specific
question, agents reply on adjacent topics, director is asked to pick
the next move.

Decision rule-judged into USER | TEACHER | OTHER_AGENT | END:
  - USER       → ✓ valid (cue user to clarify)
  - TEACHER    → ✓ valid (re-route to teacher to answer)
  - OTHER_AGENT → ✗ wrong (peer-agent "variety" routing — the bug)
  - END        → ✗ wrong

A/B:
  - baseline  : current director template with rule 13 stripped
  - with_rule : current director template as-shipped

Pass = with_rule.correctRate ≥ EVAL_PASS_THRESHOLD (default 0.7).
Pre-vs-post Δ is reported as informational only.

Note this commit only adds the eval; the rule it strips/restores in
the A/B is injected in the next commit.

* fix(orchestration): inject director rule for answering user questions

Adds rule 13 to lib/prompts/templates/director/system.md so the director
recognizes when the conversation contains an unanswered user question
and routes appropriately — to the teacher (literal role match) by
default, or to USER cue when the question is ambiguous or the user
has already complained multiple times.

This addresses the "答非所问" symptom: when agents drift onto adjacent
topics, the director keeps picking peer agents for "variety" rather
than routing back to the teacher to actually answer. The rule is
preventive (applies whenever the question is unanswered, including
the first turn after drift) rather than reactive-only (waiting for
the user to express frustration).

Rule wording uses constrained-output framing: literal `role: teacher`
match (not "best fit" / "highest priority" / "most knowledgeable"),
and an explicit list of forbidden choices (peer agents, END). Earlier
softer wordings let the model pick the assistant when it seemed more
topically relevant; the literal-match wording prevents that.

Validated by the question-answering eval added in the previous commit.
On google:gemini-3-flash-preview, 5 samples per variant across 5
synthesized scenarios:

| Scenario                                           | baseline | with_rule |
|----------------------------------------------------|----------|-----------|
| math_quadratic_axis_drift_no_frustration           | 100%     | 100%      |
| bio_dark_reaction_drift_no_frustration             | 80%      | 100%      |
| english_team_isare_drift_no_frustration            | 20%      | 100%      |
| physics_inertial_mass_escalated_frustration        | 0%       | 100%      |
| calculus_product_rule_drift_no_frustration         | 60%      | 100%      |
| **mean**                                           | **52%**  | **100%**  |

3 of 5 scenarios reproduce the bug at baseline (correct rate ≤ 60%);
the rule lifts every scenario to 100%. The remaining 2 baselines are
already high — the rule doesn't regress them.

* fix(orchestration): teach agents to answer the user's literal question

Adds an "Answering the User's Question" section to the agent system
prompt. Before this, the agent SP had no rule for direct-answering — the
persona, role guideline, current-state context, and length guidelines all
pushed the agent to advance the lesson and inspire thought, with no
explicit directive to first answer the user's literal question.

The result observed in prod ("答非所问"): when the user asks a specific
question (a formula, a yes/no, a term), agents reply with adjacent
concepts, examples, or follow-up questions — but never the literal
answer.

The new section directs the agent, on every response where the user
message contains a question, to:
- lead with the concrete answer to the literal question,
- not pivot to an adjacent topic even when it seems pedagogically
  richer,
- treat "inspire thought" and peer-differentiation guidance as
  applying only AFTER the literal question is answered,
- say "I don't know" directly when uncertain rather than answering
  a different question,
- on frustration signals from prior turns, look back at the original
  question (before the frustration) and answer THAT specifically.

This is the agent-side counterpart to the director rule landed in the
previous commit. Director routes to the teacher when the question is
unanswered; the teacher now has explicit guidance for what to do when
dispatched.

* refactor+fix(orchestration): rip maxTurns end-to-end; distinguish errors from completion

Two coupled cleanups in one commit since the outcome-handler rewrite
naturally falls out of removing the client-side max-turns loop.

The director graph used `maxTurns: turnCount + 1` to force "one
director→agent cycle per request" — a hack expressing the single-round
contract as a turn limit. This commit removes the hack and the
surrounding user-facing setting, replacing implicit intent with explicit
graph topology.

Server:
- `director-graph.ts`: drop `maxTurns` from state, drop the hard-cap
  check at the top of `directorNode`, change `agent_generate → director`
  edge to `agent_generate → END`. Single-round contract is now expressed
  by topology.
- `lib/types/chat.ts`: drop `maxTurns` and `currentTurn` from
  `SessionConfig`; drop `maxTurns?` from `CreateSessionRequest.trigger`.

Client:
- `lib/store/settings.ts`: drop `maxTurns` state, `setMaxTurns` action,
  the `'10'` default, and the localStorage migration path.
- `components/agent/agent-bar.tsx`: drop the entire compact-stepper UI
  block plus the unused MessageSquare/Minus/Plus lucide imports.
- `components/settings/agent-settings.tsx`: drop the AgentSettings prop
  and the multi-agent-only Input block.
- `lib/chat/agent-loop.ts`: drop `maxTurns` parameter, change
  `while (turnCount < maxTurns)` to `while (true)` controlled by inner
  exits, drop `'max_turns'` reason from `AgentLoopOutcome`.
- `components/chat/use-chat-sessions.ts`: drop the maxTurns lookup, drop
  the trailing arg to runAgentLoop, drop vestigial `maxTurns: 0` /
  `currentTurn: 0` from session creation.
- `eval/whiteboard-layout/runner.ts`: drop unused MAX_AGENT_TURNS plus
  the trailing arg.

i18n: drop `settings.maxTurns` and `settings.maxTurnsDesc` from six
locales (en-US, zh-CN, zh-TW, ja-JP, ar-SA, ru-RU).

Motivation for removing the user-facing setting: the LLM director
controls round length via cue_user / END decisions, so a numeric
"max discussion turns" knob is redundant with rule-driven routing.
When the director prompt was buggy, the cap masked the bug by force-
ending; once director routing is fixed, the cap just removes user
agency.

Before, every non-`cue_user` outcome of the agent loop mapped to
`status: 'completed'` in `use-chat-sessions.ts`. That conflated four
distinct paths:
- `end`: director chose END (legitimate completion)
- `empty_turns`: two consecutive agents returned no text or actions
  (parser failure, model timeout, etc.)
- `no_done`: SSE stream ended without a `done` event (infrastructure
  failure)
- `aborted`: user pressed stop (handled elsewhere)

The new switch routes `end` to completed, `empty_turns` and `no_done`
through `clearLiveSessionAfterError` so the user sees an inline System
message describing what went wrong instead of a quiet "completed".

- `lib/types/chat.ts`: add `'error'` to `SessionStatus`.
- `components/chat/use-chat-sessions.ts`: rewrite the outcome handler
  as a switch; have `clearLiveSessionAfterError` set `status: 'error'`
  so all entry points (the runAgentLoop outcome AND existing throw
  paths from fetchChat / SSE error events) converge on the same
  visible state.
- `components/chat/session-list.tsx`: add `AlertCircle` red icon for
  the new error status.
- i18n: add `chat.error.emptyAgentResponses` and
  `chat.error.streamInterrupted` strings to all six locales.

This is the in-product fix that the prod investigation surfaced as
an independent root cause of the #511 "discussion ended unexpectedly"
symptom — separate from the director and agent prompt fixes in the
earlier commits of this PR.

* fix(chat): drop dead settingsState binding; wire eval:orchestration:answering script

Two minor CR follow-ups:

- components/chat/use-chat-sessions.ts: the `settingsState` lookup at the
  top of runAgentLoopFn was the entry point for the maxTurns lookup
  ripped in the previous commit; with maxTurns gone, the binding is
  unused and ESLint flags it. Drop the line. (Other uses of
  useSettingsStore.getState() elsewhere in this file are unaffected.)

- package.json: the answering eval runner had no convenience script,
  while the premature-end runner did. Add eval:orchestration:answering
  for symmetry and discoverability.

* fix(orchestration): route ambiguous/unaddressed user turns to teacher, never USER cue

Addresses reviewer issues 2 & 3 (ambiguous "帮我看一下这个" / "Can you take a
look at this?"): the director was cueing USER for vague input, which makes no
agent speak — the user faces dead air with nothing to react to ("nobody
responds"); and when an agent did speak it guessed a topic instead of asking
what the user meant.

Root cause was the previous rule 13, which offered `{"next_agent":"USER"}` as
a valid response to an ambiguous user turn. A USER cue is the wrong tool here:
it returns control to the user without any agent speaking. The right behavior
is to dispatch the teacher, who either answers or — if the message is too
vague — asks a clarifying question out loud.

Director (`director/system.md` rule 13):
- When the most recent user turn is unaddressed (clear question, vague
  request, OR frustration signal), the only acceptable output is the literal
  `role: teacher` agent id.
- USER cue is now explicitly forbidden as a response to a user turn (reserved
  for rule 7, where an agent has just asked the user something).
- Tightened the END prohibition: a high turn count or a thoroughly-discussed
  topic does NOT mean the specific question was answered. Added rule 5
  (don't-drag-on) to the override list, since that pressure was driving the
  director to END on well-discussed-but-unanswered turns.

Agent (`agent-system/system.md`):
- Added a clause to "Answering the User's Question": if the user's message is
  too vague to answer (no clear referent), ask ONE short clarifying question
  with a concrete option or two — do not guess a topic and lecture, do not
  stay silent.

Eval (`answering-runner.ts` + `scenarios/answering.json`):
- Reclassified USER as wrong (dead air); only TEACHER counts as correct now
  (teacher answers, or asks a clarifying question for vague input).
- Added two ambiguity scenarios: a bare "帮我看一下这个" opener, and a vague
  "再帮我看看这个吧" follow-up after a real Q&A.
- Made the rule-13 strip regex match by rule number rather than heading text,
  so it survives heading rewrites.

On google:gemini-3-flash-preview, 10 samples per variant, all 7 scenarios
PASS (with_rule mean ~97%, baseline ~57%). The two ambiguity scenarios:
ambiguous_look_at_this 100%/100%, ambiguous_vague_followup 20%→100%.
Manually verified the agent side: for "帮我看一下这个" the teacher now replies
"…你想讨论的是具体的代码逻辑,还是 Agent 的决策流程图呢?" (clarifying question +
concrete options) instead of guessing.

Note: reviewer issue 1 (follow-up not answered) could not be reproduced on
this branch across 7 configurations; it is consistent with the deployed
environment running older code and/or provider API flakiness, which the C7
error-vs-completion distinction in this PR already surfaces instead of a
silent 已结束.

* docs(eval): fix stale physics scenario description after USER→wrong reclassification

The physics_inertial_mass scenario description still said "USER-cue signal —
but TEACHER re-answer is also valid", which contradicts the new classifier
(USER is dead air = wrong; only TEACHER is correct). Cosmetic only — the
description field is printed into the report, never used in classification.
2026-05-29 10:52:30 +08:00
wyuc a4cbed4b43 release: v0.2.1 2026-04-26 16:09:24 +00:00
wyucand杨慎 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>
2026-04-26 19:41:10 +08:00
wyuc 85c7038ab3 release: v0.2.0 2026-04-19 23:53:57 +08:00
7d47b0ad3a refactor(eval): unify outline-language and whiteboard-layout harness (#453)
* feat(eval): add resolveEvalModel helper with fail-fast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): add createRunDir helper with path sanitization

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): add markdown report helpers

* refactor(eval): move language test cases under eval/outline-language

* feat(eval): add outline-language types

* feat(eval): add outline-language LLM judge

* feat(eval): add outline-language reporter

* feat(eval): add outline-language runner entry

* chore(eval): add eval:outline-language pnpm script

* refactor(eval): adopt shared createRunDir and drop gpt-4o fallbacks in whiteboard runner

* refactor(eval): drop redundant non-null assertions after narrowing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(eval): remove SCORER_MODEL_DEFAULT hardcoded gpt-4o fallback

* chore(eval): delete tests/generation and clean up vitest/gitignore config

* docs(eval): explain why outline-language runner pre-validates env vars

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(eval): escape pipe chars in markdown summary table cells

LLM judge output may contain | which breaks GFM table rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-04-19 21:29:18 +08:00
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>
2026-04-18 15:29:54 +08:00
Madoka a47b2d2fb1 Feat/439 i18n key alignment check (#447)
* feat(ci): add i18n key alignment check

* fix(ci): harden i18n key alignment script
2026-04-18 13:31:55 +08:00
wyuc 77f64f567e release: v0.1.1 2026-04-14 21:15:37 +08:00
杨慎andClaude Opus 4.6 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 f7bd6bd and 5bb5133 — these are independent features that
should go into a separate branch/PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(i18n): regenerate locale JSON after merging latest main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(i18n): remove obsolete TS translation files

These files are replaced by lib/i18n/locales/*.json and are no longer
imported anywhere in the codebase.

Removed: chat.ts, common.ts, generation.ts, settings.ts, stage.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(i18n): fix prettier formatting in locale JSON files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(i18n): replace hardcoded language options with central locale registry

Add lib/i18n/locales.ts as a single source of truth for supported languages.
Language selectors in header and homepage now render dynamically from this
registry. Also adds supportedLngs to i18next config so unsupported languages
fall back correctly. Adding a new language now only requires a JSON file and
one line in the registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add missing rename keys to JSON locale files

Add classroom.rename, classroom.renamePlaceholder, and
classroom.renameFailed that were added to generation.ts on main
but missing from the JSON locale files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(i18n): extract LanguageSwitcher, restore type safety, fix locale resolution

- Extract shared <LanguageSwitcher /> component from page.tsx and header.tsx
- Derive Locale type from supportedLocales registry (as const satisfies)
- Add resolveLocale() to match browser language prefixes (e.g. 'en' → 'en-US')
- Remove config.ts changes (nonExplicitSupportedLngs + inline resources) that
  broke translation loading when combined with resourcesToBackend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): use i18next double-brace interpolation instead of manual .replace()

- Convert all {var} placeholders in locale JSONs to {{var}} (i18next default)
- Replace .replace('{var}', value) calls with t(key, {var: value}) in all
  consuming components: lecture-notes-view, pbl-renderer, use-pbl-chat,
  generation-preview, whiteboard-history, agent-settings
- Widen t() type signature in handleIssueComplete to accept interpolation options

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(i18n): unify greeting with default nickname, add translation guide

- Remove greetingDefault key; greeting always uses greetingWithName
  with displayName (falls back to profile.defaultNickname)
- Change en-US defaultNickname from "Student" to "Learner"
- Add TRANSLATION_GUIDE.md documenting how to add languages and
  explaining keys with non-obvious UX design intent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:04:42 +08:00
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>
2026-03-24 12:19:59 +08:00
wyucandClaude Opus 4.6 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>
2026-03-23 16:40:26 +08:00
1/2极客andhumingfeng 0a9b5abe34 fix(build):Next.js 16 要求 Node.js >= 20.9.0 (#21)
* fix(build):
1、README.md / README-zh.md — Node.js 版本要求 >= 18 → >= 20
2、package.json — 新增 engines: { node: ">=20.9.0" }
3、ci.yml — node-version: 20 → 22(与 Dockerfile 一致)
4、.nvmrc — 新建,内容 22

Co-authored-by: humingfeng <humfsss@gmail.com>
2026-03-17 09:01:04 +08:00
wyucandClaude Opus 4.6 3c75b88cc1 chore: switch license from MIT to AGPL-3.0 and expand READMEs
Replace MIT license with AGPL-3.0 for compatibility with PPTist-derived
code. Add commercial licensing section, project structure diagram, and
key architecture pointers to both English and Chinese READMEs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:30:01 +08:00
wyucandClaude Opus 4.6 8047bcc241 fix: correct env var names in READMEs and add license field to package.json
- GOOGLE_GENERATIVE_AI_API_KEY → GOOGLE_API_KEY (EN/ZH README)
- MINERU_API_URL → PDF_MINERU_BASE_URL (ZH README)
- Add "license": "MIT" to package.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:40:19 +08:00
wyuc 5cb426780a Merge remote-tracking branch 'origin/main' 2026-03-12 21:24:32 +08:00
yangshenandClaude Opus 4.6 e92c0fd2b3 fix: use pnpm workspace protocol for local packages to fix Turbopack build
Turbopack cannot resolve file: protocol dependencies. Switch mathml2omml
and pptxgenjs to workspace:* and add pnpm-workspace.yaml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:25:25 +08:00
wyucandClaude Opus 4.6 cc44f32ca8 Refactor: directory structure cleanup + Google Fonts localization
- Replace next/font/google with local fonts (geist + @fontsource-variable/inter)
  to eliminate build-time network dependency
- Resolve lib/utils.ts vs lib/utils/ naming conflict (cn() → lib/utils/cn.ts)
- Move React Context Providers from lib/store/ to lib/contexts/
- Consolidate hooks into lib/hooks/ (use-theme, use-i18n)
- Organize loose components into subdirectories:
  agent-* → components/agent/
  canvas-* → components/canvas/
  generation-toolbar, media-popover → components/generation/
  speech-button, tts-config-popover → components/audio/
  proactive-card → components/chat/

65 files changed, lint 0 errors, build 28 pages passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:35:06 +08:00
wyucandClaude Opus 4.6 0d20abfed9 Initial commit: OpenMAIC AI classroom platform
Next.js 16 App Router application with:
- AI-powered interactive classroom generation from PDF/text requirements
- Multi-agent discussion system (teacher, students, assistant roles)
- Provider abstraction for LLM, TTS, ASR, image, video, web search
- Local-first data architecture (IndexedDB/Dexie)
- i18n support (zh-CN/en-US)
- Structured logger, SSRF guard, lint/build passing (0 errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:41:07 +08:00