Commit Graph
68 Commits
Author SHA1 Message Date
ceb05adb41 feat(media): allocate generated assets through the registry (#1007 part 2, step b) (#1039)
* feat(media): establish shared asset ownership primitives

Introduce global browser asset-pool ownership, asset-reference collection, stage reclamation planning, and lease-based URL access. Carry allocated media identity through storage and generation boundaries with regression coverage.

* fix(media): enforce safe resolution across every consumer

Route image, video, thumbnail, presentation, and video-export consumers through one resolution state machine. Prevent opaque allocated or generated references from reaching render and export sinks, with fallback and ownership tests.

* fix(media): protect document-owned assets across mutations

Allocate pool bytes before compatibility writes and document commits, then roll back uncommitted generations safely. Preserve document ownership across edits, retries, imports, speech generation, scene changes, and stage deletion.

* fix(media): scope retries and tighten ownership guard

Scope retries to the target scene and slide and refuse ambiguous shared-reference mutations. Expand retry rendering coverage and keep direct pool URL resolution behind the shared lease owner.

* fix(storage): avoid nested lock during stage cleanup

Execute prepared reclamation plans against an explicitly deleted document so the compatibility cascade cannot re-enter the per-document lock. Cover deletion of stage-owned media rows even when the document has no references.

* refactor(media): confine reclamation to stage deletion

Remove inline pool and compatibility-row cleanup from element, speech, scene, and audio replacement flows. Keep whole-stage reclamation behind explicit stage ownership, preserve stage-less legacy audio rows, and document deferred document-truth sweeping.

* fix(media): close retry and resolution gaps

Restore shared source tasks after successful forks, scope retries across both whiteboard locations, and wait for parallel TTS workers before rollback. Resolve background media through import, rendering, and PPTX export paths while keeping retry controls visible over last-good bytes.

* test(media): execute the consumer safety matrix

Replace source-substring checks with resolver seam execution across all six UI consumers, stage hydration, and both exporters. Pin each rollback layer independently and harden the ownership guard against aliased pool imports.

* chore(packages): publish the additive DSL field

Bump the DSL patch version for the optional speech-action field. Keep the transitional reclamation policy app-owned and document the legitimate transaction rollback removals there.

* fix(media): make retry rollback task-safe

Delay allocation task re-keying until final document reconciliation succeeds, restore shared source tasks on failed forks, and require exact stage-whiteboard targets before falling through from a missed scene.

* fix(media): clear private assets and refresh leases

Delete the asset-pool database during the confirmed local-data wipe. Notify the app lease layer after same-id replacement so mounted consumers re-resolve current bytes without reaching into storage internals.

* test(media): pin closure safety guards

Exercise the CSS allocation boundary, unfiltered legacy-row ownership, delete-and-undo byte survival, and real distinct consumer seams. Restore the video-only manifest overwrite condition and share the direct video resolution hook across both element variants.

* test(media): narrow rollback element assertion

Narrow the reconciled slide element to an image before checking its source so the rollback regression remains type-safe under the full root compiler configuration.

* fix(media): close lease refresh races

Gate the first batch publication by unique resolved refs, then publish every replacement snapshot without mutating the prior React state object. Serialize invalidation behind pending releases, evict rejected refreshes, and register replacement observation at the pool boundary.

* fix(media): reopen pool after clear failures

Always evict the singleton once its store has been closed, including blocked and failed database deletion paths. Report blocked deletion as deferred and prove a later write uses a fresh live store.

* fix(media): isolate shared retry progress

Track forked regeneration under the selected element until it receives a fresh asset identity, leaving the shared source task and bytes untouched. Surface targeted failures through renderer task lookup and skip the redundant fork reconciliation lock.

* fix(media): close final asset retry gaps

Keep blocked asset clears fail-loud until a successful retry, with actionable settings guidance.

Clear failed shared-fork state across durable and live key spaces, and pin renderer lookups, lease publication identity, and committed-ref rollback protection.

* fix: preserve actionable retry failures

Localize the blocked cache-clear recovery hint across every supported locale and pin the deferred-error mapping.

Retain durable fork failure rows while retries run, deleting them only after successful generation so unstructured failures survive reload.

* fix(media): hydrate legacy stored video thumbnails

Home-page recent-video thumbnails regressed for legacy Dexie mediaFiles
rows keyed by gen_vid placeholders: the reworked hydration resolved the
row's bytes through the sealed resolver but never surfaced the stored
blob (and its poster) as object URLs for the preview card, so the CI
recent-video-thumbnail e2e specs found no visible element.

Hydration now materializes legacy stored video rows into blob URLs for
both the element src and poster while keeping the resolver invariants:
opaque refs still never reach a DOM src, and concrete addresses are
never blanked. Unit pins cover the seam so the vitest suite catches this
class without a browser.

* fix(media): resolve sole restored legacy video

Classroom playback restored tasks by exact document media references. Legacy gen_vid references can outlive the key used by the one persisted video row, leaving the player on a placeholder even though bytes were restored.

Select the sole completed stage video only for legacy sequential refs after exact and reconciled matches. Keep exact failures authoritative, refuse ambiguous candidates, and cover success, ambiguity, and failure precedence in unit tests.

* fix(media): scope legacy video recovery

Decide restored legacy video recovery once from the complete document and record it through the shared task lookup consumed by playback, editing, and resolved slides.

Keep ambiguous documents as placeholders, apply the same decision to thumbnail hydration, and preserve exact failure precedence.

* fix(media): exclude claimed video recovery tasks

Model restored legacy recovery as a two-pass match across document video elements and task rows. Remove tasks claimed by exact, targeted, or placeholder lookup before applying the sole-candidate fallback, and cover the ownership/cardinality matrix.

* fix(media): unify video element resolution

Centralize source, task, poster, and legacy recovery decisions for every video consumer. Ensure direct URLs win over opaque refs and play_video waits on element-targeted retry tasks.

* refactor(media): route legacy recovery through resolver

Let document-aware consumers request legacy video recovery through the unified element binding API, keeping thumbnail hydration on a single decision path.

* fix(media): preserve import refs and prefer pool bytes

Recognize unambiguous extensionless relative media addresses during classroom import. Resolve allocated export and thumbnail assets from the shared pool before falling back to lagging compatibility rows.

* fix(export): preserve concrete video sources

Route PPTX video elements through the shared media binding resolver so an unresolved opaque reference cannot replace a playable source. Complete browser and persisted-store cleanup when asset-pool deletion is deferred, while retaining distinct hard-failure behavior.

* fix(media): fork retries without exclusive ownership

Enumerate logical asset owners across every persisted document before allowing global pool replacement. Thread explicit targets through fresh-id rewrites and cover cross-document aliases plus unreadable ownership.

* fix(media): guard global asset reclamation

Share a fail-closed persisted-document liveness check between stage deletion and retry replacement. Preserve cross-document pool aliases while deleting stage-owned compatibility rows and cover enumeration failures.

* fix(media): cover complete slide asset references

Route slide media traversal through a shared mutable slot contract so backgrounds participate in export, thumbnail hydration, collection, and rewrite lifecycles. Snapshot complete surviving-document refs once per reclamation and preserve manifest-only owners while retaining fail-closed behavior.

* fix(media): preserve exclusive retry asset ids

Allow targeted retries to replace exclusively owned pool assets in place. Keep shared and unprovable ownership paths on fresh allocations, and pin production-shaped retries plus compatibility-row cleanup.

* fix(media): revalidate asset bindings at completion

Recheck repository-wide ownership before replacing generated media and fork scoped retries when exclusivity changed. Route video export selection through the unified resolver and keep concrete posters independent of task state.

* fix(media): count unflushed owners and broadcast replacements

The completion-time exclusivity proof read only the persisted document, but
slide duplication updates the Zustand aggregate synchronously and schedules
persistence behind a debounce. A retry finishing inside that window saw a
single persisted owner and replaced the bytes behind a reference the
duplicate also held. The proof now also counts owners in the live stage
snapshot when that snapshot represents the stage being retried, so an
unflushed duplicate forks instead.

Same-id replacement notifications were realm-local, so a second tab showing
the same classroom kept its lease pinned to the superseded blob URL. The
notification now travels over a BroadcastChannel; each receiving realm runs
its own observers against its own pool, so a spoofed message can at most
force a re-resolve. A missing or failing channel never fails the replacement.

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

* fix(media): bind replacement listeners and spare shared audio rows

A realm that only renders never sends a replacement, so binding the channel
from the sender path left passive tabs deaf to peers. Binding now happens
where the observer is registered, when the asset-pool module loads, and the
receiving realm resolves its own pool lazily so a cleared or unavailable
pool degrades to the next resolve instead of throwing.

Observer notifications ran under Promise.all, so a rejection surfaced after
BrowserAssetStore.replace had already committed and turned a durable success
into a reported failure. They are settled individually now; the callback is
wrapped because a synchronous throw would otherwise escape before allSettled
sees the array.

audioFiles rows are keyed globally by audioId, so deleting a stage removed
the sole row for an id a surviving document still referenced — playback and
both export paths read that table directly and cannot fall back to the
preserved pool blob. Rows are now filtered against surviving references,
while a failed enumeration still withholds only the irreversible pool
removal.

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

* fix(media): serve replaced bytes from the pool everywhere

Unknown survivor liveness deleted every planned audioFiles row. Those rows
are keyed globally by audioId, and playback plus both export paths read the
table directly, so losing one is as irreversible for them as removing the
pool entry. Unknown liveness now preserves the rows too, leaving bounded
garbage for a later pass that can prove exclusivity.

The earlier pool-first change covered PPTX, video collection and thumbnail
hydration but missed classroom ZIP export, which still serialized the stale
compatibility row after a lagged same-id replacement, shipping media the
classroom no longer renders.

Auditing every direct reader of the media and audio tables surfaced the same
gap in playback: speech regeneration also replaces bytes under a stable id
and does not roll the pool back when the compatibility write fails, so the
player kept serving superseded narration. It now resolves the pool first and
falls back to stored rows for legacy and imported audio.

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

* fix(audio): replace exclusively owned speech clips in place

regenerateSpeechAudio accepted the action's audioId but always passed
undefined as replaceAssetId, so regenerating an exclusively owned
pool-backed clip allocated a new asset every time, rewrote the action and
orphaned the previous pool entry and compatibility row until stage
reclamation — contradicting the stable-id path generateAndStoreTTS already
implements for media.

Ownership is now established before synthesis, and the rule itself moved to
the shared reference module so media retries, poster replacement and speech
regeneration consume one implementation instead of restating it. An
exclusively owned clip keeps its id and has its bytes replaced; a shared
clip, a legacy id with no pool entry, or unprovable ownership still gets a
fresh allocation so other holders keep their audio.

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

* chore(media): drop the import left behind by the ownership move

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

* fix(media): resolve audio pool-first and fence peer realms

Stable-id TTS regeneration commits replaced narration to the pool before the
audioFiles mirror write, so a failed mirror leaves the row stale. AudioPlayer
already resolved pool-first, but audioObjectUrl, collectAudioFiles and the
video timeline dependencies still read Dexie directly and would serve the
superseded clip. All allocated-audio readers now share one resolver, with
Dexie kept as the fallback for legacy and imported rows.

The exclusivity proof modelled unflushed owners in the active realm only, so
another tab duplicating the same asset during its save debounce could still
be observed as a single owner and have its bytes replaced globally. A peer's
pending state cannot be read across realms, so presence is probed instead:
any realm holding the stage forces the fork path.

A deferred asset-pool deletion no longer reloads the page. The database is
still on disk and the guidance asks the user to close the other tab and
retry, which the reload discarded. The decision moved into a shared helper so
the rule is pinned rather than living inline in the component.

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

* fix(media): fail closed when presence cannot be probed

The presence helper documented that an unanswerable probe must count as a
peer, but every unavailable path — no BroadcastChannel, a constructor that
threw, a send that failed, and the window before the pool's asynchronous
binding completes — returned false, so the ownership proof cleared a single
local owner and replaced globally shared bytes in place.

Probing now returns present, absent or unknown, and only a probe that was
actually sent and went unanswered is absent; the ownership decision treats
unknown exactly like present. The pool declares its binding intent
synchronously so a probe issued during the load-time window waits for the
bind instead of concluding that presence is unavailable, and releases that
gate if the import fails.

Coverage reaches the write boundary: with presence unknown, a
production-shaped targeted retry forks to a fresh id instead of calling
replace, and the original bytes stay intact for a peer's unflushed owner.

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

* chore(dsl): bump to 0.6.3 after the release dedupe took 0.6.2

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

* fix(i18n): add the deferred-clear guidance to fr-FR

The locale landed on main after this branch added the key, so the alignment
check flagged it as the one missing translation.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-08-06 18:49:34 +08:00
Percyandwyuc d895b8959e feat(classroom): route generate-classroom per stage instead of one model (#1048)
The one-shot generateClassroom path (skill API) resolved the model once
with `resolveModel({ stage: 'generate-classroom' })` and bound that
single `languageModel` into one `sceneAiCall` closure shared by every
scene-content and scene-actions call (and the classroom `aiCall` shared
by outline + agent profiles). As a result, none of the `MODEL_ROUTES`
entries for `scene-content`, `scene-content:<type>`
(slide/quiz/interactive/pbl), `scene-actions`, or `agent-profiles` had
any effect on this path — the browser UI already routes each stage
independently via the /api/generate/* endpoints, but the one-shot API
did not.

Resolve the model per stage instead, mirroring the browser UI:

- scene-content routes via the composite key `scene-content:<type>`,
  falling back to the base `scene-content` route (same resolution as
  /api/generate/scene-content). Interactive widget sub-types are not
  split, only the four core scene types.
- scene-actions routes via the `scene-actions` stage.
- agent-profiles routes via the `agent-profiles` stage (matches
  /api/generate/agent-profiles).
- outline generation keeps using the classroom model, unchanged.

Each stage is resolved lazily and only when a route is actually
configured, so unrouted deployments reuse the classroom model at zero
extra cost. A route that fails to resolve (e.g. an unknown provider)
degrades to the classroom model with a warning, never aborting
generation — the same handling already used for
`web-search-query-rewrite`.

This also fixes a latent bug on this path: PBL scene content generation
needs the resolved `LanguageModel` object (generatePBLSceneContent
drives its own LLM calls through it, not the aiCall closure), but the
call never passed it, so PBL scenes silently returned null and were
skipped. The browser UI already passes it; now the one-shot path does
too — and it also forwards the route's thinking config, so a
`scene-content:pbl` route with a `thinking` block is honored (not just
the model).

The `generate-classroom-scene` telemetry/billing label is preserved on
all scene calls to avoid changing existing logging and accounting;
agent-profiles keeps the `generate-classroom` label.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-05 06:51:22 -04:00
zhifu gaoandLauraGPT 2b3f2eaa37 feat(asr): add local FunASR provider (#1044)
Signed-off-by: LauraGPT <199134975+LauraGPT@users.noreply.github.com>
Co-authored-by: LauraGPT <199134975+LauraGPT@users.noreply.github.com>
2026-08-05 03:03:56 -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
12ccd315ea Add Atlas Cloud LLM provider (#948)
* Add Atlas Cloud provider

* Account for Atlas Cloud non-thinking model metadata

* test: verify Atlas Cloud thinking payloads

* style: format Atlas Cloud payload test

* test: type Atlas Cloud fetch mock

---------

Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-03 08:51:18 -04:00
wyucandClaude Opus 5 97f8585728 refactor(pbl-v2): route runtime agents through the shared LLM entry point (#1006)
* refactor(pbl-v2): route runtime agents through the shared LLM entry point

The five PBL v2 runtime call sites (instructor x2, evaluator, simulator x2)
invoked the AI SDK directly while the other 21 call sites in lib/ and app/ go
through callLLM / streamLLM, so they silently opted out of everything the shared
entry point applies:

- Usage accounting. recordUsage is reached only from the wrappers, so no
  instructor turn, evaluator pass, simulator line or narrator pass was ever
  accounted for - the highest-frequency, longest-context LLM traffic in the
  product. The comment at llm.ts:275, that every server-side call funnels
  through the wrappers, did not hold.
- The LLM_THINKING_DISABLED kill switch, read inside the wrappers only.
- One thinking resolution path. runtime-thinking.ts existed precisely because
  the agents bypassed the wrapper, and it was wired into instructor (x2) and
  evaluator but neither simulator site.

Also fix callLLM recording result.usage, which on a multi-step tool run
(stopWhen) is the final step alone; totalUsage is the aggregate, and the
instructor turn is exactly such a run. streamLLM already preferred the
aggregate.

Thinking semantics are preserved per call site: the teaching turns keep
force-disabling thinking, now through the wrapper's own `thinking` argument,
and keep their existing providerOptions spread, which injectProviderOptions
yields to; the simulator keeps honouring a per-request config, as before. The
one behavioural difference is on native adapters when no thinking config
arrives: the policy now resolves to an explicit lowest/disabled provider option
instead of leaving the model's default in place - which is what the policy
always claimed to do, and what OpenAI-compatible providers already got.
Unifying the wider divergence is a product decision and stays out of scope;
runtime-thinking.ts now documents it, along with the fact that the original
tool_choice justification no longer matches the tree.

A lint guard restricts importing generateText / streamText to lib/ai/llm.ts so
the split cannot grow back. It uses the @typescript-eslint variant of
no-restricted-imports deliberately: flat config replaces a rule's options per
key rather than merging them, and the base rule is already configured for the
lib/choreography and lib/video-export module boundaries.

The runtime-thinking test moves from asserting the retired helper to asserting
what the call site is responsible for, at the provider boundary: an evaluator
turn reaches the provider with a disabled config in the thinking store, does
not leak it, and is accounted for under its own usage source.

Closes #1003

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

* fix(pbl-v2): address cross-review findings on the shared-entry migration

Consensus findings from an independent cross-vendor review of cc48ada.

callLLM recorded usage only after validation passed, so with `retries > 0`
every attempt that failed validation went unaccounted, and a result handed back
after the retries were exhausted recorded nothing at all. Each attempt that
reaches that point was billed, so record before validating. Latent today (no
caller passes retryOptions) but it sits in the function this PR already touches.

The simulator's character line now hands its incoming thinking config to
streamLLM instead of resolving providerOptions by hand. The hand-rolled call
covered native adapters only, so a stage-route thinking config was silently
dropped on OpenAI-compatible providers - the wrapper also seeds the thinking
context that their fetch wrapper reads. Native adapters resolve identically:
both paths end in the same normalizeProviderId + buildThinkingProviderOptions.

runtime-thinking.ts documents two things the previous docstring implied away.
On native adapters the constant is a no-op whenever a thinking config does
arrive, because the teaching turns pass their own providerOptions and
injectProviderOptions yields to a caller-set value. And "disabled" is resolved
against each model's catalogued capability, which for some models is not off:
measured against the current catalog, gpt-5.4-pro resolves to effort medium,
gemini-2.5-pro to thinkingBudget -1 (dynamic thinking) and claude-fable-5 to
adaptive/low. Those match the model's own default today, so behaviour is
unchanged, but the policy is weaker than its name suggests.

Tests cover both accounting paths (a retried attempt and an all-attempts-failed
run each record every attempt), and the defensive totalUsage fallback is now
labelled as defensive - ai@6 always reports the aggregate.

The lint guard's scope comment also notes namespace imports, which importNames
cannot see.

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

* fix(pbl-v2): correct the thinking-policy rationale; stop tests writing the real usage log

Two things, both prompted by probing the live DeepSeek API with the instructing
turn's exact shape rather than reasoning from the tree.

The docstring in runtime-thinking.ts claimed the policy's original justification
had expired, on the grounds that `begin_turn` and the forced `tool_choice` it
used are gone. That reads as an invitation to delete the force-disable, and it
is wrong. Measured against DeepSeek V4 Pro:

  thinking on + tools + stopWhen  -> streams fine, 66 reasoning tokens
  thinking on + forced toolChoice -> still 400, "Thinking mode does not
                                     support this tool_choice"

The incompatibility is bound to a FORCED tool choice, not to tools in general.
Today's turns do not reach it, but anyone who reintroduces a forced tool choice
will, so the policy is a live guard rather than dead weight. It also buys
latency: the same probe reached its first token in 1.4s with thinking off
against 3.0s with DeepSeek's default thinking on, and the teaching turns gain
nothing from thinking while the instructor is the chattiest surface in the
product. The docstring now records the measurements instead of the guess.

Separately: a test run could append to the app's real usage log. Any test that
exercises callLLM / streamLLM reaches `recordUsage` through `recordUsageSafe`
without asking for it, so rows sourced `minimax-auth-test`,
`minimax-thinking-test`, `minimax-fixed-thinking-test` and `serialization-test`
were sitting in the live `data/usage/` file next to production traffic
(tests/ai/minimax-provider.test.ts:58,101,146 and
tests/ai/anthropic-serialization.test.ts:38 — neither predates this PR).
`recordUsage` now returns early under vitest unless the caller asked for a
specific `baseDir`, which is the fix at the boundary rather than in the two
tests that happened to trip it. Storage tests pass an explicit `baseDir` and
tests that assert accounting mock the module, so both keep working; verified by
running all three files and confirming the live file's line count is unchanged.

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

* refactor(pbl-v2): drop the hardcoded thinking policy, finishing the unification

The point of this PR is that the PBL v2 runtime reaches the model the same way
everything else does. Keeping a hardcoded `PBL_V2_TEACHING_THINKING` override
inside the agents worked against that: the teaching turns still resolved
thinking through a second, private path, and it produced the awkward edges this
PR had to disclose — an override that silently loses to a caller-set
`providerOptions` on native adapters, and a "disabled" that resolves to
`effort: medium` or `thinkingBudget: -1` (dynamic thinking) on models whose
catalogued capability has no off value.

So the policy is gone. All five sites now pass the incoming request / stage-route
config straight to `streamLLM` / `callLLM`, which is the one place that resolves
provider options and seeds the thinking context. `runtime-thinking.ts` is
deleted, and the hand-rolled `resolveThinkingProviderOptions` calls with it.

What justified keeping it, and why that was not enough:

- The DeepSeek 400 is real — probed live, thinking on + a FORCED `toolChoice`
  still fails with "Thinking mode does not support this tool_choice" — but no
  call site in the tree forces a tool choice, so the policy was guarding a
  scenario that does not exist, at the cost of the entry-point unification. The
  hazard is now a comment where a forced tool choice would be introduced, which
  is where a future author will actually look.
- Thinking costs latency on the teaching turns (~1.4s to ~3.0s first token on a
  thinking-by-default model, measured on DeepSeek V4 Pro). That is a real cost
  but a deployment preference, and it belongs in the `pbl-v2-runtime` stage
  route, not hardcoded in the agent where it overrides whatever that route says.
  Noted at the instructor call site with the numbers.

Behaviour change to be aware of: on OpenAI-compatible providers the teaching
turns no longer force thinking off, so a thinking-by-default model now thinks
unless the route says otherwise — slower first token and reasoning tokens on the
bill. Pin `thinking` off on the stage route to keep the old behaviour.

The runtime-thinking test becomes runtime-llm-entry and asserts the new
invariant at the provider boundary: an incoming config arrives intact, NO config
means no config (a re-added constant would fail this), the global kill switch
still applies, nothing leaks, and the turn is accounted for.

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

* fix(pbl-v2): forward the resolved thinking config to the simulator narrator

Review finding from @cosarah on #1006, and a correct one: the director-narrator
pass was the last site still dropping the resolved config. Both callers passed
`thinkingConfig` into `runDirectorNarratorPass`, but the helper never destructured
it and never handed it to `callLLM`, so the provider silently fell back to the
model default instead of honouring the `pbl-v2-runtime:simulator` route.

Earlier revisions of this PR documented that as pre-existing and deliberately
untouched. That defence stopped holding once the hardcoded thinking policy was
deleted: the PR's invariant became "every PBL v2 site resolves thinking through
the shared entry point, and the deployment decides", and the narrator was the one
site contradicting it.

Coverage per the review: the simulator turn is now driven end to end against a
scenario fixture, asserting that BOTH provider calls — the streamed character
line and the non-streamed narrator pass — see the incoming config, that neither
substitutes one when the request carries none, and that both are accounted for
under their own usage sources. The `['generate','stream']` assertion keeps the
test from silently covering only one of the two calls.

Fault-injected to confirm it bites: dropping the new fourth argument again turns
"forwards an incoming config to the character line AND the narrator" red.

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

* fix(lint): close the dynamic-import bypass on the LLM entry-point guard

Second review pass from @cosarah on #1006. He is right that the guard was not
airtight, though the picture turned out narrower than described — I checked each
form against eslint instead of reasoning about it:

  import { streamText } from 'ai'   already blocked
  import * as ai from 'ai'          already blocked (ESLint reports a namespace
                                    import when importNames is set)
  require('ai')                     already blocked, by the repo-wide
                                    @typescript-eslint/no-require-imports
  await import('ai')                NOT blocked  <- the real hole
  await import(`ai`)                NOT blocked

So the dynamic form is now covered by a no-restricted-syntax block, in both the
literal and template-literal shapes. That rule key is configured by five earlier
blocks and flat config replaces options per key rather than merging them, so the
new block explicitly ignores those directories rather than silently dropping
their module boundaries. It costs nothing: lib/choreography and lib/video-export
already allowlist their import sources (importing 'ai' there is an error today,
and video-export bans ImportExpression and require outright), and the two
@openmaic packages are built in isolation against @openmaic/dsl with the SDK
nowhere in their dependency graph.

The comment above the guard claimed namespace imports were out of scope because
importNames cannot see them. That was wrong; it now lists what each form is
caught by, and notes that an eslint-disable still defeats all of it — which is
the point, since the bypass then has to be written down where a reviewer sees it.

Verified with a probe file using all five shapes: five errors, one per line.
Injecting `import 'react'` plus an `'ai'` import into lib/choreography still
fails on both pre-existing boundary rules, confirming no clobbering.

Also updates the PR description, which still described the narrator as ignoring
its thinking config — stale since 9bbb720.

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

* fix(lint): cover the @openmaic packages with the dynamic-import ban

Third review pass from @cosarah on #1006, and he is right again: the guard
ignored packages/@openmaic/renderer and packages/@openmaic/storage, and
`void import('ai')` under the renderer source path passed lint. Reproduced before
fixing.

The previous revision justified those two exclusions by arguing the packages are
built in isolation against @openmaic/dsl with the SDK nowhere in their dependency
graph. That is an argument about why a bypass would not matter, not a rule that
stops one — exactly the kind of reasoning this PR exists to replace with
enforcement.

The two selectors are now a shared AI_SDK_DYNAMIC_IMPORT_BAN spread into both
package blocks, rather than dropping those directories from the repo-wide block's
ignores: they configure no-restricted-syntax for their own `@/` boundary, and
flat config replaces rule options per key, so matching their files would have
silently deleted that boundary.

lib/choreography and lib/video-export stay ignored and stay covered — their
blocks ban EVERY ImportExpression outright, which subsumes this one. That claim is
now checked rather than asserted.

Verified with a `void import('ai')` probe in six production paths — both
@openmaic packages, choreography, video-export, lib/pbl, app/api — one error
each; lib/ai/llm.ts and tests/ stay clean; and an `@/lib/foo` import under the
renderer still fails its own host-path boundary, confirming no clobbering.

Also rewrites the PR description's e2e section, which still described the deleted
thinking policy. The browser run predates that deletion, so its streaming and
accounting evidence stands (the wrapper path is unchanged) but its thinking
observations do not. Replaced with a fresh live probe of the final behaviour: with
no route thinking the instructing turn reports 60 reasoning tokens, with `thinking`
pinned off on the stage route it reports 0.

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

* fix(lint): guard every source extension, and pin the coverage matrix in a test

Fourth review pass from @cosarah on #1006. Confirmed before fixing: an
`app/api/**/route.js` containing both a named and a dynamic `'ai'` import passed
lint with exit code 0, because both guard blocks matched only `**/*.{ts,tsx}`
while every module-boundary block in the same file matches
`**/*.{ts,tsx,js,jsx,mjs,cjs}`. Both blocks now match the same set. The repo has
real `.mjs` under scripts/, so this was not hypothetical.

That is the third scope hole review has found in this guard — two @openmaic
package directories, then the extension list — and each one was closed and then
verified by hand, which is the process that produced the next one. So the matrix
is now an executable contract: tests/lint-llm-entry-guard.test.ts runs the real
eslint against the real config over every import form (named, namespace, dynamic,
dynamic-template) × every source extension × nine production paths, asserts the
three deliberate exemptions still pass, and asserts the module boundaries that
share the `no-restricted-syntax` key still fire. A future edit that narrows
`files`, adds an `ignores` entry or swaps the rule key fails CI instead of
quietly reopening the door.

Fault-injected against both earlier findings to prove it bites: narrowing the
scope back to ts/tsx turns the js/jsx/mjs/cjs rows red, and re-adding the two
@openmaic packages to `ignores` turns the dynamic-import rows red. It also treats
an eslint-ignored path as "not covered", so a path that silently stops being
linted cannot masquerade as a pass.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:11:40 +08:00
杨慎andClaude Opus 4.8 84b1907255 feat(video-export): service-backed MP4 render + in-app one-click export (#866) (#937)
* feat(video-export): service-backed MP4 render + in-app one-click export (#866)

Adds the last mile of classroom video export: turning the self-contained
Hyperframes project ZIP (#865) into an MP4 via an isolated render service,
one-click in-app.

- render-service/: standalone Node 22 + Chromium + FFmpeg container wrapping
  @hyperframes/producer's library API. Async job model (POST /render -> 202
  jobId, GET poll, GET download, DELETE cancel). Swappable JobStore /
  ArtifactStore seams (in-memory + local-disk now; Redis/S3 + presigned-302
  download later) so it scales horizontally without changing the HTTP contract.
  Concurrency + per-user guards are config knobs.
- App integration: thin Next proxy routes under app/api/export-video/* (forward
  only, no rendering) + capability probe. use-render-video.ts uploads the ZIP,
  polls via runPolledTask, downloads the MP4; shared buildExportZip prefix with
  the existing ZIP path. Export menu gains resolution/fps/quality selectors and
  a progress bar; degrades to ZIP download when RENDER_SERVICE_URL is unset.
- docker-compose: render-service under an opt-in "video-export" profile.
- Entry is main.ts (not server.ts): the producer auto-starts its own server on
  :9847 when the process entry path ends with /src/server.ts.

Verified end-to-end in the container: rendered a real 640s (10.7 min) classroom
ZIP to a valid H.264 720p + AAC MP4 (duration matches source) in ~9.6 min
(~0.9x realtime, 4-worker frame capture). Degrade path, queued-cancel + cleanup,
and per-user 429 guard all exercised. pnpm check / lint / tsc / i18n pass.

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

* feat(video-export): global render progress store, percent+ETA UI, ring on export button (#866)

Addresses two UX issues found while driving the in-app MP4 export:

1. Progress display was raw and unfriendly (showed producer's English stage
   strings like "Capturing frame 5130/19220") and had no time estimate. Now the
   menu shows only "<percent>% · about <remaining> left". ETA is computed from a
   recent-speed estimate (percent-per-ms over the last sample), EMA-smoothed —
   which tracks the render's non-uniform pace (prep -> frame capture with a
   4->1 worker drop -> encode) far better than a whole-run average, and never
   shows a stale/rising ETA.
2. Switching scenes mid-render unmounted the export menu and lost the progress
   (and reset the local "already rendering" ref, allowing a duplicate submit).
   The whole render lifecycle now lives in a global store
   (lib/store/video-render.ts), so progress survives menu close / scene switch
   and duplicate submits are guarded by status. A persistent CircularProgress
   ring on the export button shows live progress whether or not the menu is open.

Also fixes the progress scale: the producer reports progress as 0..100, but our
HTTP contract (and success path) is 0..1 — the service now normalizes it, so the
client no longer showed "2000%".

- lib/store/video-render.ts: new global store owning submit->poll->download,
  recent-speed ETA, duplicate-submit guard.
- lib/video-export-app/use-render-video.ts: thin facade over the store.
- components/ui/circular-progress.tsx: lightweight SVG progress ring.
- components/stage/{header-controls,video-export-menu}.tsx: ring on the export
  button; menu shows percent + ETA, subscribes to the store.
- render-service/src/render-manager.ts: normalize producer progress 0..100 -> 0..1.
- i18n: percent/ETA strings across all 8 locales (drops the stage-based string).
- render-service/package-lock.json: complete integrity hashes (reproducible npm ci).

Verified: ETA logic checked against the real segmented render curve (worker drop
raises ETA, encode speedup drives it to ~0); progress scale fix confirmed live
against the container (0.2 -> 20%). tsc / lint / prettier / i18n pass.

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

* fix(video-export): persist render options in the store, not the menu component

Selecting 720p/24fps/draft, switching scenes, and reopening the export menu
showed the defaults again (1080p/30/standard). The selections lived in the
VideoExportMenu component's local state, which reset when the menu unmounted on
a scene switch — the running render still used the chosen options, but the UI
misrepresented them.

Move resolution/fps/quality into the global video-render store (with a
setOptions action). The menu now reads/writes the store, so selections survive
menu close / scene switch, and while a render runs the selectors reflect the
options that render is actually using. startRender() reads options from the
store instead of taking them as an argument.

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

* fix(video-export): deployment correctness + resource/isolation controls (PR #937 review)

Addresses the blocking findings from wyuc's review. Output fidelity was fine;
these harden deployment and production resource/isolation boundaries.

#1 Compose advertised MP4 but couldn't render in prod:
- Capability now probes the service's /health (checkRenderServiceHealth), so a
  configured-but-absent service reports disabled and the UI degrades to ZIP
  instead of 502-ing.
- RENDER_SERVICE_URL is operator-supplied trusted config, so the proxy no longer
  runs it through the SSRF guard — the one-command `docker compose --profile
  video-export up` now works without globally weakening SSRF via
  ALLOW_LOCAL_NETWORKS. resolveRenderServiceUrl() is now synchronous.
- Client degrades to ZIP on any failed submit (not only 501).

#2 Unbounded upload/queue (ZIP-bomb / DoS):
- unzip.ts bounds the archive via fflate's filter BEFORE decompression: entry
  count, per-entry and total expanded size, and compression ratio.
- Proxy rejects oversized uploads (413) by Content-Length before forwarding.
- RenderManager enforces a global queue-depth cap (RENDER_MAX_QUEUE).
- All limits are env-tunable knobs in config.ts.

#3 Per-user guard was ineffective + admission ran after extraction:
- Identity is derived server-side (client IP) and forwarded as x-openmaic-client;
  the service ignores any client-supplied userId, and the proxy strips it.
- Admission is split into reserve()/submit()/release(): the slot is reserved
  BEFORE extraction, so a rejected caller never triggers a decompression.

Additional risks:
- Per-job wall-clock watchdog (RENDER_JOB_DEADLINE_MS) aborts + fails a hung
  render so it can't hold a slot/scratch forever.
- Download proxy bounds only the time-to-headers, not the body stream, so large
  MP4s over slow links no longer truncate.
- Client cancels the server job (DELETE) when a started render fails/times out.
- Compose puts render-service on an internal:true network (no host/internet
  route), sandboxing the Chromium that runs the uploaded HTML; the export ZIP is
  self-contained so no outbound is needed. README documents the standalone caveat.

Not closing #866: the smoke/golden-render CI acceptance criterion remains a
follow-up (see PR description).

Verified in-container: legal render 202; ZIP-bomb (entry-count + compression-
ratio) rejected 400 before any decompression; per-identity guard 429 with a
spoofed multipart userId ignored; reserve-before-extract leaves no scratch dir
on rejection; watchdog aborts an overrunning job and frees the slot. tsc / lint /
prettier / i18n pass; render-service tsc passes.

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

* fix(video-export): real audio durations + burned-in subtitles (PR #937 review)

Two export-fidelity issues found in wyuc's deeper E2E:

A. Narration was scheduled from estimated durations, cutting audio off mid-
   sentence and advancing the timeline early. The scheduler trusted
   AudioFileRecord.duration (recorded only since #861), so the many existing
   classrooms without it fell back to text-length estimates — measured 4.35s
   average / 10.23s max underestimate across 47 clips. timeline-deps now probes
   the real duration from each narration blob via an off-document <audio>
   (symmetric to the existing video probe), preferring it over the stored
   duration, then the estimate only when no audio asset exists. Everything
   downstream (narration starts, scene/total duration, subtitle cues) re-derives
   from the corrected value in the pure compiler — no compiler change needed.

B. The final MP4 had no subtitles (only H.264+AAC), and the ZIP's SRT/VTT used
   the same estimated boundaries. The emitter now renders a burned-in subtitle
   overlay: one caption box + a hidden div per cue, revealed/hidden by the paused
   GSAP timeline at each cue's start/end (corrected timings from A), so Chromium's
   frame capture bakes them in. The producer has no subtitle track of its own, so
   burn-in is the v1 approach.

Verified: emitter unit tests + snapshot updated (subtitle overlay + toggle
statements, escaped text, hidden-by-default); 82 video-export tests pass incl.
the determinism red-line proxy. Rendered a synthetic subtitle project through the
container and confirmed by pixel analysis that captions appear only within their
cue window (2429 near-white px in the caption band at t=1.5s vs 0 at t=0.05s).
tsc / lint / prettier / i18n pass.

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

* fix(video-export): subtitle layout + upload/admission hardening (PR #937 review)

Address the three P1 blockers plus actionable P2s from the 6a585c29 review.

P1:
- emit-hyperframes: stack every subtitle cue in one grid cell and toggle
  display:none/inline-block, so inactive cues leave the flow instead of
  pushing the active cue up into the slide title. Adds a multi-cue
  regression test the single-cue snapshot couldn't catch.
- render route + service: cap the upload by actual bytes (capBodyStream),
  not the spoofable Content-Length; the app now streams the multipart body
  through instead of buffering it via formData(). maxUploadBytes is now read.
- render-service: move makeProjectDir() inside the release()-guarded block
  so an ENOENT/ENOSPC no longer permanently leaks the admission slot; mkdir
  the scratch root at startup for the standalone path.

P2:
- config: allow RENDER_MAX_JOBS_PER_USER=0 to disable the per-identity guard.
- timeline-deps: per-probe timeout + bounded concurrency so a stuck audio
  blob can't wedge export in "compiling" forever.
- render route: only trust x-forwarded-for/x-real-ip under
  TRUST_PROXY_HEADERS=true; otherwise all callers share one "direct" bucket.
- render-service: add vitest tests (unzip limits/traversal, reservation
  arithmetic, body cap, config zero-disable) and a dedicated CI job.

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

* fix(video-export): sync render-service lockfile so `npm ci` passes in CI

The vitest devDependency's transitive esbuild@0.28.1 (and its platform
optionals) were missing from package-lock.json, so the new CI job's
`npm ci` failed with EUSAGE. Regenerated the lockfile from a clean install.

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

* fix(video-export): dedupe esbuild so render-service `npm ci` installs on linux

@hyperframes/core pins esbuild@0.25.12 exactly, hoisting it to the top and
forcing vite@8 (via vitest) to keep a nested esbuild@0.28.1 copy. npm fails to
flag that nested copy's platform-specific optionals as optional, so `npm ci`
tried to install @esbuild/aix-ppc64 on linux and died with EBADPLATFORM.

Add an `esbuild: 0.28.1` override so a single copy is shared (satisfies tsx
~0.28 and vite ^0.27||^0.28); esbuild is build-time only, so pinning the
producer's bundled build tool is runtime-inert.

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

* fix(video-export): resource/isolation hardening (PR #937 round-2 review)

Address the round-2 P1/P2/P3 findings (P1#1 lockfile EBADPLATFORM was already
fixed by the earlier esbuild-dedupe commit; CI Render Service job is green).

P1:
- Admission before buffering (#2): the render service now reserve()s the slot
  from the header identity BEFORE parsing/buffering the multipart, so concurrent
  near-cap uploads are bounded by the queue depth, not just each body by the cap.
- Chromium egress lockdown (#3): the producer exposes no browser-arg hook and the
  render shares the internal network with the app, so a container entrypoint
  installs an iptables egress lockdown (drop all outbound except loopback +
  established replies) then drops privileges. Needs CAP_NET_ADMIN (added in
  compose); graceful warn-and-continue if unavailable. The self-contained ZIP
  needs no outbound.
- Default one-render bottleneck (#4): with no trusted proxy every caller is
  "direct", so RENDER_MAX_JOBS_PER_USER=1 throttled the whole deployment. Default
  compose now sets it to 0 and relies on concurrency + global queue caps.
- Non-blocking bounded extraction (#5): unzipSync -> fflate async unzip (worker,
  off the event loop), keeping the pre-decompression filter; default expanded
  ceiling 1GB -> 512MB; a semaphore caps concurrent extractions; compose adds a
  container mem_limit.

P2:
- Raise the app submit timeout/maxDuration (300MB upload can't finish in 60s).
- video-render store: only degrade to ZIP when the service is genuinely
  unavailable (501/unreachable); surface real 429/413/5xx instead of an
  unsolicited download.
- useExportVideo dedupe guard moved to module scope so it survives the menu
  unmounting (no second concurrent ZIP pipeline).
- .env.example: RENDER_SERVICE_URL bypasses SSRF; drop the ALLOW_LOCAL_NETWORKS note.

P3:
- Deadline overruns are marked failed (not cancelled).
- submit() decrements the identity slot if jobs.create throws (no leak).
- CI sets PUPPETEER_SKIP_DOWNLOAD; unzip tests use tiny fixtures + low env limits.

Tests: render-service now 22 tests (unzip limits/traversal, admission incl.
create-leak, body cap, semaphore, config); app video-export suite unchanged.

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

* fix(video-export): buffer under the extraction permit + fail-closed egress (PR #937 round-3)

Address the two remaining round-3 P1 boundary blockers.

P1#1 — buffering was outside the gate: `bounded.formData()` materialized the
whole uploaded file into memory BEFORE `extractionGate.run()`, so up to
RENDER_MAX_QUEUE (20) admitted bodies could each buffer ~300MB (≈6GB vs the 4g
mem_limit) before the 2-permit gate. Move the entire RAM-heavy section —
formData buffering, file read, and unzip — INSIDE the permit; the queue
reservation still runs first (a rejected caller consumes nothing). Requests
beyond the permit wait with their body unconsumed (socket backpressure), so at
most maxConcurrentExtractions bodies are buffered at once. Refactored main.ts
into a testable `createApp(deps)` factory and added an integration test proving
peak concurrency in the buffering+extraction section never exceeds the permits.

P1#2 — egress lockdown failed open: the entrypoint warned and started normally
if iptables setup failed, so /health stayed green while Chromium could reach the
app. With RENDER_EGRESS_LOCKDOWN=true (default) it now FAILS CLOSED — exits
non-zero if not root, iptables is missing, or the rules don't apply. Operators
accepting an unisolated setup opt out with RENDER_EGRESS_LOCKDOWN=false. Added
scripts/egress-smoke.sh to assert the boundary (lockdown active, loopback works,
new outbound blocked).

Verified: image builds; container boots as `render` with lockdown active and
serves /health; fail-closed exits 1 without CAP_NET_ADMIN; egress smoke passes
(outbound blocked); 23/23 render-service tests + tsc; app tsc + root prettier clean.

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-20 11:28:34 +08:00
Yizuki_Ameandwyuc 7c2aaafc91 fix(ssrf): harden provider redirects and ISATAP detection (#928)
* fix(ssrf): detect private IPv4 in ISATAP addresses

* fix(ssrf): reject redirects in provider probes

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-07-16 14:46:59 +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
c56929510c feat(web-search): add SearXNG provider support (#842)
* feat(web-search): add SearXNG provider support

Enable self-hosted SearXNG via SEARXNG_BASE_URL, with server-side provider
fallback, settings sync, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: fix Prettier formatting in web-search files

Restore pnpm check by applying Prettier to imports, headers, and test assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-search): keep SearXNG base URLs server-managed only

Close the SSRF primitive by ignoring client-supplied SearXNG URLs at the
route boundary and rejecting them in resolveSafeClientWebSearchBaseUrl.
Add negative route/config tests and an opt-in live smoke test for the
JSON API.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: fix Prettier formatting for SearXNG web-search changes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: 徐松 <song.xu@aieshanghai.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 23: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
Yizuki_AmeandClaude Fable 5 e136c81c03 chore(generation): remove dead legacy pipeline chain (#904) (#905)
Remove the generation-pipeline leftovers orphaned since the old
/api/generate route was deleted in 0610053:

- pipeline-runner.ts (runGenerationPipeline / createGenerationSession)
- generateFullScenes + generateSingleScene in scene-generator.ts
- unreachable callbacks param of generateSceneOutlinesFromRequirements,
  including its five hardcoded Chinese statusMessage strings
- generating-progress.tsx (never imported since the initial commit)
- orphaned GenerationCallbacks / GenerationProgress / GenerationSession
  types, 5 unused i18n keys across all 8 locales, and stale
  generateFullScenes references in DESIGN-regenerate-scene.md

Found while investigating #884. Verified against a pre-change baseline:
tsc --noEmit diff clean, vitest failure set unchanged (1956 passed),
check:i18n-keys passes, zero residual references repo-wide.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:56:35 +08:00
Yanpeng Wang d54e62f5c4 feat(token-plan): one-click token-plan setup + deployment usage dashboard (#784)
* feat(usage): add usage normalization, pricing, model-fetch, balance, storage

Foundational layer for token-plan usage tracking (cc-switch-modeled):
- lib/usage/normalize.ts: AI SDK v6 usage → four-class token shape
- lib/usage/pricing.ts + defaults: per-class USD pricing table
- lib/server/model-fetch.ts: /models candidate-URL multi-fallback (ported)
- lib/usage/balance-providers.ts: built-in balance queries + detection
- lib/server/usage-storage.ts: fire-and-forget jsonl logging to data/usage

All pure/storage logic covered by vitest (32 tests).

* feat(usage): capture token usage at the callLLM/streamLLM chokepoint

- callLLM records result.usage before returning
- streamLLM wraps onFinish to record totalUsage on stream completion,
  preserving any caller-supplied onFinish
- provider/model derived from the model instance (no route changes)
- fs-backed storage imported dynamically; fire-and-forget, never throws
- include_usage is already sent by @ai-sdk/openai, so streaming is covered

* feat(usage): add probe-models, balance, and usage API routes

- POST /api/provider/probe-models: discover chat models via /models with
  candidate fallback; SSRF-guarded; filters non-chat ids; 401/404 typed
- POST /api/provider/balance: built-in balance detection + billing fallback
- GET /api/usage: aggregate jsonl by model/day/source with costIncomplete flag

Verified e2e against the live MAIC gateway: 16 chat models (6 filtered),
balance detected, and a real callLLM writes a costed usage row.

* feat(settings): add token-plan preset picker to provider dialog

- lib/config/token-plan-presets.ts: data-driven vendor presets (Huawei/MiniMax/
  Xiaomi token plans, OpenRouter/SiliconFlow gateways, DeepSeek/GLM/Qwen/Hunyuan/
  Doubao direct) with baseURL, protocol, optional modelsUrl, category
- add-provider-dialog: '选择厂商/自定义' tabs; picking a preset auto-fills
  baseURL+protocol+modelsUrl, custom tab unchanged
- ProviderSettings.modelsUrl carries the optional /models override
- i18n keys added across all 8 locales

Verified in-browser: preset picker renders grouped by category.

* feat(settings): add Fetch Models button and balance bar to provider panel

- 拉取模型: probes /models, merges discovered ids into the model list
  (dedupe, keeps manual additions), with success/no-endpoint/auth messages
- 查询余额: queries /api/provider/balance, renders a balance bar or a
  'check console' hint when unsupported
- index.tsx: handleModelsFetched merges probe results into provider config
- i18n keys across all 8 locales; removed a now-unused eslint-disable

Verified in-browser against MAIC gateway: 16 models fetched, balance shown.

* feat(settings): add usage dashboard to System Settings

- usage-dashboard.tsx: echarts dual-axis daily trend (tokens + cost),
  totals cards, by-model table, refresh; reads GET /api/usage
- mounted at the top of GeneralSettings (系统设置)
- honest disclaimer + costIncomplete marker when a model lacks pricing
- i18n keys across all 8 locales

Verified in-browser: shows 1 request / 31 tokens / $0.0003 from a prior call.

* feat(token-plan): multi-modal one-click setup in System Settings

- token-plan-presets.ts: presets now declare per-modality targets
  (llm/image/video/tts/webSearch); MiniMax is the full-set template,
  others LLM-only — extend by adding entries (at-our-best adaptation)
- apply-token-plan.ts: fills one key into every declared modality via
  injected store setters, isolating per-modality failures (TDD, 4 tests)
- token-plan-settings.tsx: new sidebar page — pick plan, enter key,
  one-click apply lights up adapted modalities (+LLM model probe),
  shows 'not adapted yet' for the rest, balance bar reused
- reverted add-provider dialog to plain custom form (preset picker moved here)
- i18n across all 8 locales

Verified in-browser: Token Plan page renders, MiniMax shows all 5
modalities, apply/balance UI wired. 859 tests pass, build clean.

* feat(token-plan): add custom token plan entry to Token Plan page

- Custom card at the bottom of the provider list: pick it to manually enter
  name + protocol + baseURL (LLM-only), then key + one-click apply via the
  same applyTokenPlan flow (mirrors cc-switch's custom provider)
- effectivePreset unifies preset vs custom for apply/balance/probe
- i18n keys (customGroup/customName/customHint) across all 8 locales

Verified in-browser: custom card expands the manual form; preset flow intact.

* feat(token-plan): reflect persisted config on the Token Plan page

Other settings panels read the store directly, so they survive section
switches; the Token Plan page only wrote the store, so it looked blank on
return. Now it also reads providersConfig:
- selecting a preset prefills its saved API key
- configured presets show a '已配置/Configured' badge

State persistence was never broken (apply writes zustand→localStorage);
this fixes the missing read-back so the page reflects it.

* fix(settings): isLLMProviderConfigured crashed on providers without models

Root cause of 'token plan config disappears': applyTokenPlan writes a new
LLM provider with no models yet (probe fills them later). isLLMProviderConfigured
did config.models.length unguarded → threw inside setProviderConfig's resolver
→ the whole set() aborted → key/baseUrl/type never persisted.

- guard models in isLLMProviderConfigured (shared validator; affects any
  provider written without a models array)
- seed models:[] in applyTokenPlan's LLM write for a valid initial shape
- regression tests: validator no longer throws; apply+probe write keeps apiKey

Verified in-browser: DeepSeek persists key and shows 已配置 after section switch.

* feat(token-plan): add remove/teardown for a configured token plan

Apply had no inverse. Add removeTokenPlan: clears the API key and disables
(enabled:false) every modality the plan declared; a custom LLM provider is
deleted entirely (removeProvider), built-ins keep the cleared shape.

- removeTokenPlan in apply-token-plan.ts (mirrors applyTokenPlan, isolated
  per-modality, injected setters; 3 tests)
- trash button on configured preset cards in the Token Plan page; resets
  page state if the removed plan was selected
- i18n 'remove' across all 8 locales

Verified in-browser: removing DeepSeek empties its key and drops the
已配置 badge.

* feat(usage): track multimodal usage, drop cost/pricing

Reframe usage stats as pure usage (no cost), per user decision:
- usage-storage: drop all cost fields; add kind (llm/image/video/tts/asr)
  + quantity + unit; LLM keeps token counts, others store quantity
- delete lib/usage/pricing.ts + pricing-defaults.json + its test
- instrument image/video/tts at the server-only API routes (not in the
  provider dispatch — those files are in the client graph and importing
  fs-backed usage-storage broke the client bundle)
- /api/usage: aggregate by model/day/modality, no cost
- dashboard: per-modality usage (tokens/images/seconds/chars), token-only
  daily trend, no '$'; i18n cost keys replaced with modality/unit keys
- backward compatible: legacy rows (no kind, stray cost fields) read as llm

912 tests pass, build clean.

* feat(usage): per-modality dashboard layout + softer dark-mode chart

- group usage into per-modality sections (LLM/image/video/tts/asr), each
  table's usage column uses one consistent unit (token/image/sec/char) —
  no more mixed units in a single column
- summary chips per modality with their own unit
- trend chart now plots daily REQUESTS (unit-agnostic, works for any
  modality) instead of LLM-only tokens
- theme-aware chart: faint thin line + soft gradient area, muted axis/grid
  colors via useTheme — fixes the harsh solid stroke in dark mode

Verified in-browser (dark): TTS shows '字符', LLM shows 'Token', separate
sections; chart no longer has a hard line.

* refactor(usage): dedupe model/fetch/usage helpers, parallel balance probe

Review cleanups on the token-plan/usage branch:
- extract modelInfoFromId() (shared vision heuristic + ModelInfo shape)
- extract fetchWithTimeout() shared by model-fetch and balance-providers
- extract recordGenerationUsage() to dedupe the image/tts/video routes
- queryBalance: fetch billing subscription + usage in parallel
- parseOneApiBilling: report quota without remaining when usage endpoint
  is unavailable, instead of implying zero spend (full balance)
- split the two jammed imports in settings/index.tsx

* feat(token-plan): drop custom token-plan support

Custom token plans (manual baseURL/protocol entry) added complexity for
little gain — a one-off provider is better configured directly on the
Providers page. Token Plan is now preset-only:
- remove custom mode, manual fields, and the custom card from the UI
- collapse effectivePreset back to the selected preset
- drop the now-dead removeProvider action + custom-id branch in
  removeTokenPlan
- remove orphaned customGroup/customName/customHint i18n keys (8 locales)

* feat(token-plan): add Volcengine/Tencent/Bailian plans, drop balance feature

Add three vendor token-plan presets (all map to existing built-in LLM
providers, so it's data-only — no new adapters):
- 火山方舟 Volcengine Ark → doubao, OpenAI /api/v3
- 腾讯 TokenHub Token Plan → tencent-hunyuan, OpenAI /plan/v3 (the
  plan-specific base; /v1 is the pay-as-you-go gateway)
- 阿里百炼 Token Plan → qwen, cross-model plan (Qwen + DeepSeek/Kimi/
  GLM/MiniMax) on one key; model list is probed/entered

Remove the balance/quota feature entirely — we now track usage, not cost,
and every vendor's balance query needs its own cloud AK/SK + signature
(Volcengine SigV4 / Tencent TC3 / Aliyun BSS), which the Bearer-key
billing-endpoint probe never supported anyway:
- delete lib/usage/balance-providers.ts, /api/provider/balance, its test
- strip the Check Balance button + balance bar from the token-plan page
  and the provider config panel
- remove the 4 balance i18n keys across all 8 locales
- restore an eslint-disable the branch had dropped in provider-config-panel

* feat(token-plan): use cloud-brand logos for vendor token plans

The three vendor plans are cloud offerings, not single-model products, so
icon them with the cloud brand rather than a model logo:
- 火山方舟 → volcengine.svg (was doubao.svg)
- 腾讯 TokenHub → tencentcloud.svg (was hunyuan.svg)
- 阿里百炼 → alibabacloud.svg (was bailian.svg)

Logos are the colored brand variants from lobehub/lobe-icons, matching the
existing colored-logo style (plain <img>, no dark:invert needed).

* feat(token-plan): keep only MiniMax and Volcengine presets

Trim the token-plan list to the two we want to ship: MiniMax (full-set
template) and 火山方舟 Volcengine Ark. Drop the Tencent/Bailian plans and
the OpenRouter/SiliconFlow/DeepSeek/GLM/Qwen entries.

- remove the now-unused tencentcloud.svg / alibabacloud.svg logos
  (volcengine.svg stays; the other logos are still used by the provider
  registry)
- retarget the LLM-only apply test from the deleted deepseek preset to
  volcengine-ark

* feat(token-plan): restore aggregator/third-party presets

Previous commit over-trimmed: the intent was to drop only the Tencent and
Bailian token plans, not the OpenRouter/SiliconFlow/DeepSeek/GLM/Qwen
entries. Bring those back; keep only Tencent/Bailian removed.

- token_plan: MiniMax, 火山方舟 Volcengine Ark
- aggregator: OpenRouter, SiliconFlow
- third_party: DeepSeek, GLM, Qwen

Revert the apply test back to the deepseek fixture (restored).
tencentcloud.svg / alibabacloud.svg stay deleted (their plans are gone).

* fix(token-plan): point Volcengine plan at the Coding Plan endpoint

The plan's ark--prefixed API keys authenticate only against
/api/coding/v3, not the general /api/v3 endpoint — the latter rejects them
with "The API key format is incorrect", so model probing returned nothing.
Switch the base URL to https://ark.cn-beijing.volces.com/api/coding/v3.

* fix(token-plan): Volcengine is an Agent Plan (Anthropic /api/plan)

Per the Ark Agent Plan docs, the ark--prefixed keys authenticate ONLY
against the dedicated Anthropic-compatible base https://ark.cn-beijing.
volces.com/api/plan ("其他 Base URL 无法在 Agent Plan 中使用"). The general
/api/v3 and the Coding Plan /api/coding endpoints both reject the key as
"API key format is incorrect", which is why model probing kept returning 0.

- baseUrl → https://ark.cn-beijing.volces.com/api/plan/v1 (the /v1 lets the
  Anthropic SDK land on /api/plan/v1/messages)
- apiFormat → anthropic
- rename to 火山方舟 Agent Plan

Probe still targets /api/plan/v1/models (the path exists); if the Anthropic
gateway doesn't return an OpenAI-shaped list, users fall back to typing a
model id like ark-code-latest.

* fix(token-plan): Volcengine Agent Plan = OpenAI /api/plan/v3 + ark-code-latest

Settled after probing the real key and reading cc-switch's approach:
- The ark- plan key works on the OpenAI-compatible /api/plan/v3 endpoint
  (chat/completions returns 200); switch apiFormat back to openai.
- The plan exposes NO /models list (every /api/plan/*/models is 404), which
  is why probing kept returning 0. cc-switch handles this by hardcoding a
  single ark-code-latest (an auto-routing alias valid on any tier) and does
  NOT use AK/SK for model listing — so we do the same.
- Seed defaultModels: ['ark-code-latest'] only; users add specific ids by hand.

Supporting machinery (kept, general-purpose):
- applyTokenPlan seeds models from defaultModels instead of wiping to []
- handleApply uses defaultModels and skips the doomed probe when present
- drop stray .playwright-mcp/ debug artifacts and gitignore them

* style: fix prettier formatting in usage files

CI runs prettier on the whole repo (prettier . --check); these four files
predate this branch's formatting pass and tripped the check.

* feat(token-plan): verify Volcengine Agent Plan's published model set

The Agent Plan publishes a fixed model set but exposes no /models endpoint,
so carry the documented models as CANDIDATES and verify each on apply:

- add verifyModels flag to TokenPlanModalityTarget
- new /api/provider/probe-chat-models route: sends a minimal chat request per
  candidate (OpenAI /chat/completions or Anthropic /messages) in parallel,
  returns the subset that succeeds; SSRF-guarded, auth-failure short-circuits
- handleApply gains a verify branch (before the fixed-defaultModels fast path),
  falling back to the seeded list if verification fails
- Volcengine preset now carries the 12 published Agent Plan text models
  (doubao-seed-2.0-*/deepseek-v4-*/minimax-m*/glm-5.2/kimi-k2.*) as candidates

This auto-prunes retired (docs flag deepseek-v3.2/glm-5.1 as 即将下线) and
tier-gated models without code changes. Verified all 12 resolve against a real
plan key.

* feat(token-plan): wire Volcengine Agent Plan image + video modalities

Make the Ark seedream/seedance adapters path-configurable and light up the
image/video modalities on the Volcengine plan:

- seedream/seedance adapters: resolveArkRoot() uses baseUrl verbatim when it
  already carries an /api/... path (token plan's /api/plan/v3), else appends
  the standard /api/v3 — no regression for the pay-as-you-go default host.
- applyTokenPlan: image/video branches inject a modality's defaultModels as
  customModels and set them as the active provider+model, so generation works
  out of the box. New optional setImageProvider/ModelId + setVideoProvider/
  ModelId actions (UI passes the store setters; tests omit them).
- Volcengine preset declares image (doubao-seedream-5.0-lite, verified 200 on
  /api/plan/v3/images/generations) and video (doubao-seedance-2.0/1.5-pro —
  Medium+ tiers only; lower tiers reject at call time, no code change needed
  to upgrade).

Applying the plan overwrites the shared seedream/seedance slot with the plan
config (same overwrite model as LLM); switching back to pay-as-you-go is a
manual edit or plan removal. Verified image end-to-end with a real plan key.

* feat(token-plan): verify image/video models on apply, disable unsupported tiers

The Volcengine plan lit up video optimistically, but lower tiers (Small) don't
include video — so using it 404'd with UnsupportedModel. Probe media models on
apply and only keep what the tier actually supports:

- generalize /api/provider/probe-chat-models with a `kind` (chat|image|video):
  image hits /images/generations, video hits /contents/generations/tasks with
  empty content. The model-support check (404 UnsupportedModel) runs before any
  billable work, so probing never starts a real image/video job; for media,
  "supported" = any non-404 response.
- handleApply: after lighting up image/video, probe each verifyModels modality;
  prune to the verified model set + re-select a working model, or disable the
  modality entirely if none pass (no false "available").
- Volcengine preset: image/video targets gain verifyModels: true.
- add settings.tokenPlan.tierUnsupported across 8 locales.

Verified with a real Small-tier key: image (seedream-5.0-lite) passes and is
kept; video (seedance-2.0/1.5-pro) 404s and is disabled.

* feat(web-search): add Doubao (豆包搜索) provider

Doubao Search (Custom 版) over its REST endpoint
POST open.feedcoopapi.com/search_api/web_search with Bearer auth — the
same endpoint the askecho-search-infinity MCP server wraps, so the
Volcengine Agent Plan key authenticates directly. Mirrors the MiniMax
adapter: maps Result.WebResults to WebSearchSource (prefers Summary, the
query-relevant excerpt, over Snippet for LLM use) and surfaces errors
from ResponseMetadata.Error.

- register 'doubao' in WebSearchProviderId + WEB_SEARCH_PROVIDERS
- searchWithDoubao adapter, searchWeb dispatch, store default config
- SSRF allowlist entry for the search host

* feat(audio): support Agent Plan single-key auth for Doubao TTS

generateDoubaoTTS now picks auth + endpoint from the key shape, since
Volcengine exposes Seed-TTS as two products with separate credentials
(verified: a plan key 401s on the normal endpoint, and the plan endpoint
rejects appId-style auth):
  - single key (no colon) -> X-Api-Key, for the Agent Plan /plan endpoint
  - appId:accessKey        -> X-Api-App-Id + X-Api-Access-Key (unchanged)
A malformed pair (empty half) fails clearly instead of sending an empty
header. Reuses the existing NDJSON/base64-mp3 parsing and voice list.

* fix(media): map MiniMax video 720p to its real 768P tier

normalizeVideoOptions defaults minimax-video to '720p' (the first
supported resolution), but Hailuo 2.3 only accepts 768P/1080P and rejects
720P with '2013 ... does not support resolution 720P'. MiniMax's mid tier
is 768P, not 720P (the adapter already falls back to 768P, as does the
connectivity test), so map the shared enum's '720p' to 768P. Regression
tests lock the mapping.

* feat(token-plan): add web search + TTS to Volcengine Agent Plan, widen image tiers

Extend the volcengine-ark preset now that the adapters exist:
  - webSearch -> doubao (own host open.feedcoopapi.com, not the ark endpoint)
  - tts -> doubao-tts on the /api/plan/tts endpoint (single-key auth)
  - image defaultModels widened to a best-first Seedream 5.0/4.5/4.0 list so
    a higher tier keeps the strongest model while verifyModels prunes the
    rest; video keeps the 2.0 + 1.5-pro candidates

Comments record the verified host/auth quirks of each modality.

* feat(token-plan): show result panel only after probing, with two clear states

Addresses review feedback that a green check implied generation works
when it only meant 'configured'. The panel now renders after probing
finishes (gated on results && !applying) so it reflects the final set,
and uses two states: green when the modality is configured/usable, muted
when a live probe proved it unavailable (e.g. video on a tier without it).

* feat(token-plan): scope presets to true multi-modal token plans

Drop the single-modality LLM presets (OpenRouter, SiliconFlow, DeepSeek,
GLM, Qwen) from Token Plan. A token plan's defining trait is one key
spanning many modalities; those entries are ordinary LLM API providers
already covered by the add-provider flow, and listing them here muddied
the 'one key, every modality' promise. Only MiniMax and the Volcengine
Ark Agent Plan remain. The UI already hides categories with no entries.

apply-token-plan's LLM-only test now uses a local fixture instead of the
removed deepseek preset.

* feat(token-plan): progressive reveal of probe results on apply

The result panel previously rendered all at once after probing finished,
reading as dead air during the model probe. Now rows appear immediately on
Apply: modalities with a live probe in flight show a spinner ('pending')
and resolve to lit/failed independently as each probe returns, while
non-probe modalities show lit right away. Probes run in parallel
(Promise.all) instead of sequentially.

A row only turns green once its own probe confirms, so this reveals
structure + live progress without a premature green — complementing the
earlier 'render only after probing' intent rather than reverting it.

Per review feedback from @wyuc on #784.

* fix(token-plan): enrich seeded models with built-in thinking capability

Token Plan built ModelInfo objects from probed ids via modelInfoFromId(),
filling only streaming/tools/vision — so a model that supports configurable
thinking lost capabilities.thinking and InlineThinkingControl was hidden.

modelInfoFromId now takes an optional providerId and overlays the catalog
thinking capability for that (provider, model) pair; applyTokenPlan does the
same for its synchronously-seeded list. Added the Ark Agent Plan's dotted
aliases to the metadata table:
  - native Doubao Seed 2.0 family (doubao-seed-2.0-pro/code/lite/mini)
  - cross-vendor models the plan serves through its OpenAI-compatible endpoint
    (deepseek-v4-pro/flash, glm-5.2, kimi-k2.7-code/k2.6, minimax-m3/m2.7,
    ark-code-latest)

All verified against a live plan key: each accepts the gateway's unified
reasoning_effort field (low/medium/high) and actually reasons. They share the
doubao effort adapter, which disables via 'minimal' (not 'none') — matching
what the plan endpoint accepts (it rejects reasoning_effort:'none').

Addresses review point #1 from @wyuc on #784.

* Improve token plan capability setup UI

* fix token plan setup flow

* chore: prettier format tts-providers.ts
2026-07-03 11:03:12 +08:00
jackefnandYanpeng Wang 3b7a0ca0c0 feat(document): add multi-format course material upload (#741)
* feat(document): add multi-format course material upload

* fix(document): support office uploads with mineru cloud

* fix(document): handle mineru cloud office edge cases

* fix(document): align mineru cloud artifact metadata

---------

Co-authored-by: Yanpeng Wang <yanpg.wang@gmail.com>
2026-07-02 17:58:58 +08:00
cec3c6aaea Refactor widget actions into the scene action pipeline (#796)
* refactor: align widget actions with scene pipeline

* feat(edit): migrate legacy teacherActions off interactive content on load

The widget-actions pipeline no longer persists a teacherActions authoring
layer; playback reads only scene.actions. Add migrateInteractiveContent to
strip the dead field from existing documents as they load through migrateScene
(wired into all store/classroom load paths). Satisfies the #794 forward-migration
acceptance item.

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

* fix(generation): default widget_setState.state to {} when the LLM omits it

The removed TeacherAction->Action converter always defaulted state to {}.
Restore that guard in the structured-output parser so an action missing
state can't forward state: undefined into the widget iframe, where
SET_WIDGET_STATE handlers dereference it.

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

* fix(edit): also strip nested widgetConfig.teacherActions in migration

Legacy WidgetConfig variants each declared their own teacherActions, so existing
documents can carry the dead field nested inside content.widgetConfig as well as
at the top level. Extend migrateInteractiveContent to drop both, preserving all
other widgetConfig fields.

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

* feat(prompts): document stable widget-action selectors for diagram/game/viz3d

The interactive-actions prompt documented stable target selectors only for
procedural-skill and simulation widgets. Add the canonical selectors the
diagram / game / visualization3d content prompts actually emit (node IDs via
revealOrder; #zoom-in-btn / #canvas-container / #speed-slider / ...; #start-btn),
plus a rule to use widget_setState or a speech-only beat instead of guessing a
target when no stable selector is known (e.g. free-form code widgets). Hardens
against widget actions silently no-oping on non-existent selectors.

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-28 21:13:21 +08:00
Yizuki_Ame 243e9f3a48 feat: auto-retry transient failures during scene generation (#788)
Adds automatic retry with exponential backoff for scene generation (content/actions/TTS): shared withGenerationRetry helper at the fetch boundary, smart error classification (retries 429/5xx/timeouts/network, fails fast on 401/403/404), AI SDK RetryError unwrapping, abort-aware backoff, per-route thinking config preserved, and upstream HTTP status surfaced for client retry classification. Fixes #755.
2026-06-27 07:20:22 -04:00
杨慎andwyuc 0f44e9a46f Add PBL v2 runtime APIs and classroom UI (#799)
* feat: add PBL v2 runtime UI

* fix: ship PBL v2 instructor avatar

* fix: ship PBL v2 workspace logo

* fix: prevent scenario PBL legacy fallback

* chore: restore package lint comments

* test: cover ordinary PBL legacy fallback

* fix: front-load PBL single-call validation

* fix: preserve PBL v2 content when assembling scenes

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-26 11:56:02 +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
wyucandwyuc 267965d6ed feat(generation): infer concise courseTitle from outlines for readable course names (#756)
* feat(generation): infer concise courseTitle from outlines for readable course names

Generated courses were named after the raw user prompt (truncated to 500 chars
client-side, or 50 chars / first outline title server-side), which is long and
unreadable. The outline-generation step already understands the full course
semantics, so have it emit a concise `courseTitle` alongside `languageDirective`
and `outlines`, and use it as the stage name.

- Prompts (requirements-to-outlines, interactive-outlines): top-level JSON gains
  a required `courseTitle` (≤30 chars, in the teaching language, noun phrase).
- Streaming route: extract `courseTitle` head-bound like `languageDirective`,
  emit a `courseTitle` SSE event, and include it in the `done` event.
- Non-streaming outline-generator: parse and return optional `courseTitle`
  (defensive trim + 120-char cap).
- Naming: stage.name = courseTitle || outlines[0]?.title || requirement fallback
  (client preview path backfills stage.name after outlines resolve; server
  classroom-generation uses it directly).
- Session: persist `courseTitle` on GenerationSessionState for reload safety.
- Tests: add courseTitle parsing coverage; e2e mock done event carries it.

Fully optional in the pipeline — every consumer falls back to the previous
behavior when the field is absent, so legacy/LLM-skipped cases are unaffected.

* fix(generation): make courseTitle propagation robust across all outline paths

Address review feedback on the courseTitle threading.

Prompts — align every outline-prompt schema statement to the 3-key shape.
The user-prompt "final reminder" / top-level shape blocks for
requirements-to-outlines, interactive-outlines, and task-engine-outlines still
demanded the old two-key {languageDirective, outlines} object; as the last
instruction the model reads, that caused it to omit courseTitle and the stage
name to fall back to the raw requirement. Task Engine prompts now emit
courseTitle as well.

Streaming route — normalize the streamed courseTitle (trim, ignore
whitespace-only, cap length) to match the non-streaming parser, and add a
full-buffer fallback so a title emitted after the outlines array or beyond the
head-scan window is still recovered before the done event.

Client — reset latched languageDirective/title on outline retry so a
succeeding attempt that omits them falls back instead of inheriting a failed
attempt's stale value; also carry courseTitle in the stream-end resolve path
that fires when the stream closes without an explicit done event.

courseTitle remains optional on every consumer path.

---------

Co-authored-by: wyuc <zdq1204@gmail.com>
2026-06-18 18:31:38 +08:00
wyucandClaude Opus 4.8 25cf58d19f feat(model): optional per-stage LLM model routing (#745) (#747)
* feat(model): optional per-stage LLM model routing (#745)

Add a config-only stage -> model map consulted during model resolution,
falling back to DEFAULT_MODEL when unset (zero behavior change unless opted in).

- lib/server/model-routes.ts: parse/validate/cache MODEL_ROUTES env JSON;
  LLM_STAGES registry of routable stages; getStageModel(stage).
- resolveModel: resolution order x-model > stage route > DEFAULT_MODEL > builtin;
  thread optional `stage` through resolveModelFromHeaders/FromRequest.
- Wire each route's resolveModel call site to its stage; classroom-generation
  resolves generate-classroom and web-search-query-rewrite independently.
- .env.example: document MODEL_ROUTES; unit tests for both modules.

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

* fix(model): address cross-review findings for per-stage routing (#745)

- classroom-generation: resolve the web-search-query-rewrite model lazily,
  only when explicitly routed and inside the web-search branch, with try/catch
  fallback to the classroom model. Fixes a regression where a misconfigured
  optional route (keyless/invalid provider) threw and aborted ALL classroom
  generation even with web search disabled; also avoids wasted resolution on the
  common no-search path and preserves classroom-model inheritance when unrouted.
- resolve-model: type the stage param as LlmStage across resolveModel/
  resolveModelFromHeaders/resolveModelFromRequest so a mistyped stage literal is
  a compile error instead of silently falling through to DEFAULT_MODEL.
- model-routes: simplify the cache to a process singleton (matching
  provider-config), dropping the raw-string-keyed cache and redundant STAGE_SET.
- .env.example: clarify x-model precedence and that a route to an unconfigured
  provider fails at request time (no startup validation), like a bad DEFAULT_MODEL.

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

* fix(model): stage route takes precedence over client x-model (#745)

Cross-review (codex P2 + Claude tracer) found that the browser UI always sends
its saved model as x-model, so the previous x-model > stage order made
MODEL_ROUTES inert for normal UI traffic on exactly the heavy stages the RFC
targets (scene-content, quiz, pbl-chat, chat). Flip resolution to
stage route > x-model > DEFAULT_MODEL: a configured route is the operator's
deliberate per-stage choice and wins, while unrouted stages still honor the
client's x-model. Update docs and tests accordingly.

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

* style(model): prettier-format changed files for #745 CI

Run the repo formatter (pnpm check / prettier) on the changed routes, the new
model-routes module, and the new tests so `prettier . --check` (CI) passes;
also correct a stale precedence comment in the resolve-model test.

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

* fix(model): isolate routed model from client connection params (#745)

When a stage route overrides the client's x-model and points at a different
provider, the client-sent apiKey/baseUrl/providerType (for the client's model)
must not bleed onto the routed provider — otherwise a routed Anthropic model
would be constructed with the client's OpenAI providerType/key and fail. A
routed model now resolves its connection params purely from server config, as
if no x-model was sent. Unrouted stages still honor the client params. Adds
tests covering both the drop (routed) and keep (unrouted) cases.

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

* docs(model): correct graceful-degradation comment in classroom (#745)

resolveModel does not throw on a missing key; clarify that a keyless route
surfaces later in callLLM (degraded by the outer try/catch), while only a
resolution-time failure is caught at the rewrite re-resolve. Comment-only.

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

* feat(model): route scene-content per scene type (#745)

Extend MODEL_ROUTES with composite keys scene-content:<type> for the four core
scene types (slide/quiz/interactive/pbl). getStageModel now resolves composite
`a:b` stages most-specific-first, trimming `:` segments, so scene-content:<type>
falls back to the base scene-content route, then x-model, then DEFAULT_MODEL.
The scene-content route derives its stage from outline.type. Backward
compatible: with no composite key configured, behavior is unchanged.

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

* feat(model): per-stage thinking effort + drop hardcoded gpt fallback (#745)

MODEL_ROUTES values may now be {model, effort} (not just a model string) to pin
the thinking effort per stage. Arbitration mirrors model routing: a routed stage
with effort set wins over the client's thinking (effort "none" disables); routed
with no effort uses the model's default and drops client thinking; unrouted
stages keep the client thinking. resolveModel is the single arbiter — body
thinking is threaded in via resolveModelFromRequest. classroom-generation passes
the resolved thinking into its callLLM calls so generate-classroom and
web-search-query-rewrite honor route effort too.

Also: removed the hardcoded `|| 'gpt-5.4-mini'` fallback in resolveModel — if no
model resolves (no route / x-model / DEFAULT_MODEL) it now throws instead of
silently picking a vendor default.

model-routes: getStageRoute returns {model, effort?}; getStageModel delegates to
it. Docs + tests updated.

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

* refactor(model): route value carries full ThinkingConfig (#745)

Per-stage routing accepts the full unified ThinkingConfig in the route value
({model, thinking:{mode,effort,level,enabled,budgetTokens,excludeReasoningOutput}})
instead of just an effort string. The route's thinking is passed through
resolveModel and normalized per the model's capability by callLLM, so
budgetTokens (qwen), level (Gemini), enabled/mode, etc. all work.

(qwen3.7-plus/max thinking capability now comes from upstream #753, so this no
longer registers them.)

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

* fix(model): honor routed thinking for chat-adapter; doc fixes (#745)

Cross-review (codex P2 + Claude) found chat-adapter was the only routable stage
that ignored the route's thinking: it took the routed model but built its own
thinkingConfig from the request body, so operator-pinned thinking never applied
and client thinking still leaked onto a routed model. Now pass the client
thinking into resolveModel and use the resolved thinkingConfig (route-pinned for
a routed stage, client otherwise; defaults to disabled for low-latency chat).

Also: clarify DEFAULT_MODEL is now required for server-side stages (the
hardcoded gpt-5.4-mini fallback was removed → resolveModel throws if nothing
resolves), and fix a stale {model,effort}→{model,thinking} doc comment.

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-06-17 22:26:37 +08:00
0989bd5d15 fix(proxy): bypass proxy for loopback hosts and honor NO_PROXY (#718)
proxyFetch routed every request through the configured HTTP(S) proxy,
including loopback targets. A deployment with HTTP_PROXY set would send
a http://localhost:8888 SearXNG (or any self-hosted service) request to
the external proxy, where localhost resolves to the proxy machine and
the request fails.

- Always bypass loopback hosts (localhost, *.localhost, 127.0.0.0/8, ::1).
- Honor the standard no_proxy / NO_PROXY env var: comma-separated hosts,
  `*` wildcard, optional `:port` (with protocol-default ports), and
  curl-style domain-suffix matching (`example.com` matches
  `api.example.com`; a leading dot is equivalent).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-16 18:15:14 +08:00
RongHuiQiang 50c0aefd13 fix(vocational): gate procedural content generation 2026-06-09 23:43:34 +08:00
3ccd5da6da feat(generation): opt-in parallel scene-content generation (#660)
* feat(generation): opt-in parallel scene-content generation

Scene generation was strictly serial — content -> actions -> TTS, one
outline at a time — so an N-scene classroom paid N x (content + actions
+ TTS) of wall-clock latency, which dominates the post-outline wait.

Add an opt-in hybrid two-phase path (#572):

- Phase 1 fetches scene *content* concurrently (bounded). Content is the
  only per-scene step independent of cross-scene state, so it is safe to
  parallelise; a content failure marks just that outline and does not
  pause the batch.
- Phase 2 keeps the existing in-order actions + TTS loop, so
  previousSpeeches threading, ordered addScene, the abort/epoch guards,
  and the pause-on-failure UX are all unchanged.

Gated by a server-side PARALLEL_SCENE_CONCURRENCY (default 0 = off,
clamped to 10), surfaced to the client through the existing
/api/server-providers response. With it unset the content map is null
and the loop is byte-for-byte the original serial path, so out-of-box
behaviour is unchanged.

The bounded worker pool is extracted to lib/utils/concurrency.ts
(mapWithConcurrency, with an early-stop hook for abort/epoch) and
unit-tested; getParallelSceneConcurrency env parsing/clamping is tested
too.

Closes #572

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

* refactor(generation): pipeline content fetches instead of a barrier

Address @wyuc's review on #660. The previous version awaited the whole
bounded content pool before Phase 2, so for N > C scenes the user saw the
first scene, then a stall on a single page for the entire content phase,
then a burst — perceived as worse than serial despite the wall-clock win
(the gap scales with ceil((N-1)/C)).

Pipeline instead: lazyBoundedMap starts the content fetches (<= C in
flight) and returns one promise per outline immediately; the serial loop
awaits them in order, so each resolves as soon as its own content is ready
(usually already done, hidden behind the previous scene's actions/TTS). The
first scene now paints after content(1)+actions(1)+TTS(1) — same as serial
— with the full concurrency benefit and no stall.

Also from the review:
- a content fetch can no longer take its siblings down: an unexpected throw
  is caught and returned as a failure result, routed through the same
  mark-failed path as the serial loop (symmetric);
- drop the cosmetic Phase-1 setCurrentGeneratingOrder — the loop already
  sets it per outline;
- note the intentional belt-and-suspenders clamp;
- concurrency.test.ts asserts <= limit (not == limit), and gains
  lazyBoundedMap tests including the no-barrier property.

mapWithConcurrency is kept as a thin await-all wrapper over lazyBoundedMap.

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

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-08 07:44:33 -04:00
d7c0d80afe feat(tts): per-agent auto-voice quality + register-once timbre stability (#670) (#672)
* feat(tts): voxcpm voice-design types + deterministic voice id helpers (#670)

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

* feat(tts): emit + persist per-agent voiceDesign descriptor (#670)

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

* feat(tts): voxcpm voice registration backend client (#670)

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

* feat(tts): voxcpm-voice ensure/register endpoint (#670)

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

* feat(tts): client auto-voice registration + reference-clip cache (#670)

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

* feat(tts): reference registered voice id in vLLM-Omni speech (#670)

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

* feat(tts): thread voiceDesign + backend through tts call sites (#670)

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

* fix(tts): match vLLM-Omni voice registration contract (#670)

Live e2e against the real backend revealed the assumed multipart contract was
wrong: POST /v1/audio/voices needs name + consent + audio_sample (not
voice_id/file), and there is no per-name GET (405) — existence must list
/v1/audio/voices and check membership.

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

* refactor(tts): make auto-voice register-once pattern provider-neutral (#670)

The descriptor + register-once/reference-by-id pattern is not VoxCPM-specific.
De-couple it into a provider-neutral seam so other registration-capable
providers (ElevenLabs/MiniMax/Doubao voice cloning, …) can plug in:

- lib/audio/voice-design.ts: VoiceDesign + buildVoiceDesignPrompt /
  normalizeVoiceDesign / getDeterministicVoiceId (neutral 'auto-<hash>' id,
  namespaced by providerId).
- lib/audio/voice-registration.ts: VoiceRegistrationAdapter interface +
  providerId->adapter registry + supportsVoiceRegistration.
- lib/audio/voice-registration-client.ts: neutral ensureRegisteredVoice() +
  IndexedDB clip cache.
- app/api/generate/voice (replaces .../voxcpm-voice): dispatches by providerId.
- voxcpm-registration.ts becomes the VoxCPM adapter (sole registered provider).
- AgentConfig.voiceDesign + DB cache table renamed neutral.

Behavior-preserving; VoxCPM-specific bits (inline (prompt)text, backend kinds,
capability gate) stay in the voxcpm modules. Full suite green (674).

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

* fix(tts): build the voice clip Blob from an inline Uint8Array (#670)

A factored-out helper returning Uint8Array widened to Uint8Array<ArrayBufferLike>,
which next build (stricter than bare tsc) rejects as a BlobPart. Inline the
buffer like the rest of the repo does.

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

* feat(tts): managed TTS providers resolve model server-side (#670)

A server-managed TTS provider's model was still client-driven, but the
managed-provider settings UI hides the model field — so a backend whose model
id isn't the client default (e.g. VoxCPM/vLLM-Omni serving the full model path)
always 500'd. resolveTTSModel() makes the model authoritative from server
config (${PREFIX}_MODELS, first entry) for managed providers, like key/baseUrl;
unmanaged/unconfigured providers keep the client model unchanged. Used by the
tts and voice routes.

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

* fix(tts): single agent-voice resolver + stable teacher narration voice (#670)

Route all TTS voice resolution (narration, discussion, preview) through one
resolveAgentVoiceOptions(agent, ...) that reads the agent profile and registers
+ references the voice by id. Eagerly warm up generated agents' voices on save.

Critically fixes the teacher narration drifting (male/female jumps): the registry
is always seeded with DEFAULT_AGENTS, so the old narration lookup
find(role==='teacher') returned the default teacher (no voiceDesign) instead of
the generated one — so narration never registered a voice and fell back to the
inline prompt. pickNarratorAgent() now prefers the teacher carrying a voiceDesign.

Also: drop language from the deterministic voice id (descriptor already encodes
it) so narration (directive) and discussion (locale) resolve the same id; log
the effective registeredVoiceId. Regression tests for pickNarratorAgent.

Verified e2e: agent-profiles -> 1 voice registration -> 16 narration TTS all
referencing the same registeredVoiceId; voice present on the backend.

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

* feat(tts): fall back to persona as the voice seed for agents without a voiceDesign (#670)

Preset/default agents carry no LLM voiceDesign. Rather than add a static field,
derive the bootstrap descriptor from the agent's persona when voiceDesign is
absent, so they still register one stable reference voice (stable-but-generic;
persona is not a vocal spec). Generated agents keep their LLM voiceDesign.
Also hardens replay: stage snapshots drop voiceDesign but keep persona.

Unit-tested: resolveAgentVoiceOptions uses real voiceDesign when present, persona
otherwise.

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

* fix(tts): match Auto Voice by its localized label in the agent voice picker (#670)

The picker filtered on voice.name ('Auto Voice'), but Auto Voice is shown via
its localized label (自动音色). Searching '自动' returned '没有匹配音色'. Match
the localized label for the Auto Voice option so it's findable in any language.

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

* fix(tts): address code-review round 1 (#670)

- strip parentheses in the voice-design prompt so a paren in the descriptor/
  persona can't corrupt the (prompt)text bootstrap delimiter
- gate POST /api/generate/voice on isServerTTSProviderDisabled (#665): a
  force-disabled provider was off for the tts route but not this sibling
- check voiceExists before re-registering a client-cached clip, so a cached
  voice that's still live isn't needlessly re-uploaded every session
- dedup concurrent ensureRegisteredVoice calls via an in-flight promise map
  (eager warm-up + first utterance no longer double bootstrap/register)
- warm up only the narrator (teacher), not every generated agent, to avoid
  synthesizing voices at save time for agents that may never speak
- collapse the duplicated vLLM-Omni speech payload into one object

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

* chore(tts): prettier formatting (#670)

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

* fix(tts): key auto-voice memo by (voiceId, backend) so a base-URL change re-registers (#670)

Addresses review (cosarah): registeredThisSession/inFlight were keyed by voiceId
alone, so switching the VoxCPM base URL mid-session made ensureRegisteredVoice
short-circuit and return an id registered only on the old backend — TTS then sent
that stale registeredVoiceId and skipped the inline fallback, failing with
voice-not-found on the new backend. Memo key now includes the base URL; the
IndexedDB clip cache stays keyed by voiceId (the reference clip is
backend-independent and reused to re-register elsewhere). Regression test added.

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

* fix(tts): include API key in the auto-voice memo key (#670)

Addresses review (cosarah, round 2): memoKeyFor was keyed by (voiceId, baseUrl)
but not the API key. Registration/existence checks and speech calls are
auth-scoped, so switching account/key on the same base URL could reuse a
registeredVoiceId from the old credentials and skip re-validation. Memo key now
includes the API key (in-memory only, never persisted/logged). Regression test
covers the key-switch case.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-08 14:37:49 +08:00
3e1e9f083b feat(tts): unify provider-enablement model, default browser-native off (#665) (#666)
A single TTS provider-enablement model honored everywhere (picker,
auto-assignment, fallback, lecture/playback, server).

Model
- lib/audio/provider-enablement.ts: isTTSProviderConfigured /
  isTTSProviderEnabled / listEnabledTTSProviderIds. Availability drops the
  registry defaultBaseUrl fallback (fixes Lemonade's always-shown voices),
  honors the per-provider `enabled` flag, server-disable overrides (server
  precedence).
- voice-resolver: getEnabledProvidersWithVoices (enabled server/custom
  providers) and getSelectableProvidersWithVoices (the ONE client source of
  truth = enabled providers + opt-in browser-native when the browser exposes
  voices). resolveAgentVoice picks deterministically among the given providers,
  honors a voiceConfig only while its provider is present.
- Picker + discussion build their list from getSelectableProvidersWithVoices, so
  the AgentBar and the live discussion agree on selectable voices (fixes
  student agents going silent in auto mode). Students resolve via
  resolveAgentVoice; the teacher uses the global lecture voice verbatim (same
  voice the pre-generated lecture sends), so lecture and discussion never
  diverge.
- Voice assignment vs fallback: an agent's voice is bound at generation (the
  agent-profile LLM picks from availableVoices; preset uses the user's pick).
  The fallback for a missing/invalid/unavailable voice is applied only at the
  live TTS calls, not baked into the saved agent — so it reflects current
  provider availability.

Defaults / opt-in
- Browser-native: first-class, default OFF, opt-in. Built-in providers default
  enabled:true (only surface once configured).
- TTS master toggle defaults OFF; first server-sync auto-enables it when a
  provider is configured (mirrors image/video).
- Persist v3->v4 migration normalizes the never-user-editable flags.

Server/admin disable (server precedence): server-providers.yml `enabled:false`
and env `TTS_<P>_ENABLED=false` (incl. browser-native). Surfaced as a per-provider
`disabled` flag on /api/server-providers; merged as serverDisabled; enforced at
the /api/generate/tts trust boundary, classroom media generation, and /api/health.

UI
- Settings TTS panel: per-provider enable toggle, disabled (with a hint) when the
  provider is unavailable, locked off (with a note) when server-disabled.
- AgentBar voice pills render disabled with a "No voice" placeholder when no
  provider is enabled. The toolbar media-popover TTS tab keeps its existing
  on/off toggle (no picker/CTA).

Tests: predicates, resolver determinism + unified selectable list, server
force-disable, store merge + migration + TTS auto-enable, i18n parity. Verified
in a real browser (Playwright): settings toggle states + a full Gemini classroom
generation whose TTS requests used the picked voice.

Found on main @ 2586cb5.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-07 17:25:57 +08:00
d2b6db60a4 fix: point SSRF local-network rejection at ALLOW_LOCAL_NETWORKS escape hatch (#667)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-06 11:37:07 -04:00
2586cb5e05 fix(tts): surface provider rate-limit errors as HTTP 429 (#644)
TTS providers throw a plain `Error` on any non-OK response, and the
`/api/generate/tts` route catches everything as `GENERATION_FAILED` (500),
so an upstream 429 (quota/concurrency) was indistinguishable from a real
failure — clients had no signal to retry/back off. `TTSRateLimitError`
already existed for exactly this (with a TODO noting the route collapses
everything uniformly), but only Doubao threw it and the route never mapped
it.

- Add a `RATE_LIMITED` API error code.
- Map upstream HTTP 429 to `TTSRateLimitError` at every provider's
  `!response.ok` site via a shared `throwIfTtsRateLimited` helper.
- Return `429 RATE_LIMITED` from the TTS route when a `TTSRateLimitError`
  is caught (all other errors unchanged).

Purely additive: non-429 responses keep their existing error messages.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-03 12:11:15 +08:00
wyuc 2165e55bac feat: add MiniMax web search provider (#634) 2026-06-02 11:47:13 +08:00
35d36905d1 Make server-configured providers admin-managed, not client-overridable (#620) (#624)
* refactor(providers): make server-configured providers admin-managed, not client-overridable

A server-configured provider is now authoritative: the operator owns its API
key and base URL, and the client cannot override either. This collapses the
tri-state (server key / client base URL / client key) that was the root of a
recurring "missing API key" bug class — when the settings UI echoed a base URL
with an empty key, routes that treated "any base URL ⇒ fully client-configured"
dropped the server key. #533 patched that per-route via canUseServerApiKeyForBaseUrl;
this removes the guard entirely by fixing the design.

Server:
- resolve*ApiKey/BaseUrl ignore client values when the provider is server-
  configured; add isServerConfiguredProvider(section, id); drop
  canUseServerApiKeyForBaseUrl + normalizeProviderBaseUrl.
- getServer*Providers() no longer return base URLs — only allowed models
  (LLM/image) and the managed flag (presence) — so an internal gateway/proxy
  endpoint is never exposed to the client.
- routes (chat/image/video/tts/asr/pdf + verify-*) resolve managed providers
  purely server-side; SSRF validation now applies only to unmanaged (client)
  base URLs.

Client:
- drop serverBaseUrl from ProviderSettings, the settings store, request-building
  (TTS base URL no longer echoes the server URL) and voice resolution.
- settings UI renders managed providers read-only: the key/base-URL override
  inputs and region toggles are hidden, leaving the "managed by server" notice.

Tradeoff: overriding a server provider's key with your own for the same provider
is removed; add a separate custom provider instead. Closes #620.

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

* chore(providers): refresh managed-provider copy, drop dead audio-settings, add #620 e2e

- serverConfiguredNotice now says the provider is managed and not editable here,
  pointing users to add a separate provider for their own credentials (7 locales).
- delete components/settings/audio-settings.tsx — unreferenced dead code.
- add e2e/tests/managed-provider-620.spec.ts: managed provider hides the
  key/base-URL inputs; unmanaged provider keeps them.

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

* feat(providers): lock the model catalog for managed providers with a pinned list

When the operator pins an allowed model list (MODELS env/yaml ⇒ serverModels),
the model catalog is admin-managed too: the LLM provider panel shows the pinned
models read-only with a "Server" badge and no add / reset / edit / delete
affordances. Without a pinned list the server manages only credentials and the
user keeps curating models.

This removes the prior inconsistency where the settings panel let you add models
to a pinned managed provider that the generation toolbar then filtered out.

e2e: the managed-openai case now also asserts the catalog is locked.

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

* fix(providers): ignore (not reject) client overrides for managed web-search; clear legacy serverBaseUrl

Addresses review feedback on #624:

1. Managed web-search providers now ignore a client-sent key/baseUrl instead of
   validating and rejecting it. A server-configured provider is admin-owned, so
   the server config is authoritative and a stale client base URL is dropped
   rather than 400-ing the request. The allowlist validation still runs for
   unmanaged providers, where the client base URL is actually used.

2. Drop the removed `serverBaseUrl` field from persisted localStorage on
   rehydrate (settings version 2 -> 3, stripped in both migrate and merge), so
   old clients don't keep a stale server URL in client state.

Tests: web-search route test split into unmanaged-rejects-allowlist and
managed-ignores-client-baseUrl; settings rehydrate test asserts a legacy
serverBaseUrl is stripped. Full suite 381 pass; e2e 2 pass.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-05-31 21:28:22 +08:00
86c8e0c543 feat: inline external assets at export for offline classrooms (#596) (#613)
* feat(export): add external-asset reference scanner for HTML inlining

* feat(export): add cached asset fetcher and data-uri encoder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): recursively inline url() refs inside stylesheets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): orchestrate inlining of links, scripts, images, and inline css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(export): drop dead escapeAttr, harden importmap skip in scanner

* feat(export): inline importmap modules incl three/addons graph closure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(export): extract shared module to break inline-assets import cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): inline interactive-scene assets into .maic.zip at export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(export): inline interactive assets in Resource Pack export too

* docs: document offline export asset inlining

* style: prettier formatting

* fix(test): resolve tsc type errors in export-classroom-inline test

* fix(export): keep importmap prefix fallback on partial failure, chunk base64, preserve media attr, log pptx inline failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(export): parallelize asset fetching, share fetcher across scenes, promise-cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(export): retry transient asset fetches, bound concurrency, warn user on partial inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(export): inline only woff2 fonts, drop woff/ttf siblings to halve size

Within @font-face blocks that have a woff2 url(), rewrite sibling woff/ttf/otf/eot
refs to url(about:invalid) instead of inlining them. Falls back to full inline for
ttf-only blocks. Drops katex output from ~1.79MB to ~0.71MB (~3x fewer font fetches).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(export): translate partial-inline warning and surface failed asset hosts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(proxy-media): follow redirects with per-hop SSRF revalidation (#398)

Replace blanket redirect rejection with a bounded 5-hop follow loop that
re-validates every Location target through validateUrlForSSRF before
fetching, preventing redirect-to-internal SSRF while allowing CDN
redirects (e.g. cdn.tailwindcss.com 302→/3.4.17).

* feat(export): route asset inlining through proxy-media to bypass CORS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(export): keep importmap prefix fallback, forward proxy 4xx, cap proxy size, re-export FetchAsset

* style: prettier formatting for proxy-media + proxied-fetch

* fix(export): report CSS-nested asset failures in inline report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-05-31 18:36:39 +08:00
LooThaoandwyuc 6d29bbe9af Fix server API key fallback when client echoes provider base URL (#533)
* Fix server API key fallback for provider base URLs

* Format verify PDF provider route

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-05-30 13:16:41 +08:00
Ismail Ariyan 07115dff17 feat(asr): add Azure STT (Fast Transcription) provider (#175)
* add Azure ASR config and localization

* add transcribeAzureASR func

* fix(settings): prevent Azure STT crash in ASR modal

* feat(i18n): add Azure STT provider support in language locales
2026-05-27 22:38:49 +08:00
Yizuki_Ame 47cc2a510c feat: add Brave Search and Baidu Search integration
Add Brave Search and Baidu Search providers with Baidu sub-source support, provider configuration updates, tests, and docs links.
2026-05-13 20:28:56 +08:00
wyuc 2dff6d10be feat: add generated video manifest refs (#540)
* feat: add generated video manifest refs

* style: format video manifest changes

* fix: address video manifest review feedback
2026-05-09 20:56:31 +08:00
Helckandxuruiray 46b61dedfd feat: add HappyHorse video adapter (#509)
Add Alibaba Cloud DashScope HappyHorse text-to-video support as an OpenMAIC video generation provider.

Co-authored-by: xuruiray <15962843+xuruiray@users.noreply.github.com>
2026-05-09 10:07:31 +08:00
be759a8252 feat: add lemonade to provider (#508)
* feat: add lemonade to provider

* fix(lemonade): correct TTS/ASR model IDs and expand Kokoro voices

- TTS model: kokoro → kokoro-v1
- ASR model: single 'whisper' → 6 Whisper variants (Base/Large-v3/Large-v3-Turbo/Medium/Small/Tiny), default Whisper-Base
- Expand lemonade-tts voices to full Kokoro v1 set (54 voices across en-US/en-GB/zh-CN/ja-JP/es/fr-FR/hi/it-IT/pt-BR)

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

* fix(lemonade): zh-TW i18n parity, real Kokoro default voice, language code consistency

- zh-TW.json: add missing providerLemonadeTTS/ASR/Image keys
- TTS default voice: 'default' → 'af_heart' (Kokoro expects real voice ID, not placeholder)
- Voice language codes: 'es'/'hi' → 'es-ES'/'hi-IN' for consistency

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

* test(lemonade): add unit tests for image/TTS/ASR adapters and wav-utils

Covers:
- lib/media/adapters/lemonade-image-adapter: request shape, base URL fallback, custom model, optional Bearer auth, error paths, connectivity probe
- lib/audio/tts-providers (lemonade-tts case): request body (model/voice/format), af_heart fallback, optional auth, error path
- lib/audio/asr-providers (lemonade-asr case): WAV-only validation, model/language form fields, "audio is empty" graceful empty result, error propagation, default model fallback
- lib/audio/wav-utils: isWavBlob detection by MIME and filename, normalizeASRUploadAudio pass-through and wav handling

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

* fix(lemonade): remove text path fork and harden fallbacks

* style: format lemonade tts files

* i18n: add lemonade voice language labels

* fix(lemonade): align image default model and harden wav detection

* test(lemonade): fix File BlobPart typing

* fix(lemonade): simplify tts voice selection and clear asr test state

* docs: highlight Lemonade support

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-05-08 13:53:37 +08:00
HelckandCodex 58acad55d8 feat: support OpenAI image env fallback (#510)
Summary:
- Enable the OpenAI image provider from standard OpenAI server env vars when image-specific env vars are absent.
- Add provider-config coverage for resolving the fallback API key and base URL.

Rationale:
- Allows gpt-image-2 image generation to work with common OpenAI proxy configuration.

Tests:
- pnpm vitest run tests/media/openai-image-adapter.test.ts tests/server/provider-config.test.ts
- pnpm prettier lib/server/provider-config.ts tests/server/provider-config.test.ts --check
- pnpm lint -- lib/server/provider-config.ts tests/server/provider-config.test.ts

Co-authored-by: Codex <codex@openai.com>
2026-05-04 16:59:45 +08:00
wyuc dbe1d2ec76 [codex] Add Bocha web search provider (#524)
* Add Bocha web search provider

* Address Bocha web search review feedback
2026-05-04 12:06:05 +08:00
wyuc 5e12ad892c [codex] add VoxCPM2 TTS provider (#496)
* add voxcpm2 tts provider

* address voxcpm review feedback

* translate voxcpm locale strings

* narrow toolbar model pill

* refine toolbar media controls

* address voxcpm review followups
2026-04-26 23:45:23 +08: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 a95aa87bf1 support recent model providers (#481) 2026-04-24 15:29:12 +08:00
wyucandClaude Opus 4.7 550c43a3f3 fix(generation): unify languageDirective across outline + scene pipeline (#472) (#474)
* fix(generation): unify languageDirective across outline + scene pipeline (#472)

Root issue: languageDirective was dropped, renamed, or hardcoded at several
points in the scene-generation pipeline, silently degrading prompt-level
language control. Slide paths survived because their fallback happened to
hint requirement-language; widget paths degraded to zh-CN default.

Fix plan (all code-only threading + prompt hygiene, no schema changes):

1. Thread languageDirective through every buildPrompt in scene-generator.ts:
   - Add param to generateSlideContent / generateQuizContent / generatePBLSceneContent
   - Forward from generateSceneContent's slide / quiz / pbl branches
   - Include key in the four generateSceneActions buildPrompt calls
   - Demote generatePBLSceneContent's hardcoded English to a fallback
   - Drop the dishonest `as 'zh-CN' | 'en-US'` cast on the widget path

2. Unify variable naming across widget templates + callers:
   - Rename {{language}} → {{languageDirective}} in 6 widget templates
     (simulation / diagram / code / game / visualization3d / widget-teacher-actions)
   - generateWidgetContent and generateWidgetTeacherActions now pass
     `languageDirective: languageDirective || ''` instead of a locale-code shim

3. Make the outline LLM reliably emit the wrapper object:
   - requirements-to-outlines/system.md + user.md restructured so the
     "top-level JSON object with languageDirective + outlines" contract is
     surfaced up-front, short, and unambiguous — drowning-by-scene-examples
     removed. Important Reminders reordered: top-level shape first.
   - interactive-outlines/system.md + user.md aligned with main outliner:
     adds full Language Inference section, requires wrapper output, drops
     the orphan {{language}} placeholder that never had a value source.

4. Remove lib/prompts/loader.ts prompt/snippet caches:
   - Disk reads for prompt templates are not on any hot path (LLM call
     dominates by 5 orders of magnitude). The cache's only observable
     effect was blocking prompt edits from taking effect without restart
     — which masked the wrapper bug during this investigation.

Verification:
- 7 new regression tests in tests/generation cover directive threading at
  each buildPrompt site plus the PBL caller-forwarding guarantee.
- outline-language eval (50 scenarios, gemini-3-flash-preview inference,
  qwen-3.5-flash judge): 48/50 (96%). The 2 persistent failures are in
  the proxy-request category (teacher / tutor preparing lessons for
  students) where the system prompt's rule 5 has genuine ambiguity —
  orthogonal to this fix.
- Curl burst tests: 8/8 runs each for regular-mode and interactive-mode
  outline endpoints emit the wrapper object and extract a detailed
  inferred directive.

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

* style: apply prettier formatting to loader.ts and new test file

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

* fix: thread languageDirective in scene-builder and classroom-generation

Code review caught two additional sites missing the directive:
- lib/server/classroom-generation.ts:371 — generateSceneActions call
  was missing languageDirective, reproducing the issue #472 symptom
  in the shareable-classroom server generation path.
- lib/generation/scene-builder.ts:114 — buildSceneFromOutline's
  generateSceneActions call had the same miss; latent (no current
  live caller) but wrong for consistency.

Also renumber duplicate `8.` in requirements-to-outlines/system.md
scene-level rules.

Extend regression tests with a buildSceneFromOutline end-to-end case
that captures both the content and actions prompts via a stub aiCall
and asserts the directive reaches both phases — this would have
caught the scene-builder miss.

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

* review: widget-path test + docs + fallback constant (PR #474)

Address @cosarah's review feedback:

- Add widget-path regression test (tests/generation/...): interactive
  scene with widgetType=simulation captures both widget content and
  widget-teacher-actions prompts, asserts the directive reaches each
  and no `{{language}}` / `{{languageDirective}}` placeholder leaks.
  This is exactly the path issue #472 was filed for.

- Rewrite lib/prompts/README.md "Caching" section: the cache was
  removed in this PR; README still instructed callers to use a
  clearPromptCache() function that no longer exists.

- Sync interactive-outlines/user.md with the main outliner's
  "## Language Context" reminder block (line 13-19) so both outline
  paths give the LLM the same language-inference reminders.

- Hoist 'Teach in the language that matches the user requirement.'
  into DEFAULT_LANGUAGE_DIRECTIVE, exported from outline-generator.
  Replaces 4 copies across outline-generator, scene-generator, and
  the scene-outlines-stream route.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:00:06 +08:00
wyucandClaude Opus 4.7 f40c92f1aa refactor(orchestration): migrate prompt builders to file-based templates (#459)
* refactor(prompts): move loader to lib/prompts/

Move file-based prompt loader from lib/generation/prompts/ to
project-level lib/prompts/ so it can be shared by orchestration
and PBL prompts in subsequent commits.

- Templates and snippets moved alongside the loader
- getPromptsDir() now points at lib/prompts/
- Generation pipeline imports updated to @/lib/prompts
  (outline-generator, scene-generator, search-query-builder,
   scene-outlines-stream route)
- Add tests/prompts/loader.test.ts as sanity gate

Pure file move — no behavior change.

* test(orchestration): add baseline snapshots for prompt builders

Snapshot tests cover variant matrix:
- buildStructuredPrompt: role × scene-type × peer/ledger/discussion/profile
- convertMessagesToOpenAI: mixed message kinds
- summarizeConversation: truncation
- buildDirectorPrompt: Q&A vs discussion, ledger, profile
- buildPBLSystemPrompt: default config

These lock current output so the body refactor in subsequent
commits can be verified byte-equal.

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

* test(orchestration): plug snapshot coverage gaps from review

- Split convertMessagesToOpenAI test: same-agent (existing) +
  cross-agent (new). The original test passed currentAgentId
  matching the message's agentId, so the cross-agent role-conversion
  branch was never exercised.

- Add 'teacher / quiz scene' snapshot to lock the spotlight/laser
  strip path in getEffectiveActions. The previous variant-7 test
  (whiteboard-open) only triggered the mutual-exclusion warning,
  not the strip — renamed it to reflect what it actually tests.

- Add assistant-role variant (was uncovered: ROLE_GUIDELINES,
  buildLengthGuidelines, buildWhiteboardGuidelines all branch on
  role).

* refactor(orchestration): extract summarizers from prompt-builder.ts

Move state-context, virtual whiteboard ledger replay, peer context,
message converter, and conversation summary out of the 890-line
prompt-builder.ts into focused modules under lib/orchestration/summarizers/.

prompt-builder.ts now only owns prompt assembly. director-graph
imports updated to the new locations.

Snapshot tests pass byte-equal — no behavior change.

* refactor(orchestration): move agent-system prompt to template file

The buildStructuredPrompt template literal is now
lib/prompts/templates/agent-system/system.md, assembled by a
thin variable-pass through the shared prompt loader.

Per-variant ternaries (format example, ordering, spotlight
examples, mutual-exclusion note) are kept as module-level
constants in prompt-builder.ts. ROLE_GUIDELINES,
buildLengthGuidelines, and buildWhiteboardGuidelines stay too
(they may further migrate to template partials in Phase 2).

Also: PROMPT_IDS gains a 'satisfies Record<string, PromptId>'
clause to prevent constant/union drift as more IDs are added.

Snapshot tests (12 prompt-builder + 4 director + 1 pbl) pass
byte-equal.

* refactor(prompts): align agent-system template with project conventions

- Rename agent-system placeholders snake_case → camelCase to match
  the convention already used by slide-actions/user.md and other
  generation templates. Settles convention before Task 5 lands two
  more templates that would otherwise inherit drift.

- Extract '## Speech Guidelines (CRITICAL)' into
  lib/prompts/snippets/speech-guidelines.md for reuse by director
  and PBL prompts in Task 5.

Snapshot tests pass byte-equal — placeholder name changes are
invisible in rendered output, and snippet inclusion happens at
load time.

* refactor(orchestration,pbl): move director + PBL prompts to templates

director-prompt.ts and pbl-system-prompt.ts now use the shared
template loader. Bodies collapse to thin variable assembly.

Three orchestration-tier prompts now share one infrastructure:
agent-system, director, pbl-design.

Snapshot tests pass byte-equal.

* refactor(orchestration): extract WhiteboardActionRecord + AgentTurnSummary to types module

These two interfaces are imported by 6+ modules including
summarizers/ — having them live in director-prompt.ts created
an awkward upward import direction (summarizers reaching back
to a sibling prompt builder for types).

Move them to lib/orchestration/types.ts and update all callers.
director-prompt.ts now imports them too.

Pure type-location refactor — snapshots pass byte-equal.

* test: remove snapshot tests in favor of eval-as-integration-test

Per review feedback: byte-equal snapshots were the wrong invariant
for this refactor. The goal was a stable substrate for editing
prompts, not a frozen contract. Snapshots would have generated
maintenance churn (~100-200 line diffs per intentional Phase 2
prompt tweak) without providing value the eval doesn't already.

Test posture going forward:
- tests/prompts/loader.test.ts (3 tests) — bounds the loader
  infrastructure (template load, snippet inclusion, variable
  interpolation, missing-id behavior)
- pnpm eval:whiteboard — bounds end-to-end agent loop, template
  composition, and integration with chat/director/state-manager

Removed: 1879 lines of .snap + 320 lines of test scaffolding +
fixtures. Net PR diff drops by ~2200 lines.

* docs(prompts): add README + structural assertion tests (review feedback)

Addresses two items flagged by PR #459 final review:

1. lib/prompts/README.md — conventions, syntax, gotchas, and local-testing
   recipe for template authors. Previously there was no doc for the stated
   goal of "non-engineers can edit prompts."

2. tests/prompts/templates.test.ts — 8 structural assertions covering:
   - no surviving {{...}} placeholders in any rendered template
   - role dispatch (teacher → LEAD TEACHER, student → not)
   - scene-type action stripping (quiz scene has no spotlight/laser)
   - director prompt output spec mentions next_agent

   These replace the removed byte-equal snapshot suite at much lower
   maintenance cost — they assert behaviors the refactor must preserve,
   not exact bytes.

* chore(prompts): relocate templates added on main during rebase

PR #461 (interactive mode clean) added 7 new templates under
lib/generation/prompts/templates/ (the pre-refactor location).
Rebase onto origin/main landed them at the old path; git's rename
detection didn't forward them through the directory move in db56c40.

Manually move them to lib/prompts/templates/ to match the new
convention, and fix scene-generator.ts's relative import
('./prompts/types' → '@/lib/prompts/types') since the dir no
longer exists as a sibling.

No behavior change — templates read from PROMPT_IDS values via
getPromptsDir() which already points at lib/prompts/.

* refactor(prompts): address review feedback from @cosarah

- loadSnippet now throws on missing file instead of silently returning
  a literal {{snippet:id}} string. A typo like {{snippet:speach-guidelines}}
  now fails at load time instead of reaching the LLM.

- README gains a "Still in TypeScript" section listing the role-conditional
  content that still lives in prompt-builder.ts (ROLE_GUIDELINES,
  buildLengthGuidelines, buildWhiteboardGuidelines) so contributors
  expecting a pure-markdown workflow know where to look.

- Expand tests/prompts/templates.test.ts to cover the conditional branches
  Phase 2 is most likely to touch (9 new assertions):
  - assistant role dispatch
  - peer-context section toggles on agentResponses presence
  - language constraint toggles on stage.languageDirective presence
  - director Q&A vs discussion mode branching
  - pbl-design {{issueCount}} substituted at all 3 occurrences
  - placeholder-naming-convention lint scans every template for
    non-camelCase placeholders (slide-content grandfathered)

- Comment above interpolateVariables regex documents why kebab-case
  placeholders pass through silently (the lint test now catches them).

- New test in loader.test.ts locks the throw-on-missing-snippet behavior.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:41:17 +08:00
Shangqing TuandClaude Opus 4.7 c02a607377 feat: interactive mode clean (#461)
* feat: add interactive mode

* feat: add TeacherActions for interactive mode

* fix: better prompt for ultra mode

* feat: upgrade normal mode's interactive generation with 5 widget type and actions

* feat: make interactive mode button clear

* fix: remove unused TeacherOverlay

* fix: resolve PR #458 review feedback

- Revert unrelated infra changes (Dockerfile, docker-compose.yml)
- Fix placeholder strings in generateWidgetContent
- Rename ultraMode to interactiveMode consistently across codebase
- Extract duplicated patchHtmlForIframe to shared utility
- Fix widget store race condition by keying callbacks by sceneId
- Fix teacher-action ID collisions by using descriptive IDs in prompt
- Relax outline prompt constraints for flexibility
- Optimize sidebar thumbnail with IntersectionObserver lazy loading
- Add trailing newlines to modified files

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

* fix: update generateSceneActions call signatures

The function signature changed to individual parameters instead of
options object pattern after cherry-pick.

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

* style: fix prettier formatting

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

* fix: removed extra language arg

* style: fix prettier formatting v2

* fix:  add missing i18n keys

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:35:30 +08:00
wyucandClaude Opus 4.6 91c4015732 feat(pdf): add MinerU Cloud API as PDF parsing provider (#438)
* feat(pdf): add MinerU Cloud API as PDF parsing provider

Add MinerU Cloud (v4 API) as a new PDF provider alongside unpdf (built-in)
and MinerU (self-hosted). Users can now parse PDFs via the cloud API without
deploying a self-hosted MinerU instance.

- New provider `mineru-cloud` with API key auth and optional base URL
- Cloud flow: batch create → presigned upload → poll → ZIP download → parse
- Extract shared `mineru-parser.ts` from inline code (used by both paths)
- Settings UI adapted for cloud (API key required) vs self-hosted (base URL required)
- Server-side env var support: PDF_MINERU_CLOUD_API_KEY / PDF_MINERU_CLOUD_BASE_URL
- SSRF protection on cloud verification endpoint
- i18n translations for en-US, zh-CN, ja-JP, ru-RU

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

* fix(provider-config): revert PDF activation to requiresBaseUrl

Reverts the PDF section from keylessProviders back to requiresBaseUrl: true
to fix failing test: mineru with only apiKey (no baseUrl) should not activate.
mineru-cloud API key can be configured via browser settings UI instead.

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

* fix(pdf-settings): enable test button when API key is server-configured

The test connection button was disabled when the API key was only
configured server-side (not entered by user in browser). Now checks
isServerConfigured in addition to user-entered values.

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-16 15:15:55 +08:00
杨慎andClaude Opus 4.6 470aa5e42e feat: inline language inference for outline and PBL generation (#412)
* feat: inline language inference in outline generation

Replace manual language selection with automatic inference from user
requirement text. The outline generator now produces a languageDirective
that propagates through the entire generation pipeline.

Key changes:
- Rewrite Language Inference section in outline prompt with decision
  rules for foreign language learning, cross-language PDF, proxy
  requests, and terminology handling
- Reorder pipeline: outlines before agents, so agent profiles can
  use the inferred languageDirective
- Pass languageDirective through scene content/actions generation
- Add SSE streaming of languageDirective from outline generation
- Remove manual language selection UI

Eval test suite:
- 50 test cases covering 9 scenario types: single-language, language
  learning, immersive, explicit instruction, code-switching, minimal
  input, user profiles, cross-language PDF, locale mismatch
- LLM-as-judge evaluation with configurable inference/judge models
- 50/50 pass rate with gemini-3-flash-preview + gpt-4o judge
- Excluded from CI (requires LLM API keys)

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

* chore: remove useless attributes in test case

* feat: pass languageDirective to PBL, remove hardcoded zh-CN/en-US

- Remove pblConfig.language field, use languageDirective from outline
  inference instead
- Delete all Chinese prompt duplicates in pbl-system-prompt.ts,
  agent-templates.ts; keep English templates with languageDirective
  injection
- Remove zh-CN/en-US branches in generate-pbl.ts (initial prompt,
  post-process context, welcome message)
- Pipe languageDirective through scene-generator → generatePBLContent
  → IssueboardMCP → agent templates
- Remove i18n template wrapping from PBL welcome messages in
  pbl-renderer.tsx and use-pbl-chat.ts; use LLM-generated content
  directly to avoid language mismatch when UI locale ≠ course language
- Update pblConfig schema in outline prompt (remove language field)

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

* refactor: clean up legacy language fields, pass languageDirective through buildSceneFromOutline

- Add languageDirective param to buildSceneFromOutline, combining with
  outline.languageNote via buildLanguageText
- Remove dead types: AudienceProfile, StylePreferences, LegacyUserRequirements,
  SceneOutline.language
- Remove Stage.language and all its propagation (storage, stageInfo, prompt-builder fallback)
- Remove GenerateClassroomInput.language and job store inputSummary.language
- Remove unused pdfLanguageSample from outline SSE route

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

* fix: persist languageDirective to IndexedDB, fix TTS preview and dead refs

- Add languageDirective to StageRecord and stage-storage write path
- Add DB v9 migration: convert legacy language locale codes to directives
- Use voice.language for TTS preview text instead of dead localStorage key
- Omit empty ## Language section in PBL agent prompts
- Use JSON.parse for extractLanguageDirective unescape (\uXXXX support)
- Remove dead i18n keys: toolbar.languageHint, pbl.chat.welcomeMessage

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:49:56 +08:00
wyucandClaude Opus 4.6 9a0060e2f0 feat(audio): add custom OpenAI-compatible TTS/ASR provider support (#357) (#409)
* feat(audio): extend TTSProviderId/ASRProviderId to support custom providers

* feat(audio): update provider registry helpers for custom provider support

* feat(audio): route custom providers to OpenAI-compatible implementations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(audio): add custom TTS/ASR provider CRUD to settings store

* feat(audio): add dialog for creating custom audio providers

* feat(audio): add custom provider UI with voice management and delete

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

* feat(i18n): add custom audio provider translation strings

* fix(audio): resolve remaining type errors for custom provider support

Add `as keyof typeof` casts where TTSProviderId/ASRProviderId (which now
include template literal types) index into the built-in-only registries
TTS_PROVIDERS, ASR_PROVIDERS, DEFAULT_TTS_VOICES, and DEFAULT_TTS_MODELS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(audio): allow custom provider IDs to reach switch default branch

The early guard `if (!provider) throw` blocked custom provider IDs from
reaching the switch-case default branch where they get routed to
OpenAI-compatible implementations.

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

* feat(audio): add default model field to dialog and polish voice list UI

- Add "Default Model" input to custom audio provider dialog (fixes
  Kokoro needing model:"kokoro" instead of fallback "gpt-4o-mini-tts")
- Redesign voice list as aligned table with column headers, hover
  reveal delete buttons, and empty state message
- Add Enter key support to voice add inputs
- Use consistent grid layout for add voice row

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

* fix(audio): fallback to customDefaultBaseUrl when testing custom TTS provider

The baseUrl field in provider config is empty by default (it's an
override field). Custom providers store their default URL in
customDefaultBaseUrl, which needs to be used as fallback for test.

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

* fix(audio): auto-select first voice when adding to custom TTS provider

When a custom provider's voice is 'default' (initial state before any
voices are added), adding the first voice now automatically updates the
active voice selection so test TTS works immediately.

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

* fix(audio): fallback to customDefaultBaseUrl in all TTS call sites

Scene generator, discussion TTS, and agent bar voice preview all read
providerConfig.baseUrl which is empty for custom providers (the default
URL is stored in customDefaultBaseUrl). Add fallback chain to all 4
call sites so custom providers work during actual playback, not just
in the settings test.

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

* fix(audio): include custom provider voices in agent voice picker

getAvailableProvidersWithVoices only iterated built-in TTS_PROVIDERS,
so custom providers never appeared in the agent bar voice dropdown.
Now also iterates ttsProvidersConfig for custom-tts-* entries and
includes their customVoices in the picker.

Also updated getServerVoiceList, findVoiceDisplayName, and
resolveAgentVoice to handle custom provider voice lookups.

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

* fix(audio): fallback to customDefaultBaseUrl in all ASR call sites

Same issue as TTS: audio-recorder, asr-settings, and audio-settings
all read providerConfig.baseUrl which is empty for custom providers.
Add customDefaultBaseUrl fallback to all 3 ASR call sites.

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

* feat(audio): add model list management for custom ASR providers

Custom ASR providers now support adding/removing models with a table
UI (same design as TTS voice list). The model selector dropdown shows
custom models and auto-selects the first one.

Also adds custom ASR providers to the Toolbar's ASR provider dropdown
and fixes custom provider handling in audio-settings.tsx (baseUrl
fallback, request URL preview, API key section visibility).

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

* fix(audio): seed customModels from defaultModel and fix ASR model label

- When creating custom ASR provider with a defaultModel, auto-add it
  to customModels so the model list isn't empty on first visit
- Fix ASR settings label from "TTS Model" to "Default Model"

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

* fix(audio): separate ASR model management from creation dialog

ASR models should be managed in provider settings (add/remove from
list, select default), not pre-filled at creation time. Unlike TTS
where a single default model suffices, ASR users need to manage
multiple models (e.g. whisper-small, whisper-large-v3).

- Remove Default Model field from ASR creation dialog (keep for TTS)
- Remove defaultModel parameter from addCustomASRProvider
- ASR models are added via the model list UI in provider settings

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

* fix(audio): simplify ASR model management — first model is default

Remove the separate model selector dropdown for custom ASR providers.
The first model in the list is always used as default. Users manage
the model list only (add/remove), no separate "default" selection.

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

* fix(audio): add loading state and detailed errors to ASR test

- Show spinner with "Processing..." while waiting for transcription
- Disable record button during processing
- Show specific error messages from API (e.g. "No transcription
  generated") instead of generic failure text
- Handle empty transcription result as an error with helpful message

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

* fix(i18n): use correct key for processing label in ASR test button

* fix(audio): polish custom provider UX and type safety

- Add proper type declarations for custom provider fields (customName,
  customDefaultBaseUrl, customVoices, etc.) eliminating 30+ unsafe casts
- Show custom ASR providers in toolbar with default language list
- Hide custom ASR providers without models from toolbar selection
- Add no-models warning in ASR settings panel
- Add delete confirmation dialog for custom TTS/ASR providers
- Add duplicate voice/model ID validation with toast error
- Add dedicated i18n keys for ASR model management (modelNamePlaceholder, addModel)
- Fix i18n key for ASR processing label (settings.asrProcessing)
- Add radio-button model selection in custom ASR provider settings
- Add visible OpenAI-compatible description in provider creation dialog
- Add CUSTOM_ASR_DEFAULT_LANGUAGES constant for toolbar language options

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

* fix(test): add isCustomTTSProvider/isCustomASRProvider to audio types mock

The settings-server-sync test mocked @/lib/audio/types as an empty object,
but settings.ts now imports isCustomASRProvider for provider validation.

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

* fix(audio): add customDefaultBaseUrl fallback in all TTS/ASR call sites

When a custom provider is created, the actual URL is stored in
customDefaultBaseUrl while baseUrl is initialized empty. Several call
sites only checked baseUrl, causing fetch failures during course
generation. Add fallback to customDefaultBaseUrl in:
- generation-preview/page.tsx (course generation TTS)
- tts-providers.ts (getCurrentTTSConfig)
- asr-providers.ts (getCurrentASRConfig)
- media-popover.tsx (TTS preview)
- tts-config-popover.tsx (TTS preview)

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

* style: format generation-preview/page.tsx

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

* fix: remove unused ttsSpeedRange variable in media-popover

Flagged by CodeQL code quality check.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:49:09 +08:00
Yizuki_Ame 4a32112d61 fix(#353,#354): conditionally set generatedAgentConfigs based on agentMode (#373)
* fix(#353,#354): conditionally set generatedAgentConfigs based on agentMode

#353: Only embed generatedAgentConfigs in the Stage when agentMode is
'generate'. For default mode, set agentIds instead. This prevents
default agents from being incorrectly marked as isGenerated on the
client-side registry.

Also reset agentMode to 'default' in the catch block when LLM agent
generation fails and falls back to defaults.

#354: Remove redundant setSelectedAgentIds call from Path 1 (server
hydration). Path 2 handles agent restoration uniformly for both
IndexedDB and server-loaded classrooms.

Includes unit tests covering all three scenarios (default, generate,
and LLM fallback).

* fix: replace `as any` with typed interfaces to satisfy eslint no-explicit-any

---------
2026-04-11 18:11:06 +08:00
Yizuki_Ame 96a4448feb fix: resolve DNS before SSRF validation to prevent rebinding bypass (#386)
- Make validateUrlForSSRF async; resolve hostnames via dns.promises.lookup
  with { all: true, verbatim: true } and reject if any address is private
- Add isPrivateIP helper covering IPv4 private ranges, IPv6 loopback/ULA/
  link-local, IPv4-mapped IPv6, deprecated fec0::/10 site-local, 6to4
  tunnel (2002::/16), and Teredo tunnel (2001:0000::/32)
- Detect IP literals via net.isIP() and validate directly without DNS
- Fail closed on DNS resolution errors
- Propagate async to resolveModel, resolveModelFromHeaders, and all 20+
  route-level callers; preserve existing production-only gating
- Add 14 unit tests covering public pass-through, private literals, DNS
  rebinding, mixed DNS answers, tunnel addresses, and DNS failure

Closes #378
2026-04-11 15:51:30 +08:00
2c26e3a461 feat: Ollama local LLM support (#94)
* feat: Ollama local LLM support

- Ollama as a built-in provider (OpenAI-compatible, no API key needed)
- 8 default models: llama3.3, llama3.2, qwen2.5, qwen2.5:32b, mistral, gemma3, deepseek-r1, phi4
- Keyless provider activation: providers with only a baseUrl (no API key) now activate correctly
- isProviderKeyRequired() helper for consistent key validation across routes
- i18n strings for Ollama (zh-CN + en-US) and missing Doubao strings
- SSRF guard documentation clarifying server-configured URLs bypass safely

Previously, clients could send `x-requires-api-key: false` (header) or
`requiresApiKey: false` (body) to skip API key validation for ANY provider,
including ones like OpenAI that require keys. This meant a client could
consume server-configured credentials without authorization.

Fix: the server now derives requiresApiKey exclusively from its own provider
registry. The client header and body field are removed from all server-side
parsing. For Ollama, the registry says `requiresApiKey: false` so it works
keyless. For OpenAI, the registry says `true` so the check is enforced
regardless of what a client sends.

* fix: address reviewer feedback on Ollama integration

- Fix env-var activation so OLLAMA_BASE_URL alone activates the provider
- Move SSRF bypass comment from ssrf-guard.ts to resolve-model.ts
- Add ollama and doubao provider names to i18n locale files

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

* Fixed issues from reviewer notes.

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

* style: format provider-config.ts with Prettier

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-04-09 21:07:35 +08:00