From 16f1cfe04e1bc2274e4ac067e81e237bfe0ba6ac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 12:39:56 +0800 Subject: [PATCH] feat(ui-models): pin the deepseek endpoint placeholder, add pi-ai base URL, drop the fold hint --- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 6 ++- packages/client/ui-models/README.zh.md | 6 ++- .../ui-models/src/client/ProviderEditor.tsx | 44 +++++++++---------- .../ui-models/tests/components.spec.tsx | 20 ++++++--- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index aca5e8dbb5..d1c0dd05fe 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 5bcfdcdbfe31ada89f787cd4d193e9763dba94d3 -README.zh.md: 84b4b2187851506697de635d56691ca7e988ea00 +README.md: 8d3f9ffdf183152142d111d6387fde87debc81e8 +README.zh.md: 1fdd453c2da3f7f0c1f42177a5474abfaa92b42f diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 5bcfdcdbfe..7ee55f5232 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Models settings section plugin: the provider configuration page. It joins three wire domains into one surface — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. -Rows are the *configured* providers (their profile resolves in the owning namespace); the add select's vocabulary is every dormant directory entry, so a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor renders the provider's profile subtree through [`@deepseek-ai/dsh-client-schema-form`](../schema-form); the `credential-ref` role mounts the credential control, which shows the reference's live state and stores key values **write-only** through `credentials.set` — no value ever renders back. A row is deletable only when the user layer alone carries it (removal restores the composition base). +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). -Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch (stored secrets outside the patch survive), while a field reset or row deletion lands through `settings.replace` of the whole user section so removals actually take effect. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -19,5 +19,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **A reset can drop a stored literal secret in the same subtree** — a replace-carried removal cannot re-supply secrets the wire never returned; store keys behind `credentials.*` references (the product default) and the case cannot arise. +- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Deleting a row leaves its stored key in `.env`** — removal replaces the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. - **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 84b4b21878..1fdd453c2d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,9 +4,9 @@ 模型设置分区插件:提供方配置页。它把三个协议领域汇聚为一个界面——`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标)——并渲染提供方行,一次只展开一张编辑卡片。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);新增选择框的词汇是全部休眠目录条目,因此裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器经 [`@deepseek-ai/dsh-client-schema-form`](../schema-form) 渲染该提供方的 profile 子树;`credential-ref` 角色会挂载凭据控件,它展示该引用的实时状态,并经 `credentials.set` 以**只写**方式存入密钥值——任何值都绝不回显。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段(deepseek:`baseURL` + `reasoningEffort`;pi-ai:`reasoning`);其余每个 profile 字段仍归 `settings.yaml` 所有,折叠区上也会明说。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 -「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地(patch 之外已存储的 secret 得以保留),字段重置或整行删除则经对整个用户分节的 `settings.replace` 落地,使删除真正生效。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -19,5 +19,7 @@ ## 已知限制与暂缓事项 - **重置可能丢弃同一子树中已存储的字面 secret**:经 replace 承载的删除无法重新提供协议从未返回过的 secret;把密钥放在 `credentials.*` 引用背后(产品默认做法),该情形便不会出现。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **删除一行会把它已存储的密钥留在 `.env` 里**:删除替换的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 - **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 530868c49f..64c946dff2 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -4,9 +4,9 @@ * environment-variable name — a typed key stores through `credentials.set` * under the profile's reference, deriving `_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 (deepseek: - * `baseURL` + `reasoningEffort`; pi-ai: `reasoning`). Everything else stays - * owned by `settings.yaml` — the folded hint says so. Profile edits land as a + * 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 a * minimal `settings.update` merge patch; clearing a field back to inherited * removes its key, so that apply replaces the user section (safe: the section * stores references, never key values). @@ -37,6 +37,9 @@ const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = { 'pi-ai': 'reasoning', } +/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ +const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' + /** Props of {@link ProviderEditor}. */ export interface ProviderEditorProps { /** Provider route id. */ @@ -232,24 +235,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
{t('customized')}
- {layout === 'deepseek' - ? ( -
- {t('baseUrl')} - { - setField('baseURL', event.target.value === '' ? undefined : event.target.value) - }} - /> -
- ) - : null} +
+ {t('baseUrl')} + { + setField('baseURL', event.target.value === '' ? undefined : event.target.value) + }} + /> +
{/* v8 ignore next -- EFFORT_FIELD is total over non-unknown layouts; the check only narrows the type */} {effortField !== undefined ? ( @@ -272,7 +273,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
) : null} -

{`${t('advancedHint')} (${namespace.ns})`}

diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 92fa60052b..79bc4a7c88 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -206,7 +206,9 @@ describe('ModelsSection', () => { }) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe('https://base') + // The deepseek placeholder is pinned to the public endpoint, not the + // effective value (which may reflect a launch-environment override). + expect(baseURL.placeholder).toBe('https://api.deepseek.com') fireEvent.change(baseURL, { target: { value: 'https://next2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) }) @@ -228,7 +230,7 @@ describe('ModelsSection', () => { expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} }) }) - it('falls back to the provider-default placeholder and clears typed input back to inherited', async () => { + it('pins the deepseek placeholder and clears typed input back to inherited', async () => { const { face } = scriptedFace() const bare: SettingsNamespaceView = { ns: 'llm-deepseek', @@ -250,7 +252,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe(en.baseUrlDefault) + expect(baseURL.placeholder).toBe('https://api.deepseek.com') fireEvent.change(baseURL, { target: { value: 'https://x' } }) expect(baseURL.value).toBe('https://x') fireEvent.change(baseURL, { target: { value: '' } }) @@ -273,9 +275,12 @@ describe('ModelsSection', () => { const keys = await screen.findAllByLabelText(en.keyInput) const editorKey = keys[keys.length - 1] as HTMLInputElement await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) - // No Base URL for pi-ai; the only one on the page is the setup card's. + // pi-ai carries Base URL too: the stored override shows as the value and + // the effective profile endpoint as its placeholder source. fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - expect(screen.getAllByLabelText(en.baseUrl)).toHaveLength(1) + const urls = screen.getAllByLabelText(en.baseUrl) + expect(urls).toHaveLength(2) + expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') const effort = screen.getAllByLabelText(en.effort) fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) @@ -296,6 +301,11 @@ describe('ModelsSection', () => { const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') + // A dormant profile has no endpoint anywhere: the pi-ai placeholder + // falls back to the provider-default wording. + fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) + const urls = screen.getAllByLabelText(en.baseUrl) + expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) const keys = screen.getAllByLabelText(en.keyInput) const addKey = keys[keys.length - 1] as HTMLInputElement fireEvent.change(addKey, { target: { value: 'sk-ant' } })