feat(web): configure custom DeepSeek models
This commit is contained in:
@@ -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 官方模型,即可开始使用。',
|
||||
|
||||
Reference in New Issue
Block a user