fix(web): answer the review on the declared-provider editor

The display name read only the user layer while its placeholder claimed a
cleared field falls back to the route id. A `cordis.yml` can pin a name
for a route the catalog does not ship, and clearing then restores that
name, not the id. The placeholder now reads the composition layer — the
same layer `inheritedModels` reads — and names the route id only when
nothing pins one.

The saved notice echoed the target captured when the card opened, which
never lied while the name could not change. It now names the provider as
the refreshed directory reports it, and is announced only once that
refresh is in the snapshot it reads from.

The protocol select's blank option is named, because a screen reader
announces it either way and an unnamed one is a choice with no identity.
`protocolChoices` no longer rehydrates the section schema for the two
layouts that have no per-route protocol to find.

Documentation caught up with the same broom: the module JSDoc and the
e2e header both still described one field where the fold now carries
two, the English user guide carried a Chinese UI label among English
ones, and the README's DeepSeek row inventory omitted `maxTokens`.
This commit is contained in:
Yichen Jiang
2026-08-10 23:31:21 +08:00
parent 23995f88ce
commit 28546b67fb
13 changed files with 112 additions and 28 deletions

View File

@@ -184,8 +184,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
setAdding(false)
setDeclaring(false)
if (changed) {
setSavedTarget(target)
void controller.load()
// Announced only once the refreshed directory is in the snapshot the
// notice reads its name from: an apply can rename the route, and the
// target captured when the card opened still carries the old name.
void controller.load().then(() => { setSavedTarget(target) })
}
}
@@ -225,6 +227,17 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
)
}
// The saved provider as the directory currently names it. The route id is
// what the apply cannot change, so it is what the notice is keyed by; a row
// the same apply removed keeps the captured identity, since nothing newer
// exists to name it with.
const savedRow = savedTarget === undefined
? undefined
: state.rows.find(row => row.entry.provider === savedTarget.provider)
const savedIdentity = savedRow === undefined
? savedTarget
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
const configured = state.rows.filter(row => row.configured)
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
@@ -239,11 +252,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
{savedTarget === undefined
{savedIdentity === undefined
? null
: (
<p className={styles['savedNotice']} role="status" aria-live="polite">
{providerCopy(t('savedProvider'), savedTarget)}
{providerCopy(t('savedProvider'), savedIdentity)}
</p>
)}
<ul className={styles['rows']}>

View File

@@ -8,8 +8,9 @@
* provider-native authentication);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, DeepSeek's id/name/context-window model catalog, and the
* wire protocol of a pi-ai route the adapter does not ship — the field the
* create card asked that route for, editable here for the same reason).
* display name and wire protocol of a pi-ai route the adapter does not ship —
* the two fields the create card asked that route for, editable here for the
* same reason).
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
* the models under one provider disagree about it, so a provider-scoped
* control can only be set to a value some of them reject. The composer's
@@ -151,7 +152,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const keyRef = refFor(namespace, settingsPath, props.provider)
// The same schema read the create card makes, so the choices offered here
// and there cannot drift apart: both come from the adapter's own `Config`.
const protocols = useMemo(() => protocolChoices(namespace), [namespace])
// Only the pi-ai layout has a per-route protocol for the read to find, and
// it rehydrates the whole section schema, so the other layouts skip it.
const protocols = useMemo(
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
[layout, namespace],
)
useEffect(() => {
let stale = false
@@ -362,10 +368,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
className={styles['input']}
type="text"
value={stringAt(draft, 'displayName') ?? ''}
// The route id, not the stored name: it is what this route
// is called the moment the field is cleared, and clearing
// is the only way back to it.
placeholder={props.provider}
// What this route is called the moment the field is
// cleared, which is the layer beneath the one this field
// edits: a `cordis.yml` may pin a name for a route the
// catalog does not ship, and only when nothing does is
// the answer the route id. Reading the effective value
// instead would echo the stored override back as the
// thing clearing restores.
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
?? props.provider}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setField('displayName', event.target.value) }}
@@ -405,8 +416,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
{/* A profile naming no protocol — hand-written into
settings.yaml with no model to need one — selects
nothing rather than reading as if it had picked the
first choice. */}
{probeApi === undefined ? <option value="" /> : null}
first choice. The option is named because a screen
reader announces it either way, and an empty one is
announced as a choice with no identity. */}
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>

View File

@@ -80,6 +80,7 @@ export const en = {
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
customApiUnset: 'Not selected',
customNeedsBaseUrl: 'A custom provider needs a base URL.',
customNeedsModels: 'A custom provider needs at least one model.',
create: 'Create provider',
@@ -173,6 +174,7 @@ export const zh: typeof en = {
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customApiUnset: '未选择',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
create: '创建提供方',