ソースを参照

feat(ui-models): pin the deepseek endpoint placeholder, add pi-ai base URL, drop the fold hint

Yichen Jiang 2 ヶ月 前
親
コミット
16f1cfe04e

+ 2 - 2
packages/client/ui-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
 #   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

+ 4 - 2
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.
 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 `<ROUTE>_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
 ## Model Experience
 
 
@@ -19,5 +19,7 @@ None; this package neither assembles nor sends a provider request.
 ## Known Limitations and Deferred Work
 ## 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.
 - **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.
 - **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.
 - **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.

+ 4 - 2
packages/client/ui-models/README.zh.md

@@ -4,9 +4,9 @@
 
 
 模型设置分区插件:提供方配置页。它把三个协议领域汇聚为一个界面——`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标)——并渲染提供方行,一次只展开一张编辑卡片。
 模型设置分区插件:提供方配置页。它把三个协议领域汇聚为一个界面——`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 没有引用时便派生 `<ROUTE>_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.*` 引用背后(产品默认做法),该情形便不会出现。
 - **重置可能丢弃同一子树中已存储的字面 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 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

+ 22 - 22
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -4,9 +4,9 @@
  * environment-variable name — a typed key stores through `credentials.set`
  * environment-variable name — a typed key stores through `credentials.set`
  * under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
  * under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
  * has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
  * 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
  * minimal `settings.update` merge patch; clearing a field back to inherited
  * removes its key, so that apply replaces the user section (safe: the section
  * removes its key, so that apply replaces the user section (safe: the section
  * stores references, never key values).
  * stores references, never key values).
@@ -37,6 +37,9 @@ const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
   'pi-ai': 'reasoning',
   '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}. */
 /** Props of {@link ProviderEditor}. */
 export interface ProviderEditorProps {
 export interface ProviderEditorProps {
   /** Provider route id. */
   /** Provider route id. */
@@ -232,24 +235,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
             <details className={styles['customized']}>
             <details className={styles['customized']}>
               <summary className={styles['customizedSummary']}>{t('customized')}</summary>
               <summary className={styles['customizedSummary']}>{t('customized')}</summary>
               <div className={styles['customizedBody']}>
               <div className={styles['customizedBody']}>
-                {layout === 'deepseek'
-                  ? (
-                    <div className={styles['field']}>
-                      <span className={styles['fieldLabel']}>{t('baseUrl')}</span>
-                      <input
-                        className={styles['input']}
-                        type="text"
-                        value={stringAt(draft, 'baseURL') ?? ''}
-                        placeholder={stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
-                        aria-label={t('baseUrl')}
-                        disabled={disabled}
-                        onChange={(event) => {
-                          setField('baseURL', event.target.value === '' ? undefined : event.target.value)
-                        }}
-                      />
-                    </div>
-                  )
-                  : null}
+                <div className={styles['field']}>
+                  <span className={styles['fieldLabel']}>{t('baseUrl')}</span>
+                  <input
+                    className={styles['input']}
+                    type="text"
+                    value={stringAt(draft, 'baseURL') ?? ''}
+                    placeholder={layout === 'deepseek'
+                      ? DEEPSEEK_PUBLIC_BASE_URL
+                      : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
+                    aria-label={t('baseUrl')}
+                    disabled={disabled}
+                    onChange={(event) => {
+                      setField('baseURL', event.target.value === '' ? undefined : event.target.value)
+                    }}
+                  />
+                </div>
                 {/* v8 ignore next -- EFFORT_FIELD is total over non-unknown layouts; the check only narrows the type */}
                 {/* v8 ignore next -- EFFORT_FIELD is total over non-unknown layouts; the check only narrows the type */}
                 {effortField !== undefined
                 {effortField !== undefined
                   ? (
                   ? (
@@ -272,7 +273,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                     </div>
                     </div>
                   )
                   )
                   : null}
                   : null}
-                <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
               </div>
               </div>
             </details>
             </details>
           </>
           </>

+ 15 - 5
packages/client/ui-models/tests/components.spec.tsx

@@ -206,7 +206,9 @@ describe('ModelsSection', () => {
     })
     })
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.click(screen.getByText(en.customized))
     const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
     const baseURL = screen.getByLabelText<HTMLInputElement>(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.change(baseURL, { target: { value: 'https://next2' } })
     fireEvent.click(screen.getByText(en.apply))
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
     await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
@@ -228,7 +230,7 @@ describe('ModelsSection', () => {
     expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
     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 { face } = scriptedFace()
     const bare: SettingsNamespaceView = {
     const bare: SettingsNamespaceView = {
       ns: 'llm-deepseek',
       ns: 'llm-deepseek',
@@ -250,7 +252,7 @@ describe('ModelsSection', () => {
     />)
     />)
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.click(screen.getByText(en.customized))
     const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
     const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
-    expect(baseURL.placeholder).toBe(en.baseUrlDefault)
+    expect(baseURL.placeholder).toBe('https://api.deepseek.com')
     fireEvent.change(baseURL, { target: { value: 'https://x' } })
     fireEvent.change(baseURL, { target: { value: 'https://x' } })
     expect(baseURL.value).toBe('https://x')
     expect(baseURL.value).toBe('https://x')
     fireEvent.change(baseURL, { target: { value: '' } })
     fireEvent.change(baseURL, { target: { value: '' } })
@@ -273,9 +275,12 @@ describe('ModelsSection', () => {
     const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
     const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
     const editorKey = keys[keys.length - 1] as HTMLInputElement
     const editorKey = keys[keys.length - 1] as HTMLInputElement
     await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
     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)
     fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
-    expect(screen.getAllByLabelText(en.baseUrl)).toHaveLength(1)
+    const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(urls).toHaveLength(2)
+    expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
     const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
     const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
     fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
     fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
     fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
     fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
@@ -296,6 +301,11 @@ describe('ModelsSection', () => {
     const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
     const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
     expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
     expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
     expect(pick.value).toBe('anthropic')
     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<HTMLInputElement>(en.baseUrl)
+    expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
     const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
     const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
     const addKey = keys[keys.length - 1] as HTMLInputElement
     const addKey = keys[keys.length - 1] as HTMLInputElement
     fireEvent.change(addKey, { target: { value: 'sk-ant' } })
     fireEvent.change(addKey, { target: { value: 'sk-ant' } })