Merge remote-tracking branch 'origin/master' into feature/issue-1470-skill-invoke

# Conflicts:
#	docs/module-graph.md
This commit is contained in:
Yichen Jiang
2026-08-08 14:03:29 +08:00
248 changed files with 4129 additions and 1479 deletions

View File

@@ -25,7 +25,6 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
@@ -49,14 +48,12 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",

View File

@@ -1,7 +1,6 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -179,8 +178,8 @@ declare module 'cordis' {
}
}
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
export const inject = ['remote', 'connection', 'typert']
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.

View File

@@ -20,9 +20,6 @@
{
"path": "../connection"
},
{
"path": "../../api/remotes"
},
{
"path": "../../host/apiproxy"
},

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-schema-form',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-test-runtime',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -9,6 +9,7 @@
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
@@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
/**
* Workspace mode replaces an empty config array with the root defaults. A
* falsey entry instead removes this package before entry resolution.
*/
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
@@ -61,19 +68,85 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
* the root workspace shape, so the lib half must be restated here — dropping
* it leaves the package without lib/index.js and the host Loader cannot
* import its node half.
* plus the browser client bundle. Client packages emit both halves during the
* Client pass by default; packages needed for Host reflection may opt into the
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
* workspace shape, so the lib half must be restated here — dropping it leaves
* the package without lib/index.js and the host Loader cannot import its node
* half.
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
* handoff and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
* package-invariants gate can see `lib/types/invariant.js` in each package's
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
* @param options - phase placement, lib overrides, and companion Node configs.
* @returns ENV-selected tsdown config for the current build face.
*/
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
return [{
export function clientBundle(
id: string,
libEntry: readonly string[],
options: ClientBundleOptions = {},
): BuildFaceConfig {
const lib = clientLibraryConfig(id, libEntry, options.lib)
return ({ env }) => {
const face = buildFace(env?.DSH_BUILD_FACE)
const client = clientConfig(id, face === undefined
? 'src/client/index.ts'
: 'lib/types/client/index.js')
const node = [lib, ...(options.companions ?? [])]
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
return [...node, client]
}
}
/**
* Build a Client-only Node library during the Client pass.
* @param id - Package name used in tsdown diagnostics.
* @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
* @returns ENV-selected tsdown config for the Client build face.
*/
export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
const lib = clientLibraryConfig(id, libEntry)
return clientOnly([lib])
}
/**
* Select arbitrary package-local configs only during the Client pass.
* @param configs - Node-side configs emitted after Client tsc.
* @returns ENV-selected tsdown config for the Client build face.
*/
export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
? [SKIP_WORKSPACE_BUILD]
: [...configs]
}
interface ClientBundleOptions {
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
readonly hostPhase?: boolean
/** Additional Node-side configs emitted alongside the package library. */
readonly companions?: readonly UserConfig[]
/** Overrides for the package's primary Node-side library config. */
readonly lib?: UserConfig
}
type BuildFace = 'host' | 'client' | undefined
type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
function buildFace(value: unknown): BuildFace {
if (value === undefined || value === 'host' || value === 'client') return value
throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
}
function clientLibraryConfig(
id: string,
libEntry: readonly string[],
overrides: UserConfig = {},
): UserConfig {
return {
name: id,
entry: [...libEntry],
outDir: 'lib',
format: ['esm'],
@@ -82,8 +155,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
fixedExtension: false,
dts: false,
clean: false,
}, {
entry: { client: 'src/client/index.ts' },
...overrides,
}
}
function clientConfig(id: string, entry: string): UserConfig {
return {
name: `${id}/client`,
entry: { client: entry },
// Browser bundle lands next to the node half (single lib/ artifact dir;
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
// off — a default clean would wipe the node-half output emitted above.
@@ -139,7 +218,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
name: 'dsh-css-modules-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.module.css')) return null
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
@@ -182,5 +261,15 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
}]
}
}
/** Resolve an emitted JS asset import against its source-tree counterpart. */
function sourceAssetPath(source: string, importer: string): string {
const emitted = resolvePath(dirname(importer), source)
if (existsSync(emitted)) return emitted
const marker = `${sep}lib${sep}types${sep}`
const boundary = emitted.indexOf(marker)
if (boundary < 0) return emitted
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
}

View File

@@ -15,7 +15,7 @@
"path": "../locale"
},
{
"path": "../../api/remotes"
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../runtime"

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: cf4e50630339c4055e9ae2df37246b814af06966
README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f
README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f
README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while 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 its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while 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 its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
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.
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 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 provider 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. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. 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.
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 provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. 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 list and endpoint interrogation

View File

@@ -4,11 +4,11 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。`apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段,而不重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它只修改自己看得见的字段而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问

View File

@@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
* Remove one user-added provider and its page-managed credential. Credential
* removal comes first so a second-step failure leaves the provider row visible
* and the whole operation safely retryable; both unsets are idempotent.
* The settings removal names the profile rather than rebuilding its redacted
* namespace, which would drop literal secrets stored elsewhere.
* The settings removal names the profile rather than rebuilding its whole
* namespace from a partial view.
* @param api - settings and credential wire faces.
* @param controller - the page store to refresh.
* @param target - the provider's settings address and optional managed credential.
@@ -112,16 +112,14 @@ export async function removeProviderProfile(
}
/**
* Whether a whole-section provider still needs its first key: nothing marks
* the credential configured and no literal `apiKey` is stored, so the page
* opens the setup card instead of showing a row.
* Whether a whole-section provider still needs its first key: an unconfigured
* credential opens the setup card instead of showing a row.
* @param row - the joined provider row.
* @returns whether to render the setup card.
*/
export function needsSetup(row: ProviderRow): boolean {
if (row.entry.settingsPath.length > 0) return false
if (row.credential?.configured === true) return false
return !row.literalApiKeyConfigured
return row.credential?.configured !== true
}
function targetOf(row: ProviderRow): EditorTarget {
@@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
)
}
const open = !adding && editing?.provider === row.entry.provider
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
const credentialConfigured = row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
&& row.credential?.configured === false

View File

@@ -14,9 +14,8 @@
* model picker offers each model its own levels; `settings.yaml` keeps the
* profile field for a deployment that knows its route. 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.
* path ops against the stored section — the card names only the fields it can
* see instead of rebuilding the whole subtree from a partial descriptor.
*/
import { useEffect, useMemo, useState } from 'react'
@@ -72,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
/**
* The minimal path ops carrying `after` over `before`, both as the card sees
* them (that is, redacted). Only keys the card observed are named: a stored
* `role('secret')` field appears in neither side, so it produces no op and
* survives the write — the whole reason edits are path-addressed rather than
* a rebuilt section.
* them. Only keys the card observed are named; fields absent from both sides
* produce no op, which is why edits are path-addressed rather than a rebuilt
* section.
* @param base - path of the edited subtree inside the user section.
* @param before - the subtree as loaded, or undefined when it is new.
* @param after - the subtree as edited.
@@ -197,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
* descriptor, so a wholesale replace rebuilt from it would delete the
* literal secrets the wire never returned. Ops name only the fields this
* card can see, so a stored secret is untouched by construction.
* descriptor, so a wholesale replace rebuilt from it could delete fields
* outside the card. Ops name only the fields this card can see.
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns

View File

@@ -31,8 +31,6 @@ export interface ProviderRow {
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
literalApiKeyConfigured: boolean
}
/** Page snapshot. */
@@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
}
/** Whether one namespace's redacted sidecar reports a set literal API key. */
function literalApiKeyConfigured(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
): boolean {
if (namespace === undefined) return false
const secretPath = [...path, 'apiKey']
return namespace.secrets.some(secret =>
secret.set
&& secret.path.length === secretPath.length
&& secret.path.every((key, index) => key === secretPath[index]))
}
/** The models settings page controller (one per settings surface). */
export class ModelsSettingsStore {
/** The snapshot the section renders from (uSES-safe store). */
@@ -170,7 +155,6 @@ export class ModelsSettingsStore {
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
credential: undefined,
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
}
})
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
@@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
reason: 'settings-unavailable',
}
}
if (row.literalApiKeyConfigured) return { kind: 'configured' }
if (row.apiKeyEnv === undefined) {
return {
kind: 'unavailable',

View File

@@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
}
const PiAiConfig = Schema.object({
token: Schema.string().role('secret'),
providers: Schema.dict(Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string(),
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({
})
const DeepSeekConfig = Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
@@ -99,7 +96,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
user: { baseURL: 'https://base' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
secrets: [],
revision: 0,
},
{
@@ -118,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
applies: 'live',
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
secrets: [],
revision: 0,
},
]
@@ -262,22 +259,17 @@ describe('ModelsSection', () => {
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
})
it('decides setup need from the joined credential state and literal-key sidecar', () => {
it('decides setup need from the joined credential state', () => {
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
const row = (
credential: ProviderRow['credential'],
literalApiKeyConfigured = false,
): ProviderRow => ({
const row = (credential: ProviderRow['credential']): ProviderRow => ({
entry,
configured: true,
removable: false,
apiKeyEnv: 'X',
credential,
literalApiKeyConfigured,
})
expect(needsSetup(row(undefined))).toBe(true)
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
expect(needsSetup(row(undefined, true))).toBe(false)
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
expect(needsSetup(nested)).toBe(false)
})
@@ -294,9 +286,7 @@ describe('ModelsSection', () => {
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
})
it('names only the fields the card can see, so an unseen secret survives', () => {
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
// side, so no op mentions it and the seam leaves it alone.
it('names only changed fields instead of rebuilding the section', () => {
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
@@ -724,8 +714,7 @@ describe('ModelsSection', () => {
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.
// The old path rebuilt the whole user section to clear one inherited field.
const { replace, update, mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
@@ -798,9 +787,7 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv and headers were already stored
// with these values, so no op restates them — and the profile's stored
// literal apiKey, absent from the redacted view the card read, is named by
// nothing at all.
// with these values, so no op restates them.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
@@ -1132,8 +1119,8 @@ describe('ModelsSection', () => {
})
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
// The section rebuild is what dropped stored literal secrets: this page
// only ever holds the redacted descriptor, so the removal names the path.
// The page only needs to name the profile path; rebuilding the section
// would widen the write for no benefit.
const { face, mutate, replace, controller } = await mountSection()
await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],

View File

@@ -28,7 +28,6 @@ function harness(options: {
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
@@ -66,7 +65,7 @@ function harness(options: {
? {}
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: options.literal === true }],
secrets: [],
revision: 0,
}],
})),
@@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => {
}
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
it('skips an absent adapter and an already-configured environment credential', async () => {
for (const h of [
harness({ provider: false }),
harness({ providerSettingsNs: '' }),
harness({ literal: true, describeFailure: 'credential seam absent' }),
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)

View File

@@ -19,7 +19,6 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: missingCredential,
literalApiKeyConfigured: false,
...overrides,
}
}
@@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => {
}))).toEqual({ kind: 'configured' })
})
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
expect(deepSeekReadiness(state({
credentialError: 'credentials service absent',
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
}))).toEqual({ kind: 'configured' })
})
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
kind: 'unavailable',

View File

@@ -25,7 +25,7 @@ const NAMESPACES = [
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
base: { baseURL: 'https://base' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: false }],
secrets: [],
revision: 0,
},
{
@@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => {
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: { configured: false, writable: true },
literalApiKeyConfigured: false,
})
expect(byProvider.get('openai')).toMatchObject({
configured: true,
@@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => {
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
})
it('joins a configured literal key from the redacted secret sidecar', async () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
...NAMESPACES[0],
secrets: [
{ path: ['apiKey', 'nested'], set: true },
{ path: ['different'], set: true },
{ path: ['apiKey'], set: true },
],
}] as never,
})),
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
})
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot().rows[0]).toMatchObject({
literalApiKeyConfigured: true,
apiKeyEnv: 'DEEPSEEK_API_KEY',
})
})
it('surfaces a directory failure and keeps the last good rows', async () => {
const { face } = api()
const store = new ModelsSettingsStore(face)

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* ui-primitives is browser-only, but its lib bundle IS imported under plain
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-ui-slots',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -1,11 +1,11 @@
import { clientBundle } from '../tsdown.client.ts'
const [lib, client] = clientBundle(
export default clientBundle(
'@deepseek-ai/dsh-client-ui-theme',
['lib/types/index.js', 'lib/types/invariant.js'],
{
lib: {
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
},
},
)
export default [{
...lib,
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
}, client]

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* runtime — browser consumers resolve this package through the loader module
* table.
*/
export default defineConfig([
export default clientOnly([
{
entry: { index: 'lib/types/index.js' },
outDir: 'lib',

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root-shape lib build plus a css stub: the shell's components import
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* this node lib build stubs every css import to an empty module — importing
* the lib under plain node must not crash on an asset specifier.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])