mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 13:24:41 +00:00
* feat(web-shell): editable user-scope settings and in-panel model management
Make the Settings panel able to manage ~/.qwen/settings.json (user scope),
and add model management inside the panel's Model category.
User scope: the User tab was read-only; enable writing user-scope settings
end-to-end (route allows the user scope, and the client/UI thread it through),
so the same structured controls edit ~/.qwen/settings.json.
Model management: list configured models (grouped by provider), set the
current model, add a model (reusing the existing provider-setup wizard), and
delete a model. Delete is the only new backend surface: DELETE /workspace/models
rewrites modelProviders in the owning scope, empties (rather than drops) a
provider whose last model is removed so the format-preserving settings writer
clears it cleanly, and clears model.name when the active model is deleted.
Voice Model and Model Fallbacks now use pickers instead of free text: Voice
Model reuses the voice picker; Model Fallbacks opens a multi-select (up to 3,
ordered) whose value round-trips through the comma-separated setting.
* fix(web-shell): label model sub-dialog buttons "Select" not "Edit"
Fast/Vision/Voice Model and Model Fallbacks open a picker, so their
empty-state button now reads "Select" instead of "Edit", which wrongly
implied free-text editing.
* fix(sdk): export DaemonModelDelete{Request,Result} from daemon entry
The DELETE /workspace/models types were added to daemon types but not
re-exported from @qwen-code/sdk/daemon, so consumers resolving the built
dist (webui's dts rollup in CI) failed with TS2305.
* fix(web-shell): address automated review of model management
- reset modelSettingScope when the Add Model (auth) dialog closes too, not
only the model picker / fallbacks dialog (Critical)
- delete: don't surface a reload failure as "delete failed"
- ModelFallbacksDialog: drop role=listbox/option (no arrow-key nav) for
aria-pressed toggle buttons matching the click-only interaction
- add :focus-visible outlines to model-management and fallbacks buttons
- mock daemon: handle DELETE /workspace/models
- document first-id-match-wins in removeModelFromProviders
- tests: cover 500 path, broadcast assertions, parseTarget validation codes,
active-model clearing with a pinned baseUrl, and scope the runtime-model
no-delete assertion to that row
* fix(serve): keep workspace-qualified settings route workspace-only
Widening VALID_WRITE_SCOPES to include 'user' for the primary
/workspace/settings route also loosened the trust-gated
/workspaces/:workspace/settings route, breaking its deliberate
"reject user scope" contract. Give the qualified route its own
workspace-only scope set; the primary route keeps user scope.
* fix(web-shell): address second review round of model management
- isActiveModelSelection: when the active model is pinned to a baseUrl, an
id-only delete no longer clears it (may have removed a different variant)
- DELETE /workspace/models: on a partial multi-key persist failure, broadcast
the committed writes before returning 500 (matches workspace-voice)
- model pickers (fast/vision/voice + fallbacks) read the value for the scope
being edited, so the User tab no longer shows/clears workspace values
- voice sub-dialog: functional setState so a late loadProviders().then() can't
clobber a picker the user opened meanwhile
- ModelManagementSection cancel button honors the busy state
- doc fix (emptied provider keys are kept as empty arrays), plus tests for the
baseUrl-asymmetry and partial-persist paths
* fix(web-shell): address third review round of model management
- DELETE /workspace/models returns a structured partial-persist response
({ code: 'partial_persist_error', committedKeys }) so callers can reconcile
- scrub the deleted model id out of modelFallbacks so no dangling reference
remains; trim padded request fields before matching
- reload workspace settings after a delete so a cleared active model / scrubbed
fallback isn't shown stale
- narrow the workspace-qualified SDK client back to scope: 'workspace' (that
route is workspace-only); drop the now-dead qualified scope ternary
- remove the unused providerKey from RemoveModelResult
* fix(web-shell): address fourth review round of model management
- handleFallbacksConfirm isolates the settings-reload failure from the
save-failed toast (a reload reject no longer looks like a save failure)
- readScopedModelSetting returns only the edited scope's value (no effective
fallback), so the User tab doesn't show/appear-to-clear inherited values
- Add Model button honors the busy state
- widen DaemonSettingUpdateResult.scope to 'workspace' | 'user' to match the
server echo
- tests: workspace-scope owner path, modelFallbacks scrub broadcast,
baseModelId current-match, and a delete target without baseUrl
* fix(web-shell): use theme --error-color for model delete buttons
Replace hardcoded #d64545 with var(--error-color) so the destructive model
buttons match the per-theme error color used across the web-shell (dark
#fc8181 / light #c0362c) instead of a fixed mid-red.
* fix(web-shell): address qwen /review self-review of model management
- surface requiresRestart for modelFallbacks changes: handleFallbacksConfirm
and handleDeleteModel show the restart notice, and DELETE /workspace/models
reports requiresRestart when a committed write targets a restart-required key
- only scrub a deleted model from modelFallbacks when no other provider still
configures the same bare id (fallbacks are bare-id, so a same-id model under
another provider may still want that fallback)
- widen DaemonModelDeleteResult with requiresRestart; add tests for the
keep-fallback case and the requiresRestart response
* fix(web-shell): address fifth review round of model management
Backend (mixed-scope correctness for model deletion):
- Clear the active model selection in every writable scope whose own
selection names the deleted model, comparing against the removed
entry's stored (unsanitized) baseUrl so a credential-bearing URL is
still recognized after the providers status sanitizes it.
- Scrub modelFallbacks in its own owning scope rather than the
modelProviders owner scope; the two are independently scoped.
- removeModelFromProviders now reports removedBaseUrl.
CLI:
- /language ui accepts --project/--global so the settings panel can
persist a UI-language change to the selected scope while still
switching the daemon's live locale.
Web-shell:
- Fast-model picker forwards the selected scope via --project/--global.
- Theme/Language controls display the selected scope's value; Language
persists through the scoped command.
- The model-management "current" badge uses a single-winner,
endpoint-aware match so a bare current id no longer marks every
same-base-id row current.
- Serialize Set current through the shared busy flag; reset the recorded
scope if voice-provider loading rejects.
Tests: mixed-scope route cases, strict-mutation/client-id harness
assertions, SDK setWorkspaceSetting/deleteModel transport tests, a
useDaemonProviders hook test, language scope-flag tests, and dom tests
for the current-badge and fallbacks normalization.
* fix(web-shell): address second qwen /review self-review of model management
- onSubDialog now records the model persist scope per model sub-dialog
(fast/vision/voice/fallbacks) and no longer for the non-model
approvalMode dialog — the reset effect is gated on the dialog/fallback/
auth flags, so it never covers approvalMode and would otherwise leave a
stale scope for a later command-launched picker. (The flagged voice
"scope-reset race" does not actually occur: that same effect gating means
no render between the synchronous scope set and the picker opening
re-runs it — but the scope handling is now explicit per branch.)
- Add an aria-label to the delete-confirm Cancel button so screen readers
can tell which model's confirmation is being cancelled.
Tests:
- Unit tests for getWritableScopes / getOwnKeyScope (trust + per-scope
ownership, incl. explicitly-set falsy values).
- Fast-model User-tab test asserting the /model --fast --global flag.
- Theme scoped-read test: the control shows the selected scope's value,
not the effective merge.
- Fix the useDaemonProviders mock: `current` is a provider-current object,
not a bare id.
* fix(web-shell): fix voice-picker scope race and provider memoization
- Voice model scope race: the voice picker opens asynchronously (after
loadProviders), so recording the persist scope synchronously up front
let it be clobbered — if the user opened and closed another picker while
loadProviders was in flight, the reset effect reset the scope and the
voice model persisted to the wrong scope. Now the scope is captured from
the click and applied together with the open, guarded by a
modelDialogMode ref so it only opens (and sets scope) when no other
surface opened meanwhile. (Vision/fast are synchronous and unaffected.)
- Depend on the stable `reload` fn (extracted as reloadProviders) instead
of the fresh-every-render providersState object, so handleDeleteModel /
handleCloseAuthDialog aren't recreated each render.
- Add role="group" + aria-label to the model-fallbacks option list so
screen readers announce it as a labeled multi-select group (+ test).
* fix(web-shell): address review round on model-management follow-up
Frontend:
- Remove the redundant `data-keyboard-scope` from ModelFallbacksDialog's
inner div — the wrapping DialogShell already provides it, and the extra
scope became the last match in DialogShell's close cleanup, whose
role="dialog" lookup then failed and dropped focus when the dialog
closed while another was stacked.
- Voice picker open-guard now also checks the fallbacks/auth dialog flags
(via refs), matching "no other surface opened meanwhile" so it can't
open on top of a dialog opened while providers were loading.
- Provider group key includes the index (two providers can share an
authType) to avoid duplicate-key reconciliation.
- handleCloseAuthDialog logs a failed provider reload instead of
swallowing it, like the sibling handlers.
- Model-fallbacks options at the max show a `title` explaining the limit
(new localized string).
Backend:
- Split the DELETE /workspace/models baseUrl validation so a too-long
value reports a length error, not "must be a string".
Tests:
- Workspace-scoped language change (`/language ui --project`).
- Security-sensitive key (tools.approvalMode) rejected at user scope.
- baseUrl length-limit rejection.
- Model-fallbacks Cancel → onClose; delete-confirm Cancel path restores
the Delete button; fallbacks accessible grouping already covered.
* fix(web-shell): a11y + robustness follow-ups on model management
- Model-fallbacks max-limit options use aria-disabled instead of the
native disabled attribute so they stay hoverable and can surface the
"limit reached" title (disabled buttons fire no events); the toggle
handler already no-ops at the max. CSS updated to match.
- Inline delete confirmation dismisses on Escape (the conventional
gesture) so keyboard users need not Tab to Cancel.
- scopeToWire throws on an unexpected SettingScope instead of silently
reporting it as 'user'.
- Re-export DaemonWorkspaceProviderCurrent from the webui daemon facade
alongside the sibling provider types.
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useI18n } from '../../i18n';
|
|
import styles from './ModelFallbacksDialog.module.css';
|
|
|
|
export interface FallbackModelOption {
|
|
/** Base model id persisted in the modelFallbacks setting. */
|
|
baseId: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface ModelFallbacksDialogProps {
|
|
models: FallbackModelOption[];
|
|
/** Currently configured fallback base ids, in priority order. */
|
|
current: string[];
|
|
max: number;
|
|
onConfirm: (baseIds: string[]) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ModelFallbacksDialog({
|
|
models,
|
|
current,
|
|
max,
|
|
onConfirm,
|
|
onClose,
|
|
}: ModelFallbacksDialogProps) {
|
|
const { t } = useI18n();
|
|
// Normalize the persisted value on open — trim, drop blanks/dupes, and apply
|
|
// max — so a hand-edited or oversized setting doesn't open as N/max and get
|
|
// written back verbatim.
|
|
const [selected, setSelected] = useState<string[]>(() => {
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const raw of current) {
|
|
const id = raw.trim();
|
|
if (!id || seen.has(id)) continue;
|
|
seen.add(id);
|
|
out.push(id);
|
|
if (out.length >= max) break;
|
|
}
|
|
return out;
|
|
});
|
|
|
|
// Show the available models plus any already-configured fallback whose model
|
|
// is no longer available, so the user can still see and remove it.
|
|
const rows = useMemo(() => {
|
|
const known = new Set(models.map((m) => m.baseId));
|
|
const extra = selected
|
|
.filter((id) => !known.has(id))
|
|
.map((id) => ({ baseId: id, label: id }));
|
|
return [...models, ...extra];
|
|
}, [models, selected]);
|
|
|
|
const toggle = (baseId: string) => {
|
|
setSelected((prev) => {
|
|
if (prev.includes(baseId)) return prev.filter((x) => x !== baseId);
|
|
if (prev.length >= max) return prev;
|
|
return [...prev, baseId];
|
|
});
|
|
};
|
|
|
|
return (
|
|
// No data-keyboard-scope here: the wrapping DialogShell already provides the
|
|
// keyboard scope + role="dialog". A second scope on this inner div becomes
|
|
// the last [data-keyboard-scope] match in DialogShell's close cleanup, whose
|
|
// role="dialog" lookup then fails (it's the parent <section>), dropping focus
|
|
// when this dialog closes while another DialogShell is stacked.
|
|
<div className={styles.body}>
|
|
<div className={styles.hint}>
|
|
{t('settings.models.fallbacks.hint', { max })}
|
|
</div>
|
|
<div
|
|
className={styles.list}
|
|
role="group"
|
|
aria-label={t('settings.models.fallbacks.title')}
|
|
>
|
|
{rows.length === 0 && (
|
|
<div className={styles.empty}>
|
|
{t('settings.models.fallbacks.empty')}
|
|
</div>
|
|
)}
|
|
{rows.map((model) => {
|
|
const order = selected.indexOf(model.baseId);
|
|
const isSelected = order >= 0;
|
|
const atLimit = selected.length >= max && !isSelected;
|
|
return (
|
|
<button
|
|
key={model.baseId}
|
|
type="button"
|
|
aria-pressed={isSelected}
|
|
className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`}
|
|
// aria-disabled (not the native `disabled`) so the button still
|
|
// receives hover/focus and can surface the "limit reached" title —
|
|
// disabled buttons fire no events, so the tooltip never shows. The
|
|
// toggle() handler already no-ops when at the max.
|
|
aria-disabled={atLimit || undefined}
|
|
title={
|
|
atLimit
|
|
? t('settings.models.fallbacks.limitReached', { max })
|
|
: undefined
|
|
}
|
|
onClick={() => toggle(model.baseId)}
|
|
>
|
|
<span className={styles.badge}>
|
|
{isSelected ? order + 1 : ''}
|
|
</span>
|
|
<span className={styles.label}>{model.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className={styles.footer}>
|
|
<span className={styles.count}>
|
|
{selected.length}/{max}
|
|
</span>
|
|
<div className={styles.actions}>
|
|
<button type="button" className={styles.cancel} onClick={onClose}>
|
|
{t('settings.models.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.confirm}
|
|
onClick={() => onConfirm(selected)}
|
|
>
|
|
{t('settings.models.fallbacks.confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|