feat(config): openmaic.yml, capability slots and server-side model configuration (RFC #1701) (#1765)

* ci: run CI for the provider-config integration branch

Development of the provider configuration RFC (#1701) lands on
integration/provider-config; build it on push and run CI for pull
requests into it, as with earlier integration branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): capability slot registry and stage-to-slot mapping (#1726)

* feat(config): capability slot registry and stage-to-slot mapping

First P0 step of the provider configuration RFC (#1701, tracked in
#1725): define the capability slot forest and map every LLM stage key
to exactly one slot. Pure data with no callers yet, so there is no
behavior change.

Requirements are checked on the slot that declares them and are not
passed down to child slots, so agent.title can use a model without
tool calling even though agent requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin every stage destination and the capability roots

Review found that the station and root assertions derived their
expectations from the module under test: re-pointing a single-stage
station or dropping a capability root still passed. The tests now
compare against an independently written stage table and root list,
and pin agent.title as config-only. Also document that browserless
outlines still run on the generate-classroom model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin slotForStage and full slot lineages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(config): provider presets and the openmaic.yml schema (#1727)

* feat(config): provider presets and the openmaic.yml schema

Second P0 step of the provider configuration RFC (#1701, tracked in
#1725).

- lib/config/provider-presets.ts: one preset per built-in registry
  entry, plus the token plans as multi-capability presets whose stage
  recommendations become slot recommendations. Registry ids that
  collide across capabilities get explicit preset ids.
- lib/server/model-config/openmaic-yml.ts: parse and validate the
  operator's openmaic.yml (or the file named by OPENMAIC_CONFIG):
  ${VAR} interpolation, strict schema, and cross-checks that every
  assignment names a declared provider whose preset offers the slot's
  capability. Every problem is reported with its path.
- instrumentation.ts: an invalid file refuses to start. Without a file
  nothing changes, and nothing resolves models through the file yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): harden openmaic.yml validation after review

- Look providers up by own key only, so "constructor:m" is not taken
  for a declared provider.
- Refuse non-mapping objects YAML produces (an unquoted timestamp
  becomes a Date that the schema would accept as an empty object), and
  refuse a YAML alias that refers back to itself instead of overflowing
  the stack.
- Accept lowercase variable names; refuse a "${" with no closing
  brace, checked on the text as written, never on substituted secrets.
- Cross-check the entries that are valid on their own even when the
  schema rejects others, so every problem is reported at once without
  duplicate errors for declared-but-invalid providers.
- Name the three valid assignment shapes when a value has none of them.
- Test the boot path: an invalid file exits with code 1 before any
  schedule starts; a valid one boots.
- Ignore a root openmaic.yml in git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep secrets out of openmaic.yml diagnostics

Review round 2:

- Read only variables the environment itself has, as non-empty
  strings: ${constructor} or an inherited value is not a variable.
- Never print a value that came from ${VAR}, and report YAML syntax
  errors by reason and position without js-yaml's source excerpt,
  which can quote a literal key.
- Keep the base URL requirement on presets: SearXNG from its registry
  entry, and Azure OpenAI and self-hosted MinerU, which have no usable
  default endpoint.
- Check slot names and valid model references even inside an entry
  that fails the schema, and report a failed placeholder once rather
  than again as an empty value.
- Boot test for the no-file case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): validate openmaic.yml in two phases

Rounds 1-3 of review kept finding edge cases in one feature: running
the cross-checks on the half-valid parts of a file the schema had
rejected, so that every problem showed at once. It hid problems inside
a rejected entry, lost a __proto__ slot key, and built misleading
provider errors from failed placeholders. That feature is gone.

Validation now runs in two phases. Phase 1 is the document itself:
placeholders, key names (checked on the document as written, since the
schema drops a __proto__ key), and the schema. Any problem there stops
before phase 2, the references between entries, which therefore only
ever sees a fully valid file. Each phase reports all of its problems,
one per path.

YAML syntax errors now report only their position: js-yaml's reason
can quote source text too (an alias or tag name), not just its excerpt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728)

Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated.

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

* feat(config): resolve a slot through the configuration layers (#1729)

* feat(config): resolve a slot through the configuration layers

Third P0 step of the provider configuration RFC (#1701, tracked in
#1725). resolveSlot walks from a slot to its capability root and
returns the first assignment it meets, consulting the deployment layer
(openmaic.yml, locked) before the workspace layer at every node. An
explicit null disables the subtree; nothing assigned up to the root is
unassigned, with no fallback to any vendor. The result carries the
provider, preset, registry entry, effective base URL, key, model, call
options, fallback, where it was resolved and whether it is locked, and
the requested slot's own requirements checked against the model
catalogue (met, unmet or unknown).

Pure and uncalled for now, so there is no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): lock only written slots and trust the catalogue only where it applies

Review round 1:

- locked now means the requested slot itself is written in the
  deployment layer. Inheriting a deployment value does not lock a slot,
  since the workspace may still assign it (source and resolvedAt still
  report where the value came from).
- A custom OpenAI-compatible endpoint borrows the OpenAI registry for
  transport only, so its preset no longer trusts that model catalogue:
  requirements there resolve to unknown.
- The fallback is checked against the slot's requirements too, and the
  result exposes fallbackRequirements so retries can refuse it.
- A malformed reference fails with a path-qualified SlotResolutionError
  that does not echo the value, which is often a misplaced key.
- Requirement tests use named catalogue models instead of picking one
  from the catalogue at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): order layers deployment-first and keep references out of errors

Review round 2:

- resolveSlot no longer depends on the order its caller passes the
  layers in: deployment layers always come first, for assignments and
  provider lookup alike.
- Resolution errors name the path and the preset, never a value taken
  from the reference, so a key pasted into the provider position does
  not end up in a log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): cover catalogue aliases and fallback capability errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(classroom)!: save server-generated classrooms through server persistence (#1730)

Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending.

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

* feat(config): translate the legacy configuration into a deployment layer (#1731)

* feat(config): translate the legacy configuration into a deployment layer

Deployments without openmaic.yml keep working: provider variables,
server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are
translated into the openmaic.yml shape and serve as the deployment layer
for resolveSlot.

- Providers: each configured entry becomes a provider under its preset
  id; force-disabled entries are left out; AliDocMind's key pair goes
  into credentials.
- DEFAULT_MODEL goes on the llm root. Every other slot that serves stages
  gets what those stages used before (their route, or else the default
  model), written only where inheritance would give something else, so
  an unrouted stage under a routed parent stays on the default model.
- Stages that now share a slot but had different routes, models whose
  provider has no server configuration, and stages that used the
  browser's model but would now inherit a server one become startup
  notices, never failures.
- MODEL_FALLBACK attaches to every chat assignment without its own.
- When openmaic.yml exists it wins, with a notice if legacy variables
  are also set.

Nothing reads the layer yet; routes switch over in P1 (#1725).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): follow the agent driver's rules and keep notices value-free

Review round 1:

- The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver
  route and is off otherwise, so the translation writes agent: null where
  it would inherit a server model (and a notice for a route that does
  not work today). Unrouted conversation titles reuse the driver's model
  with thinking off, as the title generator does.
- Notices name registry ids only; a credential pasted into a model
  variable is never repeated.
- Provider entries and route options are checked against the new schema
  and left out with a notice (field names only) instead of producing a
  file that would not parse; driver-only options are dropped elsewhere.
- A route with its own fallback never falls back to MODEL_FALLBACK, even
  when that fallback cannot carry over; the retry model is part of the
  comparison with the inherited value.
- Force-off switches without a configured entry are reported, a legacy
  configuration made only of them is detected, and translating one adds
  a deprecation notice.
- loadDeploymentLayer reads the process environment and working
  directory like the legacy loaders, instead of taking parameters it
  could only partly honor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): compare routes by behavior, validate references, name only known ids

Review round 2:

- Stages sharing a slot are compared by what the route changes for them
  (model, thinking, fallback), with bare ids read as openai models and a
  route to DEFAULT_MODEL counted as no route; api and contextWindow are
  inert outside the driver.
- A model reference carries over only when it names a provider from the
  providers section and forms a valid reference.
- Section keys and force-off ids are named in notices only when they are
  registry ids with a preset.
- Retries now follow the slot; where a call site picked its retry model
  by another label (scene types under scene-content, browserless
  generation), the change is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): chat references need a chat entry; one notice for retries

Review round 3:

- A model reference carries over only when its provider was translated
  from the providers section, not merely declared by another section
  under the same id.
- Routes are compared by the retry model that takes effect, so leaving
  out a fallback equals repeating MODEL_FALLBACK.
- The per-call-site retry notices kept missing cases (streaming and
  calls outside server-managed routing never retry today). They are
  replaced by one notice, given whenever a retry model is configured,
  that retries now follow the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* refactor(config)!: translate only providers and the default model (#1732)

* refactor(config)!: translate only providers and the default model

MODEL_ROUTES does not map one to one onto slots: several stages share a
slot, the agent driver and conversation titles have rules of their own,
and retries are picked by call-site labels. Emulating that took most of
the translation and still ended in notices that are easy to miss.

The translation now carries over only the unambiguous part: providers,
DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the
agent stays null, as it never used DEFAULT_MODEL). A deployment that
sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it
to write the per-stage models as slots. loadDeploymentLayer is not wired
into startup yet; that happens when routes switch to resolveSlot (P1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): say what a dropped MODEL_FALLBACK loses

Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and
calls that retried on it (server providers picked in the browser) stop
retrying; the notice says so. thinkingSchema is module-private again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(persistence): workspace model configuration with keys encrypted at rest (#1733)

* feat(persistence): workspace model configuration with keys encrypted at rest

The store behind the web settings of RFC #1701 (#1725, P1): one row per
workspace (owner) in workspace_model_config, the same shape as
openmaic.yml without policy.

- Provider secrets (apiKey, credentials) are sealed per provider with
  AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and
  never stored in the config column. Without the variable, a secret is
  created once in data/instance-secret.key (the Docker volume).
- A secret sealed under another instance secret is reported as
  unreadable ("enter again"), and a save that brings no new secret for
  that provider keeps it, so a misconfigured secret cannot destroy keys.
- Saves replace the document with a compare-and-swap on the revision,
  after the owner's identity lock; two concurrent first saves cannot
  both land (PostgreSQL contract test).
- A claim moves the anonymous configuration to the account unless the
  account has its own (core participant, order 900).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): publish the instance secret atomically; force the races in tests

Review round 1:

- The generated secret is written and flushed under a private name,
  then published with an exclusive link, so no process can read it half
  written and exactly one of two starting processes creates it. A file
  that is not a complete generated secret (empty, truncated) is refused
  instead of deriving a key anyone could compute.
- The PostgreSQL test holds both first saves at their insert until both
  have read the missing row, and holds the row lock for the update case
  until both saves queue behind it (checked by backend pid, not by any
  lock waiter in the database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): verify the written secret; require full GCM tags

Review round 2:

- The generated secret is written in full, read back and checked before
  it is published; the temporary file is removed on any failure.
- Sealed values open only with a 12-byte IV and a full 16-byte tag
  (authTagLength), so a shortened tag cannot weaken authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(config): resolve slots at request time over deployment, workspace and defaults (#1734)

* feat(config): resolve slots at request time over deployment, workspace and defaults

The runtime half of RFC #1701 resolution (#1725, P1):

- deploymentConfig(): the deployment layer, loaded once per process.
  The legacy translation now splits into the deployment's providers and
  a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks
  nothing and ranks below the workspace, as DEFAULT_MODEL ranked below
  the model a user picked.
- workspaceLayer(owner) reads the web settings; requestWorkspaceId(req)
  names the request's owner (none for an owner minted by the request).
- lookupSlot walks deployment and workspace over the whole tree first;
  the defaults are a second walk, so a default on a child never outranks
  a workspace choice higher up.
- resolveStageModel builds the language model: the configured slot, else
  what the request still names the old way (deprecated), else the
  defaults, else a loud error; a slot turned off fails whatever the
  request names. A workspace provider's endpoint is checked like a
  caller-supplied one and gets the transport that refuses redirects.
- resolveSlot gains the default source, and reports which layer declared
  the provider, its proxy and credentials.

Nothing calls it yet; call sites switch next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep request identity and workspace endpoints inside their bounds

Review round 1:

- A refused owner credential throws InvalidOwnerCredentialError (401)
  instead of resolving with the deployment's models and keys.
- A retired request owner gets no workspace: canonicalizing it would
  hand an old anonymous cookie the claiming account's settings. Only
  background work, which names a stored owner, is forwarded.
- A workspace provider may not be Amazon Bedrock (which falls back to
  the server's AWS credential chain) or set a proxy (which routes around
  the checked, redirect-refusing transport); the deployment still may.
- Test seams for the deployment config and the workspace loader, and
  tests for identity, forwarding and caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): read only the named owner's settings; refuse unmet requirements

Review round 2:

- workspaceLayer reads exactly the owner it is given. Forwarding through
  a claim let a request whose owner was claimed between its check and the
  read see the account's settings; background work passes the owner it
  works for now instead.
- A model the catalogue says does not meet the slot's requirement is
  refused before anything is built (SlotRequirementError), without
  consulting the request.
- Tests: a database failure fails the call without falling back, a
  workspace provider without a key never gets the deployment's, and the
  transport each provider source gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(identity): exempt the model settings lookup from cookie forwarding

requestWorkspaceId resolves the owner only to pick whose model settings a
generation call uses, and returns no response of its own, like the
vision prompt helper already listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(config)!: resolve every LLM call through its capability slot (#1735)

* feat(config)!: resolve every LLM call through its capability slot

The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1):

- resolveModel resolves a stage through its slot for the request's or
  job's workspace: the deployment and workspace configuration first; the
  model and key a request names (x-model, x-model-routes, body fields)
  only for a slot left unassigned, deprecated; then the defaults an
  older deployment set with DEFAULT_MODEL. Routes that read headers get
  this through resolveModelFromRequest; the chat routes pass their
  workspace; background work (agent runs, titles, generation jobs)
  passes the owner it works for now.
- Retries follow the slot: a slot-resolved model carries its slot's
  fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline
  stream use; only a model from the request path still retries on
  MODEL_FALLBACK. A fallback that cannot meet the slot is not used.
- The agent driver resolves the agent slot: tool calling required,
  api defaults to openai-completions, thinking.effort still refused.
  Conversation titles resolve agent.title with thinking off unless the
  title slot sets it.
- /api/generate-classroom resolves each step through its slot for the
  job's owner, its outline through course.outline.
- MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server
  that still sets it without openmaic.yml refuses to start. The
  pbl-chat and maic-agent stage keys, which nothing resolved, are removed.

BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models
as slots in openmaic.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage

Review round 1:

- callLLM arms a model's attached slot fallback whether or not the caller
  passes serverManaged (the PBL agents pass none); the stamp still gates
  MODEL_FALLBACK on the request path.
- /api/generate-classroom resolves the owner the job works for now at each
  stage, so stages after a claim follow the moved settings.

Streaming calls (agent driver, classroom chat, PBL streams) still do not
retry on a fallback model, as before this change; that is the #1725 item
on retries at every call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(config): resolve media and tool capabilities through their slots (#1737)

* feat(config): a provider-only reference names the provider's default model

Search and document providers mostly have no model to pick, so a slot
may name just the provider (`webSearch: tavily`); the target then has no
modelId and the adapter uses its default. Chat slots still need
providerId:modelId, checked in openmaic.yml and at resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): resolve media and tool capabilities through their slots

The capability routes and server paths of RFC #1701 (#1725, P1): text
to speech, speech recognition, images, video, web search and document
extraction resolve through their slots, like language models.

- lib/server/model-config/media.ts: the configured slot (deployment,
  then workspace); else the provider a request names the old way
  (deprecated); else the legacy defaults; else a loud error. A slot turned
  off fails whatever the request names, and while the legacy
  <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off
  provider stays off whoever assigns it. A workspace endpoint is checked
  like a caller-supplied one and may not use a proxy.
- The legacy translation assigns the media roots the provider the server
  picked when a request named none (first configured; web search by its
  old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned
  model.
- Routes: /api/generate/{image,video,tts,voice}, /api/transcription,
  /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice
  applies only to the provider it was chosen for; voices register on the
  tts slot's provider.
- Server paths: agent image and video generation, scene narration, the
  voice catalog and registration, web search and fetch_url, material
  extraction (the document slot's service, the asr slot for local
  transcription), browserless classroom media, and the generation
  capabilities reported by /api/health and the classroom capabilities
  endpoint.
- A provider-only reference (`webSearch: tavily`) names the provider's
  default model; chat slots still need providerId:modelId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep slots authoritative on every media path

- Workspace providers reach media, search and document services only at
  their preset's endpoints; a custom endpoint or proxy is deployment-only
  (403 INVALID_URL).
- A configured or turned-off document slot decides the extraction service:
  deprecated request fields may only pick a self-contained extractor, and
  legacy operator credentials are no longer reached.
- Request paths resolve their own workspace without following a claim;
  background extraction still does.
- A configured TTS slot keeps its own model; legacy pins apply only on the
  deprecated and default paths.
- Local transcription and agent voice registration keep the connection's
  network policy flags.
- The agent's web search forwards the whole resolved configuration.
- An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a
  notice instead of switching vendors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): public presets only for workspaces, plan default models

- A workspace preset whose default endpoint is on the server's own network
  (self-hosted TTS, ASR, image) is deployment-only, and a workspace
  provider's endpoint runs under the public-only policy.
- A provider-only reference to a token plan means the plan's own default
  model for that capability.
- On the legacy default provider, the request's image, video and ASR model
  still applies through its allowlist, and image/video without a model is
  still MISSING_MODEL.
- An asr assignment a workspace may not use no longer blocks document
  extraction, and a refused document service answers 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep legacy search and voice choices on their old paths

- Classroom search honours the provider and key a request names while the
  webSearch slot is unassigned; only /api/web-search prefers the operator's
  configured backend, as before.
- A TTS request's voice applies unless the request chose it for another
  provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): legacy search model and capability discovery errors

- On the legacy default search provider, the request's search model still
  applies through the server's pins.
- Capability discovery answers a refused credential (401) or a workspace
  service it may not use (403) instead of 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): classroom submission refuses a workspace service with 403

A classroom submission whose materials would go to a document or speech
service the workspace may not use answers 403 INVALID_URL instead of 500, and
its material check resolves the request's own workspace without following a
claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): workspace image and video providers run public-only

A workspace-configured image or video provider now uses the strict public
transport for its requests and for the redirects its clip download follows,
whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the
deprecated per-request path keep their policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* Revert "fix(config): workspace image and video providers run public-only"

This reverts commit 68021ded. A workspace provider cannot choose an endpoint
for media, search or document services at all: a custom base URL or proxy is
refused, and so is a preset whose default endpoint is on the server's own
network. What remains is a preset's fixed public endpoint, the same one a
deployment default uses, so these calls keep the operator's transport policy
like every other preset endpoint, and the connection no longer claims a
user-typed endpoint (userEndpoint is false).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/web-search without a provider searches as before

A deprecated web search request that names no provider again means the
server's configured provider, else the default one with the request's key,
while the webSearch slot is unassigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): browser speech recognition does not count for extraction

An asr slot assigned to speech recognition that runs in the browser gives
server-side extraction no transcription service, so audio uploads are not
advertised or accepted for extraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/parse-pdf honours an explicit local parser

A request that asks for a self-contained extractor (local parsing) parses
the PDF locally and sends it to no document service, whatever the document
slot names, as /api/extract-document already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(classroom): give the persistence contract its media slots

The generated-classroom PostgreSQL contract configured its image and TTS
providers through the legacy provider mocks, which slot resolution no
longer reads; it now sets the deployment's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(llm): streaming calls fall back on their slot's fallback (#1739)

* feat(llm): streaming calls fall back on their slot's fallback

A stream resolved through a slot that fails before any content (the request
is refused, or the first part is a retryable error) runs once on the slot's
fallback model. A failure after content has started still reaches the caller.
The outline stream keeps its own retry and fallback handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): the stream fallback is the call's last attempt, before any content

- The primary's own SDK retries run first; the fallback runs once, as the
  last attempt, and a failing fallback is not retried.
- No fallback once content reached the caller in any step (a tool that ran
  must not run again); after a fallback took over, later steps stay on it.
- The fallback gets the thinking options built for its own provider and
  model, not the primary's.
- Usage of a stream with a slot fallback is recorded per step, against the
  model that served the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): stream fallback recognises transient error payloads

Providers send a stream's error part as a plain { type, message } payload
rather than an error; a transient type (overloaded, rate limited, server
error) now counts as retryable for the slot fallback, while authentication
and request errors still do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(config): model settings API for workspaces (#1740)

* feat(config): model settings API for workspaces

GET/PUT /api/model-config read and edit a workspace's slots and providers:
every slot with its own assignment, effective model, source and lock state;
deployment providers read-only and without credential details; workspace
keys write-only and masked. Edits are checked against the whole
configuration and a revision. The view carries the preset catalogue a
workspace may add providers from, and each provider's models per capability,
so the settings UI needs no registry code of its own.

Workspaces cannot add Bedrock, self-hosted media/search/document presets or
custom endpoints for anything but chat. POST /api/model-config/import merges
settings a browser kept, item by item under the same checks, never replacing
what the workspace or deployment already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): preset ids as client-safe data

The preset id tables move to lib/config/preset-ids.ts, which imports no
registry, so browser code (the settings import) can name presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): tighten the settings API's views and checks

- Effective targets name an endpoint only for the workspace's own
  providers; a deployment's endpoints never reach a response.
- A workspace provider of a local model server preset (whose default
  endpoint is the server's own network) must name its own endpoint.
- Every change is checked against the stored shape, so an import skips a
  malformed item instead of failing the batch, and PUT validates its whole
  body (400, never 500).
- Clearing a key deletes it even when the instance can no longer open it.
- A workspace provider with its own endpoint offers chat models only, and
  an OpenAI-compatible provider offers only the models it lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): no credentials in endpoints; key-pair presets stay in the yml

- A workspace base URL cannot carry a username or password (they would be
  stored and shown in the clear), and views never show credentials a stored
  endpoint carries.
- Presets that authenticate with a key pair (AliDocMind) are not offered to
  workspaces: the settings take one key per provider.
- An import checks each proposed provider on its own, so one malformed
  provider is skipped instead of failing the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings neither offer nor accept switched-off providers

A provider the operator switched off for a capability (the legacy
<CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists,
refused as an assignment, and shown as invalid where a deployment assigns it,
as the calls themselves refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): recommendations name only capabilities a preset still offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a provider id follows the reference grammar before it is used

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): the catalogue lists the models a search provider offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(config): refuse a fallback on a slot whose calls never use one (#1743)

* fix(config): refuse a fallback on a slot whose calls never use one

Only language-model calls retry on a slot's fallback; a fallback on a
speech, image, video, search or document slot was accepted and silently did
nothing. Resolution now refuses it by path, so openmaic.yml fails at startup
and the model settings refuse it on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): openmaic.yml refuses a non-chat fallback at startup

The boot check parses openmaic.yml without resolving slots, so the refusal
also lives in the file's cross-checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* feat(settings): Models section with the course model map (#1741)

* feat(settings): client for the workspace model settings

A small client for /api/model-config: it caches the view per page, applies
changes against the revision it read, reloads on a stale revision (409
CONFLICT) or a slot the deployment has since locked, and treats a server
without persistence (404) as settings managed by the server.

Pure helpers behind the settings UI live beside it: slot changes from the
card picker (follow, off, a model, a fallback, the media switches), the
provider form's change (keys stay write-only: keep, replace or remove),
the first-run setup that adds a provider and fills only the empty,
unlocked slots with its preset's recommendations, and the layout of the
course model map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): Models section with the course model map

A new "Models" section, first in the settings dialog and the one it opens
on. It reads and writes everything through /api/model-config:

- The model map: a pannable, zoomable canvas with the course pipeline as a
  two-row serpentine flow, the default language model above the stations
  that inherit from it, and the page types under the content station. Each
  card shows the effective model, where it comes from and a lock when the
  server sets it. Solid edges follow the parent, dashed ones mark a
  setting of the slot's own.
- Editing happens in place: a picker at the card to follow the parent,
  pick a model a provider offers (or a provider, for search and document
  slots), turn the slot off, or set a fallback for chat slots. Media
  switches turn a slot off and back on.
- While no language model is configured, the default model's card offers
  a first-run setup: pick a service, give its key, and its recommended
  models fill every empty slot.
- A Providers tab lists the server's providers (read-only) and the
  workspace's own, which can be added, edited and removed as the server's
  policy allows.

The generation toolbar's "configure provider" prompt opens this section.
Below the sm breakpoint the settings nav becomes a strip above the panel.
Strings are translated for all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep what a change does not mean to touch

- A provider edit carries only the fields the form shows: a hidden model
  list or endpoint is left out, so the server keeps it; a shown field that
  is emptied is removed. A pinned model list keeps its field on edit.
- A key the server cannot read starts as "replace" (it can also be removed),
  so a key typed for it is sent instead of the broken one being kept.
- Switching a media slot off remembers what it held; switching it back on
  restores that assignment (or nothing of its own), and when that is not
  known the caller asks instead of clearing the slot to the default.
- The first-run assignments are checked against the provider as the server
  answered it: a provider with its own endpoint serves chat only, a model
  list the user gave wins over the recommended chat models, and `llm` is
  one of the provider's own chat models. The filling can be retried against
  a reloaded view, and a refusal keeps its reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): recoverable first-run, root clear action, keyboard on the map

- A first-run setup that added its provider but could not assign it (a
  stale revision, say) reports to the section, which keeps a notice with
  the provider and a way on (assign its models again, or add them under
  Providers) through any reload, instead of losing it with the form.
- The picker of a root slot with a setting of its own offers to clear it,
  leaving the server's value or default.
- A card's switch restores what the slot held; when that is unknown it
  opens the picker.
- Keyboard focus on a card outside the view pans the map to it, and the
  arrow keys pan the map while it has focus.
- The provider form offers only replace or remove for an unreadable key,
  and says that a provider with its own endpoint serves chat only.

Component tests drive the provider form, the switches, the root picker,
arrow-key panning and a first-run setup that meets a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep picker row labels whole; one unreadable-key notice

A long note no longer truncates the row's label in the slot picker, and the
provider form stops repeating the unreadable-key warning the row already
shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a lost answer to a write is an outcome, not a throw

When the server takes a change but its answer cannot be read (a truncated
body, a dropped connection), apply() no longer rejects: it reloads the view
to reconcile a change that may have been saved and returns an `unconfirmed`
outcome with the reloaded view. The first-run setup goes on when the
reloaded view has the provider it added, counts a slot write whose answer
was lost as done when the reload shows the default model set, and says so
otherwise. Busy states in the provider form, provider removal, first-run
setup and its notice are reset in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keyboard roving in the slot picker

The picker's rows are plain buttons (pressed for the current choice) in a
group, not listbox options without the keyboard model those promise. The
list is one Tab stop, the current choice or else the first row; ArrowUp and
ArrowDown step, Home and End jump, Enter and Space choose. The picker opens
on that row. The picker's and the card switches' busy states are reset in
`finally`, and the component tests cover the lost-answer first-run paths
and the picker keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a write lost in transport is unconfirmed, and first run can resume

A write whose request fails in transport may still have been saved, like
one whose answer is lost: apply() now reloads the settings and returns an
`unconfirmed` outcome in both cases, with the reloaded view when that read
worked. A first-run setup whose provider add cannot be confirmed (the
reload failed too) keeps a notice that says so, and "Check again" reads the
settings and either assigns the provider's models or says it was not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry an unconfirmed provider add under the same id

The add-provider form keeps the id it tried. When the answer was lost it
closes if the reloaded settings show that provider, and otherwise retries
under the same id, so a save that did land is updated rather than joined by
a second provider; a genuinely new add still gets a fresh id. Component
tests cover this and the first-run paths where the provider add fails in
transport after the server saved it, or cannot be confirmed at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): 5xx answers to writes are unconfirmed; reads never go back

A server error or a gateway timeout can come after a write was saved, so a
5xx answer to a write is treated like a lost answer: the settings are read
again and the outcome is `unconfirmed` with the reloaded view. A 4xx is
still a refusal, without a reload.

Reads and adopted write answers are numbered: a read's answer is dropped
when a later read started or a write's answer was adopted after it began,
or when its revision is older than the view held. The reads that must see a
write just made (after an ambiguous write, a conflict, "Check again") start
a fresh request instead of joining one begun before the write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): first run is done only once the default model is set

A slot write that succeeds without setting `llm` (another session turned it
off meanwhile, so only media slots were filled) no longer counts as a done
setup: the notice stays and says the default model is still missing, for
the user to pick on its card. Test views now carry `llm` resolved the way
the server answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): the revision decides which settings view is newer

A view with a lower revision than the one held never replaces it, whether
it comes from a read or a write's answer, and one with a higher revision
always does, whenever it arrives; the order in which reads and adoptions
were numbered only breaks ties at an equal revision (and decides while no
view is held, so a read begun before the view was forgotten does not bring
it back). A write whose answer is older than a view read meanwhile returns
the view that is current. `adopt` takes a view or null (forget it) and is
part of the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send each change against the view it was worked out from

apply() takes the view a change was computed from and sends that view's
revision, rather than the revision of whatever view is held when the write
goes out. A retry of the first-run assignments that waits for a reload in
flight is therefore refused (409) when the reload shows the settings
changed, instead of overwriting a slot set meanwhile; the reload then feeds
the next attempt. The slot picker, the card switches, the provider form,
provider removal and the first-run setup all pass the view they showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a provider edit sends only what changed, and rebases on a conflict

The provider form keeps the basis of an edit: the provider as the edit
began and the view it came from. A save sends only the fields changed
against that basis (a key-only edit sends the key), against the basis
view's revision. When the provider changed elsewhere meanwhile (409), the
edit moves onto the reloaded provider, keeping only what the user changed,
and the form says the provider changed before it is saved again. A 409 now
returns the reloaded view to work the change out again from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a refused provider add keeps its draft and picks a free id

Only an add whose outcome is unknown (a lost answer, a transport failure, a
5xx) is reconciled by looking for its id in the reloaded settings. A
confirmed refusal (a 409, another tab having added the same preset under
the same id meanwhile) saved nothing of ours: the form keeps the draft,
shows the conflict, and the retry uses an id still free in the reloaded
view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* refactor(settings)!: server-side model settings only; import browser settings once (#1744)

* feat(settings): one-time import of browser model settings

The settings store's migration to version 5 builds an import proposal
from the provider state earlier builds kept in the browser (keys, custom
endpoints, the chosen model, token plan enrollment, per-capability
selections) and keeps it under its own localStorage key. Once the store
has hydrated, the proposal is posted to /api/model-config/import: a 2xx
answer removes it (and the keys) from the browser, 400 drops it, and
anything else keeps it for a later load. Per-stage routes are not
imported. Clear Local Cache keeps a proposal still waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings): stop sending provider data from the browser

Every request now leaves model and provider choice to the server, which
resolves it through the workspace's capability slots: no model, key or
base URL headers (x-model, x-api-key, x-model-routes, x-image-*,
x-video-*), no provider, key or thinking fields in chat, TTS, voice
registration, transcription, web search or document extraction bodies.
The server keeps accepting them until they are retired.

What the client still needs to know is read from the /api/model-config
view (lib/model-settings/capabilities.ts): whether a language model is
set up, which provider the tts slot names (browser speech plays locally;
voice lists follow that provider), whether speech input, image, video
and web search are available, and the course model the toolbar shows or
edits through the settings API. The scene concurrency comes from
/api/health. The unused TTS config popover and getCurrent*Config
helpers are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: remove the browser provider state and its settings UI

The settings store keeps only the user's own preferences (playback,
narration voice and speed, speech input language, outline review,
agents, layout). Providers, keys, base URLs, the model choice, thinking
settings, per-stage routes, token plan enrollment and seeds, the
per-capability provider configs and selections and their on/off
switches are gone; the version 5 migration sets them aside for the
one-time import and drops them. The narration voice now records the
provider it was picked for and applies while the tts slot names it.

The Token Plan, Model Services and Course Model sections and their
components are removed, with apply-token-plan, the server provider sync
(ServerProvidersInit, fetchServerProviders) and helpers only they used.
A Voice section keeps the per-user parts of the old speech settings:
speed, a narration test, the VoxCPM and Qwen voices, and the speech
input language, showing which services the workspace uses. The home
toolbar picks the course model through the settings API, or shows it
read-only when the deployment locks it.

e2e fixtures answer /api/model-config instead of seeding browser
providers; the managed-provider spec, which covered the removed panel,
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): browser speech recognition while the asr slot is unassigned

Speech input worked out of the box through the browser's own recognition,
which needs no server provider; it stays available while the asr slot is
unassigned, and turning the slot off turns speech input off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): bind the model settings import to its owner and never lose staged keys

The import now asks for the browser's legacy import binding and sends
X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner
that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the
proposal for a later load); the import route joins FENCED_ENDPOINTS.
Its completion is the proposal's own key, apart from the course ledger.

Staging reports whether it succeeded. When the proposal cannot be
written (a full storage, an unreadable proposal waiting), the migration
keeps the old settings in legacyModelSettings and every load retries,
writing the store back without them once staged. Nothing logged quotes
the proposal or an error message: fixed text, item ids, error names.

Older shapes are normalised before the proposal is built (version 0
default model, the single TTS model setting, global TTS/ASR model ids,
a TTS provider's model field, the flat web search key). Capabilities
the user turned off are proposed as off (null), and selected
server-configured media providers are named by their preset id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry a failed read of the model settings

A failed read of /api/model-config with nothing to show is read again
with a backoff (2 s up to a minute) until one succeeds, instead of
leaving the page without capabilities for the rest of its life.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send no provider or model with voice registration

Voice registration, deletion and auto-registration no longer send
providerId or ttsModelId: the server registers with the provider and
model the tts slot resolves to. The model still derives the voice id
and keys the session memo. The unused OpenRouter model list hook, which
called the vendor with a browser key, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): carry only an explicit speech input off into the import

Only `asrEnabled: false` becomes an off slot (`asr: null`): without an
asr slot the browser's own speech recognition would take over, which the
user had turned off. The TTS, image, video and web search switches were
per-browser toggles that availability following the slots replaces on
purpose; turning them into lasting workspace nulls would override the
deployment's defaults from then on, so they are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): gate chat and generation on the slots they resolve

Chat and discussion resolve the classroom slot, and course generation
its outline, actions and content slots; each is now allowed whenever
those resolve to a model, even with the llm root unassigned or off
(a child assigned on its own). Settings not read yet still block
nothing. The toolbar offers "Set up model" only when a course cannot
be generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to built-in providers

A built-in chat provider whose model list held models the user added
(ids not in its catalogue) is proposed with those models, listed after
the catalogue's: a provider's model list names the chat models it
serves, so listing only the added ones would hide the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): wait for the commit explicitly in the media overlap test

The overlap test counted 50 microtasks for the mocked commit to be
reached; with the capability read before each pass that is no longer
enough, so the test failed alone and left a pass running into the next
test. It now awaits a signal from inside the commit, flushes every
pending microtask before asserting, and releases and awaits both passes
in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to an enrolled token plan

An enrolled plan's chat provider whose model list held models the user
added (ids not in the plan's own list) is proposed with them, listed
after the plan's, by the same rule as built-in providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): scope voice registration memos to the tts slot's provider

The session memo and in-flight map of auto-voice registration were keyed
on the voice and model only, so after the tts slot moved to another
backend serving the same model, registration was skipped and synthesis
named a voice that backend never registered. They are now scoped to the
provider the slot resolves to (provider id, registry entry, endpoint)
and the settings revision, captured once per registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): show the view the model settings import produced

After a successful import the page read the settings again, but that
read could join the page's first read, still in flight from before the
import, and leave the unassigned view in place. The import now hands
over the view the import route answered, and adoptNewerView lets any
read in flight finish before keeping whichever view is newer by
revision, so an older answer cannot land over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): offer and resolve voices against the tts slot's model

With the tts slot on a model that cannot speak some voices (an OpenAI
slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the
picker still offered them and synthesis on the slot's model failed.
The slot's model (its own, else the provider's default) is now the
model voices are offered under and checked against: the voice lists
hold only voices it can speak, a persisted voice it cannot speak falls
back to a compatible default, and narrator and agent bindings to such
a voice are not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): drop translations only the removed provider panels used

208 keys that only the removed provider settings (model services, token
plan, course model config, their dialogs and the TTS popover) used are
removed from all 12 locales: API key and base URL fields, provider and
model editors, connection tests, per-capability switches and the like.
Each was checked to have no remaining reference, literal or through a
key prefix built at run time. The TTS enablement locale test keeps the
key still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): provider options in openmaic.yml, passed to the TTS adapters

A provider in openmaic.yml may carry `options`: non-secret,
provider-specific settings (a map of names to strings, numbers or
booleans; `${VAR}` interpolation applies). They reach the resolved
slot target, the media connection and the settings view, and the
server's TTS paths (the TTS and voice routes, scene narration, classroom
media generation, the voice-clone tools) hand them to the adapter as
its providerOptions: over a request's options for a configured slot,
under them while the slot is unassigned (the deprecated path). Voice
registration follows them too (a VoxCPM backend without runtime
registration is refused). Workspace providers cannot set options: they
are the deployment's. The legacy configuration had no equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): read the VoxCPM backend from the tts slot's options

The voice settings, the voice lists and auto-voice registration assumed
the default VoxCPM backend once the browser no longer chose one. They
now read the `backend` option of the provider the tts slot resolves to
(shown in the settings view), and the registration memo is scoped to
the provider's options as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): label the preset choice when adding a provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): media planning from the slots; stop when settings are unread

The outline route decided whether to plan images and video from the
client's x-image-/x-video-generation-enabled headers, and the client
sent them from settings it might not have read: a course could be
generated silently without media. The route now reads the workspace's
image and video slots itself (an explicit `false` header still lets an
API client opt out; `true` turns on nothing), and the client no longer
sends them.

When the model settings cannot be read even after another try,
generation stops with a message instead of leaving out narration (the
scene fails and generation pauses; the preview's first scene errors),
and a media pass or retry stands down without marking anything as
disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pick the course model when none is set

Without a default model the toolbar showed no picker, so the course
model could only be set in Settings. It now offers the picker whenever
the workspace may set the llm slot and chat models exist, with nothing
selected until one is picked (which sets the slot; the server assigns
nothing on its own). The legacy translation's notice for a model whose
provider the server does not configure now says each workspace chooses
its model in Settings → Models, or to configure the provider and set
DEFAULT_MODEL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import keyless search choices and the Claude search model

A keyless search service (Brave) that the user selected with research
switched on is proposed as a provider of its preset with the webSearch
slot on it; untouched defaults and self-hosted services (SearXNG, whose
endpoint only the deployment may set) are not. The model picked for
Claude web search is carried as `claude:<model>` instead of being
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): refuse credential-like provider option names

A provider's `options` are shown in the settings view, so a name that
matches key, secret, token or password is refused with a pointer to
apiKey and credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pin which course slots the generation gate needs

courseGenerationUsable requires the outline, actions and a content slot.
The agents and research slots are not required, and a test pins it: with
course.agents off the roster falls back to the preset agents, and with
course.research off web search runs on the raw requirement, so neither
request is refused by the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): what the import leaves behind; extraction is a slot

The importer README lists what is not carried over and where it is set
now: per-stage routes, the per-browser media and research switches,
Baidu sub-sources, thinking settings and the VoxCPM backend (options in
openmaic.yml). The document extraction README no longer documents the
removed store fields and request-level provider fields: extraction is
the workspace's document slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): release the recorder lock when a start is refused

startRecording returned early without clearing its lock when speech input
was not set up (the asr slot off, or still unknown while the settings
loaded) or the browser lacked speech recognition, so every later click
did nothing until the component remounted. Each early return now
releases the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): decide research from a successful settings read

The home page saved `webSearch: undefined` into the generation session
when the model settings could not be read, and the preview researched
only on that saved flag, so a transient failure skipped research for the
whole generation. Saving the session now needs a successful read (read
again after a failure, else it stops with the "settings could not be
read" message), and starting or resuming generation decides research
again from the current webSearch slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(settings): type the research decision session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(config): a policy without workspace providers also stops using them (#1750)

* fix(config): a policy without workspace providers also stops using them

`policy.allowWorkspaceProviders: false` only stopped workspaces from adding
or editing providers; ones added before kept serving their assignments.
Resolution now leaves them out, with the workspace assignments that name
them (an assignment whose fallback alone names one keeps its model), so the
policy takes effect for existing workspaces too and the settings view shows
what the calls use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): policy keeps shared provider ids and refuses new references

- A workspace provider id the deployment also declares resolves to the
  deployment's provider, so its references are kept under the policy.
- The model settings refuse a new assignment to a workspace provider the
  policy no longer allows, while other edits leave dormant ones as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings validate a workspace as the policy lets the calls see it

Assignments the policy leaves dormant are not checked on unrelated edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* docs(config): openmaic.yml, capability slots and the model settings (#1742)

* docs(config): document openmaic.yml, slots and the model settings

The configuration docs are rewritten around openmaic.yml: providers, the
slot tree and its inheritance, assignment forms, provider-only references,
fallbacks, turning a capability off, locks and the workspace model settings,
policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request
fields, and migration from provider variables, server-providers.yml,
DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table).
The environment-variable reference stays as the legacy configuration.

Deployment, supported models (a preset catalogue), getting started and
VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent
runtime example use it; the openmaic skill tells agents to configure models
through openmaic.yml or the model settings. openmaic.example.yml is a starting
point, and a test parses it and every documented openmaic.yml example with the
real schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): translate the openmaic.yml documentation

Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the
English configuration, deployment, supported models, getting started and
VoxCPM2 pages: the same sections, examples and tables, with the legacy
environment-variable reference kept as before under its own heading. Also
fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English
anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): match the settings UI names, set a model in the quick start

- Configuration: the settings tabs are "Model map" and "Providers", the lock
  reads "Set by the server", and the first-run setup is "Connect a service".
  Browser import: keys are removed from the browser once imported (a failed
  import keeps them), and speech input that was switched off becomes
  asr: null.
- Getting started: the browser no longer sends a model, so the quick start
  sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and
  names Connect a service in Settings > Models as the path without a file.
- openmaic.yml is in .dockerignore so a file with inline keys is never baked
  into an image; the deployment page says to mount it at run time.

All six locales are updated where the text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): scope fallbacks, trim the example, translate volume repair

- Fallbacks apply to language-model slots (llm and the slots below it); a
  fallback on a media, search or document slot is refused at startup or on
  save.
- openmaic.example.yml is copied as-is by the READMEs, getting started and
  the skill, and startup refuses any unset ${VAR}: it now has one active
  provider (OPENAI_API_KEY) and the llm slot, with every other provider and
  slot as a commented optional block. The copy instructions say so.
- The translated deployment pages gain the root-owned volume repair
  paragraph and its chown command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): configure self-hosted and custom media endpoints in openmaic.yml

Workspaces cannot add custom non-chat endpoints or self-hosted media presets,
so the docs no longer send users to the browser settings for them:

- Configuration (legacy reference): custom OpenAI-compatible TTS and ASR
  endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and
  assigned to tts / asr / image; custom chat endpoints are an
  openai-compatible provider in openmaic.yml or the Providers tab. The claim
  that ASR configuration stays in client settings is gone.
- VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy
  variable as the fallback; the per-browser Base URL option is removed.
- MinerU in the READMEs is a document provider in openmaic.yml.
- Deployment and ComfyUI: providers declared in openmaic.yml are
  server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS,
  which only matters for endpoints a user types (chat base URLs in the model
  settings, deprecated request fields).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): document provider options and the VoxCPM2 backend option

- Configuration: the provider fields table documents `options`: non-secret,
  provider-specific settings (string, number or boolean values, ${VAR}
  allowed), shown in the model settings so never a key, and deployment-only.
- VoxCPM2 (page and READMEs): the backend is chosen with options.backend on
  the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm),
  not in the browser settings; voice registration works only on vllm-omni,
  and the troubleshooting row points at options.backend.
- The example test skips openmaic.yml blocks that set provider options until
  the schema on this branch accepts them (marked TODO).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(docs): check the VoxCPM2 examples now that provider options exist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): describe the browser import, Voice settings and web search as released

- Browser import: the proposal and its keys are removed after any 2xx (even
  with skipped items, which are kept nowhere), and after a 400 or an
  unreadable proposal; it stays for another attempt only on 401, 404, 409,
  5xx or a network error. Lists what has to be set up again by hand
  (per-stage models, thinking settings, custom speech and transcription
  providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources)
  and that the per-browser capability switches do not carry over.
- VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings >
  Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts.
- Web search has no per-generation switch: it runs when the workspace's
  webSearch slot resolves, and turning it off affects later generations.
- The READMEs' persistence section no longer says keys go in .env.local only.

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): startup errors name the field, or the line for broken YAML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* refactor(settings)!: main's settings UI on the server-side model configuration (#1767)

* feat(config): test a saved provider from the settings by its id

The settings test buttons (and the model list fetch) name a provider the
workspace has configured instead of sending its key and endpoint: the
server resolves it from the deployment or the workspace's own
configuration, under the same rules as a slot (the policy, self-hosted
media presets, custom endpoints).

- verify-model, verify-image-provider, verify-video-provider and
  verify-pdf-provider take `provider` (and `model`); probe-models takes
  `provider` for one of the workspace's own chat providers.
- generate/tts and transcription take `previewProvider` (and
  `previewModel`) for a settings preview of a provider other than the
  slot's.
- The settings view's catalogue models carry what the registry knows of
  them (capabilities, thinking controls, context window) and the
  registry entry that serves each capability, for the settings to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration

The settings dialog gets its earlier information architecture and layout
back: Token Plan, Model Services (one tab per capability: language
models, image, video, text to speech, speech recognition, document
parsing, web search, each with its list of services and their panels),
Course Model Config (the pipeline with a model per stage), Skills and
General. The model map with its provider list and the separate Voice
section are removed.

The panels read and write the workspace's model configuration on the
server instead of the browser store:

- A service's key, endpoint (chat services only) and model list are its
  workspace provider (id = the service's preset id); keys are write-only
  (a mask is shown; replace or remove). A newly added service fills the
  root slots of what it serves that have nothing set yet.
- Services the deployment configures are shown read-only; key-pair,
  self-hosted and (under a policy without workspace providers) all other
  services say that only the server's configuration can set them up.
- The Course Model main model is the `llm` slot (with its thinking
  settings); each stage is its slot (follow the main model = no
  assignment of its own); the media switches set their root slot to null
  and restore what it held; locked slots are shown disabled.
- Token Plan: connecting adds the plan's provider and fills the empty,
  unlocked slots it recommends; disconnecting removes it.
- Test buttons name the saved service; no key leaves the browser.
- The narration speed and test are back in the text-to-speech panel and
  the recognition language in the speech recognition panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(toolbar): the course model picker and extractor as before, on the server's slots

The home toolbar's model picker gets its thinking control back (the
`llm` slot's thinking settings) and its groups in the plan-first order
the settings use, and the course material popover gets its extractor
select back: it sets the `document` slot (a built-in parser that needs
no key is added on first use). The set-up prompt opens Model Services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): name the Token Plan, Model Services and Course Model sections

The docs, READMEs and messages pointed at the Models section (model map,
providers tab, "Connect a service") and the Voice section, which are
gone. They now name the sections the settings have again: a plan's key
in Token Plan, a service's key in Model Services, per-stage models and
capability switches in Course Model Config, VoxCPM voices in Model
Services → Text-to-Speech. The server-side explanations (locks,
write-only keys, deployment-only services, the one-time import) stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): web research dims only when search is off, as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): list a saved provider's models without naming a model

Fetching the models of a saved chat provider passed its bare id to the
chat resolver, which requires `provider:model`, so every request was
refused. The provider's connection is now resolved without a model (still
only the workspace's own providers, and the endpoint is still checked).
The settings view test also covers an OpenAI-compatible deployment
provider's listed chat models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings)!: Course Model Config is the course model map

Course Model Config shows the course model map again in place of the
earlier pipeline panel: the default model card on top, the pipeline as a
two-row serpentine with the content station expanding to its page
types, editing on the card (follow the parent, a model or off, and a
fallback for chat slots), "Set by the server" locks, solid and dashed
lines, zoom, pan and keyboard. Its provider tab and first-run card stay
out: services are set up in Model Services and Token Plan, and the map
links to Model Services where it needs one.

- One switch implementation (flipSwitch): off sets the slot to null, on
  restores what it held, else takes the first service that serves it
  (speech input returns to the browser's recognition), else opens the
  picker. What it turned off is remembered, and what it restored
  forgotten, only once the server confirmed the change.
- Speech input shows its switch while it runs in the browser (unset),
  and a service can be picked for it then.
- The server's providers count wherever chat models are offered (the
  map, the toolbar, Model Services); the map's "no model" note shows only
  when nothing at all offers one.
- Model Services names a provider by its preset and id
  ("OpenAI-compatible · gateway", with a generic logo), and shows one
  named after a built-in service as that service's entry.
- A typed key stays in the field when the server refuses it; it is
  cleared once saved (Doubao's paired fields too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map

Logos: the home toolbar's model picker shows the provider's logo again, on
the pill and on every group and model, as it did before the settings
restructure; so do the course model map's card picker and its read-only
pill. A provider looks the same everywhere: its plan's or built-in
service's logo (a provider named after one included), its preset's, or
the generic service icon for a custom endpoint, as in Model Services. The
naming and logo helpers move to a data-only module the toolbar can load.

Review fixes:
- The map's card picker sets the thinking settings of a chat slot's own
  model (the main model and each stage), against the view it shows; locked
  slots have no picker.
- A service that needs no key (the browser's own speech) is offered in a
  media slot's picker, and the text-to-speech panel can make it the
  narration: the provider is added, then the slot assigned against the
  view the add answered.
- Fetching a provider's models reports them as added only once the server
  saved the list; a refused or lost write leaves a failure to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): removing a provider's key frees the slots that needed it

Removing the stored key of a workspace provider left the slots assigned to
it pointing at a provider that can no longer be called, so the default
model (or a stage) failed at generation time while the settings still
showed it in use. Removing the key now drops those assignments, as
removing the provider does, and the slots follow their parents again. Only
the capabilities whose provider needs a key are affected: a local model
server or a keyless search keeps what it serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): connecting a token plan fills an empty web search slot

A token plan's web search has no models to pick, so the plan's preset
recommended nothing for the webSearch slot and connecting a plan (TokenDance,
MiniMax) left search unassigned, where the browser-side Token Plan used to
select the plan's search. The preset now recommends the plan's search for
it, and the first-run fill assigns the provider by itself to the slot when
it is still empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(settings): show each TTS service's full request URL

The server-backed TTS panel showed only the preset base URL as the request
URL. Append the path each built-in service calls, as main did, so Gemini
shows /interactions and Doubao /unidirectional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* ci: stop triggering on integration/provider-config before it merges to main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): cache provider logos so switching service tabs does not blank them (#1777)

Next serves public/ files with `Cache-Control: public, max-age=0`, so each
logo a newly mounted list draws is revalidated before it paints. Switching
tabs in Model Services mounts a fresh list, and its logos stayed blank until
every revalidation came back. Cache /logos/* for a day with a week of
stale-while-revalidate; the files are not content-hashed, so not immutable.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

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

* fix(settings): hide the Skills section when the agent runtime is unavailable (#1778)

Without the agent runtime, GET /api/agent/skills returns 404 by design, so
the Settings Skills section could only ever show a load error with a retry
that cannot succeed.

Probe GET /api/agent/runtime once per tab and list the Skills item only when
it reports `enabled` (the flag plus DATABASE_URL, the same check the skills
route gates on). The item stays hidden while the answer is unknown, and a
request to open the dialog on `skills` without the runtime lands on the first
section.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

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

* fix(generation): show the selected extractor's formats in the material upload hint (#1779)

The attach popover's dropzone hint claimed documents, slides, spreadsheets
and images for every extractor, so with unpdf (PDF only, plus the built-in
plain-text extractor) users could pick a .docx via "All Files" and only then
hit a generic "unsupported" error.

- The hint now lists exactly the formats the active extractors accept
  (getFormatLabelsForProviders), with the per-file size limit.
- The unsupported-file error names the extractor and its supported formats;
  it covers the file picker, drag-and-drop onto the dropzone, and the
  cleanup that drops attached files after switching extractors.
- Format labels are file-type names shared by all locales; the list
  separator is localized. Strings updated in all 12 locales.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

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

* fix(settings): connecting a token plan applies its recommended configuration (#1780)

Since model configuration moved to the server, connecting a token plan only
filled the slots that were still empty, so a workspace that had already
picked models never switched to the plan's setup. The browser-side Token Plan
used to make the plan's default model, its recommended course stages and its
image, video, speech and search services the active selection.

Connecting a plan (or saving a new key for a connected one) now applies the
plan's recommendation as one slots change: the default model, each course
stage the plan names, and its media and search services. When that would
replace assignments the workspace made itself, the panel asks first: use the
plan's recommended setup, or keep the current one and fill only the empty
slots. Slots the deployment locks are never offered or changed, and a plan
yields the slots a connected plan of higher priority recommends, whatever
the connect order. A replaced language-model assignment keeps its fallback;
thinking settings go with the model they were set for. Disconnecting still
removes the provider, which frees the slots that named it.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

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

* feat(home): the toolbar picker sets the default model and shows stages set separately (#1781)

* feat(home): label the toolbar model as the default and show stages set separately

The home toolbar picker only sets the `llm` root, but read as if it chose
the model for everything. It now says what it changes:

- the pill reads "Default · <model>" (the prefix is hidden on phones);
- a hint after it counts the stages under `llm` whose own setting
  resolves to something other than the default (inheriting or matching
  stages do not count; deployment-set stages do), lists them in a tooltip
  with the map's stage names, and opens Course Model Config on a click;
- the dropdown says picking here leaves stages set separately alone;
- when every stage a course is always generated with (outline, each page
  type's content, actions) has its own setting, the pill summarises the
  per-stage setup (naming the Token Plan when one plan provider serves
  them all) and opens Course Model Config instead of offering a switch.

Picking a model still changes only `llm`; the locked and first-run states
are unchanged. New strings are added to all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(logos): size the DeepSeek logo to its own view box

The SVG declared width 182 and height 29 around a 34x29 view box, so any
square icon slot scaled it as a wide strip and the whale drew as a dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782)

* fix(agent-runtime): drop the thinking effort the agent slot inherits

The agent slot follows llm, so a thinking level picked for the default
model (the home toolbar writes it on llm) reached the agent driver, which
refuses any thinking effort because its tool calls cannot carry one. Every
agent run failed for a user who had picked a thinking level.

The agent slot now declares that it carries no thinking effort:

- Resolution drops an effort the agent inherits from an ancestor and keeps
  the rest of the thinking settings; an inherited effort of none stays
  "thinking off".
- An effort set on the agent slot itself is refused when it is saved:
  openmaic.yml at load and a workspace change through the settings API.
  The driver's own check stays as a backstop for settings stored earlier.
- The model map's thinking control for the Agent card offers no effort
  levels: an effort model that can be switched off shows on/off, and one
  that cannot shows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(agent-runtime): keep a session usable after a run that failed while starting

A run that fails before it completes any message (for example, while
resolving its model) writes nothing to the entry tree, but its lifecycle
frames are in the event log. The runner treated any lifecycle frame as proof
that the tree should hold history, so every later run of that conversation
failed with "tree is empty after a prior run".

The empty-tree check now asks the event log whether a prior run completed a
message. The runner appends every completed message to the tree right after
its message_end event, so an empty tree is refused only when a message_end
exists: the tree lost history it held. An empty tree after runs that never
completed anything is legal, and the next run starts the conversation over:

- it resumes the conversation instead of opening it again, so the opening
  prompt is not painted twice;
- a durable message is taken as the session's opening message only when it
  was posted before the first run; a message posted after a failed start is
  delivered as a follow-up, not consumed in place of the session's prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783)

* fix(model-config): ignore request-named providers when workspace providers are off

With `policy.allowWorkspaceProviders: false`, users may only use the
providers openmaic.yml declares. The deprecated request paths still let a
request run on its own model, key and endpoint while a slot was unassigned.

Under that policy the language model and media resolvers now skip the
provider a request names, document extraction keeps only a self-contained
extractor from the request fields, and the header form of the provider
test routes (verify-model, verify-image-provider, verify-video-provider)
is refused. Behaviour is unchanged when the policy is true or unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(model-config): pin the media defaults an upgrade keeps

Before the server-side settings, the browser switched image, video and
narration on at its first sync with the server whenever the server had a
provider for them, and the classroom chat and the agent searched the web
whenever a search provider was configured. The translated legacy defaults
keep those capabilities on, unlocked, so a workspace can switch each off;
an explicit openmaic.yml assignment stays as written and locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(secrets): warn at startup when stored keys will not open

Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or
under a secret generated in data/instance-secret.key when it is unset. On
a host whose data directory does not survive a restart, or with several
replicas, a new secret is generated and the stored keys stop opening; on
a read-only data directory no secret can be created and saving a key fails.

At boot, with one query in the background, the server now warns when:
- OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new
  secret file;
- the secret file is missing (so a new one would be generated) while the
  database holds keys sealed under an earlier secret;
- stored keys were sealed under a different secret than the current one.

Each warning says the keys have to be entered again and recommends
setting OPENMAIC_SECRET_KEY. The server still starts in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs: upgrade notes for server-side model configuration

Add breaking changes and upgrade notes to CHANGELOG.md for the move of
model configuration to the server: openmaic.yml and capability slots,
MODEL_ROUTES refusing to start without openmaic.yml (with the
route-to-slot mapping, including maic-agent-driver -> agent), the
narrowed /api/generate-classroom body, deprecated request fields and the
workspace-provider policy, the media capability defaults after an
upgrade, the one-time browser settings import and what it cannot carry,
and OPENMAIC_SECRET_KEY persistence across restarts and replicas.

The configuration and deployment docs (all languages) and .env.example
now cover replicas, ephemeral and read-only data directories, the new
startup warnings, and request fields being ignored under the policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784)

* fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints

The one-time import of browser model settings deleted the staged proposal,
keys included, on any 2xx answer, even when the server skipped items. The
version 5 store migration had already dropped the originals, so a skipped
provider lost its key everywhere. An Azure TTS/STT user with a regional
endpoint lost theirs this way: the server refused the endpoint as a custom
media endpoint.

Import:
- Only what the answer shows the workspace holding leaves the browser.
  Every other item (skipped as invalid, an id the deployment declares, or
  unconfirmed because the answer could not be read) is kept in the browser
  as staged, with its keys, under its own key. Skips that leave nothing
  behind (EXISTS, a locked slot) are not kept. If the items cannot be kept,
  the proposal stays for a later load.
- What the builder cannot propose is kept the same way at migration time:
  custom speech/transcription providers, AliDocMind's key pair, custom chat
  providers without an endpoint or of an unsupported type.
- Kept items are never sent again and do not re-trigger the import. A toast
  tells the user once; Settings -> Model Services lists them with the reason
  and a copy-key button until they are discarded. Setting one up again (a
  new workspace provider of its preset, or the slot) clears it. Clearing
  the local cache keeps them.
- The import answer now carries a `code` per skipped item, and a provider
  id the deployment declares is reported as PROVIDER_RESERVED.

Azure Speech:
- Workspace azure-tts / azure-asr providers take their official regional
  endpoint (https://<region>.tts.speech.microsoft.com,
  https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com),
  region [a-z0-9]+, https only, no userinfo, port, path, query or fragment.
  It is checked at save time and at resolution (resolve-slot marks anything
  else as a custom endpoint, which media resolution refuses), and stored
  normalised. Their registry default only names the region as a placeholder,
  so they now require it. The TTS/ASR panels get a regional endpoint field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): let a keyless OpenAI-compatible provider run

A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in
openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but
building its model threw "API key required for provider: openai": the
preset rides on the OpenAI registry entry, which requires a key.

- The openai-compatible preset marks its key optional, and the slot model
  passes that to getModel (a new `requiresApiKey` override on ModelConfig).
  Every other preset keeps the registry's rule, so OpenAI itself still
  needs a key and fails with the same clear error.
- A request without a key no longer carries an empty `Authorization:
  Bearer ` header; with a key it is sent as before.

On main, a custom OpenAI-compatible provider sent from the browser was
built the same way (providerType openai, registry default requiresApiKey
true) and also needed a key on the server; keyless worked only for registry
entries marked keyless (Ollama, Lemonade). This lets the documented keyless
openmaic.yml provider work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): carry narration, image and video switched off over to the workspace

The browser import carried a capability's "on" choice but not an explicit
"off" for narration, images and video. A user who had switched one off got
it back on after upgrading, because the deployment's defaults now turn
those capabilities on.

When the old store has `ttsEnabled`, `imageGenerationEnabled` or
`videoGenerationEnabled` stored as false while a usable provider for the
capability was there, the import proposes `tts: null` / `image: null` /
`video: null`, as `asrEnabled: false` already becomes `asr: null`.

Earlier builds defaulted these switches to off and turned them on by
themselves once a provider was usable (a server provider on the first
load, a key the user entered), and off when none was. So `false` with a
usable provider (server-configured and not switched off by the operator,
or with the user's own key; browser speech synthesis does not count) is the
user's choice, and `false` without one is only the default, which is not
carried over. A server that gained a provider after the browser's first
load cannot be told apart and is read as off: that is visible in the
settings and costs nothing, while reading it as on could start paid
generation the user refused.

Web search off is not carried over: it only stopped course research, while
chat and the agent kept searching through the same provider. A slot the
deployment locks is skipped like any other import (SLOT_LOCKED).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(import): drop a browser-kept key only when the server holds the same one (#1785)

* fix(import): drop a browser-kept setting only when the server holds the same one

The one-time import of browser model settings could still lose a key:

- A provider id the workspace already had was answered `EXISTS`, and the
  browser deleted its copy, even when the workspace held another key. The
  server now compares the proposed provider with the stored one (preset,
  endpoint in its normalised form, models, and the key, decrypted and
  compared in constant time) and answers `EXISTS_SAME` or
  `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a
  key this instance cannot open is never confirmed. Nothing else about the
  stored provider is returned.
- Provider ids and slot ids shared one namespace in the answer, so a slot
  `tts` that was imported confirmed a refused provider `tts` and its key
  was deleted. The answer now names each item's kind
  (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind
  and id.
- The notice in Settings cleared a kept provider, key included, as soon as
  any new workspace provider of its preset appeared, even one without a
  key. A kept key now leaves only when such a provider holds it as far as
  the view shows (key set, readable, same mask); otherwise it stays, with
  its copy button, until the user discards it. Key pairs and keys too short
  to show in a mask are never cleared this way. The key mask moves to a
  module the server and the browser share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(changelog): the narration, image and video off switches are carried over

The changelog still said the image, video and narration off switches were
not carried over. They become `tts`/`image`/`video: null` when a usable
provider was set up, as the configuration docs and the import README
already say; only the research switch, and a switch off only by default,
are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(import): never clear a kept key by itself

A kept provider that holds a key (or a key pair) used to leave the
browser once a new workspace provider of its preset held a key whose mask
matched. A last-four-characters match does not confirm the workspace
holds that key, so such an item now stays, with its copy button, until the
user discards it. Kept items without a key still leave by themselves once
the view shows them set up again. The key mask goes back to the server
module, its only user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

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

* fix(import): keep browser settings on a refused or self-contradicting answer (#1787)

Two more ways the one-time import of browser model settings could lose a
key:

- A 400 removed the proposal outright. The server refuses the whole
  proposal then (an unexpected top-level field is enough), so sending it
  again cannot succeed, but its keys existed nowhere else. Every item is
  now kept in the browser first, keys included, as refused with the
  server's message, and only then is the proposal removed; if they cannot
  be kept, the proposal stays. Kept items are never sent again. 401, 404,
  409, 5xx and network failures still keep the proposal for a later load.
- An answer that listed an item as both imported and skipped, or skipped
  it with different codes, let `imported` (or the last code) win, which
  could delete a key the server did not hold. Such an item is now kept as
  unconfirmed.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wyucandClaude Opus 5.5 authored and GitHub committed 2026-10-02 20:12:43 +08:00
1 parent c72a64b4b6
commit 5312c2b4b4
443 files changed
+45212 -26853

No files matched your search

+2
View File
@@ -20,6 +20,8 @@ dist
.env*
!.env.example
server-providers*.yml
# model configuration: may hold inline keys; mounted at run time, never baked in
openmaic.yml
# misc
assets
+63 -82
View File
@@ -1,14 +1,37 @@
# =============================================================================
# OpenMAIC Environment Variables
# Copy this file to .env.local and fill in the values you need.
# Provider variables are optional — only configure the providers you want to use.
# DATABASE_URL is required, except under docker-compose.yml, which sets it for
# the bundled PostgreSQL (see "Persistence" below).
# You can also use server-providers.yml for configuration (see docs).
# the bundled PostgreSQL (see "Persistence" below). Everything else is optional.
#
# Models are configured in openmaic.yml (copy openmaic.example.yml), which
# references the keys below as ${VAR}, or in the model settings of the web app.
# The provider variables in this file, server-providers.yml, DEFAULT_MODEL and
# MODEL_FALLBACK are the legacy configuration: they still work while there is
# no openmaic.yml and are deprecated. See the Configuration docs.
# =============================================================================
# --- Model configuration file ------------------------------------------------
# Path of the model configuration. Default: openmaic.yml in the directory the
# server starts in (the repository root; /app in the Docker image). When set,
# the file must exist. Read at startup; restart after changing it.
# OPENMAIC_CONFIG=openmaic.yml
# --- Instance secret ---------------------------------------------------------
# Encrypts provider keys saved in the web settings. When unset, one is created
# on first start in data/instance-secret.key (the Docker data volume). Keep it
# with the database: saved keys cannot be read without it. Set it explicitly
# when running more than one instance, when data/ does not survive a restart,
# or on a read-only filesystem, e.g. from `openssl rand -base64 32`. The server
# warns at startup when stored keys were sealed under a different secret.
# OPENMAIC_SECRET_KEY=
# --- LLM Providers -----------------------------------------------------------
# Format: {PROVIDER}_API_KEY, {PROVIDER}_BASE_URL (optional), {PROVIDER}_MODELS (optional, comma-separated)
# Legacy: without openmaic.yml these configure providers directly. With
# openmaic.yml they no longer configure providers on their own: reference them
# from the file as ${VAR} (for example apiKey: ${OPENAI_API_KEY}). The TTS,
# ASR, image, video, PDF and web search variables below work the same way.
OPENAI_API_KEY=
OPENAI_BASE_URL=
@@ -401,17 +424,13 @@ WEB_SEARCH_CLAUDE_MODELS=
# The runtime uses the PostgreSQL connection (DATABASE_URL) from the
# "Persistence" section below, which every deployment configures.
# REQUIRED while the runtime is enabled: MODEL_ROUTES must explicitly route the
# "maic-agent-driver" stage to a provider-prefixed model id. There is
# intentionally no fallback — without this route every agent session fails at
# run start. The route object must set api (or its alias dialect) to
# "openai-completions" or "openai-responses"; any other value is rejected, and
# a bare model id without a provider prefix is rejected too. Optional fields:
# contextWindow pins the effective context window below the provider catalog
# value (used by compaction thresholds), and thinking must never set effort
# (the tool-using driver cannot combine reasoning_effort with function tools on
# this transport).
# MODEL_ROUTES='{"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'
# The agent runs on the `agent` slot of the model configuration (openmaic.yml,
# or the model settings): a model that supports tool calling. Optional fields
# on the slot: api ("openai-completions", the default, or "openai-responses"),
# contextWindow (pins the window compaction assumes below the catalogue value),
# and thinking, which must never set effort (the tool-using driver cannot
# combine reasoning_effort with function tools on this transport). A
# deployment configured only through DEFAULT_MODEL has the agent turned off.
# Runner tuning. Defaults are shown; only relevant once the runtime is enabled.
# OPENMAIC_AGENT_RUNTIME_SCAN_INTERVAL_MS=1000
@@ -447,73 +466,26 @@ WEB_SEARCH_CLAUDE_MODELS=
# --- Misc ---------------------------------------------------------------------
# Server-side default model for API routes like /api/generate-classroom.
# Required for server-side stages (those that don't receive a client x-model):
# resolveModel throws if a stage resolves to no model (no MODEL_ROUTES entry, no
# x-model, no DEFAULT_MODEL) — there is intentionally no hardcoded vendor fallback.
# Example: anthropic:claude-3-5-haiku-20241022 or google:gemini-3-flash-preview
# OpenAI example: openai:gpt-5.5
# MiniMax example: minimax:MiniMax-M2.7-highspeed
# Bedrock example: bedrock:us.anthropic.claude-sonnet-5
# Default model (deprecated: prefer openmaic.yml). Without openmaic.yml it
# becomes the default of every chat slot (the `llm` root), below the models a
# workspace picks in the settings; the agent stays off. There is no hardcoded
# vendor fallback: a call with no model configured fails.
# Example: openai:gpt-5.5, minimax:MiniMax-M2.7-highspeed,
# bedrock:us.anthropic.claude-sonnet-5
DEFAULT_MODEL=
# Optional per-stage model routing (#745). A JSON object mapping a generation
# stage to a model string (`provider:model`). For most stages, resolution order
# is stage route > x-model (client) > DEFAULT_MODEL. A configured route is the
# operator's deliberate choice and wins even when the browser sends its saved
# model as x-model. `conversation-title` is the exception: when unconfigured it
# reuses the exact `maic-agent-driver` connection, never x-model or DEFAULT_MODEL,
# and keeps thinking disabled unless its own route explicitly enables it.
# At boot the server validates MODEL_ROUTES / DEFAULT_MODEL / <PREFIX>_MODELS
# and prints [config] warnings for unknown stages, unregistered providers,
# providers with no API key, and bare model ids (which still default to openai
# but are deprecated — write provider:model). Warnings only: the server starts
# regardless, so a bad value is caught here instead of at request time.
# Routable stages: scene-outlines-stream, scene-content, scene-actions,
# agent-profiles, quiz-grade, pbl-chat, pbl-v2-runtime, chat-adapter,
# generate-classroom, web-search-query-rewrite, maic-agent,
# maic-agent-driver, conversation-title.
# scene-content can also be routed per scene type with composite keys:
# scene-content:slide, scene-content:quiz, scene-content:interactive,
# scene-content:pbl. A type falls back to the base scene-content route when it
# has no key of its own (so scene-content:<type> > scene-content > x-model >
# DEFAULT_MODEL).
# pbl-v2-runtime follows the same composite fallback pattern with
# pbl-v2-runtime:instructor, pbl-v2-runtime:open-task, pbl-v2-runtime:evaluate
# and pbl-v2-runtime:simulator, falling back to the base pbl-v2-runtime route.
# maic-agent-driver is REQUIRED when the agent runtime is enabled; see the
# "Agent runtime (experimental)" section for its api/dialect constraints.
# A route value can be a model string, OR an object {"model","thinking"} where
# `thinking` is the full ThinkingConfig: mode (default|disabled|enabled|auto),
# effort (none|minimal|low|medium|high|xhigh|max), level (minimal|low|medium|
# high, Gemini), enabled (bool), budgetTokens (number), excludeReasoningOutput
# (bool). It is normalized per the model's capability. When a stage is routed:
# a set `thinking` wins over the client's thinking; with no `thinking` the routed
# model uses its own default and the client's thinking is dropped. Unrouted
# stages keep the client thinking.
# Example: cheap default, stronger model only for the heavy/conversational stages:
# MODEL_ROUTES='{"scene-content":"openai:gpt-5.4","scene-actions":"openai:gpt-5.4","pbl-chat":"anthropic:claude-sonnet-4","chat-adapter":"anthropic:claude-sonnet-4"}'
# Example: per scene type + pinned thinking (qwen budget, deepseek off):
# MODEL_ROUTES='{"scene-content:interactive":{"model":"qwen:qwen3.7-plus","thinking":{"enabled":true,"budgetTokens":8000}},"scene-content:quiz":{"model":"deepseek:deepseek-v4-pro","thinking":{"enabled":false}}}'
# MODEL_ROUTES=
# Per-stage models are slots in openmaic.yml (course.outline,
# course.content.slide, classroom, agent, ...). MODEL_ROUTES is no longer read:
# a server that still sets it without openmaic.yml refuses to start. The agent
# runtime's former "maic-agent-driver" route is now the `agent` slot (with its
# `api` and `contextWindow`).
# Retryable-failure model fallback (optional). When a generation call fails with
# a retryable failure (SDK-classified transient error, timeout, network error,
# quota 429, capacity 503), the shared callLLM layer retries ONCE on the
# fallback model. Content-safety rejections and other 4xx failures never fall
# back. Unset = behaviour unchanged.
#
# Scope notes:
# - Per-stage fallback only applies to stages listed in LLM_STAGES
# (see lib/server/model-routes.ts); keys outside it are ignored.
# - On the shared callLLM layer the fallback fires on retryable ERRORS and,
# when a fallback model is configured, on genuinely empty output as well.
# Non-empty results that fail a caller-supplied validator never fall back.
#
# Two ways to configure (per-stage wins over global):
# A) per route: add a "fallback" field to a MODEL_ROUTES route object
# MODEL_ROUTES='{"scene-content":{"model":"openai:gpt-5.4","fallback":"qwen:deepseek-v4-pro"}}'
# B) global: one fallback for every callLLM stage
# Retry model (deprecated: prefer a slot's `fallback` in openmaic.yml). When a
# generation call fails with a retryable failure (SDK-classified transient
# error, timeout, network error, quota 429, capacity 503) or returns empty
# output, the shared callLLM layer retries ONCE on the slot's fallback model.
# Content-safety rejections and other 4xx failures never fall back. Without
# openmaic.yml, MODEL_FALLBACK becomes the fallback of DEFAULT_MODEL.
# MODEL_FALLBACK=
# LOG_LEVEL=info
@@ -695,10 +667,19 @@ DEFAULT_MODEL=
# ASSET_COLLECTION_INTERVAL_MS=900000
# ASSET_COLLECTION_GRACE_MS=3600000
# Root directory for the file-backed classroom store: the classroom JSON
# documents plus their generated media/audio. Defaults to <cwd>/data/classrooms.
# This moves ONLY the classrooms — the classroom generation job store is not
# configurable and stays at <cwd>/data/classroom-jobs.
# Directory of the file-backed classroom store of earlier versions. Defaults to
# <cwd>/data/classrooms. Classrooms generated through /api/generate-classroom
# are now saved in PostgreSQL (courses) and the asset pool (media, narration);
# at startup the server imports the <id>.json classrooms it finds here, once,
# retrying in the background until it completes, and logs a summary. They go to
# the shared team owner or the single user when one is configured; otherwise to
# a dedicated owner no visitor resolves to, so each classroom keeps opening
# read-only from its old link and appears in no visitor's library (export it as
# a ZIP and import it to own a copy). An id another course already holds is
# skipped. Keep this directory in place: the import leaves the files where they
# are, imported classrooms may still reference files in it, and agent-run
# courses serve narration and material media from it. The old
# <cwd>/data/classroom-jobs directory is no longer read.
# OPENMAIC_CLASSROOMS_DIR=/var/lib/openmaic/classrooms
# How long an allocated asset stays pending -- stored, but not yet named by any
+8 -2
View File
@@ -4,7 +4,8 @@ on:
push:
branches: [main]
pull_request:
branches: [main, runtime-server-backend, feat/maic-editor-v0, feat/maic-editor-v1]
branches:
[main, runtime-server-backend, feat/maic-editor-v0, feat/maic-editor-v1, integration/provider-config]
concurrency:
group: storage-pg-contract-${{ github.ref }}
@@ -86,8 +87,13 @@ jobs:
tests/persistence/owner-claims.pg.test.ts \
tests/persistence/owner-assets-legacy-race.pg.test.ts \
tests/persistence/owner-materials.pg.test.ts \
tests/persistence/workspace-model-config.pg.test.ts \
tests/persistence/runtime-tombstone-collision.pg.test.ts \
tests/agent-runtime/event-notify.pg.test.ts \
tests/agent-runtime/park-attempt-budget.pg.test.ts \
tests/agent-runtime/session-events-live.pg.test.ts \
tests/legacy-browser-import/import-routes.pg.test.ts
tests/legacy-browser-import/import-routes.pg.test.ts \
tests/server/classroom-generation-jobs.pg.test.ts \
tests/server/classroom-generation-persistence.pg.test.ts \
tests/server/legacy-classroom-import.pg.test.ts \
tests/persistence/exclusive-create-invariant.pg.test.ts
+3
View File
@@ -90,3 +90,6 @@ e2e/screenshots/
# local e2e screenshots for #619 review
e2e/__screenshots-619/
/.playwright-mcp/
# Operator model configuration (see openmaic.yml in RFC #1701); it names secrets via ${VAR}
/openmaic.yml
+11
View File
@@ -15,6 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Server persistence: runtime sessions (`/api/persistence/runtime/*`) are keyed by the owner the owner identity seam resolves, not by a client-supplied `x-learner-key` behind the development token. `PERSISTENCE_DEV_TOKEN`, `NEXT_PUBLIC_PERSISTENCE_TOKEN` and `PERSISTENCE_ALLOW_INSECURE_DEV_AUTH` are removed and ignored; the browser learns its learner key from `GET /api/persistence/learner-key`. Runtime sessions written before this change were keyed by a browser-minted learner key and are no longer reachable; they are not migrated, because trusting a client-supplied old key would restore client-chosen identity. Course documents and media are unaffected. **If the development token was your only access gate**, put the deployment behind `ACCESS_CODE` or a gateway, or register owner auth methods, before upgrading: without the token the endpoint serves every visitor as their own anonymous owner.
- Server persistence: assets are allocated in a per-owner partition, so `ASSET_QUOTA_BYTES` is a per-owner ceiling and only the owner can replace or delete an entry. Other owners read an entry by id while a live course of the entry's owner references it, and a document write references and commits only its owner's entries (and legacy ones), so naming another owner's id records nothing. Entries in the old shared partition stay readable by id, and can be replaced or deleted only by an owner who owns every course referencing them.
- Server-side classroom generation (`POST /api/generate-classroom`) writes through server persistence. A job runs for the request owner and saves the finished classroom, complete and create-only, into that owner's course library, with its images, videos and narration in the owner's asset pool; nothing is written under `data/classrooms` or `data/classroom-jobs` any more. `POST /api/classroom` and `GET /api/classroom` are removed, and so is the classroom page's fallback to `GET /api/classroom`. Jobs are stored in PostgreSQL and are owner-scoped: `GET /api/generate-classroom/<jobId>` answers only the owner that created the job (or the account it was claimed into) and `404` for everyone else, like an unknown id. With a fixed owner (`PERSISTENCE_SHARED_OWNER_ID`, `OWNER_SINGLE_USER`, or a host auth method) this is automatic; in anonymous-cookie mode a headless caller must keep the cookie from its first response and send it when polling, and the classrooms it generates belong to that anonymous owner: anyone can open them by link, but no browser user can edit them. Run with a fixed owner or host auth when API-generated courses should be editable. When the owner's asset store refuses an image, a video or a narration clip during a job, that phase stops there (the others go on) and the job result names the stopped phases in its warning.
- Model configuration moves to the server (provider-configuration RFC, Discussion #1701). Models and providers are chosen per capability slot (`llm` with its children such as `course.outline`, `classroom` and `agent`, plus `tts`, `asr`, `image`, `video`, `webSearch`, `document`) in an optional `openmaic.yml` (or the file `OPENMAIC_CONFIG` names), which locks what it sets, and in each workspace's model settings, stored in PostgreSQL (`workspace_model_config`). The browser no longer keeps provider keys, models or capability switches. Without `openmaic.yml` the legacy provider variables, `server-providers.yml`, `DEFAULT_MODEL` and `MODEL_FALLBACK` keep working: they are translated at startup into deployment providers and unlocked defaults, with a deprecation notice and a notice for each entry that does not carry over. With `openmaic.yml` present they are ignored for model resolution (a notice says so); `<CAP>_<VENDOR>_ENABLED=false` still applies.
- **`MODEL_ROUTES` without `openmaic.yml` stops the server at startup** (`MODEL_ROUTES does not carry over to the model configuration: ...`). Earlier releases required `MODEL_ROUTES` for the agent runtime (a `maic-agent-driver` route), so every deployment that runs the Pro agent must migrate before upgrading: write `openmaic.yml` and remove `MODEL_ROUTES`. Each route becomes the slot of its stage: `maic-agent-driver` → `agent` (its `api` and `contextWindow` move to the slot; a thinking `effort` is refused there as before), `conversation-title` → `agent.title` (unset, it follows `agent`), `scene-outlines-stream` → `course.outline`, `scene-content` and `scene-content:slide|quiz|interactive|pbl` → `course.content` and `course.content.slide|quiz|interactive|pbl`, `scene-actions` → `course.actions`, `agent-profiles` → `course.agents`, `web-search-query-rewrite` → `course.research`, `chat-adapter`, `quiz-grade` and `pbl-v2-runtime` (with its `:instructor`, `:open-task`, `:evaluate`, `:simulator` variants) → `classroom`, `generate-classroom` → `llm`. Stages that share a slot take one model. A route's `thinking` moves to the slot. See "Migrating from the legacy configuration" in the configuration docs for a worked example.
- Deprecated request fields: `x-model`, `x-api-key`, `x-base-url`, `x-provider-type`, `x-model-routes`, `x-image-provider`, `x-image-model`, `x-video-provider`, `x-video-model` and the equivalent body fields of the speech, search and document routes are honoured only while the slot they would affect has no assignment in `openmaic.yml` or the workspace (a slot set to `null` refuses the request), and not at all under `policy.allowWorkspaceProviders: false`, which also refuses the header form of the provider test routes. The server logs a warning the first time a request names its own language model. They will be removed two minor versions after the model settings ship.
- `POST /api/generate-classroom` takes `{ requirement, materialIds? }`. `pdfContent` is refused with `400 INVALID_REQUEST`: upload the document with `POST /api/materials` and pass the returned id in `materialIds` (at most 5). `enableWebSearch`, `enableImageGeneration`, `enableVideoGeneration`, `enableTTS`, `agentMode`, `webSearchProviderId`, `webSearchApiKey`, `webSearchModelId` and `baiduSubSources` are ignored, like any other unknown field: search, images, video and narration run whenever the caller's workspace resolves them (`GET /api/generate-classroom/capabilities` reports which), there is no per-request opt-out, and agent profiles are always generated. The job's `inputSummary` becomes `{ materialCount }`.
### Deprecated
@@ -26,6 +31,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The first start creates the `owner_merges` table (with the `stage_meta` and owner-material schemas). Nothing is claimed until a claim is triggered; the default trigger is the explicit `POST /api/identity/claim`.
- The first start on an existing installation changes `document_stages` under table locks. `CREATE INDEX document_stages_folder_idx` builds the new index under a SHARE lock, which blocks writes to `document_stages` for the length of the build. `DROP INDEX` of `document_stages_owner_idx` / `document_stages_owner_folder_idx` takes a brief ACCESS EXCLUSIVE lock. If a host added `NOT NULL` or a default to `owner_id`, `ALTER TABLE ... DROP NOT NULL` / `DROP DEFAULT` also takes ACCESS EXCLUSIVE, which blocks reads too and waits behind any open transaction on the table. Upgrade in a quiet window, or start one instance first and let it finish before the rest start (concurrent starts are serialized anyway; see Bug Fixes). Once done, later starts change nothing.
- To keep the index build off the start path on a large table, create the index and drop the old ones yourself beforehand, outside a transaction: `CREATE INDEX CONCURRENTLY IF NOT EXISTS document_stages_folder_idx ON document_stages (folder_id, id) WHERE folder_id IS NOT NULL;`, `DROP INDEX CONCURRENTLY IF EXISTS document_stages_owner_idx;`, `DROP INDEX CONCURRENTLY IF EXISTS document_stages_owner_folder_idx;`. The start then builds and drops nothing. It still takes and releases the same momentary SHARE lock that every other `CREATE INDEX IF NOT EXISTS` in the schema takes on each start.
- Classrooms an earlier version stored under `data/classrooms` (or `OPENMAIC_CLASSROOMS_DIR`) are imported once, in the background after startup, retried with backoff until the import completes: each keeps its id (its link shows not-found until its import lands), its own `/api/classroom-media` media and narration move to the asset pool, and the outcome is recorded in the new `legacy_classroom_imports` table, so later starts skip it. An id another course already holds is skipped and that course stays as it is; so are invalid files and reservation placeholders, decided before any media is stored, and creates the host refuses, whose stored media is released. When the owner's asset store is full the run stops, records nothing for the classrooms it did not import, releases what it had stored for the current one while it is still unclaimed, logs it, and tries again later. A classroom whose import fails otherwise is retried and, after three failed attempts, skipped with its last error. One instance imports at a time. Imported classrooms belong to the shared team owner or the single user when one is configured, otherwise to a dedicated owner (`system:legacy-classrooms`): they open read-only from their old link and appear in no visitor's library. The files are not deleted; keep the directory, since imported classrooms may still reference other classrooms' files and agent-run courses still serve media from it.
- Rolling upgrades: jobs an older instance recorded under `data/classroom-jobs` are not migrated, and polling them answers `404`. An older instance still running during the upgrade may finish a classroom the importer had skipped as a reservation placeholder; that file is not imported later.
- Media capabilities after an upgrade with the legacy configuration: the first configured TTS, ASR, image, video and document provider, and the first search provider in the old priority order, become unlocked defaults, so these capabilities are on wherever the server configures a provider. This keeps the earlier behaviour of the web app, which switched image, video and narration on at a browser's first visit whenever the server had a provider for them, and gave the classroom chat and the agent web search whenever a search provider was configured. Two things change: course generation now researches the web whenever web search is on (it was an opt-in switch, off by default), and headless `/api/generate-classroom` jobs use every configured capability (the request flags defaulted to off). A workspace switches any of them off in **Settings → Course Model Config**; a deployment that wants one off for everybody sets its slot to `null` in `openmaic.yml` (which locks it).
- Settings stored in a browser by an earlier version (providers with their keys and base URLs, the chosen model, token plan enrollment and each capability's selection) are imported into the workspace once, never replacing a workspace setting or a slot `openmaic.yml` locks. Not carried over, to set up again: per-stage models, thinking settings, custom speech and transcription providers, AliDocMind's key pair, the VoxCPM backend and Baidu search sub-sources (the custom providers and the key pair stay in the browser with their keys, listed in Settings → Model Services). Speech input switched off becomes `asr: null`, and narration, images or video switched off while a usable provider for them was set up become `tts: null`, `image: null` or `video: null`. The research switch is not carried over (it only stopped course research; chat and the agent kept searching), nor is a switch that was off only by default: each capability now runs whenever its slot resolves.
- Keys saved in the model settings are encrypted under `OPENMAIC_SECRET_KEY`, or, when it is unset, under a secret generated on first use in `data/instance-secret.key`. Set `OPENMAIC_SECRET_KEY` when several replicas share the database, when `data/` does not survive a restart (containers without a volume, serverless hosts) and when the working directory is read-only (saving a key fails there otherwise); back up the secret file with the database otherwise. Keys sealed under a lost or different secret cannot be read and have to be entered again. The server warns at startup when `OPENMAIC_SECRET_KEY` is unset and the data directory is not writable, and when stored keys were sealed under a different secret than the current one; it still starts.
### Features
@@ -34,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Owner identity: a built-in `singleUser` owner for personal installations. `OWNER_SINGLE_USER=true` resolves every request to one owner (`OWNER_SINGLE_USER_ID`, default `local`) with `kind: 'user'` and `course:publish`, minting no anonymous cookie; the principal gets a claim candidate from an anonymous cookie the browser still carries, so earlier anonymous work can be claimed into it explicitly (`POST /api/identity/claim`; `OWNER_CLAIM_TRIGGER=auto` would merge every visiting browser's anonymous library, so it is not a default). It runs with or without `ACCESS_CODE`; without one the server logs one prominent startup warning (in place of the generic unset-`ACCESS_CODE` warning) that anyone who can reach it shares, edits and can delete the single library. It excludes `PERSISTENCE_SHARED_OWNER_ID`; hosts may register `singleUserAuthMethod()` last, under the same rules as `sharedTeamAuthMethod()`. Malformed values fail startup. The app warns at startup when published beyond loopback (`OPENMAIC_PUBLISH_ADDRESS`, which the Compose file passes to it) with the Compose default PostgreSQL password.
- Owner identity: one resolution entry point with composable auth methods. A host registers an ordered list of `OwnerAuthMethod`s once from `instrumentation.ts` with `configureOwnerAuthentication({ methods, anonymousFallback? })` (single-shot, sealed on first use, validated at boot). Each method answers `authenticated`, `not-applicable` (no credential of its kind) or `invalid` (its credential is present but bad); core keeps the first `authenticated` answer, refuses any `invalid` answer with `401 INVALID_CREDENTIAL` without asking later methods, and when no method applies falls back to the anonymous cookie owner, or answers `401` with `anonymousFallback: false`. Route handlers and Server Actions ask the same methods in the same order, once per request. `PERSISTENCE_SHARED_OWNER_ID` still selects the `sharedTeam` owner on its own; beside host methods it is kept by listing `sharedTeamAuthMethod()` last, and setting the variable while a registration leaves it out fails startup. Identity gateways are covered by a documented recipe (a host method that verifies the gateway's signed JWT against the identity provider's keys), not by a built-in. Host methods live in `lib/server/identity/host/`, the only place the boundary test lets code read gateway identity headers or an incoming `Authorization` header. On `403 OWNER_RETIRED`, core calls `clearCredential` only on the anonymous fallback and on methods that declare `issuesAnonymousOwners: true`, never on an account method. The unreleased `OWNER_AUTHENTICATOR` / `TRUSTED_PROXY_*` variables of an earlier built-in gateway-header authenticator fail startup when set.
- Owner identity: claiming anonymous work into an account. When a host auth method authenticates a non-anonymous principal and the same request also carries a valid anonymous owner cookie, core attaches a `pendingClaim` naming that anonymous owner (never for an anonymous principal or the built-in `sharedTeam`, and a method cannot set one itself), which covers both an anonymous-then-sign-in flow and a deployment moving from anonymous use to accounts. `POST /api/identity/claim` (same-origin JSON only; `OWNER_CLAIM_TRIGGER=auto` claims on the first request carrying a pending claim, except the explicit claim routes) moves the anonymous owner's folders, courses, materials, agent sessions, skills, runtime sessions and asset entries to the account in one transaction, records it in the new `owner_merges` table, and drops the anonymous cookie. Same-named folders merge, a folder id the account already uses is renumbered, a colliding skill handle gets the first free numeric suffix, runtime sessions are re-keyed as stored, and quotas are not applied to what moves. Claims are idempotent per pair, refused for an anonymous owner another account already claimed, and never chain. A retired anonymous id writes nothing afterwards: creates and writes through `/api/persistence`, the folder, course, material and skill routes, and writes by id to moved rows answer `403 OWNER_RETIRED`, with a `Set-Cookie` that drops the retired cookie. Agent runs, generated media and skills created by a run that started before the claim follow it to the account. Every owner write takes a per-owner advisory lock first, so a fenced write racing a claim is moved or refused; lock waits are bounded (`OWNER_CLAIM_LOCK_WAIT_MS`, `OWNER_WRITE_LOCK_WAIT_MS`) and running out answers `503 OWNER_BUSY` with `Retry-After`. The anonymous cookie is a bearer credential: its holder can claim that anonymous work into a signed-in account, so clear it on shared devices. Hosts add their own tables with `registerClaimParticipant`, and an auth method may implement `describeStoredOwner` and `clearCredential`; `principalFromStoredOwner(ownerId)` describes a stored id for work without a request. The runtime contract's `POST /runtime/learners/merge` now performs the same claim, allowed only from the request's own pending claim. The owner-events stream tells a retired owner only that it moved, never the account's id.
- `@openmaic/storage` 0.36.0: `PgAssetStore.releasePending(principal, ref)` deletes an entry only while it is still an unclaimed allocation (the principal's, pending, and named by no document), and answers whether it did. It locks the pending entry and then checks references in a separate statement, like the collector's entry pass, so a reference a concurrent backfill commits is never cascaded away. A writer that allocated ids and then failed to write the document naming them can release them at once without risking an entry a document did commit.
- `@openmaic/storage` 0.35.1: `reassignDocumentFolders(tx, { fromOwnerId, toOwnerId, documentOwnership })` moves one owner's folders and filing to another (merging same-named folders, renumbering colliding ids); `PgAssetStore.reassignPrincipal(fromKey, toKey)` moves a principal's entries under both principals' write locks; `PgUserSkillStore.mergeOwner(from, to)` moves skills, renaming a handle the target holds to one neither side holds; `PgRuntimeStore.reassignLearner(from, to)` re-keys sessions without re-validating them. `PgUserSkillStore` (`resolveFinalOwner`) and `PgRuntimeStore` (`resolveFinalLearner`) take a hook that runs as the create transaction's first statement. Every HTTP handler (runtime, documents, assets) now answers a `DocumentWriteRefusedError` as `403` with its code, and the new `StorageBusyError(code, message, retryAfterSeconds)` as `503` with its code and `Retry-After`. `PgUserSkillStore.list` orders ties by id.
- Server persistence: host extension hooks, registered once from `instrumentation.ts` next to the owner auth methods and sealed on first use. `configurePersistenceHooks` adds `authorizeCreate` / `onCreate` (run once per created course inside its transaction; a refusal answers `403 CREATE_REFUSED` and a throw rolls the create back), a `library` provider for what `GET /api/stages` lists (never an id the read path would refuse), and `beforeAssetAllocate` (refuse an upload, `create` or `replace`, with any `Response` before bytes are stored or quota counted). Hooks receive an `actor` whose `source` is `'request'` (with the resolved principal) or `'background'` (an agent run; a refusal reaches the agent as a fixed message). `configureAssetByteStore` replaces the `ASSET_S3_BUCKET` switch for the request path and the asset collector alike; under `ASSET_BYTE_EGRESS=redirect` a store that does not declare `signsReadUrls` stops the server at boot. Nothing changes when no hook is registered.
- `@openmaic/storage` 0.33.0: `DocumentWriteRefusedError(stageId, code, message)` lets a store refuse a write as policy; the document HTTP handler answers `403` with the error's code and applies nothing. `isDocumentWriteRefusedError` also recognizes one thrown by another copy of the package, by name and shape.
+143 -131
View File
@@ -108,54 +108,99 @@ pnpm install
```bash
cp .env.example .env.local
cp openmaic.example.yml openmaic.yml
```
至少填写一个 LLM 服务商的 API Key:
```env
OPENAI_API_KEY=sk-...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=YOUR-DEPLOYMENT-NAME
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=...
GROK_API_KEY=xai-...
OPENROUTER_API_KEY=sk-or-...
TENCENT_API_KEY=sk-...
XIAOMI_API_KEY=...
# 或使用 AWS 凭证和 BEDROCK_REGION 配置 Amazon Bedrock。
```
也可以通过 `server-providers.yml` 配置服务商:
复制出的示例只需要在 `.env.local` 中设置 `OPENAI_API_KEY`(也可以把其中的服务商换成你有 key 的那家);其余服务商和槽位都已注释,需要时再取消注释。`openmaic.yml` 声明服务端可以调用哪些**服务商**(账号),以及每个**槽位**(一处用到 AI 的地方:大纲、幻灯片内容、语音合成、联网搜索……)使用哪个模型。Key 留在 `.env.local` 里,通过 `${VAR}` 引用:
```yaml
providers:
openai:
apiKey: sk-...
azure:
apiKey: ...
baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
models:
- YOUR-DEPLOYMENT-NAME
preset: openai
apiKey: ${OPENAI_API_KEY} # 在 .env.local 里写 OPENAI_API_KEY=sk-...
anthropic:
apiKey: sk-ant-...
bedrock:
models:
- us.anthropic.claude-sonnet-5
- us.anthropic.claude-opus-4-8
preset: anthropic
apiKey: ${ANTHROPIC_API_KEY}
slots:
llm: openai:gpt-5.5 # 默认对话模型
course.outline: anthropic:claude-sonnet-4-6
video: null # 关闭某项能力
```
写进文件的槽位会被锁定;没写的槽位沿用父槽位,可以在 Web 端的模型设置里选择,用户也可以在那里接入自己的服务(在那里保存的 Key 用 `OPENMAIC_SECRET_KEY` 加密存储)。服务启动时会校验该文件,出错时会指出出错的字段(YAML 语法错误则指出行号)。槽位说明、预设、回退模型和策略见[配置说明](packages/docs/content/docs/configuration.zh-cn.mdx),预设和模型 ID 见[支持的模型](packages/docs/content/docs/supported-models.zh-cn.mdx)。
支持的服务商:**OpenAI**、**Azure OpenAI**、**Anthropic**、**Amazon Bedrock**、**Google Gemini**、**DeepSeek**、**通义千问 Qwen**、**Kimi**、**MiniMax**、**Grok (xAI)**、**OpenRouter**、**TokenDance**、**豆包**、**腾讯混元 / TokenHub**、**小米 MiMo**、**智谱 GLM**、**Ollama**(本地)、**Lemonade**(本地 LLM / 图像 / TTS / ASR)、**FunASR**(本地 ASR)以及任何兼容 OpenAI API 的服务。
Amazon Bedrock 快速示例:
> **从旧版本升级?** 没有 `openmaic.yml` 时,服务商环境变量(`OPENAI_API_KEY`、`TTS_*`、`IMAGE_*` 等)、`server-providers.yml`、`DEFAULT_MODEL` 和 `MODEL_FALLBACK` 仍然有效:服务启动时会自动转换,并在日志里给出弃用提示。`MODEL_ROUTES` 不再读取,在没有 `openmaic.yml` 的情况下设置它会导致服务拒绝启动。详见[从旧配置迁移](packages/docs/content/docs/configuration.zh-cn.mdx#从旧配置迁移)。
```env
BEDROCK_REGION=us-east-1
BEDROCK_MODELS=us.anthropic.claude-sonnet-5,us.anthropic.claude-opus-4-8
DEFAULT_MODEL=bedrock:us.anthropic.claude-sonnet-5
Token Plan 快速示例(一个 Key 同时覆盖对话、图像、视频、TTS 与联网搜索;`tokendance` 用法相同):
```yaml
providers:
minimax:
preset: minimax
apiKey: ${MINIMAX_API_KEY}
slots:
llm: minimax:MiniMax-M3
image: minimax # 只写服务商 ID:使用该套餐的默认模型
video: minimax
tts: minimax
webSearch: minimax
```
Bedrock 使用 AWS 环境凭证或 AWS SDK 凭证链。临时凭证可设置 `AWS_ACCESS_KEY_ID`、`AWS_SECRET_ACCESS_KEY` 和 `AWS_SESSION_TOKEN`,也可以使用运行环境可用的 AWS profile / role。
TokenDance 快速示例(默认模型选速度快、长上下文的模型):
```yaml
providers:
tokendance:
preset: tokendance
apiKey: ${TOKENDANCE_API_KEY}
slots:
llm: tokendance:deepseek-v4.1-flash
image: tokendance
video: tokendance
tts: tokendance
webSearch: tokendance
```
小米 MiMo Token Plan 与智谱 GLM 快速示例:
```yaml
providers:
mimo:
preset: xiaomi
apiKey: ${MIMO_API_KEY}
baseUrl: https://token-plan-cn.xiaomimimo.com/v1
glm:
preset: glm
apiKey: ${GLM_API_KEY}
baseUrl: https://open.bigmodel.cn/api/paas/v4 # 国际站用 https://api.z.ai/api/paas/v4
slots:
llm: mimo:mimo-v2.5-pro
course.content: glm:glm-5.1
```
新加坡或欧洲 Token Plan 集群可分别使用 `https://token-plan-sgp.xiaomimimo.com/v1`、`https://token-plan-ams.xiaomimimo.com/v1`。
Amazon Bedrock 快速示例:
```yaml
providers:
bedrock:
preset: bedrock
models: [us.anthropic.claude-sonnet-5, us.anthropic.claude-opus-4-8]
slots:
llm: bedrock:us.anthropic.claude-sonnet-5
```
Bedrock 使用 AWS 环境凭证或 AWS SDK 凭证链,区域取自 `BEDROCK_REGION`(例如在 `.env.local` 中设置 `BEDROCK_REGION=us-east-1`)。临时凭证可设置 `AWS_ACCESS_KEY_ID`、`AWS_SECRET_ACCESS_KEY` 和 `AWS_SESSION_TOKEN`,也可以使用运行环境可用的 AWS profile / role。
> **推荐配置:** 打开全部模态时 OpenMAIC 效果最好——配图、语音讲解、视频片段与联网检索都会参与生成。最省事的方式是用一个 Key 覆盖全部模态(见上方的 Token Plan 示例),默认模型选 `deepseek-v4.1-flash` 这类速度快、长上下文的模型即可。
<a id="lemonade-local-ai"></a>
@@ -165,13 +210,30 @@ OpenMAIC 支持将 Lemonade 作为本地 OpenAI 兼容服务商使用,可用
本地启动 Lemonade 后,在 OpenMAIC 中配置:
```env
LEMONADE_BASE_URL=http://localhost:13305/v1
TTS_LEMONADE_BASE_URL=http://localhost:13305/v1
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1
```yaml
providers:
lemonade:
preset: lemonade
baseUrl: http://localhost:13305/v1
lemonade-tts:
preset: lemonade-tts
baseUrl: http://localhost:13305/v1
lemonade-asr:
preset: lemonade-asr
baseUrl: http://localhost:13305/v1
lemonade-image:
preset: lemonade-image
baseUrl: http://localhost:13305/v1
slots:
llm: lemonade:Gemma-4-26B-A4B-it-GGUF
tts: lemonade-tts
asr: lemonade-asr
image: lemonade-image
```
旧版环境变量的等价写法是 `LEMONADE_BASE_URL`、`TTS_LEMONADE_BASE_URL`、`ASR_LEMONADE_BASE_URL` 和 `IMAGE_LEMONADE_BASE_URL`。
<a id="funasr-local-asr"></a>
### 可选:FunASR(本地语音识别)
@@ -188,92 +250,20 @@ funasr-server --device cuda --model fun-asr-nano
将 OpenMAIC 指向该服务:
```env
ASR_FUNASR_BASE_URL=http://localhost:8000/v1
```yaml
providers:
funasr:
preset: funasr-asr
baseUrl: http://localhost:8000/v1
slots:
asr: funasr
```
(旧版环境变量写法:`ASR_FUNASR_BASE_URL=http://localhost:8000/v1`。)
纯 CPU 环境可运行 `funasr-server --device cpu --model sensevoice`。生产部署方式参见 [FunASR 部署指南](https://github.com/modelscope/FunASR#deploy)。
OpenAI 快速示例:
```env
OPENAI_API_KEY=sk-...
DEFAULT_MODEL=openai:gpt-5.5
```
MiniMax 快速示例:
```env
MINIMAX_API_KEY=...
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic/v1
DEFAULT_MODEL=minimax:MiniMax-M2.7-highspeed
TTS_MINIMAX_API_KEY=...
TTS_MINIMAX_BASE_URL=https://api.minimaxi.com
IMAGE_MINIMAX_API_KEY=...
IMAGE_MINIMAX_BASE_URL=https://api.minimaxi.com
IMAGE_OPENAI_API_KEY=...
IMAGE_OPENAI_BASE_URL=https://api.openai.com/v1
VIDEO_MINIMAX_API_KEY=...
VIDEO_MINIMAX_BASE_URL=https://api.minimaxi.com
```
小米 MiMo Token Plan 快速示例:
```env
MIMO_API_KEY=tp-...
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
DEFAULT_MODEL=xiaomi:mimo-v2.5-pro
```
新加坡或欧洲 Token Plan 集群可分别使用 `https://token-plan-sgp.xiaomimimo.com/v1`、`https://token-plan-ams.xiaomimimo.com/v1`。
TokenDance 快速示例(一个 Key 同时覆盖对话、图像、视频、TTS 与联网搜索):
```env
TOKENDANCE_API_KEY=sk-...
TOKENDANCE_BASE_URL=https://tokendance.space/gateway/v1
DEFAULT_MODEL=tokendance:deepseek-v4.1-flash
IMAGE_SEEDREAM_API_KEY=sk-...
IMAGE_SEEDREAM_BASE_URL=https://tokendance.space/gateway/ark/v3
IMAGE_SEEDREAM_MODELS=seedream-5.0-lite
VIDEO_MINIMAX_API_KEY=sk-...
VIDEO_MINIMAX_BASE_URL=https://tokendance.space/gateway/minimax
VIDEO_MINIMAX_MODELS=minimax-h3
TTS_MINIMAX_API_KEY=sk-...
TTS_MINIMAX_BASE_URL=https://tokendance.space/gateway/minimax
TTS_MINIMAX_MODELS=minimax-speech-2.8-turbo
BOCHA_API_KEY=sk-...
BOCHA_BASE_URL=https://tokendance.space/gateway/bocha
```
不想改 `.env.local` 的话,在 **设置 → Token Plan → TokenDance** 中可以一键把同一个 Key 填入全部模态。
智谱 GLM 快速示例:
```env
# 国内站(默认)
GLM_API_KEY=...
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# 国际站(z.ai)
GLM_API_KEY=...
GLM_BASE_URL=https://api.z.ai/api/paas/v4
DEFAULT_MODEL=glm:glm-5.1
```
> **推荐配置:** 打开全部模态时 OpenMAIC 效果最好——配图、语音讲解、视频片段与联网检索都会参与生成。最省事的方式是用一个 Key 覆盖全部模态(见上方的一键示例),默认模型选 `deepseek-v4.1-flash` 这类速度快、长上下文的模型即可。
>
> 如果希望默认走 MiniMax,可设置 `DEFAULT_MODEL=minimax:MiniMax-M2.7-highspeed`。
### 3. 启动数据库
```bash
@@ -335,6 +325,8 @@ cp .env.example .env.local
docker compose up --build
```
如需用 `openmaic.yml` 配置模型,请先从 `openmaic.example.yml` 复制出该文件,再取消 `docker-compose.yml` 中对应挂载行的注释;否则启动后在模型设置里接入模型服务即可。
打开 **http://localhost:3000**。整套服务是两个容器:应用和 PostgreSQL;PostgreSQL 健康检查通过后应用才会启动。课程、生成的媒体和运行时会话都[存储在服务端](#服务端持久化postgresql)的命名卷(`openmaic-postgres`、`openmaic-data`)中,`docker compose down` 和重新构建后依然保留;`docker compose down -v` 会删除它们。
Compose 文件默认按**个人安装**配置:
@@ -409,7 +401,7 @@ DATABASE_URL=postgres://openmaic:password@localhost:5432/openmaic pnpm start
本地开发时,`pnpm db:up` 启动一个独立的开发数据库(以单独的项目和数据卷 `openmaic-dev-db` 运行 Compose 的 `postgres` 服务定义,本机所有检出共用)并发布到 `127.0.0.1`(端口 `OPENMAIC_DB_PORT`,默认 `5432`);对应的 `DATABASE_URL` 已在 `.env.example` 中以注释给出,`pnpm db:down` 可将其停止。Serverless 平台(见 [Vercel 部署](#vercel-部署))请把 `DATABASE_URL` 指向外部 PostgreSQL。
和往常一样把服务商 API Key 填进 `.env.local`。课程文档、文件夹、对话历史与学习者运行时会话、生成的媒体都保存在服务端。留在浏览器里的只有属于该设备、丢失也不会丢课的数据:应用设置与界面偏好、播放进度与编辑器当前场景、编辑器撤销历史、服务端已保存的讲解与媒体的本地缓存(以及因存储已满被拒、为重试保留的字节)、生成期间暂存的 PDF 图片,以及在该浏览器注册的 TTS 音色。**设置 → 清除本地缓存**只清除这些,不会动服务端的任何数据。
和往常一样配置模型(`openmaic.yml` 加 `.env.local` 中的 key,或 设置 → 模型服务)。课程文档、文件夹、对话历史与学习者运行时会话、生成的媒体都保存在服务端。留在浏览器里的只有属于该设备、丢失也不会丢课的数据:应用设置与界面偏好、播放进度与编辑器当前场景、编辑器撤销历史、服务端已保存的讲解与媒体的本地缓存(以及因存储已满被拒、为重试保留的字节)、生成期间暂存的 PDF 图片,以及在该浏览器注册的 TTS 音色。**设置 → 清除本地缓存**只清除这些,不会动服务端的任何数据。
**从纯浏览器构建升级。** 此前纯浏览器构建保存在浏览器中的课程会自动搬到服务端,无需任何操作,也没有界面提示:该浏览器首次打开升级后的应用、页面空闲时,单向导入器会把课程连同对话、学习者运行时、播放进度、智能体阵容、文件夹及其归类、测验进度和媒体复制给服务端为该浏览器解析出的所有者(默认是匿名 cookie 所有者),课程随后出现在课程库中。每个浏览器只导入一次:服务端把该浏览器绑定给第一个请求的所有者(`POST /api/identity/legacy-import-binding`),认领会把绑定带到账号(匿名所有者登录后被认领)。导入器的每个请求都携带浏览器 id,未持有绑定的所有者的请求会被拒绝(`409 LEGACY_IMPORT_NOT_BOUND`),因此之后在同一浏览器中使用的其他所有者不会得到任何导入。浏览器中的原始数据保持不变,**设置 → 清除本地缓存**也不会删除它们。服务端已存在的同名课程以服务端为准;课程 id 已被其他所有者占用时,以新 id 导入;在服务端删除过的课程不会被重新导入。导入进度记录在浏览器中(只有一个随机浏览器 id,不含任何所有者信息),中断后下次加载时继续,且不会重复导入;问题会以 `[legacy-browser-import]` 前缀记录在浏览器控制台中。该导入器是临时的,将在之后的几个版本中移除。
@@ -538,13 +530,25 @@ docker compose --profile video-export up --build
[MinerU](https://github.com/opendatalab/MinerU) 提供更强的表格、公式和 OCR 解析能力。你可以使用 [MinerU 官方 API](https://mineru.net/) 或[自行部署](https://opendatalab.github.io/MinerU/quick_start/docker_deployment/)。
在 `.env.local` 中设置 `PDF_MINERU_BASE_URL`(如需认证则同时设置 `PDF_MINERU_API_KEY`)。
在 `openmaic.yml` 中声明它并分配给 `document` 槽位:官方 API 使用 `mineru-cloud`,自行部署的实例使用 `mineru` 并填写其 `baseUrl`。
```yaml
providers:
mineru:
preset: mineru-cloud
apiKey: ${PDF_MINERU_CLOUD_API_KEY}
slots:
document: mineru
```
没有 `openmaic.yml` 时,在 `.env.local` 中设置旧版变量 `PDF_MINERU_CLOUD_API_KEY` 或 `PDF_MINERU_BASE_URL` 仍然有效。
### 可选:VoxCPM2(自托管 TTS,支持音色克隆)
[VoxCPM2](https://github.com/OpenBMB/VoxCPM) 是 OpenBMB 开源的 TTS 模型,支持声音克隆。OpenMAIC 自带适配器,把 VoxCPM 跑在自己机器上即可对接。
**1. 部署 VoxCPM 后端。** 三种部署形态,背后是同一套 OpenMAIC 适配器,在设置里切换即可。
**1. 部署 VoxCPM 后端。** 三种部署形态,背后是同一套 OpenMAIC 适配器,在 `openmaic.yml` 中用 `options.backend` 选择(见第 2 步)。
| 后端 | 接口 | 适用场景 |
| --- | --- | --- |
@@ -554,17 +558,25 @@ docker compose --profile video-export up --build
每种后端的具体启动步骤见 [VoxCPM 仓库](https://github.com/OpenBMB/VoxCPM)。
**2. 在 OpenMAIC 中配置。** 打开 设置 → **语音合成** → **VoxCPM2**,选择后端类型并填入 Base URL,下方的 Request URL 预览会显示实际请求地址。
**2. 在 OpenMAIC 中配置。** VoxCPM2 运行在你自己的网络中,因此由部署在 `openmaic.yml` 中配置(不需要 API Key);工作区不能在 **设置 → 模型服务** 中添加它:
<img src="assets/voxcpm/voxcpm-connection.png" width="85%" alt="VoxCPM2 连接设置:后端选择、Base URL、模型名" />
```yaml
providers:
voxcpm:
preset: voxcpm-tts
baseUrl: http://localhost:8000/v1
options:
backend: vllm-omni # vllm-omni(默认)| python-api | nano-vllm
也可以通过环境变量预先配置(不需要 API Key):
```env
TTS_VOXCPM_BASE_URL=http://localhost:8000/v1
slots:
tts: voxcpm
```
**3. 管理音色。** 三种音色模式,都在 **设置 → 语音合成 → VoxCPM2 → VoxCPM 音色** 里。
音色注册只在 `vllm-omni` 后端可用;`python-api` 和 `nano-vllm` 会随每次请求发送音色提示。
没有 `openmaic.yml` 时,旧版的 `TTS_VOXCPM_BASE_URL=http://localhost:8000/v1` 可以设置端点,但无法选择后端。
**3. 管理音色。** 把 VoxCPM2 分配给 `tts` 槽位后,打开 **设置 → 模型服务 → 语音合成 → VoxCPM2**(只有 `tts` 解析到 `voxcpm-tts` 服务商时才会出现音色管理)。三种音色模式:
<img src="assets/voxcpm/voxcpm-voice-manager.png" width="85%" alt="VoxCPM2 音色管理:Auto / Prompt / Clone 三种模式" />
+164 -137
View File
@@ -128,54 +128,99 @@ pnpm install
```bash
cp .env.example .env.local
cp openmaic.example.yml openmaic.yml
```
Fill in at least one LLM provider key:
```env
OPENAI_API_KEY=sk-...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=YOUR-DEPLOYMENT-NAME
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=...
GROK_API_KEY=xai-...
OPENROUTER_API_KEY=sk-or-...
TENCENT_API_KEY=sk-...
XIAOMI_API_KEY=...
# Or configure Amazon Bedrock with AWS credentials and BEDROCK_REGION.
```
You can also configure providers via `server-providers.yml`:
The copied example needs only `OPENAI_API_KEY` in `.env.local` (or change its provider to one you have a key for); its other providers and slots are commented out until you want them. `openmaic.yml` says which **providers** (accounts) the server can call and which model each **slot** (a use of AI: the outline, slide content, text to speech, web search, …) uses. Keys stay in `.env.local` and are referenced as `${VAR}`:
```yaml
providers:
openai:
apiKey: sk-...
azure:
apiKey: ...
baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
models:
- YOUR-DEPLOYMENT-NAME
preset: openai
apiKey: ${OPENAI_API_KEY} # OPENAI_API_KEY=sk-... in .env.local
anthropic:
apiKey: sk-ant-...
bedrock:
models:
- us.anthropic.claude-sonnet-5
- us.anthropic.claude-opus-4-8
preset: anthropic
apiKey: ${ANTHROPIC_API_KEY}
slots:
llm: openai:gpt-5.5 # the default chat model
course.outline: anthropic:claude-sonnet-4-6
video: null # turn a capability off
```
Slots you write are locked; slots you leave out follow their parent and can be chosen in the model settings of the web app, where users can also connect services of their own (keys saved there are encrypted with `OPENMAIC_SECRET_KEY`). The server validates the file at startup and names every mistake by its field (or, for broken YAML, its line). See [Configuration](packages/docs/content/docs/configuration.mdx) for the slot reference, presets, fallbacks and policy, and [Supported models](packages/docs/content/docs/supported-models.mdx) for preset and model IDs.
Supported providers: **OpenAI**, **Azure OpenAI**, **Anthropic**, **Amazon Bedrock**, **Google Gemini**, **DeepSeek**, **Qwen**, **Kimi**, **MiniMax**, **Grok (xAI)**, **OpenRouter**, **TokenDance**, **Doubao**, **Tencent Hunyuan/TokenHub**, **Xiaomi MiMo**, **GLM (Zhipu)**, **Ollama** (local), **Lemonade** (local LLM / image / TTS / ASR), **FunASR** (local ASR), and any OpenAI-compatible API.
Amazon Bedrock quick example:
> **Upgrading?** Provider environment variables (`OPENAI_API_KEY`, `TTS_*`, `IMAGE_*`, …), `server-providers.yml`, `DEFAULT_MODEL` and `MODEL_FALLBACK` still work while there is no `openmaic.yml`: the server translates them at startup and logs a deprecation notice. `MODEL_ROUTES` is no longer read, and a server that sets it without `openmaic.yml` refuses to start. See [Migrating from the legacy configuration](packages/docs/content/docs/configuration.mdx#migrating-from-the-legacy-configuration).
```env
BEDROCK_REGION=us-east-1
BEDROCK_MODELS=us.anthropic.claude-sonnet-5,us.anthropic.claude-opus-4-8
DEFAULT_MODEL=bedrock:us.anthropic.claude-sonnet-5
Token plan quick example (one key for chat, image, video, TTS and web search; `tokendance` works the same way):
```yaml
providers:
minimax:
preset: minimax
apiKey: ${MINIMAX_API_KEY}
slots:
llm: minimax:MiniMax-M3
image: minimax # a provider id alone: the plan's default model
video: minimax
tts: minimax
webSearch: minimax
```
Bedrock uses AWS environment credentials or the AWS SDK credential provider chain. For temporary credentials, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`, or use an AWS profile / role available to the runtime.
TokenDance quick example with a fast long-context default model:
```yaml
providers:
tokendance:
preset: tokendance
apiKey: ${TOKENDANCE_API_KEY}
slots:
llm: tokendance:deepseek-v4.1-flash
image: tokendance
video: tokendance
tts: tokendance
webSearch: tokendance
```
Xiaomi MiMo Token Plan and GLM (Zhipu) quick example:
```yaml
providers:
mimo:
preset: xiaomi
apiKey: ${MIMO_API_KEY}
baseUrl: https://token-plan-cn.xiaomimimo.com/v1
glm:
preset: glm
apiKey: ${GLM_API_KEY}
baseUrl: https://open.bigmodel.cn/api/paas/v4 # or https://api.z.ai/api/paas/v4
slots:
llm: mimo:mimo-v2.5-pro
course.content: glm:glm-5.1
```
Use `https://token-plan-sgp.xiaomimimo.com/v1` or `https://token-plan-ams.xiaomimimo.com/v1` for the Singapore or Europe Token Plan clusters.
Amazon Bedrock quick example:
```yaml
providers:
bedrock:
preset: bedrock
models: [us.anthropic.claude-sonnet-5, us.anthropic.claude-opus-4-8]
slots:
llm: bedrock:us.anthropic.claude-sonnet-5
```
Bedrock uses AWS environment credentials or the AWS SDK credential provider chain, with the region from `BEDROCK_REGION` (for example `BEDROCK_REGION=us-east-1` in `.env.local`). For temporary credentials, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`, or use an AWS profile / role available to the runtime.
> **Recommended setup:** OpenMAIC is at its best with every modality turned on — generated illustrations, narration, video clips, and web-grounded research. The least friction is a single key that covers all of them (see the token plan examples above), with a fast long-context model such as `deepseek-v4.1-flash` as the default.
<a id="lemonade-local-ai"></a>
@@ -185,13 +230,30 @@ OpenMAIC supports Lemonade as a local, OpenAI-compatible provider for LLMs, imag
Run Lemonade locally, then point OpenMAIC to it:
```env
LEMONADE_BASE_URL=http://localhost:13305/v1
TTS_LEMONADE_BASE_URL=http://localhost:13305/v1
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1
```yaml
providers:
lemonade:
preset: lemonade
baseUrl: http://localhost:13305/v1
lemonade-tts:
preset: lemonade-tts
baseUrl: http://localhost:13305/v1
lemonade-asr:
preset: lemonade-asr
baseUrl: http://localhost:13305/v1
lemonade-image:
preset: lemonade-image
baseUrl: http://localhost:13305/v1
slots:
llm: lemonade:Gemma-4-26B-A4B-it-GGUF
tts: lemonade-tts
asr: lemonade-asr
image: lemonade-image
```
With the legacy variables, the same is `LEMONADE_BASE_URL`, `TTS_LEMONADE_BASE_URL`, `ASR_LEMONADE_BASE_URL` and `IMAGE_LEMONADE_BASE_URL`.
<a id="funasr-local-asr"></a>
### Optional: FunASR (Local Speech Recognition)
@@ -208,98 +270,26 @@ funasr-server --device cuda --model fun-asr-nano
Point OpenMAIC at the server:
```env
ASR_FUNASR_BASE_URL=http://localhost:8000/v1
```yaml
providers:
funasr:
preset: funasr-asr
baseUrl: http://localhost:8000/v1
slots:
asr: funasr
```
(Legacy variable: `ASR_FUNASR_BASE_URL=http://localhost:8000/v1`.)
Use `funasr-server --device cpu --model sensevoice` for a CPU-only setup. See the [FunASR deployment guide](https://github.com/modelscope/FunASR#deploy) for production options.
### Optional: Local Audio and Video Extraction
OpenMAIC can extract timestamped transcripts and prepared video keyframes locally. Install the system `ffmpeg` package so both `ffmpeg` and `ffprobe` are executable on `PATH`, then configure one server ASR provider (for example FunASR, Lemonade, or OpenAI) using the variables above. The application resolves the executables at extraction time; ffmpeg is not an npm dependency and is not required to start or use OpenMAIC.
OpenMAIC can extract timestamped transcripts and prepared video keyframes locally. Install the system `ffmpeg` package so both `ffmpeg` and `ffprobe` are executable on `PATH`, then assign the `asr` slot (for example FunASR, Lemonade, or OpenAI) as above. The application resolves the executables at extraction time; ffmpeg is not an npm dependency and is not required to start or use OpenMAIC.
If the executables are unavailable, the local extractor is skipped. A configured AliDocMind provider remains available as the cloud extraction path. When neither local ffmpeg extraction nor AliDocMind is available, audio/video materials are marked failed with an actionable setup message instead of hanging or completing with an empty transcript.
OpenAI quick example:
```env
OPENAI_API_KEY=sk-...
DEFAULT_MODEL=openai:gpt-5.5
```
MiniMax quick examples:
```env
MINIMAX_API_KEY=...
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic/v1
DEFAULT_MODEL=minimax:MiniMax-M2.7-highspeed
TTS_MINIMAX_API_KEY=...
TTS_MINIMAX_BASE_URL=https://api.minimaxi.com
IMAGE_MINIMAX_API_KEY=...
IMAGE_MINIMAX_BASE_URL=https://api.minimaxi.com
IMAGE_OPENAI_API_KEY=...
IMAGE_OPENAI_BASE_URL=https://api.openai.com/v1
VIDEO_MINIMAX_API_KEY=...
VIDEO_MINIMAX_BASE_URL=https://api.minimaxi.com
```
Xiaomi MiMo Token Plan quick example:
```env
MIMO_API_KEY=tp-...
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
DEFAULT_MODEL=xiaomi:mimo-v2.5-pro
```
Use `https://token-plan-sgp.xiaomimimo.com/v1` or `https://token-plan-ams.xiaomimimo.com/v1` for the Singapore or Europe Token Plan clusters.
TokenDance quick example (one key for chat, image, video, TTS, and web search):
```env
TOKENDANCE_API_KEY=sk-...
TOKENDANCE_BASE_URL=https://tokendance.space/gateway/v1
DEFAULT_MODEL=tokendance:deepseek-v4.1-flash
IMAGE_SEEDREAM_API_KEY=sk-...
IMAGE_SEEDREAM_BASE_URL=https://tokendance.space/gateway/ark/v3
IMAGE_SEEDREAM_MODELS=seedream-5.0-lite
VIDEO_MINIMAX_API_KEY=sk-...
VIDEO_MINIMAX_BASE_URL=https://tokendance.space/gateway/minimax
VIDEO_MINIMAX_MODELS=minimax-h3
TTS_MINIMAX_API_KEY=sk-...
TTS_MINIMAX_BASE_URL=https://tokendance.space/gateway/minimax
TTS_MINIMAX_MODELS=minimax-speech-2.8-turbo
BOCHA_API_KEY=sk-...
BOCHA_BASE_URL=https://tokendance.space/gateway/bocha
```
Without touching `.env.local`, **Settings → Token Plan → TokenDance** applies the same key to every modality in one step.
GLM (Zhipu) quick examples:
```env
# China (default)
GLM_API_KEY=...
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# International (z.ai)
GLM_API_KEY=...
GLM_BASE_URL=https://api.z.ai/api/paas/v4
DEFAULT_MODEL=glm:glm-5.1
```
> **Recommended setup:** OpenMAIC is at its best with every modality turned on — generated illustrations, narration, video clips, and web-grounded research. The least friction is a single key that covers all of them (see the one-key example above), with a fast long-context model such as `deepseek-v4.1-flash` as the default.
>
> If you want to use MiniMax as the default server model, set `DEFAULT_MODEL=minimax:MiniMax-M2.7-highspeed`.
### 3. Start the database
```bash
@@ -378,6 +368,8 @@ cp .env.example .env.local
docker compose up --build
```
To configure models with `openmaic.yml`, create it from `openmaic.example.yml` and uncomment its mount in `docker-compose.yml`; otherwise connect a model service in the model settings once the app is running.
Open **http://localhost:3000**. The stack is two containers, the app and
PostgreSQL; the app starts once PostgreSQL reports healthy. Courses, generated
media and runtime sessions are [stored on the server](#server-backed-persistence-postgresql)
@@ -511,7 +503,7 @@ matching `DATABASE_URL` is commented in `.env.example`, and `pnpm db:down` stops
it again. Serverless hosts (see [Vercel Deployment](#vercel-deployment)) point
`DATABASE_URL` at an external PostgreSQL database.
Add your provider API keys to `.env.local` as usual. Course documents, folders,
Configure models as usual (`openmaic.yml` with keys in `.env.local`, or Settings → Model Services). Course documents, folders,
chat history and learner runtime sessions, and generated media are stored on
the server. What stays in the browser is what belongs to the device and can be
lost without losing a course: app settings and UI preferences, the playback
@@ -1298,15 +1290,30 @@ it uses the same PostgreSQL connection as the rest of the app:
NEXT_PUBLIC_PRO_WORKBENCH_ENABLED=true
OPENMAIC_AGENT_RUNTIME_ENABLED=true
DATABASE_URL=postgres://openmaic:openmaic-dev@postgres:5432/openmaic
MODEL_ROUTES='{"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'
```
The agent runs on the `agent` slot of `openmaic.yml` (or the model settings), which follows the default chat model unless it has its own assignment and needs a model with tool calling:
```yaml
providers:
openai:
preset: openai
apiKey: ${OPENAI_API_KEY}
slots:
llm: openai:gpt-5.5
agent:
model: openai:gpt-5.5
api: openai-completions # or openai-responses
```
While the flag is off, the `/api/agent/sessions*` and `/api/agent/owner-events`
routes answer `404`; the course library and folder routes do not depend on the
flag (see [Server-backed persistence](#server-backed-persistence-postgresql)). `MODEL_ROUTES` must explicitly
route `maic-agent-driver` to a provider-prefixed model with an
`openai-completions` or `openai-responses` `api`/`dialect`; there is intentionally
no fallback.
flag (see [Server-backed persistence](#server-backed-persistence-postgresql)). The `agent` slot's
`api` is `openai-completions` (the default) or `openai-responses`, and it must not set
`thinking.effort` (the configuration is refused at startup or when saved); it may set
`thinking.mode`. An effort the agent inherits from `llm` is dropped for the agent. A deployment still configured only through `DEFAULT_MODEL` has the
agent turned off until the `agent` slot is assigned.
Runner cadence (scan interval, heartbeat, lease TTL, concurrency, attempts) and
the reserved compaction knobs are listed in `.env.example`.
@@ -1327,13 +1334,25 @@ The app auto-detects the service via `RENDER_SERVICE_URL` (preset in `docker-com
[MinerU](https://github.com/opendatalab/MinerU) provides enhanced parsing for complex tables, formulas, and OCR. You can use the [MinerU official API](https://mineru.net/) or [self-host your own instance](https://opendatalab.github.io/MinerU/quick_start/docker_deployment/).
Set `PDF_MINERU_BASE_URL` (and `PDF_MINERU_API_KEY` if needed) in `.env.local`.
Declare it in `openmaic.yml` and assign the `document` slot to it: `mineru-cloud` for the official API, or `mineru` with the `baseUrl` of your own instance.
```yaml
providers:
mineru:
preset: mineru-cloud
apiKey: ${PDF_MINERU_CLOUD_API_KEY}
slots:
document: mineru
```
Without `openmaic.yml`, the legacy variables `PDF_MINERU_CLOUD_API_KEY` or `PDF_MINERU_BASE_URL` in `.env.local` still work.
### Optional: VoxCPM2 (Self-Hosted TTS with Voice Cloning)
[VoxCPM2](https://github.com/OpenBMB/VoxCPM) is an open-source TTS model from OpenBMB with voice cloning. OpenMAIC ships an adapter; run VoxCPM on your own hardware and OpenMAIC will talk to it.
**1. Run a VoxCPM backend.** Three deployment styles, all behind the same OpenMAIC adapter. You toggle which one in Settings.
**1. Run a VoxCPM backend.** Three deployment styles, all behind the same OpenMAIC adapter. You pick which one with `options.backend` in `openmaic.yml` (step 2).
| Backend | Endpoint | When to use |
| --- | --- | --- |
@@ -1343,17 +1362,25 @@ Set `PDF_MINERU_BASE_URL` (and `PDF_MINERU_API_KEY` if needed) in `.env.local`.
See the [VoxCPM repo](https://github.com/OpenBMB/VoxCPM) for backend setup.
**2. Point OpenMAIC at it.** Open Settings → **Text-to-Speech** → **VoxCPM2**, pick the backend, and paste your Base URL. The Request URL preview confirms OpenMAIC will hit the right endpoint.
**2. Point OpenMAIC at it.** VoxCPM2 runs on your own network, so the deployment configures it in `openmaic.yml` (no API key required); workspaces cannot add it in **Settings → Model Services**:
<img src="assets/voxcpm/voxcpm-connection.png" width="85%" alt="VoxCPM2 connection settings: backend selector, Base URL, model" />
```yaml
providers:
voxcpm:
preset: voxcpm-tts
baseUrl: http://localhost:8000/v1
options:
backend: vllm-omni # vllm-omni (default) | python-api | nano-vllm
Or pre-configure it via env var (no API key required):
```env
TTS_VOXCPM_BASE_URL=http://localhost:8000/v1
slots:
tts: voxcpm
```
**3. Manage voices.** Three voice modes, all under **Settings → Text-to-Speech → VoxCPM2 → VoxCPM Voices**.
Voice registration works only on the `vllm-omni` backend; `python-api` and `nano-vllm` send the voice prompt with each request.
Without `openmaic.yml`, the legacy `TTS_VOXCPM_BASE_URL=http://localhost:8000/v1` sets the endpoint but cannot choose a backend.
**3. Manage voices.** With VoxCPM2 assigned to the `tts` slot, open **Settings → Model Services → Text-to-Speech → VoxCPM2** (the voice manager appears only when `tts` resolves to a `voxcpm-tts` provider). Three voice modes:
<img src="assets/voxcpm/voxcpm-voice-manager.png" width="85%" alt="VoxCPM2 VoxCPM Voices section with Auto, Prompt and Clone modes" />
+4 -2
View File
@@ -23,6 +23,7 @@ import {
} from '@/lib/chat/pi/config';
import { runPiDirectorLoop } from '@/lib/chat/pi/director-loop';
import type { SendEvent } from '@/lib/chat/pi/types';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { resolveModel } from '@/lib/server/resolve-model';
import { parseUserStageRoutes } from '@/lib/server/model-routes';
import { apiError } from '@/lib/server/api-response';
@@ -153,6 +154,7 @@ async function chat(req: NextRequest, principal: OwnerPrincipal): Promise<Respon
} = await resolveModel({
modelString: body.model,
stage: 'chat-adapter',
workspaceId: await requestWorkspaceId(req),
// Honor the classroom-interaction per-stage override the client sends in
// `x-model-routes`. A routed stage brings its own key and base URL; otherwise the body credentials are used (never x-* headers).
userRoutes: parseUserStageRoutes(req.headers.get('x-model-routes')),
@@ -242,11 +244,11 @@ async function chat(req: NextRequest, principal: OwnerPrincipal): Promise<Respon
log.warn('Native whiteboard capability unavailable: persistence initialization failed');
}
}
let nativeWebSearchConfig: ReturnType<typeof resolveClassroomWebSearchConfig>;
let nativeWebSearchConfig: Awaited<ReturnType<typeof resolveClassroomWebSearchConfig>>;
try {
nativeWebSearchConfig =
childRuntimeMode === 'native'
? resolveClassroomWebSearchConfig({
? await resolveClassroomWebSearchConfig(await requestWorkspaceId(req), {
webSearchProviderId: body.webSearchProviderId,
webSearchApiKey: body.webSearchApiKey,
webSearchBaseUrl: body.webSearchBaseUrl,
+2
View File
@@ -18,6 +18,7 @@ import { isProviderKeyRequired } from '@/lib/ai/providers';
import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
import { apiError } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { resolveModel } from '@/lib/server/resolve-model';
import { parseUserStageRoutes } from '@/lib/server/model-routes';
import type { ThinkingConfig } from '@/lib/types/provider';
@@ -73,6 +74,7 @@ export async function POST(req: NextRequest) {
} = await resolveModel({
modelString: body.model,
stage: 'chat-adapter',
workspaceId: await requestWorkspaceId(req),
// Honor the classroom-interaction per-stage override the client sends in
// `x-model-routes`. A routed stage brings its own key and base URL; otherwise the body credentials are used (never x-* headers).
userRoutes: parseUserStageRoutes(req.headers.get('x-model-routes')),
@@ -1,26 +1,16 @@
import { promises as fs, createReadStream, type ReadStream } from 'fs';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage';
import {
CLASSROOMS_DIR,
classroomMediaMimeType,
isValidClassroomId,
} from '@/lib/server/classroom-storage';
import { parseRangeHeader } from '@/lib/server/http-range';
import { createLogger } from '@/lib/logger';
const log = createLogger('ClassroomMedia');
const MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.aac': 'audio/aac',
};
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=86400, immutable' } as const;
/** Bridge a fs ReadStream into a web ReadableStream, propagating errors and cancel. */
@@ -76,7 +66,7 @@ export async function GET(
}
const ext = path.extname(realPath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const contentType = classroomMediaMimeType(ext) ?? 'application/octet-stream';
// Range requests enable progressive playback and seeking for hosted media
// (e.g. <video> streams the moov atom first, then fetches on seek).
-169
View File
@@ -1,169 +0,0 @@
import { type NextRequest } from 'next/server';
import { validateScene } from '@openmaic/dsl';
import { apiSuccess, apiError, API_ERROR_CODES } from '@/lib/server/api-response';
import {
buildRequestOrigin,
ClassroomAlreadyExistsError,
CLASSROOM_ID_MAX_ATTEMPTS,
generateClassroomId,
isValidClassroomId,
persistClassroom,
readClassroom,
} from '@/lib/server/classroom-storage';
import { sanitizeSceneContent } from '@/lib/server/sanitize-scene-content';
import { createLogger } from '@/lib/logger';
const log = createLogger('Classroom API');
function describeSceneIssue(issue: { path: string; message: string }): string {
const at = issue.path && issue.path !== '' ? issue.path : '/';
return `${at}: ${issue.message}`;
}
export async function POST(request: NextRequest) {
let stageId: string | undefined;
let sceneCount: number | undefined;
try {
const body = await request.json();
const { stage, scenes } = body;
stageId = stage?.id;
sceneCount = scenes?.length;
if (!stage || !scenes) {
return apiError(
API_ERROR_CODES.MISSING_REQUIRED_FIELD,
400,
'Missing required fields: stage, scenes',
);
}
if (typeof stage !== 'object' || Array.isArray(stage)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom stage');
}
if (!Array.isArray(scenes)) {
return apiError(
API_ERROR_CODES.INVALID_REQUEST,
400,
'Invalid classroom scenes: must be an array',
);
}
// The scenes must already have the shape the slide DSL declares (id,
// stageId, title, order, type and a content payload bound to that type).
// Rejecting malformed scenes here keeps garbage out of storage instead of
// letting viewers choke on it later.
for (const [index, scene] of scenes.entries()) {
const result = validateScene(scene);
if (!result.valid) {
const first = result.errors[0];
return apiError(
API_ERROR_CODES.INVALID_REQUEST,
400,
`Invalid classroom scene at index ${index}`,
first ? describeSceneIssue(first) : undefined,
);
}
}
const baseUrl = buildRequestOrigin(request);
// Sanitize every HTML-bearing string in the payload before it reaches
// storage: stored slide HTML is restricted to the formatting vocabulary
// the renderer produces (see sanitize-scene-content.ts).
const safeStage = sanitizeSceneContent(stage);
const safeScenes = sanitizeSceneContent(scenes);
// The storage id is ALWAYS server-generated. A caller-supplied stage.id is
// ignored: ids are public share-URL segments, so accepting one would let
// any visitor name — and therefore attempt to replace — an existing
// classroom. The generated id becomes the stage id and every scene's
// stageId so the persisted document is internally consistent.
let persisted: Awaited<ReturnType<typeof persistClassroom>> | undefined;
for (let attempt = 0; attempt < CLASSROOM_ID_MAX_ATTEMPTS; attempt += 1) {
const id = generateClassroomId();
// Defence in depth: the generator only emits allowlisted characters, but
// an id must never be joined into a filesystem path unasserted.
if (!isValidClassroomId(id)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom id');
}
try {
persisted = await persistClassroom(
{
id,
stage: { ...safeStage, id },
scenes: safeScenes.map((scene) => ({ ...scene, stageId: id })),
},
baseUrl,
{ exclusive: true },
);
break;
} catch (error) {
if (!(error instanceof ClassroomAlreadyExistsError)) {
throw error;
}
// Astronomically unlikely with a 10-character id: pick a fresh id and
// retry a bounded number of times rather than clobbering the
// incumbent classroom.
}
}
if (!persisted) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, 'Classroom id collision');
}
return apiSuccess({ id: persisted.id, url: persisted.url }, 201);
} catch (error) {
log.error(
`Classroom storage failed [stageId=${stageId ?? 'unknown'}, scenes=${sceneCount ?? 0}]:`,
error,
);
return apiError(
API_ERROR_CODES.INTERNAL_ERROR,
500,
'Failed to store classroom',
error instanceof Error ? error.message : String(error),
);
}
}
export async function GET(request: NextRequest) {
try {
const id = request.nextUrl.searchParams.get('id');
if (!id) {
return apiError(
API_ERROR_CODES.MISSING_REQUIRED_FIELD,
400,
'Missing required parameter: id',
);
}
if (!isValidClassroomId(id)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom id');
}
const classroom = await readClassroom(id);
if (!classroom) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, 'Classroom not found');
}
// Classroom files written before this change were stored unsanitized and
// cannot be migrated on deployments we do not control. Run the same
// sanitizer over the payload on the way out so already-stored content is
// cleaned at the single serve choke point too.
return apiSuccess({ classroom: sanitizeSceneContent(classroom) });
} catch (error) {
log.error(
`Classroom retrieval failed [id=${request.nextUrl.searchParams.get('id') ?? 'unknown'}]:`,
error,
);
return apiError(
API_ERROR_CODES.INTERNAL_ERROR,
500,
'Failed to retrieve classroom',
error instanceof Error ? error.message : String(error),
);
}
}
+117 -39
View File
@@ -16,6 +16,17 @@ import {
selectDocumentExtractorProvider,
} from '@/lib/document';
import type { MediaArtifact } from '@/lib/document';
import type { DocumentExtractorConfig, DocumentExtractorProvider } from '@/lib/document/types';
import {
documentSlotGoverns,
extractorConfigFor,
resolveExtractionServices,
slotGovernedRequest,
slotMediaExtractorConfig,
type ExtractionServices,
} from '@/lib/server/material-extraction/services';
import { mediaResolutionResponse } from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { normalizeDocumentMimeType, SUPPORTED_MEDIA_MIME_TYPES } from '@/lib/document/mime';
import { createLogger } from '@/lib/logger';
import {
@@ -221,12 +232,45 @@ function formatTimestamp(ms: number): string {
* exact current messages.
*/
async function runExtraction(
services: ExtractionServices,
source: ExtractSource,
requestConfig: ExtractRequestConfig,
logState: ExtractLogState,
isAssetIdForm: boolean,
) {
const { fileName, fileSize, mimeType, buffer } = source;
// A configured or turned-off document slot decides the service; the
// deprecated request fields may only pick a self-contained extractor.
const governed = documentSlotGoverns(services);
requestConfig = slotGovernedRequest(services, requestConfig);
async function extractionResponse(
extractor: DocumentExtractorProvider,
extractorConfig: DocumentExtractorConfig,
): Promise<Response> {
const artifact = await extractor.extract({
buffer,
fileName,
fileSize,
mimeType,
config: extractorConfig,
});
const result = documentArtifactToParsedPdfContent(artifact);
const resultWithMetadata: ParsedPdfContent = {
...result,
metadata: {
...result.metadata,
pageCount: result.metadata?.pageCount ?? 0,
fileName,
fileSize,
mimeType,
parser: result.metadata?.parser ?? extractor.id,
},
};
return apiSuccess({ data: resultWithMetadata });
}
// Media (audio/video) takes the media extraction path → MediaArtifact,
// flattened to the same text shape documents produce. Same route, same
@@ -249,14 +293,20 @@ async function runExtraction(
`Provider "${requestConfig.providerId}" cannot extract ${mimeType}. Choose a media-capable provider (AliDocMind or local ffmpeg).`,
);
}
// The document slot's media extractor (openmaic.yml or the model
// settings), else the legacy rules below.
const slotMedia = slotMediaExtractorConfig(services, requestConfig.providerId);
const mediaManaged =
!slotMedia &&
!governed &&
requestConfig.providerId !== 'local-ffmpeg' &&
isServerConfiguredProvider('pdf', 'alidocmind');
// When managed, resolve the server-owned AK/SK (env OR YAML) explicitly so
// a YAML-only deployment works — the client-level env fallback reads env
// vars only. Client-entered creds are used only when unmanaged.
const mediaManagedCreds = mediaManaged ? resolveManagedAliDocMindCredentials() : undefined;
let mediaClientBaseUrl = mediaManaged ? undefined : requestConfig.baseUrl || undefined;
let mediaClientBaseUrl =
mediaManaged || slotMedia ? undefined : requestConfig.baseUrl || undefined;
// A client-supplied media extractor endpoint must pass the extractor's
// endpoint rule (see checkClientMediaExtractorBaseUrl).
if (mediaClientBaseUrl) {
@@ -271,20 +321,24 @@ async function runExtraction(
fileName,
fileSize,
mimeType,
config: {
providerId: requestConfig.providerId || '',
apiKey: mediaManaged ? undefined : requestConfig.apiKey || undefined,
baseUrl: mediaManaged ? mediaManagedCreds?.baseUrl : mediaClientBaseUrl,
accessKeyId: mediaManaged
? mediaManagedCreds?.accessKeyId
: requestConfig.accessKeyId || undefined,
accessKeySecret: mediaManaged
? mediaManagedCreds?.accessKeySecret
: requestConfig.accessKeySecret || undefined,
// Env fallback is a last resort for a managed provider whose creds
// weren't resolved above (defensive; resolver already covers env+YAML).
allowEnvFallback: mediaManaged,
},
config: slotMedia
? { ...slotMedia, providerId: requestConfig.providerId || '' }
: {
providerId: requestConfig.providerId || '',
// Local transcription uses the asr slot's connection.
...(services.asr ? { asr: services.asr } : {}),
apiKey: mediaManaged ? undefined : requestConfig.apiKey || undefined,
baseUrl: mediaManaged ? mediaManagedCreds?.baseUrl : mediaClientBaseUrl,
accessKeyId: mediaManaged
? mediaManagedCreds?.accessKeyId
: requestConfig.accessKeyId || undefined,
accessKeySecret: mediaManaged
? mediaManagedCreds?.accessKeySecret
: requestConfig.accessKeySecret || undefined,
// Env fallback is a last resort for a managed provider whose creds
// weren't resolved above (defensive; resolver already covers env+YAML).
allowEnvFallback: mediaManaged,
},
});
logState.resolvedProviderId =
mediaArtifact.metadata.providerId || requestConfig.providerId || '';
@@ -317,9 +371,19 @@ async function runExtraction(
return apiSuccess({ data: mediaResult });
}
// The document slot's service (openmaic.yml or the model settings) when the
// request names no other extractor; legacy server providers keep the rules
// below.
const slotService =
services.document?.origin === 'configuration' &&
(!requestConfig.providerId || requestConfig.providerId === services.document.providerId)
? services.document
: undefined;
let provider = requestConfig.providerId
? getDocumentExtractorProvider(requestConfig.providerId)
: undefined;
: slotService
? getDocumentExtractorProvider(slotService.providerId)
: undefined;
if (requestConfig.providerId && !provider) {
return apiError(
'INVALID_REQUEST',
@@ -355,6 +419,29 @@ async function runExtraction(
}
logState.resolvedProviderId = provider.id;
const usesSlotService = slotService?.providerId === provider.id;
if (governed && provider.requiresServiceConfig && !usesSlotService) {
return apiError(
'INVALID_REQUEST',
422,
`${requestedTypeLabel(mimeType)} extraction needs a document service, and ${
services.document ? 'the configured one cannot read this type' : 'none is configured'
}. Assign one to the document slot in the model settings or openmaic.yml.`,
);
}
if (slotService && usesSlotService) {
let slotConfig = extractorConfigFor(provider.id, services);
// A workspace provider's endpoint was typed by a user: the extractor's
// endpoint rule applies as to a client one.
if (!slotService.managed && slotConfig.baseUrl) {
const checked = await checkClientDocumentExtractorBaseUrl(provider.id, slotConfig.baseUrl);
if (!checked.ok) return apiError('INVALID_URL', 403, checked.message);
slotConfig = { ...slotConfig, baseUrl: checked.baseUrl };
}
return extractionResponse(provider, slotConfig);
}
let managed = isPdfProviderId(provider.id) && isServerConfiguredProvider('pdf', provider.id);
let clientBaseUrl = managed ? undefined : requestConfig.baseUrl || undefined;
if (isSelfHostedMinerUProvider(provider.id) && !managed && !clientBaseUrl) {
@@ -420,28 +507,7 @@ async function runExtraction(
managed,
};
const artifact = await provider.extract({
buffer,
fileName,
fileSize,
mimeType,
config,
});
const result = documentArtifactToParsedPdfContent(artifact);
const resultWithMetadata: ParsedPdfContent = {
...result,
metadata: {
...result.metadata,
pageCount: result.metadata?.pageCount ?? 0,
fileName,
fileSize,
mimeType,
parser: result.metadata?.parser ?? provider.id,
},
};
return apiSuccess({ data: resultWithMetadata });
return extractionResponse(provider, config);
}
export async function POST(req: NextRequest): Promise<Response> {
@@ -652,7 +718,19 @@ async function extract(req: NextRequest, ownerCookies: OwnerCookies): Promise<Re
);
}
return await runExtraction(source, requestConfig, logState, isAssetIdForm);
// The document and speech services of the request's workspace (slots).
// A request's own workspace: never forwarded through a claim.
let services: ExtractionServices;
try {
services = await resolveExtractionServices((await requestWorkspaceId(req)) ?? undefined, {
forward: false,
});
} catch (error) {
const refused = mediaResolutionResponse(error, 'Document extraction');
if (refused) return refused;
throw error;
}
return await runExtraction(services, source, requestConfig, logState, isAssetIdForm);
} catch (error) {
log.error(
`Document extraction failed [provider=${logState.resolvedProviderId ?? 'unknown'}, file="${sanitizeLogValue(
+43 -18
View File
@@ -5,6 +5,8 @@ import {
readClassroomGenerationJob,
} from '@/lib/server/classroom-job-store';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
import { ownerApiError, withOwnerResponseHeaders } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwner } from '@/lib/server/identity/with-owner';
import { createLogger } from '@/lib/logger';
const log = createLogger('ClassroomJob API');
@@ -21,26 +23,49 @@ export async function GET(req: NextRequest, context: { params: Promise<{ jobId:
return apiError('INVALID_REQUEST', 400, 'Invalid classroom generation job id');
}
const job = await readClassroomGenerationJob(jobId);
if (!job) {
return apiError('INVALID_REQUEST', 404, 'Classroom generation job not found');
}
// Only the owner that created the job may poll it; for anyone else it is
// the same 404 as an unknown id.
return withRequestOwner(req, async ({ ownerId }, responseHeaders) => {
try {
const job = await readClassroomGenerationJob(jobId, ownerId);
if (!job) {
return ownerApiError(
'INVALID_REQUEST',
404,
'Classroom generation job not found',
responseHeaders,
);
}
const pollUrl = `${buildRequestOrigin(req)}/api/generate-classroom/${jobId}`;
const pollUrl = `${buildRequestOrigin(req)}/api/generate-classroom/${jobId}`;
return apiSuccess({
jobId: job.id,
status: job.status,
step: job.step,
progress: job.progress,
message: job.message,
pollUrl,
pollIntervalMs: 5000,
scenesGenerated: job.scenesGenerated,
totalScenes: job.totalScenes,
result: job.result,
error: job.error,
done: job.status === 'succeeded' || job.status === 'failed',
return withOwnerResponseHeaders(
apiSuccess({
jobId: job.id,
status: job.status,
step: job.step,
progress: job.progress,
message: job.message,
pollUrl,
pollIntervalMs: 5000,
scenesGenerated: job.scenesGenerated,
totalScenes: job.totalScenes,
result: job.result,
error: job.error,
done: job.status === 'succeeded' || job.status === 'failed',
}),
responseHeaders,
);
} catch (error) {
log.error(`Classroom job retrieval failed [jobId=${jobId}]:`, error);
return ownerApiError(
'INTERNAL_ERROR',
500,
'Failed to retrieve classroom generation job',
responseHeaders,
error instanceof Error ? error.message : String(error),
);
}
});
} catch (error) {
log.error(`Classroom job retrieval failed [jobId=${resolvedJobId ?? 'unknown'}]:`, error);
@@ -0,0 +1,58 @@
/**
* GET /api/generate-classroom/capabilities — what a classroom generation job
* on this server is configured to do, and which uploads it can generate from.
* Read-only and owner-free; it sits behind the same access-code gate as every
* other API route.
*
* Every fact is derived, never restated: `capabilities` is what the generation
* pipeline itself reads; `formats` are the upload policy's formats that an
* extractor available on this server can read (the same check a submission's
* `materialIds` must pass); the byte caps are the upload route's own limits;
* `maxCount` and `maxTotalBytes` bound one request's `materialIds`.
*/
import type { NextRequest } from 'next/server';
import { mediaResolutionResponse } from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { apiSuccess } from '@/lib/server/api-response';
import {
MAX_CLASSROOM_MATERIAL_TOTAL_BYTES,
MAX_CLASSROOM_MATERIALS,
} from '@/lib/server/classroom-materials';
import { resolveServerGenerationCapabilities } from '@/lib/server/generation-capabilities';
import { resolveExtractableMimeTypes } from '@/lib/server/material-extraction/availability';
import {
MATERIAL_DOCUMENT_UPLOAD_LIMIT,
MATERIAL_MEDIA_UPLOAD_LIMIT,
} from '@/lib/server/materials/upload-limits';
import { WORKBENCH_MATERIAL_FORMATS } from '@/lib/workbench/material-upload-policy';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
// The formats this caller's document and speech services can read.
let extractable: Set<string>;
let capabilities: Awaited<ReturnType<typeof resolveServerGenerationCapabilities>>;
try {
const workspaceId = await requestWorkspaceId(req);
extractable = await resolveExtractableMimeTypes({
ownerId: workspaceId ?? undefined,
forward: false,
});
capabilities = await resolveServerGenerationCapabilities(workspaceId);
} catch (error) {
const refused = mediaResolutionResponse(error, 'Capability discovery');
if (refused) return refused;
throw error;
}
return apiSuccess({
capabilities,
materials: {
formats: WORKBENCH_MATERIAL_FORMATS.filter((format) => extractable.has(format.mime)),
maxCount: MAX_CLASSROOM_MATERIALS,
maxTotalBytes: MAX_CLASSROOM_MATERIAL_TOTAL_BYTES,
maxDocumentBytes: MATERIAL_DOCUMENT_UPLOAD_LIMIT,
maxMediaBytes: MATERIAL_MEDIA_UPLOAD_LIMIT,
},
});
}
+116 -76
View File
@@ -1,98 +1,138 @@
import { after, type NextRequest } from 'next/server';
import { nanoid } from 'nanoid';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { apiSuccess } from '@/lib/server/api-response';
import { type GenerateClassroomInput } from '@/lib/server/classroom-generation';
import { runClassroomGenerationJob } from '@/lib/server/classroom-job-runner';
import { createClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import {
ClassroomMaterialsRejectedError,
MAX_CLASSROOM_MATERIALS,
resolveClassroomMaterials,
} from '@/lib/server/classroom-materials';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
import { isMaterialId } from '@/lib/server/materials/material-id';
import { ownerApiError, withOwnerResponseHeaders } from '@/lib/server/agent-runtime/route-response';
import { withRequestOwner } from '@/lib/server/identity/with-owner';
import { WorkspaceEndpointError } from '@/lib/server/model-config/media';
import { createLogger } from '@/lib/logger';
const log = createLogger('GenerateClassroom API');
export const maxDuration = 30;
type PdfContent = NonNullable<GenerateClassroomInput['pdfContent']>;
const PDF_CONTENT_REMOVED_MESSAGE =
'pdfContent is no longer accepted: upload the document with POST /api/materials and pass the returned materialId in materialIds';
function isValidPdfContent(value: unknown): value is PdfContent {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
const INVALID_MATERIAL_IDS_MESSAGE = `materialIds must be an array of at most ${MAX_CLASSROOM_MATERIALS} material ids`;
type ParsedBody =
| { ok: true; input: GenerateClassroomInput }
| { ok: false; code: 'INVALID_REQUEST' | 'MISSING_REQUIRED_FIELD'; message: string };
/**
* The request body is `{ requirement, materialIds? }`. Optional capabilities
* are not request fields (they follow the server's provider configuration),
* and other unknown fields are ignored. The one removed field that is refused
* rather than ignored is `pdfContent`: ignoring it would silently generate
* without the caller's document.
*/
function parseBody(raw: unknown): ParsedBody {
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
if (body.pdfContent !== undefined) {
return { ok: false, code: 'INVALID_REQUEST', message: PDF_CONTENT_REMOVED_MESSAGE };
}
const { text, images } = value as { text?: unknown; images?: unknown };
return (
typeof text === 'string' &&
Array.isArray(images) &&
images.every((item) => typeof item === 'string')
);
const requirement = body.requirement;
if (typeof requirement !== 'string' || !requirement) {
return {
ok: false,
code: 'MISSING_REQUIRED_FIELD',
message: 'Missing required field: requirement',
};
}
if (body.materialIds === undefined) return { ok: true, input: { requirement } };
if (
!Array.isArray(body.materialIds) ||
body.materialIds.some((id) => typeof id !== 'string' || !isMaterialId(id.trim()))
) {
return { ok: false, code: 'INVALID_REQUEST', message: INVALID_MATERIAL_IDS_MESSAGE };
}
const materialIds = [...new Set((body.materialIds as string[]).map((id) => id.trim()))];
if (materialIds.length > MAX_CLASSROOM_MATERIALS) {
return { ok: false, code: 'INVALID_REQUEST', message: INVALID_MATERIAL_IDS_MESSAGE };
}
return {
ok: true,
input: { requirement, ...(materialIds.length ? { materialIds } : {}) },
};
}
export async function POST(req: NextRequest) {
let requirementSnippet: string | undefined;
try {
const rawBody = (await req.json()) as Partial<GenerateClassroomInput>;
requirementSnippet = rawBody.requirement?.substring(0, 60);
const pdfContent = rawBody.pdfContent;
// Materials are owner-scoped, so generation runs as the request owner: the
// same owner that uploaded them. The owner is resolved before anything else
// so every response, including a 400, carries its cookies (see
// `withRequestOwner`).
return withRequestOwner(req, async ({ ownerId }, responseHeaders) => {
let raw: unknown;
try {
raw = await req.json();
} catch {
return ownerApiError('INVALID_REQUEST', 400, 'Invalid JSON body', responseHeaders);
}
const parsed = parseBody(raw);
if (!parsed.ok) return ownerApiError(parsed.code, 400, parsed.message, responseHeaders);
const body = parsed.input;
if (pdfContent !== undefined && !isValidPdfContent(pdfContent)) {
return apiError(
'INVALID_REQUEST',
400,
'Invalid pdfContent: expected { text: string; images: string[] }',
try {
if (body.materialIds) {
try {
await resolveClassroomMaterials(ownerId, body.materialIds, { forward: false });
} catch (error) {
if (error instanceof ClassroomMaterialsRejectedError) {
return ownerApiError('INVALID_REQUEST', 400, error.message, responseHeaders);
}
// A document or speech service this workspace may not use.
if (error instanceof WorkspaceEndpointError) {
return ownerApiError('INVALID_URL', 403, error.message, responseHeaders);
}
throw error;
}
}
const baseUrl = buildRequestOrigin(req);
const jobId = nanoid(10);
const job = await createClassroomGenerationJob(jobId, body, { ownerId });
const pollUrl = `${baseUrl}/api/generate-classroom/${jobId}`;
after(() => runClassroomGenerationJob(jobId, body, baseUrl, { ownerId }));
return withOwnerResponseHeaders(
apiSuccess(
{
jobId,
status: job.status,
step: job.step,
message: job.message,
pollUrl,
pollIntervalMs: 5000,
},
202,
),
responseHeaders,
);
} catch (error) {
log.error(
`Classroom generation job creation failed [requirement="${body.requirement.substring(0, 60)}..."]:`,
error,
);
// The error is logged above; its text (a database error, say) stays off the wire.
return ownerApiError(
'INTERNAL_ERROR',
500,
'Failed to create classroom generation job',
responseHeaders,
);
}
const body: GenerateClassroomInput = {
requirement: rawBody.requirement || '',
...(pdfContent !== undefined ? { pdfContent } : {}),
...(rawBody.enableWebSearch != null ? { enableWebSearch: rawBody.enableWebSearch } : {}),
...(rawBody.webSearchProviderId ? { webSearchProviderId: rawBody.webSearchProviderId } : {}),
...(rawBody.webSearchApiKey ? { webSearchApiKey: rawBody.webSearchApiKey } : {}),
...(rawBody.webSearchModelId ? { webSearchModelId: rawBody.webSearchModelId } : {}),
...(rawBody.baiduSubSources ? { baiduSubSources: rawBody.baiduSubSources } : {}),
...(rawBody.enableImageGeneration != null
? { enableImageGeneration: rawBody.enableImageGeneration }
: {}),
...(rawBody.enableVideoGeneration != null
? { enableVideoGeneration: rawBody.enableVideoGeneration }
: {}),
...(rawBody.enableTTS != null ? { enableTTS: rawBody.enableTTS } : {}),
...(rawBody.agentMode ? { agentMode: rawBody.agentMode } : {}),
};
const { requirement } = body;
if (!requirement) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: requirement');
}
const baseUrl = buildRequestOrigin(req);
const jobId = nanoid(10);
const job = await createClassroomGenerationJob(jobId, body);
const pollUrl = `${baseUrl}/api/generate-classroom/${jobId}`;
after(() => runClassroomGenerationJob(jobId, body, baseUrl));
return apiSuccess(
{
jobId,
status: job.status,
step: job.step,
message: job.message,
pollUrl,
pollIntervalMs: 5000,
},
202,
);
} catch (error) {
log.error(
`Classroom generation job creation failed [requirement="${requirementSnippet ?? 'unknown'}..."]:`,
error,
);
return apiError(
'INTERNAL_ERROR',
500,
'Failed to create classroom generation job',
error instanceof Error ? error.message : 'Unknown error',
);
}
});
}
+74 -39
View File
@@ -6,10 +6,9 @@
*
* POST /api/generate/image
*
* Headers:
* x-image-provider: ImageProviderId (optional, server-configured default)
* x-api-key: string (optional, server fallback)
* x-base-url: string (optional, server fallback)
* The provider comes from the image slot of the model configuration. The
* headers below are deprecated and count only while the slot is unassigned:
* x-image-provider, x-image-model, x-api-key, x-base-url
*
* Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? }
* Response: { success: boolean, result?: ImageGenerationResult, error?: string }
@@ -24,8 +23,14 @@ import {
resolveImageApiKey,
resolveImageBaseUrl,
resolveImageModel,
resolveServerImageProviderId,
} from '@/lib/server/provider-config';
import {
mediaResolutionResponse,
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
@@ -50,32 +55,22 @@ export async function POST(request: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
}
// The client may express no provider preference (empty header) — fall back
// to the first server-configured image provider, else fail loud.
const providerId = (request.headers.get('x-image-provider')?.trim() ||
resolveServerImageProviderId()) as ImageProviderId;
if (!providerId) {
return apiError('MISSING_PROVIDER', 400, 'No image provider configured');
// The image slot decides; the provider a request names (x-image-provider
// with its key and base URL, deprecated) counts only when it is unassigned.
let connection: MediaConnection;
try {
connection = await resolveMediaSlot('image', {
workspaceId: await requestWorkspaceId(request),
legacyRequest: () => requestedImageProvider(request),
});
} catch (error) {
const refused = mediaResolutionResponse(error, 'Image generation');
if (refused) return refused;
throw error;
}
// Enforce server precedence: a force-disabled provider is off for everyone,
// regardless of any client key/selection — mirror the TTS contract (#665).
if (isServerProviderDisabled('image', providerId)) {
return apiError('PROVIDER_DISABLED', 403, 'This image provider is disabled by the server');
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('image', providerId);
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-image-model')?.trim() || undefined;
if (clientBaseUrl) {
const ssrfError = await validateClientBaseUrl(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}
const apiKey = resolveImageApiKey(providerId, clientApiKey);
const { providerId, apiKey, baseUrl, managed } = connection as MediaConnection & {
providerId: ImageProviderId;
};
const provider = IMAGE_PROVIDERS[providerId];
if (provider?.requiresApiKey && !apiKey) {
return apiError(
@@ -84,14 +79,15 @@ export async function POST(request: NextRequest) {
`No API key configured for image provider: ${providerId}`,
);
}
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
// A managed provider may pin its model list server-side
// (IMAGE_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default; unmanaged providers use the
// client header directly.
const model = resolveImageModel(providerId, clientModel);
// A configured slot without a model uses the provider's first catalogue
// model. On the legacy default provider the request's model still applies
// through its allowlist, as before slots.
const model =
connection.origin === 'configuration'
? (connection.modelId ?? provider?.models?.[0]?.id)
: connection.origin === 'default'
? resolveImageModel(providerId, request.headers.get('x-image-model')?.trim() || undefined)
: connection.modelId;
// Workflow-based providers (e.g. comfyui-image) have no model catalog and
// need no model; everyone else must resolve one.
if (!model && provider?.models && provider.models.length > 0) {
@@ -110,7 +106,7 @@ export async function POST(request: NextRequest) {
);
const result = await generateImage(
withMediaProviderFetch({ providerId, apiKey, baseUrl, model }, managed),
withMediaProviderFetch({ providerId, apiKey: apiKey ?? '', baseUrl, model }, managed),
sizedOptions,
);
@@ -140,3 +136,42 @@ export async function POST(request: NextRequest) {
return apiError('INTERNAL_ERROR', 500, 'Image generation failed');
}
}
/**
* The provider the request names with x-image-provider (deprecated), with the
* key, base URL and model headers, or undefined when it names none.
*/
async function requestedImageProvider(request: NextRequest): Promise<MediaConnection | undefined> {
const providerId = request.headers.get('x-image-provider')?.trim() as ImageProviderId | undefined;
if (!providerId) return undefined;
// A force-disabled provider is off for everyone (#665).
if (isServerProviderDisabled('image', providerId)) {
throw new RequestedProviderRefusedError(
apiError('PROVIDER_DISABLED', 403, 'This image provider is disabled by the server'),
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('image', providerId);
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
if (clientBaseUrl) {
const ssrfError = await validateClientBaseUrl(clientBaseUrl);
if (ssrfError) throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, ssrfError));
}
// A managed provider may pin its model list server-side
// (IMAGE_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default.
const model = resolveImageModel(
providerId,
request.headers.get('x-image-model')?.trim() || undefined,
);
return {
providerId,
apiKey: resolveImageApiKey(providerId, clientApiKey),
...(model ? { modelId: model } : {}),
baseUrl: resolveImageBaseUrl(providerId, clientBaseUrl),
managed,
userEndpoint: Boolean(clientBaseUrl),
origin: 'request',
};
}
@@ -13,6 +13,7 @@
* { type: 'error', error: string }
*/
import { attachedModelFallback } from '@/lib/ai/model-fallbacks';
import { NextRequest } from 'next/server';
import { streamLLM } from '@/lib/ai/llm';
import {
@@ -42,6 +43,8 @@ import type {
import { apiError } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import { resolveServerGenerationCapabilities } from '@/lib/server/generation-capabilities';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { sortDocumentImagesForVision } from '@/lib/document/bundle';
import { resolveVisionImagesForPrompt } from '@/lib/persistence/resolve-vision-images';
import { resolveVocationalActive } from '@/lib/config/feature-flags';
@@ -408,8 +411,15 @@ export async function POST(req: NextRequest) {
}
// Build media snippet conditions based on enabled flags.
const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true';
const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true';
// The workspace's image and video slots decide whether the outline may
// plan media (the same facts the generation pipeline and the capabilities
// endpoint read). An API client may still opt out with an explicit
// `false` header; `true` never turns on what the slots do not offer.
const capabilities = await resolveServerGenerationCapabilities(await requestWorkspaceId(req));
const imageGenerationEnabled =
capabilities.imageGeneration && req.headers.get('x-image-generation-enabled') !== 'false';
const videoGenerationEnabled =
capabilities.videoGeneration && req.headers.get('x-video-generation-enabled') !== 'false';
const mediaGenerationEnabled = imageGenerationEnabled || videoGenerationEnabled;
const hasSourceImages = (pdfImages?.length ?? 0) > 0;
@@ -529,7 +539,9 @@ export async function POST(req: NextRequest) {
if (!shouldFallbackFor(error, text)) return false;
let fallback: Awaited<ReturnType<typeof resolveFallbackModel>>;
try {
fallback = await resolveFallbackModel('scene-outlines-stream');
// The slot's own fallback when the model came from a slot.
const attached = attachedModelFallback(languageModel);
fallback = attached ? await attached() : await resolveFallbackModel();
} catch {
// Misconfigured fallback provider — keep the real error instead of
// surfacing e.g. "API key required for provider: …" to the client.
@@ -582,11 +594,10 @@ export async function POST(req: NextRequest) {
// fallback path.
let streamError: unknown = undefined;
let finishReason: string | undefined = undefined;
const fullStream = streamLLM(
streamParams,
'scene-outlines-stream',
thinkingConfig,
).fullStream;
// This route retries and falls back itself (maybeFallback).
const fullStream = streamLLM(streamParams, 'scene-outlines-stream', thinkingConfig, {
enabled: false,
}).fullStream;
for await (const part of fullStream) {
// Stop doing work the moment the client goes away — otherwise
+105 -35
View File
@@ -22,6 +22,7 @@ import {
resolveTTSApiKey,
resolveTTSBaseUrl,
resolveTTSModel,
slotTTSModel,
TTSModelNotAllowedError,
} from '@/lib/server/provider-config';
import type { TTSProviderId } from '@/lib/audio/types';
@@ -30,7 +31,19 @@ import { apiError, apiSuccess } from '@/lib/server/api-response';
import { findUnsafeNetworkTargetError, validatePublicUrlForSSRF } from '@/lib/server/ssrf-guard';
import { VOXCPM_AUTO_VOICE_ID, VOXCPM_TTS_PROVIDER_ID } from '@/lib/audio/voxcpm';
import { QwenVoiceCloneError, qwenVoiceCloneErrorMessage } from '@/lib/audio/qwen-voice-clone';
import { isQwenCloneVoice } from '@/lib/audio/constants';
import { DEFAULT_TTS_VOICES, isQwenCloneVoice } from '@/lib/audio/constants';
import {
adapterOptions,
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import {
savedMediaConnection,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
const log = createLogger('TTS API');
@@ -53,30 +66,57 @@ export async function POST(req: NextRequest) {
ttsBaseUrl?: string;
ttsProviderOptions?: Record<string, unknown>;
};
ttsProviderId = body.ttsProviderId;
ttsVoice = typeof body.ttsVoice === 'string' ? body.ttsVoice.trim() : undefined;
const requestedProviderId =
typeof body.ttsProviderId === 'string' && body.ttsProviderId ? body.ttsProviderId : undefined;
const requestedVoice = typeof body.ttsVoice === 'string' ? body.ttsVoice.trim() : undefined;
audioId = body.audioId;
// Validate required fields
if (!text || !audioId || !ttsProviderId || !ttsVoice) {
// A voice that is sent must say something; an absent one means the default.
if (!text || !audioId || (body.ttsVoice !== undefined && !requestedVoice)) {
return apiError(
'MISSING_REQUIRED_FIELD',
400,
'Missing required fields: text, audioId, ttsProviderId, ttsVoice',
'Missing required fields: text, audioId (and a non-empty ttsVoice when sent)',
);
}
// The tts slot decides; the provider, key and base URL a request names
// (deprecated) count only when it is unassigned.
// A settings preview names a saved provider instead (`previewProvider`,
// with an optional `previewModel`): the server's configuration of it.
let connection: MediaConnection;
try {
const preview = savedProviderRef(body.previewProvider, body.previewModel);
connection = preview
? await savedMediaConnection(req, 'tts', preview)
: await resolveMediaSlot('tts', {
workspaceId: await requestWorkspaceId(req),
legacyRequest: async () =>
requestedProviderId
? requestedTTSProvider(requestedProviderId, ttsApiKey, ttsBaseUrl)
: undefined,
});
} catch (error) {
const refused = savedProviderResponse(error, 'Text to speech');
if (refused) return refused;
throw error;
}
ttsProviderId = connection.providerId;
// A voice belongs to its provider: the request's voice unless it was
// chosen for another provider, in which case the provider's default.
ttsVoice =
requestedVoice && (!requestedProviderId || requestedProviderId === ttsProviderId)
? requestedVoice
: DEFAULT_TTS_VOICES[ttsProviderId as keyof typeof DEFAULT_TTS_VOICES] || requestedVoice;
if (!ttsVoice) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: ttsVoice');
}
// Reject browser-native TTS — must be handled client-side
if (ttsProviderId === 'browser-native-tts') {
return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side');
}
// Enforce server precedence: a force-disabled provider is off for everyone,
// regardless of any client key/selection (#665).
if (isServerTTSProviderDisabled(ttsProviderId)) {
return apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server');
}
const voxcpmVoicePrompt =
typeof ttsProviderOptions?.voicePrompt === 'string' ? ttsProviderOptions.voicePrompt : '';
const voxcpmRegisteredVoiceId =
@@ -96,28 +136,14 @@ export async function POST(req: NextRequest) {
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('tts', ttsProviderId);
const clientBaseUrl = managed ? undefined : ttsBaseUrl || undefined;
// A client-supplied BYOK base URL always runs under the strict public
// policy; only a server-managed (or built-in default) target may inherit the
// operator's ALLOW_LOCAL_NETWORKS opt-in. Never derived from request input.
const publicOnly = Boolean(clientBaseUrl);
if (clientBaseUrl) {
const ssrfError = await validatePublicUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}
const apiKey = resolveTTSApiKey(ttsProviderId, managed ? undefined : ttsApiKey || undefined);
const baseUrl = resolveTTSBaseUrl(ttsProviderId, clientBaseUrl);
const { managed, apiKey, baseUrl } = connection;
// A user-supplied endpoint (a BYOK base URL, a workspace provider) runs
// under the strict public policy; only operator configuration may inherit
// the operator's ALLOW_LOCAL_NETWORKS opt-in.
const publicOnly = connection.userEndpoint;
// Pre-flight the same key requirement the library enforces: a keyed provider
// with no key (server config AND client-supplied key both absent) is a
// client contract violation, not a server failure. Return the image route's
// MISSING_API_KEY envelope instead of falling through to a 500
// GENERATION_FAILED. Unknown/custom providers keep their existing behavior.
// with no key is a client contract violation, not a server failure.
const ttsProvider = TTS_PROVIDERS[ttsProviderId as keyof typeof TTS_PROVIDERS];
if (ttsProvider?.requiresApiKey && !apiKey) {
return apiError(
@@ -130,19 +156,30 @@ export async function POST(req: NextRequest) {
// Build TTS config (managed providers may pin the model server-side)
const qwenCloneVoice = ttsProviderId === 'qwen-tts' && isQwenCloneVoice(ttsVoice);
const requestedSpeed = ttsSpeed ?? 1.0;
const resolvedModelId = resolveTTSModel(ttsProviderId, ttsModelId, ttsVoice);
// A configured slot's own model; on the deprecated and default paths the
// client's (or default) model under the legacy server pins. Qwen
// voice-clone voices switch to the clone model either way.
const resolvedModelId =
connection.origin === 'configuration'
? slotTTSModel(ttsProviderId, connection.modelId, ttsVoice)
: resolveTTSModel(
ttsProviderId,
connection.origin === 'request' ? ttsModelId : connection.modelId,
ttsVoice,
);
const config = {
providerId: ttsProviderId as TTSProviderId,
modelId: resolvedModelId,
voice: ttsVoice,
speed: qwenCloneVoice ? 1 : requestedSpeed,
apiKey,
apiKey: apiKey ?? '',
baseUrl,
publicOnly,
// A server-configured provider's endpoint may be on a local network.
managed,
// The provider's own options (a VoxCPM backend, say) with the voice's.
providerOptions: {
...(ttsProviderOptions || {}),
...adapterOptions(connection, ttsProviderOptions),
...(qwenCloneVoice ? { qwenVoiceClone: true } : {}),
},
};
@@ -202,3 +239,36 @@ export async function POST(req: NextRequest) {
);
}
}
/**
* The TTS provider a request names in its body (deprecated), with the key and
* base URL it sends for an unmanaged provider.
*/
async function requestedTTSProvider(
providerId: string,
clientApiKey: string | undefined,
clientBaseUrl: string | undefined,
): Promise<MediaConnection> {
// A force-disabled provider is off for everyone (#665).
if (isServerTTSProviderDisabled(providerId)) {
throw new RequestedProviderRefusedError(
apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server'),
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('tts', providerId);
const baseUrlFromClient = managed ? undefined : clientBaseUrl || undefined;
if (baseUrlFromClient) {
const ssrfError = await validatePublicUrlForSSRF(baseUrlFromClient);
if (ssrfError) throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, ssrfError));
}
const baseUrl = resolveTTSBaseUrl(providerId, baseUrlFromClient);
return {
providerId,
apiKey: resolveTTSApiKey(providerId, managed ? undefined : clientApiKey || undefined),
...(baseUrl ? { baseUrl } : {}),
managed,
userEndpoint: Boolean(baseUrlFromClient),
origin: 'request',
};
}
+73 -40
View File
@@ -6,11 +6,9 @@
*
* POST /api/generate/video
*
* Headers:
* x-video-provider: VideoProviderId (optional, server-configured default)
* x-video-model: string (optional model override)
* x-api-key: string (optional, server fallback)
* x-base-url: string (optional, server fallback)
* The provider comes from the video slot of the model configuration. The
* headers below are deprecated and count only while the slot is unassigned:
* x-video-provider, x-video-model, x-api-key, x-base-url
*
* Body: { prompt, duration?, aspectRatio?, resolution? }
* Response: { success: boolean, result?: VideoGenerationResult, error?: string }
@@ -18,20 +16,26 @@
import { NextRequest } from 'next/server';
import { recordGenerationUsage } from '@/lib/server/usage-storage';
import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers';
import { generateVideo, normalizeVideoOptions, VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import {
isServerConfiguredProvider,
isServerProviderDisabled,
resolveVideoApiKey,
resolveVideoBaseUrl,
resolveVideoModel,
resolveServerVideoProviderId,
} from '@/lib/server/provider-config';
import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateClientBaseUrl } from '@/lib/server/ssrf-guard';
import { withVideoProviderFetch } from '@/lib/server/media-provider-fetch';
import {
mediaResolutionResponse,
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
const log = createLogger('VideoGeneration API');
@@ -45,32 +49,21 @@ export async function POST(request: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
}
// The client may express no provider preference (empty header) — fall back
// to the first server-configured video provider, else fail loud.
const providerId = (request.headers.get('x-video-provider')?.trim() ||
resolveServerVideoProviderId()) as VideoProviderId;
if (!providerId) {
return apiError('MISSING_PROVIDER', 400, 'No video provider configured');
// The video slot decides; the provider a request names (x-video-provider
// with its key and base URL, deprecated) counts only when it is unassigned.
let connection: MediaConnection;
try {
connection = await resolveMediaSlot('video', {
workspaceId: await requestWorkspaceId(request),
legacyRequest: () => requestedVideoProvider(request),
});
} catch (error) {
const refused = mediaResolutionResponse(error, 'Video generation');
if (refused) return refused;
throw error;
}
// Enforce server precedence: a force-disabled provider is off for everyone,
// regardless of any client key/selection — mirror the TTS contract (#665).
if (isServerProviderDisabled('video', providerId)) {
return apiError('PROVIDER_DISABLED', 403, 'This video provider is disabled by the server');
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('video', providerId);
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-video-model')?.trim() || undefined;
if (clientBaseUrl) {
const ssrfError = await validateClientBaseUrl(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}
const apiKey = resolveVideoApiKey(providerId, clientApiKey);
const providerId = connection.providerId as VideoProviderId;
const { apiKey, baseUrl, managed } = connection;
if (!apiKey) {
return apiError(
'MISSING_API_KEY',
@@ -78,14 +71,15 @@ export async function POST(request: NextRequest) {
`No API key configured for video provider: ${providerId}`,
);
}
const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl);
// A managed provider may pin its model list server-side
// (VIDEO_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default; unmanaged providers use the
// client header directly.
const model = resolveVideoModel(providerId, clientModel);
// A configured slot without a model uses the provider's first catalogue
// model. On the legacy default provider the request's model still applies
// through its allowlist, as before slots.
const model =
connection.origin === 'configuration'
? (connection.modelId ?? VIDEO_PROVIDERS[providerId]?.models?.[0]?.id)
: connection.origin === 'default'
? resolveVideoModel(providerId, request.headers.get('x-video-model')?.trim() || undefined)
: connection.modelId;
if (!model) {
return apiError(
'MISSING_MODEL',
@@ -138,3 +132,42 @@ export async function POST(request: NextRequest) {
return apiError('INTERNAL_ERROR', 500, 'Video generation failed');
}
}
/**
* The provider the request names with x-video-provider (deprecated), with the
* key, base URL and model headers, or undefined when it names none.
*/
async function requestedVideoProvider(request: NextRequest): Promise<MediaConnection | undefined> {
const providerId = request.headers.get('x-video-provider')?.trim() as VideoProviderId | undefined;
if (!providerId) return undefined;
// A force-disabled provider is off for everyone (#665).
if (isServerProviderDisabled('video', providerId)) {
throw new RequestedProviderRefusedError(
apiError('PROVIDER_DISABLED', 403, 'This video provider is disabled by the server'),
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('video', providerId);
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
if (clientBaseUrl) {
const ssrfError = await validateClientBaseUrl(clientBaseUrl);
if (ssrfError) throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, ssrfError));
}
// A managed provider may pin its model list server-side
// (VIDEO_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default.
const model = resolveVideoModel(
providerId,
request.headers.get('x-video-model')?.trim() || undefined,
);
return {
providerId,
apiKey: resolveVideoApiKey(providerId, clientApiKey),
...(model ? { modelId: model } : {}),
baseUrl: resolveVideoBaseUrl(providerId, clientBaseUrl),
managed,
userEndpoint: Boolean(clientBaseUrl),
origin: 'request',
};
}
+90 -21
View File
@@ -14,6 +14,15 @@
* POST /api/generate/voice
*/
import { TTS_PROVIDERS } from '@/lib/audio/constants';
import {
adapterOptions,
mediaResolutionResponse,
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import { NextRequest } from 'next/server';
import {
isServerConfiguredProvider,
@@ -90,9 +99,6 @@ export async function POST(req: NextRequest) {
voiceId = typeof body.voiceId === 'string' ? body.voiceId.trim() : undefined;
const design = normalizeVoiceDesign(body.descriptor);
if (!providerId) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'providerId is required');
}
if (!voiceId) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'voiceId is required');
}
@@ -105,10 +111,31 @@ export async function POST(req: NextRequest) {
);
}
// A server-force-disabled provider is off for everyone (#665), same as the TTS route.
if (isServerTTSProviderDisabled(providerId)) {
return apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server');
// Voices register on the tts slot's provider; the provider, key and base
// URL a request names (deprecated) count only when it is unassigned.
const requestedProviderId = providerId;
let connection: MediaConnection;
try {
connection = await resolveMediaSlot('tts', {
workspaceId: await requestWorkspaceId(req),
legacyRequest: async () =>
requestedProviderId
? requestedTTSProvider(requestedProviderId, body.ttsApiKey, body.ttsBaseUrl)
: undefined,
});
} catch (error) {
const refused = mediaResolutionResponse(error, 'Text to speech');
if (refused) return refused;
throw error;
}
if (requestedProviderId && requestedProviderId !== connection.providerId) {
return apiError(
'INVALID_REQUEST',
400,
'Voices register on the configured text-to-speech provider, not the one requested',
);
}
providerId = connection.providerId;
const adapter = getVoiceRegistrationAdapter(providerId);
if (!adapter) {
@@ -119,21 +146,26 @@ export async function POST(req: NextRequest) {
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('tts', providerId);
const clientBaseUrl = managed ? undefined : body.ttsBaseUrl || undefined;
// A client BYOK base URL is always validated under the strict public policy;
// only a server-managed backend may inherit ALLOW_LOCAL_NETWORKS.
const publicOnly = Boolean(clientBaseUrl);
if (clientBaseUrl) {
const ssrfError = await validatePublicUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
// Whether a configured provider registers voices depends on its options (a
// VoxCPM backend without runtime registration does not). The deprecated
// request path keeps its behaviour.
if (
!deleting &&
connection.origin === 'configuration' &&
!adapter.supportsRegistration(adapterOptions(connection))
) {
return apiError(
'INVALID_REQUEST',
400,
`Provider "${providerId}" does not support voice registration with its configured options`,
);
}
const apiKey = resolveTTSApiKey(providerId, managed ? undefined : body.ttsApiKey || undefined);
const baseUrl = resolveTTSBaseUrl(providerId, clientBaseUrl);
const { managed } = connection;
const publicOnly = connection.userEndpoint;
const apiKey = connection.apiKey ?? '';
const baseUrl =
connection.baseUrl ?? TTS_PROVIDERS[providerId as keyof typeof TTS_PROVIDERS]?.defaultBaseUrl;
if (!baseUrl) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'TTS base URL is required');
}
@@ -147,7 +179,12 @@ export async function POST(req: NextRequest) {
model:
providerId === 'qwen-tts'
? resolveQwenVoiceCloneModel()
: resolveTTSModel(providerId, body.ttsModelId),
: connection.origin === 'configuration'
? connection.modelId
: resolveTTSModel(
providerId,
connection.origin === 'request' ? body.ttsModelId : connection.modelId,
),
};
if (deleting) {
@@ -157,7 +194,7 @@ export async function POST(req: NextRequest) {
// Vendor IDs may be present in exported classroom data, so possession of
// an ID is not ownership. Only a caller-supplied account key authorizes
// provider-side deletion; a managed server key must never be used here.
if (managed || !body.ttsApiKey?.trim()) {
if (connection.origin !== 'request' || managed || !body.ttsApiKey?.trim()) {
return apiSuccess({
voiceId,
deleted: false,
@@ -263,3 +300,35 @@ export async function POST(req: NextRequest) {
req.signal.removeEventListener('abort', abortFromRequest);
}
}
/** The TTS provider a request names in its body (deprecated). */
async function requestedTTSProvider(
providerId: string,
clientApiKey: string | undefined,
clientBaseUrl: string | undefined,
): Promise<MediaConnection> {
// A server-force-disabled provider is off for everyone (#665), as in the TTS route.
if (isServerTTSProviderDisabled(providerId)) {
throw new RequestedProviderRefusedError(
apiError('PROVIDER_DISABLED', 403, 'This TTS provider is disabled by the server'),
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('tts', providerId);
const baseUrlFromClient = managed ? undefined : clientBaseUrl || undefined;
// A client BYOK base URL is always validated under the strict public policy;
// only a server-managed backend may inherit ALLOW_LOCAL_NETWORKS.
if (baseUrlFromClient) {
const ssrfError = await validatePublicUrlForSSRF(baseUrlFromClient);
if (ssrfError) throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, ssrfError));
}
const baseUrl = resolveTTSBaseUrl(providerId, baseUrlFromClient);
return {
providerId,
apiKey: resolveTTSApiKey(providerId, managed ? undefined : clientApiKey || undefined),
...(baseUrl ? { baseUrl } : {}),
managed,
userEndpoint: Boolean(baseUrlFromClient),
origin: 'request',
};
}
+5 -14
View File
@@ -1,10 +1,6 @@
import { apiSuccess } from '@/lib/server/api-response';
import {
getServerWebSearchProviders,
getServerImageProviders,
getServerVideoProviders,
getServerTTSProviders,
} from '@/lib/server/provider-config';
import { resolveServerGenerationCapabilities } from '@/lib/server/generation-capabilities';
import { getParallelSceneConcurrency } from '@/lib/server/provider-config';
const version = process.env.npm_package_version || '0.1.0';
@@ -13,13 +9,8 @@ export async function GET() {
status: 'ok',
version,
accessCodeConfigured: Boolean(process.env.ACCESS_CODE),
capabilities: {
// A capability is available only when at least one provider is enabled —
// force-disabled providers (disabled: true) do not count (#665).
webSearch: Object.values(getServerWebSearchProviders()).some((info) => !info.disabled),
imageGeneration: Object.values(getServerImageProviders()).some((info) => !info.disabled),
videoGeneration: Object.values(getServerVideoProviders()).some((info) => !info.disabled),
tts: Object.values(getServerTTSProviders()).some((info) => !info.disabled),
},
capabilities: await resolveServerGenerationCapabilities(),
// How many scenes the browser may generate at once (PARALLEL_SCENE_CONCURRENCY).
generation: { parallelSceneConcurrency: getParallelSceneConcurrency() },
});
}
+44 -5
View File
@@ -5,15 +5,28 @@
* Materials are session-scoped; the client names the session and the session's
* owner row is the authorization. A foreign or missing session, and a material
* id that does not exist or belongs to another session, all answer the same
* plain 404 (no existence oracle).
* plain 404 (no existence oracle). Session materials live with the agent
* runtime, so the read sits behind its gate.
*
* Deletion is deliberately not exposed: the session-material store from the
* materials slice has no delete operation, and this slice adds no persistence
* — a later slice grows deletion on the store, then the route.
* DELETE /api/materials/[id] — delete one of the caller's own library uploads
* (the ids `POST /api/materials` returns), releasing its quota. It needs only
* server persistence. Session material rows are not deletable: an agent
* session keeps its own copy of a bound upload, and binding a deleted id
* fails as unavailable.
*/
import type { NextRequest } from 'next/server';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import {
isAgentRuntimeConfigured,
isServerPersistenceConfigured,
} from '@/lib/config/feature-flags';
import type { ConnectableQueryable } from '@openmaic/storage/server/reference';
import { deleteOwnerMaterial } from '@/lib/persistence/owner-materials';
import { ownerWriteErrorResponse } from '@/lib/persistence/owner-merges';
import { getServerPersistenceProvider } from '@/lib/persistence/server-provider';
import { getMaterialByteStore } from '@/lib/server/materials/bytes';
import { isMaterialId } from '@/lib/server/materials/material-id';
import { apiError } from '@/lib/server/api-response';
import {
getSessionMaterial,
@@ -42,3 +55,29 @@ export async function GET(req: NextRequest, { params }: Params) {
return ownerJson({ material: publicMaterialView(material) }, 200, responseHeaders);
});
}
export async function DELETE(req: NextRequest, { params }: Params) {
if (!isServerPersistenceConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwner(req, async ({ ownerId }, responseHeaders) => {
const { id } = await params;
if (!isMaterialId(id)) return ownerNotFound(responseHeaders);
const provider = await getServerPersistenceProvider(process.env.DATABASE_URL ?? '');
const byteStore = getMaterialByteStore();
let deleted: boolean;
try {
deleted = await deleteOwnerMaterial(
provider.pool as unknown as ConnectableQueryable,
ownerId,
id,
(ossKey) => byteStore.delete(ossKey),
);
} catch (error) {
const claimed = ownerWriteErrorResponse(error, responseHeaders);
if (claimed) return claimed;
throw error;
}
if (!deleted) return ownerNotFound(responseHeaders);
return ownerJson({ materialId: id, deleted: true }, 200, responseHeaders);
});
}
+17 -10
View File
@@ -31,8 +31,12 @@
* - Every response echoes the `x-request-id` header so the uploader can pair
* a failure with its log line.
*
* The configured runtime gates the family (the workbench is agent-runtime
* territory): off, or on without a DATABASE_URL, answers the same plain 404.
* Gates: the upload writes only the owner-scoped material library, so it is
* served whenever server persistence is configured (a DATABASE_URL), with the
* agent runtime on or off — `POST /api/generate-classroom` consumes these
* uploads by id. The list stays behind the agent runtime gate: it names an
* agent session (`sessionId`) and reads that session's materials, which only
* exist with the runtime on. Either gate closed answers the same plain 404.
*/
import { createHash, randomUUID } from 'node:crypto';
import { basename } from 'node:path';
@@ -43,7 +47,10 @@ import { NextResponse } from 'next/server';
import { createMaterialId } from '@openmaic/storage';
import type { ConnectableQueryable } from '@openmaic/storage/server/reference';
import { isAgentRuntimeConfigured } from '@/lib/config/feature-flags';
import {
isAgentRuntimeConfigured,
isServerPersistenceConfigured,
} from '@/lib/config/feature-flags';
import { apiError } from '@/lib/server/api-response';
import { agentRuntimeConfig } from '@/lib/server/agent-runtime/config';
import { ownerJson, ownerNotFound } from '@/lib/server/agent-runtime/route-response';
@@ -64,6 +71,10 @@ import {
import { ownerWriteErrorResponse } from '@/lib/persistence/owner-merges';
import { getServerPersistenceProvider } from '@/lib/persistence/server-provider';
import { getMaterialByteStore } from '@/lib/server/materials/bytes';
import {
MATERIAL_DOCUMENT_UPLOAD_LIMIT,
MATERIAL_MEDIA_UPLOAD_LIMIT,
} from '@/lib/server/materials/upload-limits';
import {
isWorkbenchMaterialMime,
MEDIA_MIME_TYPES,
@@ -72,10 +83,6 @@ import {
export const runtime = 'nodejs';
const DOCUMENT_UPLOAD_LIMIT = Math.min(
agentRuntimeConfig.maxDocumentBytes,
agentRuntimeConfig.maxUploadBytes,
);
const MEDIA_MIME_SET = new Set<string>(MEDIA_MIME_TYPES);
/** The store's keyset-paging ceiling (default 50, capped at 200). */
@@ -197,7 +204,7 @@ export async function POST(req: NextRequest) {
return response;
};
if (!isAgentRuntimeConfigured()) return new Response('Not found', { status: 404 });
if (!isServerPersistenceConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwner(req, async ({ ownerId }, responseHeaders) => {
try {
@@ -222,8 +229,8 @@ export async function POST(req: NextRequest) {
);
}
const uploadLimit = MEDIA_MIME_SET.has(mime)
? agentRuntimeConfig.maxUploadBytes
: DOCUMENT_UPLOAD_LIMIT;
? MATERIAL_MEDIA_UPLOAD_LIMIT
: MATERIAL_DOCUMENT_UPLOAD_LIMIT;
declaredBytes = Number(req.headers.get('content-length') ?? 0);
if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimit) {
+98
View File
@@ -0,0 +1,98 @@
/**
* POST /api/model-config/import — merge settings a browser kept into the
* workspace (RFC #1701): the one-way import of the provider settings earlier
* builds stored in the browser. Body: `{ providers?, slots? }` as in
* openmaic.yml. Existing settings always win; each item is checked like an
* edit, and the answer lists what was imported and what was skipped and why,
* with the new view. Safe to repeat: a second import finds everything there.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { isServerPersistenceConfigured } from '@/lib/config/feature-flags';
import { ownerWriteErrorResponse } from '@/lib/persistence/owner-merges';
import { getServerPersistenceProvider } from '@/lib/persistence/server-provider';
import {
readWorkspaceModelConfig,
saveWorkspaceModelConfig,
WorkspaceConfigConflictError,
} from '@/lib/persistence/workspace-model-config';
import { withRequestOwner } from '@/lib/server/identity/with-owner';
import {
importModelSettings,
modelSettingsView,
type ModelSettingsProposal,
} from '@/lib/server/model-config/settings';
export const runtime = 'nodejs';
const proposalSchema = z
.object({
providers: z
// Each provider is checked on its own: a malformed one is skipped.
.record(z.string().min(1).max(64), z.unknown())
.optional(),
slots: z.record(z.string().min(1), z.unknown()).optional(),
})
.strict();
/** Attempts against a settings write that raced this one. */
const MAX_ATTEMPTS = 3;
export async function POST(req: NextRequest) {
if (!isServerPersistenceConfigured()) return new Response('Not found', { status: 404 });
let proposal: ModelSettingsProposal;
try {
const parsed = proposalSchema.safeParse(await req.json());
if (!parsed.success) throw new Error('invalid');
proposal = parsed.data as ModelSettingsProposal;
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_REQUEST', message: 'Expected { providers?, slots? }' } },
{ status: 400 },
);
}
return withRequestOwner(req, async ({ ownerId }, headers) => {
const queryable = (await getServerPersistenceProvider(process.env.DATABASE_URL ?? '')).pool;
try {
for (let attempt = 1; ; attempt++) {
const current = await readWorkspaceModelConfig(queryable, ownerId);
const result = await importModelSettings(current?.config ?? null, proposal);
try {
if (result.imported.length) {
await saveWorkspaceModelConfig(
queryable,
ownerId,
result.config,
current?.revision ?? null,
);
}
} catch (error) {
if (error instanceof WorkspaceConfigConflictError && attempt < MAX_ATTEMPTS) continue;
throw error;
}
const stored = await readWorkspaceModelConfig(queryable, ownerId);
return NextResponse.json(
{
imported: result.imported,
skipped: result.skipped,
view: modelSettingsView(stored),
},
{ headers },
);
}
} catch (error) {
if (error instanceof WorkspaceConfigConflictError) {
return NextResponse.json(
{ error: { code: 'CONFLICT', message: 'The settings kept changing; try again' } },
{ status: 409, headers },
);
}
const refused = ownerWriteErrorResponse(error, headers);
if (refused) return refused;
throw error;
}
});
}
+135
View File
@@ -0,0 +1,135 @@
/**
* GET/PUT /api/model-config — the workspace's model settings (RFC #1701).
*
* GET answers the slot tree as this workspace sees it: every slot with its own
* assignment, its effective model and where that comes from, and whether the
* deployment locks it; and the providers it can use, with keys masked. PUT
* applies one change (slot assignments, a workspace provider, or removing one)
* against the revision the caller read, and answers the new view. The owner is
* the request's, through the owner identity seam, never a parameter.
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { isServerPersistenceConfigured } from '@/lib/config/feature-flags';
import { ownerWriteErrorResponse } from '@/lib/persistence/owner-merges';
import { getServerPersistenceProvider } from '@/lib/persistence/server-provider';
import {
readWorkspaceModelConfig,
saveWorkspaceModelConfig,
WorkspaceConfigConflictError,
WorkspaceConfigInvalidError,
} from '@/lib/persistence/workspace-model-config';
import { withRequestOwner } from '@/lib/server/identity/with-owner';
import {
applyModelSettingsChange,
keysClearedBy,
modelSettingsView,
ModelSettingsError,
type ModelSettingsChange,
} from '@/lib/server/model-config/settings';
export const runtime = 'nodejs';
function jsonError(status: number, code: string, message: string, headers: Headers) {
return NextResponse.json({ error: { code, message } }, { status, headers });
}
async function pool() {
return (await getServerPersistenceProvider(process.env.DATABASE_URL ?? '')).pool;
}
export async function GET(req: NextRequest) {
if (!isServerPersistenceConfigured()) return new Response('Not found', { status: 404 });
return withRequestOwner(req, async ({ ownerId }, headers) => {
const stored = await readWorkspaceModelConfig(await pool(), ownerId);
return NextResponse.json(modelSettingsView(stored), { headers });
});
}
const id = z.string().min(1).max(128);
/** An assignment's own shape is checked with the whole configuration it lands in. */
const assignment = z.union([z.string().min(1), z.null(), z.record(z.string(), z.unknown())]);
const bodySchema = z
.object({
revision: z.number().int().nonnegative().nullable(),
change: z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('slots'),
set: z.record(id, assignment).optional(),
clear: z.array(id).optional(),
})
.strict(),
z
.object({
kind: z.literal('provider'),
id,
preset: id,
apiKey: z.string().optional(),
baseUrl: z.string().min(1).nullable().optional(),
models: z.array(z.string().min(1)).nullable().optional(),
})
.strict(),
z.object({ kind: z.literal('remove-provider'), id }).strict(),
]),
})
.strict();
export async function PUT(req: NextRequest) {
if (!isServerPersistenceConfigured()) return new Response('Not found', { status: 404 });
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_REQUEST', message: 'Body must be JSON' } },
{ status: 400 },
);
}
const parsed = bodySchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: 'INVALID_REQUEST', message: 'Expected { revision, change }' } },
{ status: 400 },
);
}
const revision = parsed.data.revision;
const change = parsed.data.change as ModelSettingsChange;
return withRequestOwner(req, async ({ ownerId }, headers) => {
const queryable = await pool();
try {
const current = await readWorkspaceModelConfig(queryable, ownerId);
if ((current?.revision ?? null) !== revision) {
return jsonError(409, 'CONFLICT', 'The settings changed; reload them', headers);
}
const next = await applyModelSettingsChange(current?.config ?? null, change);
await saveWorkspaceModelConfig(queryable, ownerId, next, revision, {
clearKeys: keysClearedBy(change),
});
const stored = await readWorkspaceModelConfig(queryable, ownerId);
return NextResponse.json(modelSettingsView(stored), { headers });
} catch (error) {
if (error instanceof ModelSettingsError) {
return jsonError(
error.code === 'SLOT_LOCKED' ? 409 : 400,
error.code,
error.message,
headers,
);
}
if (error instanceof WorkspaceConfigConflictError) {
return jsonError(409, 'CONFLICT', 'The settings changed; reload them', headers);
}
if (error instanceof WorkspaceConfigInvalidError) {
return jsonError(400, 'INVALID_SETTINGS', error.message, headers);
}
const refused = ownerWriteErrorResponse(error, headers);
if (refused) return refused;
throw error;
}
});
}
+77 -17
View File
@@ -9,7 +9,19 @@ import type { ParsedPdfContent } from '@/lib/types/pdf';
import { documentArtifactToParsedPdfContent, extractDocument } from '@/lib/document';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { isSelfContainedExtractor } from '@/lib/server/material-extraction/services';
import { checkClientDocumentExtractorBaseUrl } from '@/lib/server/client-extractor-endpoint';
import {
mediaResolutionResponse,
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import {
requestWorkspaceId,
SlotDisabledError,
SlotUnassignedError,
} from '@/lib/server/model-config/runtime';
const log = createLogger('Parse PDF');
export async function POST(req: NextRequest) {
@@ -36,27 +48,47 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF file provided');
}
// providerId is required from the client — no server-side store to fall back to
const effectiveProviderId = providerId || ('unpdf' as PDFProviderId);
pdfFileName = pdfFile?.name;
resolvedProviderId = effectiveProviderId;
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('pdf', effectiveProviderId);
let clientBaseUrl = managed ? undefined : baseUrl || undefined;
if (clientBaseUrl) {
const checked = await checkClientDocumentExtractorBaseUrl(effectiveProviderId, clientBaseUrl);
if (!checked.ok) {
return apiError('INVALID_URL', 403, checked.message);
// The document slot's service; the provider a request names (deprecated)
// only when it is unassigned; the local parser when there is none.
// An explicit self-contained extractor (local parsing) needs no service
// and sends the file nowhere, whatever the slot names.
const selfContained = providerId && isSelfContainedExtractor(providerId) ? providerId : null;
let service: MediaConnection | undefined;
try {
if (!selfContained)
service = await resolveMediaSlot('document', {
workspaceId: await requestWorkspaceId(req),
legacyRequest: async () =>
providerId ? requestedDocumentProvider(providerId, apiKey, baseUrl) : undefined,
});
} catch (error) {
if (!(error instanceof SlotDisabledError || error instanceof SlotUnassignedError)) {
const refused = mediaResolutionResponse(error, 'Document parsing');
if (refused) return refused;
throw error;
}
clientBaseUrl = checked.baseUrl;
}
if (service && !service.managed && service.baseUrl && service.origin === 'configuration') {
const checked = await checkClientDocumentExtractorBaseUrl(
service.providerId as PDFProviderId,
service.baseUrl,
);
if (!checked.ok) return apiError('INVALID_URL', 403, checked.message);
service = { ...service, baseUrl: checked.baseUrl };
}
resolvedProviderId = selfContained ?? service?.providerId ?? 'unpdf';
const config = {
providerId: effectiveProviderId,
apiKey: resolvePDFApiKey(effectiveProviderId, managed ? undefined : apiKey || undefined),
baseUrl: resolvePDFBaseUrl(effectiveProviderId, clientBaseUrl),
managed,
providerId: resolvedProviderId as PDFProviderId,
...(service?.apiKey ? { apiKey: service.apiKey } : {}),
...(service?.baseUrl ? { baseUrl: service.baseUrl } : {}),
...(service?.credentials?.accessKeyId
? {
accessKeyId: service.credentials.accessKeyId,
accessKeySecret: service.credentials.accessKeySecret,
}
: {}),
managed: service ? service.managed : true,
};
// Convert PDF to buffer
@@ -93,3 +125,31 @@ export async function POST(req: NextRequest) {
return apiError('PARSE_FAILED', 500, error instanceof Error ? error.message : 'Unknown error');
}
}
/** The document provider a request names in its form (deprecated). */
async function requestedDocumentProvider(
providerId: PDFProviderId,
clientApiKey: string | null,
clientBaseUrl: string | null,
): Promise<MediaConnection> {
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('pdf', providerId);
let baseUrlFromClient = managed ? undefined : clientBaseUrl || undefined;
if (baseUrlFromClient) {
const checked = await checkClientDocumentExtractorBaseUrl(providerId, baseUrlFromClient);
if (!checked.ok) {
throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, checked.message));
}
baseUrlFromClient = checked.baseUrl;
}
const apiKey = resolvePDFApiKey(providerId, managed ? undefined : clientApiKey || undefined);
const baseUrl = resolvePDFBaseUrl(providerId, baseUrlFromClient);
return {
providerId,
...(apiKey ? { apiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
managed,
userEndpoint: Boolean(baseUrlFromClient),
origin: 'request',
};
}
+21 -1
View File
@@ -3,6 +3,11 @@ import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateClientBaseUrl, validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { fetchModels, ModelFetchError } from '@/lib/server/model-fetch';
import {
savedChatEndpoint,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
const log = createLogger('ProbeModels');
@@ -22,11 +27,26 @@ export async function POST(req: NextRequest) {
return apiError('INVALID_REQUEST', 400, 'Invalid JSON body');
}
try {
const { baseUrl, apiKey, modelsUrl } = body as {
let { baseUrl, apiKey, modelsUrl } = body as {
baseUrl?: string;
apiKey?: string;
modelsUrl?: string;
};
// The settings name one of the workspace's own providers (`provider`):
// its stored endpoint and key are used, and nothing else from the request.
const saved = (body as { provider?: unknown }).provider;
if (saved !== undefined) {
try {
const ref = savedProviderRef(saved);
if (!ref) return apiError('MISSING_REQUIRED_FIELD', 400, 'provider is required');
({ baseUrl, apiKey } = await savedChatEndpoint(req, ref));
modelsUrl = undefined;
} catch (error) {
const refused = savedProviderResponse(error, 'language model');
if (refused) return refused;
throw error;
}
}
if (!baseUrl) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'baseUrl is required');
+82 -39
View File
@@ -6,8 +6,18 @@ import {
resolveASRApiKey,
resolveASRBaseUrl,
resolveASRModel,
resolveServerASRProviderId,
} from '@/lib/server/provider-config';
import {
RequestedProviderRefusedError,
resolveMediaSlot,
type MediaConnection,
} from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
import {
savedMediaConnection,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
import type { ASRProviderId } from '@/lib/audio/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
@@ -35,49 +45,46 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Audio file is required');
}
// Prefer an enabled operator-configured backend when the client omitted its
// selection. Never guess a vendor: fail loudly when no backend is enabled.
const effectiveProviderId =
providerId || (resolveServerASRProviderId() as ASRProviderId | undefined);
if (!effectiveProviderId) {
return apiError('MISSING_PROVIDER', 400, 'No enabled ASR provider is configured');
}
resolvedProviderId = effectiveProviderId;
resolvedModelId = modelId;
// Enforce server precedence: a force-disabled provider is off for everyone,
// regardless of any client key/selection — mirror the TTS contract (#665).
if (isServerProviderDisabled('asr', effectiveProviderId)) {
return apiError('PROVIDER_DISABLED', 403, 'This ASR provider is disabled by the server');
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('asr', effectiveProviderId);
const clientBaseUrl = managed ? undefined : baseUrl || undefined;
// A client-supplied BYOK base URL is always judged under the strict public
// policy, even when the operator enabled local networks for their own
// server-configured ASR backend.
const publicOnly = Boolean(clientBaseUrl);
if (clientBaseUrl) {
const ssrfError = await validatePublicUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
// The asr slot decides; the provider, key and base URL a request names
// (deprecated) count only when it is unassigned.
// A settings test names a saved provider instead (`previewProvider`, with
// an optional `previewModel`): the server's configuration of it.
let connection: MediaConnection;
try {
const preview = savedProviderRef(
formData.get('previewProvider') ?? undefined,
formData.get('previewModel') ?? undefined,
);
connection = preview
? await savedMediaConnection(req, 'asr', preview)
: await resolveMediaSlot('asr', {
workspaceId: await requestWorkspaceId(req),
legacyRequest: async () =>
providerId ? requestedASRProvider(providerId, modelId, apiKey, baseUrl) : undefined,
});
} catch (error) {
const refused = savedProviderResponse(error, 'Speech recognition');
if (refused) return refused;
throw error;
}
resolvedProviderId = connection.providerId;
const config = {
providerId: effectiveProviderId,
// A managed provider may pin its model list server-side
// (ASR_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default; unmanaged providers use the
// client model directly.
modelId: resolveASRModel(effectiveProviderId, modelId),
providerId: connection.providerId as ASRProviderId,
// On the legacy default provider the request's model still applies
// through its allowlist, as before slots.
modelId:
connection.origin === 'default'
? resolveASRModel(connection.providerId, modelId)
: connection.modelId,
language: language || 'auto',
apiKey: resolveASRApiKey(effectiveProviderId, managed ? undefined : apiKey || undefined),
baseUrl: resolveASRBaseUrl(effectiveProviderId, clientBaseUrl),
publicOnly,
apiKey: connection.apiKey ?? '',
baseUrl: connection.baseUrl,
// A user-supplied endpoint is judged under the strict public policy,
// even when the operator enabled local networks for their own backend.
publicOnly: connection.userEndpoint,
// A server-configured provider's endpoint may be on a local network.
managed,
managed: connection.managed,
};
// Reflect the resolved (possibly server-pinned) model in failure logs.
resolvedModelId = config.modelId;
@@ -103,3 +110,39 @@ export async function POST(req: NextRequest) {
);
}
}
/** The ASR provider a request names in its form (deprecated). */
async function requestedASRProvider(
providerId: ASRProviderId,
clientModel: string | undefined,
clientApiKey: string | null,
clientBaseUrl: string | null,
): Promise<MediaConnection> {
// A force-disabled provider is off for everyone (#665).
if (isServerProviderDisabled('asr', providerId)) {
throw new RequestedProviderRefusedError(
apiError('PROVIDER_DISABLED', 403, 'This ASR provider is disabled by the server'),
);
}
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('asr', providerId);
const baseUrlFromClient = managed ? undefined : clientBaseUrl || undefined;
if (baseUrlFromClient) {
const ssrfError = await validatePublicUrlForSSRF(baseUrlFromClient);
if (ssrfError) throw new RequestedProviderRefusedError(apiError('INVALID_URL', 403, ssrfError));
}
// A managed provider may pin its model list server-side
// (ASR_<PREFIX>_MODELS): an allowlisted client choice wins, otherwise the
// first pinned entry is the managed default.
const model = resolveASRModel(providerId, clientModel);
const baseUrl = resolveASRBaseUrl(providerId, baseUrlFromClient);
return {
providerId,
...(model ? { modelId: model } : {}),
apiKey: resolveASRApiKey(providerId, managed ? undefined : clientApiKey || undefined),
...(baseUrl ? { baseUrl } : {}),
managed,
userEndpoint: Boolean(baseUrlFromClient),
origin: 'request',
};
}
+42
View File
@@ -29,6 +29,13 @@ import { apiError, apiSuccess } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { validateClientBaseUrl } from '@/lib/server/ssrf-guard';
import { withMediaProviderFetch } from '@/lib/server/media-provider-fetch';
import {
savedMediaConnection,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
import { requestProvidersAllowed } from '@/lib/server/model-config/runtime';
import { REQUEST_PROVIDERS_REFUSED } from '@/lib/server/resolve-model';
const log = createLogger('VerifyImageProvider');
@@ -39,6 +46,41 @@ export const maxDuration = 30;
export async function POST(request: NextRequest) {
try {
// The settings test a saved provider by its id (JSON body `provider`, with
// an optional `model`): the server's configuration supplies key and endpoint.
const body = request.headers.get('content-type')?.includes('application/json')
? ((await request.json().catch(() => null)) as { provider?: unknown; model?: unknown } | null)
: null;
if (body?.provider !== undefined) {
let connection;
try {
const ref = savedProviderRef(body.provider, body.model);
if (!ref) return apiError('MISSING_PROVIDER', 400, 'No image provider named');
connection = await savedMediaConnection(request, 'image', ref);
} catch (error) {
const refused = savedProviderResponse(error, 'image');
if (refused) return refused;
throw error;
}
const providerId = connection.providerId as ImageProviderId;
const model = connection.modelId ?? IMAGE_PROVIDERS[providerId]?.models?.[0]?.id;
const result = await testImageConnectivity(
withMediaProviderFetch(
{ providerId, apiKey: connection.apiKey ?? '', baseUrl: connection.baseUrl, model },
connection.managed,
),
);
if (!result.success) return apiError('UPSTREAM_ERROR', 500, result.message);
return apiSuccess({ message: result.message });
}
// The old header form tests a provider the request names: not under
// `policy.allowWorkspaceProviders: false`, which leaves only the
// configuration's providers (tested by id above).
if (!requestProvidersAllowed()) {
return apiError('PROVIDER_DISABLED', 403, REQUEST_PROVIDERS_REFUSED);
}
const providerId = (request.headers.get('x-image-provider')?.trim() ||
resolveServerImageProviderId()) as ImageProviderId;
if (!providerId) {
+33 -8
View File
@@ -2,6 +2,11 @@ import { NextRequest } from 'next/server';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolveModel } from '@/lib/server/resolve-model';
import {
savedLanguageModel,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
import { callLLM } from '@/lib/ai/llm';
import { upstreamHttpStatus } from '@/lib/server/llm-error-response';
const log = createLogger('Verify Model');
@@ -17,23 +22,43 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Model name is required');
}
// Parse model string and resolve server-side fallback
// The settings test a saved provider by its id (`provider`, with `model`
// its model id): the server's configuration supplies key and endpoint.
let languageModel;
let savedRef: string | undefined;
try {
const result = await resolveModel({
modelString: model,
apiKey: apiKey || '',
baseUrl: baseUrl || undefined,
providerType,
});
languageModel = result.model;
savedRef = savedProviderRef(body.provider, model);
if (savedRef) languageModel = (await savedLanguageModel(req, savedRef)).model;
} catch (error) {
const refused = savedProviderResponse(error, 'language model');
if (refused) return refused;
return apiError(
'INVALID_REQUEST',
401,
error instanceof Error ? error.message : String(error),
);
}
if (savedRef) {
model = savedRef;
} else {
// Parse model string and resolve server-side fallback
try {
const result = await resolveModel({
modelString: model,
apiKey: apiKey || '',
baseUrl: baseUrl || undefined,
providerType,
});
languageModel = result.model;
} catch (error) {
return apiError(
'INVALID_REQUEST',
401,
error instanceof Error ? error.message : String(error),
);
}
}
if (!languageModel) return apiError('MISSING_MODEL', 400, 'Model name is required');
// Send a minimal test message. Use the unified wrapper so compatible
// providers can receive provider-specific request options.
+88 -39
View File
@@ -17,7 +17,12 @@ import {
ALIDOCMIND_ENDPOINT_NOT_ALLOWED_MESSAGE,
resolveSafeClientAliDocMindEndpoint,
} from '@/lib/server/alidocmind-endpoint';
import { MINERU_CLOUD_DEFAULT_BASE } from '@/lib/pdf/constants';
import { MINERU_CLOUD_DEFAULT_BASE, PDF_PROVIDERS } from '@/lib/pdf/constants';
import {
savedMediaConnection,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
const log = createLogger('Verify PDF Provider');
@@ -38,10 +43,90 @@ function probePolicy(managed: boolean): ProviderFetchPolicy {
const AUTH_FAILED_MESSAGE = 'Authentication failed, please check the API Key';
const CONNECTION_FAILED_MESSAGE = 'Cannot connect to server, please check the Base URL';
/** MinerU Cloud: an authenticated call to the batch endpoint tells whether the token works. */
async function probeMinerUCloud(cloudBase: string, apiKey: string, managed: boolean) {
const response = await providerFetch(
`${cloudBase.replace(/\/+$/, '')}/extract-results/batch/test-connection`,
{
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
},
probePolicy(managed),
);
// Only the status matters; release the connection without reading the body.
await response.body?.cancel().catch(() => undefined);
// Other responses (including 4xx for "batch not found") mean auth + connectivity works.
if (response.status === 401 || response.status === 403) {
log.warn(`MinerU Cloud probe rejected credentials [status=${response.status}]`);
return apiError('INTERNAL_ERROR', 500, AUTH_FAILED_MESSAGE);
}
return apiSuccess({ message: 'Connection successful' });
}
/** A self-hosted service: any HTTP answer from its base URL means it is up. */
async function probeSelfHosted(baseUrl: string, apiKey: string | undefined, managed: boolean) {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
const response = await providerFetch(
baseUrl,
{ headers, signal: AbortSignal.timeout(10000) },
probePolicy(managed),
);
await response.body?.cancel().catch(() => undefined);
// MinerU's FastAPI root returns 404 (no root route), but the server is reachable.
return apiSuccess({ message: 'Connection successful' });
}
/**
* A saved document provider (the settings name it by id): the server's
* configuration supplies the key, key pair and endpoint.
*/
async function verifySavedProvider(req: NextRequest, ref: string): Promise<Response> {
let connection;
try {
connection = await savedMediaConnection(req, 'document', ref);
} catch (error) {
const refused = savedProviderResponse(error, 'document');
if (refused) return refused;
throw error;
}
const { providerId, apiKey, baseUrl, credentials, managed } = connection;
if (providerId === 'alidocmind') {
if (!credentials?.accessKeyId || !credentials.accessKeySecret) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'AliDocMind has no key pair configured');
}
const { verifyAliDocMindCredentials } = await import('@/lib/pdf/alidocmind-client');
const result = await verifyAliDocMindCredentials({
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
endpoint: baseUrl,
});
if (!result.ok) {
return apiError('INVALID_CREDENTIALS', 400, `Authentication failed: ${result.error}`);
}
return apiSuccess({ message: 'Connection successful' });
}
if (providerId === 'mineru-cloud') {
if (!apiKey) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'API Key is required for MinerU Cloud');
}
return probeMinerUCloud(baseUrl || MINERU_CLOUD_DEFAULT_BASE, apiKey, managed);
}
const endpoint = baseUrl || PDF_PROVIDERS[providerId as keyof typeof PDF_PROVIDERS]?.baseUrl;
if (!endpoint) return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required');
return probeSelfHosted(endpoint, apiKey, managed);
}
export async function POST(req: NextRequest) {
let providerId: string | undefined;
try {
const body = await req.json();
if (body?.provider !== undefined) {
const ref = savedProviderRef(body.provider);
if (!ref) return apiError('MISSING_REQUIRED_FIELD', 400, 'Provider ID is required');
providerId = ref;
return await verifySavedProvider(req, ref);
}
providerId = body.providerId;
const { apiKey, baseUrl, accessKeyId, accessKeySecret } = body;
@@ -124,28 +209,7 @@ export async function POST(req: NextRequest) {
).replace(/\/+$/, '');
// Probe the batch endpoint with an empty body to verify auth
const response = await providerFetch(
`${cloudBase}/extract-results/batch/test-connection`,
{
headers: {
Authorization: `Bearer ${resolvedApiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(10000),
},
probePolicy(managed),
);
// Only the status matters; release the connection without reading the body.
await response.body?.cancel().catch(() => undefined);
// Other responses (including 4xx for "batch not found") mean auth + connectivity works.
// Only network errors, redirects, or 401/403 indicate a problem.
if (response.status === 401 || response.status === 403) {
log.warn(`MinerU Cloud probe rejected credentials [status=${response.status}]`);
return apiError('INTERNAL_ERROR', 500, AUTH_FAILED_MESSAGE);
}
return apiSuccess({ message: 'Connection successful' });
return await probeMinerUCloud(cloudBase, resolvedApiKey, managed);
}
// Self-hosted providers: verify by connecting to the base URL
@@ -163,22 +227,7 @@ export async function POST(req: NextRequest) {
}
const resolvedApiKey = resolvePDFApiKey(providerId, managed ? undefined : apiKey);
const headers: Record<string, string> = {};
if (resolvedApiKey) {
headers['Authorization'] = `Bearer ${resolvedApiKey}`;
}
const response = await providerFetch(
resolvedBaseUrl,
{ headers, signal: AbortSignal.timeout(10000) },
probePolicy(managed),
);
await response.body?.cancel().catch(() => undefined);
// MinerU's FastAPI root returns 404 (no root route), but the server is reachable.
// Any HTTP response (including 404) means the server is up.
return apiSuccess({ message: 'Connection successful' });
return await probeSelfHosted(resolvedBaseUrl, resolvedApiKey, managed);
} catch (error) {
log.error(`PDF provider verification failed [provider=${providerId ?? 'unknown'}]:`, error);
+43 -1
View File
@@ -15,7 +15,7 @@
*/
import { NextRequest } from 'next/server';
import { testVideoConnectivity } from '@/lib/media/video-providers';
import { testVideoConnectivity, VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import {
isServerConfiguredProvider,
isServerProviderDisabled,
@@ -29,11 +29,53 @@ import { apiError, apiSuccess } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { validateClientBaseUrl } from '@/lib/server/ssrf-guard';
import { withMediaProviderFetch } from '@/lib/server/media-provider-fetch';
import {
savedMediaConnection,
savedProviderRef,
savedProviderResponse,
} from '@/lib/server/model-config/saved-provider';
import { requestProvidersAllowed } from '@/lib/server/model-config/runtime';
import { REQUEST_PROVIDERS_REFUSED } from '@/lib/server/resolve-model';
const log = createLogger('VerifyVideoProvider');
export async function POST(request: NextRequest) {
try {
// The settings test a saved provider by its id (JSON body `provider`, with
// an optional `model`): the server's configuration supplies key and endpoint.
const body = request.headers.get('content-type')?.includes('application/json')
? ((await request.json().catch(() => null)) as { provider?: unknown; model?: unknown } | null)
: null;
if (body?.provider !== undefined) {
let connection;
try {
const ref = savedProviderRef(body.provider, body.model);
if (!ref) return apiError('MISSING_PROVIDER', 400, 'No video provider named');
connection = await savedMediaConnection(request, 'video', ref);
} catch (error) {
const refused = savedProviderResponse(error, 'video');
if (refused) return refused;
throw error;
}
const providerId = connection.providerId as VideoProviderId;
const model = connection.modelId ?? VIDEO_PROVIDERS[providerId]?.models?.[0]?.id;
const result = await testVideoConnectivity(
withMediaProviderFetch(
{ providerId, apiKey: connection.apiKey ?? '', baseUrl: connection.baseUrl, model },
connection.managed,
),
);
if (!result.success) return apiError('UPSTREAM_ERROR', 500, result.message);
return apiSuccess({ message: result.message });
}
// The old header form tests a provider the request names: not under
// `policy.allowWorkspaceProviders: false`, which leaves only the
// configuration's providers (tested by id above).
if (!requestProvidersAllowed()) {
return apiError('PROVIDER_DISABLED', 403, REQUEST_PROVIDERS_REFUSED);
}
const providerId = (request.headers.get('x-video-provider')?.trim() ||
resolveServerVideoProviderId()) as VideoProviderId;
if (!providerId) {
+44 -81
View File
@@ -8,13 +8,6 @@
import { NextRequest } from 'next/server';
import { callLLM } from '@/lib/ai/llm';
import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search';
import {
isServerConfiguredProvider,
isServerProviderDisabled,
resolveServerWebSearchProviderId,
resolveWebSearchApiKey,
resolveWebSearchModel,
} from '@/lib/server/provider-config';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import {
@@ -23,9 +16,15 @@ import {
} from '@/lib/server/search-query-builder';
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import type { AICallFn } from '@openmaic/generation';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import { DEFAULT_WEB_SEARCH_PROVIDER_ID, WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types';
import { resolveWebSearchRouteBaseUrl } from '@/lib/server/web-search-config';
import {
resolveWebSearchConnection,
WebSearchConfigError,
type WebSearchConfig,
} from '@/lib/server/web-search-config';
import { mediaResolutionResponse } from '@/lib/server/model-config/media';
import { requestWorkspaceId } from '@/lib/server/model-config/runtime';
const log = createLogger('WebSearch');
@@ -56,67 +55,42 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
}
const serverProviderId = resolveServerWebSearchProviderId() as WebSearchProviderId | undefined;
let providerId: WebSearchProviderId =
requestProviderId && WEB_SEARCH_PROVIDERS[requestProviderId]
? requestProviderId
: (serverProviderId ?? 'tavily');
// Prefer the operator's server-configured backend over stale client defaults
// (e.g. Tavily without a key, or Brave HTML scrape with empty results).
if (
serverProviderId &&
isServerConfiguredProvider('webSearch', serverProviderId) &&
providerId !== serverProviderId &&
!isServerConfiguredProvider('webSearch', providerId)
) {
log.info(
`Using server-configured web search provider "${serverProviderId}" instead of "${providerId}"`,
);
providerId = serverProviderId;
}
// Enforce server precedence: a force-disabled provider is off for everyone,
// regardless of any client key/selection — mirror the TTS contract (#665).
// Checked after the server-preference override so a disabled client choice
// yields to the operator's enabled backend.
if (isServerProviderDisabled('webSearch', providerId)) {
return apiError(
'PROVIDER_DISABLED',
403,
'This web search provider is disabled by the server',
);
}
const provider = WEB_SEARCH_PROVIDERS[providerId];
// Managed providers are admin-owned: ignore (don't reject) any client-sent
// key/baseUrl. The server config is authoritative, so a stale client base
// URL is dropped rather than failing the request.
const managed = isServerConfiguredProvider('webSearch', providerId);
const clientApiKey = managed ? undefined : bodyApiKey;
// SearXNG base URLs are operator-managed only (SEARXNG_BASE_URL); never trust client input.
const clientBaseUrl = managed || providerId === 'searxng' ? undefined : bodyBaseUrl;
const apiKey = resolveWebSearchApiKey(providerId, clientApiKey);
if (provider.requiresApiKey && !apiKey) {
return apiError(
'MISSING_API_KEY',
400,
`${provider.name} API key is not configured. Set it in Settings -> Web Search or configure ${getWebSearchEnvKey(providerId)} on the server.`,
);
}
let baseUrl: string | undefined;
// The webSearch slot decides; the provider, key and base URL a request
// names (deprecated) count only when it is unassigned.
let config: WebSearchConfig;
try {
baseUrl = resolveWebSearchRouteBaseUrl(providerId, clientBaseUrl);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid web search base URL';
return apiError('INVALID_REQUEST', 400, message);
}
if (provider.requiresBaseUrl && !baseUrl) {
return apiError(
'MISSING_REQUIRED_FIELD',
400,
getMissingBaseUrlMessage(providerId, provider.name),
config = await resolveWebSearchConnection(
await requestWorkspaceId(req),
{
webSearchProviderId: requestProviderId,
webSearchApiKey: bodyApiKey,
webSearchBaseUrl: bodyBaseUrl,
webSearchModelId: claudeModelId,
baiduSubSources,
},
{
refuseDisabled: true,
preferServerProvider: true,
fallbackProviderId: DEFAULT_WEB_SEARCH_PROVIDER_ID,
},
);
} catch (error) {
const refused = mediaResolutionResponse(error, 'Web search');
if (refused) return refused;
if (error instanceof WebSearchConfigError) {
const provider = error.providerId ? WEB_SEARCH_PROVIDERS[error.providerId] : undefined;
const message =
error.providerId && provider && error.code === 'MISSING_API_KEY'
? `${provider.name} API key is not configured. Set it in the model settings or configure ${getWebSearchEnvKey(error.providerId)} on the server.`
: error.providerId && provider && error.code === 'MISSING_REQUIRED_FIELD'
? getMissingBaseUrlMessage(error.providerId, provider.name)
: error.message;
return apiError(error.code, 400, message);
}
if (error instanceof Error && /base URL/.test(error.message)) {
return apiError('INVALID_REQUEST', 400, error.message);
}
throw error;
}
// Clamp rewrite input at the route boundary; framework body limits still apply to total request size.
@@ -159,18 +133,7 @@ export async function POST(req: NextRequest) {
finalQueryLength: searchQuery.finalQueryLength,
});
const result = await searchWeb({
providerId,
query: searchQuery.query,
apiKey,
baseUrl,
...(providerId === 'baidu' && baiduSubSources ? { baiduSubSources } : {}),
// A server-pinned model (WEB_SEARCH_CLAUDE_MODELS) is authoritative over
// the client-selected one, matching how managed keys/base URLs behave.
...(providerId === 'claude'
? { claudeModelId: resolveWebSearchModel('claude', claudeModelId) }
: {}),
});
const result = await searchWeb({ ...config, query: searchQuery.query });
const context = formatSearchResultsAsContext(result);
return apiSuccess({
@@ -191,7 +154,7 @@ function getMissingBaseUrlMessage(providerId: WebSearchProviderId, providerName:
if (providerId === 'searxng') {
return `${providerName} base URL is not configured. Set SEARXNG_BASE_URL on the server.`;
}
return `${providerName} base URL is not configured. Set ${getWebSearchEnvKey(providerId)} on the server or configure the base URL in Settings -> Web Search.`;
return `${providerName} base URL is not configured. Set ${getWebSearchEnvKey(providerId)} on the server or configure the base URL in the model settings.`;
}
function getWebSearchEnvKey(providerId: WebSearchProviderId): string {
+61 -115
View File
@@ -17,7 +17,6 @@ import {
resolveNarratorVoiceForGeneration,
} from '@/lib/audio/voice-resolver';
import { isQwenCloneVoice, resolveTTSModelForVoice } from '@/lib/audio/constants';
import { isTTSProviderEnabled } from '@/lib/audio/provider-enablement';
import { useAllVoiceProfiles } from '@/lib/audio/voxcpm-voices';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
@@ -33,7 +32,9 @@ import {
cleanupOldImages,
storeImages,
} from '@/lib/utils/image-storage';
import { getCurrentModelConfig, getStageRoutesHeaderValue } from '@/lib/utils/model-config';
import { requireModelCapabilities } from '@/lib/model-settings/capabilities';
import { withResearchDecision } from '@/lib/generation/research-decision';
import { narrationPlan, ttsSelection } from '@/lib/audio/tts-selection';
import { resolveSessionDocumentSources } from '@/lib/document/session-sources';
import { MAX_VISION_IMAGES } from '@/lib/constants/generation';
import {
@@ -250,40 +251,9 @@ function GenerationPreviewContent() {
};
}, []);
// Get API credentials from localStorage
const getApiHeaders = () => {
const modelConfig = getCurrentModelConfig();
const settings = useSettingsStore.getState();
const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId];
const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId];
const stageRoutesHeader = getStageRoutesHeaderValue();
return {
'Content-Type': 'application/json',
...(stageRoutesHeader ? { 'x-model-routes': stageRoutesHeader } : {}),
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
'x-base-url': modelConfig.baseUrl,
'x-provider-type': modelConfig.providerType || '',
// Image generation provider
'x-image-provider': settings.imageProviderId || '',
'x-image-model': settings.imageModelId || '',
'x-image-api-key': imageProviderConfig?.apiKey || '',
'x-image-base-url': imageProviderConfig?.baseUrl || '',
// Video generation provider
'x-video-provider': settings.videoProviderId || '',
'x-video-model': settings.videoModelId || '',
'x-video-api-key': videoProviderConfig?.apiKey || '',
'x-video-base-url': videoProviderConfig?.baseUrl || '',
// Media generation toggles
'x-image-generation-enabled': String(settings.imageGenerationEnabled ?? false),
'x-video-generation-enabled': String(settings.videoGenerationEnabled ?? false),
};
};
const withThinkingConfig = <T extends Record<string, unknown>>(body: T) => {
const { thinkingConfig } = getCurrentModelConfig();
return thinkingConfig ? { ...body, thinkingConfig } : body;
};
// The server resolves every model and provider, and which media the
// outline may plan, from the workspace's model settings.
const getApiHeaders = () => ({ 'Content-Type': 'application/json' });
// Auto-start generation when session is loaded
useEffect(() => {
@@ -322,6 +292,18 @@ function GenerationPreviewContent() {
setCurrentStepIndex(0);
try {
// The workspace's model settings, read once for the whole run (again
// after a failed read; generation does not start without them). Research
// follows the webSearch slot as read now, not as the session saved it.
const capabilities = await requireModelCapabilities();
if (!capabilities) throw new Error(t('generation.modelSettingsUnavailable'));
const decided = withResearchDecision(currentSession, capabilities);
if (decided !== currentSession) {
currentSession = decided;
setSession(decided);
sessionStorage.setItem('generationSession', JSON.stringify(decided));
}
// Compute active steps for this session (recomputed after session mutations)
let activeSteps = getActiveSteps(currentSession);
@@ -341,18 +323,6 @@ function GenerationPreviewContent() {
const sortedDocumentSources = [...documentSources].sort((a, b) => a.order - b.order);
const parsedParts = await Promise.all(
sortedDocumentSources.map(async (source): Promise<ParsedDocumentPart> => {
const providerId = source.providerId || currentSession.pdfProviderId;
const legacySourceConfig = (
source as SessionDocumentSource & {
providerConfig?: {
apiKey?: string;
baseUrl?: string;
accessKeyId?: string;
accessKeySecret?: string;
};
}
).providerConfig;
const providerConfig = currentSession.pdfProviderConfig || legacySourceConfig;
const documentBlob = await loadDocumentBlob(source.storageKey);
if (!(documentBlob instanceof Blob) || documentBlob.size === 0) {
throw new Error(t('generation.courseMaterialLoadFailed'));
@@ -360,19 +330,9 @@ function GenerationPreviewContent() {
const documentFile = new File([documentBlob], source.name || 'document.pdf', {
type: source.mimeType || documentBlob.type || 'application/pdf',
});
// The workspace's document slot names the extractor on the server.
const parseFormData = new FormData();
parseFormData.append('file', documentFile);
if (providerId) parseFormData.append('providerId', providerId);
if (providerConfig?.apiKey?.trim())
parseFormData.append('apiKey', providerConfig.apiKey);
if (providerConfig?.baseUrl?.trim())
parseFormData.append('baseUrl', providerConfig.baseUrl);
if (providerConfig?.accessKeyId?.trim()) {
parseFormData.append('accessKeyId', providerConfig.accessKeyId);
}
if (providerConfig?.accessKeySecret?.trim()) {
parseFormData.append('accessKeySecret', providerConfig.accessKeySecret);
}
const parseResponse = await fetch('/api/extract-document', {
method: 'POST',
body: parseFormData,
@@ -408,7 +368,6 @@ function GenerationPreviewContent() {
lastModified: source.lastModified,
mimeType: source.mimeType,
order: source.order,
providerId,
},
text: parseData.text as string,
rawTextLength: (parseData.text as string).length,
@@ -476,23 +435,14 @@ function GenerationPreviewContent() {
setCurrentStepIndex(webSearchStepIdx);
setWebSearchSources([]);
const wsSettings = useSettingsStore.getState();
const wsProviderId = wsSettings.webSearchProviderId;
const wsConfig = wsSettings.webSearchProvidersConfig?.[wsProviderId];
// The workspace's webSearch slot names the provider on the server.
const res = await fetch('/api/web-search', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(
withThinkingConfig({
query: currentSession.requirements.requirement,
pdfText: currentSession.pdfText || undefined,
providerId: wsProviderId,
apiKey: wsConfig?.apiKey || undefined,
baseUrl: wsProviderId === 'searxng' ? undefined : wsConfig?.baseUrl || undefined,
baiduSubSources: wsProviderId === 'baidu' ? wsSettings.baiduSubSources : undefined,
claudeModelId: wsProviderId === 'claude' ? wsConfig?.modelId || undefined : undefined,
}),
),
body: JSON.stringify({
query: currentSession.requirements.requirement,
pdfText: currentSession.pdfText || undefined,
}),
signal,
});
@@ -570,15 +520,13 @@ function GenerationPreviewContent() {
fetch('/api/generate/scene-outlines-stream', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(
withThinkingConfig({
requirements: currentSession.requirements,
pdfText: currentSession.pdfText,
pdfImages: currentSession.pdfImages,
imageMapping,
researchContext: currentSession.researchContext,
}),
),
body: JSON.stringify({
requirements: currentSession.requirements,
pdfText: currentSession.pdfText,
pdfImages: currentSession.pdfImages,
imageMapping,
researchContext: currentSession.researchContext,
}),
signal,
})
.then((res) => {
@@ -736,6 +684,8 @@ function GenerationPreviewContent() {
// ── Agent generation (after outlines — uses languageDirective + outlines) ──
const settings = useSettingsStore.getState();
// The tts slot's provider, and the user's voice for it.
const tts = ttsSelection();
let agents: Array<{
id: string;
name: string;
@@ -800,10 +750,9 @@ function GenerationPreviewContent() {
];
const getAvailableVoicesForGeneration = () => {
const providers = getEnabledProvidersWithVoices(
settings.ttsProvidersConfig,
voiceProfiles,
);
const providers = tts
? getEnabledProvidersWithVoices(tts.providersConfig, voiceProfiles)
: [];
return providers.flatMap((p) =>
p.voices.map((v) => {
const cloneModelGroup =
@@ -834,29 +783,29 @@ function GenerationPreviewContent() {
// provider) is NOT pinned — the LLM then picks a working advertised
// voice and the narration fallback machinery stays alive.
const getNarratorVoiceForGeneration = () =>
resolveNarratorVoiceForGeneration(
settings.ttsProviderId,
settings.ttsVoice,
settings.ttsProvidersConfig[settings.ttsProviderId],
);
tts
? resolveNarratorVoiceForGeneration(
tts.providerId,
tts.voice,
tts.providersConfig[tts.providerId],
)
: undefined;
const agentResp = await fetch('/api/generate/agent-profiles', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(
withThinkingConfig({
stageInfo: { name: stage.name, description: stage.description },
sceneOutlines: outlines.map((o) => ({
title: o.title,
description: o.description,
})),
languageDirective,
availableAvatars: allAvatars.map((a) => a.path),
avatarDescriptions: allAvatars.map((a) => ({ path: a.path, desc: a.desc })),
availableVoices: getAvailableVoicesForGeneration(),
narratorVoice: getNarratorVoiceForGeneration(),
}),
),
body: JSON.stringify({
stageInfo: { name: stage.name, description: stage.description },
sceneOutlines: outlines.map((o) => ({
title: o.title,
description: o.description,
})),
languageDirective,
availableAvatars: allAvatars.map((a) => a.path),
avatarDescriptions: allAvatars.map((a) => ({ path: a.path, desc: a.desc })),
availableVoices: getAvailableVoicesForGeneration(),
narratorVoice: getNarratorVoiceForGeneration(),
}),
signal,
});
@@ -1012,15 +961,12 @@ function GenerationPreviewContent() {
}
const firstScene = data.scene;
// Generate TTS for first scene (part of actions step — blocking)
if (
settings.ttsEnabled &&
settings.ttsProviderId !== 'browser-native-tts' &&
isTTSProviderEnabled(
settings.ttsProviderId,
settings.ttsProvidersConfig?.[settings.ttsProviderId],
)
) {
// Generate TTS for first scene (part of actions step — blocking). Model
// settings that cannot be read stop generation: narration is never
// dropped silently.
const narration = await narrationPlan();
if (narration === 'unknown') throw new Error(t('generation.modelSettingsUnavailable'));
if (narration === 'server') {
const ttsResult = await generateTTSForScene(
firstScene,
languageDirective,
-7
View File
@@ -24,13 +24,6 @@ export interface GenerationSessionState {
pdfStorageKey?: string;
pdfFileName?: string;
documentMimeType?: string;
pdfProviderId?: string;
pdfProviderConfig?: {
apiKey?: string;
baseUrl?: string;
accessKeyId?: string;
accessKeySecret?: string;
};
// Web search context
researchContext?: string;
researchSources?: Array<{ title: string; url: string }>;
+2 -2
View File
@@ -8,7 +8,7 @@ import 'katex/dist/katex.min.css';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { I18nProvider } from '@/lib/hooks/use-i18n';
import { Toaster } from '@/components/ui/sonner';
import { ServerProvidersInit } from '@/components/server-providers-init';
import { ModelSettingsInit } from '@/components/model-settings-init';
import { StorageHealthNotice } from '@/components/storage-health-notice';
import { AccessCodeGuard } from '@/components/access-code-guard';
import { ProSwapWatcher } from '@/components/workbench/ProSwapWatcher';
@@ -47,7 +47,7 @@ export default function RootLayout({
>
<ThemeProvider>
<I18nProvider>
<ServerProvidersInit />
<ModelSettingsInit />
<ProSwapWatcher />
<AccessCodeGuard>{children}</AccessCodeGuard>
<Toaster position="top-center" />
+25 -42
View File
@@ -51,8 +51,12 @@ import type {
SessionDocumentSource,
UserRequirements,
} from '@/lib/types/generation';
import { useSettingsStore } from '@/lib/store/settings';
import { hasUsableLLMProvider } from '@/lib/store/settings-validation';
import {
courseGenerationUsable,
requireModelCapabilities,
} from '@/lib/model-settings/capabilities';
import { withResearchDecision } from '@/lib/generation/research-decision';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
import {
StageListItem,
@@ -171,11 +175,10 @@ function HomePage() {
const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } =
useDraftCache<string>({ key: 'requirementDraft' });
// A usable LLM provider exists ⇒ a concrete model is always selected (#580
// invariant). Gate generation on this single condition (state A vs B)
// instead of inspecting modelId directly.
const providersConfig = useSettingsStore((s) => s.providersConfig);
const hasUsableProvider = hasUsableLLMProvider(providersConfig);
// Generation needs the course slots it resolves (outline, content, actions)
// to name a model, whether or not the llm root does (the server's view;
// while it cannot be read the server has the last word).
const hasUsableProvider = courseGenerationUsable(useModelCapabilities());
const [recentOpen, setRecentOpen] = useState(true);
const persistRecentOpen = (next: boolean) => {
setRecentOpen(next);
@@ -580,28 +583,12 @@ function HomePage() {
setError(null);
// The material list and the extractor provider config are frozen for the
// duration of prep: `preparingGenerate` makes add/remove inert and
// disables the toolbar affordances (including the extractor Select and the
// web-search toggle), so neither can change under the session build below.
// Capture both at click time and build the session from this snapshot,
// never from live form state or live store state.
// The material list is frozen for the duration of prep: `preparingGenerate`
// makes add/remove inert, so it cannot change under the session build
// below. Capture it at click time and build the session from this
// snapshot, never from live form state. (The extractor is the workspace's
// document slot, resolved on the server.)
const frozenMaterials = [...form.courseMaterials].sort((a, b) => a.order - b.order);
const settingsSnapshot = useSettingsStore.getState();
const frozenPdfProviderId = settingsSnapshot.pdfProviderId;
const frozenPdfProviderConfig = settingsSnapshot.pdfProvidersConfig?.[
settingsSnapshot.pdfProviderId
]
? {
apiKey: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].apiKey,
baseUrl: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].baseUrl,
accessKeyId:
settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeyId,
accessKeySecret:
settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeySecret,
}
: undefined;
// Flip the generating UI state before material bytes are copied locally.
setPreparingGenerate(true);
try {
@@ -610,24 +597,23 @@ function HomePage() {
requirement: form.requirement,
userNickname: userProfile.nickname || undefined,
userBio: userProfile.bio || undefined,
// Course-level web search now lives in settings (课程模型配置 → 联网调研)
webSearch: useSettingsStore.getState().webSearchEnabled || undefined,
// Research follows the workspace's webSearch slot; decided below from
// a successful read (and again when generation starts).
interactiveMode: form.vocationalTestMode ? true : form.interactiveMode,
...(form.vocationalTestMode ? { taskEngineMode: true } : {}),
};
// Nothing is saved from settings that could not be read.
const capabilities = await requireModelCapabilities();
if (!capabilities) throw new Error(t('generation.modelSettingsUnavailable'));
Object.assign(
requirements,
withResearchDecision({ requirements }, capabilities).requirements,
);
let documentSources: SessionDocumentSource[] | undefined;
let pdfProviderId: string | undefined;
let pdfProviderConfig:
| { apiKey?: string; baseUrl?: string; accessKeyId?: string; accessKeySecret?: string }
| undefined;
if (frozenMaterials.length > 0) {
// The session is built from the click-time snapshot (frozen above),
// never from live store state.
pdfProviderId = frozenPdfProviderId;
pdfProviderConfig = frozenPdfProviderConfig;
const storedDocumentKeys: string[] = [];
try {
documentSources = [];
@@ -645,7 +631,6 @@ function HomePage() {
}),
order: index + 1,
storageKey,
providerId: pdfProviderId,
});
}
} catch (error) {
@@ -665,8 +650,6 @@ function HomePage() {
pdfStorageKey: documentSources?.[0]?.storageKey,
pdfFileName: documentSources?.[0]?.name,
documentMimeType: documentSources?.[0]?.mimeType,
pdfProviderId,
pdfProviderConfig,
sceneOutlines: null,
currentStep: 'generating' as const,
};
+8 -7
View File
@@ -2,7 +2,8 @@
import { useEffect, useState, ReactNode } from 'react';
import { AccessCodeModal } from '@/components/access-code-modal';
import { useSettingsStore } from '@/lib/store/settings';
import { importLegacyModelSettings } from '@/components/model-settings-init';
import { modelSettingsClient } from '@/lib/model-settings/client';
export function AccessCodeGuard({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<{
@@ -44,13 +45,13 @@ export function AccessCodeGuard({ children }: { children: ReactNode }) {
open={true}
onSuccess={() => {
setStatus((s) => ({ ...s, authenticated: true }));
// ServerProvidersInit runs on mount, which on an ACCESS_CODE-gated
// Model settings are read on mount, which on an ACCESS_CODE-gated
// deployment is before any access cookie exists: the middleware
// answers 401 and the store silently keeps its blank defaults.
// Nothing re-fetches afterwards, so every server-configured
// provider reads as unconfigured until a manual reload. Re-fetch
// now that the request will be authorized.
void useSettingsStore.getState().fetchServerProviders();
// answers 401 and nothing is known about the workspace's models
// until a reload. Read them again now that the request will be
// authorized, and retry the one-time import of browser settings.
void modelSettingsClient.load();
void importLegacyModelSettings();
}}
/>
)}
+14 -30
View File
@@ -7,6 +7,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { useSlotTTSProvidersConfig, useTTSSelection } from '@/lib/audio/use-tts-selection';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { resolveAgentVoice, getSelectableProvidersWithVoices } from '@/lib/audio/voice-resolver';
import { playBrowserTTSPreview } from '@/lib/audio/browser-tts-preview';
@@ -77,7 +78,7 @@ function AgentVoicePill({
disabled?: boolean;
}) {
const { t, locale } = useI18n();
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
const ttsProvidersConfig = useSlotTTSProvidersConfig();
const agentVoiceOverrides = useSettingsStore((s) => s.agentVoiceOverrides);
const setAgentVoiceOverride = useSettingsStore((s) => s.setAgentVoiceOverride);
const resolved = resolveAgentVoice(agent, agentIndex, availableProviders, agentVoiceOverrides);
@@ -156,17 +157,12 @@ function AgentVoicePill({
const res = await fetch('/api/generate/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The tts slot names the provider and model on the server.
body: JSON.stringify({
text: previewText,
audioId: 'voice-preview',
ttsProviderId: providerId,
ttsModelId: modelId || providerConfig?.modelId,
ttsVoice: voiceId,
ttsSpeed: 1,
ttsApiKey: providerConfig?.apiKey,
// Managed providers resolve their base URL server-side; only send
// the client's own base URL (custom providers).
ttsBaseUrl: providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl,
ttsProviderOptions: providerOptions,
}),
signal: controller.signal,
@@ -355,12 +351,12 @@ function TeacherVoicePill({
disabled?: boolean;
}) {
const { t, locale } = useI18n();
const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
const ttsVoice = useSettingsStore((s) => s.ttsVoice);
const setTTSProvider = useSettingsStore((s) => s.setTTSProvider);
// The tts slot names the provider; the user picks a voice of it.
const selection = useTTSSelection();
const ttsProviderId = selection?.providerId;
const ttsVoice = selection?.voice;
const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
const setTTSProviderConfig = useSettingsStore((s) => s.setTTSProviderConfig);
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
const ttsProvidersConfig = useSlotTTSProvidersConfig();
const [popoverOpen, setPopoverOpen] = useState(false);
const [voiceQuery, setVoiceQuery] = useState('');
const [previewingId, setPreviewingId] = useState<string | null>(null);
@@ -437,17 +433,12 @@ function TeacherVoicePill({
const res = await fetch('/api/generate/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The tts slot names the provider and model on the server.
body: JSON.stringify({
text: previewText,
audioId: 'voice-preview',
ttsProviderId: providerId,
ttsModelId: modelId || providerConfig?.modelId,
ttsVoice: voiceId,
ttsSpeed: 1,
ttsApiKey: providerConfig?.apiKey,
// Managed providers resolve their base URL server-side; only send
// the client's own base URL (custom providers).
ttsBaseUrl: providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl,
ttsProviderOptions: providerOptions,
}),
signal: controller.signal,
@@ -543,11 +534,7 @@ function TeacherVoicePill({
: provider.providerName}
</div>
{group.voices.map((voice) => {
const currentModelId = ttsProvidersConfig[ttsProviderId]?.modelId || '';
const isActive =
ttsProviderId === provider.providerId &&
ttsVoice === voice.id &&
currentModelId === (group.modelId || '');
const isActive = ttsProviderId === provider.providerId && ttsVoice === voice.id;
const previewKey = `${provider.providerId}::${voice.id}`;
const isPreviewing = previewingId === previewKey;
const canPreview = !isNonPreviewableVoice(provider.providerId, voice.id);
@@ -562,11 +549,7 @@ function TeacherVoicePill({
<button
type="button"
onClick={() => {
setTTSProvider(provider.providerId);
setTTSVoice(voice.id);
if (group.modelId) {
setTTSProviderConfig(provider.providerId, { modelId: group.modelId });
}
setTTSVoice(voice.id, provider.providerId);
setPopoverOpen(false);
}}
className={cn(
@@ -619,8 +602,9 @@ export function AgentBar() {
const agentMode = useSettingsStore((s) => s.agentMode);
const setAgentMode = useSettingsStore((s) => s.setAgentMode);
const setAgentSelectionIsUserSet = useSettingsStore((s) => s.setAgentSelectionIsUserSet);
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
const ttsProvidersConfig = useSlotTTSProvidersConfig();
// Narration is on when the workspace's tts slot resolves to a provider.
const ttsEnabled = !!useTTSSelection();
const [open, setOpen] = useState(false);
const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([]);
-265
View File
@@ -1,265 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import { Volume2, Play, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { getTTSVoices, isQwenCloneVoice, resolveTTSModelForVoice } from '@/lib/audio/constants';
import { useTTSPreview } from '@/lib/audio/use-tts-preview';
import {
getVoxCPMProviderOptions,
getVoxCPMVoiceOptions,
useAllVoiceProfiles,
} from '@/lib/audio/voxcpm-voices';
import {
VOXCPM_AUTO_VOICE_ID,
normalizeVoxCPMBackend,
voxCPMBackendSupportsReferenceAudio,
} from '@/lib/audio/voxcpm';
/** Extract the English name from voice name format "ChineseName (English)" */
function getVoiceDisplayName(
id: string,
name: string,
lang: string,
t: (key: string) => string,
): string {
if (id === VOXCPM_AUTO_VOICE_ID) {
return t('settings.voxcpmAutoVoice');
}
if (lang === 'en-US') {
const match = name.match(/\(([^)]+)\)/);
return match ? match[1] : name;
}
return name;
}
export function TtsConfigPopover() {
const { t, locale } = useI18n();
const [open, setOpen] = useState(false);
const { previewing, startPreview, stopPreview } = useTTSPreview();
const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
const ttsVoice = useSettingsStore((s) => s.ttsVoice);
const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
const setTTSSpeed = useSettingsStore((s) => s.setTTSSpeed);
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
const { profiles: voiceProfiles } = useAllVoiceProfiles();
const voxcpmProfiles = voiceProfiles.filter((profile) => profile.providerId === 'voxcpm-tts');
const qwenProfiles = voiceProfiles.filter((profile) => profile.providerId === 'qwen-tts');
const voxcpmBackend = normalizeVoxCPMBackend(
ttsProvidersConfig['voxcpm-tts']?.providerOptions?.backend,
);
const voices =
ttsProviderId === 'voxcpm-tts'
? getVoxCPMVoiceOptions(voxcpmProfiles, {
supportsClone: voxCPMBackendSupportsReferenceAudio(voxcpmBackend),
})
: ttsProviderId === 'qwen-tts'
? [
...getTTSVoices(ttsProviderId),
...qwenProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
language: 'auto',
gender: 'neutral' as const,
})),
]
: getTTSVoices(ttsProviderId);
const localizedVoices = voices.map((voice) => ({
...voice,
displayName: getVoiceDisplayName(voice.id, voice.name, locale, t),
}));
const pillCls =
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border';
const canPreview = ttsProviderId !== 'voxcpm-tts' || ttsVoice !== VOXCPM_AUTO_VOICE_ID;
const usesQwenClone = ttsProviderId === 'qwen-tts' && isQwenCloneVoice(ttsVoice);
const handleVoiceChange = (voiceId: string) => {
setTTSVoice(voiceId);
};
const handlePreview = useCallback(async () => {
if (previewing) {
stopPreview();
return;
}
if (!canPreview) {
toast.info(t('settings.voxcpmAutoVoiceNoPreview'));
return;
}
try {
const providerConfig = ttsProvidersConfig[ttsProviderId];
const providerOptions =
ttsProviderId === 'voxcpm-tts'
? {
...(providerConfig?.providerOptions || {}),
...(await getVoxCPMProviderOptions(ttsVoice, { role: 'teacher', locale })),
}
: undefined;
await startPreview({
text: t('settings.ttsTestTextDefault'),
providerId: ttsProviderId,
modelId: resolveTTSModelForVoice(ttsProviderId, ttsVoice, providerConfig?.modelId),
voice: ttsVoice,
speed: ttsSpeed,
apiKey: providerConfig?.apiKey,
// Managed providers resolve their base URL server-side; only send the
// client's own base URL (custom providers).
baseUrl: providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl,
providerOptions,
});
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
toast.error(message);
}
}, [
previewing,
canPreview,
startPreview,
stopPreview,
t,
locale,
ttsProviderId,
ttsProvidersConfig,
ttsSpeed,
ttsVoice,
]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
stopPreview();
}
setOpen(nextOpen);
},
[stopPreview],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
className={cn(
pillCls,
ttsEnabled
? 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-700/50'
: 'border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
)}
>
<Volume2 className="size-3.5" />
{ttsEnabled && (
<span className="max-w-[60px] truncate">
{localizedVoices.find((v) => v.id === ttsVoice)?.displayName || ttsVoice}
</span>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('toolbar.ttsHint')}</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-[280px] max-w-[calc(100vw-2rem)] p-0">
{/* Header with toggle */}
<div className="flex items-center gap-2.5 px-3.5 py-3 border-b border-border/40">
<Volume2
className={cn(
'size-4 shrink-0',
ttsEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
)}
/>
<span
className={cn('flex-1 text-sm font-medium', !ttsEnabled && 'text-muted-foreground')}
>
{t('toolbar.ttsTitle')}
</span>
<Switch
checked={ttsEnabled}
onCheckedChange={setTTSEnabled}
className="scale-[0.85] origin-right"
/>
</div>
{/* Config body */}
{ttsEnabled && (
<div className="px-3.5 py-3 space-y-3">
{/* Voice + Preview row */}
<div className="flex items-center gap-2">
<Select value={ttsVoice} onValueChange={handleVoiceChange}>
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{localizedVoices.map((v) => (
<SelectItem key={v.id} value={v.id} className="text-xs">
{v.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<button
onClick={handlePreview}
disabled={!canPreview}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-all shrink-0',
!canPreview && 'cursor-not-allowed opacity-50',
previewing
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{previewing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{previewing ? t('toolbar.ttsPreviewing') : t('toolbar.ttsPreview')}
</button>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{t('settings.ttsSpeed')}</span>
<span>{usesQwenClone ? '1×' : `${ttsSpeed.toFixed(2)}×`}</span>
</div>
<input
aria-label={t('settings.ttsSpeed')}
type="range"
min={0.5}
max={2}
step={0.05}
value={usesQwenClone ? 1 : ttsSpeed}
disabled={usesQwenClone}
onChange={(event) => setTTSSpeed(Number(event.target.value))}
className="w-full disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{usesQwenClone && (
<p className="text-[11px] text-muted-foreground">
{t('settings.qwenCloneSpeedHint')}
</p>
)}
</div>
)}
</PopoverContent>
</Popover>
);
}
+14 -104
View File
@@ -20,14 +20,13 @@ import type { DiscussionRequest } from '@/components/roundtable';
import type { Action } from '@/lib/types/action';
import type { Stage } from '@/lib/types/stage';
import type { UIMessage } from 'ai';
import type { ThinkingConfig } from '@/lib/types/provider';
import { useStageStore } from '@/lib/store';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSettingsStore, type SettingsState } from '@/lib/store/settings';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserProfileStore } from '@/lib/store/user-profile';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { useI18n } from '@/lib/hooks/use-i18n';
import { getCurrentModelConfig, getStageRoutesHeaderValue } from '@/lib/utils/model-config';
import { classroomChatUsable, loadModelCapabilities } from '@/lib/model-settings/capabilities';
import { USER_AVATAR } from '@/lib/types/roundtable';
import { StreamBuffer } from '@/lib/buffer/stream-buffer';
import type { AgentStartItem, ActionItem } from '@/lib/buffer/stream-buffer';
@@ -42,7 +41,6 @@ import { createLogger } from '@/lib/logger';
import { isPiChatEnabled } from '@/lib/config/feature-flags';
import type { CleanupSource } from '@/lib/playback/auto-resume';
import { nanoid } from 'nanoid';
import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types';
import { isWhiteboardReferenceAvailable } from '@/lib/whiteboard/element-reference';
import { refreshWhiteboardRuntimeProjection } from '@/lib/whiteboard/runtime/browser-projection';
@@ -157,18 +155,8 @@ export type ChatRequestTemplate = {
[key: string]: unknown;
};
userProfile?: { nickname?: string; bio?: string };
apiKey: string;
baseUrl?: string;
model?: string;
providerType?: string;
thinkingConfig?: ThinkingConfig;
directorState?: DirectorState;
piSessionBoundary?: PiSessionBoundaryContext;
webSearchProviderId?: WebSearchProviderId;
webSearchApiKey?: string;
webSearchBaseUrl?: string;
webSearchModelId?: string;
baiduSubSources?: BaiduSubSources;
elementReference?: ElementReference;
};
@@ -220,38 +208,6 @@ export function withPiInclassWhiteboardTools<T extends ChatRequestTemplate>(requ
};
}
type PiWebSearchSettings = Pick<
SettingsState,
'webSearchProviderId' | 'webSearchProvidersConfig' | 'baiduSubSources'
>;
/** Snapshot only the selected provider fields accepted by the classroom resolver. */
export function withPiWebSearchSettings<T extends ChatRequestTemplate>(
requestTemplate: T,
settings: PiWebSearchSettings,
): T {
const providerId = settings.webSearchProviderId;
const providerConfig = settings.webSearchProvidersConfig[providerId];
const request = { ...requestTemplate };
delete request.webSearchProviderId;
delete request.webSearchApiKey;
delete request.webSearchBaseUrl;
delete request.webSearchModelId;
delete request.baiduSubSources;
return {
...request,
webSearchProviderId: providerId,
...(providerConfig?.apiKey ? { webSearchApiKey: providerConfig.apiKey } : {}),
...(providerConfig?.baseUrl && !providerConfig.isServerConfigured && providerId !== 'searxng'
? { webSearchBaseUrl: providerConfig.baseUrl }
: {}),
...(providerId === 'claude' && providerConfig?.modelId
? { webSearchModelId: providerConfig.modelId }
: {}),
...(providerId === 'baidu' ? { baiduSubSources: { ...settings.baiduSubSources } } : {}),
} as T;
}
export function shouldAwaitPresentationAction(actionName: string): boolean {
return actionName.startsWith('wb_');
}
@@ -392,24 +348,16 @@ export function getPiSingleRequestOutcome(
}
/**
* Attach the user's per-stage LLM routes (`x-model-routes`) to an outgoing chat
* request's headers, so the classroom-interaction override reaches the server.
* The header is omitted when no stage is routed (following the mainline).
* POST /api/chat (the stateless agent loop). The server resolves the model
* from the workspace's model settings.
*/
export function withStageRoutesHeader(headers: Record<string, string>): Record<string, string> {
const stageRoutesHeader = getStageRoutesHeaderValue();
if (stageRoutesHeader) headers['x-model-routes'] = stageRoutesHeader;
return headers;
}
/** POST /api/chat (the stateless agent loop) with per-stage user routes attached. */
export function fetchStatelessChat(
body: Record<string, unknown>,
signal: AbortSignal,
): Promise<Response> {
return fetch('/api/chat', {
method: 'POST',
headers: withStageRoutesHeader({ 'Content-Type': 'application/json' }),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
@@ -459,7 +407,7 @@ export async function runPiSingleRequest(
}
const response = await fetch('/api/chat/pi', {
method: 'POST',
headers: withStageRoutesHeader({ 'Content-Type': 'application/json' }),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...requestTemplate, ...(interactiveState ? { interactiveState } : {}) }),
signal: controller.signal,
});
@@ -1321,13 +1269,10 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
const piRequestTemplate = firstRequestContext
? { ...requestTemplate, storeState, piSessionBoundary: firstRequestContext }
: { ...requestTemplate, storeState };
const piRequestWithWebSearch = withPiWebSearchSettings(
piRequestTemplate,
useSettingsStore.getState(),
);
// Web search is the workspace's webSearch slot, resolved on the server.
await runPiSingleRequest(
sessionId,
withPiInclassWhiteboardTools(piRequestWithWebSearch),
withPiInclassWhiteboardTools(piRequestTemplate),
controller,
sessionType,
createStatelessStreamConsumer,
@@ -1359,11 +1304,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
{
config: requestTemplate.config,
userProfile: requestTemplate.userProfile,
apiKey: requestTemplate.apiKey,
baseUrl: requestTemplate.baseUrl,
model: requestTemplate.model,
providerType: requestTemplate.providerType,
thinkingConfig: requestTemplate.thinkingConfig,
},
{
getStoreState: buildFreshAgentLoopStoreState,
@@ -1725,7 +1665,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
log.info(`[ChatArea] Resuming session: ${sessionId}`);
const userProfileState = useUserProfileStore.getState();
const mc = getCurrentModelConfig();
const agentIds =
useSettingsStore.getState().selectedAgentIds?.length > 0
@@ -1744,11 +1683,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
nickname: userProfileState.nickname || undefined,
bio: userProfileState.bio || undefined,
},
apiKey: mc.apiKey,
baseUrl: mc.baseUrl,
model: mc.modelString,
providerType: mc.providerType,
thinkingConfig: mc.thinkingConfig,
directorState: session.directorState,
},
controller,
@@ -1832,18 +1766,12 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
// Capture the next request only after that mutation has settled.
await pendingRetirementRef.current;
// Validate model configuration before sending
const modelConfig = getCurrentModelConfig();
if (!modelConfig.modelId) {
// Validate model configuration before sending: chat resolves the
// workspace's classroom slot (assigned there or inherited).
if (!classroomChatUsable(await loadModelCapabilities())) {
toast.error(t('settings.modelNotConfigured'));
return;
}
if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) {
toast.error(t('settings.setupNeeded'), {
description: t('settings.apiKeyDesc'),
});
return;
}
// Create a new session when there's no active QA session to append to.
// A completed session should NOT be reused — start a fresh one instead.
@@ -1937,7 +1865,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
);
const userProfileState = useUserProfileStore.getState();
const mc = getCurrentModelConfig();
await runAgentLoopFn(
sessionId!,
@@ -1951,11 +1878,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
nickname: userProfileState.nickname || undefined,
bio: userProfileState.bio || undefined,
},
apiKey: mc.apiKey,
baseUrl: mc.baseUrl,
model: mc.modelString,
providerType: mc.providerType,
thinkingConfig: mc.thinkingConfig,
directorState: existingSession?.directorState,
...(options.elementReference ? { elementReference: options.elementReference } : {}),
},
@@ -2006,18 +1928,12 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
// but being explicit guards against future refactors)
livePausedRef.current = false;
// Validate model configuration before starting discussion
const modelConfig = getCurrentModelConfig();
if (!modelConfig.modelId) {
// Validate model configuration before starting discussion: it resolves
// the workspace's classroom slot (assigned there or inherited).
if (!classroomChatUsable(await loadModelCapabilities())) {
toast.error(t('settings.modelNotConfigured'));
return;
}
if (modelConfig.requiresApiKey && !modelConfig.apiKey && !modelConfig.isServerConfigured) {
toast.error(t('settings.setupNeeded'), {
description: t('settings.apiKeyDesc'),
});
return;
}
// Auto-end previous active QA/Discussion sessions to ensure only one is active
const activeQAOrDiscussion = sessionsRef.current.filter(isOpenLiveSession);
@@ -2070,7 +1986,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
try {
const userProfileState = useUserProfileStore.getState();
const mc = getCurrentModelConfig();
await runAgentLoopFn(
sessionId,
@@ -2087,11 +2002,6 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
nickname: userProfileState.nickname || undefined,
bio: userProfileState.bio || undefined,
},
apiKey: mc.apiKey,
baseUrl: mc.baseUrl,
model: mc.modelString,
providerType: mc.providerType,
thinkingConfig: mc.thinkingConfig,
},
controller,
'discussion',
+1 -24
View File
@@ -42,11 +42,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
import { FileQuestion, Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import {
applyClassroomStageAndScenes,
defaultClassroomLoadDeps,
runClassroomLoad,
} from '@/lib/classroom/load-classroom';
import { defaultClassroomLoadDeps, runClassroomLoad } from '@/lib/classroom/load-classroom';
import {
paneAvailabilityRetryDelay,
resolveClassroomSurfaceView,
@@ -113,13 +109,6 @@ export function ClassroomSurface({
isCurrent,
loadFromStorage,
getCurrentStage: () => useStageStore.getState().stage,
fetchClassroom: defaultClassroomLoadDeps.fetchClassroom,
applyFallbackScenes: (args) =>
defaultClassroomLoadDeps.applyFallbackScenes({
...args,
isCurrent,
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: (restored) =>
defaultClassroomLoadDeps.applyRestoredMediaTasks(restored, isCurrent),
@@ -148,18 +137,6 @@ export function ClassroomSurface({
return 'absent';
}
if (loadResult.outcome === 'unavailable') {
if (variant === 'pane') {
// Retry through the availability schedule; exhaustion lands on the
// error card with Retry, not the not-found claim.
return 'unavailable';
}
setLoadUnavailable(true);
setError(LOAD_UNAVAILABLE_ERROR);
setLoading(false);
return 'failed';
}
if (loadResult.outcome === 'cancelled') return 'cancelled';
if (loadResult.outcome === 'failed') return 'failed';
+3 -3
View File
@@ -94,6 +94,7 @@ import {
resolveSpeechAudioId,
} from '@/lib/audio/regenerate-speech-tts';
import { useMayGenerateForStage } from '@/lib/classroom/generation-permission';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
const EMPTY: Action[] = [];
const EMPTY_ELEMENTS: { id?: string; type: string; content?: string }[] = [];
@@ -1057,9 +1058,8 @@ export function ActionsBar({ sceneId }: { sceneId: string }) {
// narration that already exists, and seeing whether a line has any, spend
// nothing, and a course with no ownership record must not lose its playback
// controls the way it must lose its ability to bill the operator.
const ttsActive = useSettingsStore(
(s) => s.ttsEnabled && s.ttsProviderId !== 'browser-native-tts',
);
const ttsTarget = useModelCapabilities().tts;
const ttsActive = !!ttsTarget && ttsTarget.registryId !== 'browser-native-tts';
const mayGenerate = useMayGenerateForStage(useStageStore((s) => s.stage?.id));
// Agents a discussion can be initiated by — sourced from the user's currently
+3 -1
View File
@@ -1,5 +1,6 @@
'use client';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { toast } from 'sonner';
import {
forwardRef,
@@ -246,7 +247,8 @@ export const PlaybackChromeRoot = forwardRef<PlaybackChromeRootHandle, PlaybackC
// Selected agents from settings store (Zustand)
const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
const ttsMuted = useSettingsStore((s) => s.ttsMuted);
const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
// Narration is on when the workspace's tts slot resolves to a provider.
const ttsEnabled = !!useModelCapabilities().tts;
// Generate participants from selected agents
const participants = useMemo(
+155 -50
View File
@@ -13,25 +13,62 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { getThinkingConfigKey } from '@/lib/ai/thinking-config';
import type { SettingsSection } from '@/lib/types/settings';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import type { PDFProviderId } from '@/lib/pdf/types';
import { getAcceptStringForProviders, isMimeSupportedByProviders } from '@/lib/document/mime';
import { toast } from 'sonner';
import { findSlot, type ModelSettingsChange } from '@/lib/model-settings/client';
import {
courseGenerationUsable,
effectiveTarget,
modelCapabilities,
} from '@/lib/model-settings/capabilities';
import { assignmentRefs, modelChange, modelRef, providerLabel } from '@/lib/model-settings/edit';
import { serviceEntries, slotThinking, thinkingChange } from '@/lib/model-settings/services';
import { useModelSettingsView } from '@/lib/model-settings/use-model-settings';
import { modelSettingsClient } from '@/lib/model-settings/client';
import {
getAcceptStringForProviders,
getFormatLabelsForProviders,
isMimeSupportedByProviders,
} from '@/lib/document/mime';
import {
MAX_DOCUMENT_BUNDLE_FILES,
MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES,
} from '@/lib/document/bundle';
import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials';
import type { SelectedCourseMaterial } from '@/lib/types/generation';
import { ModelPicker } from '@/components/settings/model-picker';
import { ProviderLogo } from '@/components/settings/model-picker';
import { HomeModelPicker } from '@/components/settings/home-model-picker';
import { useLLMPickerGroups } from '@/components/settings/use-llm-picker-groups';
// ─── Constants ───────────────────────────────────────────────
const MAX_COURSE_MATERIAL_SIZE_MB = 50;
const MAX_COURSE_MATERIAL_SIZE_BYTES = MAX_COURSE_MATERIAL_SIZE_MB * 1024 * 1024;
type Translate = (key: string, options?: Record<string, unknown>) => string;
/**
* The formats the active extractors accept, as a localized list ("PDF, TXT,
* MD"). The format labels are file-type names shared by every locale; only
* the separator is localized.
*/
export function courseMaterialFormatList(t: Translate, providerIds: readonly string[]): string {
return getFormatLabelsForProviders(providerIds).join(t('upload.formatListSeparator'));
}
/** The "this file type is unsupported" message, naming the extractor and what it accepts. */
export function unsupportedCourseMaterialMessage(
t: Translate,
extractorName: string,
providerIds: readonly string[],
): string {
return t('upload.unsupportedCourseMaterial', {
parser: extractorName,
formats: courseMaterialFormatList(t, providerIds),
});
}
// ─── Types ───────────────────────────────────────────────────
export interface GenerationToolbarProps {
// PDF
@@ -63,32 +100,83 @@ export function GenerationToolbar({
onSettingsOpen,
}: GenerationToolbarProps) {
const { t } = useI18n();
const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig);
const setPDFProvider = useSettingsStore((s) => s.setPDFProvider);
const providerId = useSettingsStore((s) => s.providerId);
const modelId = useSettingsStore((s) => s.modelId);
const setModel = useSettingsStore((s) => s.setModel);
const thinkingConfigs = useSettingsStore((s) => s.thinkingConfigs);
const setThinkingConfig = useSettingsStore((s) => s.setThinkingConfig);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const { groups: llmPickerGroups } = useLLMPickerGroups();
const currentProviderName = llmPickerGroups.find((g) => g.id === providerId)?.name ?? providerId;
const currentThinkingConfig = thinkingConfigs[getThinkingConfigKey(providerId, modelId)];
// The workspace's model settings (the server's view): the llm root is the
// course model, the document slot the extractor.
const view = useModelSettingsView();
const llmSlot = view ? findSlot(view, 'llm') : undefined;
const llm = effectiveTarget(view, 'llm');
const providerId = llm?.providerId ?? '';
const modelId = llm?.modelId ?? '';
const llmPickerGroups = useLLMPickerGroups(view);
const currentProviderName = view && providerId ? providerLabel(view, providerId) : providerId;
const currentGroup = llmPickerGroups.find((group) => group.id === providerId);
// The deployment may lock the course model; it is then shown, not picked.
const llmEditable = !!llmSlot && !llmSlot.locked && llmPickerGroups.length > 0;
const applyChange = async (change: ModelSettingsChange | undefined) => {
if (!change) return;
const result = await modelSettingsClient.apply(change);
if (!result.ok) toast.error(t('toolbar.modelChangeFailed', { message: result.message }));
};
const selectModel = (pid: string, mid: string) =>
llmSlot && applyChange(modelChange(llmSlot, modelRef(pid, mid)));
// The extractor is the document slot: the workspace's document services
// (and the built-in ones, which need no key) are offered.
const documentSlot = view ? findSlot(view, 'document') : undefined;
const documentTarget = effectiveTarget(view, 'document');
const documentProviderId = (documentTarget?.registryId ?? 'unpdf') as PDFProviderId;
const documentEntries = useMemo(
() =>
view
? serviceEntries(view, 'document', Object.keys(PDF_PROVIDERS)).filter(
(entry) =>
entry.state === 'deployment' ||
entry.state === 'workspace' ||
(entry.state === 'available' &&
!PDF_PROVIDERS[entry.registryId as PDFProviderId]?.requiresApiKey),
)
: [],
[view],
);
const selectExtractor = async (entryId: string) => {
const entry = documentEntries.find((item) => item.id === entryId);
if (!entry || !view) return;
let current = view;
if (!entry.provider && entry.preset) {
const added = await modelSettingsClient.apply(
{ kind: 'provider', id: entry.id, preset: entry.preset.id },
view,
);
if (!added.ok) {
toast.error(t('toolbar.modelChangeFailed', { message: added.message }));
return;
}
current = added.view;
}
const slot = findSlot(current, 'document');
if (slot) {
const result = await modelSettingsClient.apply(modelChange(slot, entry.id), current);
if (!result.ok) toast.error(t('toolbar.modelChangeFailed', { message: result.message }));
}
};
// Course material handler. `plain-text` is always active alongside the
// user-selected extractor so txt/md files remain uploadable without
// workspace's extractor so txt/md files remain uploadable without
// configuring an external service.
const activeDocumentProviderIds = useMemo(
() => [pdfProviderId, 'plain-text'] as const,
[pdfProviderId],
() => [documentProviderId, 'plain-text'] as const,
[documentProviderId],
);
const acceptForCurrentProvider = useMemo(
() => getAcceptStringForProviders(activeDocumentProviderIds),
[activeDocumentProviderIds],
);
const extractorName = PDF_PROVIDERS[documentProviderId]?.name ?? documentProviderId;
const unsupportedMessage = () =>
unsupportedCourseMaterialMessage(t, extractorName, activeDocumentProviderIds);
// If the user switches to a provider that doesn't support already attached
// materials, drop only the incompatible files so the eventual extraction
@@ -106,7 +194,7 @@ export function GenerationToolbar({
for (const file of unsupportedMaterials) {
onCourseMaterialRemove(file.id);
}
onPdfError(t('upload.unsupportedCourseMaterial'));
onPdfError(unsupportedMessage());
// Intentionally omit callbacks/t from deps: adding them would re-run this
// provider capability cleanup on unrelated parent re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -122,12 +210,8 @@ export function GenerationToolbar({
activeDocumentProviderIds,
),
);
if (supportedFiles.length === 0) {
onPdfError(t('upload.unsupportedCourseMaterial'));
return;
}
if (supportedFiles.length !== incomingFiles.length) {
onPdfError(t('upload.unsupportedCourseMaterial'));
onPdfError(unsupportedMessage());
return;
}
if (supportedFiles.some((file) => file.size > MAX_COURSE_MATERIAL_SIZE_BYTES)) {
@@ -167,19 +251,43 @@ export function GenerationToolbar({
return (
<div className="flex items-center gap-1 flex-wrap">
{/* ── Model selection: pill (picker popover) or Set-up CTA (#580) ── */}
{llmPickerGroups.length > 0 ? (
<ModelPicker
{/* ── Course model: pill (picker popover), read-only pill, or Set-up CTA (#580) ── */}
{llmEditable ? (
// Editable: the picker, with nothing selected while `llm` resolves to
// nothing (no default model); picking a model sets the llm slot.
<HomeModelPicker
view={view}
onOpenCourseModels={onSettingsOpen && (() => onSettingsOpen('course-models'))}
groups={llmPickerGroups}
value={providerId && modelId ? { providerId, modelId } : null}
onSelect={(pid, mid) => setModel(pid as Parameters<typeof setModel>[0], mid)}
thinkingConfig={currentThinkingConfig}
onThinkingChange={(config) => setThinkingConfig(providerId, modelId, config)}
ariaLabel={`${currentProviderName} / ${modelId}`}
onSelect={(pid, mid) => void selectModel(pid, mid)}
thinkingConfig={slotThinking(llmSlot)}
onThinkingChange={
llmSlot && assignmentRefs(llmSlot.assignment).model
? (config) => void applyChange(thinkingChange(llmSlot, config))
: undefined
}
placeholder={t('toolbar.pickModel')}
ariaLabel={llm ? `${currentProviderName} / ${modelId}` : t('toolbar.pickModel')}
className="h-8 w-auto max-w-[260px] gap-1.5 rounded-full px-2.5 text-xs"
t={t}
/>
) : llm ? (
<span
className={cn(pillCls, 'cursor-default border-border/50 text-muted-foreground')}
aria-label={`${currentProviderName} / ${modelId}`}
title={t('toolbar.modelLockedHint')}
>
{currentGroup ? (
<ProviderLogo group={currentGroup} className="size-3.5" />
) : (
<Bot className="size-3.5" />
)}
<span className="max-w-[200px] truncate">{modelId || currentProviderName}</span>
</span>
) : (
view &&
!courseGenerationUsable(modelCapabilities(view)) &&
onSettingsOpen && (
<Tooltip>
<TooltipTrigger asChild>
@@ -222,36 +330,30 @@ export function GenerationToolbar({
align="start"
className="max-h-[calc(var(--radix-popover-content-available-height)-8px)] w-72 overflow-y-auto p-0"
>
{/* Extractor selector */}
{/* Extractor selector: the workspace's document slot */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t('toolbar.documentExtractor')}
</span>
<Select
value={pdfProviderId}
onValueChange={(v) => setPDFProvider(v as PDFProviderId)}
disabled={materialsLocked}
value={documentTarget?.providerId ?? ''}
onValueChange={(v) => void selectExtractor(v)}
disabled={materialsLocked || !documentSlot || documentSlot.locked}
>
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
<SelectValue />
<SelectValue placeholder={PDF_PROVIDERS.unpdf?.name ?? 'unpdf'} />
</SelectTrigger>
<SelectContent>
{Object.values(PDF_PROVIDERS).map((provider) => {
const cfg = pdfProvidersConfig[provider.id];
// AliDocMind authenticates with an AK/SK pair rather than a
// single apiKey — recognize either credential shape.
const hasCredentials =
!!cfg?.apiKey || (!!cfg?.accessKeyId && !!cfg?.accessKeySecret);
const available =
!provider.requiresApiKey || hasCredentials || !!cfg?.isServerConfigured;
{documentEntries.map((entry) => {
const provider = PDF_PROVIDERS[entry.registryId as PDFProviderId];
return (
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
<div className={cn('flex items-center gap-1.5', !available && 'opacity-50')}>
{provider.icon && (
<SelectItem key={entry.id} value={entry.id}>
<div className="flex items-center gap-1.5">
{provider?.icon && (
<img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" />
)}
{provider.name}
{cfg?.isServerConfigured && (
{provider?.name ?? entry.id}
{entry.state === 'deployment' && (
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
{t('settings.serverConfigured')}
</span>
@@ -307,7 +409,10 @@ export function GenerationToolbar({
<Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
<p className="text-xs font-medium">{t('toolbar.courseMaterialUpload')}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5 text-center">
{t('upload.courseMaterialSizeLimit')}
{t('upload.courseMaterialFormats', {
formats: courseMaterialFormatList(t, activeDocumentProviderIds),
size: MAX_COURSE_MATERIAL_SIZE_MB,
})}
</p>
<p className="text-[10px] text-muted-foreground/60 text-center">
{t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { runModelSettingsImport } from '@/lib/legacy-browser-import/model-settings-import';
import { takeUnimportedNotice } from '@/lib/legacy-browser-import/model-settings-unimported';
import { useI18n } from '@/lib/hooks/use-i18n';
import { adoptNewerView } from '@/lib/model-settings/adopt-newer-view';
import { modelSettingsClient, type ModelSettingsClient } from '@/lib/model-settings/client';
import { useSettingsStore } from '@/lib/store/settings';
/**
* Import the model settings an earlier build kept in this browser, once the
* settings store has hydrated (its migration is what sets them aside), and
* show the workspace's model settings as the import left them.
*/
export async function importLegacyModelSettings(
client: ModelSettingsClient = modelSettingsClient,
): Promise<void> {
let answered = false;
const outcome = await runModelSettingsImport({
// The view the import produced, kept over any older read still in flight.
onImported: async (view) => {
answered = true;
await adoptNewerView(client, view);
},
});
if (outcome === 'imported' && !answered) await client.load();
}
/** The toast's text, rendered inside the toaster so it follows the language once detected. */
function UnimportedToastText() {
const { t } = useI18n();
return <>{t('settings.unimported.toast')}</>;
}
/**
* After the import: tell the user once, in a toast, about settings that could
* not be moved to the server and are kept in this browser instead (Settings →
* Model Services lists them until the user sets them up again or discards
* them).
*/
export function announceUnimportedModelSettings(): void {
if (!takeUnimportedNotice()) return;
toast.warning(<UnimportedToastText />, { id: 'model-settings-unimported', duration: 15_000 });
}
async function run(): Promise<void> {
try {
await importLegacyModelSettings();
} finally {
announceUnimportedModelSettings();
}
}
/** Runs the import on load. Renders nothing. */
export function ModelSettingsInit() {
useEffect(() => {
const persist = useSettingsStore.persist;
if (persist.hasHydrated()) {
void run();
return;
}
let done = false;
const unsubscribe = persist.onFinishHydration(() => {
if (done) return;
done = true;
void run();
});
return () => {
done = true;
unsubscribe();
};
}, []);
return null;
}
+6 -2
View File
@@ -25,6 +25,8 @@ import { useAudioRecorder } from '@/lib/hooks/use-audio-recorder';
import { useI18n } from '@/lib/hooks/use-i18n';
import { toast } from 'sonner';
import { useSettingsStore, PLAYBACK_SPEEDS } from '@/lib/store/settings';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { useASRAvailable } from '@/lib/hooks/use-asr-available';
import { ProactiveCard } from '@/components/chat/proactive-card';
import { PresentationSpeechOverlay } from '@/components/roundtable/presentation-speech-overlay';
import { AvatarDisplay } from '@/components/ui/avatar-display';
@@ -212,8 +214,10 @@ export function Roundtable({
const { t } = useI18n();
const ttsMuted = useSettingsStore((s) => s.ttsMuted);
const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
const ttsEnabled = useSettingsStore((state) => state.ttsEnabled);
const asrEnabled = useSettingsStore((state) => state.asrEnabled);
// The workspace's tts and asr slots decide whether narration and speech
// input are available.
const ttsEnabled = !!useModelCapabilities().tts;
const asrEnabled = useASRAvailable();
const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
const ttsVolume = useSettingsStore((s) => s.ttsVolume);
const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
@@ -62,8 +62,9 @@ import type {
import { trimmedPBLText } from '@/lib/pbl/v2/readers';
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
import { applyInstructorEvent } from './apply-instructor-event';
import { getCurrentModelConfig, getStageRoutesHeaderValue } from '@/lib/utils/model-config';
import { useSettingsStore } from '@/lib/store/settings';
import { PROVIDERS } from '@/lib/ai/providers';
import { useModelSettingsView } from '@/lib/model-settings/use-model-settings';
import { effectiveTarget } from '@/lib/model-settings/capabilities';
import { normalizeProjectRuntime } from '@/lib/pbl/v2/operations/kernel/progress';
import {
appendRuntimeEvent,
@@ -437,7 +438,6 @@ export function PBLV2SubmissionPanel({
});
let workingProject = structuredClone(snapshot);
try {
const modelConfig = getCurrentModelConfig();
const runStream = async (
endpoint: string,
body: Record<string, unknown>,
@@ -450,15 +450,8 @@ export function PBLV2SubmissionPanel({
streamStatus,
draft,
});
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString || '',
'x-api-key': modelConfig.apiKey || '',
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
const stageRoutesHeader = getStageRoutesHeaderValue();
if (stageRoutesHeader) headers['x-model-routes'] = stageRoutesHeader;
// The server runs the workspace's classroom model.
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
try {
const stored = localStorage.getItem('locale');
if (stored) headers['x-user-locale'] = stored;
@@ -924,12 +917,17 @@ function SubmissionModal({
onSubmit,
}: SubmissionModalProps) {
const { t } = useI18n();
// Whether the currently-selected model can read images. Reactive so that
// Whether the model the evaluation runs on (the workspace's classroom slot)
// can read images, as far as the model catalogue says. Reactive so that
// switching models (in Settings) updates the image-caption gating live.
const hasVision = useSettingsStore((s) => {
const model = findModelById(s.providerId, s.providersConfig[s.providerId]?.models, s.modelId);
return !!model?.capabilities?.vision;
});
const classroomModel = effectiveTarget(useModelSettingsView(), 'classroom');
const hasVision =
!!classroomModel?.modelId &&
!!findModelById(
classroomModel.registryId,
PROVIDERS[classroomModel.registryId as keyof typeof PROVIDERS]?.models,
classroomModel.modelId,
)?.capabilities?.vision;
const [mode, setMode] = useState<'paste' | 'file'>('paste');
const [text, setText] = useState('');
const [filename, setFilename] = useState('');
@@ -47,7 +47,6 @@ import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
import { trackSubmissionScore } from '@/lib/pbl/v2/operations/runtime/dynamic-signals';
import { normalizeProjectRuntime } from '@/lib/pbl/v2/operations/kernel/progress';
import { getCurrentModelConfig, getStageRoutesHeaderValue } from '@/lib/utils/model-config';
import { createLogger } from '@/lib/logger';
import { applyInstructorEvent } from './apply-instructor-event';
@@ -289,16 +288,8 @@ export async function runOneStream(args: OneStreamArgs): Promise<PBLProjectV2> {
const { endpoint, body, startingProject, setDraftAssistant, onPatch } = args;
let workingProject = startingProject;
const modelConfig = getCurrentModelConfig();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
const stageRoutesHeader = getStageRoutesHeaderValue();
if (stageRoutesHeader) headers['x-model-routes'] = stageRoutesHeader;
// The server runs the workspace's classroom model.
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// PBL Planner already reads `x-user-locale` from this header for
// generation-time language lock; the evaluator route does NOT
// need it (the project already carries `language`) but forwarding
+2 -11
View File
@@ -16,7 +16,6 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { getCurrentModelConfig, getStageRoutesHeaderValue } from '@/lib/utils/model-config';
import { createLogger } from '@/lib/logger';
const log = createLogger('QuizView');
@@ -98,16 +97,8 @@ async function gradeShortAnswerQuestion(
): Promise<QuestionResult> {
const pts = q.points ?? 1;
try {
const modelConfig = getCurrentModelConfig();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
const stageRoutesHeader = getStageRoutesHeaderValue();
if (stageRoutesHeader) headers['x-model-routes'] = stageRoutesHeader;
// The server grades with the workspace's classroom model.
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const res = await fetch('/api/quiz-grade', {
method: 'POST',
-48
View File
@@ -1,48 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useSettingsStore } from '@/lib/store/settings';
/**
* Fetches server-configured providers on mount and merges into settings store,
* then reconciles token-plan model seeds: enabled plans whose preset data
* changed since they were applied get the new model defaults re-seeded
* (credentials untouched). Renders nothing — purely a side-effect component.
*/
export function ServerProvidersInit() {
const fetchServerProviders = useSettingsStore((state) => state.fetchServerProviders);
const reconcileTokenPlanSeeds = useSettingsStore((state) => state.reconcileTokenPlanSeeds);
const applyCatalogThinkingMetadata = useSettingsStore(
(state) => state.applyCatalogThinkingMetadata,
);
useEffect(() => {
const persist = useSettingsStore.persist;
const run = () => {
fetchServerProviders();
reconcileTokenPlanSeeds();
applyCatalogThinkingMetadata();
};
// The KV storage hydrates asynchronously (a network round-trip on
// server-backed deployments). Running the reconcile before hydration
// finishes would read the default state — a silent no-op — and the
// thinking backfill's writes would be refused by the unhydrated store
// and discarded when the persisted value lands. Gate on real hydration.
if (persist.hasHydrated()) {
run();
return;
}
let done = false;
const unsubscribe = persist.onFinishHydration(() => {
if (done) return;
done = true;
run();
});
return () => {
done = true;
unsubscribe();
};
}, [fetchServerProviders, reconcileTokenPlanSeeds, applyCatalogThinkingMetadata]);
return null;
}
@@ -1,141 +0,0 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
export interface NewAudioProviderData {
name: string;
baseUrl: string;
defaultModel: string;
requiresApiKey: boolean;
}
interface AddAudioProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (data: NewAudioProviderData) => void;
type: 'tts' | 'asr';
}
export function AddAudioProviderDialog({
open,
onOpenChange,
onAdd,
type,
}: AddAudioProviderDialogProps) {
const { t } = useI18n();
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModel, setDefaultModel] = useState('');
const [requiresApiKey, setRequiresApiKey] = useState(false);
// Reset form when dialog closes
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (!open) {
setName('');
setBaseUrl('');
setDefaultModel('');
setRequiresApiKey(false);
}
}
const handleAdd = () => {
if (!name.trim() || !baseUrl.trim()) return;
onAdd({
name: name.trim(),
baseUrl: baseUrl.trim(),
defaultModel: defaultModel.trim(),
requiresApiKey,
});
onOpenChange(false);
};
const titleKey =
type === 'tts' ? 'settings.addCustomTTSProvider' : 'settings.addCustomASRProvider';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogTitle className="sr-only">{t(titleKey)}</DialogTitle>
<DialogDescription className="sr-only">
{t('settings.addCustomAudioProviderDescription')}
</DialogDescription>
<div className="space-y-4">
<div className="pb-3 border-b">
<h2 className="text-lg font-semibold">{t(titleKey)}</h2>
<p className="text-xs text-muted-foreground mt-1">
{t('settings.addCustomAudioProviderDescription')}
</p>
</div>
<div className="space-y-2">
<Label>{t('settings.providerName')}</Label>
<Input
placeholder={type === 'tts' ? 'My Local TTS' : 'My Local ASR'}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t('settings.defaultBaseUrl')}</Label>
<Input
type="url"
placeholder="http://localhost:8000/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
{/* Default Model — TTS only (ASR models are managed in provider settings) */}
{type === 'tts' && (
<div className="space-y-2">
<Label>{t('settings.defaultModel')}</Label>
<Input
placeholder="tts-1"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('settings.defaultModelHint')}</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="audio-requires-api-key"
checked={requiresApiKey}
onCheckedChange={(checked) => setRequiresApiKey(checked as boolean)}
/>
<label htmlFor="audio-requires-api-key" className="text-sm cursor-pointer">
{t('settings.requiresApiKey')}
</label>
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('settings.cancelEdit')}
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!name.trim() || !baseUrl.trim()}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{t('settings.addProviderButton')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+71 -44
View File
@@ -5,35 +5,58 @@ import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/compone
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import type { ApplyChange, ModelSettingsView } from '@/lib/model-settings/client';
import { newProviderId } from '@/lib/model-settings/edit';
import { reportApply } from './server-settings';
export interface NewProviderData {
name: string;
type: 'openai' | 'anthropic' | 'google';
baseUrl: string;
icon: string;
requiresApiKey: boolean;
/** Optional explicit /models URL override (kept for parity; unused by manual form). */
modelsUrl?: string;
/** The API dialects a custom endpoint can speak, and the preset each one is. */
const API_MODE_PRESETS = {
openai: 'openai-compatible',
anthropic: 'anthropic',
google: 'google',
} as const;
type ApiMode = keyof typeof API_MODE_PRESETS;
/** A provider id from a name: lowercase letters, digits and dashes. */
export function providerIdFromName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 59);
}
interface AddProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (provider: NewProviderData) => void;
view: ModelSettingsView;
apply: ApplyChange;
/** Called with the new provider's id once it is saved. */
onAdded: (providerId: string) => void;
}
export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDialogProps) {
/**
* Add a language model service at an endpoint of the workspace's own: an
* OpenAI-, Anthropic- or Google-compatible API. Its name becomes the
* provider's id; its key and models are set in its panel afterwards.
*/
export function AddProviderDialog({
open,
onOpenChange,
view,
apply,
onAdded,
}: AddProviderDialogProps) {
const { t } = useI18n();
const [name, setName] = useState('');
const [type, setType] = useState<'openai' | 'anthropic' | 'google'>('openai');
const [type, setType] = useState<ApiMode>('openai');
const [baseUrl, setBaseUrl] = useState('');
const [icon, setIcon] = useState('');
const [requiresApiKey, setRequiresApiKey] = useState(true);
const [saving, setSaving] = useState(false);
// Reset form when dialog closes (derived state pattern)
const [prevOpen, setPrevOpen] = useState(open);
@@ -43,13 +66,24 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
setName('');
setType('openai');
setBaseUrl('');
setIcon('');
setRequiresApiKey(true);
}
}
const handleClose = () => onOpenChange(false);
const handleAdd = () => onAdd({ name, type, baseUrl, icon, requiresApiKey });
const preset = API_MODE_PRESETS[type];
const available = view.presets.some((entry) => entry.id === preset);
const slug = providerIdFromName(name);
const handleAdd = async () => {
if (!slug || !baseUrl.trim()) return;
const id = newProviderId(view, slug);
setSaving(true);
try {
const result = await apply({ kind: 'provider', id, preset, baseUrl: baseUrl.trim() }, view);
if (reportApply(result, t)) onAdded(id);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -71,13 +105,18 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
value={name}
onChange={(e) => setName(e.target.value)}
/>
{slug && (
<p className="text-xs text-muted-foreground">
{t('settings.serverConfig.providerIdHint', { id: slug })}
</p>
)}
</div>
{/* API Mode */}
<div className="space-y-2">
<Label>{t('settings.providerApiMode')}</Label>
<div className="grid grid-cols-3 gap-2">
{(['openai', 'anthropic', 'google'] as const).map((mode) => (
{(Object.keys(API_MODE_PRESETS) as ApiMode[]).map((mode) => (
<button
key={mode}
onClick={() => setType(mode)}
@@ -100,7 +139,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
</div>
</div>
{/* Default Base URL */}
{/* Base URL */}
<div className="space-y-2">
<Label>{t('settings.defaultBaseUrl')}</Label>
<Input
@@ -111,35 +150,23 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
/>
</div>
{/* Icon URL */}
<div className="space-y-2">
<Label>{t('settings.providerIcon')}</Label>
<Input
type="url"
placeholder="https://example.com/icon.svg"
value={icon}
onChange={(e) => setIcon(e.target.value)}
/>
</div>
{/* Requires API Key */}
<div className="flex items-center space-x-2">
<Checkbox
id="requires-api-key"
checked={requiresApiKey}
onCheckedChange={(checked) => setRequiresApiKey(checked as boolean)}
/>
<label htmlFor="requires-api-key" className="text-sm cursor-pointer">
{t('settings.requiresApiKey')}
</label>
</div>
{!available && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t('settings.serverConfig.serverOnlyPolicy')}
</p>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-3 border-t">
<Button variant="outline" size="sm" onClick={handleClose}>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('settings.cancelEdit')}
</Button>
<Button size="sm" onClick={handleAdd} className="gap-1.5">
<Button
size="sm"
onClick={() => void handleAdd()}
disabled={!slug || !baseUrl.trim() || !available || saving}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{t('settings.addProviderButton')}
</Button>
+222 -470
View File
@@ -4,16 +4,6 @@ import { useState, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectContent,
@@ -22,42 +12,60 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { getValidASRLanguage, useSettingsStore } from '@/lib/store/settings';
import { ASR_PROVIDERS } from '@/lib/audio/constants';
import { regionalEndpointTemplate } from '@/lib/config/official-endpoints';
import type { ASRProviderId } from '@/lib/audio/types';
import { isCustomASRProvider } from '@/lib/audio/types';
import { Mic, MicOff, CheckCircle2, XCircle, Eye, EyeOff, Plus, Loader2 } from 'lucide-react';
import { Mic, MicOff, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { createLogger } from '@/lib/logger';
import { normalizeASRUploadAudio } from '@/lib/audio/wav-utils';
import { getASRServerDisabledError } from '@/lib/audio/asr-enablement';
import { modelChange, modelRef } from '@/lib/model-settings/edit';
import {
ApiKeyField,
EndpointServerOnlyHint,
RegionalEndpointField,
ServerConfiguredNotice,
ServerOnlyNotice,
reportApply,
rootUse,
saveServiceProvider,
type ServicePanelProps,
} from './server-settings';
const log = createLogger('ASRSettings');
interface ASRSettingsProps {
selectedProviderId: ASRProviderId;
/** Language names for the recognition language picker, in the UI language (codes as a fallback). */
function languageName(code: string, locale: string): string {
if (code === 'auto') return code;
try {
return new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code;
} catch {
return code;
}
}
export function ASRSettings({ selectedProviderId }: ASRSettingsProps) {
const { t } = useI18n();
/**
* A speech recognition service: its key (write-only), a recording test on the
* server with the saved configuration, its model (the workspace's while it is
* in use), and the language the user's speech input listens for.
*/
export function ASRSettings({ view, apply, entry }: ServicePanelProps) {
const { t, locale } = useI18n();
const asrLanguage = useSettingsStore((state) => state.asrLanguage);
const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig);
const setASRProviderConfig = useSettingsStore((state) => state.setASRProviderConfig);
const removeCustomASRProvider = useSettingsStore((state) => state.removeCustomASRProvider);
const setASRLanguage = useSettingsStore((state) => state.setASRLanguage);
const asrProvider = ASR_PROVIDERS[selectedProviderId as keyof typeof ASR_PROVIDERS];
const isCustom = isCustomASRProvider(selectedProviderId);
const providerConfig = asrProvidersConfig[selectedProviderId];
const isServerConfigured = !!providerConfig?.isServerConfigured;
const requiresApiKey = isCustom
? !!providerConfig?.requiresApiKey
: !!asrProvider?.requiresApiKey;
const isKeylessLocalProvider = !isCustom && !requiresApiKey && !!asrProvider?.defaultBaseUrl;
const providerId = entry.registryId as ASRProviderId;
const asrProvider = ASR_PROVIDERS[providerId as keyof typeof ASR_PROVIDERS];
const regionalEndpoint = regionalEndpointTemplate('asr', providerId);
const isBrowser = providerId === 'browser-native';
const editable = (entry.state === 'workspace' || entry.state === 'available') && !isBrowser;
const use = rootUse(view, 'asr', entry.id);
const languages = asrProvider?.supportedLanguages ?? [];
const effectiveLanguage = getValidASRLanguage(providerId, asrLanguage);
const modelId = use.modelId || asrProvider?.defaultModelId;
const [showApiKey, setShowApiKey] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [asrResult, setASRResult] = useState('');
@@ -65,15 +73,7 @@ export function ASRSettings({ selectedProviderId }: ASRSettingsProps) {
const [testMessage, setTestMessage] = useState('');
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
// Reset state when provider changes (derived state pattern)
const [prevProviderId, setPrevProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevProviderId) {
setPrevProviderId(selectedProviderId);
setShowApiKey(false);
setTestStatus('idle');
setTestMessage('');
setASRResult('');
}
const canTest = isBrowser || !!entry.provider;
const handleToggleASRRecording = async () => {
if (isRecording) {
@@ -81,274 +81,180 @@ export function ASRSettings({ selectedProviderId }: ASRSettingsProps) {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
} else {
setASRResult('');
setTestStatus('testing');
setTestMessage('');
return;
}
setASRResult('');
setTestStatus('testing');
setTestMessage('');
// Browser-native tests execute wholly in the client and therefore need
// the same server force-off guard as the normal recorder path.
const serverDisabledError = getASRServerDisabledError(providerConfig);
if (serverDisabledError) {
if (isBrowser) {
const SpeechRecognitionCtor =
(window as unknown as Record<string, unknown>).SpeechRecognition ||
(window as unknown as Record<string, unknown>).webkitSpeechRecognition;
if (!SpeechRecognitionCtor) {
setTestStatus('error');
setTestMessage(serverDisabledError);
setTestMessage(t('settings.asrNotSupported'));
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vendor-prefixed API without standard typings
const recognition = new (SpeechRecognitionCtor as new () => any)();
recognition.lang = effectiveLanguage || 'zh-CN';
recognition.onresult = (event: {
results: { [index: number]: { [index: number]: { transcript: string } } };
}) => {
setASRResult(event.results[0][0].transcript);
setTestStatus('success');
setTestMessage(t('settings.asrTestSuccess'));
};
recognition.onerror = (event: { error: string }) => {
setTestStatus('error');
setTestMessage(t('settings.asrTestFailed') + ': ' + event.error);
};
recognition.onend = () => {
setIsRecording(false);
};
recognition.start();
setIsRecording(true);
return;
}
if (selectedProviderId === 'browser-native') {
const SpeechRecognitionCtor =
(window as unknown as Record<string, unknown>).SpeechRecognition ||
(window as unknown as Record<string, unknown>).webkitSpeechRecognition;
if (!SpeechRecognitionCtor) {
setTestStatus('error');
setTestMessage(t('settings.asrNotSupported'));
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vendor-prefixed API without standard typings
const recognition = new (SpeechRecognitionCtor as new () => any)();
recognition.lang = asrLanguage || 'zh-CN';
recognition.onresult = (event: {
results: {
[index: number]: { [index: number]: { transcript: string } };
};
}) => {
const transcript = event.results[0][0].transcript;
setASRResult(transcript);
setTestStatus('success');
setTestMessage(t('settings.asrTestSuccess'));
};
recognition.onerror = (event: { error: string }) => {
setTestStatus('error');
setTestMessage(t('settings.asrTestFailed') + ': ' + event.error);
};
recognition.onend = () => {
setIsRecording(false);
};
recognition.start();
setIsRecording(true);
} else {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
const audioChunks: Blob[] = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((track) => track.stop());
setIsProcessing(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
const audioChunks: Blob[] = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((track) => track.stop());
setIsProcessing(true);
try {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const uploadAudio = await normalizeASRUploadAudio(selectedProviderId, audioBlob);
const formData = new FormData();
formData.append('audio', uploadAudio.blob, uploadAudio.fileName);
formData.append('providerId', selectedProviderId);
formData.append(
'modelId',
asrProvidersConfig[selectedProviderId]?.modelId ||
asrProvider?.defaultModelId ||
'',
);
formData.append('language', asrLanguage);
const apiKeyValue = asrProvidersConfig[selectedProviderId]?.apiKey;
if (apiKeyValue?.trim()) formData.append('apiKey', apiKeyValue);
const baseUrlValue =
asrProvidersConfig[selectedProviderId]?.baseUrl ||
providerConfig?.customDefaultBaseUrl ||
'';
if (baseUrlValue?.trim()) formData.append('baseUrl', baseUrlValue);
const response = await fetch('/api/transcription', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
if (data.text?.trim()) {
setASRResult(data.text);
setTestStatus('success');
setTestMessage(t('settings.asrTestSuccess'));
} else {
setTestStatus('error');
setTestMessage(data.error || t('settings.asrNoTranscription'));
}
} else {
setTestStatus('error');
const errorData = await response
.json()
.catch(() => ({ error: response.statusText }));
setTestMessage(errorData.details || errorData.error || t('settings.asrTestFailed'));
}
} catch (error) {
log.error('ASR test failed:', error);
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const uploadAudio = await normalizeASRUploadAudio(providerId, audioBlob);
const formData = new FormData();
formData.append('audio', uploadAudio.blob, uploadAudio.fileName);
// The saved service is tested with the server's configuration of it.
formData.append('previewProvider', entry.id);
if (use.inUse && use.modelId) formData.append('previewModel', use.modelId);
formData.append('language', effectiveLanguage);
const response = await fetch('/api/transcription', { method: 'POST', body: formData });
if (response.ok) {
const data = await response.json();
if (data.text?.trim()) {
setASRResult(data.text);
setTestStatus('success');
setTestMessage(t('settings.asrTestSuccess'));
} else {
setTestStatus('error');
setTestMessage(
error instanceof Error && error.message
? `${t('settings.asrTestFailed')}: ${error.message}`
: t('settings.asrTestFailed'),
);
} finally {
setIsProcessing(false);
setTestMessage(data.error || t('settings.asrNoTranscription'));
}
};
mediaRecorder.start();
setIsRecording(true);
} else {
setTestStatus('error');
const errorData = await response.json().catch(() => ({ error: response.statusText }));
setTestMessage(errorData.details || errorData.error || t('settings.asrTestFailed'));
}
} catch (error) {
log.error('Failed to access microphone:', error);
log.error('ASR test failed:', error);
setTestStatus('error');
setTestMessage(t('settings.microphoneAccessFailed'));
setTestMessage(
error instanceof Error && error.message
? `${t('settings.asrTestFailed')}: ${error.message}`
: t('settings.asrTestFailed'),
);
} finally {
setIsProcessing(false);
}
}
};
mediaRecorder.start();
setIsRecording(true);
} catch (error) {
log.error('Failed to access microphone:', error);
setTestStatus('error');
setTestMessage(t('settings.microphoneAccessFailed'));
}
};
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
{entry.state === 'deployment' && <ServerConfiguredNotice />}
{entry.state === 'server-only' && (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
)}
{/* No models warning for custom providers */}
{isCustom && ((providerConfig?.customModels as Array<{ id: string }>) || []).length === 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.noModelsWarning')}
</div>
)}
{/* API Key & Base URL — hidden for managed providers, which are admin-owned
and not overridable from the client. */}
{!isServerConfigured && (requiresApiKey || isCustom || isKeylessLocalProvider) && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.asrApiKey')}</Label>
<div className="relative">
<Input
name={`asr-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={t('settings.enterApiKey')}
value={asrProvidersConfig[selectedProviderId]?.apiKey || ''}
onChange={(e) =>
setASRProviderConfig(selectedProviderId, {
apiKey: e.target.value,
})
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm">{t('settings.asrBaseUrl')}</Label>
<Input
name={`asr-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
isCustom
? providerConfig?.customDefaultBaseUrl || 'http://localhost:8000/v1'
: asrProvider?.defaultBaseUrl || t('settings.enterCustomBaseUrl')
}
value={asrProvidersConfig[selectedProviderId]?.baseUrl || ''}
onChange={(e) =>
setASRProviderConfig(selectedProviderId, {
baseUrl: e.target.value,
})
}
className="text-sm"
/>
</div>
{/* API Key & Base URL — the server's services are the operator's. */}
{editable && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.asrApiKey')}</Label>
<ApiKeyField
name={`asr-api-key-${entry.id}`}
provider={entry.provider}
inputClassName="font-mono text-sm"
onSave={(apiKey) => saveServiceProvider(view, apply, entry, { apiKey }, t)}
onRemove={() => saveServiceProvider(view, apply, entry, { apiKey: '' }, t)}
/>
</div>
{/* Request URL Preview */}
{(() => {
const effectiveBaseUrl =
asrProvidersConfig[selectedProviderId]?.baseUrl ||
(isCustom ? providerConfig?.customDefaultBaseUrl : asrProvider?.defaultBaseUrl) ||
'';
if (!effectiveBaseUrl) return null;
let endpointPath = '';
if (isCustom) {
endpointPath = '/audio/transcriptions';
} else {
switch (selectedProviderId) {
case 'openai-whisper':
case 'funasr-asr':
case 'lemonade-asr':
endpointPath = '/audio/transcriptions';
break;
case 'qwen-asr':
endpointPath = '/services/aigc/multimodal-generation/generation';
break;
}
}
if (!endpointPath) return null;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl + endpointPath}
</p>
);
})()}
</>
<div className="space-y-2">
<Label className="text-sm">{t('settings.asrBaseUrl')}</Label>
{regionalEndpoint ? (
// Azure Speech takes its official endpoint for the key's region.
<RegionalEndpointField
name={`asr-endpoint-${entry.id}`}
provider={entry.provider}
template={regionalEndpoint}
onSave={(baseUrl) => saveServiceProvider(view, apply, entry, { baseUrl }, t)}
/>
) : (
<>
{asrProvider?.defaultBaseUrl && (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {asrProvider.defaultBaseUrl}
</p>
)}
<EndpointServerOnlyHint />
</>
)}
</div>
</div>
)}
{/* Test ASR */}
<div className="space-y-2">
<Label className="text-sm">{t('settings.testASR')}</Label>
<div className="flex gap-2">
<Input
value={asrResult}
readOnly
placeholder={t('settings.asrResultPlaceholder')}
className="flex-1 bg-muted/50"
/>
<Button
onClick={handleToggleASRRecording}
disabled={
isProcessing ||
(requiresApiKey &&
!asrProvidersConfig[selectedProviderId]?.apiKey?.trim() &&
!isServerConfigured)
}
className="gap-2 w-[140px]"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('settings.asrProcessing')}
</>
) : isRecording ? (
<>
<MicOff className="h-4 w-4" />
{t('settings.stopRecording')}
</>
) : (
<>
<Mic className="h-4 w-4" />
{t('settings.startRecording')}
</>
)}
</Button>
{entry.state !== 'server-only' && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.testASR')}</Label>
<div className="flex gap-2">
<Input
value={asrResult}
readOnly
placeholder={t('settings.asrResultPlaceholder')}
className="flex-1 bg-muted/50"
/>
<Button
onClick={() => void handleToggleASRRecording()}
disabled={isProcessing || !canTest}
className="gap-2 w-[140px]"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('settings.asrProcessing')}
</>
) : isRecording ? (
<>
<MicOff className="h-4 w-4" />
{t('settings.stopRecording')}
</>
) : (
<>
<Mic className="h-4 w-4" />
{t('settings.startRecording')}
</>
)}
</Button>
</div>
</div>
</div>
)}
{testMessage && (
<div
@@ -368,209 +274,55 @@ export function ASRSettings({ selectedProviderId }: ASRSettingsProps) {
</div>
)}
{/* Model Selection — built-in providers */}
{!isCustom && asrProvider?.models?.length > 0 && (
{/* Model: the workspace's speech recognition model while this service is in use. */}
{asrProvider?.models?.length > 0 && entry.state !== 'server-only' && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.defaultModel')}</Label>
<Select
value={asrProvidersConfig[selectedProviderId]?.modelId || asrProvider?.defaultModelId}
onValueChange={(value) => setASRProviderConfig(selectedProviderId, { modelId: value })}
value={modelId}
disabled={!use.inUse || !use.slot || use.slot.locked}
onValueChange={async (value) => {
if (!use.slot) return;
reportApply(await apply(modelChange(use.slot, modelRef(entry.id, value)), view), t);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{asrProvider?.models.map((model) => (
{asrProvider.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!use.inUse && (
<p className="text-xs text-muted-foreground">
{t('settings.serverConfig.modelWhenInUse')}
</p>
)}
</div>
)}
{/* Model Management — custom providers */}
{isCustom && (
<div className="space-y-3">
<Label className="text-sm">{t('settings.availableModels')}</Label>
{(() => {
const customModels =
(providerConfig?.customModels as Array<{ id: string; name: string }>) || [];
const activeModelId =
asrProvidersConfig[selectedProviderId]?.modelId || customModels[0]?.id || '';
return (
<>
{customModels.length > 0 ? (
<div className="rounded-lg border border-border/60 overflow-hidden">
<div className="grid grid-cols-[20px_1fr_1fr_36px] gap-0 bg-muted/40 px-3 py-1.5 border-b border-border/40">
<span />
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
ID
</span>
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('settings.modelNamePlaceholder')}
</span>
<span />
</div>
{customModels.map((model, index) => {
const isActive = model.id === activeModelId;
return (
<div
key={model.id}
onClick={() =>
setASRProviderConfig(selectedProviderId, { modelId: model.id })
}
className={cn(
'grid grid-cols-[20px_1fr_1fr_36px] gap-0 items-center px-3 py-2 group cursor-pointer transition-colors',
isActive ? 'bg-primary/5' : 'hover:bg-muted/20',
index > 0 && 'border-t border-border/30',
)}
>
<div className="flex items-center -ml-1">
<div
className={cn(
'size-4 rounded-full border-2 flex items-center justify-center transition-colors',
isActive
? 'border-primary'
: 'border-muted-foreground/30 group-hover:border-muted-foreground/50',
)}
>
{isActive && <div className="size-2 rounded-full bg-primary" />}
</div>
</div>
<span className="text-sm font-mono text-foreground/80 truncate pr-3">
{model.id}
</span>
<span className="text-sm text-foreground/60 truncate pr-3">
{model.name}
</span>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
const models = [...customModels];
models.splice(index, 1);
const newModelId = isActive ? models[0]?.id || '' : activeModelId;
setASRProviderConfig(selectedProviderId, {
customModels: models,
modelId: newModelId,
});
}}
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground/50 italic">
{t('settings.noModelsAdded')}
</p>
)}
<AddModelRow
existingIds={customModels.map((m) => m.id)}
onAdd={(modelId, modelName) => {
const models = [...customModels, { id: modelId, name: modelName }];
setASRProviderConfig(selectedProviderId, {
customModels: models,
modelId: models[0].id,
});
}}
/>
</>
);
})()}
{/* The language the user's speech input listens for (kept per user). */}
{languages.length > 0 && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.asrLanguage')}</Label>
<Select value={effectiveLanguage} onValueChange={setASRLanguage}>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((code) => (
<SelectItem key={code} value={code}>
{languageName(code, locale)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Delete Custom Provider */}
{isCustom && (
<div className="pt-4 border-t">
<Button variant="destructive" size="sm" onClick={() => setShowDeleteConfirm(true)}>
{t('settings.deleteProvider')}
</Button>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={showDeleteConfirm}
onOpenChange={(open) => !open && setShowDeleteConfirm(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.deleteProvider')}</AlertDialogTitle>
<AlertDialogDescription>{t('settings.deleteProviderConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('settings.cancelEdit')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
removeCustomASRProvider(selectedProviderId);
setShowDeleteConfirm(false);
}}
>
{t('settings.deleteProvider')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function AddModelRow({
onAdd,
existingIds,
}: {
onAdd: (id: string, name: string) => void;
existingIds: string[];
}) {
const { t } = useI18n();
const [modelId, setModelId] = useState('');
const [modelName, setModelName] = useState('');
const handleAdd = () => {
if (!modelId.trim()) return;
if (existingIds.includes(modelId.trim())) {
toast.error('Duplicate ID');
return;
}
onAdd(modelId.trim(), modelName.trim() || modelId.trim());
setModelId('');
setModelName('');
};
return (
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
<Input
value={modelId}
onChange={(e) => setModelId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="text-sm font-mono"
placeholder={t('settings.modelIdPlaceholder')}
/>
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="text-sm"
placeholder={t('settings.modelNamePlaceholder')}
/>
<Button
variant="outline"
size="sm"
onClick={handleAdd}
disabled={!modelId.trim()}
className="shrink-0 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t('settings.addModel')}
</Button>
</div>
);
}
File diff suppressed because it is too large. Load diff
+157
View File
@@ -0,0 +1,157 @@
'use client';
// The home toolbar's model picker: it sets the default model (the `llm`
// root), so it says so, and says when some stages use a model of their own
// (set in Course Model Config). When every stage a course is generated with
// has its own model, picking a default would change nothing for the course:
// the pill then summarises the per-stage setup and opens Course Model Config.
import type { ComponentProps } from 'react';
import { Layers } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ModelSettingsView } from '@/lib/model-settings/client';
import { modelName } from '@/lib/model-settings/edit';
import {
courseStagesAllOverridden,
courseStagesPlanName,
defaultModelOverrides,
type OverriddenStage,
} from '@/lib/model-settings/overrides';
import { ModelPicker } from './model-picker';
import { slotName } from './models/slot-meta';
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** One line per stage set separately: its name as the map shows it, and its model (or off). */
export function overrideLines(
view: ModelSettingsView,
overrides: OverriddenStage[],
t: Translate,
): { slot: string; stage: string; model: string }[] {
return overrides.map((entry) => {
// A page type is named with the content station it belongs to.
const stage =
entry.parent && entry.parent !== 'llm'
? `${slotName(t, entry.parent)} · ${slotName(t, entry.slot)}`
: slotName(t, entry.slot);
const model =
entry.target.kind === 'off'
? t('toolbar.off')
: modelName(view, 'chat', entry.target.providerId, entry.target.modelId ?? '') ||
entry.target.providerId;
return { slot: entry.slot, stage, model };
});
}
function OverrideList({
view,
overrides,
heading,
t,
}: {
view: ModelSettingsView;
overrides: OverriddenStage[];
heading: string;
t: Translate;
}) {
return (
<div className="max-w-[280px] space-y-1 py-0.5 text-xs">
<p className="font-medium">{heading}</p>
<ul className="space-y-0.5">
{overrideLines(view, overrides, t).map((line) => (
<li key={line.slot} className="flex items-baseline gap-2">
<span className="shrink-0 opacity-80">{line.stage}</span>
<span className="min-w-0 truncate font-mono">{line.model}</span>
</li>
))}
</ul>
</div>
);
}
export function HomeModelPicker({
view,
onOpenCourseModels,
className,
t,
...picker
}: Omit<ComponentProps<typeof ModelPicker>, 'valuePrefix' | 'note' | 't'> & {
view: ModelSettingsView | null;
/** Open Settings on Course Model Config; without it no hint or summary is offered. */
onOpenCourseModels?: () => void;
t: Translate;
}) {
const overrides = defaultModelOverrides(view);
const hasDefault = !!picker.value;
if (view && hasDefault && onOpenCourseModels && courseStagesAllOverridden(view)) {
const plan = courseStagesPlanName(view);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onOpenCourseModels}
aria-label={t('toolbar.perStageSetup')}
className={cn(
'inline-flex min-w-0 items-center gap-1.5 border border-border/60 bg-background font-medium transition-colors',
'hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
className,
)}
>
<Layers className="size-3.5 shrink-0 text-violet-500" aria-hidden="true" />
<span className="shrink-0">{t('toolbar.perStageSetup')}</span>
{plan && (
<span className="min-w-0 truncate text-muted-foreground">
<span aria-hidden="true">· </span>
{plan}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<OverrideList
view={view}
overrides={overrides}
heading={t('toolbar.perStageSetupHint')}
t={t}
/>
</TooltipContent>
</Tooltip>
);
}
return (
<>
<ModelPicker
{...picker}
className={className}
valuePrefix={hasDefault ? t('toolbar.defaultModel') : undefined}
note={t('toolbar.defaultModelNote')}
t={t}
/>
{view && onOpenCourseModels && overrides.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onOpenCourseModels}
className="inline-flex shrink-0 items-center whitespace-nowrap rounded-full px-1.5 py-1 text-[11px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
{t('toolbar.stagesSetSeparately', { count: overrides.length })}
</button>
</TooltipTrigger>
<TooltipContent>
<OverrideList
view={view}
overrides={overrides}
heading={t('toolbar.stagesSetSeparatelyHint')}
t={t}
/>
</TooltipContent>
</Tooltip>
)}
</>
);
}
+93 -430
View File
@@ -1,72 +1,39 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import { useOpenRouterModels } from '@/lib/media/use-openrouter-models';
import {
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
Zap,
Plus,
Settings2,
Trash2,
RefreshCw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ImageProviderId } from '@/lib/media/types';
interface ImageSettingsProps {
selectedProviderId: ImageProviderId;
}
import { Loader2, CheckCircle2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { modelChange, modelRef } from '@/lib/model-settings/edit';
import { MediaServicePanel } from './media-service-panel';
import { reportApply, rootUse, type ServicePanelProps } from './server-settings';
interface WorkflowEntry {
id: string;
name: string;
}
export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {
/**
* An image generation service (see MediaServicePanel). ComfyUI, which the
* server configures, lists its workflows instead of models; picking one makes
* it the workspace's image model.
*/
export function ImageSettings(props: ServicePanelProps) {
const { view, apply, entry } = props;
const { t } = useI18n();
const imageModelId = useSettingsStore((state) => state.imageModelId);
const imageProvidersConfig = useSettingsStore((state) => state.imageProvidersConfig);
const _setImageModelId = useSettingsStore((state) => state.setImageModelId);
const setImageProvider = useSettingsStore((state) => state.setImageProvider);
const setImageProviderConfig = useSettingsStore((state) => state.setImageProviderConfig);
const [showApiKey, setShowApiKey] = useState(false);
const [testLoading, setTestLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
// Model dialog state
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
const [modelForm, setModelForm] = useState({ id: '', name: '' });
const registry = IMAGE_PROVIDERS[entry.registryId as ImageProviderId];
const isComfyUI = entry.registryId === 'comfyui-image';
const use = rootUse(view, 'image', entry.id);
// ComfyUI workflow list state
const [workflows, setWorkflows] = useState<WorkflowEntry[]>([]);
const [workflowsLoading, setWorkflowsLoading] = useState(false);
const [workflowsError, setWorkflowsError] = useState<string | null>(null);
const isComfyUI = selectedProviderId === 'comfyui-image';
// Reset test state when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
setPrevSelectedProviderId(selectedProviderId);
setTestStatus('idle');
setTestMessage('');
}
// Fetch ComfyUI workflows when the provider is selected
const fetchWorkflows = useCallback(async () => {
setWorkflowsLoading(true);
@@ -84,400 +51,96 @@ export function ImageSettings({ selectedProviderId }: ImageSettingsProps) {
}, [t]);
useEffect(() => {
if (isComfyUI) {
fetchWorkflows();
if (isComfyUI && entry.provider) {
void fetchWorkflows();
}
}, [isComfyUI, fetchWorkflows]);
}, [isComfyUI, entry.provider, fetchWorkflows]);
const currentConfig = imageProvidersConfig[selectedProviderId];
const currentProvider = IMAGE_PROVIDERS[selectedProviderId];
// OpenRouter's catalog is fetched live so the picker is never a curated
// shortlist; every other provider keeps its registry list.
const { models: builtInModels } = useOpenRouterModels(
'image',
selectedProviderId === 'openrouter-image',
currentProvider?.models || [],
currentConfig?.apiKey,
currentConfig?.baseUrl,
);
const customModels = useMemo(
() => currentConfig?.customModels || [],
[currentConfig?.customModels],
);
const isServerConfigured = !!currentConfig?.isServerConfigured;
const requiresApiKey = currentProvider?.requiresApiKey ?? true;
const handleApiKeyChange = (apiKey: string) => {
setImageProviderConfig(selectedProviderId, {
apiKey,
...(apiKey.trim() ? { enabled: true } : {}),
});
// Picking a workflow makes ComfyUI (with it) the workspace's image model.
const pickWorkflow = async (workflowId: string) => {
if (!use.slot || use.slot.locked || !entry.provider) return;
reportApply(await apply(modelChange(use.slot, modelRef(entry.id, workflowId)), view), t);
};
const handleBaseUrlChange = (baseUrl: string) => {
setImageProviderConfig(selectedProviderId, {
baseUrl,
...(baseUrl.trim() ? { enabled: true } : {}),
});
};
const workflowList = isComfyUI && entry.provider && (
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label className="text-base">{t('settings.comfyuiWorkflows')}</Label>
<Button
variant="outline"
size="sm"
onClick={() => void fetchWorkflows()}
disabled={workflowsLoading}
className="gap-1.5"
>
{workflowsLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t('settings.comfyuiRefresh')}
</Button>
</div>
const handleTest = async () => {
setTestLoading(true);
setTestStatus('idle');
setTestMessage('');
try {
const response = await fetch('/api/verify-image-provider', {
method: 'POST',
headers: {
'x-image-provider': selectedProviderId,
'x-image-model': imageModelId || '',
'x-api-key': currentConfig?.apiKey || '',
'x-base-url': currentConfig?.baseUrl || '',
},
});
const data = await response.json();
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.imageConnectivitySuccess'));
} else {
setTestStatus('error');
setTestMessage(`${t('settings.imageConnectivityFailed')}: ${data.error}`);
}
} catch (err) {
setTestStatus('error');
setTestMessage(`${t('settings.imageConnectivityFailed')}: ${err}`);
} finally {
setTestLoading(false);
}
};
// Model CRUD
const handleOpenAddModel = () => {
setEditingModelIndex(null);
setModelForm({ id: '', name: '' });
setShowModelDialog(true);
};
const handleOpenEditModel = (index: number) => {
setEditingModelIndex(index);
setModelForm({ ...customModels[index] });
setShowModelDialog(true);
};
const handleSaveModel = useCallback(() => {
if (!modelForm.id.trim()) return;
const newCustomModels = [...customModels];
if (editingModelIndex !== null) {
newCustomModels[editingModelIndex] = {
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
};
} else {
newCustomModels.push({
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
});
}
setImageProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
setShowModelDialog(false);
}, [modelForm, editingModelIndex, customModels, selectedProviderId, setImageProviderConfig]);
const handleDeleteModel = (index: number) => {
const newCustomModels = customModels.filter((_, i) => i !== index);
setImageProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
};
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
{workflowsError && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300">
{workflowsError}
</div>
)}
{/* Managed providers are admin-owned: the operator's key and base URL
are authoritative and not overridable here, so the editing inputs
are hidden (server ignores client-sent overrides for these). */}
{!isServerConfigured && (
<>
{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`image-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={t('settings.enterApiKey')}
value={currentConfig?.apiKey || ''}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="h-8 pr-8"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testLoading || (requiresApiKey && !currentConfig?.apiKey)}
className="gap-1.5"
>
{testLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
{testMessage && (
<div
className={cn(
'rounded-lg p-3 text-sm overflow-hidden',
testStatus === 'success' &&
'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
testStatus === 'error' &&
'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
)}
>
<div className="flex items-start gap-2 min-w-0">
{testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
{testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
<p className="flex-1 min-w-0 break-all">{testMessage}</p>
</div>
</div>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label>Base URL</Label>
<Input
name={`image-base-url-${selectedProviderId}`}
type="url"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={currentConfig?.baseUrl || ''}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder={
currentConfig?.baseUrl ||
currentProvider?.defaultBaseUrl ||
t('settings.enterCustomBaseUrl')
}
className="h-8"
/>
{(() => {
const effectiveBaseUrl =
currentConfig?.baseUrl || currentProvider?.defaultBaseUrl || '';
if (!effectiveBaseUrl) return null;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl}
</p>
);
})()}
</div>
</>
{!workflowsLoading && !workflowsError && workflows.length === 0 && (
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 text-sm text-muted-foreground text-center">
{t('settings.comfyuiNoWorkflowsFoundPrefix')}{' '}
<code className="font-mono text-xs">public/</code>.
<br />
{t('settings.comfyuiAddWorkflowPrefix')}{' '}
<code className="font-mono text-xs">comfyui-*.json</code>{' '}
{t('settings.comfyuiAddWorkflowSuffix')}
</div>
)}
{/* ── ComfyUI: Workflow list ── */}
{isComfyUI ? (
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label className="text-base">{t('settings.comfyuiWorkflows')}</Label>
<Button
variant="outline"
size="sm"
onClick={fetchWorkflows}
disabled={workflowsLoading}
className="gap-1.5"
>
{workflowsLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
<div className="space-y-1.5">
{workflows.map((workflow) => {
const selected = use.inUse && use.modelId === workflow.id;
return (
<button
key={workflow.id}
type="button"
disabled={use.slot?.locked}
className={cn(
'flex w-full items-center justify-between p-3 rounded-lg border text-left transition-colors disabled:cursor-not-allowed',
selected
? 'border-primary bg-primary/5'
: 'border-border/50 bg-card hover:border-border hover:bg-accent/30',
)}
{t('settings.comfyuiRefresh')}
</Button>
</div>
{workflowsError && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300">
{workflowsError}
</div>
)}
{!workflowsLoading && !workflowsError && workflows.length === 0 && (
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 text-sm text-muted-foreground text-center">
{t('settings.comfyuiNoWorkflowsFoundPrefix')}{' '}
<code className="font-mono text-xs">public/</code>.
<br />
{t('settings.comfyuiAddWorkflowPrefix')}{' '}
<code className="font-mono text-xs">comfyui-*.json</code>{' '}
{t('settings.comfyuiAddWorkflowSuffix')}
</div>
)}
<div className="space-y-1.5">
{workflows.map((workflow) => (
<div
key={workflow.id}
className={cn(
'flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors',
imageModelId === workflow.id
? 'border-primary bg-primary/5'
: 'border-border/50 bg-card hover:border-border hover:bg-accent/30',
)}
onClick={() => {
// Selecting a workflow here must also make ComfyUI the
// active image provider — otherwise the workflow filename
// gets written into imageModelId while a different
// provider (e.g. Seedream) stays active, and that
// provider's next generation call sends this filename as
// its model id. Mirrors the atomic setImageProvider +
// setImageModelId pairing used in media-popover.tsx.
setImageProvider(selectedProviderId);
_setImageModelId(workflow.id);
}}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{workflow.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">
{workflow.id}
</div>
</div>
{imageModelId === workflow.id && (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0 ml-2" />
)}
onClick={() => void pickWorkflow(workflow.id)}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{workflow.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{workflow.id}</div>
</div>
))}
</div>
{selected && <CheckCircle2 className="h-4 w-4 text-primary shrink-0 ml-2" />}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t('settings.comfyuiFolderHintPrefix')} <code className="font-mono">public/</code>{' '}
{t('settings.comfyuiFolderHintMiddle')}{' '}
<code className="font-mono">comfyui-anime-style.json</code> → &quot;Anime Style&quot;.
</p>
</div>
) : (
/* ── All other providers: standard model list ── */
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label className="text-base">{t('settings.models')}</Label>
<Button variant="outline" size="sm" onClick={handleOpenAddModel} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
{t('settings.addNewModel')}
</Button>
</div>
<div className="space-y-1.5">
{/* Built-in models */}
{builtInModels.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
</div>
))}
{/* Custom models */}
{customModels.map((model, index) => (
<div
key={`custom-${index}`}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => handleOpenEditModel(index)}
title={t('settings.editModel')}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteModel(index)}
title={t('settings.deleteModel')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit Model Dialog — only used for non-ComfyUI providers */}
<Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
<DialogContent className="sm:max-w-md">
<DialogTitle>
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogTitle>
<DialogDescription className="sr-only">
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogDescription>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>{t('settings.modelId')}</Label>
<Input
value={modelForm.id}
onChange={(e) => setModelForm((prev) => ({ ...prev, id: e.target.value }))}
placeholder="e.g. my-custom-model-v1"
className="h-8 font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t('settings.modelName')}</Label>
<Input
value={modelForm.name}
onChange={(e) => setModelForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. My Custom Model"
className="h-8 text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setShowModelDialog(false)}>
{t('settings.cancelEdit')}
</Button>
<Button size="sm" onClick={handleSaveModel} disabled={!modelForm.id.trim()}>
{t('settings.saveModel')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<p className="text-xs text-muted-foreground">
{t('settings.comfyuiFolderHintPrefix')} <code className="font-mono">public/</code>{' '}
{t('settings.comfyuiFolderHintMiddle')}{' '}
<code className="font-mono">comfyui-anime-style.json</code> → &quot;Anime Style&quot;.
</p>
</div>
);
return (
<MediaServicePanel
{...props}
kind="image"
defaultBaseUrl={registry?.defaultBaseUrl}
catalogue={registry?.models ?? []}
>
{workflowList || undefined}
</MediaServicePanel>
);
}
File diff suppressed because it is too large. Load diff
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useState, type ReactNode } from 'react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Loader2, CheckCircle2, XCircle, Zap } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { CatalogueModel } from '@/lib/config/provider-presets';
import { cn } from '@/lib/utils';
import {
ApiKeyField,
EndpointServerOnlyHint,
ServerConfiguredNotice,
ServerOnlyNotice,
saveServiceProvider,
type ServicePanelProps,
} from './server-settings';
/** Test a saved image or video service on the server, with its stored key. */
async function verifySavedMedia(
kind: 'image' | 'video',
providerId: string,
modelId?: string,
): Promise<{ success: boolean; error?: string }> {
const response = await fetch(`/api/verify-${kind}-provider`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId, ...(modelId ? { model: modelId } : {}) }),
});
return (await response.json()) as { success: boolean; error?: string };
}
/**
* An image or video service: its key (write-only) with a connection test, the
* endpoint it calls, and the models it offers. A service the server
* configures is shown read-only, and one only the server can configure says so.
*/
export function MediaServicePanel({
view,
apply,
entry,
kind,
defaultBaseUrl,
catalogue,
children,
}: ServicePanelProps & {
kind: 'image' | 'video';
/** The registry's endpoint, shown as the request URL. */
defaultBaseUrl?: string;
/** The registry's models, for a service no provider of which is saved yet. */
catalogue: readonly CatalogueModel[];
/** What replaces the model list (ComfyUI's workflows). */
children?: ReactNode;
}) {
const { t } = useI18n();
const provider = entry.provider;
const editable = entry.state === 'workspace' || entry.state === 'available';
const [testLoading, setTestLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const models =
provider?.capabilities[kind]?.models ?? entry.preset?.capabilities[kind]?.models ?? catalogue;
const success = t(
kind === 'image' ? 'settings.imageConnectivitySuccess' : 'settings.videoConnectivitySuccess',
);
const failed = t(
kind === 'image' ? 'settings.imageConnectivityFailed' : 'settings.videoConnectivityFailed',
);
const handleTest = async () => {
setTestLoading(true);
setTestStatus('idle');
setTestMessage('');
try {
const data = await verifySavedMedia(kind, entry.id);
if (data.success) {
setTestStatus('success');
setTestMessage(success);
} else {
setTestStatus('error');
setTestMessage(`${failed}: ${data.error}`);
}
} catch (err) {
setTestStatus('error');
setTestMessage(`${failed}: ${err}`);
} finally {
setTestLoading(false);
}
};
return (
<div className="space-y-6 max-w-3xl">
{entry.state === 'deployment' && <ServerConfiguredNotice />}
{entry.state === 'server-only' && (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
)}
{editable && (
<>
{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<ApiKeyField
name={`${kind}-api-key-${entry.id}`}
provider={provider}
onSave={(apiKey) => saveServiceProvider(view, apply, entry, { apiKey }, t)}
onRemove={() => saveServiceProvider(view, apply, entry, { apiKey: '' }, t)}
>
<Button
variant="outline"
size="sm"
onClick={() => void handleTest()}
disabled={testLoading || !provider}
className="gap-1.5"
>
{testLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</ApiKeyField>
{testMessage && (
<div
className={cn(
'rounded-lg p-3 text-sm overflow-hidden',
testStatus === 'success' &&
'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
testStatus === 'error' &&
'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
)}
>
<div className="flex items-start gap-2 min-w-0">
{testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
{testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
<p className="flex-1 min-w-0 break-all">{testMessage}</p>
</div>
</div>
)}
</div>
{/* Base URL: the service's own endpoint; another one is the server's to set. */}
<div className="space-y-2">
<Label>Base URL</Label>
{defaultBaseUrl && (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {defaultBaseUrl}
</p>
)}
<EndpointServerOnlyHint />
</div>
</>
)}
{children ?? (
<div className="space-y-3">
<Label className="text-base">{t('settings.models')}</Label>
<div className="space-y-1.5">
{models.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+46 -243
View File
@@ -5,88 +5,56 @@ import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/compone
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Sparkles, Wrench, Zap, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { EditingModel } from '@/lib/types/settings';
import type { ProviderId } from '@/lib/ai/providers';
import { cn } from '@/lib/utils';
import { createVerifyModelRequest } from './utils';
import { verifySavedModel } from './server-settings';
interface ModelEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingModel: EditingModel | null;
setEditingModel: (model: EditingModel | null) => void;
onSave: () => void;
onAutoSave?: () => void; // Auto-save on blur
providerId: ProviderId;
apiKey: string;
baseUrl?: string;
providerType?: string;
requiresApiKey?: boolean;
isServerConfigured?: boolean;
/** The model id being edited ('' for a new model). */
modelId: string;
isNew: boolean;
/** The saved provider, for testing the model; undefined until the service is saved. */
providerId?: string;
onSave: (modelId: string) => void | Promise<void>;
}
/**
* Add or edit a model of a language model service: its id, and a test of it
* on the server. A model's name, capabilities and context window come from
* the built-in catalogue; a service's model list holds ids only.
*/
export function ModelEditDialog({
open,
onOpenChange,
editingModel,
setEditingModel,
onSave,
onAutoSave,
modelId,
isNew,
providerId,
apiKey,
baseUrl,
providerType,
requiresApiKey,
isServerConfigured,
onSave,
}: ModelEditDialogProps) {
const { t } = useI18n();
const [id, setId] = useState(modelId);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
// Reset test status when dialog closes
// Start from the model being edited, and reset the test, whenever the dialog opens.
useEffect(() => {
if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when dialog closes
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when dialog opens
setId(modelId);
setTestStatus('idle');
setTestMessage('');
}
}, [open]);
const handleClose = () => {
onOpenChange(false);
setEditingModel(null);
};
}, [open, modelId]);
const handleTestModel = useCallback(async () => {
if (!editingModel) {
return;
}
if (!providerId || !id.trim()) return;
setTestStatus('testing');
setTestMessage('');
try {
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
createVerifyModelRequest({
providerId,
modelId: editingModel.model.id,
apiKey,
baseUrl,
providerType,
requiresApiKey,
}),
),
});
const data = await response.json();
const data = await verifySavedModel(providerId, id.trim());
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.connectionSuccess'));
@@ -98,28 +66,20 @@ export function ModelEditDialog({
setTestStatus('error');
setTestMessage(t('settings.connectionFailed'));
}
}, [editingModel, apiKey, baseUrl, providerId, providerType, requiresApiKey, t]);
}, [id, providerId, t]);
if (!editingModel) return null;
const title = isNew ? t('settings.addNewModel') : t('settings.editModel');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogTitle className="sr-only">
{editingModel.modelIndex === null ? t('settings.addNewModel') : t('settings.editModel')}
</DialogTitle>
<DialogTitle className="sr-only">{title}</DialogTitle>
<DialogDescription className="sr-only">
{editingModel.modelIndex === null
? t('settings.addNewModelDescription')
: t('settings.editModelDescription')}
{isNew ? t('settings.addNewModelDescription') : t('settings.editModelDescription')}
</DialogDescription>
<div className="space-y-4">
<div className="pb-3 border-b">
<h2 className="text-lg font-semibold">
{editingModel.modelIndex === null
? t('settings.addNewModel')
: t('settings.editModel')}
</h2>
<h2 className="text-lg font-semibold">{title}</h2>
</div>
{/* Model ID */}
@@ -127,178 +87,16 @@ export function ModelEditDialog({
<Label>{t('settings.modelId')}</Label>
<Input
placeholder={t('settings.modelIdPlaceholder')}
value={editingModel.model.id}
value={id}
onChange={(e) => {
const newId = e.target.value;
const currentName = editingModel.model.name;
const currentId = editingModel.model.id;
// Auto-sync name if it's empty or matches the old ID
const shouldSyncName = !currentName || currentName === currentId;
setEditingModel({
...editingModel,
model: {
...editingModel.model,
id: newId,
name: shouldSyncName ? newId : currentName,
},
});
// Reset test status when model ID changes
setId(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
onBlur={() => onAutoSave?.()}
/>
</div>
{/* Display Name */}
<div className="space-y-2">
<Label>{t('settings.modelName')}</Label>
<Input
placeholder={t('settings.modelNamePlaceholder')}
value={editingModel.model.name}
onChange={(e) =>
setEditingModel({
...editingModel,
model: { ...editingModel.model, name: e.target.value },
})
}
onBlur={() => onAutoSave?.()}
/>
</div>
{/* Capabilities */}
<div className="space-y-2">
<Label>{t('settings.modelCapabilities')}</Label>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="cap-vision"
checked={editingModel.model.capabilities?.vision || false}
onCheckedChange={(checked) => {
setEditingModel({
...editingModel,
model: {
...editingModel.model,
capabilities: {
...editingModel.model.capabilities,
vision: checked as boolean,
},
},
});
onAutoSave?.();
}}
/>
<label
htmlFor="cap-vision"
className="text-sm flex items-center gap-1.5 cursor-pointer"
>
<Sparkles className="h-3.5 w-3.5" />
{t('settings.capabilities.vision')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="cap-tools"
checked={editingModel.model.capabilities?.tools || false}
onCheckedChange={(checked) => {
setEditingModel({
...editingModel,
model: {
...editingModel.model,
capabilities: {
...editingModel.model.capabilities,
tools: checked as boolean,
},
},
});
onAutoSave?.();
}}
/>
<label
htmlFor="cap-tools"
className="text-sm flex items-center gap-1.5 cursor-pointer"
>
<Wrench className="h-3.5 w-3.5" />
{t('settings.capabilities.tools')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="cap-streaming"
checked={editingModel.model.capabilities?.streaming || false}
onCheckedChange={(checked) => {
setEditingModel({
...editingModel,
model: {
...editingModel.model,
capabilities: {
...editingModel.model.capabilities,
streaming: checked as boolean,
},
},
});
onAutoSave?.();
}}
/>
<label
htmlFor="cap-streaming"
className="text-sm flex items-center gap-1.5 cursor-pointer"
>
<Zap className="h-3.5 w-3.5" />
{t('settings.capabilities.streaming')}
</label>
</div>
</div>
</div>
{/* Advanced Settings */}
<div className="space-y-3 pt-3 border-t">
<Label className="text-base">{t('settings.advancedSettings')}</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-sm">{t('settings.contextWindowLabel')}</Label>
<Input
type="number"
placeholder={t('settings.contextWindowPlaceholder')}
value={editingModel.model.contextWindow || ''}
onChange={(e) =>
setEditingModel({
...editingModel,
model: {
...editingModel.model,
contextWindow: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
onBlur={() => onAutoSave?.()}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">{t('settings.outputWindowLabel')}</Label>
<Input
type="number"
placeholder={t('settings.outputWindowPlaceholder')}
value={editingModel.model.outputWindow || ''}
onChange={(e) =>
setEditingModel({
...editingModel,
model: {
...editingModel.model,
outputWindow: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
onBlur={() => onAutoSave?.()}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('settings.serverConfig.modelFromCatalogue')}
</p>
</div>
{/* Test Model */}
@@ -309,11 +107,7 @@ export function ModelEditDialog({
variant="outline"
size="sm"
onClick={handleTestModel}
disabled={
!editingModel.model.id ||
testStatus === 'testing' ||
(requiresApiKey && !apiKey && !isServerConfigured)
}
disabled={!id.trim() || !providerId || testStatus === 'testing'}
className={cn(
testStatus === 'success' && 'border-green-600 text-green-600 hover:bg-green-50',
testStatus === 'error' && 'border-red-600 text-red-600 hover:bg-red-50',
@@ -344,10 +138,19 @@ export function ModelEditDialog({
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-3 border-t">
<Button variant="outline" size="sm" onClick={handleClose}>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('settings.cancelEdit')}
</Button>
<Button size="sm" onClick={onSave}>
<Button
size="sm"
disabled={!id.trim()}
onClick={() => {
if (!id.trim()) {
return;
}
void onSave(id.trim());
}}
>
{t('settings.saveModel')}
</Button>
</div>
+65 -3
View File
@@ -6,7 +6,7 @@
// groups,不传 thinking 回调,即为「仅搜索」的纯模型选择。
import { useMemo, useState } from 'react';
import { Bot, Brain, Check, CornerDownRight, Search } from 'lucide-react';
import { Bot, Box, Brain, Check, CornerDownRight, Search } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
@@ -43,6 +43,42 @@ export interface ModelPickerGroup {
models: PickerModel[];
/** Token Plan 套餐播种的目录:置顶展示并带标识 */
isTokenPlan?: boolean;
/**
* The provider's logo. `null` draws the generic service icon (a custom
* endpoint); undefined draws none.
*/
icon?: string | null;
/** A single-colour logo that is inverted on a dark background. */
invertIcon?: boolean;
}
/** A group's logo: its image, the generic service icon, or nothing. */
export function ProviderLogo({
group,
className,
}: {
group: Pick<ModelPickerGroup, 'icon' | 'invertIcon' | 'name'>;
className?: string;
}) {
if (group.icon === undefined) return null;
if (group.icon === null) {
return <Box className={cn('shrink-0 text-muted-foreground', className)} aria-hidden="true" />;
}
return (
<img
src={group.icon}
alt=""
aria-hidden="true"
className={cn(
'shrink-0 rounded-sm object-contain',
group.invertIcon && 'dark:invert',
className,
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
);
}
function formatThinkingValue(value: string, t: (key: string) => string) {
@@ -63,7 +99,7 @@ function formatCompactThinkingValue(value: string | undefined) {
}
/** 行内思考强度控制(开/关/自动、档位、effort、budget) */
function InlineThinkingControl({
export function InlineThinkingControl({
capability,
config,
onChange,
@@ -287,6 +323,8 @@ export function ModelPicker({
size = 'sm',
className,
ariaLabel,
valuePrefix,
note,
t,
}: {
groups: ModelPickerGroup[];
@@ -305,6 +343,10 @@ export function ModelPicker({
className?: string;
/** 触发器的可及名。首页工具栏传 `Provider / Model`,e2e 与读屏都依赖它。 */
ariaLabel?: string;
/** A short word before the selected model on the trigger (the home toolbar's "Default"). */
valuePrefix?: string;
/** One muted line at the top of the dropdown, saying what picking here changes. */
note?: string;
t: (key: string) => string;
}) {
const [open, setOpen] = useState(false);
@@ -374,6 +416,14 @@ export function ModelPicker({
>
{value ? (
<>
{selectedEntry && <ProviderLogo group={selectedEntry.group} className="size-4" />}
{/* Hidden on phones, where the toolbar leaves room for the model name only. */}
{valuePrefix && (
<span className="hidden shrink-0 text-muted-foreground sm:inline">
{valuePrefix}
<span aria-hidden="true"> ·</span>
</span>
)}
<span className="min-w-0 flex-1 truncate text-left font-mono">{selectedLabel}</span>
{thinkingBadge && (
<span className="shrink-0 rounded-full bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold leading-none text-violet-700 dark:bg-violet-900/40 dark:text-violet-200">
@@ -411,6 +461,9 @@ export function ModelPicker({
onWheelCapture={(e) => e.stopPropagation()}
>
<div className="border-b p-2">
{note && (
<p className="px-0.5 pb-2 text-[11px] leading-snug text-muted-foreground">{note}</p>
)}
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -425,7 +478,14 @@ export function ModelPicker({
{/* 列表高度跟随 Radix 给出的可用高度自适应收缩(减去搜索区 ~64px):
trigger 靠近视口底部时弹层变矮而不是向上翻转溢出视口——否则顶部
的搜索框会被裁到屏幕外(如第一行站点的检查器)。 */}
<div className="max-h-[min(320px,calc(var(--radix-popover-content-available-height)-64px))] min-h-0 overflow-y-auto p-1.5">
<div
className={cn(
'min-h-0 overflow-y-auto p-1.5',
note
? 'max-h-[min(320px,calc(var(--radix-popover-content-available-height)-96px))]'
: 'max-h-[min(320px,calc(var(--radix-popover-content-available-height)-64px))]',
)}
>
{followLabel && onFollow && (
<button
onClick={() => {
@@ -463,6 +523,7 @@ export function ModelPicker({
<div key={group.id}>
{groups.length > 1 && (
<p className="flex items-center gap-1.5 px-2.5 pb-1 pt-2 text-[10px] font-medium text-muted-foreground">
<ProviderLogo group={group} className="size-3.5" />
{group.name}
{group.isTokenPlan && (
<span className="rounded-full bg-primary/10 px-1.5 py-px text-[9px] font-semibold leading-tight text-primary">
@@ -496,6 +557,7 @@ export function ModelPicker({
: 'hover:bg-muted/60',
)}
>
<ProviderLogo group={group} className="size-4" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-medium">{model.name}</div>
{model.id !== model.name && (
+325
View File
@@ -0,0 +1,325 @@
'use client';
/**
* The "Model Services" section: one tab per capability, each with the list of
* its services and the selected one's panel. The list and the panels read the
* workspace's model configuration on the server; keys are written there and
* never come back, and services the server configures are shown read-only.
*/
import { useMemo, useState } from 'react';
import {
Box,
FileText,
Film,
Image as ImageIcon,
Mic,
MoreHorizontal,
Search,
Trash2,
Volume2,
type LucideIcon,
} from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
import type { SlotCapability } from '@/lib/config/model-slots';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ApplyChange, ModelSettingsView } from '@/lib/model-settings/client';
import { serviceEntries, type ServiceEntry } from '@/lib/model-settings/services';
import { cn } from '@/lib/utils';
import type { SettingsSection } from '@/lib/types/settings';
import { AddProviderDialog } from './add-provider-dialog';
import { ASRSettings } from './asr-settings';
import { ImageSettings } from './image-settings';
import { PDFSettings } from './pdf-settings';
import { ProviderConfigPanel } from './provider-config-panel';
import { PINNED_PROVIDER_ID } from './provider-links';
import { ProviderList } from './provider-list';
import { removeServiceProvider, rootUse } from './server-settings';
import { REGISTRY_INFO, entryIcon, entryName, isEntryConfigured } from './service-display';
import { TTSSettings } from './tts-settings';
import { UnimportedSettingsNotice } from './unimported-settings-notice';
import { VideoSettings } from './video-settings';
import { WebSearchSettings } from './web-search-settings';
/** 「模型服务」分区内的服务 tab:沿用旧一级分区的值与面板组件。 */
export type ServiceTab = Extract<
SettingsSection,
'providers' | 'image' | 'video' | 'tts' | 'asr' | 'pdf' | 'web-search'
>;
export const SERVICE_TABS = [
'providers',
'image',
'video',
'tts',
'asr',
'pdf',
'web-search',
] as const satisfies readonly ServiceTab[];
export const SERVICE_TAB_LABELS: Record<ServiceTab, string> = {
providers: 'settings.providers',
image: 'settings.imageSettings',
video: 'settings.videoSettings',
tts: 'settings.ttsSettings',
asr: 'settings.asrSettings',
pdf: 'settings.documentParsingSettings',
'web-search': 'settings.webSearchSettings',
};
const SERVICE_TAB_ICONS: Record<ServiceTab, LucideIcon> = {
providers: Box,
image: ImageIcon,
video: Film,
tts: Volume2,
asr: Mic,
pdf: FileText,
'web-search': Search,
};
export const SERVICE_TAB_DESCRIPTIONS: Record<ServiceTab, string> = {
providers: 'settings.modelServices.desc.providers',
image: 'settings.modelServices.desc.image',
video: 'settings.modelServices.desc.video',
tts: 'settings.modelServices.desc.tts',
asr: 'settings.modelServices.desc.asr',
pdf: 'settings.modelServices.desc.pdf',
'web-search': 'settings.modelServices.desc.webSearch',
};
/** The capability (and root slot) each tab configures. */
export const TAB_CAPABILITY: Record<ServiceTab, SlotCapability> = {
providers: 'chat',
image: 'image',
video: 'video',
tts: 'tts',
asr: 'asr',
pdf: 'document',
'web-search': 'webSearch',
};
export { REGISTRY_INFO, entryIcon, entryName, isEntryConfigured } from './service-display';
export function ModelServicesPanel({
view,
apply,
tab,
onTabChange,
}: {
view: ModelSettingsView;
apply: ApplyChange;
tab: ServiceTab;
onTabChange: (tab: ServiceTab) => void;
}) {
const { t } = useI18n();
const capability = TAB_CAPABILITY[tab];
const entries = useMemo(() => {
const list = serviceEntries(view, capability, REGISTRY_INFO[capability].ids);
// Kimi 推广位:置顶于内置服务之首(其他账号/自定义服务在前,其余保持原顺序)。
const rank = (entry: ServiceEntry) =>
entry.provider && !entry.serviceId ? 0 : entry.id === PINNED_PROVIDER_ID ? 1 : 2;
return capability === 'chat' ? [...list].sort((a, b) => rank(a) - rank(b)) : list;
}, [view, capability]);
const [selected, setSelected] = useState<Partial<Record<ServiceTab, string>>>({});
const [showAddProvider, setShowAddProvider] = useState(false);
const [deleting, setDeleting] = useState<ServiceEntry | null>(null);
const entry = entries.find((item) => item.id === selected[tab]) ?? entries[0];
const select = (id: string) => setSelected((prev) => ({ ...prev, [tab]: id }));
const canAdd = view.policy.allowWorkspaceProviders && view.presets.length > 0;
const header = () => {
if (!entry) return null;
const name = entryName(entry, capability, t);
const icon = entryIcon(entry, capability);
const configured = isEntryConfigured(entry, capability);
return (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10">
{icon ? (
<img
src={icon}
alt={name}
className={cn(
'size-5 object-contain',
MONO_LOGO_PROVIDERS.has(entry.serviceId ?? entry.registryId) && 'dark:invert',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="size-5 text-primary" />
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{name}</p>
<p className="text-[11px] text-muted-foreground">
{configured
? t('settings.modelServices.configuredHint')
: t('settings.modelServices.notConfiguredHint')}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{entry.state === 'workspace' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={t('settings.more')}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleting(entry)}
>
<Trash2 className="size-4" />
{t('settings.deleteProvider')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{entry.provider && rootUse(view, capability, entry.id).inUse && (
<Badge variant="outline" className="shrink-0 text-primary">
{t('settings.serverConfig.inUse')}
</Badge>
)}
{configured ? (
<Badge variant="secondary" className="shrink-0 text-emerald-600 dark:text-emerald-400">
{t('settings.modelServices.ready')}
</Badge>
) : (
<Badge variant="secondary" className="shrink-0 text-amber-600 dark:text-amber-400">
{t('settings.modelServices.pending')}
</Badge>
)}
</div>
</div>
);
};
const panelProps = entry ? { view, apply, entry } : null;
return (
<div className="flex h-full min-h-0 flex-col">
{/* Settings of an earlier build that could not be moved to the server. */}
<UnimportedSettingsNotice view={view} />
{/* 七个服务的胶囊 tab(收拢后的一级列) */}
<div className="flex gap-1 overflow-x-auto pb-3" role="tablist">
{SERVICE_TABS.map((id) => {
const Icon = SERVICE_TAB_ICONS[id];
const active = tab === id;
return (
<button
key={id}
role="tab"
aria-selected={active}
onClick={() => onTabChange(id)}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors',
active
? 'bg-primary/10 font-medium text-primary ring-1 ring-inset ring-primary/15'
: 'text-muted-foreground hover:bg-muted',
)}
>
<Icon className="size-3.5" />
{t(SERVICE_TAB_LABELS[id])}
</button>
);
})}
</div>
{/* provider 列表 + 配置面板(统一容器,对齐原型) */}
<div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/50 max-sm:flex-col">
<div className="w-52 shrink-0 border-r border-border/50 bg-muted/20 p-2 max-sm:max-h-48 max-sm:w-full max-sm:border-b max-sm:border-r-0">
<ProviderList
providers={entries.map((item) => ({
id: item.id,
name: entryName(item, capability, t),
icon: entryIcon(item, capability),
registryId: item.serviceId ?? item.registryId,
configured: isEntryConfigured(item, capability),
}))}
selectedProviderId={entry?.id ?? ''}
onSelect={select}
onAddProvider={
capability === 'chat' && canAdd ? () => setShowAddProvider(true) : undefined
}
/>
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-4">
{header()}
{panelProps && (
<div className="mt-4" key={`${tab}:${entry!.id}`}>
{tab === 'providers' && <ProviderConfigPanel {...panelProps} />}
{tab === 'image' && <ImageSettings {...panelProps} />}
{tab === 'video' && <VideoSettings {...panelProps} />}
{tab === 'tts' && <TTSSettings {...panelProps} />}
{tab === 'asr' && <ASRSettings {...panelProps} />}
{tab === 'pdf' && <PDFSettings {...panelProps} />}
{tab === 'web-search' && <WebSearchSettings {...panelProps} />}
</div>
)}
</div>
</div>
<AddProviderDialog
open={showAddProvider}
onOpenChange={setShowAddProvider}
view={view}
apply={apply}
onAdded={(id) => {
setShowAddProvider(false);
select(id);
}}
/>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.deleteProvider')}</AlertDialogTitle>
<AlertDialogDescription>{t('settings.deleteProviderConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('settings.cancelEdit')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleting?.provider) void removeServiceProvider(view, apply, deleting.id, t);
setDeleting(null);
}}
>
{t('settings.deleteProvider')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { Lock } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ApplyChange, ModelSettingsView } from '@/lib/model-settings/client';
import { ModelMap } from './model-map';
import { MS } from './slot-meta';
/**
* The body of Course Model Config: the course model map (which model each
* part of the product uses, edited on its card) with its legend. The models
* come from the services set up in Model Services and Token Plan; the map
* links there when it needs one.
*/
export function CourseModelMap({
view,
apply,
onManageProviders,
}: {
view: ModelSettingsView;
apply: ApplyChange;
/** Open Model Services. */
onManageProviders: () => void;
}) {
const { t } = useI18n();
return (
<div className="flex h-full min-h-0 flex-col gap-3">
<div
className="flex flex-wrap items-center justify-end gap-x-4 gap-y-1 text-xs text-muted-foreground"
aria-label={t(`${MS}.legend.label`)}
>
<span className="inline-flex items-center gap-1.5">
<span className="w-5 border-t-[1.5px] border-primary/50" aria-hidden="true" />
{t(`${MS}.legend.follows`)}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-5 border-t-[1.5px] border-dashed border-muted-foreground/50"
aria-hidden="true"
/>
{t(`${MS}.legend.own`)}
</span>
<span className="inline-flex items-center gap-1.5">
<Lock className="size-3" aria-hidden="true" />
{t(`${MS}.legend.locked`)}
</span>
</div>
<ModelMap view={view} apply={apply} t={t} onManageProviders={onManageProviders} />
</div>
);
}
+648
View File
@@ -0,0 +1,648 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Scan } from 'lucide-react';
import type { ModelSettingsView, ApplyChange } from '@/lib/model-settings/client';
import { findSlot } from '@/lib/model-settings/client';
import {
CANVAS_WIDTH,
CHILD_GAP,
CHILD_WIDTH,
FLOW,
NODE_WIDTH,
PER_PAGE,
ROOT_WIDTH,
ROW_1,
ROW_2,
STATIONS,
edgeKind,
placeStations,
stationLit,
type PlacedStation,
} from '@/lib/model-settings/diagram';
import type { OffMemory } from '@/lib/model-settings/edit';
import { cn } from '@/lib/utils';
import { MS } from './slot-meta';
import { StationNode, type NodeContext } from './station-node';
type T = (key: string, options?: Record<string, unknown>) => string;
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
export interface Viewport {
x: number;
y: number;
k: number;
}
/** What each media switch turned off, kept for the page across the dialog opening and closing. */
const PAGE_OFF_MEMORY: OffMemory = new Map();
const MIN_ZOOM = 0.35;
const MAX_ZOOM = 2;
const DEFAULT_HEIGHT = 96;
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
/** A fit of the world into the canvas; on a narrow canvas, a readable zoom from the left. */
function fitView(width: number, height: number, worldHeight: number): Viewport {
const pad = 28;
const k = clampZoom(
Math.min((width - pad * 2) / CANVAS_WIDTH, (height - pad * 2) / worldHeight, 1.1),
);
// Too small to read: keep a readable zoom, centred on the root the rest hangs from.
if (width < 600 && k < 0.62) {
const rootCentre = STATIONS[0].x + ROOT_WIDTH / 2;
return { k: 0.62, x: Math.min(12, width / 2 - rootCentre * 0.62), y: 16 };
}
return {
k,
x: (width - CANVAS_WIDTH * k) / 2,
y: Math.max(16, (height - worldHeight * k) / 2),
};
}
const PAN_STEP = 60;
/** Arrow keys move the map (the content follows the arrow's opposite, like scrolling). */
const PAN_KEYS: Record<string, [number, number]> = {
ArrowLeft: [PAN_STEP, 0],
ArrowRight: [-PAN_STEP, 0],
ArrowUp: [0, PAN_STEP],
ArrowDown: [0, -PAN_STEP],
};
/**
* The viewport moved just enough for a box (world coordinates) to show inside
* the canvas with a margin; the same viewport when it already shows. A box
* larger than the canvas shows from its top left.
*/
export function revealBox(v: Viewport, box: Box, width: number, height: number): Viewport {
const margin = 16;
const shift = (start: number, size: number, extent: number) => {
if (start < margin || size > extent - margin * 2) return margin - start;
if (start + size > extent - margin) return extent - margin - (start + size);
return 0;
};
const dx = shift(v.x + box.x * v.k, box.w * v.k, width);
const dy = shift(v.y + box.y * v.k, box.h * v.k, height);
return dx || dy ? { ...v, x: v.x + dx, y: v.y + dy } : v;
}
function ZoomButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
className="flex size-7 items-center justify-center rounded-[7px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
{children}
</button>
);
}
function childStation(parent: PlacedStation, index: number): PlacedStation {
const slot = parent.children[index];
return {
id: `child:${slot.slot}`,
kind: 'station',
x: 0,
y: 0,
lines: [{ slot: slot.slot, view: slot }],
children: [],
};
}
/**
* The course model map: a pannable, zoomable canvas with the pipeline as a
* two-row serpentine flow, the default language model above the stations
* that inherit from it, and edges drawn solid for inheritance and dashed for
* a setting of the station's own.
*/
export function ModelMap({
view,
apply,
t,
onManageProviders,
offMemory = PAGE_OFF_MEMORY,
}: {
view: ModelSettingsView;
apply: ApplyChange;
t: T;
onManageProviders: () => void;
/** What the switches turned off; kept for the page unless a test passes its own. */
offMemory?: OffMemory;
}) {
const stations = useMemo(() => placeStations(view), [view]);
const [openKey, setOpenKey] = useState<string | null>(null);
const expandable = stations.find((station) => station.expandable && station.children.length);
const [expanded, setExpanded] = useState(
() => !!expandable?.children.some((slot) => slot.assignment !== undefined || slot.locked),
);
// Card heights vary with their lines; edges and the children follow the measured boxes.
const nodes = useRef(new Map<string, HTMLDivElement>());
const [heights, setHeights] = useState<Record<string, number>>({});
useLayoutEffect(() => {
const next: Record<string, number> = {};
let changed = false;
for (const [id, element] of nodes.current) {
next[id] = element.offsetHeight;
if (heights[id] !== next[id]) changed = true;
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement: heights are only known after the cards render
if (changed) setHeights(next);
}, [view, expanded, heights]);
const children =
expanded && expandable ? expandable.children.map((_, i) => childStation(expandable, i)) : [];
const boxes = new Map<string, Box>();
for (const station of stations) {
boxes.set(station.id, {
x: station.x,
y: station.y,
w: station.kind === 'root' ? ROOT_WIDTH : NODE_WIDTH,
h: heights[station.id] ?? DEFAULT_HEIGHT,
});
}
if (expandable && children.length) {
const parent = boxes.get(expandable.id)!;
const span = children.length * CHILD_WIDTH + (children.length - 1) * CHILD_GAP;
const x0 = parent.x + parent.w - span;
children.forEach((child, index) => {
boxes.set(child.id, {
x: x0 + index * (CHILD_WIDTH + CHILD_GAP),
y: parent.y + parent.h + 48,
w: CHILD_WIDTH,
h: heights[child.id] ?? DEFAULT_HEIGHT,
});
});
}
const worldHeight = Math.max(...[...boxes.values()].map((box) => box.y + box.h)) + 40;
// ── viewport ──
const canvas = useRef<HTMLDivElement>(null);
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, k: 1 });
const [animating, setAnimating] = useState(false);
const [narrow, setNarrow] = useState(false);
const worldHeightRef = useRef(worldHeight);
useLayoutEffect(() => {
worldHeightRef.current = worldHeight;
}, [worldHeight]);
// Until the user pans or zooms, the map keeps fitting its content.
const touched = useRef(false);
const fit = useCallback((animate: boolean) => {
const element = canvas.current;
if (!element) return;
if (animate) {
setAnimating(true);
window.setTimeout(() => setAnimating(false), 470);
}
setViewport(fitView(element.clientWidth, element.clientHeight, worldHeightRef.current));
}, []);
useEffect(() => {
const element = canvas.current;
if (!element) return;
const observer = new ResizeObserver(() => {
setNarrow(element.clientWidth < 600);
touched.current = false;
fit(false);
});
observer.observe(element);
return () => observer.disconnect();
}, [fit]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- refit to the measured content until the user takes over
if (!touched.current) fit(false);
}, [worldHeight, fit]);
const zoomAt = useCallback((factor: number, cx: number, cy: number) => {
touched.current = true;
setOpenKey(null);
setViewport((v) => {
const k = clampZoom(v.k * factor);
return { k, x: cx - ((cx - v.x) * k) / v.k, y: cy - ((cy - v.y) * k) / v.k };
});
}, []);
const zoomBy = (factor: number) => {
const element = canvas.current;
if (element) zoomAt(factor, element.clientWidth / 2, element.clientHeight / 2);
};
// Scrolling pans; with Ctrl or ⌘ it zooms at the pointer. Needs a non-passive listener.
useEffect(() => {
const element = canvas.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = element.getBoundingClientRect();
if (event.ctrlKey || event.metaKey) {
zoomAt(Math.exp(-event.deltaY * 0.01), event.clientX - rect.left, event.clientY - rect.top);
} else {
touched.current = true;
setOpenKey(null);
setViewport((v) => ({ ...v, x: v.x - event.deltaX, y: v.y - event.deltaY }));
}
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [zoomAt]);
const drag = useRef<{ x: number; y: number; vx: number; vy: number; moved: boolean } | null>(
null,
);
const [panning, setPanning] = useState(false);
const onPointerDown = (event: React.PointerEvent) => {
if (event.button !== 0) return;
if ((event.target as HTMLElement).closest('button,a,input,select,textarea,[role="switch"]')) {
return;
}
drag.current = {
x: event.clientX,
y: event.clientY,
vx: viewport.x,
vy: viewport.y,
moved: false,
};
};
useEffect(() => {
const move = (event: PointerEvent) => {
const state = drag.current;
if (!state) return;
const dx = event.clientX - state.x;
const dy = event.clientY - state.y;
if (!state.moved && Math.hypot(dx, dy) > 4) {
state.moved = true;
touched.current = true;
setPanning(true);
setOpenKey(null);
}
if (state.moved) setViewport((v) => ({ ...v, x: state.vx + dx, y: state.vy + dy }));
};
const up = () => {
drag.current = null;
setPanning(false);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
}, []);
// ── edges ──
const byId = new Map(stations.map((station) => [station.id, station]));
const flowEdges: { d: string; off: boolean; key: string }[] = [];
const flow = FLOW.map((id) => byId.get(id)).filter(
(station): station is PlacedStation => !!station,
);
for (let i = 0; i < flow.length - 1; i++) {
const a = boxes.get(flow[i].id)!;
const b = boxes.get(flow[i + 1].id)!;
const ya = a.y + 22;
const yb = b.y + 22;
let d: string;
if (flow[i].row === 1 && flow[i + 1].row === 1) d = `M${a.x + a.w + 4} ${ya} L${b.x - 6} ${yb}`;
else if (flow[i].row === 2 && flow[i + 1].row === 2) {
d = `M${a.x - 4} ${ya} L${b.x + b.w + 6} ${yb}`;
} else {
const xo = Math.max(a.x + a.w, b.x + b.w) + 46;
d = `M${a.x + a.w + 4} ${ya} C ${xo} ${ya}, ${xo} ${yb}, ${b.x + b.w + 6} ${yb}`;
}
flowEdges.push({ d, off: !stationLit(flow[i + 1]), key: `${flow[i].id}>${flow[i + 1].id}` });
}
const root = byId.get('llm');
const rootBox = root ? boxes.get(root.id) : undefined;
const treeEdges: { d: string; kind: ReturnType<typeof edgeKind>; key: string }[] = [];
if (rootBox) {
const rx = rootBox.x + rootBox.w / 2;
const ry = rootBox.y + rootBox.h;
for (const station of stations) {
const slot = station.lines[0].view;
if (station.kind === 'root' || slot.parent !== 'llm') continue;
const b = boxes.get(station.id)!;
const tx = b.x + b.w / 2;
let d: string;
if (station.kind === 'aside') {
const y = b.y + b.h / 2;
const ey = rootBox.y + rootBox.h / 2;
d =
b.x < rootBox.x
? `M${rootBox.x} ${ey} C ${rootBox.x - 70} ${ey}, ${b.x + b.w + 70} ${y}, ${b.x + b.w} ${y}`
: `M${rootBox.x + rootBox.w} ${ey} C ${rootBox.x + rootBox.w + 70} ${ey}, ${b.x - 70} ${y}, ${b.x} ${y}`;
} else if (station.row === 1) {
d = `M${rx} ${ry} C ${rx} ${ry + 40}, ${tx} ${b.y - 50}, ${tx} ${b.y}`;
} else {
const bend = ROW_1 - 26;
d = `M${rx} ${ry} C ${rx} ${ry + 30}, ${tx} ${bend - 40}, ${tx} ${bend} L ${tx} ${b.y}`;
}
treeEdges.push({ d, kind: edgeKind(slot), key: `llm>${station.id}` });
}
}
if (expandable && children.length) {
const parent = boxes.get(expandable.id)!;
const cx = parent.x + parent.w / 2;
const cy = parent.y + parent.h;
for (const child of children) {
const b = boxes.get(child.id)!;
const tx = b.x + b.w / 2;
treeEdges.push({
d: `M${cx} ${cy} C ${cx} ${cy + 26}, ${tx} ${b.y - 26}, ${tx} ${b.y}`,
kind: edgeKind(child.lines[0].view),
key: child.id,
});
}
}
// The band around the stations generated page by page.
const bandBoxes = [
...PER_PAGE.map((id) => boxes.get(id)).filter((box): box is Box => !!box),
...children.map((child) => boxes.get(child.id)!),
];
const band = bandBoxes.length
? {
left: Math.min(...bandBoxes.map((box) => box.x)) - 14,
right: Math.max(...bandBoxes.map((box) => box.x + box.w)) + 14,
bottom: Math.max(...bandBoxes.map((box) => box.y + box.h)) + 18,
}
: null;
const ctx: NodeContext = {
view,
apply,
t,
openKey,
setOpenKey,
onManageProviders,
offMemory,
};
// Keyboard focus on a card outside the view pans the map to it.
const revealFocused = (event: React.FocusEvent) => {
const element = canvas.current;
const id = (event.target as HTMLElement)
.closest?.('[data-station]')
?.getAttribute('data-station');
const box = id ? boxes.get(id) : undefined;
if (!element || !box || !element.clientWidth) return;
setViewport((v) => {
const next = revealBox(v, box, element.clientWidth, element.clientHeight);
if (next === v) return v;
touched.current = true;
return next;
});
};
// No default model and nothing that offers one (the server's providers
// included): the root says where to set one up.
const llm = findSlot(view, 'llm');
const empty =
llm &&
llm.effective.status === 'unassigned' &&
!llm.locked &&
!view.providers.some((provider) => provider.capabilities.chat)
? view.policy.allowWorkspaceProviders &&
view.presets.some((preset) => preset.capabilities.chat)
? ('workspace' as const)
: ('server' as const)
: undefined;
const followers = view.slots.filter(
(slot) =>
slot.capability === 'chat' &&
slot.slot !== 'llm' &&
!slot.configOnly &&
slot.effective.status === 'assigned' &&
slot.effective.resolvedAt === 'llm',
).length;
const register = (id: string) => (element: HTMLDivElement | null) => {
if (element) nodes.current.set(id, element);
else nodes.current.delete(id);
};
const agent = byId.get('agent');
const asr = byId.get('asr');
return (
<div className="relative min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60">
<div
ref={canvas}
tabIndex={0}
role="group"
aria-label={t(`${MS}.map.label`)}
onPointerDown={onPointerDown}
// Focusing a card scrolls this clipped box to reveal it; the viewport
// transform does the moving, so the box stays put.
onScroll={(event) => {
event.currentTarget.scrollLeft = 0;
event.currentTarget.scrollTop = 0;
}}
onDoubleClick={(event) => {
if (!(event.target as HTMLElement).closest('[data-station]')) fit(true);
}}
onFocus={revealFocused}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
const pan = PAN_KEYS[event.key];
if (pan) {
event.preventDefault();
touched.current = true;
setOpenKey(null);
setViewport((v) => ({ ...v, x: v.x + pan[0], y: v.y + pan[1] }));
} else if (event.key === '+' || event.key === '=') zoomBy(1.2);
else if (event.key === '-') zoomBy(1 / 1.2);
else if (event.key === '0') fit(true);
}}
className={cn(
'absolute inset-0 touch-none overflow-hidden bg-muted/20 outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
panning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{
backgroundImage:
'radial-gradient(color-mix(in oklab, var(--foreground) 11%, transparent) 1px, transparent 1.2px)',
backgroundSize: '20px 20px',
}}
>
<div
className={cn(
'absolute left-0 top-0 origin-top-left',
animating && 'transition-transform duration-[450ms] ease-[cubic-bezier(.16,1,.3,1)]',
)}
style={{
width: CANVAS_WIDTH,
height: worldHeight,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{band && (
<div
className="absolute rounded-[14px] border border-dashed border-primary/20 bg-primary/[0.035] transition-[height] duration-300"
style={{
left: band.left,
top: ROW_2 - 18,
width: band.right - band.left,
height: band.bottom - ROW_2 + 18,
}}
>
<span className="absolute -top-2.5 left-3.5 rounded-full border bg-card px-2 py-px text-[11px] font-medium text-muted-foreground">
{t(`${MS}.map.perPage`)}
</span>
</div>
)}
<svg
width={CANVAS_WIDTH}
height={worldHeight}
className="pointer-events-none absolute inset-0 overflow-visible"
aria-hidden="true"
>
<defs>
<marker
id="model-map-arrow"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path
d="M0 0.5 L7 4 L0 7.5"
fill="none"
className="stroke-muted-foreground/50"
strokeWidth={1.4}
strokeLinecap="round"
/>
</marker>
</defs>
{flowEdges.map((edge) => (
<path
key={edge.key}
d={edge.d}
fill="none"
strokeWidth={1.5}
className="stroke-muted-foreground/35"
strokeDasharray={edge.off ? '4 4' : undefined}
markerEnd="url(#model-map-arrow)"
/>
))}
{treeEdges.map((edge) => (
<path
key={edge.key}
d={edge.d}
fill="none"
strokeWidth={1.5}
className={cn(
'transition-[stroke] duration-500',
edge.kind === 'inherit' && 'stroke-primary/50',
edge.kind === 'detached' && 'stroke-muted-foreground/40',
edge.kind === 'none' && 'stroke-border',
)}
strokeDasharray={edge.kind === 'inherit' ? undefined : '3 5'}
/>
))}
</svg>
{agent && (
<p className="absolute left-0.5 top-[18px] text-[11px] font-medium text-muted-foreground/70">
{t(`${MS}.map.outside`)}
</p>
)}
{asr && (
<p
className="absolute top-[18px] text-[11px] font-medium text-muted-foreground/70"
style={{ left: asr.x + 2 }}
>
{t(`${MS}.map.standalone`)}
</p>
)}
{stations.map((station) => {
const box = boxes.get(station.id)!;
return (
<StationNode
key={station.id}
ref={register(station.id)}
station={station}
ctx={ctx}
x={box.x}
y={box.y}
width={box.w}
empty={station.kind === 'root' ? empty : undefined}
followers={station.kind === 'root' ? followers : undefined}
expanded={station.expandable ? expanded : undefined}
onExpand={
station.expandable
? () => {
setOpenKey(null);
setExpanded((value) => !value);
}
: undefined
}
/>
);
})}
{children.map((child) => {
const box = boxes.get(child.id)!;
return (
<StationNode
key={child.id}
ref={register(child.id)}
station={child}
ctx={ctx}
x={box.x}
y={box.y}
width={box.w}
/>
);
})}
</div>
</div>
<div
role="group"
aria-label={t(`${MS}.map.zoom`)}
className="absolute bottom-3 left-3 z-[1] flex items-center gap-0.5 rounded-[10px] border bg-card p-[3px] shadow-sm"
>
<ZoomButton label={t(`${MS}.map.zoomOut`)} onClick={() => zoomBy(1 / 1.2)}>
<Minus className="size-4" />
</ZoomButton>
<button
type="button"
title={t(`${MS}.map.reset`)}
onClick={() => zoomBy(1 / viewport.k)}
className="h-7 min-w-12 rounded-[7px] px-2 text-xs tabular-nums text-muted-foreground hover:bg-muted hover:text-foreground"
>
{Math.round(viewport.k * 100)}%
</button>
<ZoomButton label={t(`${MS}.map.zoomIn`)} onClick={() => zoomBy(1.2)}>
<Plus className="size-4" />
</ZoomButton>
<ZoomButton label={t(`${MS}.map.fit`)} onClick={() => fit(true)}>
<Scan className="size-4" />
</ZoomButton>
</div>
{!narrow && (
<p className="pointer-events-none absolute bottom-4 right-3.5 z-[1] text-[11.5px] text-muted-foreground/70">
{t(`${MS}.map.hint`)}
</p>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { createElement } from 'react';
import {
Bot,
Clapperboard,
FileText,
Film,
FolderKanban,
GraduationCap,
Image as ImageIcon,
ListChecks,
ListTree,
MessageSquareText,
Mic,
MousePointerClick,
Presentation,
Search,
Sparkles,
Users,
Volume2,
type LucideIcon,
} from 'lucide-react';
import type { SlotCapability } from '@/lib/config/model-slots';
import { slotKey } from '@/lib/model-settings/edit';
export const MS = 'settings.modelSettings';
const SLOT_ICONS: Record<string, LucideIcon> = {
llm: Sparkles,
'course.research': Search,
'course.outline': ListTree,
'course.agents': Users,
'course.content': Presentation,
'course.content.slide': Presentation,
'course.content.quiz': ListChecks,
'course.content.interactive': MousePointerClick,
'course.content.pbl': FolderKanban,
'course.actions': Clapperboard,
classroom: GraduationCap,
agent: Bot,
};
export const CAPABILITY_ICONS: Record<SlotCapability, LucideIcon> = {
chat: MessageSquareText,
tts: Volume2,
asr: Mic,
image: ImageIcon,
video: Film,
webSearch: Search,
document: FileText,
};
export function SlotIcon({
slot,
capability,
className,
}: {
slot: string;
capability: SlotCapability;
className?: string;
}) {
const Icon = SLOT_ICONS[slot] ?? CAPABILITY_ICONS[capability];
return createElement(Icon, { className, 'aria-hidden': true });
}
/** Why a change did not go through, in the user's language where it is ours to say. */
export function applyErrorText(
result: { reason: string; message: string },
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (result.reason === 'conflict') return t(`${MS}.picker.conflict`);
if (result.reason === 'locked') return t(`${MS}.picker.lockedNow`);
if (result.reason === 'unconfirmed') return t(`${MS}.picker.unconfirmed`);
return result.message;
}
/** A slot's name, or its id for a slot this build has no name for. */
export function slotName(t: (key: string) => string, slot: string): string {
const key = `${MS}.slots.${slotKey(slot)}.name`;
const name = t(key);
return name === key ? slot : name;
}
export function slotDescription(t: (key: string) => string, slot: string): string | undefined {
const key = `${MS}.slots.${slotKey(slot)}.desc`;
const text = t(key);
return text === key ? undefined : text;
}
+576
View File
@@ -0,0 +1,576 @@
'use client';
import { useState } from 'react';
import { AlertCircle, Info, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
findSlot,
type ModelSettingsChange,
type ModelSettingsView,
type SlotView,
type ApplyChange,
} from '@/lib/model-settings/client';
import {
PROVIDER_ONLY_CAPABILITIES,
currentChoice,
fallbackChange,
modelChange,
modelRef,
providerLabel,
providersFor,
slotChange,
splitRef,
} from '@/lib/model-settings/edit';
import { cn } from '@/lib/utils';
import { slotRefusesThinkingEffort, type SlotCapability } from '@/lib/config/model-slots';
import { thinkingCapabilityWithoutEffort } from '@/lib/ai/thinking-config';
import {
assignService,
keylessServices,
slotThinking,
thinkingChange,
type ServiceEntry,
} from '@/lib/model-settings/services';
import { InlineThinkingControl, ProviderLogo } from '../model-picker';
import { REGISTRY_INFO, entryIcon, entryName, logoInverts, providerLogo } from '../service-display';
import { MS, applyErrorText, slotDescription, slotName } from './slot-meta';
import { lineText } from './station-text';
type T = (key: string, options?: Record<string, unknown>) => string;
const NO_FALLBACK = '__none__';
/**
* One choice of the picker: a plain button (pressed when it is the slot's
* current choice). Only one row per list is a Tab stop; the arrow keys move
* between rows (see {@link rovingKeys}).
*/
function Row({
current,
busy,
disabled,
tabStop,
onClick,
children,
note,
logo,
}: {
logo?: React.ReactNode;
current?: boolean;
busy?: boolean;
disabled?: boolean;
tabStop: boolean;
onClick: () => void;
children: React.ReactNode;
note?: string;
}) {
return (
<button
type="button"
data-picker-row=""
aria-pressed={!!current}
tabIndex={tabStop ? 0 : -1}
disabled={disabled}
onClick={onClick}
className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] outline-none transition-colors',
'hover:bg-muted/70 focus-visible:bg-muted/70 disabled:cursor-not-allowed disabled:opacity-60',
current && 'bg-primary/10 hover:bg-primary/10',
)}
>
{logo}
<span className="min-w-0 max-w-[80%] shrink-0 truncate">{children}</span>
{note && (
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{note}</span>
)}
<span className="ml-auto flex shrink-0 items-center">
{busy ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden="true" />
) : (
current && <span className="size-1.5 rounded-full bg-primary" aria-hidden="true" />
)}
</span>
</button>
);
}
/**
* Roving focus over the rows of a list: ArrowUp/ArrowDown step, Home/End jump,
* Enter/Space choose. Other keys (and keys in the typed-model field) pass.
*/
export function rovingKeys(event: React.KeyboardEvent<HTMLElement>) {
const target = event.target as HTMLElement;
if (!target.matches('[data-picker-row]')) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
target.click();
return;
}
const rows = [
...event.currentTarget.querySelectorAll<HTMLElement>('[data-picker-row]:not(:disabled)'),
];
const at = rows.indexOf(target);
const next =
event.key === 'ArrowDown'
? rows[Math.min(at + 1, rows.length - 1)]
: event.key === 'ArrowUp'
? rows[Math.max(at - 1, 0)]
: event.key === 'Home'
? rows[0]
: event.key === 'End'
? rows[rows.length - 1]
: undefined;
if (!next) return;
event.preventDefault();
next.focus();
}
function GroupLabel({ children, logo }: { children: React.ReactNode; logo?: React.ReactNode }) {
return (
<p
className="flex items-center gap-1.5 truncate px-2 pb-0.5 pt-2 text-[11px] text-muted-foreground"
role="presentation"
>
{logo}
{children}
</p>
);
}
/** A provider's logo for a capability, as Model Services shows it (the generic icon for a custom endpoint). */
function Logo({
view,
providerId,
capability,
className,
}: {
view: ModelSettingsView;
providerId: string;
capability: SlotCapability;
className?: string;
}) {
return (
<ProviderLogo
group={{
name: providerId,
icon: providerLogo(view, providerId, capability) ?? null,
invertIcon: logoInverts(view, providerId, capability),
}}
className={className ?? 'size-3.5'}
/>
);
}
/** A model id typed for a chat provider without a catalogue. */
function TypedModel({
providerId,
onUse,
disabled,
t,
}: {
providerId: string;
onUse: (ref: string) => void;
disabled?: boolean;
t: T;
}) {
const [value, setValue] = useState('');
return (
<form
className="flex items-center gap-1.5 px-2 py-1"
onSubmit={(event) => {
event.preventDefault();
if (value.trim()) onUse(modelRef(providerId, value.trim()));
}}
>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={t(`${MS}.picker.modelId`)}
aria-label={t(`${MS}.picker.modelId`)}
className="h-7 font-mono text-xs"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{t(`${MS}.picker.use`)}
</button>
</form>
);
}
/**
* The inline picker of a slot: follow its parent, one of the models the
* providers offer for its capability, or off; for a chat slot with a model of
* its own, a fallback. A choice is saved at once.
*/
export function SlotPicker({
view,
slot,
apply,
onDone,
onManageProviders,
t,
}: {
view: ModelSettingsView;
slot: SlotView;
apply: ApplyChange;
onDone: () => void;
onManageProviders?: () => void;
t: T;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const current = currentChoice(slot);
const parent = slot.parent ? findSlot(view, slot.parent) : undefined;
const providers = providersFor(view, slot.capability);
const providerOnly = PROVIDER_ONLY_CAPABILITIES.includes(slot.capability);
const chat = slot.capability === 'chat';
const text = lineText(view, slot, t);
const description = slotDescription(t, slot.slot);
const run = async (key: string, change: ModelSettingsChange, close = true) => {
setBusy(key);
setMessage(null);
try {
const result = await apply(change, view);
if (result.ok) {
if (close) onDone();
return;
}
setMessage(applyErrorText(result, t));
} finally {
setBusy(null);
}
};
const pick = (ref: string) => {
if (current.kind === 'model' && current.model === ref) return onDone();
void run(ref, modelChange(slot, ref));
};
// The thinking settings of the model the slot names itself, when the model has any.
// A slot whose tool calls cannot carry a reasoning effort (the agent) is not
// offered effort levels: an effort model it can turn off becomes on/off.
const thinkingCapability = (() => {
if (current.kind !== 'model') return undefined;
const { providerId, modelId } = splitRef(current.model);
const capability = view.providers
.find((provider) => provider.id === providerId)
?.capabilities.chat?.models.find((model) => model.id === modelId)?.capabilities?.thinking;
return slotRefusesThinkingEffort(slot.slot)
? thinkingCapabilityWithoutEffort(capability)
: capability;
})();
// Services the workspace may add without a key: listed for media slots, added when picked.
const keyless =
slot.capability === 'chat' || !view.policy.allowWorkspaceProviders
? []
: keylessServices(
view,
slot.capability,
REGISTRY_INFO[slot.capability].ids,
REGISTRY_INFO[slot.capability].requiresApiKey,
);
const pickKeyless = async (entry: ServiceEntry) => {
setBusy(entry.id);
setMessage(null);
try {
const result = await assignService(apply, view, entry, slot.slot);
if (result.ok) onDone();
else setMessage(applyErrorText(result, t));
} finally {
setBusy(null);
}
};
const isCurrent = (ref: string) => current.kind === 'model' && current.model === ref;
// The rows in order, to make the current one (else the first) the Tab stop.
const rowKeys = [
...(parent ? ['follow'] : []),
...(!parent && slot.assignment !== undefined ? ['clear'] : []),
...providers.flatMap((provider) =>
providerOnly
? [provider.id]
: [
...(chat ? [] : [provider.id]),
...(provider.capabilities[slot.capability]?.models ?? []).map((model) =>
modelRef(provider.id, model.id),
),
],
),
...(slot.slot !== 'llm' ? ['off'] : []),
];
const currentKey =
current.kind === 'model' ? current.model : current.kind === 'off' ? 'off' : 'follow';
const tabStop = rowKeys.includes(currentKey) ? currentKey : rowKeys[0];
return (
<div className="flex max-h-[min(420px,var(--radix-popover-content-available-height))] flex-col">
<div className="border-b px-3 pb-2 pt-2.5">
<p className="text-[13px] font-semibold leading-tight">{slotName(t, slot.slot)}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{text.source || text.value}</p>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto p-1"
role="group"
aria-label={slotName(t, slot.slot)}
data-slot-picker={slot.slot}
onKeyDown={rovingKeys}
>
{parent && (
<Row
tabStop={tabStop === 'follow'}
current={current.kind === 'follow'}
busy={busy === 'follow'}
disabled={!!busy}
onClick={() =>
current.kind === 'follow'
? onDone()
: void run('follow', slotChange(slot, { kind: 'follow' }))
}
note={lineText(view, parent, t).value}
>
{t(`${MS}.picker.follow`, { name: slotName(t, parent.slot) })}
</Row>
)}
{/* A root has no parent to follow: dropping its own setting leaves
whatever the server provides (its value or default), if anything. */}
{!parent && slot.assignment !== undefined && (
<Row
tabStop={tabStop === 'clear'}
busy={busy === 'follow'}
disabled={!!busy}
onClick={() => void run('follow', slotChange(slot, { kind: 'follow' }))}
note={t(`${MS}.picker.clearHint`)}
>
{t(`${MS}.picker.clear`)}
</Row>
)}
{providers.length === 0 && keyless.length === 0 && (
<p className="px-2 py-2 text-xs text-muted-foreground">{t(`${MS}.picker.noProviders`)}</p>
)}
{providerOnly && providers.length > 0 && (
<>
<GroupLabel>{t(`${MS}.picker.providers`)}</GroupLabel>
{providers.map((provider) => (
<Row
key={provider.id}
tabStop={tabStop === provider.id}
current={isCurrent(provider.id)}
busy={busy === provider.id}
disabled={!!busy}
onClick={() => pick(provider.id)}
logo={<Logo view={view} providerId={provider.id} capability={slot.capability} />}
>
{providerLabel(view, provider.id)}
</Row>
))}
</>
)}
{!providerOnly &&
providers.map((provider) => {
const models = provider.capabilities[slot.capability]?.models ?? [];
return (
<div key={provider.id} role="group" aria-label={providerLabel(view, provider.id)}>
<GroupLabel
logo={<Logo view={view} providerId={provider.id} capability={slot.capability} />}
>
{providerLabel(view, provider.id)}
</GroupLabel>
{!chat && (
<Row
tabStop={tabStop === provider.id}
current={isCurrent(provider.id)}
busy={busy === provider.id}
disabled={!!busy}
onClick={() => pick(provider.id)}
>
{t(`${MS}.picker.providerDefault`)}
</Row>
)}
{models.map((model) => {
const ref = modelRef(provider.id, model.id);
return (
<Row
key={model.id}
tabStop={tabStop === ref}
current={isCurrent(ref)}
busy={busy === ref}
disabled={!!busy}
onClick={() => pick(ref)}
>
{model.name}
</Row>
);
})}
{chat && models.length === 0 && (
<TypedModel providerId={provider.id} onUse={pick} disabled={!!busy} t={t} />
)}
</div>
);
})}
{/* Services that need no key (the browser's own speech): added on first use. */}
{keyless.map((entry) => (
<div key={entry.id} role="group" aria-label={entryName(entry, slot.capability, t)}>
<GroupLabel
logo={
<ProviderLogo
group={{
name: entry.id,
icon: entryIcon(entry, slot.capability) ?? null,
}}
className="size-3.5"
/>
}
>
{entryName(entry, slot.capability, t)}
</GroupLabel>
<Row
tabStop={false}
busy={busy === entry.id}
disabled={!!busy}
onClick={() => void pickKeyless(entry)}
>
{t(`${MS}.picker.providerDefault`)}
</Row>
</div>
))}
{slot.slot !== 'llm' && (
<>
<div className="mx-2 my-1 border-t" role="presentation" />
<Row
tabStop={tabStop === 'off'}
current={current.kind === 'off'}
busy={busy === 'off'}
disabled={!!busy}
onClick={() =>
current.kind === 'off'
? onDone()
: void run('off', slotChange(slot, { kind: 'off' }))
}
note={t(`${MS}.picker.offHint`)}
>
{t(`${MS}.picker.off`)}
</Row>
</>
)}
</div>
{chat && current.kind === 'model' && (
<div className="flex items-center gap-2 border-t px-3 py-2">
<span className="shrink-0 text-xs text-muted-foreground">
{t(`${MS}.picker.fallback`)}
</span>
<Select
value={current.fallback ?? NO_FALLBACK}
onValueChange={(value) =>
void run(
'fallback',
fallbackChange(slot, value === NO_FALLBACK ? undefined : value),
false,
)
}
disabled={!!busy}
>
<SelectTrigger
size="sm"
className="h-7 min-w-0 flex-1 text-xs"
aria-label={t(`${MS}.picker.fallback`)}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={NO_FALLBACK} className="text-xs">
{t(`${MS}.picker.noFallback`)}
</SelectItem>
{providersFor(view, 'chat').map((provider) => (
<SelectGroup key={provider.id}>
<SelectLabel className="text-[11px]">
{providerLabel(view, provider.id)}
</SelectLabel>
{(provider.capabilities.chat?.models ?? []).map((model) => (
<SelectItem
key={model.id}
value={modelRef(provider.id, model.id)}
className="text-xs"
>
{model.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
)}
{chat && current.kind === 'model' && thinkingCapability && (
<div
className="flex items-center gap-2 border-t px-3 py-2"
aria-label={t(`${MS}.picker.thinking`)}
role="group"
>
<span className="shrink-0 text-xs text-muted-foreground">
{t(`${MS}.picker.thinking`)}
</span>
<InlineThinkingControl
capability={thinkingCapability}
config={slotThinking(slot)}
onChange={(config) => {
const change = thinkingChange(slot, config);
if (change) void run('thinking', change, false);
}}
t={t}
/>
</div>
)}
{message && (
<p
role="alert"
className="mx-2 mb-2 flex items-start gap-1.5 rounded-md bg-destructive/10 px-2.5 py-2 text-[11px] leading-relaxed text-destructive"
>
<AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-words">{message}</span>
</p>
)}
{(description || onManageProviders) && (
<div className="flex items-start gap-1.5 border-t px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0">
{description && <>{description} </>}
{onManageProviders && (
<button
type="button"
onClick={onManageProviders}
className="font-medium text-primary underline-offset-2 hover:underline"
>
{t(`${MS}.picker.manageProviders`)}
</button>
)}
</span>
</div>
)}
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
'use client';
import { forwardRef, useState } from 'react';
import { ChevronDown, Lock } from 'lucide-react';
import { toast } from 'sonner';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { ModelSettingsView, SlotView, ApplyChange } from '@/lib/model-settings/client';
import { stationLit, type PlacedStation, type StationLine } from '@/lib/model-settings/diagram';
import { providersFor, type OffMemory } from '@/lib/model-settings/edit';
import { flipSwitch, switchChecked } from '@/lib/model-settings/services';
import { cn } from '@/lib/utils';
import { SlotPicker } from './slot-picker';
import { MS, SlotIcon, applyErrorText, slotName } from './slot-meta';
import { lineText } from './station-text';
type T = (key: string, options?: Record<string, unknown>) => string;
type Apply = ApplyChange;
/** Shared by every node: which picker is open, how to apply, where services are managed. */
export interface NodeContext {
view: ModelSettingsView;
apply: Apply;
t: T;
openKey: string | null;
setOpenKey: (key: string | null) => void;
/** Open Model Services, where the services the slots use are set up. */
onManageProviders: () => void;
/** What each switched-off slot held, to restore when it is switched on. */
offMemory: OffMemory;
}
function SlotLine({
line,
slot,
ctx,
size,
}: {
line: StationLine;
slot: SlotView;
ctx: NodeContext;
size: 'root' | 'station';
}) {
const { view, apply, t, openKey, setOpenKey } = ctx;
const text = lineText(view, slot, t);
const name = slotName(t, slot.slot);
const key = line.labelKey ? t(`${MS}.stations.lines.${line.labelKey}`) : undefined;
const open = openKey === slot.slot;
const [switching, setSwitching] = useState(false);
// A switch wherever there is something to turn on or off: a slot that runs
// (speech input runs in the browser while it is unset), one switched off,
// or one some service could serve.
const toggleable =
!!line.toggle &&
!slot.locked &&
(slot.effective.status === 'assigned' ||
(slot.effective.status === 'disabled' && slot.assignment === null) ||
(slot.effective.status === 'unassigned' &&
(slot.capability === 'asr' || providersFor(view, slot.capability).length > 0)));
const content = (
<>
{key && (
<span className="row-span-2 min-w-6 self-center text-[11px] text-muted-foreground/80">
{key}
</span>
)}
<span
className={cn(
'min-w-0 truncate',
size === 'root' ? 'text-[15px] tracking-tight' : 'text-[12.5px]',
text.tone === 'own' && 'font-semibold',
text.tone === 'inherit' && 'text-muted-foreground',
(text.tone === 'off' || text.tone === 'none') && 'text-muted-foreground/70',
text.tone === 'none' && !slot.parent && 'text-amber-600 dark:text-amber-400',
text.tone === 'invalid' && 'text-destructive',
)}
>
{text.value}
</span>
{!slot.locked && (
<ChevronDown
className={cn(
'row-start-1 size-3.5 self-center text-muted-foreground/70 opacity-0 transition-opacity group-hover/line:opacity-100',
open && 'opacity-100',
key ? 'col-start-3' : 'col-start-2',
)}
aria-hidden="true"
/>
)}
{text.source && (
<span
className={cn(
'min-w-0 truncate text-[11px] text-muted-foreground/80',
key ? 'col-start-2' : 'col-start-1',
)}
>
{text.source}
</span>
)}
</>
);
const grid = cn(
'group/line grid min-w-0 flex-1 items-baseline gap-x-1.5 rounded-md px-1.5 py-1 text-left',
key ? 'grid-cols-[auto_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_auto]',
);
return (
<div className="flex items-center gap-1.5">
{slot.locked ? (
<div className={grid} title={`${text.value}${text.source ? ` · ${text.source}` : ''}`}>
{content}
</div>
) : (
<Popover open={open} onOpenChange={(next) => setOpenKey(next ? slot.slot : null)}>
<PopoverTrigger asChild>
<button
type="button"
data-slot-id={slot.slot}
aria-label={t(`${MS}.card.edit`, { name, value: text.value })}
className={cn(
grid,
'cursor-pointer transition-colors hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-primary',
open && 'bg-muted/70',
)}
>
{content}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
collisionPadding={12}
className="w-[272px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl p-0"
onWheelCapture={(event) => event.stopPropagation()}
// Open on the picker's Tab stop: the current choice, else the first row.
onOpenAutoFocus={(event) => {
const row = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>(
'[data-picker-row][tabindex="0"]',
);
if (!row) return;
event.preventDefault();
row.focus();
}}
>
<SlotPicker
view={view}
slot={slot}
apply={apply}
onDone={() => setOpenKey(null)}
onManageProviders={() => {
setOpenKey(null);
ctx.onManageProviders();
}}
t={t}
/>
</PopoverContent>
</Popover>
)}
{toggleable && (
<Switch
checked={switchChecked(slot)}
disabled={switching}
aria-label={t(`${MS}.card.toggle`, { name })}
className="mr-1 h-4 w-7 [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3"
onCheckedChange={async (on) => {
setSwitching(true);
try {
const result = await flipSwitch(apply, view, slot, on, ctx.offMemory);
// Nothing known to restore and nothing to serve it: let the user choose.
if (result === 'needs-service') setOpenKey(slot.slot);
else if (!result.ok) toast.error(applyErrorText(result, t));
} finally {
setSwitching(false);
}
}}
/>
)}
</div>
);
}
/** A card on the map: a station's title and one line per slot it holds. */
export const StationNode = forwardRef<
HTMLDivElement,
{
station: PlacedStation;
ctx: NodeContext;
x: number;
y: number;
width: number;
/** Root only: nothing offers a language model yet (set one up in Model Services). */
empty?: 'workspace' | 'server';
/** Expandable stations: whether the children are drawn, and how to toggle it. */
expanded?: boolean;
onExpand?: () => void;
followers?: number;
}
>(function StationNode({ station, ctx, x, y, width, empty, expanded, onExpand, followers }, ref) {
const { t } = ctx;
const first = station.lines[0].view;
const lit = stationLit(station);
const locked = station.lines.some((line) => line.view.locked);
const root = station.kind === 'root';
const title = station.labelKey
? t(`${MS}.stations.${station.labelKey}`)
: slotName(t, first.slot);
const ownChildren = station.children.filter(
(slot) => slot.assignment !== undefined || slot.locked,
).length;
// With nothing that offers a language model, the root's line has nothing to pick from.
const showLines = !root || !empty;
return (
<div
ref={ref}
data-station={station.id}
className={cn(
'absolute rounded-[10px] border bg-card text-card-foreground transition-[border-color,background-color,opacity] duration-500',
root ? 'px-2.5 pb-2.5 pt-3' : 'px-2 pb-2 pt-2.5',
lit
? cn(
'border-border shadow-[0_1px_2px_rgb(0_0_0/0.05),0_4px_12px_-2px_rgb(0_0_0/0.06)]',
root && 'border-primary/40',
)
: 'border-dashed border-border bg-card/60',
)}
style={{ left: x, top: y, width }}
>
<div className="flex items-center gap-2 px-1 pb-1">
<span
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-[7px]',
lit ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground/70',
)}
aria-hidden="true"
>
<SlotIcon slot={first.slot} capability={first.capability} className="size-3.5" />
</span>
<span
className={cn(
'min-w-0 flex-1 truncate font-semibold',
root ? 'text-[13.5px]' : 'text-[13px]',
!lit && 'text-muted-foreground',
)}
>
{title}
</span>
{locked && (
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 text-muted-foreground/70"
role="img"
tabIndex={0}
aria-label={t(`${MS}.card.locked`)}
>
<Lock className="size-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-56 text-xs">
{t(`${MS}.card.lockedHint`)}
</TooltipContent>
</Tooltip>
)}
</div>
{showLines &&
station.lines.map((line) => (
<SlotLine
key={line.slot}
line={line}
slot={line.view}
ctx={ctx}
size={root ? 'root' : 'station'}
/>
))}
{root && empty === 'workspace' && (
<div className="flex flex-col items-start gap-2 px-1.5 pb-0.5 pt-2">
<p className="text-xs leading-relaxed text-muted-foreground">{t(`${MS}.empty.prompt`)}</p>
<button
type="button"
onClick={ctx.onManageProviders}
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
>
{t(`${MS}.empty.open`)}
</button>
</div>
)}
{root && empty === 'server' && (
<p className="px-1.5 pt-1 text-xs leading-relaxed text-muted-foreground">
{t(`${MS}.empty.askAdmin`)}
</p>
)}
{root && followers !== undefined && first.effective.status === 'assigned' && (
<p className="px-1.5 pt-0.5 text-[11.5px] text-muted-foreground">
{t(`${MS}.card.followers`, { count: followers })}
</p>
)}
{onExpand && station.children.length > 0 && (
<button
type="button"
aria-expanded={expanded}
onClick={onExpand}
className="mx-1 mt-1 flex w-[calc(100%-0.5rem)] items-center gap-1.5 rounded-b-md border-t px-1.5 pb-0.5 pt-1.5 text-left text-[11.5px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
{ownChildren
? t(`${MS}.card.childrenOwn`, { count: ownChildren })
: t(`${MS}.card.childrenFollow`)}
<ChevronDown
className={cn(
'ml-auto size-3.5 transition-transform duration-300',
expanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
)}
</div>
);
});
@@ -0,0 +1,55 @@
import type { ModelSettingsView, SlotView } from '@/lib/model-settings/client';
import {
PROVIDER_ONLY_CAPABILITIES,
modelName,
providerLabel,
slotSource,
} from '@/lib/model-settings/edit';
import { MS, slotName } from './slot-meta';
type T = (key: string, options?: Record<string, unknown>) => string;
export interface LineText {
/** The model (or the provider, for its default model), or the slot's state. */
value: string;
/** Where it comes from: the provider, the parent it follows, the server. */
source: string;
tone: 'own' | 'inherit' | 'off' | 'none' | 'invalid';
}
/** What a slot's line on a card says. */
export function lineText(view: ModelSettingsView, slot: SlotView, t: T): LineText {
const effective = slot.effective;
const source = slotSource(slot);
const follows =
source.kind === 'inherited'
? t(`${MS}.source.inherited`, { name: slotName(t, source.from) })
: undefined;
if (effective.status === 'assigned') {
const provider = providerLabel(view, effective.providerId);
const value = effective.modelId
? modelName(view, slot.capability, effective.providerId, effective.modelId)
: provider;
if (follows) return { value, source: follows, tone: 'inherit' };
const origin =
source.kind === 'deployment' || source.kind === 'default'
? t(`${MS}.source.${source.kind}`)
: undefined;
// Search and document providers have no model to name: the value is the provider.
const via = effective.modelId
? provider
: PROVIDER_ONLY_CAPABILITIES.includes(slot.capability)
? undefined
: t(`${MS}.card.providerDefault`);
return { value, source: [via, origin].filter(Boolean).join(' · '), tone: 'own' };
}
if (effective.status === 'disabled') {
return { value: t(`${MS}.card.off`), source: follows ?? '', tone: 'off' };
}
if (effective.status === 'invalid') {
return { value: t(`${MS}.card.invalid`), source: effective.message, tone: 'invalid' };
}
return { value: t(`${MS}.card.unassigned`), source: follows ?? '', tone: 'none' };
}
+80 -297
View File
@@ -2,16 +2,22 @@
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import { MINERU_CLOUD_DEFAULT_BASE, PDF_PROVIDERS } from '@/lib/pdf/constants';
import type { PDFProviderId } from '@/lib/pdf/types';
import { getFormatLabelsForProviders } from '@/lib/document/mime';
import { CheckCircle2, Eye, EyeOff, Loader2, Zap, XCircle } from 'lucide-react';
import { CheckCircle2, Loader2, Zap, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
ApiKeyField,
EndpointServerOnlyHint,
ServerConfiguredNotice,
ServerOnlyNotice,
saveServiceProvider,
type ServicePanelProps,
} from './server-settings';
/**
* Get display label for feature
@@ -28,67 +34,34 @@ function getFeatureLabel(feature: string, t: (key: string) => string): string {
return labels[feature] || feature;
}
interface PDFSettingsProps {
selectedProviderId: PDFProviderId;
}
export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
/**
* A document parsing service. MinerU Cloud takes a key; AliDocMind's key pair
* and a self-hosted MinerU are the server's to configure; the built-in
* parsers need nothing.
*/
export function PDFSettings({ view, apply, entry }: ServicePanelProps) {
const { t } = useI18n();
const [showApiKey, setShowApiKey] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const pdfProvidersConfig = useSettingsStore((state) => state.pdfProvidersConfig);
const setPDFProviderConfig = useSettingsStore((state) => state.setPDFProviderConfig);
const pdfProvider = PDF_PROVIDERS[selectedProviderId];
const isServerConfigured = !!pdfProvidersConfig[selectedProviderId]?.isServerConfigured;
const providerConfig = pdfProvidersConfig[selectedProviderId];
const hasApiKey = !!providerConfig?.apiKey;
const hasBaseUrl = !!providerConfig?.baseUrl;
const isCloud = selectedProviderId === 'mineru-cloud';
const isSelfHosted = selectedProviderId === 'mineru';
const isAliDocMind = selectedProviderId === 'alidocmind';
const hasAccessKeys = !!providerConfig?.accessKeyId && !!providerConfig?.accessKeySecret;
const needsRemoteConfig = isSelfHosted || isCloud || isAliDocMind;
// For cloud: test requires API key (user-entered or server-configured);
// for AliDocMind: requires AK + SK; for self-hosted: requires base URL.
const canTest = isAliDocMind
? hasAccessKeys || isServerConfigured
: isCloud
? hasApiKey || isServerConfigured
: hasBaseUrl || isServerConfigured;
// Reset state when provider changes
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
setPrevSelectedProviderId(selectedProviderId);
setShowApiKey(false);
setTestStatus('idle');
setTestMessage('');
}
const providerId = entry.registryId as PDFProviderId;
const pdfProvider = PDF_PROVIDERS[providerId];
const provider = entry.provider;
const isCloud = providerId === 'mineru-cloud';
const needsRemoteConfig = !!pdfProvider?.requiresApiKey || !!pdfProvider?.requiresCredentials;
const editable =
(entry.state === 'workspace' || entry.state === 'available') && needsRemoteConfig;
const handleTestConnection = async () => {
setTestStatus('testing');
setTestMessage('');
try {
const response = await fetch('/api/verify-pdf-provider', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: selectedProviderId,
apiKey: providerConfig?.apiKey || '',
baseUrl: providerConfig?.baseUrl || '',
accessKeyId: providerConfig?.accessKeyId || '',
accessKeySecret: providerConfig?.accessKeySecret || '',
}),
body: JSON.stringify({ provider: entry.id }),
});
const data = await response.json();
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.connectionSuccess'));
@@ -105,218 +78,53 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
{entry.state === 'deployment' && <ServerConfiguredNotice />}
{entry.state === 'server-only' && (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
)}
{/* Configuration section — hidden for managed providers, which are
admin-owned and not overridable from the client. */}
{!isServerConfigured && needsRemoteConfig && (
{editable && (
<>
<div className="grid grid-cols-2 gap-4">
{/* API Key — shown first for cloud, second for self-hosted */}
{isCloud && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfApiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`pdf-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={t('settings.mineruCloudApiKeyPlaceholder')}
value={providerConfig?.apiKey || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !canTest}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
</div>
)}
{/* AliDocMind uses Aliyun AccessKey ID + Secret (not a single apiKey). */}
{isAliDocMind && (
<>
<div className="space-y-2">
<Label className="text-sm">{t('settings.alidocmindAccessKeyId')}</Label>
<Input
name={`pdf-ak-id-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="LTAI..."
value={providerConfig?.accessKeyId || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { accessKeyId: e.target.value })
}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">{t('settings.alidocmindAccessKeySecret')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`pdf-ak-secret-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={t('settings.enterApiKey')}
value={providerConfig?.accessKeySecret || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, {
accessKeySecret: e.target.value,
})
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !canTest}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
</div>
</>
)}
{/* Base URL */}
{(isSelfHosted || isCloud) && (
<div className="space-y-2">
<Label className="text-sm">
{t('settings.pdfBaseUrl')}
{isCloud && (
<span className="text-muted-foreground ml-1 font-normal">
({t('settings.optional')})
</span>
)}
</Label>
<div className="flex gap-2">
<Input
name={`pdf-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={isCloud ? 'https://mineru.net/api/v4' : 'http://localhost:8080'}
value={providerConfig?.baseUrl || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value })
}
className="text-sm"
/>
{/* Test button for self-hosted (next to base URL) */}
{isSelfHosted && (
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !canTest}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
)}
</div>
</div>
)}
{/* API Key for self-hosted (optional, second column) */}
{isSelfHosted && (
<div className="space-y-2">
<Label className="text-sm">
{t('settings.pdfApiKey')}
<span className="text-muted-foreground ml-1 font-normal">
({t('settings.optional')})
</span>
</Label>
<div className="relative">
<Input
name={`pdf-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={t('settings.enterApiKey')}
value={providerConfig?.apiKey || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfApiKey')}</Label>
<ApiKeyField
name={`pdf-api-key-${entry.id}`}
provider={provider}
placeholder={isCloud ? t('settings.mineruCloudApiKeyPlaceholder') : undefined}
inputClassName="font-mono text-sm"
onSave={(apiKey) => saveServiceProvider(view, apply, entry, { apiKey }, t)}
onRemove={() => saveServiceProvider(view, apply, entry, { apiKey: '' }, t)}
>
<Button
variant="outline"
size="sm"
onClick={() => void handleTestConnection()}
disabled={testStatus === 'testing' || !provider}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</ApiKeyField>
</div>
{/* Base URL: the service's own; another endpoint is the server's to set. */}
{isCloud && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfBaseUrl')}</Label>
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {MINERU_CLOUD_DEFAULT_BASE}/file-urls/batch
</p>
<EndpointServerOnlyHint />
</div>
)}
{/* Test result message */}
{testMessage && (
<div
@@ -335,33 +143,6 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
</div>
</div>
)}
{/* Request URL Preview */}
{(() => {
if (isAliDocMind) {
const base = providerConfig?.baseUrl || 'docmind-api.cn-hangzhou.aliyuncs.com';
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {base.replace(/^https?:\/\//, '')}
</p>
);
}
if (isCloud) {
const base = providerConfig?.baseUrl || 'https://mineru.net/api/v4';
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {base}/file-urls/batch
</p>
);
}
const effectiveBaseUrl = providerConfig?.baseUrl || '';
if (!effectiveBaseUrl) return null;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl}/file_parse
</p>
);
})()}
</>
)}
@@ -369,7 +150,7 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
<div className="space-y-2">
<Label className="text-sm">{t('settings.supportedFormats')}</Label>
<div className="flex flex-wrap gap-2">
{getFormatLabelsForProviders([selectedProviderId]).map((format) => (
{getFormatLabelsForProviders([providerId]).map((format) => (
<Badge key={format} variant="secondary" className="font-normal">
<CheckCircle2 className="h-3 w-3 mr-1" />
{format}
@@ -378,23 +159,25 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
</div>
{/* Self-hosted MinerU can under-deliver on PDF/image formats when the
server lacks the pipeline/core extras — warn against over-promising. */}
{isSelfHosted && (
{providerId === 'mineru' && (
<p className="text-xs text-muted-foreground">{t('settings.mineruSelfHostFormatsNote')}</p>
)}
</div>
{/* Features List */}
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfFeatures')}</Label>
<div className="flex flex-wrap gap-2">
{pdfProvider.features.map((feature) => (
<Badge key={feature} variant="secondary" className="font-normal">
<CheckCircle2 className="h-3 w-3 mr-1" />
{getFeatureLabel(feature, t)}
</Badge>
))}
{pdfProvider && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfFeatures')}</Label>
<div className="flex flex-wrap gap-2">
{pdfProvider.features.map((feature) => (
<Badge key={feature} variant="secondary" className="font-normal">
<CheckCircle2 className="h-3 w-3 mr-1" />
{getFeatureLabel(feature, t)}
</Badge>
))}
</div>
</div>
</div>
)}
</div>
);
}
+203 -253
View File
@@ -1,10 +1,9 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
@@ -19,8 +18,6 @@ import {
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
RotateCcw,
Plus,
Zap,
@@ -34,125 +31,69 @@ import {
ExternalLink,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ProviderConfig } from '@/lib/ai/providers';
import type { ProvidersConfig } from '@/lib/types/settings';
import { createVerifyModelRequest, formatContextWindow } from './utils';
import { PROVIDERS, type ProviderId } from '@/lib/ai/providers';
import type { CatalogueModel } from '@/lib/config/provider-presets';
import { formatContextWindow } from './utils';
import { PROVIDER_SIGNUP_LINKS } from './provider-links';
import { ModelEditDialog } from './model-edit-dialog';
import {
ApiKeyField,
ServerConfiguredNotice,
ServerOnlyNotice,
saveServiceProvider,
type ProviderFields,
type ServicePanelProps,
verifySavedModel,
} from './server-settings';
import { cn } from '@/lib/utils';
interface ProviderConfigPanelProps {
provider: ProviderConfig;
initialApiKey: string;
initialBaseUrl: string;
initialRequiresApiKey: boolean;
providersConfig: ProvidersConfig;
onConfigChange: (apiKey: string, baseUrl: string, requiresApiKey: boolean) => void;
onSave: () => void; // Auto-save on blur
onEditModel: (index: number) => void;
onDeleteModel: (index: number) => void;
onAddModel: () => void;
/** Merge probed model ids into the provider's list; returns the count added. */
onModelsFetched?: (ids: string[]) => number;
/** Optional explicit /models URL override (from a preset). */
modelsUrl?: string;
onResetToDefault?: () => void; // Reset provider to default configuration
isBuiltIn: boolean; // To determine if reset button should be shown
}
export function ProviderConfigPanel({
provider,
initialApiKey,
initialBaseUrl,
initialRequiresApiKey,
providersConfig,
onConfigChange,
onSave,
onEditModel,
onDeleteModel,
onAddModel,
onModelsFetched,
modelsUrl,
onResetToDefault,
isBuiltIn,
}: ProviderConfigPanelProps) {
/**
* A language model service: its key (write-only), its endpoint where the
* server lets a workspace set one, and its models. A service the server
* configures is shown read-only.
*/
export function ProviderConfigPanel({ view, apply, entry }: ServicePanelProps) {
const { t } = useI18n();
const signupLinks = PROVIDER_SIGNUP_LINKS[provider.id];
const registry = PROVIDERS[entry.registryId as ProviderId];
const provider = entry.provider;
const signupLinks = PROVIDER_SIGNUP_LINKS[entry.id];
const editable = entry.state === 'workspace' || entry.state === 'available';
const serverConfigured = entry.state === 'deployment';
// Local state for this provider
const [apiKey, setApiKey] = useState(initialApiKey);
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
const [requiresApiKey, setRequiresApiKey] = useState(initialRequiresApiKey);
const [showApiKey, setShowApiKey] = useState(false);
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '');
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [showResetDialog, setShowResetDialog] = useState(false);
const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle');
const [fetchMessage, setFetchMessage] = useState('');
const [editing, setEditing] = useState<{ index: number | null; id: string } | null>(null);
// Update local state when provider changes or initial values change
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync local state from props on provider change
setApiKey(initialApiKey);
const models: CatalogueModel[] = useMemo(
() => provider?.capabilities.chat?.models ?? entry.preset?.capabilities.chat?.models ?? [],
[provider, entry.preset],
);
// A provider's own model list narrows (or names) what it serves.
const pinned = !!provider?.models?.length;
// Only the workspace's own endpoint may be changed, and only for chat services.
const endpointEditable = editable && !!entry.preset?.customEndpoint;
setBaseUrl(initialBaseUrl);
const save = useCallback(
(fields: ProviderFields) => saveServiceProvider(view, apply, entry, fields, t),
[view, apply, entry, t],
);
setRequiresApiKey(initialRequiresApiKey);
setTestStatus('idle');
setTestMessage('');
setFetchStatus('idle');
setFetchMessage('');
}, [provider.id, initialApiKey, initialBaseUrl, initialRequiresApiKey]);
// Notify parent of changes
const handleApiKeyChange = (key: string) => {
setApiKey(key);
onConfigChange(key, baseUrl, requiresApiKey);
};
const handleBaseUrlChange = (url: string) => {
setBaseUrl(url);
onConfigChange(apiKey, url, requiresApiKey);
};
const handleRequiresApiKeyChange = (requires: boolean) => {
setRequiresApiKey(requires);
onConfigChange(apiKey, baseUrl, requires);
};
const saveModels = (ids: string[] | null) => save({ models: ids && ids.length ? ids : null });
const handleTestApi = useCallback(async () => {
setTestStatus('testing');
setTestMessage('');
const availableModels = providersConfig[provider.id]?.models || [];
if (availableModels.length === 0) {
if (models.length === 0) {
setTestStatus('error');
setTestMessage(t('settings.noModelsAvailable') || 'No models available for testing');
return;
}
const testModelId = availableModels[0].id;
try {
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
createVerifyModelRequest({
providerId: provider.id,
modelId: testModelId,
apiKey,
baseUrl,
providerType: provider.type,
requiresApiKey,
}),
),
});
const data = await response.json();
const data = await verifySavedModel(entry.id, models[0].id);
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.connectionSuccess'));
@@ -164,11 +105,10 @@ export function ProviderConfigPanel({
setTestStatus('error');
setTestMessage(t('settings.connectionFailed'));
}
}, [apiKey, baseUrl, provider.id, provider.type, requiresApiKey, providersConfig, t]);
}, [entry.id, models, t]);
const effectiveBaseUrl = baseUrl || provider.defaultBaseUrl || '';
// Probe the provider's /models endpoint and merge results into the model list.
// Ask the provider which models it serves (on the server, with its stored
// key) and add them to its list.
const handleFetchModels = useCallback(async () => {
setFetchStatus('fetching');
setFetchMessage('');
@@ -176,16 +116,24 @@ export function ProviderConfigPanel({
const response = await fetch('/api/provider/probe-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl: effectiveBaseUrl, apiKey, modelsUrl }),
body: JSON.stringify({ provider: entry.id }),
});
const data = await response.json();
if (response.ok && data.success) {
const ids: string[] = (data.models || []).map((m: { id: string }) => m.id);
const added = onModelsFetched?.(ids) ?? 0;
const current = models.map((model) => model.id);
const additions = ids.filter((id) => !current.includes(id));
// Report models as added only once the server saved them; a refused
// or lost write leaves a failure the user can retry.
if (additions.length && !(await saveModels([...current, ...additions]))) {
setFetchStatus('error');
setFetchMessage(t('settings.serverConfig.fetchNotSaved'));
return;
}
setFetchStatus('success');
setFetchMessage(
t('settings.fetchModelsResult')
.replace('{added}', String(added))
.replace('{added}', String(additions.length))
.replace('{total}', String(ids.length)),
);
} else if (response.status === 404) {
@@ -202,27 +150,30 @@ export function ProviderConfigPanel({
setFetchStatus('error');
setFetchMessage(t('settings.fetchModelsFailed'));
}
}, [apiKey, effectiveBaseUrl, modelsUrl, onModelsFetched, t]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry.id, models, t]);
const models = providersConfig[provider.id]?.models || [];
const isServerConfigured = providersConfig[provider.id]?.isServerConfigured;
// When the operator pins an allowed model list (MODELS env/yaml), the model
// catalog is admin-managed too — view-only, no add/edit/delete. Without a
// pinned list the server manages only credentials and the user curates models.
const modelsLocked = !!providersConfig[provider.id]?.serverModels?.length;
const commitBaseUrl = (value: string) => {
const next = value.trim();
if (next === (provider?.baseUrl ?? '')) return;
if (!provider && !next) return;
void save({ baseUrl: next || null });
};
const placeholderUrl =
registry?.baseUrlPlaceholder || registry?.defaultBaseUrl || 'https://api.example.com/v1';
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
{serverConfigured && <ServerConfiguredNotice />}
{entry.state === 'server-only' && (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
)}
{/* Managed providers are admin-owned: the operator's key and base URL are
authoritative and not overridable here, so the editing inputs are hidden. */}
{!isServerConfigured && (
{/* The server's providers are the operator's: their key and endpoint
are neither shown nor editable here. */}
{editable && (
<>
{/* 推广位(如 Kimi):获取 API key 的国内/海外双链接。 */}
{signupLinks && (
@@ -251,36 +202,18 @@ export function ProviderConfigPanel({
{/* API Key */}
<div className="space-y-2">
<Label>{t('settings.apiSecret')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`llm-api-key-${provider.id}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="sk-..."
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
onBlur={onSave}
disabled={!requiresApiKey}
className="h-8 pr-8"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={!requiresApiKey}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<ApiKeyField
name={`llm-api-key-${entry.id}`}
provider={provider}
placeholder="sk-..."
onSave={(apiKey) => save({ apiKey })}
onRemove={() => save({ apiKey: '' })}
>
<Button
variant="outline"
size="sm"
onClick={handleTestApi}
disabled={testStatus === 'testing' || (requiresApiKey && !apiKey)}
disabled={testStatus === 'testing' || !provider}
className="gap-1.5"
>
{testStatus === 'testing' ? (
@@ -292,7 +225,7 @@ export function ProviderConfigPanel({
</>
)}
</Button>
</div>
</ApiKeyField>
{testMessage && (
<div
className={cn(
@@ -308,107 +241,94 @@ export function ProviderConfigPanel({
</div>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id={`requires-api-key-${provider.id}`}
checked={requiresApiKey}
onCheckedChange={(checked) => {
handleRequiresApiKeyChange(checked as boolean);
onSave();
}}
/>
<label
htmlFor={`requires-api-key-${provider.id}`}
className="text-sm cursor-pointer text-muted-foreground"
>
{t('settings.requiresApiKey')}
</label>
</div>
</div>
{/* API Host */}
<div className="space-y-2">
<Label>{t('settings.apiHost')}</Label>
<Input
name={`llm-base-url-${provider.id}`}
type="url"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
provider.baseUrlPlaceholder ||
provider.defaultBaseUrl ||
'https://api.example.com/v1'
}
value={baseUrl}
onChange={(e) => handleBaseUrlChange(e.target.value)}
onBlur={onSave}
className="h-8"
/>
{provider.alternateBaseUrls && provider.alternateBaseUrls.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{provider.alternateBaseUrls.map((alt) => {
const active = (baseUrl || provider.defaultBaseUrl) === alt.url;
return (
<button
key={alt.url}
type="button"
onClick={() => {
handleBaseUrlChange(alt.url);
onSave();
}}
className={cn(
'px-2 py-0.5 text-xs rounded-md border transition-colors',
active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
{t(alt.label)}
</button>
);
})}
</div>
)}
{(() => {
const effectiveBaseUrl = baseUrl || provider.defaultBaseUrl || '';
if (!effectiveBaseUrl) return null;
// Generate endpoint path based on provider type
let endpointPath = '';
switch (provider.type) {
case 'openai':
endpointPath = '/chat/completions';
break;
case 'azure':
endpointPath = '/v1/responses?api-version=v1';
break;
case 'anthropic':
endpointPath = '/messages';
break;
case 'google':
endpointPath = '/models/[model]';
break;
default:
endpointPath = '';
}
const fullUrl = effectiveBaseUrl + endpointPath;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {fullUrl}
{endpointEditable && (
<div className="space-y-2">
<Label>{t('settings.apiHost')}</Label>
<Input
name={`llm-base-url-${entry.id}`}
type="url"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={placeholderUrl}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
onBlur={() => commitBaseUrl(baseUrl)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitBaseUrl(baseUrl);
}}
className="h-8"
/>
{registry?.alternateBaseUrls && registry.alternateBaseUrls.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{registry.alternateBaseUrls.map((alt) => {
const active = (baseUrl || registry.defaultBaseUrl) === alt.url;
return (
<button
key={alt.url}
type="button"
onClick={() => {
// The default endpoint is no endpoint of the provider's own.
const next = alt.url === registry.defaultBaseUrl ? '' : alt.url;
setBaseUrl(next);
commitBaseUrl(next);
}}
className={cn(
'px-2 py-0.5 text-xs rounded-md border transition-colors',
active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
{t(alt.label)}
</button>
);
})}
</div>
)}
{(() => {
const effectiveBaseUrl = baseUrl || registry?.defaultBaseUrl || '';
if (!effectiveBaseUrl) return null;
let endpointPath = '';
switch (registry?.type) {
case 'openai':
endpointPath = '/chat/completions';
break;
case 'azure':
endpointPath = '/v1/responses?api-version=v1';
break;
case 'anthropic':
endpointPath = '/messages';
break;
case 'google':
endpointPath = '/models/[model]';
break;
default:
endpointPath = '';
}
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl + endpointPath}
</p>
);
})()}
{provider?.baseUrl && Object.keys(provider.capabilities).length === 1 && (
<p className="text-xs text-muted-foreground">
{t('settings.serverConfig.endpointChatOnly')}
</p>
);
})()}
</div>
)}
</div>
)}
</>
)}
{/* Models - No selection state, just list for management */}
<div className="space-y-3">
{provider.id === 'azure' && (
{entry.registryId === 'azure' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
{t('settings.azureDeploymentHint')}
</div>
@@ -416,15 +336,15 @@ export function ProviderConfigPanel({
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Label className="text-base">{t('settings.models')}</Label>
{modelsLocked && (
{serverConfigured && (
<span className="text-[10px] px-1 py-0 h-4 leading-4 rounded bg-muted text-muted-foreground">
{t('settings.serverConfigured')}
</span>
)}
</div>
{!modelsLocked && (
{editable && (
<div className="flex items-center gap-2 flex-wrap">
{isBuiltIn && onResetToDefault && (
{pinned && entry.preset?.capabilities.chat?.models.length ? (
<Button
variant="outline"
size="sm"
@@ -434,13 +354,13 @@ export function ProviderConfigPanel({
<RotateCcw className="h-3.5 w-3.5" />
{t('settings.reset')}
</Button>
)}
{provider.supportsModelDiscovery !== false && (
) : null}
{registry?.supportsModelDiscovery !== false && (
<Button
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={fetchStatus === 'fetching' || (requiresApiKey && !apiKey)}
disabled={fetchStatus === 'fetching' || !provider}
className="gap-1.5"
>
{fetchStatus === 'fetching' ? (
@@ -451,7 +371,12 @@ export function ProviderConfigPanel({
{t('settings.fetchModels')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onAddModel} className="gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setEditing({ index: null, id: '' })}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{t('settings.addNewModel')}
</Button>
@@ -521,15 +446,16 @@ export function ProviderConfigPanel({
</div>
</div>
{/* Edit/Delete Buttons — hidden when the model catalog is server-managed */}
{!modelsLocked && (
{/* Edit/Delete Buttons — only for the workspace's own services */}
{editable && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => onEditModel(index)}
onClick={() => setEditing({ index, id: model.id })}
title={t('settings.editModel')}
aria-label={`${t('settings.editModel')} ${model.id}`}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
@@ -537,8 +463,13 @@ export function ProviderConfigPanel({
variant="outline"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDeleteModel(index)}
onClick={() =>
void saveModels(
models.map((entryModel) => entryModel.id).filter((_, i) => i !== index),
)
}
title={t('settings.deleteModel')}
aria-label={`${t('settings.deleteModel')} ${model.id}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -550,6 +481,25 @@ export function ProviderConfigPanel({
</div>
</div>
{/* Edit Model Dialog */}
<ModelEditDialog
open={editing !== null}
onOpenChange={(open) => !open && setEditing(null)}
modelId={editing?.id ?? ''}
isNew={editing?.index === null}
providerId={provider ? entry.id : undefined}
onSave={async (id) => {
const ids = models.map((model) => model.id);
if (editing?.index === null || editing === null) {
if (!ids.includes(id)) ids.push(id);
} else {
ids[editing.index] = id;
}
await saveModels([...new Set(ids)]);
setEditing(null);
}}
/>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
@@ -562,7 +512,7 @@ export function ProviderConfigPanel({
<AlertDialogAction
onClick={() => {
setShowResetDialog(false);
onResetToDefault?.();
void saveModels(null);
}}
>
{t('settings.confirmReset')}
+27 -32
View File
@@ -3,23 +3,26 @@
import { Box, ExternalLink, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { ProviderId, ProviderConfig } from '@/lib/ai/providers';
import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
import { PINNED_PROVIDER_ID, PROVIDER_SIGNUP_LINKS, pickRegionalLink } from './provider-links';
interface ProviderWithServerInfo extends ProviderConfig {
isServerConfigured?: boolean;
apiKey?: string;
export interface ProviderListItem {
id: string;
name: string;
icon?: string;
/** The registry entry, for the logo's dark-mode treatment and signup links. */
registryId: string;
configured: boolean;
}
interface ProviderListProps {
providers: ProviderWithServerInfo[];
selectedProviderId: ProviderId;
onSelect: (providerId: ProviderId) => void;
onAddProvider: () => void;
providers: ProviderListItem[];
selectedProviderId: string;
onSelect: (providerId: string) => void;
onAddProvider?: () => void;
}
// 「模型服务」分区内的语言模型服务列表:样式对齐原型
// 「模型服务」分区内的服务列表:样式对齐原型
// model-services-panel(头像 + 名称 + 配置状态点,选中为描边卡片)。
export function ProviderList({
providers,
@@ -29,36 +32,26 @@ export function ProviderList({
}: ProviderListProps) {
const { t, locale } = useI18n();
// Helper function to get translated provider name
const getProviderDisplayName = (provider: ProviderConfig) => {
const translationKey = `settings.providerNames.${provider.id}`;
const translated = t(translationKey);
// If translation exists (not equal to key), use it; otherwise fallback to provider.name
return translated !== translationKey ? translated : provider.name;
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex-1 space-y-1 overflow-y-auto p-1">
{providers.map((provider) => {
const configured =
!!provider.isServerConfigured || !!provider.apiKey || provider.requiresApiKey === false;
const configured = provider.configured;
const active = selectedProviderId === provider.id;
// 推广位(如 Kimi):常驻主色描边强调(选中态沿用更深的选中描边),
// 行尾附获取 API key 的跳转(按界面语言分流国内/海外)。
const promoted = provider.id === PINNED_PROVIDER_ID;
const signupLinks = PROVIDER_SIGNUP_LINKS[provider.id];
const signupLinks = configured ? undefined : PROVIDER_SIGNUP_LINKS[provider.id];
return (
// 外链必须是 button 的兄弟节点(交互元素不可嵌套):绝对定位覆盖在
// 行右端,点击不会透传到选择按钮;有链接的行给按钮留出右侧空间。
<div key={provider.id} className="relative">
<button
onClick={() => onSelect(provider.id)}
aria-pressed={active}
className={cn(
'group flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors',
signupLinks && 'pr-8',
// 选中态与其他服务列(ProviderListColumn)完全一致;
// 推广强调只作用于未选中时,选中后回归标准样式。
active
? 'bg-background shadow-sm ring-1 ring-border/70'
: cn(
@@ -76,10 +69,10 @@ export function ProviderList({
{provider.icon ? (
<img
src={provider.icon}
alt={getProviderDisplayName(provider)}
alt={provider.name}
className={cn(
'size-4 object-contain',
MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
MONO_LOGO_PROVIDERS.has(provider.registryId) && 'dark:invert',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@@ -96,7 +89,7 @@ export function ProviderList({
active ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{getProviderDisplayName(provider)}
{provider.name}
</span>
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<span
@@ -127,13 +120,15 @@ export function ProviderList({
})}
{/* Add Provider(列表尾部的幽灵行) */}
<button
onClick={onAddProvider}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground/70 transition-colors hover:bg-background/60 hover:text-foreground"
>
<Plus className="size-3.5" />
{t('settings.addProviderButton')}
</button>
{onAddProvider && (
<button
onClick={onAddProvider}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground/70 transition-colors hover:bg-background/60 hover:text-foreground"
>
<Plus className="size-3.5" />
{t('settings.addProviderButton')}
</button>
)}
</div>
</div>
);
+365
View File
@@ -0,0 +1,365 @@
'use client';
/**
* Shared pieces of the settings panels that edit the workspace's model
* configuration on the server (`/api/model-config`): the loading and
* unavailable states, the notices for what only the server configures, the
* write-only API key field, and saving a provider from a service panel.
*/
import { useState, type ReactNode } from 'react';
import { AlertCircle, Eye, EyeOff, Loader2, RefreshCw, Server } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { SlotCapability } from '@/lib/config/model-slots';
import { useI18n } from '@/lib/hooks/use-i18n';
import type {
ApplyChange,
ApplyResult,
ModelSettingsChange,
ModelSettingsClient,
ModelSettingsView,
ProviderView,
SlotView,
} from '@/lib/model-settings/client';
import { findSlot } from '@/lib/model-settings/client';
import { useModelSettings } from '@/lib/model-settings/use-model-settings';
import {
assignmentsForNewProvider,
ROOT_SLOT,
type ServiceEntry,
} from '@/lib/model-settings/services';
import { cn } from '@/lib/utils';
export type T = (key: string, options?: Record<string, unknown>) => string;
export const SC = 'settings.serverConfig';
/** Why a change did not go through, in the user's language where it is ours to say. */
export function applyErrorText(result: { reason: string; message: string }, t: T): string {
if (result.reason === 'conflict') return t(`${SC}.conflict`);
if (result.reason === 'locked') return t(`${SC}.lockedNow`);
if (result.reason === 'unconfirmed') return t(`${SC}.unconfirmed`);
return result.message;
}
/** Report a change that did not go through. */
export function reportApply(
result: ApplyResult,
t: T,
): result is Extract<ApplyResult, { ok: true }> {
if (!result.ok) toast.error(applyErrorText(result, t));
return result.ok;
}
/**
* The workspace's model settings for a section: shows loading, an error with
* a retry, or that the server keeps none, and otherwise renders the section
* with the view and the way to change it.
*/
export function ServerSettingsGate({
client,
children,
}: {
client?: ModelSettingsClient;
children: (view: ModelSettingsView, apply: ApplyChange) => ReactNode;
}) {
const { t } = useI18n();
const { state, apply, reload } = useModelSettings(client);
const view = state.view;
if (state.phase === 'unavailable') {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-12 text-center">
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Server className="size-5" aria-hidden="true" />
</span>
<h3 className="text-sm font-semibold">{t(`${SC}.unavailableTitle`)}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(`${SC}.unavailableBody`)}
</p>
</div>
);
}
if (!view) {
if (state.phase === 'error') {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-12 text-center">
<AlertCircle className="size-5 text-destructive" aria-hidden="true" />
<p className="text-xs text-muted-foreground">
{t(`${SC}.loadFailed`, { message: state.error ?? '' })}
</p>
<Button variant="outline" size="sm" onClick={() => void reload()}>
<RefreshCw className="size-3.5" aria-hidden="true" />
{t(`${SC}.retry`)}
</Button>
</div>
);
}
return (
<div className="flex items-center justify-center gap-2 py-16 text-xs text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
{t(`${SC}.loading`)}
</div>
);
}
return <>{children(view, apply)}</>;
}
/** The notice of a service the server configures: nothing about it can be changed here. */
export function ServerConfiguredNotice() {
const { t } = useI18n();
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
);
}
/** Why a service cannot be set up here: only the server's configuration can. */
export function ServerOnlyNotice({ policy }: { policy?: boolean }) {
const { t } = useI18n();
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
{t(policy ? `${SC}.serverOnlyPolicy` : `${SC}.serverOnly`)}
</div>
);
}
/** Why an endpoint field is not offered: only the server may point this service elsewhere. */
export function EndpointServerOnlyHint() {
const { t } = useI18n();
return <p className="text-xs text-muted-foreground">{t(`${SC}.endpointServerOnly`)}</p>;
}
/**
* A write-only API key: the stored key is never shown, only its mask. Typing
* a key and leaving the field (or pressing Enter) saves it; "Remove key"
* removes the stored one.
*/
export function ApiKeyField({
provider,
onSave,
onRemove,
disabled,
placeholder,
name,
inputClassName,
children,
}: {
provider?: ProviderView;
/** Saves the key; resolves truthy when it was saved (the field keeps it otherwise). */
onSave: (key: string) => Promise<unknown> | unknown;
onRemove?: () => void | Promise<unknown>;
disabled?: boolean;
placeholder?: string;
/** The input's name (also its accessible name when no label wraps it). */
name: string;
inputClassName?: string;
/** Shown next to the field (a test button). */
children?: ReactNode;
}) {
const { t } = useI18n();
const [value, setValue] = useState('');
const [show, setShow] = useState(false);
const key = provider?.key;
const stored = !!key?.set && !key.unreadable;
const commit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
// Only a key the server took leaves the field; a refused one stays to fix.
if (await onSave(trimmed)) setValue('');
};
return (
<div className="space-y-1.5">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={name}
aria-label={name}
type={show ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
stored
? t(`${SC}.keyStoredPlaceholder`, { mask: key?.mask ?? '' })
: (placeholder ?? t('settings.enterApiKey'))
}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit();
}}
disabled={disabled}
className={cn('h-8 pr-8', inputClassName)}
/>
<button
type="button"
onClick={() => setShow(!show)}
aria-label={t(show ? `${SC}.hideKey` : `${SC}.showKey`)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{children}
</div>
{key?.unreadable ? (
<p className="text-xs text-amber-600 dark:text-amber-400">{t(`${SC}.keyUnreadable`)}</p>
) : stored ? (
<p className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>{t(`${SC}.keyStored`, { mask: key?.mask ?? '' })}</span>
{onRemove && (
<button
type="button"
onClick={() => void onRemove()}
disabled={disabled}
className="rounded-sm text-destructive underline-offset-2 hover:underline"
>
{t(`${SC}.removeKey`)}
</button>
)}
</p>
) : null}
</div>
);
}
/**
* The official regional endpoint of a service that has one per region (Azure
* Speech): the only endpoint a workspace provider of it takes. Leaving the
* field (or pressing Enter) saves it.
*/
export function RegionalEndpointField({
provider,
template,
name,
disabled,
onSave,
}: {
provider?: ProviderView;
/** The endpoint with `<region>` for the region. */
template: string;
name: string;
disabled?: boolean;
onSave: (baseUrl: string | null) => Promise<unknown> | unknown;
}) {
const { t } = useI18n();
const saved = provider?.baseUrl ?? '';
const [value, setValue] = useState(saved);
const commit = async () => {
const trimmed = value.trim();
if (trimmed === saved) return;
// A refused endpoint stays in the field to fix.
if (!(await onSave(trimmed || null))) return;
setValue(trimmed);
};
return (
<div className="space-y-1.5">
<Input
name={name}
aria-label={name}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={template}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit();
}}
disabled={disabled}
className="h-8 font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t(`${SC}.regionalEndpointHint`, { template })}
</p>
</div>
);
}
/** What a service panel may change about its provider. */
export interface ProviderFields {
apiKey?: string;
baseUrl?: string | null;
models?: string[] | null;
}
/**
* Save a service's provider: add it (with the entry's preset and id) or
* update it, against the view the panel shows. A provider just added fills
* the root slots of what it serves that have nothing set yet, as setting up a
* service always did.
*/
export async function saveServiceProvider(
view: ModelSettingsView,
apply: ApplyChange,
entry: ServiceEntry,
fields: ProviderFields,
t: T,
): Promise<ModelSettingsView | null> {
const preset = entry.provider?.preset ?? entry.preset?.id;
if (!preset) return null;
const change: ModelSettingsChange = { kind: 'provider', id: entry.id, preset, ...fields };
const result = await apply(change, view);
if (!reportApply(result, t)) return null;
if (entry.provider) return result.view;
const set = assignmentsForNewProvider(result.view, entry.id);
if (!Object.keys(set).length) return result.view;
const filled = await apply({ kind: 'slots', set }, result.view);
return reportApply(filled, t) ? filled.view : result.view;
}
/** Remove a workspace provider: its slots follow their parents again. */
export async function removeServiceProvider(
view: ModelSettingsView,
apply: ApplyChange,
providerId: string,
t: T,
): Promise<boolean> {
return reportApply(await apply({ kind: 'remove-provider', id: providerId }, view), t);
}
/** The props every Model Services panel takes. */
export interface ServicePanelProps {
view: ModelSettingsView;
apply: ApplyChange;
entry: ServiceEntry;
}
/**
* Whether the capability's root slot resolves to this provider, and the
* model it uses: what the service is used for (set in Course Model Config).
*/
export function rootUse(
view: ModelSettingsView,
capability: SlotCapability,
providerId: string,
): { inUse: boolean; modelId?: string; slot?: SlotView } {
const slot = findSlot(view, ROOT_SLOT[capability]);
const effective = slot?.effective;
if (effective?.status !== 'assigned' || effective.providerId !== providerId) {
return { inUse: false, slot };
}
return { inUse: true, modelId: effective.modelId, slot };
}
/** Test a saved chat provider's model on the server (no key leaves the browser). */
export async function verifySavedModel(
providerId: string,
modelId: string,
): Promise<{ success: boolean; error?: string }> {
const response = await fetch('/api/verify-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId, model: modelId }),
});
return (await response.json()) as { success: boolean; error?: string };
}
+180
View File
@@ -0,0 +1,180 @@
/**
* How the settings name and picture a service: each capability's built-in
* services (names, logos, whether they need a key), and the name and logo of
* an entry or a provider. Shared by Model Services, the course model map and
* the model pickers, so a provider looks the same everywhere. Data only (no
* panels), so the home toolbar can use it.
*/
import { MONO_LOGO_PROVIDERS, PROVIDERS } from '@/lib/ai/providers';
import { ASR_PROVIDERS, TTS_PROVIDERS } from '@/lib/audio/constants';
import { resolveASRProviderName, resolveTTSProviderName } from '@/lib/audio/provider-display';
import type { SlotCapability } from '@/lib/config/model-slots';
import { TOKEN_PLAN_PRESETS } from '@/lib/config/token-plan-presets';
import { tokenPlanPresetId } from '@/lib/config/preset-ids';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import type { ModelSettingsView } from '@/lib/model-settings/client';
import { entryConfigured, serviceEntries, type ServiceEntry } from '@/lib/model-settings/services';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
import { IMAGE_PROVIDER_NAMES, VIDEO_PROVIDER_NAMES } from './media-provider-names';
type T = (key: string, options?: Record<string, unknown>) => string;
const IMAGE_PROVIDER_ICONS: Record<string, string> = {
seedream: '/logos/doubao.svg',
'openai-image': '/logos/openai.svg',
'qwen-image': '/logos/bailian.svg',
'nano-banana': '/logos/gemini.svg',
'minimax-image': '/logos/minimax.svg',
'grok-image': '/logos/grok.svg',
'comfyui-image': '/logos/comfyui.svg',
'openrouter-image': '/logos/openrouter.svg',
lemonade: '/logos/lemonade.svg',
};
const VIDEO_PROVIDER_ICONS: Record<string, string> = {
seedance: '/logos/doubao.svg',
kling: '/logos/kling.svg',
veo: '/logos/gemini.svg',
'minimax-video': '/logos/minimax.svg',
'grok-video': '/logos/grok.svg',
'openrouter-video': '/logos/openrouter.svg',
happyhorse: '/logos/qwen.svg',
};
interface RegistryInfo {
ids: readonly string[];
name: (id: string, t: T) => string;
icon: (id: string) => string | undefined;
requiresApiKey: (id: string) => boolean;
}
type Entry = { name?: string; icon?: string; requiresApiKey?: boolean };
const entryOf = (registry: Record<string, Entry>, id: string): Entry | undefined => registry[id];
const translated = (t: T, key: string, fallback: string) => {
const text = t(key);
return text && text !== key ? text : fallback;
};
/** Each capability's built-in services: their names, logos and whether they need a key. */
export const REGISTRY_INFO: Record<SlotCapability, RegistryInfo> = {
chat: {
ids: Object.keys(PROVIDERS),
name: (id, t) =>
translated(t, `settings.providerNames.${id}`, entryOf(PROVIDERS, id)?.name ?? id),
icon: (id) => entryOf(PROVIDERS, id)?.icon,
requiresApiKey: (id) => entryOf(PROVIDERS, id)?.requiresApiKey !== false,
},
image: {
ids: Object.keys(IMAGE_PROVIDERS),
name: (id, t) =>
translated(
t,
`settings.${(IMAGE_PROVIDER_NAMES as Record<string, string>)[id]}`,
entryOf(IMAGE_PROVIDERS, id)?.name ?? id,
),
icon: (id) => IMAGE_PROVIDER_ICONS[id],
requiresApiKey: (id) => entryOf(IMAGE_PROVIDERS, id)?.requiresApiKey !== false,
},
video: {
ids: Object.keys(VIDEO_PROVIDERS),
name: (id, t) =>
translated(
t,
`settings.${(VIDEO_PROVIDER_NAMES as Record<string, string>)[id]}`,
entryOf(VIDEO_PROVIDERS, id)?.name ?? id,
),
icon: (id) => VIDEO_PROVIDER_ICONS[id],
requiresApiKey: (id) => entryOf(VIDEO_PROVIDERS, id)?.requiresApiKey !== false,
},
tts: {
ids: Object.keys(TTS_PROVIDERS),
name: (id, t) => resolveTTSProviderName(id, t),
icon: (id) => entryOf(TTS_PROVIDERS, id)?.icon,
requiresApiKey: (id) => entryOf(TTS_PROVIDERS, id)?.requiresApiKey !== false,
},
asr: {
ids: Object.keys(ASR_PROVIDERS),
name: (id, t) => resolveASRProviderName(id, t),
icon: (id) => entryOf(ASR_PROVIDERS, id)?.icon,
requiresApiKey: (id) => entryOf(ASR_PROVIDERS, id)?.requiresApiKey !== false,
},
document: {
ids: Object.keys(PDF_PROVIDERS),
name: (id) => entryOf(PDF_PROVIDERS, id)?.name ?? id,
icon: (id) => entryOf(PDF_PROVIDERS, id)?.icon,
requiresApiKey: (id) => entryOf(PDF_PROVIDERS, id)?.requiresApiKey !== false,
},
webSearch: {
ids: Object.keys(WEB_SEARCH_PROVIDERS),
name: (id, t) => getWebSearchProviderDisplayName(id as never, t),
icon: (id) => entryOf(WEB_SEARCH_PROVIDERS, id)?.icon,
requiresApiKey: (id) => entryOf(WEB_SEARCH_PROVIDERS, id)?.requiresApiKey !== false,
},
};
const PLAN_BY_PRESET = new Map(
TOKEN_PLAN_PRESETS.map((plan) => [tokenPlanPresetId(plan.id), plan]),
);
/**
* A service's name: a plan's own name; a built-in service's name for its
* entry (a provider named after it included); else the provider's preset and
* id ("OpenAI-compatible · gateway"), so two accounts of one service stay apart.
*/
export function entryName(entry: ServiceEntry, capability: SlotCapability, t: T): string {
const provider = entry.provider;
const plan = provider ? PLAN_BY_PRESET.get(provider.preset) : undefined;
if (plan) return plan.id === 'volcengine-ark' ? 'Seed' : plan.name;
if (!provider || entry.serviceId) {
return REGISTRY_INFO[capability].name(entry.serviceId ?? entry.registryId, t);
}
const presetName =
provider.preset === 'openai-compatible'
? t('settings.serverConfig.openaiCompatible')
: REGISTRY_INFO[capability].name(entry.registryId, t);
return `${presetName} · ${provider.id}`;
}
/** A service's logo: its plan's or built-in service's; none (a generic box) for a custom endpoint. */
export function entryIcon(entry: ServiceEntry, capability: SlotCapability): string | undefined {
const plan = entry.provider ? PLAN_BY_PRESET.get(entry.provider.preset) : undefined;
if (plan) return plan.icon;
if (entry.serviceId) return REGISTRY_INFO[capability].icon(entry.serviceId);
if (entry.provider?.preset === 'openai-compatible') return undefined;
return REGISTRY_INFO[capability].icon(entry.registryId);
}
export function isEntryConfigured(entry: ServiceEntry, capability: SlotCapability): boolean {
return entryConfigured(entry, REGISTRY_INFO[capability].requiresApiKey(entry.registryId));
}
/**
* A provider's logo for a capability, as its Model Services entry shows it:
* its plan's, its built-in service's (a provider named after one included),
* else its preset's; none (a generic icon) for a custom endpoint.
*/
export function providerLogo(
view: ModelSettingsView,
providerId: string,
capability: SlotCapability,
): string | undefined {
const entry = serviceEntries(view, capability, REGISTRY_INFO[capability].ids).find(
(item) => item.id === providerId,
);
return entry ? entryIcon(entry, capability) : undefined;
}
/** Whether a logo is a single-colour mark that needs inverting on a dark background. */
export function logoInverts(
view: ModelSettingsView,
providerId: string,
capability: SlotCapability,
) {
const entry = serviceEntries(view, capability, REGISTRY_INFO[capability].ids).find(
(item) => item.id === providerId,
);
return !!entry && MONO_LOGO_PROVIDERS.has(entry.serviceId ?? entry.registryId);
}
+176 -216
View File
@@ -3,12 +3,14 @@
// The "Token Plan" section: a two-column panel with a service tablist on the
// left (logo, display-name mapping, status row, keyboard navigation) and a
// one-line header on the right (status / update key / manage-account link / ⋯
// menu disconnect). Saving the key connects the plan by applying or removing a
// local settings-store preset — there is no server-side connection, OAuth, or
// quota consent. The "capabilities offered" block below stays a segmented tab
// list of models.
// menu disconnect). A plan is a provider of the workspace's model settings on
// the server: saving its key adds that provider (or replaces its key) and
// applies the plan's recommended configuration to the slots. When that would
// replace models the workspace picked, the user is asked first: the plan's
// setup, or keep theirs and fill only the empty slots. Disconnecting removes
// the provider. A plan the server configures is shown as connected, read-only.
import { useState, useCallback, useRef } from 'react';
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -45,23 +47,24 @@ import {
} from '@/components/ui/alert-dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useSettingsStore } from '@/lib/store/settings';
import {
TOKEN_PLAN_PRESETS,
MODALITY_ORDER,
type TokenPlanPreset,
type TokenPlanModality,
} from '@/lib/config/token-plan-presets';
import { tokenPlanPresetId } from '@/lib/config/preset-ids';
import type { ApplyChange, ModelSettingsView, PresetView } from '@/lib/model-settings/client';
import { modelName, providerLabel, splitRef } from '@/lib/model-settings/edit';
import { planProvider } from '@/lib/model-settings/services';
import {
applyTokenPlan,
isTokenPlanActive,
isTokenPlanUsable,
removeTokenPlan,
restoreSharedProviderCredentials,
setTokenPlanAuthorization,
} from '@/lib/config/apply-token-plan';
connectConflicts,
connectTokenPlan,
type PlanApplyMode,
type PlanConflict,
} from '@/lib/model-settings/token-plan';
import { applyErrorText, reportApply, ServerOnlyNotice } from './server-settings';
import { MS, slotName } from './models/slot-meta';
const MODALITY_LABEL_KEYS: Record<TokenPlanModality, string> = {
llm: 'settings.providers',
@@ -169,49 +172,39 @@ function modalityModels(preset: TokenPlanPreset, m: TokenPlanModality): string[]
return [target.providerId];
}
export function TokenPlanSettings() {
export function TokenPlanSettings({
view,
apply,
}: {
view: ModelSettingsView;
apply: ApplyChange;
}) {
const { t } = useI18n();
const setProviderConfig = useSettingsStore((s) => s.setProviderConfig);
const setImageProviderConfig = useSettingsStore((s) => s.setImageProviderConfig);
const setVideoProviderConfig = useSettingsStore((s) => s.setVideoProviderConfig);
const setTTSProviderConfig = useSettingsStore((s) => s.setTTSProviderConfig);
const setWebSearchProviderConfig = useSettingsStore((s) => s.setWebSearchProviderConfig);
const setImageProvider = useSettingsStore((s) => s.setImageProvider);
const setImageModelId = useSettingsStore((s) => s.setImageModelId);
const setVideoProvider = useSettingsStore((s) => s.setVideoProvider);
const setVideoModelId = useSettingsStore((s) => s.setVideoModelId);
const setModel = useSettingsStore((s) => s.setModel);
const setStageRoute = useSettingsStore((s) => s.setStageRoute);
const setTTSProvider = useSettingsStore((s) => s.setTTSProvider);
const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider);
// Read provider configs so the page can reflect already-persisted state
// (other settings panels read the store directly; this page must too).
const providersConfig = useSettingsStore((s) => s.providersConfig);
const tokenPlanEnrollments = useSettingsStore((s) => s.tokenPlanEnrollments);
const tokenPlanDisabled = useSettingsStore((s) => s.tokenPlanDisabled);
const [selectedId, setSelectedId] = useState<string>(TOKEN_PLAN_PRESETS[0]?.id ?? '');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [editingKey, setEditingKey] = useState(false);
const [saving, setSaving] = useState(false);
const [disconnectOpen, setDisconnectOpen] = useState(false);
/** A connect waiting for the user to choose how to treat the slots it would replace. */
const [pending, setPending] = useState<{
plan: TokenPlanPreset;
key: string;
conflicts: PlanConflict[];
} | null>(null);
const [activeTab, setActiveTab] = useState<TokenPlanModality>('llm');
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const selected =
TOKEN_PLAN_PRESETS.find((p) => p.id === selectedId) ?? TOKEN_PLAN_PRESETS[0] ?? null;
// A plan is connected only when it was enrolled through the Token Plan UI
// (explicit marker) and its LLM credentials are still present — never merely
// because the provider has a key: minimax/tokendance/doubao double as
// ordinary direct providers, and a personal key is not a plan connection.
const isPresetEnabled = (preset: TokenPlanPreset): boolean =>
isTokenPlanActive(preset, { tokenPlanEnrollments, providersConfig });
// 授权层:已连接且未被「启用此套餐」关闭。左列状态行与课程模型配置的
// 候选口径都看这个,避免「已连接但不参与配置」被误读成已在生效。
const isPresetUsable = (preset: TokenPlanPreset): boolean =>
isTokenPlanUsable(preset, { tokenPlanEnrollments, providersConfig, tokenPlanDisabled });
/** The plan's preset as the server offers it to the workspace (absent: the workspace cannot add it). */
const presetOf = (preset: TokenPlanPreset) =>
view.presets.find((entry) => entry.id === tokenPlanPresetId(preset.id));
/** The plan's provider in the workspace's settings: connected when there is one. */
const providerOf = (preset: TokenPlanPreset) => planProvider(view, tokenPlanPresetId(preset.id));
const isPresetEnabled = (preset: TokenPlanPreset): boolean => !!providerOf(preset);
// The modalities a preset declares, in display order — drives the tab bar.
const presetModalities = (preset: TokenPlanPreset): TokenPlanModality[] =>
@@ -225,143 +218,81 @@ export function TokenPlanSettings() {
setDisconnectOpen(false);
};
const disablePreset = (preset: TokenPlanPreset) => {
removeTokenPlan(preset, {
setProviderConfig,
setImageProviderConfig,
setVideoProviderConfig,
setTTSProviderConfig,
setWebSearchProviderConfig,
setStageRoute,
getStageRoutes: () => useSettingsStore.getState().llmStageRoutes,
setTokenPlanEnrolled: (presetId, llmProviderId) =>
useSettingsStore.getState().setTokenPlanEnrolled(presetId, llmProviderId),
setTokenPlanEnabled: (presetId, enabled) =>
useSettingsStore.getState().setTokenPlanEnabled(presetId, enabled),
setTokenPlanSeedVersion: (presetId, fingerprint) =>
useSettingsStore.getState().setTokenPlanSeedVersion(presetId, fingerprint),
getTokenPlanEnrollments: () => useSettingsStore.getState().tokenPlanEnrollments,
// 共享槽位交还需要完整状态(enrollments + providersConfig + 授权开关)。
getTokenPlanPriorityState: () => {
const s = useSettingsStore.getState();
return {
tokenPlanEnrollments: s.tokenPlanEnrollments,
providersConfig: s.providersConfig,
tokenPlanDisabled: s.tokenPlanDisabled,
};
},
});
};
const disconnect = (preset: TokenPlanPreset) => {
disablePreset(preset);
// Disconnect = remove the plan's provider; the stages that used it follow
// the ones above them again.
const disconnect = async (preset: TokenPlanPreset) => {
const provider = providerOf(preset);
setDisconnectOpen(false);
setApiKey('');
setEditingKey(false);
toast.success(t('settings.tokenPlan.saved'));
};
// Authorization toggle: record the flag and cascade it to each modality
// provider's `enabled`, so Course Model Config candidates, stage-route
// pruning, and media guards reuse the existing authorization layer.
const toggleAuthorization = (preset: TokenPlanPreset, checked: boolean) => {
const store = useSettingsStore.getState();
store.setTokenPlanEnabled(preset.id, checked);
// 级联读的是「写入标志位之后」的状态:这样共享 provider 的避让判定
// 看到的是本次切换后的真实生效集合。
const next = useSettingsStore.getState();
const nextState = {
tokenPlanEnrollments: next.tokenPlanEnrollments,
providersConfig: next.providersConfig,
tokenPlanDisabled: next.tokenPlanDisabled,
};
const writeActions = {
setProviderConfig,
setImageProviderConfig,
setVideoProviderConfig,
setTTSProviderConfig,
setWebSearchProviderConfig,
};
setTokenPlanAuthorization(preset, checked, writeActions, nextState);
// 共享槽位归属再解析(review P0-03):关闭时排除本套餐(槽位交给剩余
// 生效套餐);重新开启时必须**纳入**本套餐——它是此刻最高优先级的候选
// owner,排除自己会让刚启用的套餐抢不回槽位(regression #1)。
restoreSharedProviderCredentials(preset.id, writeActions, nextState, {
excludeConcerned: !checked,
});
// 重新开启时补种:关闭期间 stage route 会被授权层清理掉(provider 的
// enabled=false),仅把开关拨回去并不会让它们回来。清掉指纹让
// reconcile 重新播种,并由 priorityState 决定此刻能占到哪些槽位。
if (checked) {
useSettingsStore.getState().setTokenPlanSeedVersion(preset.id, null);
useSettingsStore.getState().reconcileTokenPlanSeeds();
if (!provider || provider.source !== 'workspace') return;
if (reportApply(await apply({ kind: 'remove-provider', id: provider.id }, view), t)) {
toast.success(t('settings.tokenPlan.saved'));
}
};
// Save = connect: seed every declared modality's config + the plan's model
// defaults, then we're done. No probing — the models a plan offers are
// listed as-is, and the user picks/toggles on the generation bar.
const handleApply = useCallback(
(key: string) => {
const trimmedKey = key.trim();
if (!selected || !trimmedKey) return;
applyTokenPlan(selected, trimmedKey, {
setProviderConfig,
setImageProviderConfig,
setVideoProviderConfig,
setTTSProviderConfig,
setWebSearchProviderConfig,
setImageProvider,
setImageModelId,
setVideoProvider,
setVideoModelId,
setModel,
setStageRoute,
setTTSProvider,
setWebSearchProvider,
// Enrollment + seed-fingerprint bookkeeping happen inside applyTokenPlan
// so they stay in lockstep with what actually got written.
setTokenPlanEnrolled: (presetId, llmProviderId) =>
useSettingsStore.getState().setTokenPlanEnrolled(presetId, llmProviderId),
setTokenPlanEnabled: (presetId, enabled) =>
useSettingsStore.getState().setTokenPlanEnabled(presetId, enabled),
setTokenPlanSeedVersion: (presetId, fingerprint) =>
useSettingsStore.getState().setTokenPlanSeedVersion(presetId, fingerprint),
getTokenPlanEnrollments: () => useSettingsStore.getState().tokenPlanEnrollments,
// 独占槽位按套餐列表顺序仲裁:新连一个靠后的套餐不抢占靠前套餐
// 已声明的主线模型 / stage route / 各模态选中项。
getTokenPlanPriorityState: () => {
const s = useSettingsStore.getState();
return {
tokenPlanEnrollments: s.tokenPlanEnrollments,
providersConfig: s.providersConfig,
tokenPlanDisabled: s.tokenPlanDisabled,
};
},
});
// Connect (or save a new key): add the plan's provider, or replace its key,
// then apply the plan's recommended configuration to the slots.
const connect = async (plan: TokenPlanPreset, key: string, mode: PlanApplyMode) => {
const preset = presetOf(plan);
if (!preset) return;
setSaving(true);
try {
const result = await connectTokenPlan(apply, view, preset, key, mode);
if (result.status === 'failed') {
toast.error(applyErrorText(result, t));
return;
}
if (result.status === 'partial' && result.message) {
toast.warning(applyErrorText({ reason: result.reason ?? '', message: result.message }, t));
}
setApiKey('');
setEditingKey(false);
toast.success(t('settings.tokenPlan.saved'));
},
[
selected,
setProviderConfig,
setImageProviderConfig,
setVideoProviderConfig,
setTTSProviderConfig,
setWebSearchProviderConfig,
setImageProvider,
setImageModelId,
setVideoProvider,
setVideoModelId,
setModel,
setStageRoute,
setTTSProvider,
setWebSearchProvider,
t,
],
);
} finally {
setSaving(false);
}
};
// Save = connect. When the plan's recommendation would replace models the
// workspace picked, ask first; otherwise apply it straight away.
const handleApply = async (key: string) => {
const trimmedKey = key.trim();
if (!selected || !trimmedKey) return;
const preset = presetOf(selected);
if (!preset) return;
const conflicts = connectConflicts(view, preset);
if (conflicts.length) {
setPending({ plan: selected, key: trimmedKey, conflicts });
return;
}
await connect(selected, trimmedKey, 'overwrite');
};
const choose = (mode: PlanApplyMode) => {
const chosen = pending;
setPending(null);
if (chosen) void connect(chosen.plan, chosen.key, mode);
};
/** How a slot's assignment reads in the confirmation: its model (and provider), or off. */
const assignmentLabel = (
conflict: PlanConflict,
ref: string | null,
preset: PresetView | undefined,
): string => {
if (ref === null) return t(`${MS}.card.off`);
const capability = conflict.slot.capability;
const { providerId, modelId } = splitRef(ref);
const known = view.providers.some((provider) => provider.id === providerId);
const provider = known ? providerLabel(view, providerId) : (preset?.name ?? providerId);
if (!modelId) return provider;
const model = known
? modelName(view, capability, providerId, modelId)
: (preset?.capabilities[capability]?.models.find((entry) => entry.id === modelId)?.name ??
modelId);
return `${model} · ${provider}`;
};
const tp = 'settings.tokenPlan';
@@ -382,7 +313,6 @@ export function TokenPlanSettings() {
>
{TOKEN_PLAN_PRESETS.map((preset, index) => {
const enabled = isPresetEnabled(preset);
const usable = isPresetUsable(preset);
const active = selected?.id === preset.id;
return (
<button
@@ -429,14 +359,8 @@ export function TokenPlanSettings() {
{presetDisplayName(preset)}
</span>
<span className="flex items-center justify-center gap-1 text-xs leading-5 text-muted-foreground sm:justify-start">
{usable && <Check className="size-3 shrink-0" />}
{t(
!enabled
? `${tp}.statusNotConnected`
: usable
? `${tp}.statusConnected`
: `${tp}.statusDisabled`,
)}
{enabled && <Check className="size-3 shrink-0" />}
{t(enabled ? `${tp}.statusConnected` : `${tp}.statusNotConnected`)}
</span>
</span>
</button>
@@ -452,7 +376,10 @@ export function TokenPlanSettings() {
>
{(() => {
const enabled = isPresetEnabled(selected);
const usable = isPresetUsable(selected);
const provider = providerOf(selected);
// Only the workspace's own plan can have its key updated or be disconnected.
const own = provider?.source === 'workspace';
const canConnect = !provider && !!presetOf(selected);
return (
<>
{/* 头部一行:身份 + 状态 + 服务级动作(更新密钥 / 管理账号 / 更多) */}
@@ -463,15 +390,15 @@ export function TokenPlanSettings() {
</h4>
<div className="flex flex-wrap items-center gap-x-2 text-xs leading-5 text-muted-foreground">
<span>
{t(
!enabled
? `${tp}.statusNotConnected`
: usable
? `${tp}.statusConnected`
: `${tp}.statusDisabled`,
)}
{t(enabled ? `${tp}.statusConnected` : `${tp}.statusNotConnected`)}
</span>
{enabled && (
{provider?.source === 'deployment' && (
<span>{t('settings.serverConfig.planServer')}</span>
)}
{own && provider?.key?.mask && (
<span className="font-mono">{provider.key.mask}</span>
)}
{own && (
<button
type="button"
onClick={() => {
@@ -540,27 +467,7 @@ export function TokenPlanSettings() {
<ExternalLink className="size-3 shrink-0" aria-hidden="true" />
</a>
))}
{/* 授权层开关:关闭后此套餐不再出现在课程模型配置中
(凭证与连接保留)。未连接时不可开启。 */}
<Tooltip>
{/* 垫一层 span:TooltipTrigger asChild 会把自己的
data-state 合并到子元素上,直接套 Switch 会覆盖其
checked/unchecked 状态,导致选中配色失效。 */}
<TooltipTrigger asChild>
<span className="inline-flex">
<Switch
checked={usable}
disabled={!enabled}
onCheckedChange={(checked) => toggleAuthorization(selected, checked)}
aria-label={t(`${tp}.enableThisPlan`)}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{t(`${tp}.enableThisPlanHint`)}
</TooltipContent>
</Tooltip>
{enabled && (
{own && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -584,12 +491,15 @@ export function TokenPlanSettings() {
</div>
{/* 密钥表单:未连接或更新密钥时显示;保存即连接 */}
{(!enabled || editingKey) && (
{!enabled && !canConnect && (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
)}
{(canConnect || (own && editingKey)) && (
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault();
handleApply(apiKey);
void handleApply(apiKey);
}}
>
{/* Field and its commit share one row; the button never owns a line. */}
@@ -618,7 +528,7 @@ export function TokenPlanSettings() {
type="submit"
size="sm"
className="ml-auto shrink-0"
disabled={!apiKey.trim()}
disabled={!apiKey.trim() || saving}
>
{t(editingKey ? `${tp}.updateKey` : `${tp}.saveKey`)}
</Button>
@@ -732,6 +642,56 @@ export function TokenPlanSettings() {
)}
</div>
{/* Connecting would replace models the workspace picked: ask first. */}
{pending && (
<AlertDialog
open
onOpenChange={(open) => {
if (!open) setPending(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(`${tp}.applyTitle`)}</AlertDialogTitle>
<AlertDialogDescription>
{t(`${tp}.applyBody`, { name: presetDisplayName(pending.plan) })}
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="max-h-60 space-y-1.5 overflow-y-auto text-xs leading-5">
{pending.conflicts.map((conflict) => {
const preset = presetOf(pending.plan);
const current =
conflict.current === null
? null
: typeof conflict.current === 'string'
? conflict.current
: conflict.current.model;
return (
<li key={conflict.slot.slot} className="flex flex-wrap items-baseline gap-x-2">
<span className="font-medium">{slotName(t, conflict.slot.slot)}</span>
<span className="min-w-0 break-all text-muted-foreground">
{assignmentLabel(conflict, current, preset)}
{' → '}
<span className="text-foreground">
{assignmentLabel(conflict, conflict.recommended, preset)}
</span>
</span>
</li>
);
})}
</ul>
<AlertDialogFooter>
<AlertDialogAction variant="outline" onClick={() => choose('keep')}>
{t(`${tp}.applyKeep`)}
</AlertDialogAction>
<AlertDialogAction onClick={() => choose('overwrite')}>
{t(`${tp}.applyRecommended`)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* 解除连接确认 */}
{selected && (
<AlertDialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
@@ -742,7 +702,7 @@ export function TokenPlanSettings() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => disconnect(selected)}>
<AlertDialogAction onClick={() => void disconnect(selected)}>
{t(`${tp}.disconnect`)}
</AlertDialogAction>
</AlertDialogFooter>
File diff suppressed because it is too large. Load diff
+905
View File
@@ -0,0 +1,905 @@
'use client';
/**
* The voices a user makes for voice-design and voice-clone providers (VoxCPM,
* Qwen): kept per user, shown in the text-to-speech panel when the workspace
* uses that provider.
*/
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { slotVoxCPMBackend } from '@/lib/audio/tts-selection';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { DEFAULT_TTS_VOICES } from '@/lib/audio/constants';
import {
Volume2,
Loader2,
Plus,
Trash2,
Upload,
Wand2,
FileAudio,
Mic,
Square,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useTTSPreview } from '@/lib/audio/use-tts-preview';
import {
getVoxCPMProviderOptions,
normalizeQwenReferenceAudio,
preserveRecordedVoiceName,
normalizeVoxCPMReferenceAudio,
validateVoxCPMReferenceAudio,
VOXCPM_REFERENCE_AUDIO_MAX_SECONDS,
useVoxCPMVoiceProfiles,
useQwenVoiceProfiles,
} from '@/lib/audio/voxcpm-voices';
import {
VOXCPM_TTS_PROVIDER_ID,
getVoxCPMProfileVoiceId,
voxCPMBackendSupportsReferenceAudio,
} from '@/lib/audio/voxcpm';
export function VoxCPMVoiceManager() {
const { t, locale } = useI18n();
const { profiles, addPromptVoice, addCloneVoice, deleteVoice } = useVoxCPMVoiceProfiles();
const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
const { previewing, startPreview, stopPreview } = useTTSPreview();
// The backend is the tts slot's provider option (openmaic.yml `options`).
const voxcpmBackend = slotVoxCPMBackend(useModelCapabilities().tts);
const supportsReferenceAudio = voxCPMBackendSupportsReferenceAudio(voxcpmBackend);
const [createMode, setCreateMode] = useState<'prompt' | 'clone'>('prompt');
const [promptName, setPromptName] = useState('');
const [voicePrompt, setVoicePrompt] = useState('');
const [cloneName, setCloneName] = useState('');
const [clonePromptText, setClonePromptText] = useState('');
const [cloneVoicePrompt, setCloneVoicePrompt] = useState('');
const [cloneFile, setCloneFile] = useState<File | null>(null);
const [saving, setSaving] = useState<'prompt' | 'clone' | null>(null);
const [isRecordingReference, setIsRecordingReference] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [previewingVoiceId, setPreviewingVoiceId] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingChunksRef = useRef<Blob[]>([]);
const recordingStreamRef = useRef<MediaStream | null>(null);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopRecordingTimer = () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
const stopRecordingStream = () => {
recordingStreamRef.current?.getTracks().forEach((track) => track.stop());
recordingStreamRef.current = null;
};
const startReferenceRecording = async () => {
if (isRecordingReference) return;
if (
typeof navigator === 'undefined' ||
typeof MediaRecorder === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
toast.error(t('settings.voxcpmRecordingUnsupported'));
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : undefined;
const mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
recordingChunksRef.current = [];
recordingStreamRef.current = stream;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) recordingChunksRef.current.push(event.data);
};
mediaRecorder.onstop = () => {
void (async () => {
const type = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(recordingChunksRef.current, { type });
if (blob.size > 0) {
try {
const referenceAudio = await normalizeVoxCPMReferenceAudio(
blob,
`voxcpm-reference-${Date.now()}.webm`,
);
const file = new File([referenceAudio.blob], referenceAudio.name, {
type: referenceAudio.mimeType,
});
setCloneFile(file);
if (!cloneName.trim()) setCloneName(t('settings.voxcpmRecordedVoiceName'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('settings.voxcpmRecordingFailed'),
);
}
}
recordingChunksRef.current = [];
setIsRecordingReference(false);
setRecordingSeconds(0);
stopRecordingTimer();
stopRecordingStream();
})();
};
mediaRecorder.start();
setIsRecordingReference(true);
setRecordingSeconds(0);
recordingTimerRef.current = setInterval(() => {
setRecordingSeconds((seconds) => {
const nextSeconds = seconds + 1;
if (nextSeconds >= VOXCPM_REFERENCE_AUDIO_MAX_SECONDS) {
stopReferenceRecording();
}
return nextSeconds;
});
}, 1000);
} catch (error) {
setIsRecordingReference(false);
stopRecordingTimer();
stopRecordingStream();
toast.error(
error instanceof Error ? error.message : t('settings.voxcpmRecordingStartFailed'),
);
}
};
const stopReferenceRecording = () => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
};
useEffect(() => {
return () => {
stopRecordingTimer();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
stopRecordingStream();
};
}, []);
useEffect(() => {
if (!previewing) setPreviewingVoiceId(null);
}, [previewing]);
const handlePreviewVoice = async (voiceId: string) => {
if (previewingVoiceId === voiceId) {
stopPreview();
setPreviewingVoiceId(null);
return;
}
setPreviewingVoiceId(voiceId);
try {
const providerOptions = await getVoxCPMProviderOptions(voiceId, {
backend: voxcpmBackend,
role: 'teacher',
locale,
});
await startPreview({
text: t('settings.ttsTestTextDefault'),
providerId: VOXCPM_TTS_PROVIDER_ID,
voice: voiceId,
speed: ttsSpeed,
providerOptions,
});
} catch (error) {
setPreviewingVoiceId(null);
toast.error(error instanceof Error ? error.message : t('settings.voxcpmPreviewFailed'));
}
};
const handleAddPromptVoice = async () => {
if (!promptName.trim() || !voicePrompt.trim()) return;
setSaving('prompt');
try {
await addPromptVoice({
name: promptName,
voicePrompt,
});
setPromptName('');
setVoicePrompt('');
toast.success(t('settings.voxcpmVoiceSaved'));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.voxcpmVoiceSaveFailed'));
} finally {
setSaving(null);
}
};
const handleCloneFileChange = async (file: File | null) => {
if (!file) {
setCloneFile(null);
return;
}
try {
await validateVoxCPMReferenceAudio(file);
setCloneFile(file);
} catch (error) {
setCloneFile(null);
toast.error(
error instanceof Error ? error.message : t('settings.voxcpmReferenceAudioInvalid'),
);
}
};
const handleAddCloneVoice = async () => {
if (!cloneName.trim() || !cloneFile) return;
setSaving('clone');
try {
await addCloneVoice({
name: cloneName,
referenceAudio: cloneFile,
referenceAudioName: cloneFile.name,
referenceAudioMimeType: cloneFile.type,
promptText: clonePromptText,
voicePrompt: cloneVoicePrompt,
});
setCloneName('');
setClonePromptText('');
setCloneVoicePrompt('');
setCloneFile(null);
toast.success(t('settings.voxcpmCloneSaved'));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.voxcpmCloneSaveFailed'));
} finally {
setSaving(null);
}
};
const promptCount = profiles.filter((profile) => profile.kind !== 'clone').length;
const cloneCount = profiles.filter((profile) => profile.kind === 'clone').length;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<Label className="text-base font-semibold">{t('settings.voxcpmVoicesTitle')}</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t('settings.voxcpmVoicesDescription')}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t('settings.voxcpmAutoVoicePrivacyNote')}
</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="rounded-md border border-border/60 px-2 py-1">
{t('settings.voxcpmPromptCount', { count: promptCount + 1 })}
</span>
<span className="rounded-md border border-border/60 px-2 py-1">
{t('settings.voxcpmCloneCount', { count: cloneCount })}
</span>
{!supportsReferenceAudio && (
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-300">
{t('settings.voxcpmCloneUnsupported')}
</span>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border/70 bg-background">
<div className="grid lg:grid-cols-[minmax(280px,0.95fr)_minmax(0,1.15fr)]">
<section className="border-b border-border/60 lg:border-b-0 lg:border-r">
<div className="flex h-12 items-center justify-between border-b border-border/60 px-4">
<span className="text-sm font-medium">{t('settings.voxcpmVoicePool')}</span>
<span className="text-xs text-muted-foreground">
{t('settings.voxcpmVoiceCount', { count: profiles.length + 1 })}
</span>
</div>
<div className="max-h-[420px] overflow-y-auto">
<VoiceProfileRow
icon={<Wand2 className="h-4 w-4" />}
title={t('settings.voxcpmAutoVoice')}
badge={t('toolbar.default')}
badgeTone="default"
detail={t('settings.voxcpmAutoVoiceDescription')}
kind="auto"
/>
{profiles.length > 0 ? (
profiles.map((profile) => {
const voiceId = getVoxCPMProfileVoiceId(profile.id);
const canPreview = profile.kind !== 'clone' || supportsReferenceAudio;
return (
<VoiceProfileRow
key={profile.id}
icon={
profile.kind === 'clone' ? (
<FileAudio className="h-4 w-4" />
) : (
<Wand2 className="h-4 w-4" />
)
}
title={profile.name}
badge={
profile.kind === 'clone' && !supportsReferenceAudio
? t('settings.voxcpmUnavailable')
: profile.kind === 'clone'
? t('settings.voxcpmClone')
: 'Prompt'
}
badgeTone={
profile.kind === 'clone' && !supportsReferenceAudio ? 'warning' : 'neutral'
}
detail={
profile.kind === 'clone' && !supportsReferenceAudio
? t('settings.voxcpmCloneUnsupportedDetail')
: profile.kind === 'clone'
? profile.referenceAudioName || 'reference audio'
: profile.voicePrompt || ''
}
kind={profile.kind === 'clone' ? 'clone' : 'prompt'}
muted={profile.kind === 'clone' && !supportsReferenceAudio}
previewing={canPreview && previewingVoiceId === voiceId}
onPreview={canPreview ? () => handlePreviewVoice(voiceId) : undefined}
onDelete={async () => {
await deleteVoice(profile.id);
}}
/>
);
})
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground/60">
{t('settings.voxcpmNoCustomVoices')}
</div>
)}
</div>
</section>
<section className="p-4">
<Tabs
value={createMode}
onValueChange={(value) => setCreateMode(value as typeof createMode)}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-9 rounded-md bg-muted p-1">
<TabsTrigger value="prompt" className="gap-1.5 rounded-sm px-3 text-sm">
<Wand2 className="h-3.5 w-3.5" />
Prompt
</TabsTrigger>
<TabsTrigger value="clone" className="gap-1.5 rounded-sm px-3 text-sm">
<Upload className="h-3.5 w-3.5" />
{t('settings.voxcpmClone')}
</TabsTrigger>
</TabsList>
{createMode === 'clone' && !supportsReferenceAudio && (
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-300">
{t('settings.voxcpmCloneSaveOnly')}
</span>
)}
</div>
<TabsContent value="prompt" className="mt-4 space-y-3">
<Input
value={promptName}
onChange={(e) => setPromptName(e.target.value)}
placeholder={t('settings.voxcpmVoiceNamePlaceholder')}
className="h-10 rounded-md text-sm"
/>
<Textarea
value={voicePrompt}
onChange={(e) => setVoicePrompt(e.target.value)}
placeholder={t('settings.voxcpmPromptPlaceholder')}
className="min-h-28 resize-none rounded-md text-sm"
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleAddPromptVoice}
disabled={saving === 'prompt' || !promptName.trim() || !voicePrompt.trim()}
className="h-9 gap-1.5 rounded-md"
>
{saving === 'prompt' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t('settings.voxcpmAddVoice')}
</Button>
</div>
</TabsContent>
<TabsContent value="clone" className="mt-4 space-y-3">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
<Input
value={cloneName}
onChange={(e) => setCloneName(e.target.value)}
placeholder={t('settings.voxcpmCloneVoiceNamePlaceholder')}
className="h-10 rounded-md text-sm"
/>
<label className="inline-flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground">
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[180px] truncate">
{cloneFile ? cloneFile.name : t('settings.voxcpmUploadReferenceAudio')}
</span>
<input
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
void handleCloneFileChange(e.target.files?.[0] || null);
e.target.value = '';
}}
/>
</label>
<Button
type="button"
variant={isRecordingReference ? 'destructive' : 'outline'}
size="sm"
onClick={
isRecordingReference ? stopReferenceRecording : startReferenceRecording
}
className="h-10 gap-2 rounded-md"
>
{isRecordingReference ? (
<>
<Square className="h-3.5 w-3.5" />
{formatRecordingTime(recordingSeconds)}
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
{t('settings.voxcpmRecord')}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground/70">
{t('settings.voxcpmReferenceAudioLimitHint')}
</p>
<Textarea
value={clonePromptText}
onChange={(e) => setClonePromptText(e.target.value)}
placeholder={t('settings.voxcpmReferenceTextPlaceholder')}
className="min-h-20 resize-none rounded-md text-sm"
/>
<Input
value={cloneVoicePrompt}
onChange={(e) => setCloneVoicePrompt(e.target.value)}
placeholder={t('settings.voxcpmVoiceDescriptionPlaceholder')}
className="h-10 rounded-md text-sm"
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleAddCloneVoice}
disabled={saving === 'clone' || !cloneName.trim() || !cloneFile}
className="h-9 gap-1.5 rounded-md"
>
{saving === 'clone' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t('settings.voxcpmAddClone')}
</Button>
</div>
</TabsContent>
</Tabs>
</section>
</div>
</div>
</div>
);
}
export function QwenVoiceCloneManager() {
const { t } = useI18n();
const { profiles, addCloneVoice, deleteVoice } = useQwenVoiceProfiles();
const ttsVoiceProviderId = useSettingsStore((state) => state.ttsVoiceProviderId);
const ttsVoice = useSettingsStore((state) => state.ttsVoice);
const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
const setTTSVoice = useSettingsStore((state) => state.setTTSVoice);
const { previewing, startPreview, stopPreview } = useTTSPreview();
const [name, setName] = useState('');
const [refText, setRefText] = useState('');
const [referenceFile, setReferenceFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [previewingVoiceId, setPreviewingVoiceId] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingChunksRef = useRef<Blob[]>([]);
const recordingStreamRef = useRef<MediaStream | null>(null);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopRecordingTimer = () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
const stopRecordingStream = () => {
recordingStreamRef.current?.getTracks().forEach((track) => track.stop());
recordingStreamRef.current = null;
};
const normalizeFile = async (file: File): Promise<File> => {
const normalized = await normalizeQwenReferenceAudio(file, file.name);
return new File([normalized.blob], normalized.name, { type: normalized.mimeType });
};
const handleFileChange = async (file: File | null) => {
if (!file) {
setReferenceFile(null);
return;
}
try {
setReferenceFile(await normalizeFile(file));
} catch (error) {
setReferenceFile(null);
toast.error(error instanceof Error ? error.message : t('settings.qwenCloneSaveFailed'));
}
};
const startRecording = async () => {
if (isRecording) return;
if (
typeof navigator === 'undefined' ||
typeof MediaRecorder === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
toast.error(t('settings.qwenCloneUnsupported'));
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : undefined;
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
recordingChunksRef.current = [];
recordingStreamRef.current = stream;
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) recordingChunksRef.current.push(event.data);
};
recorder.onstop = () => {
void (async () => {
try {
const blob = new Blob(recordingChunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
if (blob.size > 0) {
const file = new File([blob], `qwen-reference-${Date.now()}.webm`, {
type: blob.type,
});
setReferenceFile(await normalizeFile(file));
setName((currentName) =>
preserveRecordedVoiceName(currentName, t('settings.voxcpmRecordedVoiceName')),
);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.qwenCloneSaveFailed'));
} finally {
recordingChunksRef.current = [];
setIsRecording(false);
setRecordingSeconds(0);
stopRecordingTimer();
stopRecordingStream();
}
})();
};
recorder.start();
setIsRecording(true);
setRecordingSeconds(0);
recordingTimerRef.current = setInterval(() => {
setRecordingSeconds((seconds) => {
const nextSeconds = seconds + 1;
if (nextSeconds >= 58 && mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
return nextSeconds;
});
}, 1000);
} catch (error) {
setIsRecording(false);
stopRecordingTimer();
stopRecordingStream();
toast.error(error instanceof Error ? error.message : t('settings.qwenCloneUnsupported'));
}
};
const stopRecording = () => {
if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop();
};
useEffect(() => {
return () => {
stopRecordingTimer();
if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop();
stopRecordingStream();
};
}, []);
useEffect(() => {
if (!previewing) setPreviewingVoiceId(null);
}, [previewing]);
const handleSave = async () => {
if (!name.trim() || !refText.trim() || !referenceFile) return;
setSaving(true);
try {
const voiceId = await addCloneVoice({ name, referenceAudio: referenceFile, refText });
// A new clone becomes the narration voice (this manager shows only while
// the tts slot is Qwen).
setTTSVoice(voiceId, 'qwen-tts');
setName('');
setRefText('');
setReferenceFile(null);
toast.success(t('settings.qwenCloneSaved'));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.qwenCloneSaveFailed'));
} finally {
setSaving(false);
}
};
const handlePreview = async (voiceId: string) => {
if (previewingVoiceId === voiceId) {
stopPreview();
setPreviewingVoiceId(null);
return;
}
setPreviewingVoiceId(voiceId);
try {
await startPreview({
text: t('settings.ttsTestTextDefault'),
providerId: 'qwen-tts',
voice: voiceId,
speed: ttsSpeed,
});
} catch (error) {
setPreviewingVoiceId(null);
toast.error(error instanceof Error ? error.message : t('settings.qwenCloneSaveFailed'));
}
};
const handleDelete = async (voiceId: string) => {
const vendorDeleted = await deleteVoice(voiceId);
if (!vendorDeleted) toast.warning(t('settings.qwenCloneDeleteWarning'));
if (ttsVoiceProviderId === 'qwen-tts' && ttsVoice === voiceId) {
setTTSVoice(DEFAULT_TTS_VOICES['qwen-tts'], 'qwen-tts');
}
};
const recordingSupported =
typeof navigator !== 'undefined' &&
typeof MediaRecorder !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia;
return (
<div className="space-y-4 pt-2">
<div>
<Label className="text-base font-semibold leading-normal">
{t('settings.qwenCloneVoicesTitle')}
</Label>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.qwenCloneRefAudioHint')}</p>
{!recordingSupported && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
{t('settings.qwenCloneUnsupported')}
</p>
)}
</div>
<div className="overflow-hidden rounded-lg border border-border/70 bg-background">
<div className="grid lg:grid-cols-[minmax(0,45fr)_minmax(0,55fr)]">
{/* 左:音色池(结果展示区) */}
<section className="flex min-h-0 flex-col border-b border-border/50 lg:border-b-0">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border/50 px-4">
<span className="text-sm font-medium">{t('settings.voxcpmVoicePool')}</span>
<span className="text-xs text-muted-foreground">
{t('settings.voxcpmVoiceCount', { count: profiles.length })}
</span>
</div>
<div className="max-h-[360px] min-h-[240px] overflow-y-auto">
{profiles.length ? (
profiles.map((profile) => (
<VoiceProfileRow
key={profile.id}
icon={<FileAudio className="h-4 w-4" />}
title={profile.name}
badge={t('settings.voxcpmClone')}
detail={profile.referenceAudioName || profile.id}
kind="clone"
previewing={previewingVoiceId === profile.id}
onPreview={() => handlePreview(profile.id)}
onDelete={() => handleDelete(profile.id)}
/>
))
) : (
<div className="flex min-h-[200px] items-center justify-center px-4 py-8 text-center text-sm text-muted-foreground/60">
{t('settings.voxcpmNoCustomVoices')}
</div>
)}
</div>
</section>
{/* 右:创建音色(操作区,浅色卡片,暗表单自上而下按操作顺序) */}
<section className="p-3">
<div className="flex h-full flex-col gap-3 rounded-lg bg-muted/40 p-4">
<div className="grid grid-cols-2 gap-2">
<label className="inline-flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground">
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">
{referenceFile ? referenceFile.name : t('settings.qwenCloneUploadShort')}
</span>
<input
type="file"
accept="audio/*"
className="hidden"
onChange={(event) => {
void handleFileChange(event.target.files?.[0] || null);
event.target.value = '';
}}
/>
</label>
<Button
type="button"
variant={isRecording ? 'destructive' : 'outline'}
disabled={!recordingSupported}
onClick={isRecording ? stopRecording : startRecording}
className="h-10 gap-2 rounded-md"
>
{isRecording ? (
<>
<Square className="h-3.5 w-3.5" />
{formatRecordingTime(recordingSeconds)}
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
{t('settings.voxcpmRecord')}
</>
)}
</Button>
</div>
<Textarea
value={refText}
onChange={(event) => setRefText(event.target.value)}
placeholder={t('settings.qwenCloneRefText')}
className="min-h-24 resize-none rounded-md text-sm"
/>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('settings.voxcpmCloneVoiceNamePlaceholder')}
className="h-10 rounded-md text-sm"
/>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !refText.trim() || !referenceFile}
className="h-10 w-full gap-1.5 rounded-md"
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t('settings.voxcpmAddClone')}
</Button>
</div>
</section>
</div>
</div>
</div>
);
}
function formatRecordingTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function VoiceProfileRow({
icon,
title,
badge,
badgeTone = 'neutral',
detail,
kind = 'prompt',
muted,
previewing,
onPreview,
onDelete,
}: {
icon: ReactNode;
title: string;
badge: string;
badgeTone?: 'default' | 'warning' | 'neutral';
detail: string;
kind?: 'auto' | 'prompt' | 'clone';
muted?: boolean;
previewing?: boolean;
onPreview?: () => void;
onDelete?: () => void | Promise<void>;
}) {
const iconClassName =
kind === 'auto'
? 'bg-violet-50 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300'
: kind === 'clone'
? 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300'
: 'bg-muted text-muted-foreground';
const badgeClassName =
badgeTone === 'default'
? 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800/70 dark:bg-violet-950/40 dark:text-violet-300'
: badgeTone === 'warning'
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-300'
: 'border-border/70 bg-background text-muted-foreground';
const { t } = useI18n();
return (
<div
className={cn(
'group relative flex min-h-16 items-center gap-3 border-t border-border/50 px-4 py-3 first:border-t-0',
muted ? 'opacity-60' : 'hover:bg-muted/35',
)}
>
<div
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
iconClassName,
)}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{title}</span>
<span
className={cn(
'rounded-md border px-1.5 py-0.5 text-[10px] leading-none',
badgeClassName,
)}
>
{badge}
</span>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{detail}</p>
</div>
{onPreview && (
<Button
variant="ghost"
size="icon"
onClick={() => onPreview()}
aria-label={
previewing ? t('settings.voxcpmStopPreview') : t('settings.voxcpmPreviewVoice')
}
className="h-8 w-8 text-muted-foreground opacity-70 hover:text-foreground group-hover:opacity-100"
>
{previewing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Volume2 className="h-3.5 w-3.5" />
)}
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="icon"
onClick={() => void onDelete()}
aria-label={t('settings.voxcpmDeleteVoice')}
className="h-8 w-8 text-muted-foreground opacity-70 hover:text-destructive group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
@@ -0,0 +1,217 @@
'use client';
/**
* Settings of an earlier build that could not be moved to the server and are
* kept in this browser instead (`lib/legacy-browser-import/model-settings-unimported.ts`):
* listed with the reason, so the user can set them up again, until the user
* discards them. One without a key leaves the list by itself once the view
* shows it set up again (see {@link settledUnimported}). A kept key is never
* dropped on a guess: it can be copied until the user discards it.
*/
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Copy } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import type { SlotCapability } from '@/lib/config/model-slots';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
discardUnimported,
forgetUnimported,
readUnimported,
unimportedKey,
type UnimportedModelSetting,
type UnimportedReason,
} from '@/lib/legacy-browser-import/model-settings-unimported';
import type { ModelSettingsView } from '@/lib/model-settings/client';
const CAPABILITY_LABEL: Record<SlotCapability, string> = {
chat: 'settings.providers',
tts: 'settings.ttsSettings',
asr: 'settings.asrSettings',
image: 'settings.imageSettings',
video: 'settings.videoSettings',
webSearch: 'settings.webSearchSettings',
document: 'settings.documentParsingSettings',
};
const REASON_KEY: Record<UnimportedReason, string> = {
refused: 'settings.unimported.reason.refused',
reserved: 'settings.unimported.reason.reserved',
'custom-service': 'settings.unimported.reason.customService',
'key-pair': 'settings.unimported.reason.keyPair',
unsupported: 'settings.unimported.reason.unsupported',
unconfirmed: 'settings.unimported.reason.unconfirmed',
};
/**
* The kept items the view shows were set up again, by {@link unimportedKey}.
*
* A slot is set up again once the workspace sets it. A provider without a key
* is, once a workspace provider of its preset that was not there when it was
* kept (`knownProviders`) exists. A provider that holds a key (or a key pair)
* never leaves by itself: nothing in the view confirms the workspace holds
* that key, so only the user discards it.
*/
export function settledUnimported(
items: readonly UnimportedModelSetting[],
view: ModelSettingsView,
): string[] {
return items
.filter((item) => {
if (item.kind === 'slot') {
return view.slots.some((slot) => slot.slot === item.id && slot.assignment !== undefined);
}
if (!item.preset) return false;
const { apiKey, accessKeyId, accessKeySecret } = item.settings;
if (apiKey || accessKeyId || accessKeySecret) return false;
const known = new Set(item.knownProviders ?? []);
return view.providers.some(
(provider) =>
provider.source === 'workspace' &&
provider.preset === item.preset &&
!known.has(provider.id),
);
})
.map(unimportedKey);
}
export function UnimportedSettingsNotice({ view }: { view: ModelSettingsView }) {
const { t } = useI18n();
const [confirming, setConfirming] = useState(false);
/** Bumped when the user discards the list, to read the storage again. */
const [discarded, setDiscarded] = useState(0);
// Read from the browser's storage whenever the view changes: what was set
// up again leaves the list (and the storage, below).
const { items, settled } = useMemo(() => {
void discarded;
const kept = readUnimported().items;
const done = settledUnimported(kept, view);
return { items: kept.filter((item) => !done.includes(unimportedKey(item))), settled: done };
}, [view, discarded]);
useEffect(() => {
if (settled.length) forgetUnimported(settled);
}, [settled]);
if (!items.length) return null;
const copyKey = async (key: string) => {
try {
await navigator.clipboard.writeText(key);
toast.success(t('settings.unimported.keyCopied'));
} catch {
toast.error(t('settings.unimported.copyFailed'));
}
};
return (
<div
role="status"
className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<p className="font-medium">{t('settings.unimported.title')}</p>
<p className="text-xs">{t('settings.unimported.description')}</p>
<ul className="space-y-1.5">
{items.map((item) => {
const { apiKey, accessKeyId, accessKeySecret } = item.settings;
const keyPair =
accessKeyId || accessKeySecret
? `AccessKey ID: ${accessKeyId ?? ''}\nAccessKey Secret: ${accessKeySecret ?? ''}`
: undefined;
const key = apiKey ?? keyPair;
return (
<li
key={unimportedKey(item)}
className="flex flex-wrap items-start justify-between gap-2 rounded-md bg-background/60 px-2 py-1.5"
>
<div className="min-w-0 space-y-0.5">
<p className="text-xs font-medium">
{item.kind === 'slot'
? t('settings.unimported.modelChoice', {
slot: item.id,
assignment: item.settings.assignment ?? '—',
})
: item.capability
? `${item.name} · ${t(CAPABILITY_LABEL[item.capability])}`
: item.name}
</p>
{item.settings.baseUrl && (
<p className="break-all font-mono text-[11px] opacity-80">
{item.settings.baseUrl}
</p>
)}
<p className="text-[11px] opacity-80">
{t(REASON_KEY[item.reason] ?? REASON_KEY.unconfirmed, {
detail: item.detail ?? '',
})}
</p>
</div>
{key && (
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1 text-xs"
onClick={() => void copyKey(key)}
>
<Copy className="size-3" />
{t(
apiKey ? 'settings.unimported.copyKey' : 'settings.unimported.copyKeyPair',
)}
</Button>
)}
</li>
);
})}
</ul>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setConfirming(true)}
>
{t('settings.unimported.dismiss')}
</Button>
</div>
</div>
</div>
<AlertDialog open={confirming} onOpenChange={setConfirming}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.unimported.dismissTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('settings.unimported.dismissDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('settings.cancelEdit')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
discardUnimported();
setDiscarded((count) => count + 1);
setConfirming(false);
}}
>
{t('settings.unimported.dismiss')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+51 -116
View File
@@ -1,130 +1,65 @@
'use client';
// 共享的 LLM 模型组构建:课程模型配置与首页工具栏的模型选择器共用同一份
// 「可用 provider 过滤 + 套餐组按优先级置顶 + 组内套餐推荐序」逻辑。优先级
// 口径唯一:生效套餐按 TOKEN_PLAN_PRESETS 声明顺序(activeTokenPlansIn
// PriorityOrder),两处消费不允许各自排序。
// 「工作区可用的语言模型服务 + 套餐组置顶 + 组内套餐推荐序」逻辑。服务与模型
// 来自服务端的模型设置视图(/api/model-config),浏览器不保存任何 provider。
import { useMemo } from 'react';
import { useSettingsStore } from '@/lib/store/settings';
import { isLLMProviderConfigured } from '@/lib/store/settings-validation';
import { modelIdsMatch } from '@/lib/ai/model-aliases';
import type { ProviderId } from '@/lib/ai/providers';
import type { ModelInfo } from '@/lib/types/provider';
import { TOKEN_PLAN_PRESETS } from '@/lib/config/token-plan-presets';
import { activeTokenPlansInPriorityOrder } from '@/lib/config/apply-token-plan';
import { tokenPlanPresetId } from '@/lib/config/preset-ids';
import type { ModelSettingsView } from '@/lib/model-settings/client';
import { providerLabel, providersFor } from '@/lib/model-settings/edit';
import type { ModelPickerGroup } from './model-picker';
import { logoInverts, providerLogo } from './service-display';
/**
* Token Plan 套餐播种的 LLM provider → 套餐推荐模型序(defaultModels,即官方
* 推荐顺序)。组内模型按此序排列——即便目录被 /models 探测或旧种子打乱
* (例如 cogevol 系列始终排最前)。
*
* 注意:这里只回答「组内模型怎么排」。组与组之间谁靠前,取决于运行时哪些
* 套餐真正生效,见 useLLMPickerGroups 里的 planRank。
*/
const TOKEN_PLAN_LLM_PRESETS: ReadonlyMap<string, readonly string[]> = new Map(
TOKEN_PLAN_PRESETS.flatMap((preset) => {
const llm = preset.modalities.llm;
return llm?.defaultModels?.length ? [[llm.providerId, llm.defaultModels] as const] : [];
}),
/** Each plan's preset id → its rank (the Token Plan list order) and recommended model order. */
const PLAN_ORDER: ReadonlyMap<string, { rank: number; models: readonly string[] }> = new Map(
TOKEN_PLAN_PRESETS.map((plan, rank) => [
tokenPlanPresetId(plan.id),
{ rank, models: plan.modalities.llm?.defaultModels ?? [] },
]),
);
export interface LLMProviderEntry {
id: ProviderId;
name: string;
models: ModelInfo[];
}
/**
* 可选的 LLM provider 与模型组。providers 是原始可用列表(供名称解析等
* 使用);groups 是选择器直接消费的分组视图(含套餐置顶与推荐序)。
* The language model groups of a model settings view: one per provider that
* serves chat (the server's and the workspace's), plans first in the Token
* Plan order, each plan's models in its recommended order.
*/
export function useLLMPickerGroups(): {
groups: ModelPickerGroup[];
providers: LLMProviderEntry[];
} {
const providersConfig = useSettingsStore((s) => s.providersConfig);
const tokenPlanEnrollments = useSettingsStore((s) => s.tokenPlanEnrollments);
const tokenPlanDisabled = useSettingsStore((s) => s.tokenPlanDisabled);
export function llmPickerGroups(view: ModelSettingsView | null): ModelPickerGroup[] {
if (!view) return [];
return providersFor(view, 'chat')
.map((provider, index) => {
const plan = PLAN_ORDER.get(provider.preset);
const rank = plan ? new Map(plan.models.map((id, i) => [id, i] as const)) : null;
return {
index,
group: {
id: provider.id,
name: providerLabel(view, provider.id),
isTokenPlan: !!plan,
// As Model Services shows it; a custom endpoint gets the generic icon.
icon: providerLogo(view, provider.id, 'chat') ?? null,
invertIcon: logoInverts(view, provider.id, 'chat'),
planRank: plan?.rank ?? Number.MAX_SAFE_INTEGER,
models: (provider.capabilities.chat?.models ?? [])
.map((model) => ({
id: model.id,
name: model.name,
thinking: model.capabilities?.thinking,
}))
.sort(
(a, b) =>
(rank?.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
(rank?.get(b.id) ?? Number.MAX_SAFE_INTEGER),
),
} satisfies ModelPickerGroup & { planRank: number },
};
})
.sort((a, b) => a.group.planRank - b.group.planRank || a.index - b.index)
.map(({ group: { planRank: _planRank, ...group } }) => group);
}
// ── 可用 LLM 选项(已配置 provider 的模型目录,带思考能力供选择器渲染) ──
const providers = useMemo(
() =>
Object.entries(providersConfig ?? {})
.filter(
([, config]) =>
config.enabled !== false && // 授权层「启用此提供方」关闭时不展示
isLLMProviderConfigured(config),
)
.map(([id, config]) => ({
id: id as ProviderId,
name: config.name || id,
models:
config.isServerConfigured && !config.apiKey && config.serverModels?.length
? config.models.filter((model) =>
config.serverModels?.some((serverModelId) =>
modelIdsMatch(id, model.id, serverModelId),
),
)
: config.models,
})),
[providersConfig],
);
// 生效套餐的 LLM provider → 优先级序号(0 最高)。顺序取自
// TOKEN_PLAN_PRESETS 的声明顺序,与 Token Plan 列表里看到的上下顺序一致;
// 被「启用此套餐」关闭或未连接的套餐不进入该表,因此其 provider 在选择
// 列表中退回普通 provider 的位置。
const planRank = useMemo(() => {
const rank = new Map<string, number>();
activeTokenPlansInPriorityOrder({
tokenPlanEnrollments,
providersConfig: providersConfig ?? {},
tokenPlanDisabled,
}).forEach((preset, index) => {
const pid = preset.modalities.llm?.providerId;
// 同一 provider 被多个套餐声明时,靠前的套餐先落位、不被后者覆盖。
if (pid && !rank.has(pid)) rank.set(pid, index);
});
return rank;
}, [tokenPlanEnrollments, providersConfig, tokenPlanDisabled]);
// 套餐 provider 的模型组在选择列表中置顶,多个套餐之间按套餐列表顺序排
// 列;组内模型按套餐推荐序排列(稳定排序:目录中不在推荐序里的模型保持
// 原相对顺序排在后面)。
const groups = useMemo<ModelPickerGroup[]>(
() =>
providers
.map((p) => {
const recommended = TOKEN_PLAN_LLM_PRESETS.get(p.id);
const rank = recommended
? new Map(recommended.map((id, index) => [id, index] as const))
: null;
return {
id: p.id,
name: p.name,
// 「是套餐组」以运行时生效状态为准,而非该 provider 恰好被某个
// 套餐声明过——关掉的套餐不该继续霸占置顶位。
isTokenPlan: planRank.has(p.id),
planRank: planRank.get(p.id) ?? Number.MAX_SAFE_INTEGER,
models: p.models
.map((m) => ({
id: m.id,
name: m.name,
thinking: m.capabilities?.thinking,
}))
.sort(
(a, b) =>
(rank?.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
(rank?.get(b.id) ?? Number.MAX_SAFE_INTEGER),
),
};
})
// 稳定排序:套餐组按套餐列表顺序在前,其余保持 provider 原顺序。
.sort((a, b) => a.planRank - b.planRank),
[providers, planRank],
);
return { groups, providers };
/** {@link llmPickerGroups}, memoized for a view. */
export function useLLMPickerGroups(view: ModelSettingsView | null): ModelPickerGroup[] {
return useMemo(() => llmPickerGroups(view), [view]);
}
+1 -84
View File
@@ -1,44 +1,4 @@
import type { ProviderId, ProviderType, ModelInfo } from '@/lib/types/provider';
import type { ProviderSettings } from '@/lib/types/settings';
import { getCatalogThinkingCapability } from '@/lib/ai/model-metadata';
/** Heuristic: model ids matching this are treated as vision-capable. */
const VISION_MODEL_PATTERN = /vision|vl|omni|4o|gpt-5|gemini|claude/i;
/**
* Builds a default ModelInfo from a probed model id. Vision capability is
* inferred from the id via {@link VISION_MODEL_PATTERN}. Shared by the provider
* panel and the token-plan apply flow so the heuristic stays in one place.
*
* When `providerId` is given, the built-in thinking capability for that
* (provider, model) pair is overlaid — so a model that supports configurable
* thinking keeps its `capabilities.thinking` instead of silently losing it
* (which would hide InlineThinkingControl). Unknown pairs are unaffected.
*/
export function modelInfoFromId(id: string, providerId?: string): ModelInfo {
const thinking = providerId ? getCatalogThinkingCapability(providerId, id) : undefined;
return {
id,
name: id,
capabilities: {
streaming: true,
tools: true,
vision: VISION_MODEL_PATTERN.test(id),
...(thinking ? { thinking } : {}),
},
};
}
interface NewCustomProviderConfig {
name: string;
type: ProviderType;
baseUrl: string;
icon: string;
requiresApiKey: boolean;
/** Optional explicit /models URL override (from a preset). */
modelsUrl?: string;
}
/** A context or output window for display: 128K, 1M, 1.5M. */
export function formatContextWindow(size?: number): string {
if (!size) return '-';
@@ -60,46 +20,3 @@ export function formatContextWindow(size?: number): string {
return size.toString();
}
export function getProviderTypeLabel(type: string, t: (key: string) => string): string {
const translationKey = `settings.providerTypes.${type}`;
const translated = t(translationKey);
// If translation exists (not equal to key), use it; otherwise fallback to type
return translated !== translationKey ? translated : type;
}
export function createCustomProviderSettings(
providerData: NewCustomProviderConfig,
): ProviderSettings {
return {
apiKey: '',
baseUrl: providerData.baseUrl || '',
models: [],
name: providerData.name,
type: providerData.type,
defaultBaseUrl: providerData.baseUrl || undefined,
icon: providerData.icon || undefined,
requiresApiKey: providerData.requiresApiKey,
isBuiltIn: false,
modelsUrl: providerData.modelsUrl || undefined,
};
}
interface VerifyModelRequestConfig {
providerId: ProviderId;
modelId: string;
apiKey?: string;
baseUrl?: string;
providerType?: ProviderType | string;
requiresApiKey?: boolean;
}
export function createVerifyModelRequest(config: VerifyModelRequestConfig) {
return {
apiKey: config.apiKey || '',
baseUrl: config.baseUrl || '',
model: `${config.providerId}:${config.modelId}`,
providerType: config.providerType,
requiresApiKey: config.requiresApiKey,
};
}
+11 -355
View File
@@ -1,363 +1,19 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import { useOpenRouterModels } from '@/lib/media/use-openrouter-models';
import {
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
Zap,
Plus,
Settings2,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { VideoProviderId } from '@/lib/media/types';
import { MediaServicePanel } from './media-service-panel';
import type { ServicePanelProps } from './server-settings';
interface VideoSettingsProps {
selectedProviderId: VideoProviderId;
}
export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {
const { t } = useI18n();
const videoModelId = useSettingsStore((state) => state.videoModelId);
const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig);
const setVideoProviderConfig = useSettingsStore((state) => state.setVideoProviderConfig);
const [showApiKey, setShowApiKey] = useState(false);
const [testLoading, setTestLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
// Model dialog state
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
const [modelForm, setModelForm] = useState({ id: '', name: '' });
// Reset test state when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
setPrevSelectedProviderId(selectedProviderId);
setTestStatus('idle');
setTestMessage('');
}
const currentConfig = videoProvidersConfig[selectedProviderId];
const currentProvider = VIDEO_PROVIDERS[selectedProviderId];
// OpenRouter's catalog is fetched live so the picker is never a curated
// shortlist; every other provider keeps its registry list.
const { models: builtInModels } = useOpenRouterModels(
'video',
selectedProviderId === 'openrouter-video',
currentProvider?.models || [],
currentConfig?.apiKey,
currentConfig?.baseUrl,
);
const customModels = useMemo(
() => currentConfig?.customModels || [],
[currentConfig?.customModels],
);
const isServerConfigured = !!currentConfig?.isServerConfigured;
const handleApiKeyChange = (apiKey: string) => {
setVideoProviderConfig(selectedProviderId, {
apiKey,
...(apiKey.trim() ? { enabled: true } : {}),
});
};
const handleBaseUrlChange = (baseUrl: string) => {
setVideoProviderConfig(selectedProviderId, {
baseUrl,
...(baseUrl.trim() ? { enabled: true } : {}),
});
};
const handleTest = async () => {
setTestLoading(true);
setTestStatus('idle');
setTestMessage('');
try {
const response = await fetch('/api/verify-video-provider', {
method: 'POST',
headers: {
'x-video-provider': selectedProviderId,
'x-video-model': videoModelId || '',
'x-api-key': currentConfig?.apiKey || '',
'x-base-url': currentConfig?.baseUrl || '',
},
});
const data = await response.json();
if (data.success) {
setTestStatus('success');
setTestMessage(t('settings.videoConnectivitySuccess'));
} else {
setTestStatus('error');
setTestMessage(`${t('settings.videoConnectivityFailed')}: ${data.error}`);
}
} catch (err) {
setTestStatus('error');
setTestMessage(`${t('settings.videoConnectivityFailed')}: ${err}`);
} finally {
setTestLoading(false);
}
};
// Model CRUD
const handleOpenAddModel = () => {
setEditingModelIndex(null);
setModelForm({ id: '', name: '' });
setShowModelDialog(true);
};
const handleOpenEditModel = (index: number) => {
setEditingModelIndex(index);
setModelForm({ ...customModels[index] });
setShowModelDialog(true);
};
const handleSaveModel = useCallback(() => {
if (!modelForm.id.trim()) return;
const newCustomModels = [...customModels];
if (editingModelIndex !== null) {
newCustomModels[editingModelIndex] = {
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
};
} else {
newCustomModels.push({
id: modelForm.id.trim(),
name: modelForm.name.trim() || modelForm.id.trim(),
});
}
setVideoProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
setShowModelDialog(false);
}, [modelForm, editingModelIndex, customModels, selectedProviderId, setVideoProviderConfig]);
const handleDeleteModel = (index: number) => {
const newCustomModels = customModels.filter((_, i) => i !== index);
setVideoProviderConfig(selectedProviderId, {
customModels: newCustomModels,
});
};
/** A video generation service (see MediaServicePanel). */
export function VideoSettings(props: ServicePanelProps) {
const registry = VIDEO_PROVIDERS[props.entry.registryId as VideoProviderId];
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
)}
{/* Managed providers are admin-owned: the operator's key and base URL are
authoritative and not overridable here, so the editing inputs are hidden. */}
{!isServerConfigured && (
<>
{/* API Key + Test inline */}
<div className="space-y-2">
<Label>API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`video-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
selectedProviderId === 'kling'
? 'accessKey:secretKey'
: t('settings.enterApiKey')
}
value={currentConfig?.apiKey || ''}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="h-8 pr-8"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testLoading || !currentConfig?.apiKey}
className="gap-1.5"
>
{testLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
{testMessage && (
<div
className={cn(
'rounded-lg p-3 text-sm overflow-hidden',
testStatus === 'success' &&
'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
testStatus === 'error' &&
'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
)}
>
<div className="flex items-start gap-2 min-w-0">
{testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
{testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
<p className="flex-1 min-w-0 break-all">{testMessage}</p>
</div>
</div>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label>Base URL</Label>
<Input
name={`video-base-url-${selectedProviderId}`}
type="url"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={currentConfig?.baseUrl || ''}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder={currentProvider?.defaultBaseUrl || t('settings.enterCustomBaseUrl')}
className="h-8"
/>
{(() => {
const effectiveBaseUrl =
currentConfig?.baseUrl || currentProvider?.defaultBaseUrl || '';
if (!effectiveBaseUrl) return null;
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {effectiveBaseUrl}
</p>
);
})()}
</div>
</>
)}
{/* Model list */}
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label className="text-base">{t('settings.models')}</Label>
<Button variant="outline" size="sm" onClick={handleOpenAddModel} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
{t('settings.addNewModel')}
</Button>
</div>
<div className="space-y-1.5">
{/* Built-in models */}
{builtInModels.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
</div>
))}
{/* Custom models */}
{customModels.map((model, index) => (
<div
key={`custom-${index}`}
className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => handleOpenEditModel(index)}
title={t('settings.editModel')}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteModel(index)}
title={t('settings.deleteModel')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* Add/Edit Model Dialog */}
<Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
<DialogContent className="sm:max-w-md">
<DialogTitle>
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogTitle>
<DialogDescription className="sr-only">
{editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
</DialogDescription>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>{t('settings.modelId')}</Label>
<Input
value={modelForm.id}
onChange={(e) => setModelForm((prev) => ({ ...prev, id: e.target.value }))}
placeholder="e.g. my-custom-model-v1"
className="h-8 font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t('settings.modelName')}</Label>
<Input
value={modelForm.name}
onChange={(e) => setModelForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. My Custom Model"
className="h-8 text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setShowModelDialog(false)}>
{t('settings.cancelEdit')}
</Button>
<Button size="sm" onClick={handleSaveModel} disabled={!modelForm.id.trim()}>
{t('settings.saveModel')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<MediaServicePanel
{...props}
kind="video"
defaultBaseUrl={registry?.defaultBaseUrl}
catalogue={registry?.models ?? []}
/>
);
}
+70 -170
View File
@@ -1,9 +1,6 @@
'use client';
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
@@ -12,153 +9,102 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import {
BAIDU_SUB_SOURCES,
CLAUDE_WEB_SEARCH_DEFAULT_MODEL,
CLAUDE_WEB_SEARCH_MODELS,
WEB_SEARCH_PROVIDERS,
} from '@/lib/web-search/constants';
import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types';
import { ExternalLink, Eye, EyeOff } from 'lucide-react';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import { modelChange, modelRef } from '@/lib/model-settings/edit';
import {
ApiKeyField,
EndpointServerOnlyHint,
ServerConfiguredNotice,
ServerOnlyNotice,
reportApply,
rootUse,
saveServiceProvider,
type ServicePanelProps,
} from './server-settings';
interface WebSearchSettingsProps {
selectedProviderId: WebSearchProviderId;
}
export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps) {
/**
* A web search service: its key (write-only) and, for Claude, the model that
* searches, which is the workspace's search model while Claude is in use.
*/
export function WebSearchSettings({ view, apply, entry }: ServicePanelProps) {
const { t } = useI18n();
const [showApiKey, setShowApiKey] = useState(false);
const webSearchProvidersConfig = useSettingsStore((state) => state.webSearchProvidersConfig);
const setWebSearchProviderConfig = useSettingsStore((state) => state.setWebSearchProviderConfig);
const baiduSubSources = useSettingsStore((state) => state.baiduSubSources);
const setBaiduSubSources = useSettingsStore((state) => state.setBaiduSubSources);
const provider = WEB_SEARCH_PROVIDERS[selectedProviderId];
const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured;
const isOperatorManagedBaseUrl = selectedProviderId === 'searxng';
// Managed providers are admin-owned: hide the key/base-URL override inputs.
const showCredentialFields = !isServerConfigured && !isOperatorManagedBaseUrl;
const provider = WEB_SEARCH_PROVIDERS[entry.registryId as WebSearchProviderId];
const editable = entry.state === 'workspace' || entry.state === 'available';
const use = rootUse(view, 'webSearch', entry.id);
const buildRequestUrl = (baseUrl: string) => {
const trimmed = baseUrl.replace(/\/$/, '');
if (!provider.endpointPath) return trimmed;
if (!provider?.endpointPath) return trimmed;
if (trimmed.endsWith(provider.endpointPath)) return trimmed;
return `${trimmed}${provider.endpointPath}`;
};
// Reset showApiKey when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
setPrevSelectedProviderId(selectedProviderId);
setShowApiKey(false);
}
return (
<div className="space-y-6 max-w-3xl">
{/* Server-configured notice */}
{isServerConfigured && (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
{t('settings.serverConfiguredNotice')}
</div>
)}
{entry.state === 'deployment' && <ServerConfiguredNotice />}
{entry.state === 'server-only' &&
(entry.registryId === 'searxng' && view.policy.allowWorkspaceProviders ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.searxngServerOnlyNotice')}
</div>
) : (
<ServerOnlyNotice policy={!view.policy.allowWorkspaceProviders} />
))}
{isOperatorManagedBaseUrl && !isServerConfigured && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.searxngServerOnlyNotice')}
</div>
)}
{!provider.requiresApiKey && !isServerConfigured && !isOperatorManagedBaseUrl && (
{editable && provider && !provider.requiresApiKey && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.webSearchApiKeyOptional')}
</div>
)}
{/* API Key + Base URL Configuration */}
{showCredentialFields && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.webSearchApiKey')}</Label>
<div className="relative">
<Input
name={`web-search-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
!provider.requiresApiKey
? t('settings.optionalOverride')
: t('settings.enterApiKey')
}
value={webSearchProvidersConfig[selectedProviderId]?.apiKey || ''}
onChange={(e) =>
setWebSearchProviderConfig(selectedProviderId, {
apiKey: e.target.value,
})
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.webSearchApiKeyHint')}</p>
</div>
<div className="space-y-2">
<Label className="text-sm">{t('settings.webSearchBaseUrl')}</Label>
<Input
name={`web-search-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={provider.defaultBaseUrl || ''}
value={webSearchProvidersConfig[selectedProviderId]?.baseUrl || ''}
onChange={(e) =>
setWebSearchProviderConfig(selectedProviderId, {
baseUrl: e.target.value,
})
}
className="text-sm"
/>
</div>
{/* API Key + Base URL */}
{editable && provider && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.webSearchApiKey')}</Label>
<ApiKeyField
name={`web-search-api-key-${entry.id}`}
provider={entry.provider}
placeholder={
!provider.requiresApiKey
? t('settings.optionalOverride')
: t('settings.enterApiKey')
}
inputClassName="font-mono text-sm"
onSave={(apiKey) => saveServiceProvider(view, apply, entry, { apiKey }, t)}
onRemove={() => saveServiceProvider(view, apply, entry, { apiKey: '' }, t)}
/>
<p className="text-xs text-muted-foreground">{t('settings.webSearchApiKeyHint')}</p>
</div>
{/* Request URL Preview */}
{(() => {
const effectiveBaseUrl =
webSearchProvidersConfig[selectedProviderId]?.baseUrl ||
provider.defaultBaseUrl ||
'';
if (!effectiveBaseUrl) return null;
const fullUrl = buildRequestUrl(effectiveBaseUrl);
return (
<div className="space-y-2">
<Label className="text-sm">{t('settings.webSearchBaseUrl')}</Label>
{provider.defaultBaseUrl && (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {fullUrl}
{t('settings.requestUrl')}: {buildRequestUrl(provider.defaultBaseUrl)}
</p>
);
})()}
</>
)}
<EndpointServerOnlyHint />
</div>
</div>
)}
{/* Claude search model — hidden for managed providers, where the
operator pins the model via WEB_SEARCH_CLAUDE_MODELS */}
{selectedProviderId === 'claude' && !isServerConfigured && (
{/* Claude search model: the workspace's search model while Claude searches. */}
{entry.registryId === 'claude' && entry.state !== 'server-only' && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.claudeSearchModel')}</Label>
<Select
value={webSearchProvidersConfig.claude?.modelId || CLAUDE_WEB_SEARCH_DEFAULT_MODEL}
onValueChange={(value) => setWebSearchProviderConfig('claude', { modelId: value })}
value={use.modelId || CLAUDE_WEB_SEARCH_DEFAULT_MODEL}
disabled={!use.inUse || !use.slot || use.slot.locked}
onValueChange={async (value) => {
if (!use.slot) return;
reportApply(await apply(modelChange(use.slot, modelRef(entry.id, value)), view), t);
}}
>
<SelectTrigger>
<SelectValue />
@@ -171,57 +117,11 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('settings.claudeSearchModelHint')}</p>
</div>
)}
{selectedProviderId === 'baidu' && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm">{t('settings.baiduSubSources')}</Label>
<p className="text-xs text-muted-foreground">
{t('settings.baiduSubSourcesDescription')}
</p>
</div>
<div className="space-y-2">
{(
Object.entries(BAIDU_SUB_SOURCES) as Array<
[keyof BaiduSubSources, (typeof BAIDU_SUB_SOURCES)[keyof typeof BAIDU_SUB_SOURCES]]
>
).map(([key, meta]) => {
const enabled = baiduSubSources[key];
return (
<div
key={key}
className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"
>
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">{t(meta.labelKey)}</div>
<div className="text-xs text-muted-foreground">
{t(meta.descriptionKey)}
{meta.docsUrl && (
<a
href={meta.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 ml-1.5 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 hover:underline"
>
{t('settings.viewDocs')}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) => setBaiduSubSources({ [key]: checked })}
aria-label={t(meta.labelKey)}
/>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{use.inUse
? t('settings.claudeSearchModelHint')
: t('settings.serverConfig.modelWhenInUse')}
</p>
</div>
)}
</div>
@@ -12,7 +12,8 @@ import {
} from '@/lib/media/resolve-media-ref';
import { isGeneratedMediaPlaceholder } from '@/lib/media/media-ref';
import { useMediaGenerationStore, type MediaTask } from '@/lib/store/media-generation';
import { useSettingsStore } from '@/lib/store/settings';
import { mediaGenerationDisabled as isMediaGenerationDisabled } from '@/lib/model-settings/capabilities';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { resolveMediaTaskForElement } from '@/lib/media/media-task-resolution';
export interface ResolvedImageSrc {
@@ -84,7 +85,7 @@ export function resolveImageSrc(
*/
export function useResolvedImageSrc(elementInfo: PPTImageElement): ResolvedImageSrc {
const stageId = useMediaStageId();
const mediaGenerationDisabled = useSettingsStore((state) => !state.imageGenerationEnabled);
const mediaGenerationDisabled = isMediaGenerationDisabled(useModelCapabilities(), 'image');
const task = useMediaGenerationStore((state) =>
resolveMediaTaskForElement(state.tasks, elementInfo, stageId),
);
@@ -12,7 +12,8 @@ import { mediaResolutionCanRetry } from '@/lib/media/resolve-media-ref';
import { RotateCcw, Film, ShieldAlert, VideoOff } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { createLogger } from '@/lib/logger';
import { useSettingsStore } from '@/lib/store/settings';
import { mediaGenerationDisabled as isMediaGenerationDisabled } from '@/lib/model-settings/capabilities';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { useSceneData } from '@/lib/contexts/scene-context';
import type { SlideContent } from '@/lib/types/stage';
import { useResolvedVideoMedia } from './useResolvedVideoMedia';
@@ -38,7 +39,7 @@ export function BaseVideoElement({ elementInfo }: BaseVideoElementProps) {
// Only subscribe to media store when inside a classroom (stageId provided via context).
const stageId = useMediaStageId();
const mediaGenerationDisabled = useSettingsStore((state) => !state.videoGenerationEnabled);
const mediaGenerationDisabled = isMediaGenerationDisabled(useModelCapabilities(), 'video');
const tasks = useMediaGenerationStore((state) => state.tasks);
const { mediaRef, task, resolution, resolvedSrc, resolvedPoster } = useResolvedVideoMedia(
elementInfo,
@@ -5,7 +5,8 @@ import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useMediaStageId } from '@/lib/contexts/media-stage-context';
import { mediaFailureNoticeKey } from '@/lib/media/media-failure';
import { mediaResolutionCanRetry } from '@/lib/media/resolve-media-ref';
import { useSettingsStore } from '@/lib/store/settings';
import { mediaGenerationDisabled as isMediaGenerationDisabled } from '@/lib/model-settings/capabilities';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import { RotateCcw, ShieldAlert, VideoOff } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSceneData } from '@/lib/contexts/scene-context';
@@ -27,7 +28,7 @@ export function VideoElement({ elementInfo, selectElement }: VideoElementProps)
const { t } = useI18n();
const { sceneId, sceneData } = useSceneData<SlideContent>();
const stageId = useMediaStageId();
const mediaGenerationDisabled = useSettingsStore((state) => !state.videoGenerationEnabled);
const mediaGenerationDisabled = isMediaGenerationDisabled(useModelCapabilities(), 'video');
const tasks = useMediaGenerationStore((state) => state.tasks);
const { mediaRef, resolution, resolvedSrc, resolvedPoster, task } = useResolvedVideoMedia(
elementInfo,
@@ -8,7 +8,8 @@ import { useMayGenerateForStage } from '@/lib/classroom/generation-permission';
import { useAssetUrlLeases, type AssetUrlLeaseState } from '@/lib/media/use-asset-url';
import { mayNameAPoolAsset } from '@/lib/media/media-placeholder';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useSettingsStore } from '@/lib/store/settings';
import { mediaGenerationDisabled as isMediaGenerationDisabled } from '@/lib/model-settings/capabilities';
import { useModelCapabilities } from '@/lib/model-settings/use-model-settings';
import {
MISSING_ASSET_LEASE,
isConcreteMediaAddress,
@@ -202,8 +203,9 @@ export function poolLeasableSlideRefs(
export function useResolvedSlideMedia(slide: Slide): ResolvedSlideMedia {
const stageId = useMediaStageId();
const imageGenerationDisabled = useSettingsStore((state) => !state.imageGenerationEnabled);
const videoGenerationDisabled = useSettingsStore((state) => !state.videoGenerationEnabled);
const capabilities = useModelCapabilities();
const imageGenerationDisabled = isMediaGenerationDisabled(capabilities, 'image');
const videoGenerationDisabled = isMediaGenerationDisabled(capabilities, 'video');
const signature = useMediaGenerationStore((state) => {
if (!stageId) return '';
return slide.elements
+4 -1
View File
@@ -54,7 +54,10 @@ services:
- default # outbound internet (LLM/media provider APIs, etc.)
- render # reach the render service over the isolated network
volumes:
# Optional: mount server-providers.yml for provider config
# Optional: the model configuration (copy openmaic.example.yml to
# openmaic.yml first; a missing file would be mounted as a directory).
# - ./openmaic.yml:/app/openmaic.yml:ro
# Legacy, read only when there is no openmaic.yml:
# - ./server-providers.yml:/app/server-providers.yml:ro
- openmaic-data:/app/data
restart: unless-stopped
+13 -4
View File
@@ -3,14 +3,23 @@ import { MockApi } from './mock-api';
type Fixtures = {
mockApi: MockApi;
/** Answers the workspace model settings on every page (see MockApi.mockModelSettings). */
modelSettings: void;
};
export const test = base.extend<Fixtures>({
// Every page reads the workspace's model settings; by default a course
// model is set up. A spec that needs another view calls
// `mockApi.mockModelSettings(...)`, whose routes take precedence.
modelSettings: [
async ({ page }, use) => {
await new MockApi(page).mockModelSettings();
await use();
},
{ auto: true },
],
mockApi: async ({ page }, use) => {
const mockApi = new MockApi(page);
// Always mock server-providers — called on every page load by root layout
await mockApi.mockServerProviders();
await use(mockApi);
await use(new MockApi(page));
},
});
+1 -6
View File
@@ -6,17 +6,12 @@ export const SCENE_ID = 'scene-interactive-state';
export const IFRAME_TITLE = `Interactive Scene ${SCENE_ID}`;
const SETTINGS_STORAGE = JSON.stringify({
state: {
modelId: 'gpt-4o',
providerId: 'openai',
providersConfig: { openai: { apiKey: 'mock-only' } },
agentMode: 'preset',
selectedAgentIds: [],
ttsEnabled: false,
reviewOutlineEnabled: false,
autoConfigApplied: true,
sidebarCollapsed: false,
},
version: 2,
version: 5,
});
const INTERACTIVE_HTML = `<!doctype html><html><body><main id="experiment"><h1>Current-state activity</h1><input id="value" type="range" min="0" max="10" value="1"><label><input id="pause" type="checkbox">Pause render</label><button id="draw">Draw</button><p id="result"></p></main><script>
+25 -9
View File
@@ -2,6 +2,11 @@ import type { Page } from '@playwright/test';
import { mockOutlines } from './test-data/scene-outlines';
import { mockSceneContentResponse } from './test-data/scene-content';
import { createMockSceneActionsResponse } from './test-data/scene-actions';
import {
createModelSettingsView,
DEFAULT_MODEL_SETTINGS,
type ModelSettingsOptions,
} from './test-data/model-settings';
/**
* Wraps Playwright's page.route() to mock OpenMAIC API endpoints.
@@ -66,18 +71,29 @@ export class MockApi {
});
}
/** Mock the server providers endpoint (returns empty — client-side config only) */
async mockServerProviders() {
await this.page.route('**/api/server-providers', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providers: {} }),
});
/**
* Answer the workspace model settings (`/api/model-config`): the view the
* app reads, and a PUT that sets the course model (the toolbar picker). The
* one-time import of browser settings finds nothing to do (404).
*/
async mockModelSettings(options: ModelSettingsOptions = DEFAULT_MODEL_SETTINGS) {
let current = { ...options };
await this.page.route('**/api/model-config/import', (route) =>
route.fulfill({ status: 404, body: 'Not found' }),
);
await this.page.route('**/api/model-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as {
change?: { kind?: string; set?: Record<string, unknown> };
};
const llm = body.change?.kind === 'slots' ? body.change.set?.llm : undefined;
if (typeof llm === 'string') current = { ...current, llm };
}
await route.fulfill({ json: createModelSettingsView(current) });
});
}
/** Set up API mocks for the generation flow. Note: server-providers is already mocked by the base fixture. */
/** Set up API mocks for the generation flow. Note: model settings are already mocked by the base fixture. */
async setupGenerationMocks(stageId?: string) {
await this.mockSceneOutlinesStream();
await this.mockSceneContent();
+120
View File
@@ -0,0 +1,120 @@
/**
* The workspace model settings the app reads from `GET /api/model-config`
* (lib/server/model-config/settings.ts). The browser keeps no provider state:
* whether a language model is set up, which one the course uses and which
* media is available all come from this view, so specs answer it.
*/
/** The capability slots, as `lib/config/model-slots.ts` declares them. */
const SLOTS: Array<{ slot: string; parent: string | null; capability: string }> = [
{ slot: 'llm', parent: null, capability: 'chat' },
{ slot: 'course.research', parent: 'llm', capability: 'chat' },
{ slot: 'course.outline', parent: 'llm', capability: 'chat' },
{ slot: 'course.agents', parent: 'llm', capability: 'chat' },
{ slot: 'course.content', parent: 'llm', capability: 'chat' },
{ slot: 'course.content.slide', parent: 'course.content', capability: 'chat' },
{ slot: 'course.content.quiz', parent: 'course.content', capability: 'chat' },
{ slot: 'course.content.interactive', parent: 'course.content', capability: 'chat' },
{ slot: 'course.content.pbl', parent: 'course.content', capability: 'chat' },
{ slot: 'course.actions', parent: 'llm', capability: 'chat' },
{ slot: 'classroom', parent: 'llm', capability: 'chat' },
{ slot: 'agent', parent: 'llm', capability: 'chat' },
{ slot: 'agent.title', parent: 'agent', capability: 'chat' },
{ slot: 'tts', parent: null, capability: 'tts' },
{ slot: 'asr', parent: null, capability: 'asr' },
{ slot: 'image', parent: null, capability: 'image' },
{ slot: 'video', parent: null, capability: 'video' },
{ slot: 'webSearch', parent: null, capability: 'webSearch' },
{ slot: 'document', parent: null, capability: 'document' },
];
/** Display names of the chat presets these specs use. */
const PRESET_NAMES: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Claude',
google: 'Gemini',
};
export interface ModelSettingsOptions {
/** Chat providers (preset id = provider id) and the models each offers. */
providers?: Record<string, string[]>;
/** The course model, `provider:model`; omitted: no language model. */
llm?: string;
/** Whether the llm root is locked by the deployment. */
locked?: boolean;
/** Media roots that resolve to a provider (registry ids). */
media?: Partial<Record<'tts' | 'asr' | 'image' | 'video' | 'webSearch' | 'document', string>>;
}
export type ModelSettingsView = ReturnType<typeof createModelSettingsView>;
/** A view with the given course model and providers; every other slot follows or is unassigned. */
export function createModelSettingsView(options: ModelSettingsOptions = {}) {
const providers = options.providers ?? {};
const [llmProvider, ...rest] = (options.llm ?? '').split(':');
const llmModel = rest.join(':');
const target = (providerId: string, registryId: string, modelId?: string) => ({
providerId,
providerSource: 'deployment',
presetId: registryId,
registryId,
...(modelId ? { modelId } : {}),
});
return {
revision: 1 as number | null,
policy: { allowWorkspaceProviders: true },
presets: Object.keys(providers).map((id) => ({
id,
name: PRESET_NAMES[id] ?? id,
kind: 'single',
capabilities: { chat: { models: [] } },
requiresBaseUrl: false,
customEndpoint: true,
recommended: {},
})),
providers: Object.entries(providers).map(([id, models]) => ({
id,
preset: id,
source: 'workspace',
capabilities: { chat: { models: models.map((model) => ({ id: model, name: model })) } },
key: { set: true, mask: '…' },
})),
slots: SLOTS.map(({ slot, parent, capability }) => {
let effective: Record<string, unknown> = { status: 'unassigned' };
if (capability === 'chat' && llmProvider) {
effective = {
status: 'assigned',
resolvedAt: 'llm',
source: 'workspace',
requirements: [],
...target(llmProvider, llmProvider, llmModel),
};
}
const media = options.media?.[slot as keyof NonNullable<ModelSettingsOptions['media']>];
if (media) {
effective = {
status: 'assigned',
resolvedAt: slot,
source: 'deployment',
requirements: [],
...target(media, media),
};
}
return {
slot,
parent,
capability,
configOnly: slot === 'agent.title',
locked: slot === 'llm' && !!options.locked,
...(slot === 'llm' && options.llm ? { assignment: options.llm } : {}),
effective,
};
}),
};
}
/** The view the specs start from: OpenAI's gpt-4o as the course model. */
export const DEFAULT_MODEL_SETTINGS: ModelSettingsOptions = {
providers: { openai: ['gpt-4o', 'gpt-4o-mini'] },
llm: 'openai:gpt-4o',
};
+6 -9
View File
@@ -9,22 +9,19 @@
*/
export const SETTINGS_KV_KEY = 'maic:account:settings-storage';
/** Default settings-storage value for e2e tests (Zustand persist v4 format) */
/**
* Default settings-storage value for e2e tests (Zustand persist v5 format):
* the user's own preferences. Models and providers are the workspace's,
* answered by `/api/model-config` (see ./model-settings.ts).
*/
export function createSettingsStorage(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
state: {
modelId: 'gpt-4o',
providerId: 'openai',
providersConfig: {
openai: { apiKey: 'test-key' },
},
agentMode: 'preset',
selectedAgentIds: [],
ttsEnabled: false,
reviewOutlineEnabled: false,
autoConfigApplied: true,
...overrides,
},
version: 2,
version: 5,
});
}
@@ -25,8 +25,8 @@ test('actual classroom component reference samples declared area state on send w
// Keep this isolated test away from every provider/model endpoint.
if (path.includes('/chat') || path.includes('/generate') || path.includes('/tts'))
return route.abort();
if (path === '/api/server-providers')
return route.fulfill({ json: { providers: {}, mediaProviders: {}, defaultModel: null } });
// The model settings are the fixture's (a course model is set up).
if (path.startsWith('/api/model-config')) return route.fallback();
if (path === '/api/comfyui-workflows') return route.fulfill({ json: { workflows: [] } });
await route.continue();
});
@@ -171,8 +171,8 @@ test('missing AbortSignal.any still sends the classroom question without state',
});
if (path.includes('/chat') || path.includes('/generate') || path.includes('/tts'))
return route.abort();
if (path === '/api/server-providers')
return route.fulfill({ json: { providers: {}, mediaProviders: {}, defaultModel: null } });
// The model settings are the fixture's (a course model is set up).
if (path.startsWith('/api/model-config')) return route.fallback();
if (path === '/api/comfyui-workflows') return route.fulfill({ json: { workflows: [] } });
await route.continue();
});
-116
View File
@@ -1,116 +0,0 @@
import { test, expect } from '../fixtures/base';
import { HomePage } from '../pages/home.page';
import { createSettingsStorage } from '../fixtures/test-data/settings';
/**
* #620 — server-configured providers are admin-managed and not client-overridable.
*
* Managed provider (openai, present in /api/server-providers): the settings panel
* shows the "managed by server" notice and HIDES the API key / base-URL override
* inputs.
* Unmanaged provider (anthropic, absent from /api/server-providers): the panel keeps
* the editable API key / base-URL inputs.
*
* Playwright Chromium locale is en-US.
*/
const SCREENSHOT_DIR = 'e2e/screenshots';
function serverProvidersBody(providers: Record<string, { models?: string[] }>) {
return JSON.stringify({
providers,
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
});
}
test.describe.configure({ mode: 'serial' });
test.describe('#620 managed providers are read-only', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/server-providers', (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
// Only openai is server-configured (managed); anthropic is not.
body: serverProvidersBody({ openai: { models: ['gpt-4o', 'gpt-4o-mini'] } }),
}),
);
await page.addInitScript(
(settings) => {
localStorage.setItem('maic:account:settings-storage', settings);
},
createSettingsStorage({
modelId: '',
providerId: 'openai',
providersConfig: {
openai: { apiKey: '' },
anthropic: { apiKey: 'sk-ant-local' },
},
autoConfigApplied: true,
}),
);
});
async function openProviderSettings(page: HomePage['page']) {
const home = new HomePage(page);
await Promise.all([page.waitForResponse('**/api/server-providers'), home.goto()]);
await expect(home.textarea).toBeVisible();
// Header gear button opens the settings dialog (defaults to the first section).
await page.locator('button:has(svg.lucide-settings)').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
// Post-restructure IA: the provider list + config panel live under the
// "Model Services" section.
await page.getByRole('button', { name: 'Model Services' }).click();
}
test('managed provider (openai) hides the key / base-URL override inputs', async ({ page }) => {
await openProviderSettings(page);
// The providers column defaults to the promoted provider (Kimi); select
// openai explicitly — it is server-managed in this mock.
await page.getByRole('button', { name: /^OpenAI/ }).click();
// openai is the selected provider (store providerId) and is server-managed:
// the override inputs must be absent.
await expect(page.locator('input[name="llm-api-key-openai"]')).toHaveCount(0);
await expect(page.locator('input[name="llm-base-url-openai"]')).toHaveCount(0);
// The mock pins an allowed model list, so the catalog is locked too:
// no add/reset affordance, and the pinned models are shown read-only.
// (Scope to the dialog: the homepage toolbar model pill also shows the
// selected model name after the settings-IA restructure.)
await expect(page.getByRole('button', { name: /new model/i })).toHaveCount(0);
await expect(page.getByRole('dialog').getByText('gpt-4o', { exact: true })).toBeVisible();
await page.screenshot({
path: `${SCREENSHOT_DIR}/620-managed-openai-readonly.png`,
fullPage: true,
animations: 'disabled',
caret: 'hide',
});
});
test('unmanaged provider (anthropic) keeps the editable inputs', async ({ page }) => {
await openProviderSettings(page);
// anthropic is displayed as "Claude" in the provider list.
await page
.getByRole('button', { name: /claude/i })
.first()
.click();
await expect(page.locator('input[name="llm-api-key-anthropic"]')).toBeVisible();
await expect(page.locator('input[name="llm-base-url-anthropic"]')).toBeVisible();
await page.screenshot({
path: `${SCREENSHOT_DIR}/620-unmanaged-anthropic-editable.png`,
fullPage: true,
animations: 'disabled',
caret: 'hide',
});
});
});
+21 -68
View File
@@ -1,17 +1,17 @@
import { test, expect } from '../fixtures/base';
import { HomePage } from '../pages/home.page';
import { createSettingsStorage } from '../fixtures/test-data/settings';
/**
* #580 — "usable provider ⇒ a concrete model is always selected".
*
* State A: no usable provider (keyless ollama/lemonade no longer count until
* the user sets an explicit baseUrl) → generate button disabled, the
* toolbar shows the single "Set up model" affordance. NO
* The course model is the workspace's `llm` slot, read from the server
* (`/api/model-config`); the browser keeps no provider state.
*
* State A: the workspace has no language model → generate button disabled,
* the toolbar shows the single "Set up model" affordance. NO
* modelNotConfigured toast, NO forced settings dialog.
* State B: a server-configured provider → a concrete model is auto-resolved,
* the toolbar shows provider / model (never "Select Model"), and
* generation is enabled.
* State B: the llm slot names a model → the toolbar shows provider / model
* (never "Select Model"), and generation is enabled.
*
* The Playwright Chromium locale is en-US, so UI strings are English
* ("Set up model" = settings.configureProvider, "Enter Classroom").
@@ -20,50 +20,17 @@ import { createSettingsStorage } from '../fixtures/test-data/settings';
const SCREENSHOT_DIR = 'e2e/screenshots';
const SETUP_CTA = 'Set up model';
// fetchServerProviders reads data.tts/asr/pdf/image/video/webSearch via
// Object.keys(); omitting them throws and is silently swallowed by its
// try/catch, so the mock must return the full shape (like the unit helper).
function serverProvidersBody(providers: Record<string, { models?: string[] }>) {
return JSON.stringify({
providers,
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
});
}
// Run serially with one worker: a single shared dev server + async
// server-provider reconcile makes parallel runs flaky.
test.describe.configure({ mode: 'serial' });
test.describe('#580 model-selection invariant', () => {
test('State A: no usable provider → disabled generate + single Set-up affordance, no toast', async ({
test('State A: no language model → disabled generate + single Set-up affordance, no toast', async ({
page,
mockApi,
}) => {
await page.route('**/api/server-providers', (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: serverProvidersBody({}),
}),
);
await page.addInitScript(
(settings) => {
localStorage.setItem('maic:account:settings-storage', settings);
},
createSettingsStorage({
modelId: '',
providerId: 'openai',
providersConfig: { openai: { apiKey: '' } },
autoConfigApplied: true,
}),
);
await mockApi.mockModelSettings({ providers: {} });
const home = new HomePage(page);
await Promise.all([page.waitForResponse('**/api/server-providers'), home.goto()]);
await Promise.all([page.waitForResponse('**/api/model-config'), home.goto()]);
await expect(home.textarea).toBeVisible();
// Single affordance is the toolbar "Set up model" CTA.
@@ -71,8 +38,8 @@ test.describe('#580 model-selection invariant', () => {
// No model pill (its aria-label would contain " / ").
await expect(page.locator('button[aria-label*=" / "]')).toHaveCount(0);
// Even with a requirement typed, generation stays disabled (gate is
// hasUsableProvider, not modelId) — and crucially NO toast / forced dialog.
// Even with a requirement typed, generation stays disabled — and
// crucially NO toast / forced dialog.
await home.fillRequirement('Explain how photosynthesis works');
await expect(home.enterButton).toBeDisabled();
await expect(page.locator('[data-sonner-toast]')).toHaveCount(0);
@@ -86,34 +53,20 @@ test.describe('#580 model-selection invariant', () => {
});
});
test('State B: server-configured provider → concrete model auto-selected, generation enabled', async ({
test('State B: the llm slot names a model → model pill, generation enabled', async ({
page,
mockApi,
}) => {
await page.route('**/api/server-providers', (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: serverProvidersBody({ openai: { models: ['gpt-4o', 'gpt-4o-mini'] } }),
}),
);
await page.addInitScript(
(settings) => {
localStorage.setItem('maic:account:settings-storage', settings);
},
createSettingsStorage({
modelId: '',
providerId: 'openai',
providersConfig: { openai: { apiKey: '' } },
autoConfigApplied: true,
}),
);
await mockApi.mockModelSettings({
providers: { openai: ['gpt-4o', 'gpt-4o-mini'] },
llm: 'openai:gpt-4o',
});
const home = new HomePage(page);
await Promise.all([page.waitForResponse('**/api/server-providers'), home.goto()]);
await Promise.all([page.waitForResponse('**/api/model-config'), home.goto()]);
await expect(home.textarea).toBeVisible();
// Reconcile resolves (openai, '' → first server model 'gpt-4o'); the
// toolbar shows the model pill, never "Set up model"/"Select Model".
// The toolbar shows the model pill, never "Set up model"/"Select Model".
const modelPill = page.locator('button[aria-label^="OpenAI / "]');
await expect(modelPill).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(SETUP_CTA, { exact: true })).toHaveCount(0);
+17 -64
View File
@@ -1,34 +1,17 @@
import { test, expect } from '../fixtures/base';
import { createSettingsStorage } from '../fixtures/test-data/settings';
// These are deliberate repeated-open stress cases. Parallel dev-server runs
// can spend most of the default 30s budget compiling before the ten cycles.
test.describe.configure({ timeout: 60_000 });
for (const method of ['click', 'Enter', 'Space']) {
test(`model picker selects and closes with ${method}`, async ({ page }) => {
test(`model picker selects and closes with ${method}`, async ({ page, mockApi }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/server-providers', (route) =>
route.fulfill({
json: {
providers: {
openai: { models: ['gpt-4o', 'gpt-4o-mini'] },
anthropic: { models: ['claude-sonnet-4-6'] },
},
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
},
}),
);
await page.addInitScript(
(settings) => localStorage.setItem('maic:account:settings-storage', settings),
createSettingsStorage({ providersConfig: { openai: { apiKey: '' } } }),
);
await mockApi.mockModelSettings({
providers: { openai: ['gpt-4o', 'gpt-4o-mini'], anthropic: ['claude-sonnet-4-6'] },
llm: 'openai:gpt-4o',
});
await page.goto('/');
const picker = page.locator('button[aria-label*=" / "]').first();
await expect(picker).toBeVisible();
@@ -51,42 +34,25 @@ for (const method of ['click', 'Enter', 'Space']) {
});
}
test('provider switch and nested thinking popup remain usable', async ({ page }) => {
test('provider switch and repeated dismissal remain usable', async ({ page, mockApi }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/server-providers', (route) =>
route.fulfill({
json: {
providers: { openai: { models: ['gpt-4o'] }, anthropic: { models: ['claude-sonnet-4-6'] } },
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
},
}),
);
await page.addInitScript(
(settings) => localStorage.setItem('maic:account:settings-storage', settings),
createSettingsStorage({ providersConfig: { openai: { apiKey: '' } } }),
);
await mockApi.mockModelSettings({
providers: { openai: ['gpt-4o'], anthropic: ['claude-sonnet-4-6'] },
llm: 'openai:gpt-4o',
});
await page.goto('/');
const picker = page.locator('button[aria-label*=" / "]').first();
await picker.click();
let dialog = page.getByRole('dialog');
// Post-restructure picker: providers are group headings (not tabs), so a
// provider switch is just selecting a row from the Claude group.
// Providers are group headings (not tabs), so a provider switch is just
// selecting a row from the Claude group; it sets the workspace's llm slot.
await dialog.locator('div[role="button"]').filter({ hasText: 'claude-sonnet-4-6' }).click();
await expect(dialog).toBeHidden();
await expect(picker).toHaveAttribute('aria-label', /Claude/);
for (let i = 0; i < 10; i++) {
await picker.click();
dialog = page.getByRole('dialog');
// Inline thinking control on the selected row (nested Select popover).
await dialog.getByRole('combobox').first().click();
const options = page.getByRole('option');
await options.first().click();
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
@@ -94,26 +60,13 @@ test('provider switch and nested thinking popup remain usable', async ({ page })
expect(errors).toEqual([]);
});
test('outside click closes and keyboard dismissal restores trigger focus', async ({ page }) => {
test('outside click closes and keyboard dismissal restores trigger focus', async ({
page,
mockApi,
}) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/server-providers', (route) =>
route.fulfill({
json: {
providers: { openai: { models: ['gpt-4o'] } },
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
},
}),
);
await page.addInitScript(
(settings) => localStorage.setItem('maic:account:settings-storage', settings),
createSettingsStorage({ providersConfig: { openai: { apiKey: '' } } }),
);
await mockApi.mockModelSettings({ providers: { openai: ['gpt-4o'] }, llm: 'openai:gpt-4o' });
await page.goto('/');
const picker = page.locator('button[aria-label*=" / "]').first();
await picker.click();
+1 -1
View File
@@ -69,7 +69,7 @@ test('playback cursor persists to device KV and survives a fresh page', async ({
(settings) => {
localStorage.setItem('maic:account:settings-storage', settings);
},
createSettingsStorage({ autoPlayLecture: true, ttsEnabled: false }),
createSettingsStorage({ autoPlayLecture: true }),
);
const stageId = await seedStage(page);
+19 -45
View File
@@ -11,41 +11,11 @@ import { createSettingsStorage, SETTINGS_KV_KEY } from '../fixtures/test-data/se
* The Playwright Chromium locale is en-US, so UI strings are English.
*/
// fetchServerProviders reads data.tts/asr/pdf/image/video/webSearch via
// Object.keys(); omitting them throws and is silently swallowed by its
// try/catch, so the mock must return the full shape.
function serverProvidersBody(providers: Record<string, { models?: string[] }>) {
return JSON.stringify({
providers,
tts: {},
asr: {},
pdf: {},
image: {},
video: {},
webSearch: {},
});
}
const SETTINGS = createSettingsStorage({
modelId: 'gpt-4o',
providerId: 'openai',
providersConfig: { openai: { apiKey: 'test-key' } },
autoConfigApplied: true,
});
const SETTINGS = createSettingsStorage({ reviewOutlineEnabled: true, playbackSpeed: 1.5 });
test.describe.configure({ mode: 'serial' });
test.describe('settings persistence through the KVStore', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/server-providers', (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: serverProvidersBody({ openai: { models: ['gpt-4o', 'gpt-4o-mini'] } }),
}),
);
});
test('loads a store already living in the KV scope, with no raw key present', async ({
page,
}) => {
@@ -58,16 +28,20 @@ test.describe('settings persistence through the KVStore', () => {
);
const home = new HomePage(page);
await Promise.all([page.waitForResponse('**/api/server-providers'), home.goto()]);
await home.goto();
await expect(home.textarea).toBeVisible();
// The seeded provider/model reached the UI, so hydration read the KV scope.
const modelPill = page.locator('button[aria-label^="OpenAI / "]');
await expect(modelPill).toBeVisible({ timeout: 15_000 });
await expect(modelPill).toHaveAttribute('aria-label', /OpenAI \/ gpt-4o/);
await home.fillRequirement('Explain how photosynthesis works');
await expect(home.enterButton).toBeEnabled();
// The store reads the KV scope and leaves the value there.
await expect
.poll(
() =>
page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw)?.state?.playbackSpeed : undefined;
}, SETTINGS_KV_KEY),
{ timeout: 15_000 },
)
.toBe(1.5);
// Nothing was written under the raw key.
expect(await page.evaluate(() => localStorage.getItem('settings-storage'))).toBeNull();
@@ -79,22 +53,22 @@ test.describe('settings persistence through the KVStore', () => {
}, SETTINGS);
const home = new HomePage(page);
await Promise.all([page.waitForResponse('**/api/server-providers'), home.goto()]);
await home.goto();
await expect(home.textarea).toBeVisible();
// No migration: the raw blob is not read, and it is best-effort purged
// (it held plaintext provider API keys).
// (in earlier builds it held plaintext provider API keys).
await expect
.poll(() => page.evaluate(() => localStorage.getItem('settings-storage')), {
timeout: 15_000,
})
.toBeNull();
// And the seeded apiKey never reached the KV scope.
const leakedKey = await page.evaluate((key) => {
// And the seeded value never reached the KV scope.
const leaked = await page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw)?.state?.providersConfig?.openai?.apiKey : undefined;
return raw ? JSON.parse(raw)?.state?.playbackSpeed : undefined;
}, SETTINGS_KV_KEY);
expect(leakedKey).not.toBe('test-key');
expect(leaked).not.toBe(1.5);
});
});
@@ -26,8 +26,8 @@ test('selects a panned/zoomed whiteboard element, sends its identity, and retain
}
if (path.includes('/chat') || path.includes('/generate') || path.includes('/tts'))
return route.abort();
if (path === '/api/server-providers')
return route.fulfill({ json: { providers: {}, mediaProviders: {}, defaultModel: null } });
// The model settings are the fixture's (a course model is set up).
if (path.startsWith('/api/model-config')) return route.fallback();
if (path === '/api/comfyui-workflows') return route.fulfill({ json: { workflows: [] } });
await route.continue();
});
+1 -1
View File
@@ -135,7 +135,7 @@ async function runScenario(
await runAgentLoop(
{
config: scenario.config,
apiKey: '', // Server resolves API key from env/YAML
// The server resolves the key from its configuration.
model,
},
{
+27
View File
@@ -44,6 +44,19 @@ export async function register(): Promise<void> {
warnIfAccessCodeIsUnset(process.env.ACCESS_CODE);
}
// Warn-only: the instance secret that seals keys saved in the model settings
// (lib/server/instance-secret-check.ts), checked against the keys already
// stored with one query in the background. It never stops the server.
const { warnAboutInstanceSecret } = await import('@/lib/server/instance-secret-check');
void warnAboutInstanceSecret();
// The one-time import of classrooms earlier versions stored as files
// (lib/server/legacy-classroom-import.ts). It reads the disk and the
// database, so it runs in the background, retrying with backoff until it
// completes: `register` must not wait on it.
const { startLegacyClassroomImport } = await import('@/lib/server/legacy-classroom-import');
const legacyClassroomImport = startLegacyClassroomImport();
// Imported dynamically so the Edge bundle never pulls in `pg`.
const { startAssetCollectorSchedule } =
await import('@/lib/persistence/asset-collector-schedule');
@@ -103,6 +116,11 @@ export async function register(): Promise<void> {
} catch (error) {
console.error('[instrumentation] Agent event notify bus drain failed', error);
}
try {
await legacyClassroomImport.stop();
} catch (error) {
console.error('[instrumentation] Legacy classroom import drain failed', error);
}
try {
await assetSchedule?.stop();
} catch (error) {
@@ -146,6 +164,15 @@ async function validateBootConfiguration(): Promise<void> {
const { requireDatabaseUrl } = await import('@/lib/server/database-requirement');
runConfigurationCheck(() => requireDatabaseUrl());
// The deployment's model configuration: openmaic.yml (or the file named by
// OPENMAIC_CONFIG), else the legacy provider variables translated. A
// malformed file, an unset `${VAR}` or a slot pointing at an undeclared
// provider is refused here with every problem listed, and so is a
// MODEL_ROUTES left without openmaic.yml, rather than discovered by the
// first generation. Loaded once; the notices are printed here.
const { deploymentConfig } = await import('@/lib/server/model-config/runtime');
runConfigurationCheck(deploymentConfig);
// The asset quota, read here rather than at the first persistence request.
// The provider that consumes it is lazy and memoised, so a malformed ceiling
// would otherwise let the process boot, pass its health check, and then fail
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff. Show more