fix(web): show the plugin settings a user actually gets

Three things the page got wrong.

The web-search provider's defaults lived only at their use site, so the
served section carried no value for them and the card fell back to a zero it
invented — a number the schema itself rejects. Declaring them on the schema
makes the settings service the one authority: `maxUses` now reads 5 because
that is what the Host resolves, not because the page guessed. `baseURL` keeps
its code-side default, which exists so `$DEEPSEEK_SEARCH_BASE_URL` can win.

A field the Host serves no value for now renders empty rather than as zero.

The cards were rows in a settings page of cards: name and description ran
together on one line because the shared disclosure row lays them side by side.
Each card now draws its own header, stacking the two, and the section follows
the idiom the Agent Preset page established.
This commit is contained in:
Yichen Jiang
2026-08-10 21:29:45 +08:00
parent 1b473be886
commit dae6cad065
27 changed files with 241 additions and 151 deletions

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -10,25 +10,25 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img
- text: 关闭 - text: 关闭
- heading "插件配置" [level=2] - heading "插件配置" [level=2]
- paragraph: 本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效 - paragraph: 配置本部署已安装的插件
- list: - list:
- listitem: - listitem:
- button "终端 限制 agent 运行的每一条命令。": - 'button "展开设置: 终端"':
- img
- text: 终端 限制 agent 运行的每一条命令。 - text: 终端 限制 agent 运行的每一条命令。
- listitem:
- button "Agent 循环 Agent 如何派发工具调用。":
- img - img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用。 - text: Agent 循环 Agent 如何派发工具调用。
- listitem:
- button "网页搜索 DeepSeek 搜索提供方。":
- img - img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方。 - text: 网页搜索 DeepSeek 搜索提供方。
- img

View File

@@ -10,9 +10,9 @@
- button "Agent 预设": - button "Agent 预设":
- img - img
- text: Agent 预设 - text: Agent 预设
- button "插件": - button "插件配置":
- img - img
- text: 插件 - text: 插件配置
- button "打开配置文件" - button "打开配置文件"
- button "关闭": - button "关闭":
- img - img

View File

@@ -67,5 +67,8 @@
"lib/invariant.js", "lib/invariant.js",
"lib/client.js", "lib/client.js",
"lib/types/**/*.d.ts" "lib/types/**/*.d.ts"
] ],
"dependencies": {
"clsx": "^2.0.0"
}
} }

View File

@@ -1,6 +1,5 @@
/** The agent-loop plugin's card: how many tool calls may run at once. */ /** The agent-loop plugin's card: how many tool calls may run at once. */
import { useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField } from './fields.tsx' import { NumberField } from './fields.tsx'
@@ -34,17 +33,14 @@ export type AgentLoopCardProps =
export function AgentLoopCard(props: AgentLoopCardProps) { export function AgentLoopCard(props: AgentLoopCardProps) {
const { t } = props const { t } = props
const state = props.useAgentLoopCard(snapshot => snapshot) const state = props.useAgentLoopCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable const disabled = !state.writable
return ( return (
<PluginCard <PluginCard
title={t('agentLoopTitle')} t={t}
description={t('agentLoopDescription')} titleKey="agentLoopTitle"
descriptionKey="agentLoopDescription"
available={state.available} available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled} readOnly={disabled}
readOnlyLabel={t('readOnly')}
> >
<NumberField <NumberField
id="plugin-config-agent-loop-parallel" id="plugin-config-agent-loop-parallel"

View File

@@ -1,6 +1,5 @@
/** The shell plugin's card: the limits every command the agent runs is bound by. */ /** The shell plugin's card: the limits every command the agent runs is bound by. */
import { useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField } from './fields.tsx' import { NumberField } from './fields.tsx'
@@ -38,17 +37,14 @@ export type BashCardProps =
export function BashCard(props: BashCardProps) { export function BashCard(props: BashCardProps) {
const { t } = props const { t } = props
const state = props.useBashCard(snapshot => snapshot) const state = props.useBashCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable const disabled = !state.writable
return ( return (
<PluginCard <PluginCard
title={t('bashTitle')} t={t}
description={t('bashDescription')} titleKey="bashTitle"
descriptionKey="bashDescription"
available={state.available} available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled} readOnly={disabled}
readOnlyLabel={t('readOnly')}
> >
<NumberField <NumberField
id="plugin-config-bash-timeout" id="plugin-config-bash-timeout"

View File

@@ -1,35 +1,86 @@
/* Plugin card: one expandable row per plugin, its body holding the controls. */ /* Plugin card: a header that names the plugin, disclosing its controls in place. */
.card { .card {
list-style: none; list-style: none;
border-bottom: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
} }
.row { .card:hover {
padding: 16px 0; border-color: var(--dsw-alias-label-dimmed);
} }
.title { /* An open card reads as the one being worked on, not merely taller. */
font-size: 14px; .cardOpen {
font-weight: 400; background: var(--dsw-alias-bg-layer-2);
line-height: 22px; border-color: var(--dsw-alias-label-dimmed);
}
.header {
width: 100%;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
/* Name over description: the description is what tells two plugins apart, so
it gets its own line rather than trailing the name. */
.headText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
.description { .description {
font-size: 12px; font-size: 13px;
font-weight: 400; line-height: 1.5;
line-height: 18px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform .16s;
}
.chevronOpen {
transform: rotate(180deg);
}
.body { .body {
padding: 0 0 8px 24px; border-top: 1px solid var(--dsw-alias-border-l2);
margin: 0 16px;
padding-bottom: 8px;
} }
.readOnly { .readOnly {
margin: 0 0 8px; margin: 12px 0 0;
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 1.5;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }

View File

@@ -1,29 +1,35 @@
/** /**
* One plugin's card: an expandable row whose body is that plugin's controls. * One plugin's card: a header naming the plugin and what its settings govern,
* A card renders nothing while its namespace is unavailable — a deployment * disclosing that plugin's controls in place.
* that does not compose the owning plugin should show no trace of it, rather *
* than an empty or disabled card the user cannot act on. * The header is its own button rather than a shared disclosure row because a
* card stacks its name over its description, while that row lays the two side
* by side — the layout, not the behavior, is what differs. Disclosure is
* card-local state: which card a user has open is a reading gesture, not
* something the Host or the section has any stake in.
*
* A card renders nothing while its namespace is unavailable: a deployment that
* does not compose the owning plugin should show no trace of it, rather than a
* disabled card the user cannot act on.
*/ */
import type { ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives' import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginCard.module.css' import css from './PluginCard.module.css'
/** Card chrome shared by every plugin section. */ /** Card chrome shared by every plugin section. */
export interface PluginCardProps { export interface PluginCardProps {
/** Plugin name shown on the row. */ /** Locale reader for this section's copy. */
title: string t: (key: PluginConfigKey) => string
/** One line describing what this plugin's settings govern. */ /** Locale key of the plugin's name. */
description: string titleKey: PluginConfigKey
/** Locale key of the line describing what this plugin's settings govern. */
descriptionKey: PluginConfigKey
/** False while the namespace is not served to this client. */ /** False while the namespace is not served to this client. */
available: boolean available: boolean
/** Whether the card body is showing. */ /** True when the Host document is read-only, which disables the fields. */
open: boolean
/** Toggle the card body. */
onToggle: () => void
/** Copy shown when the settings document refuses writes. */
readOnlyLabel?: string | undefined
/** True when the Host document is read-only. */
readOnly: boolean readOnly: boolean
/** The plugin's controls. */ /** The plugin's controls. */
children: ReactNode children: ReactNode
@@ -31,31 +37,36 @@ export interface PluginCardProps {
/** /**
* Render one plugin card. * Render one plugin card.
* @param props - card chrome, disclosure state, and the plugin's controls. * @param props - the plugin's copy keys, its availability, and its controls.
* @returns the card, or nothing when the namespace is unavailable. * @returns the card, or nothing when the namespace is unavailable.
*/ */
export function PluginCard(props: PluginCardProps) { export function PluginCard(props: PluginCardProps) {
const [open, setOpen] = useState(false)
if (!props.available) return null if (!props.available) return null
const title = props.t(props.titleKey)
return ( return (
<li className={css.card}> <li className={clsx(css.card, open && css.cardOpen)}>
<DisclosureRow <button
icon={null} type="button"
title={props.title} className={css.header}
open={props.open} aria-expanded={open}
expandable aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
expandOnRowClick onClick={() => { setOpen(!open) }}
onToggle={props.onToggle}
rowClassName={css.row}
titleClassName={css.title}
collapsedContent={<span className={css.description}>{props.description}</span>}
> >
<div className={css.body}> <span className={css.headText}>
{props.readOnly && props.readOnlyLabel !== undefined <span className={css.name}>{title}</span>
? <p className={css.readOnly} role="status">{props.readOnlyLabel}</p> <span className={css.description}>{props.t(props.descriptionKey)}</span>
: null} </span>
{props.children} <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</div> </button>
</DisclosureRow> {open
? (
<div className={css.body}>
{props.readOnly ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
{props.children}
</div>
)
: null}
</li> </li>
) )
} }

View File

@@ -3,34 +3,34 @@
.section { .section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
} }
.heading { .heading {
margin: 0; margin: 0;
font-size: 16px; font-size: 18px;
font-weight: 500; font-weight: 600;
line-height: 24px;
color: var(--dsw-alias-label-primary);
} }
.intro { .intro {
margin: 8px 0 16px; margin: 0;
font-size: 12px; font-size: 13px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.cards { .cards {
list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; display: flex;
flex-direction: column;
gap: 10px;
} }
.empty { .empty {
margin: 0; margin: 0;
padding: 16px 0; font-size: 13px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }

View File

@@ -4,7 +4,6 @@
* the settings section, so the literal never rides a response. * the settings section, so the literal never rides a response.
*/ */
import { useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField, SecretField, TextField } from './fields.tsx' import { NumberField, SecretField, TextField } from './fields.tsx'
@@ -44,17 +43,14 @@ export type WebSearchCardProps =
export function WebSearchCard(props: WebSearchCardProps) { export function WebSearchCard(props: WebSearchCardProps) {
const { t } = props const { t } = props
const state = props.useWebSearchCard(snapshot => snapshot) const state = props.useWebSearchCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable const disabled = !state.writable
return ( return (
<PluginCard <PluginCard
title={t('webSearchTitle')} t={t}
description={t('webSearchDescription')} titleKey="webSearchTitle"
descriptionKey="webSearchDescription"
available={state.available} available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled} readOnly={disabled}
readOnlyLabel={t('readOnly')}
> >
<SecretField <SecretField
id="plugin-config-web-search-key" id="plugin-config-web-search-key"

View File

@@ -21,7 +21,7 @@ export interface AgentLoopSettings {
/** What the agent-loop card renders. */ /** What the agent-loop card renders. */
export interface AgentLoopCardState extends CardShell { export interface AgentLoopCardState extends CardShell {
/** Parallel tool-call cap. */ /** Parallel tool-call cap. */
maxParallelToolCalls: CardField<number> maxParallelToolCalls: CardField<number | undefined>
} }
/** The registration-side face the agent-loop card's slot entry injects. */ /** The registration-side face the agent-loop card's slot entry injects. */
@@ -42,7 +42,7 @@ export class AgentLoopCardController extends CardController<AgentLoopSettings, A
constructor(scope: SettingsScope<AgentLoopSettings>) { constructor(scope: SettingsScope<AgentLoopSettings>) {
super(scope, snapshot => ({ super(scope, snapshot => ({
...shellOf(snapshot), ...shellOf(snapshot),
maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0), maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', undefined),
})) }))
} }

View File

@@ -21,9 +21,9 @@ export interface BashSettings {
/** What the shell card renders. */ /** What the shell card renders. */
export interface BashCardState extends CardShell { export interface BashCardState extends CardShell {
/** Command timeout in milliseconds. */ /** Command timeout in milliseconds. */
timeoutMs: CardField<number> timeoutMs: CardField<number | undefined>
/** Per-stream output cap in bytes. */ /** Per-stream output cap in bytes. */
maxOutputBytes: CardField<number> maxOutputBytes: CardField<number | undefined>
} }
/** The registration-side face the shell card's slot entry injects. */ /** The registration-side face the shell card's slot entry injects. */
@@ -50,8 +50,8 @@ export class BashCardController extends CardController<BashSettings, BashCardSta
...shellOf(snapshot), ...shellOf(snapshot),
// The fallbacks only show before the Host serves a section; every served // The fallbacks only show before the Host serves a section; every served
// section is already schema-defaulted by the owning executor. // section is already schema-defaulted by the owning executor.
timeoutMs: fieldOf(snapshot, 'timeoutMs', 0), timeoutMs: fieldOf(snapshot, 'timeoutMs', undefined),
maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', 0), maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', undefined),
})) }))
} }

View File

@@ -7,6 +7,10 @@
padding: 12px 0; padding: 12px 0;
} }
.field + .field {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.head { .head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -16,9 +20,9 @@
.label { .label {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 500;
line-height: 22px; line-height: 1.5;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
@@ -29,19 +33,22 @@
} }
.badge { .badge {
padding: 0 8px; border-radius: 999px;
border-radius: 10px; padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-bg-module-platform); background: var(--dsw-alias-bg-module-platform);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
} }
.badgeMuted { .badgeMuted {
padding: 0 8px; border-radius: 999px;
border-radius: 10px; padding: 1px 8px;
font-size: 12px; font-size: 11px;
line-height: 20px; line-height: 17px;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
@@ -51,7 +58,7 @@
padding: 0; padding: 0;
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 1.5;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
cursor: pointer; cursor: pointer;
} }
@@ -65,17 +72,22 @@
} }
.input { .input {
height: 36px; height: 34px;
padding: 0 12px; padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px; border-radius: 8px;
background: var(--dsw-alias-bg-module-platform); background: var(--dsw-alias-bg-layer-3);
font: inherit; font: inherit;
font-size: 14px; font-size: 13px;
line-height: 22px; line-height: 1.5;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
.input:focus-visible {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input:disabled { .input:disabled {
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
cursor: default; cursor: default;
@@ -84,7 +96,6 @@
.hint { .hint {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
font-weight: 400; line-height: 1.5;
line-height: 18px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }

View File

@@ -115,8 +115,11 @@ function DraftInput(props: {
/** A whole-number field committed on blur or Enter. */ /** A whole-number field committed on blur or Enter. */
export function NumberField(props: FieldProps & { export function NumberField(props: FieldProps & {
/** Current effective value. */ /**
value: number * Current effective value, or undefined when the Host served none — which
* renders empty rather than as a number nobody chose.
*/
value: number | undefined
/** Commit a parsed value; a draft that is not a finite number is discarded. */ /** Commit a parsed value; a draft that is not a finite number is discarded. */
onCommit: (next: number) => void onCommit: (next: number) => void
}) { }) {
@@ -124,13 +127,13 @@ export function NumberField(props: FieldProps & {
<FieldFrame {...props}> <FieldFrame {...props}>
<DraftInput <DraftInput
id={props.id} id={props.id}
value={String(props.value)} value={props.value === undefined ? '' : String(props.value)}
disabled={props.disabled} disabled={props.disabled}
numeric numeric
onSettle={(draft, restore) => { onSettle={(draft, restore) => {
const parsed = Number(draft) const parsed = Number(draft)
if (draft.trim() === '' || !Number.isFinite(parsed)) { if (draft.trim() === '' || !Number.isFinite(parsed)) {
restore(String(props.value)) restore(props.value === undefined ? '' : String(props.value))
return return
} }
if (parsed === props.value) return if (parsed === props.value) return

View File

@@ -13,11 +13,9 @@ export type PluginConfigKey =
/** English copy. */ /** English copy. */
export const en: Record<PluginConfigKey, string> = { export const en: Record<PluginConfigKey, string> = {
nav: 'Plugins', nav: 'Plugin config',
title: 'Plugin configuration', title: 'Plugin configuration',
intro: intro: 'Configure the plugins this deployment installed.',
'Settings owned by the plugins this deployment composes. A value you set here layers over the '
+ 'composition default and applies to the next use.',
empty: 'This deployment exposes no plugin settings.', empty: 'This deployment exposes no plugin settings.',
overridden: 'Overridden', overridden: 'Overridden',
reset: 'Reset to default', reset: 'Reset to default',
@@ -48,9 +46,9 @@ export const en: Record<PluginConfigKey, string> = {
/** Simplified Chinese copy. */ /** Simplified Chinese copy. */
export const zh: Record<PluginConfigKey, string> = { export const zh: Record<PluginConfigKey, string> = {
nav: '插件', nav: '插件配置',
title: '插件配置', title: '插件配置',
intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。', intro: '配置本部署已安装的插件。',
empty: '本部署没有开放任何插件设置。', empty: '本部署没有开放任何插件设置。',
overridden: '已覆盖', overridden: '已覆盖',
reset: '恢复默认', reset: '恢复默认',

View File

@@ -36,7 +36,7 @@ export interface WebSearchCardState extends CardShell {
/** Provider endpoint. */ /** Provider endpoint. */
baseURL: CardField<string> baseURL: CardField<string>
/** Searches allowed per request. */ /** Searches allowed per request. */
maxUses: CardField<number> maxUses: CardField<number | undefined>
/** Credential reference the key is written under. */ /** Credential reference the key is written under. */
apiKeyRef: string apiKeyRef: string
/** Whether the Host reports a credential configured for that reference. */ /** Whether the Host reports a credential configured for that reference. */
@@ -78,7 +78,7 @@ export class WebSearchCardController extends CardController<WebSearchSettings, W
super(scope, snapshot => ({ super(scope, snapshot => ({
...shellOf(snapshot), ...shellOf(snapshot),
baseURL: fieldOf(snapshot, 'baseURL', ''), baseURL: fieldOf(snapshot, 'baseURL', ''),
maxUses: fieldOf(snapshot, 'maxUses', 0), maxUses: fieldOf(snapshot, 'maxUses', undefined),
apiKeyRef: refOf(snapshot), apiKeyRef: refOf(snapshot),
apiKeyConfigured: credential.configured, apiKeyConfigured: credential.configured,
})) }))

View File

@@ -48,7 +48,7 @@ describe('ui-plugin-config apply', () => {
const section = slots.entries('settings.section')[0]! const section = slots.entries('settings.section')[0]!
expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time. // The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件') expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
}) })

View File

@@ -116,6 +116,22 @@ describe('NumberField', () => {
expect(onCommit).not.toHaveBeenCalled() expect(onCommit).not.toHaveBeenCalled()
}) })
it('renders an absent value as empty rather than as a number nobody chose', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={undefined} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
expect(input).toHaveProperty('value', '')
// A draft typed and then cleared restores the same emptiness, not a zero.
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.blur(input)
expect(input).toHaveProperty('value', '')
expect(onCommit).not.toHaveBeenCalled()
})
it('suppresses every interaction while disabled', () => { it('suppresses every interaction while disabled', () => {
const onCommit = vi.fn() const onCommit = vi.fn()
const onReset = vi.fn() const onReset = vi.fn()

View File

@@ -14,7 +14,8 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react' import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16,
IconSettingsOutline16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css' import css from './SettingsRoot.module.css'
@@ -23,6 +24,7 @@ import css from './SettingsRoot.module.css'
function navIcon(id: string) { function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} /> if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} /> if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} /> return <IconSettingsOutline16 className={css.navIcon} size={16} />
} }

View File

@@ -63,11 +63,14 @@ export interface Config {
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
apiKey: z.string().role('secret'), apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
// Declared here rather than only at the use site: a configuration surface
// renders the resolved section, so a default the schema does not carry reads
// there as no value at all.
baseURL: z.string(), baseURL: z.string(),
model: z.string(), model: z.string().default(DEEPSEEK_DEFAULT_MODEL),
apiVersion: z.string(), apiVersion: z.string().default(DEEPSEEK_DEFAULT_API_VERSION),
maxTokens: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_TOKENS),
maxUses: z.number().step(1).min(1), maxUses: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_USES),
}) })
/** /**

4
pnpm-lock.yaml generated
View File

@@ -2291,6 +2291,10 @@ importers:
version: 18.3.1 version: 18.3.1
packages/client/ui-plugin-config: packages/client/ui-plugin-config:
dependencies:
clsx:
specifier: ^2.0.0
version: 2.1.1
devDependencies: devDependencies:
'@deepseek-ai/dsh-client-connection': '@deepseek-ai/dsh-client-connection':
specifier: workspace:^ specifier: workspace:^