Merge remote-tracking branch 'origin/master' into worktree/locale-browser-default

# Conflicts:
#	apps/web/tests/onboarding-deepseek-config.e2e.ts
#	packages/client/ui-settings-general/tests/apply.spec.ts
This commit is contained in:
creatixchu
2026-07-31 16:42:37 +08:00
136 changed files with 3428 additions and 451 deletions

View File

@@ -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: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `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, without presenting route liveness as provider status.
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `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, without presenting route liveness as provider status.
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), and a localized confirmation dialog must complete before the page submits that destructive unset.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. 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.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分Models 页仍是诊断界面。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读设置凭据能力不可用时,该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -1,7 +1,99 @@
.dialog {
width: min(420px, 100%);
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(104px, 18vh, 156px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.description {
margin: 16px 0 0;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.primary {
width: 100%;
min-width: 132px;
}
.brand,
.title,
.description,
.actions {
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.title { animation-delay: 40ms; }
.description { animation-delay: 80ms; }
.actions { animation-delay: 120ms; }
@keyframes credential-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.description,
.actions {
animation: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 64px;
}
.brand {
margin-bottom: 30px;
}
.actions {
align-items: stretch;
flex-direction: column-reverse;
margin-top: 32px;
}
.primary,
.later {
width: 100%;
}
}

View File

@@ -1,13 +1,13 @@
/**
* Official-DeepSeek first-run dialog. Readiness comes from the same
* Official-DeepSeek first-run step. Readiness comes from the same
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -37,29 +37,35 @@ function assertNever(_value: never): never {
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, t } = props
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const [dismissed, setDismissed] = useState(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
useEffect(() => {
if (active && !dismissed && state.status === 'idle') void controller.load()
}, [active, controller, dismissed, state.status])
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
const close = (): void => {
setDismissed(true)
}
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'configured'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
useEffect(() => {
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
}, [readiness.kind])
const openModels = (): void => {
close()
complete()
openSection('models')
}
if (!active || dismissed) return null
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
@@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
return (
<Modal
open
onClose={close}
title={t('onboardingTitle')}
closeLabel={t('onboardingLater')}
description={t('onboardingDescription')}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
)}
/>
</div>
</section>
)
}

View File

@@ -24,8 +24,8 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerActive?: boolean
providerSettingsNs?: string
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
@@ -40,9 +40,7 @@ function harness(options: {
const face = {
llm: {
providers: () => {
if (options.providersReject === true) {
return Promise.reject(new Error('provider transport unavailable'))
}
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
return Promise.resolve(ok({
providers: options.provider === false
? []
@@ -91,9 +89,11 @@ function harness(options: {
}
const controller = new ModelsSettingsStore(face as never)
const openSection = vi.fn()
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
active: true,
stepId: 'deepseek-official',
complete,
openSection,
useSessions: unusedHook,
useWorkspaces: unusedHook,
@@ -101,36 +101,36 @@ function harness(options: {
useSnapshot: bindSnapshotSelector(controller.store),
t: key => en[key],
}
return { controller, openSection, props, configure: () => { fileConfigured = true } }
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents one accessible route to Models', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
expect(screen.queryByRole('textbox')).toBeNull()
})
it('opens the Models section and dismisses the prompt', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).not.toHaveBeenCalled()
})
@@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
}
@@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
view.unmount()
}
})
@@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => {
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('stays hidden while the onboarding owner is inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
expect(h.complete).toHaveBeenCalledOnce()
})
})