feat(web): configure custom DeepSeek models

This commit is contained in:
Yichen Jiang
2026-07-31 14:08:59 +08:00
parent 4a061f33d0
commit a332f2f333
42 changed files with 948 additions and 211 deletions
@@ -0,0 +1,196 @@
/**
* Curated editor for the direct DeepSeek adapter's advisory model catalog.
* The settings layer replaces `models` as one array, so the parent supplies
* the effective inherited rows until the first edit materializes a user
* override; reset removes that override instead of copying defaults into it.
*/
import type { ReactNode } from 'react'
import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
export type DeepSeekModelDraft = Record<string, unknown>
/** A localized validation failure for one user-owned model array. */
export interface DeepSeekModelsValidationFailure {
/** Zero-based model position. */
index: number
/** Message key owned by the Models settings section. */
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
}
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
export function modelDrafts(value: unknown): DeepSeekModelDraft[] {
if (!Array.isArray(value)) return []
return value.map(entry =>
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entry as DeepSeekModelDraft
: {})
}
/**
* Validate adapter constraints that the serialized schema cannot express.
* @param value - user-owned `models` value, or undefined while inherited.
* @returns the first invalid row, or undefined when the adapter will accept it.
*/
export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined {
if (value === undefined) return undefined
const models = modelDrafts(value)
const seen = new Set<string>()
for (const [index, model] of models.entries()) {
const id = model['id']
if (typeof id !== 'string' || id.length === 0) return { index, key: 'modelIdRequired' }
if (seen.has(id)) return { index, key: 'modelIdDuplicate' }
seen.add(id)
const name = model['name']
if (name !== undefined && (typeof name !== 'string' || name.length === 0)) {
return { index, key: 'modelNameInvalid' }
}
const contextWindow = model['contextWindow']
if (contextWindow !== undefined
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
return { index, key: 'modelContextInvalid' }
}
}
return undefined
}
/** Props of {@link DeepSeekModelsEditor}. */
export interface DeepSeekModelsEditorProps {
/** Effective rows: inherited until the parent materializes an override. */
models: readonly DeepSeekModelDraft[]
/** Whether the user layer currently owns the whole array. */
overridden: boolean
/** Fallback capacity used when a row omits its exact value. */
defaultContextWindow: number | undefined
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every mutation. */
disabled: boolean
/** Replace the user-owned array after one visible edit. */
onChange: (models: DeepSeekModelDraft[]) => void
/** Remove the user-owned array and return to inheritance. */
onReset: () => void
}
/**
* Render the direct DeepSeek adapter's id/name/context-window catalog.
* @param props - effective rows plus the array-level override actions.
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
if (at !== index) return copy
if (value === undefined) Reflect.deleteProperty(copy, key)
else copy[key] = value
return copy
})
props.onChange(next)
}
const remove = (index: number): void => {
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
}
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelCatalogHeader']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
</span>
</div>
{props.overridden
? (
<button
type="button"
className={styles['linkButton']}
disabled={props.disabled}
onClick={props.onReset}
>
{props.t('resetModels')}
</button>
)
: null}
</div>
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelTable']}>
{/* Captions sit above the rows and are hidden from assistive tech:
every field already carries the indexed `aria-label` naming it. */}
<div className={styles['modelColumns']} aria-hidden="true">
<span>{props.t('modelId')}</span>
<span>{props.t('modelName')}</span>
<span>{props.t('contextWindow')}</span>
</div>
{props.models.map((model, index) => (
<div className={styles['modelRow']} key={index}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelNamePlaceholder')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<input
className={styles['input']}
type="number"
min={1}
step={1}
value={typeof model['contextWindow'] === 'number' ? model['contextWindow'] : ''}
placeholder={props.defaultContextWindow === undefined
? props.t('contextWindowPlaceholder')
: String(props.defaultContextWindow)}
aria-label={`${props.t('contextWindow')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(
index,
'contextWindow',
event.target.value === '' ? undefined : Number(event.target.value),
)
}}
/>
<button
type="button"
className={styles['rowDelete']}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
<span className={styles['hiddenLabel']}>{props.t('removeModel')}</span>
</button>
</div>
))}
</div>
)}
<button
type="button"
className={styles['addModelButton']}
disabled={props.disabled}
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
>
<IconPlusOutline16 size={14} />
{props.t('addModel')}
</button>
</section>
)
}
@@ -1,3 +1,13 @@
/* Models settings section, in the settings-panel design language: 14/22 body,
* 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense),
* 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and
* the Button/Input primitives already use.
*
* Every color resolves through a `--dsw-alias-*` token. The section used to
* name `--border` / `--surface` / `--text-*`, which nothing in this app
* defines, so it always rendered the light-mode literals written as their
* fallbacks and stayed light under the dark theme. */
.section {
display: flex;
flex-direction: column;
@@ -7,20 +17,24 @@
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 13px;
color: var(--text-tertiary, #888);
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--text-warning, #a15c00);
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
.rows {
@@ -29,17 +43,18 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
/* A configured provider: outlined on the panel fill, so the filled editor
card it expands into reads as the nested object. */
.rowCard {
border: 1px solid var(--border, #e2e2e2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface, #fff);
}
.rowHead {
@@ -49,8 +64,10 @@
}
.rowName {
font-size: 15px;
font-weight: 600;
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.badges {
@@ -63,8 +80,9 @@
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-success, #0a7d33);
color: var(--dsw-alias-state-success-primary);
font-size: 12px;
line-height: 18px;
}
.badgeOk::before {
@@ -76,59 +94,118 @@
}
.badgeMuted {
color: var(--text-tertiary, #999);
font-size: 12px;
}
.badgeWarn {
color: var(--text-warning, #a15c00);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.rowActions {
display: inline-flex;
gap: 8px;
align-items: center;
gap: 4px;
}
/* `box-sizing` on every control here: the app has no global border-box reset,
so without it the outlined variants stand 2px taller than the filled ones
they sit beside (Cancel next to Apply, Edit next to Delete). */
.primaryButton,
.secondaryButton,
.addButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.primaryButton {
border: none;
border-radius: 999px;
padding: 8px 18px;
background: var(--accent-strong, #111);
color: var(--text-inverse, #fff);
font: inherit;
cursor: pointer;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
}
.secondaryButton {
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 6px 14px;
background: var(--surface, #fff);
color: inherit;
font: inherit;
cursor: pointer;
.primaryButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.secondaryButton,
.addButton {
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
color: var(--dsw-alias-label-primary);
}
.secondaryButton:hover:not(:disabled),
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.dangerButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border: none;
background: none;
color: var(--text-danger, #c0392b);
border-radius: 18px;
background: transparent;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.dangerButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Provider-row controls take the dense capsule (Button `.sm`). */
.rowActions .secondaryButton,
.rowActions .dangerButton {
height: 28px;
padding: 0 10px;
border-radius: 14px;
font-size: 12px;
line-height: 18px;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.5;
.dangerButton:disabled,
.addButton:disabled,
.linkButton:disabled,
.addModelButton:disabled,
.rowDelete:disabled {
opacity: 0.4;
cursor: default;
}
.primaryButton:focus-visible,
.secondaryButton:focus-visible,
.dangerButton:focus-visible,
.addButton:focus-visible,
.linkButton:focus-visible,
.addModelButton:focus-visible,
.rowDelete:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Editing surface: a filled module on the panel, matching the settings
selector fill rather than adding another outline inside the row. */
.editor {
border: 1px solid var(--border, #e6e6e6);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -143,12 +220,15 @@
.editorTitle {
font-size: 14px;
font-weight: 600;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.editorRoute {
font-size: 12px;
color: var(--text-tertiary, #999);
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.field {
@@ -162,30 +242,37 @@
align-items: center;
gap: 10px;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--text-secondary, #555);
color: var(--dsw-alias-label-secondary);
}
.linkButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: none;
background: none;
padding: 0;
color: var(--text-tertiary, #888);
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
text-decoration: underline;
line-height: 18px;
cursor: pointer;
}
.linkButton:disabled {
opacity: 0.5;
cursor: default;
.linkButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.advancedHint {
margin: 0;
font-size: 12px;
color: var(--text-tertiary, #999);
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.editorActions {
@@ -202,26 +289,12 @@
.addButton {
align-self: flex-start;
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
cursor: pointer;
}
.addButton:disabled {
opacity: 0.5;
cursor: default;
}
.addCard,
.setupCard {
border: 1px solid var(--border, #e6e6e6);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -229,24 +302,56 @@
list-style: none;
}
/* Nested in a card that already carries the module chrome. */
.addCard .editor,
.setupCard .editor {
border: none;
background: none;
padding: 0;
}
.customized {
border-top: 1px solid var(--border, #ececec);
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 10px;
}
/* Native disclosure marker replaced by a rotating chevron: the built-in
triangle differs per engine and cannot take the label color. */
.customizedSummary {
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 2px 4px;
margin-left: -4px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--text-secondary, #555);
list-style: revert;
color: var(--dsw-alias-label-secondary);
list-style: none;
}
.customizedSummary::-webkit-details-marker {
display: none;
}
.customizedSummary::before {
content: '';
width: 5px;
height: 5px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
transform: rotate(-45deg) translate(-1px, -1px);
transition: transform 120ms ease;
}
.customized[open] > .customizedSummary::before {
transform: rotate(45deg) translate(-1px, -1px);
}
.customizedSummary:hover {
color: var(--dsw-alias-label-primary);
}
.customizedBody {
@@ -256,28 +361,179 @@
padding-top: 12px;
}
/* Model catalog: a table, not a stack of cards. The column captions are
written once above the rows, so a row is one line of fields plus its
delete control; each field still carries the indexed `aria-label` that
names it, and the caption strip is hidden from assistive tech to keep
that name from being announced twice. */
.modelCatalog {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.modelCatalogHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelCatalogHeading {
display: flex;
flex-direction: column;
gap: 2px;
}
.modelCatalogTitle {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.modelCatalogMeta,
.modelEmpty {
margin: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.modelTable {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Captions and rows share one track list so the columns line up. */
.modelColumns,
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px;
align-items: center;
gap: 8px;
}
.modelColumns {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
/* The inset belongs on the caption cell, not the strip: padding on the grid
container would narrow its tracks against the rows' and walk the captions
left column by column. 1px border + 10px padding is the field text inset. */
.modelColumns > span {
padding-left: 11px;
}
.rowDelete {
box-sizing: border-box;
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.rowDelete:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.modelEmpty {
padding: 12px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 8px;
text-align: center;
}
.addModelButton {
box-sizing: border-box;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.addModelButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--border, #d9d9d9);
border-radius: 10px;
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
font-size: 14px;
line-height: 22px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
/* Enum pickers hold a handful of short options; a field-width dropdown reads
as a text field the user is expected to fill. */
select.input {
max-width: 240px;
cursor: pointer;
}
.input:focus {
outline: none;
border-color: var(--accent-strong, #111);
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--text-tertiary, #aaa);
color: var(--dsw-alias-label-dimmed);
}
.input:disabled {
opacity: 0.6;
cursor: default;
}
.error {
margin: 0;
font-size: 12px;
color: var(--text-danger, #c0392b);
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
/* Icon-button label seat: named for assistive tech and for the tests that
query these controls by their text. */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.customizedSummary::before {
transition: none;
}
}
@@ -11,6 +11,7 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
@@ -272,7 +273,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
setEditing(targetOf(first))
}}
>
{`+ ${t('add')}`}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
)}
</div>
@@ -5,19 +5,23 @@
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
* minimal `settings.mutate` path ops against the stored section — the card
* reads the redacted descriptor, so it names only the fields it can see and a
* stored literal secret is never collaterally removed.
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
* DeepSeek's id/name/context-window model catalog). Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card reads the redacted
* descriptor, so it names only the fields it can see and a stored literal
* secret is never collaterally removed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
import {
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
@@ -236,6 +246,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const effortField = EFFORT_FIELD[family]
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models']))
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
return (
<>
<div className={styles['field']}>
@@ -289,6 +303,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
</div>
</details>
</>
@@ -27,6 +27,23 @@ export const en = {
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
resetModels: 'Restore defaults',
model: 'Model',
modelId: 'Model ID',
modelName: 'Display name',
modelNamePlaceholder: 'Uses the model ID when empty',
contextWindow: 'Context window',
contextWindowPlaceholder: 'Uses the provider default',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
modelIdRequired: 'Model ID is required.',
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
modelContextInvalid: 'Context window must be a positive integer.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
@@ -64,6 +81,23 @@ export const zh: typeof en = {
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
resetModels: '恢复默认模型',
model: '模型',
modelId: '模型 ID',
modelName: '显示名称',
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
modelContextInvalid: '上下文窗口必须是正整数。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',