Merge remote-tracking branch 'origin/master' into worktree/web-skill-tool-row

This commit is contained in:
Yichen Jiang
2026-08-06 16:41:57 +08:00
157 changed files with 8729 additions and 942 deletions

View File

@@ -15,7 +15,7 @@ export type {
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -2502,6 +2502,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
// The fixture endpoint is imaginary, so the interrogation answers the
// catalog it already serves — enough for a surface to exercise adopting
// candidates without a reachable provider.
discoverModels: request => ok(request, {
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
}),
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
@@ -2619,6 +2625,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'credentials.unset': return this.api.credentials.unset(request)
case 'llm.providers': return this.api.llm.providers(request)
case 'llm.models': return this.api.llm.models(request)
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
}
}

View File

@@ -25,7 +25,7 @@ export type {
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,

View File

@@ -44,10 +44,15 @@ export const Config: z<ConnectionConfig> = z.object({
* reconnaissance no anonymous caller should have. `trustedHosts` is a
* DNS-rebinding fence, explicitly not authentication, so the whole
* configuration plane stays loopback-same-origin until a real authentication
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
* deliberately NOT here: it carries provider ids, display names, and model
* lists — no endpoints, keys, or key state — and a LAN client's model picker
* legitimately needs it.
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
* carries a draft credential, and it makes the HOST issue a GET to a URL the
* caller chose and reports back the status or the parsed body — an anonymous
* LAN caller would have a probe for whatever the host can reach and the
* browser cannot.
*
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
* it carries provider ids, display names, and model lists — no endpoints,
* keys, or key state — and a LAN client's model picker legitimately needs it.
*/
const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
@@ -60,6 +65,7 @@ const PRIVILEGED_METHODS = new Set([
'credentials.describe',
'credentials.set',
'credentials.unset',
'llm.discoverModels',
])
/**

View File

@@ -197,6 +197,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -129,13 +129,15 @@ describe('connection node half', () => {
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included. The same declared authority reaches
// configuration plane, reads included, plus the one method that makes the
// host fetch a caller-chosen URL. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.discoverModels',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
@@ -221,6 +223,9 @@ describe('connection node half over a real HTTP server', () => {
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
// Carries a draft credential and turns the host into a fetcher for a
// URL the caller picked: an anonymous LAN caller must not reach it.
'llm.discoverModels',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}

View File

@@ -232,6 +232,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

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: c578ecfc9163245e8666cb6d2d327efdaccccf89
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957
README.md: b55914197e472edec8a8b6d4d3e02036d1697728
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec

View File

@@ -4,12 +4,20 @@ 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 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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. 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 a localized confirmation dialog must complete before the page submits that destructive unset.
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. 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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. 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 a localized confirmation dialog must complete before the page submits that destructive unset.
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 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. 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.
## Model list and endpoint interrogation
A pi-ai profile's `models` list is edited on the card: one row per model showing its id and display name, with the context window and output cap behind a per-row disclosure and two label-free actions — expand and delete — on the right. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing a capacity drops it rather than storing a value the schema would reject, and the adapter's route-level fallbacks size whatever configuration leaves out — an empty capacity shows those fallbacks' magnitude as its placeholder, a hint rather than a mirror, since the field counts `K` as 1000 and a deployment may override them. A capacity that is not a positive integer is simply not stored.
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts.
## Model Experience
None, as the section renders a browser configuration UI; nothing here reaches a model request.
@@ -22,4 +30,6 @@ None; this package neither assembles nor sends a provider request.
- **Only the API key and 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)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. 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 unsets 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.
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
- **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.

View File

@@ -4,12 +4,20 @@
模型设置插件:提供方配置页和按条件显示的 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以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek`reasoning`pi-ai以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」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、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问
pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,行上显示 id 与显示名称,上下文窗口与输出上限收在该行的展开区内,右侧是两个无文字的操作——展开与删除。空列表意味着「使用该路由的内置 catalog」因此每一行都只会被刻意添加清空容量会丢弃它而不是存入一个 schema 会拒绝的值,配置留空的部分由适配器的路由级回退值定尺寸——留空的容量以这些回退值的量级作为占位符,那只是提示而非镜像:该字段按 1000 计 `K`,且部署可以覆盖这些回退值。不是正整数的容量根本不会被存下。
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。
## 模型体验
无。该分区渲染浏览器配置 UI这里没有任何内容进入模型请求。
@@ -22,4 +30,6 @@
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -0,0 +1,240 @@
/**
* The card that declares a provider pi-ai does not ship — an OpenAI-compatible
* gateway, a self-hosted server, or a provider newer than the installed
* catalog.
*
* This is a create, not an edit, which is why it is its own card rather than
* the provider editor with extra fields: the route id is being *chosen* here,
* and the settings address does not exist until it is. One `settings.mutate`
* sets the whole profile at `providers.<route>`; the key travels separately
* through `credentials.set` under the reference the profile records, exactly as
* an existing provider's key does.
*
* The three fields a hand-declared route cannot default — endpoint, protocol,
* and at least one model — are required here rather than at load, so the
* failure names the field while the user is still looking at it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { EditorFooter } from './EditorFooter.tsx'
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import type { ModelDraft } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** The settings namespace a hand-declared provider is written into. */
const NS = 'llm-pi-ai'
/** A route id usable as a settings key and as the stem of a credential name. */
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/** Props of {@link CustomProviderCard}. */
export interface CustomProviderCardProps {
/** Route ids already declared, so the card refuses to shadow one. */
taken: readonly string[]
/** Wire protocols the adapter can serve, in the order it reports them. */
protocols: readonly string[]
/**
* Revision of the `llm-pi-ai` user section this card opened at, sent with
* the create so a route another tab declared meanwhile is a refusal rather
* than a silent overwrite of its whole profile.
*/
revision: number
/** Wire faces for the write and for interrogating the endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the card; `changed` reports whether a provider was created. */
onClose: (changed: boolean) => void
}
/**
* Render the custom-provider creation card.
* @param props - existing routes, protocol choices, wire faces, and copy.
* @returns the creation card.
*/
export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const { taken, protocols, api, t } = props
// Captured at mount, like the editor's: the write must be judged against the
// section this card was drafted over, not whatever it grew into meanwhile.
const [openedAt] = useState(() => props.revision)
const [route, setRoute] = useState('')
const [displayName, setDisplayName] = useState('')
const [baseURL, setBaseURL] = useState('')
const [protocol, setProtocol] = useState(protocols[0] ?? '')
const [keyDraft, setKeyDraft] = useState('')
const [models, setModels] = useState<readonly ModelDraft[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const disabled = props.readOnly || busy
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
const routeTaken = taken.includes(route)
// Rows are checked by the same per-row validator the editor cards use, so a
// bad row is named by its position here too. Capacities have route-level
// fallbacks; what a route cannot default is at least one model.
const modelFailure = validateDeepSeekModels(models)
const ready = route.length > 0 && !routeInvalid && !routeTaken
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
// The one blocked gate worth a line under the form. The route id is omitted
// because its own field already explains itself, and a satisfied card says
// nothing at all rather than printing an empty paragraph.
const hint = failure !== undefined || ready
? undefined
: baseURL.length === 0
? t('customNeedsBaseUrl')
: modelFailure !== undefined
? `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
: t('customNeedsModels')
/** Perform the create, returning a failure message or undefined. */
const createOnce = async (): Promise<string | undefined> => {
const keyRef = deriveKeyRef(route)
const profile = {
...displayName.length === 0 ? {} : { displayName },
apiKeyEnv: keyRef,
api: protocol,
baseURL,
models: models.map(model => ({ ...model })),
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
// The profile landed; saying the key did not is the only honest report,
// and the row is now editable so the key can be entered again there.
if (!stored.result.ok) return stored.result.error.message
}
return undefined
}
const create = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const outcome = await createOnce()
if (outcome !== undefined) {
setFailure(outcome)
return
}
props.onClose(true)
} catch (error) {
// A transport failure rejects rather than answering; without this the
// card would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
return (
<div className={styles['editor']}>
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{t('customTitle')}</span>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
<input
className={styles['input']}
type="text"
value={route}
placeholder="acme-gateway"
aria-label={t('customRoute')}
disabled={disabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
<p className={styles['advancedHint']}>
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
</p>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
className={styles['input']}
type="text"
value={displayName}
placeholder={route.length === 0 ? t('customDisplayName') : route}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setDisplayName(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
className={styles['input']}
type="text"
value={baseURL}
placeholder="https://gateway.example/v1"
aria-label={t('baseUrl')}
disabled={disabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={styles['input']}
value={protocol}
aria-label={t('customApi')}
disabled={disabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
<input
className={styles['input']}
type="password"
autoComplete="off"
value={keyDraft}
placeholder={t('keyPlaceholder')}
aria-label={t('keyInput')}
disabled={disabled}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
</div>
<ModelListEditor
models={models}
onChange={setModels}
probe={{
settingsNs: NS,
baseURL,
api: protocol,
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}}
api={api}
t={t}
disabled={disabled}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{/* Only the gates with something to say render; the route-id gate has its
own field-level hint, so its blocked state would print an empty line. */}
{hint === undefined ? null : <p className={styles['advancedHint']}>{hint}</p>}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || !ready}
submitLabel="create"
submitBusyLabel="creating"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void create() }}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
/**
* The action row every provider card ends with: dismiss on the left, commit on
* the right.
*
* The two cards commit different things — one creates a route, one edits an
* existing profile — but the row itself carries no such knowledge. It renders
* what it is handed, so the cards keep sole ownership of when a commit is
* allowed and what the in-flight wording is.
*
* Cancel refuses input only while a commit is in flight, never because the card
* is disabled: a card the deployment cannot write to must still be dismissable.
*
* @module dsh-client-ui-models/client/EditorFooter
*/
import type { ReactNode } from 'react'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Props of {@link EditorFooter}. */
export interface EditorFooterProps {
/** Localizer for the row's own labels. */
t: (key: keyof typeof en) => string
/** Whether a commit is in flight; holds Cancel and swaps the commit label. */
busy: boolean
/** Whether the commit is refused, as judged by the owning card. */
submitDisabled: boolean
/** Commit label while idle. */
submitLabel: keyof typeof en
/** Commit label while a commit is in flight. */
submitBusyLabel: keyof typeof en
/** Dismiss the card without committing. */
onCancel: () => void
/** Run the card's commit. */
onSubmit: () => void
}
/**
* Render one provider card's action row.
* @param props - the labels, commit gating, and handlers the owning card supplies.
* @returns the cancel/commit row.
*/
export function EditorFooter(props: EditorFooterProps): ReactNode {
const { t } = props
return (
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={props.busy}
onClick={props.onCancel}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={props.submitDisabled}
onClick={props.onSubmit}
>
{props.busy ? t(props.submitBusyLabel) : t(props.submitLabel)}
</button>
</div>
)
}

View File

@@ -0,0 +1,459 @@
/**
* The model list of one pi-ai provider profile, plus the action that asks the
* provider what it serves.
*
* The list is the profile's `models` array as the card holds it: an empty list
* means "serve this route's built-in catalog", and any entry replaces that
* catalog, so a row is only ever added deliberately. Fetching asks the endpoint
* **the form currently shows** — including a key typed but not yet saved — so
* adding a provider is one pass instead of save-then-return; the reply is
* candidates the user picks from, never configuration written behind them.
*
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
* with no readable listing) is not a dead end: the failure is shown next to the
* rows the user can still fill in by hand.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
import { messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/**
* One configured model row. Structurally open, exactly like the DeepSeek
* catalog editor's rows: a profile field this card does not edit — one a future
* schema adds, or one hand-written in `settings.yaml` — has to survive being
* edited here rather than being dropped by a rebuild.
*/
export type ModelDraft = DeepSeekModelDraft
/** A row's text field, or the empty string when unset or not a string. */
function textOf(model: ModelDraft, key: string): string {
const value = model[key]
return typeof value === 'string' ? value : ''
}
/** A row's numeric field, or `undefined` when unset or not a number. */
function numberOf(model: ModelDraft, key: string): number | undefined {
const value = model[key]
return typeof value === 'number' ? value : undefined
}
/** What an interrogation needs, taken from the live form. */
export interface ProbeTarget {
/** Settings namespace whose adapter family answers. */
settingsNs: string
/**
* Route being edited, when the card edits one. An adapter that already
* describes it answers from its own registry, so such a card can ask without
* an endpoint at all.
*/
provider?: string
/** Endpoint as the form currently shows it. */
baseURL?: string
/** Wire protocol the form names, when it names one. */
api?: string
/** Key typed into the form and not yet stored, when there is one. */
apiKey?: string
}
/** Props of {@link ModelListEditor}. */
export interface ModelListEditorProps {
/** The rows as currently drafted. */
models: readonly ModelDraft[]
/** Whether the user layer currently owns the whole array; absent on a create. */
overridden?: boolean
/** Replace the drafted rows. */
onChange: (models: ModelDraft[]) => void
/** Remove the user-owned array and return to inheritance; absent on a create. */
onReset?: () => void
/** Endpoint facts for the fetch action. */
probe: ProbeTarget
/** Wire face the fetch action calls. */
api: Pick<IApiClient, 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every control (read-only deployment or a pending write). */
disabled: boolean
}
/** Disclosure chevron; rotates to point down while its row is open. */
function IconChevron({ open }: { open: boolean }): ReactNode {
return (
<svg
width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden
style={{ transform: open ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
>
<path d="M6 3.5L10.5 8L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/** Removal glyph for one model row. */
function IconTrash(): ReactNode {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9a1 1 0 001 .9h4.6a1 1 0 001-.9L12 4M6.5 6.8v4.4M9.5 6.8v4.4"
stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
)
}
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/**
* What an empty capacity field is worth, shown as its placeholder so a row left
* blank does not read as a model with no capacity at all.
*
* The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s
* `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person
* would say them. They are a hint, not a mirror: this page counts `K` as 1000,
* so typing `256K` stores 256000 while leaving the field blank keeps the
* adapter's 262144. A deployment that overrides those defaults is not
* reflected here — nothing on this page can read them.
*/
const CAPACITY_HINT: Readonly<Record<CapacityField, string>> = {
contextWindow: '256K',
maxTokens: '32K',
}
/**
* Spell a stored count for a field that may be unset. The spelling itself is
* {@link formatCapacity}, shared with the DeepSeek catalog editor so both
* surfaces read and write one K/M vocabulary.
* @param value - stored capacity, or `undefined` for an unset field.
* @returns the field text, empty when unset.
*/
function capacitySpelling(value: number | undefined): string {
return value === undefined ? '' : formatCapacity(value)
}
/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
function adopt(candidate: DiscoveredModelView): ModelDraft {
return {
id: candidate.id,
...candidate.name === undefined ? {} : { name: candidate.name },
...candidate.contextWindow === undefined ? {} : { contextWindow: candidate.contextWindow },
...candidate.maxTokens === undefined ? {} : { maxTokens: candidate.maxTokens },
}
}
/**
* Render the model list with its fetch action.
* @param props - the drafted rows, probe target, wire face, and copy.
* @returns the model-list editor.
*/
export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const { models, onChange, probe, api, t, disabled } = props
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const [candidates, setCandidates] = useState<readonly DiscoveredModelView[] | undefined>(undefined)
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
// Rows carry an id and a name; capacities are the exception, so they stay
// folded until asked for rather than crowding every row with four inputs.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
// Capacities are edited as text, so a field's keystrokes are held here rather
// than re-derived from the parsed count on every change — that would rewrite
// `1000` to `1K` mid-word. Unreadable text is kept past blur so the refusal
// names a row the user can still see, which is why this is one entry PER
// FIELD: a single buffer would be displaced by editing any other field, and
// the abandoned one would render its stored NaN as the literal `NaN`.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(new Map())
/** Buffer key for one capacity field; the row half moves when rows do. */
const bufferKey = (index: number, field: CapacityField): string => `${String(index)}:${field}`
const editCapacity = (index: number, field: CapacityField, text: string): void => {
setEditing(current => new Map(current).set(bufferKey(index, field), text))
patch(index, { [field]: parseCapacity(text) })
}
/** What a capacity field shows: the buffer while typing, else the stored count. */
const capacityText = (model: ModelDraft, index: number, field: CapacityField): string =>
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, field))
/** Drop one row's entries and shift the rows after it down, in one pass. */
const reindexOnRemove = (
current: ReadonlyMap<string, string>,
index: number,
): Map<string, string> => {
const next = new Map<string, string>()
for (const [key, value] of current) {
const at = Number(key.slice(0, key.indexOf(':')))
if (at === index) continue
// Only the row number moves; the field half of the key is untouched.
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value)
}
return next
}
const toggleExpanded = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
const patch = (index: number, next: Record<string, string | number | undefined>): void => {
onChange(models.map((model, at) => {
if (at !== index) return model
// Rebuilt rather than spread over: an emptied optional field has to leave
// the profile, not be stored as a value its schema would reject.
// Spread first so a field this card does not edit survives; an emptied
// optional field is then dropped rather than stored as a value its
// schema would reject.
const cleared = new Set(
Object.entries(next).filter(([, value]) => value === undefined || value === '').map(([key]) => key),
)
return Object.fromEntries(
Object.entries({ ...model, ...next }).filter(([key]) => !cleared.has(key)),
)
}))
}
const fetchModels = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const response = await api.llm.discoverModels({
settingsNs: probe.settingsNs,
...probe.provider === undefined ? {} : { provider: probe.provider },
...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
...probe.api === undefined ? {} : { api: probe.api },
...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
})
if (!response.result.ok) {
setFailure(response.result.error.message)
return
}
const found = response.result.value.models
if (found.length === 0) {
setFailure(t('fetchEmpty'))
return
}
// Everything already configured starts unchecked, so adopting a
// selection never silently rewrites a capacity the user corrected.
const known = new Set(models.map(model => textOf(model, 'id')))
setCandidates(found)
setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
} catch (error) {
// The transport rejected rather than answering; without this the button
// would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
const closePicker = (): void => {
setCandidates(undefined)
setPicked(new Set())
}
const adoptPicked = (): void => {
/* v8 ignore next -- the dialog only renders with candidates loaded */
if (candidates === undefined) return
const byId = new Map(models.map(model => [textOf(model, 'id'), model]))
for (const candidate of candidates) {
if (!picked.has(candidate.id)) continue
// A row the user already tuned wins over the provider's own numbers.
// Keyed by id, so a half-typed row whose id is still empty is not a
// match and the candidate joins as its own row — correct, since a row
// without an id is not yet a model and the create/apply gates refuse it.
byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate))
}
onChange([...byId.values()])
closePicker()
}
const toggle = (id: string): void => {
setPicked((current) => {
const next = new Set(current)
if (!next.delete(id)) next.add(id)
return next
})
}
// A route the adapter already describes answers without an endpoint; only a
// draft with neither has nothing to ask about.
const askable = probe.provider !== undefined || (probe.baseURL !== undefined && probe.baseURL.length > 0)
return (
<section className={styles['modelCatalog']} aria-label={t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{t('models')}</span>
{props.overridden === undefined
? null
: (
<span className={styles['modelCatalogMeta']}>
{props.overridden ? t('modelsCustomized') : t('modelsInherited')}
</span>
)}
</div>
{props.overridden === true && props.onReset !== undefined
? (
<button
type="button"
className={styles['linkButton']}
disabled={disabled}
onClick={props.onReset}
>
{t('resetModels')}
</button>
)
: null}
<button
type="button"
className={styles['linkButton']}
disabled={disabled || busy || !askable}
title={askable ? undefined : t('fetchNeedsBaseUrl')}
onClick={() => { void fetchModels() }}
>
{busy ? t('fetching') : t('fetchModels')}
</button>
</div>
{models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
{models.map((model, index) => (
<div key={index} className={styles['modelEntry']}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={textOf(model, 'id')}
placeholder={t('modelId')}
aria-label={`${t('modelId')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { id: event.target.value }) }}
/>
<input
className={styles['input']}
type="text"
value={textOf(model, 'name')}
placeholder={t('modelName')}
aria-label={`${t('modelName')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { name: event.target.value === '' ? undefined : event.target.value }) }}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${t('modelAdvanced')} ${index + 1}`}
aria-expanded={expanded.has(index)}
title={t('modelAdvanced')}
onClick={() => { toggleExpanded(index) }}
>
<IconChevron open={expanded.has(index)} />
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${t('removeModel')} ${index + 1}`}
title={t('removeModel')}
disabled={disabled}
onClick={() => {
onChange(models.filter((_model, at) => at !== index))
// Both stores are keyed by position, so every row after this
// one shifts down and would otherwise inherit its neighbour's
// state — a different row's capacities popping open, or its
// half-typed text appearing in another row's field.
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at < index) next.add(at)
else if (at > index) next.add(at - 1)
}
return next
})
setEditing(current => reindexOnRemove(current, index))
}}
>
<IconTrash />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelContextWindow')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, 'contextWindow')}
placeholder={CAPACITY_HINT.contextWindow}
aria-label={`${t('modelContextWindow')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'contextWindow', event.target.value) }}
/>
</label>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelMaxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, 'maxTokens')}
placeholder={CAPACITY_HINT.maxTokens}
aria-label={`${t('modelMaxTokens')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
/>
</label>
</div>
)
: null}
</div>
))}
<button
type="button"
className={styles['addModelButton']}
disabled={disabled}
onClick={() => { onChange([...models, { id: '' }]) }}
>
{t('addModel')}
</button>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<Modal
open={candidates !== undefined}
onClose={closePicker}
title={t('fetchTitle')}
closeLabel={t('close')}
description={t('fetchDescription')}
className={styles['fetchDialog'] as string}
footer={(
<>
<Button variant="outline" onClick={closePicker}>{t('cancel')}</Button>
<Button variant="outline" onClick={adoptPicked}>{t('fetchAdopt')}</Button>
</>
)}
>
<ul className={styles['candidateList']}>
{(candidates ?? []).map(candidate => (
<li key={candidate.id} className={styles['candidate']}>
<label className={styles['candidateLabel']}>
<input
type="checkbox"
checked={picked.has(candidate.id)}
onChange={() => { toggle(candidate.id) }}
/>
{/* The id alone: it is the string adoption writes, and the
capacities the endpoint reported are adopted with it and
editable in the row that appears. */}
<span className={styles['candidateId']}>{candidate.id}</span>
</label>
</li>
))}
</ul>
</Modal>
</section>
)
}

View File

@@ -264,11 +264,26 @@
gap: 12px;
}
/* The two ways to gain a provider, as equal siblings spanning the same width
as the rows above. Wraps rather than shrinking below a legible label. */
.addActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.addButton {
display: inline-flex;
align-items: center;
/* Overrides the shared button base above: these two are not pills sitting in
a footer but the last slot of the provider list, so they split the row
evenly and repeat the row cards' corner. Dashed, like every other "nothing
here yet" affordance on this page, to read as a place rather than a
command. */
flex: 1 1 0;
min-width: 180px;
gap: 6px;
align-self: flex-start;
height: 44px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
}
.addCard,
@@ -572,3 +587,44 @@ select.input {
transition: none;
}
}
.fetchDialog {
max-width: 520px;
/* The candidate list scrolls inside this dialog, an elevated surface, so the
scrollbar indirection is rebound here rather than on the scrolling child:
the elevation choice belongs with the surface and inherits down (see
ui-theme styles/scrollbar.css for the contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.candidateList {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 320px;
margin: 0;
overflow-y: auto;
padding: 0;
list-style: none;
}
.candidate {
border-radius: 6px;
}
.candidateLabel {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
}
.candidateId {
flex: 1 1 auto;
font-family: var(--ds-font-family-code);
font-size: 13px;
overflow-wrap: anywhere;
}

View File

@@ -14,7 +14,8 @@ import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { messageOf, protocolChoices } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -27,7 +28,7 @@ export interface ModelsSectionInjected {
/** uSES subscription hook bound to the store. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials'>
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
@@ -118,10 +119,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [declaring, setDeclaring] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) void controller.load()
}
@@ -163,6 +166,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'))
return (
<div className={styles['section']}>
@@ -202,7 +209,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<button
type="button"
className={styles['secondaryButton']}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
onClick={() => {
// One card at a time: leaving `declaring` set would show
// the create card beside this editor, and closing either
// one discards the other's draft.
setDeclaring(false)
setAdding(false)
setEditing(open ? undefined : target)
}}
>
{t('edit')}
</button>
@@ -274,24 +288,55 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
/>
</div>
)
: (
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
)}
: declaring
? (
<div className={styles['addCard']}>
<CustomProviderCard
taken={state.rows.map(row => row.entry.provider)}
protocols={protocols}
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
</div>
)
: (
// One row for the two ways to gain a provider: adopt one the
// adapter already knows, or declare one it does not. Side by side
// and equal-width so they read as siblings and line up with the
// rows above, rather than two pills of different lengths.
<div className={styles['addActions']}>
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setDeclaring(false)
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
<button
type="button"
className={styles['addButton']}
disabled={protocols.length === 0 || !state.writable}
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
>
<IconPlusOutline16 size={14} />
{t('customAdd')}
</button>
</div>
)}
</div>
<Modal
open={deleteTarget !== undefined}

View File

@@ -22,6 +22,8 @@ import {
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -56,8 +58,8 @@ export interface ProviderEditorProps {
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Wire faces for writes and for interrogating a provider endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
@@ -167,6 +169,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
}
// The model list is validated by the same per-row checker for both families,
// so a bad row is named by its position rather than by a blanket message.
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
// What the form currently shows, which is what an interrogation must ask:
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
const probeBaseURL = stringAt(draft, 'baseURL') ?? stringAt(fallback, 'baseURL')
const probe = {
settingsNs: namespace.ns,
// Naming the route lets an adapter that already describes it answer from
// its own registry — better metadata, no network call, no endpoint needed.
provider: props.provider,
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
...probeApi === undefined ? {} : { api: probeApi },
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}
/**
* 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
@@ -183,10 +201,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
{
// The same checker gates the submit button, so a card cannot reach this
// with a bad row; it stays because the schema check below would refuse
// the write with a message naming a path instead of the row, and because
// nothing but this function decides what is written.
const failure = validateDeepSeekModels(getPath(next, ['models']))
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
if (failure !== undefined) {
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
@@ -263,6 +286,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
const catalogProps = {
models,
overridden: modelsOverridden,
t,
disabled,
onChange: (next: Record<string, unknown>[]) => {
setDraft(current => setPath(current, ['models'], next))
},
onReset: () => { setDraft(current => deletePath(current, ['models'])) },
}
return (
<>
<div className={styles['field']}>
@@ -316,22 +350,20 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{/* Both families edit the same rows through the same contract; only
the extras differ — DeepSeek's inherited capacities, pi-ai's
endpoint interrogation. */}
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
{...catalogProps}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
</div>
</details>
</>
@@ -354,24 +386,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
: curatedFields(layout)}
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={busy}
onClick={() => { props.onClose(false) }}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={disabled || layout === 'unknown'}
onClick={() => { void apply() }}
>
{busy ? t('applying') : t('apply')}
</button>
</div>
{modelFailure === undefined
? null
: (
<p className={styles['advancedHint']}>
{`${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`}
</p>
)}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
submitLabel="apply"
submitBusyLabel="applying"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void apply() }}
/>
</div>
)
}

View File

@@ -52,6 +52,29 @@ export const en = {
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
modelCapacityInvalid: 'A capacity must be a number, optionally suffixed K or M.',
modelDuplicate: 'Each model ID may appear once.',
modelContextWindow: 'Context window',
modelMaxTokens: 'Max output tokens',
fetchModels: 'Fetch available models',
fetching: 'Asking the provider\u2026',
fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
fetchEmpty: 'The provider listed no models. Add them by hand.',
fetchTitle: 'Choose models to add',
fetchDescription: 'These are the models this provider has available. Choose the ones to add.',
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
customNeedsBaseUrl: 'A custom provider needs a base URL.',
customNeedsModels: 'A custom provider needs at least one model.',
create: 'Create provider',
creating: 'Creating\u2026',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingGoToSettings: 'Go to settings',
@@ -113,6 +136,29 @@ export const zh: typeof en = {
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
modelDuplicate: '每个模型 ID 只能出现一次。',
modelContextWindow: '上下文窗口',
modelMaxTokens: '最大输出 token',
fetchModels: '获取可用模型',
fetching: '正在询问提供方\u2026',
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
fetchTitle: '选择要添加的模型',
fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。',
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customRoute: 'Provider ID',
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '只能使用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
create: '创建提供方',
creating: '创建中\u2026',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',

View File

@@ -11,7 +11,13 @@ import type {
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form'
/**
* Any route key walks a dict schema to the same profile node, so the lookup
* names one that cannot collide with a configured route.
*/
const PROBE_ROUTE = '\u0000probe'
/** One provider row the page renders. */
export interface ProviderRow {
@@ -66,6 +72,22 @@ export function deriveKeyRef(provider: string): string {
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
}
/**
* The wire protocols a hand-declared route may name, read out of the owning
* namespace's own schema. This stays a schema read rather than a wire field so
* the choices the page offers cannot drift from the ones the adapter accepts:
* both come from the same `Config`.
* @param namespace - the namespace view whose schema declares the profile shape.
* @returns the protocol identifiers, or an empty list when the schema has none.
*/
export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] {
if (namespace === undefined) return []
const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined)
if (list?.type !== 'union' || list.list === undefined) return []
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
}
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
if (namespace === undefined) return undefined

View File

@@ -0,0 +1,865 @@
// @vitest-environment jsdom
/** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
/** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */
const PiAiConfig = Schema.object({
providers: Schema.dict(Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
displayName: Schema.string(),
api: Schema.union(PROTOCOLS),
baseURL: Schema.string(),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
contextWindow: Schema.number(),
maxTokens: Schema.number(),
})),
reasoning: Schema.union(['off', 'high']),
})),
})
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string, code: string): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } }
}
function piAiNamespace(
providers: Record<string, unknown>,
userProviders: Record<string, unknown> = providers,
): SettingsNamespaceView {
return {
ns: 'llm-pi-ai',
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
// `value` is the effective section; `user` is only the layer this page
// writes. They differ whenever a composition `base` supplies something.
value: { providers },
base: {},
user: { providers: userProviders },
applies: 'live',
secrets: [],
revision: 3,
}
}
function scriptedFace(options: {
providers?: Record<string, unknown>
/** User layer, when it differs from the effective section. */
userProviders?: Record<string, unknown>
discover?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
} = {}) {
const providers = options.providers ?? {
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
}
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
providers: Object.keys(providers).map(provider => ({
provider,
displayName: provider,
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', provider],
active: true,
})),
}))),
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: discover,
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))),
update: vi.fn(),
replace: vi.fn(),
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
}))),
set,
unset: vi.fn(),
},
}
return { face, discover, mutate, set, namespace }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
/** The settings write one card produced, as the scripted face recorded it. */
interface MutateCall {
ns: string
expectedRevision?: number
ops: { op: string; path: string[]; value?: unknown }[]
}
/** The first interrogation payload; fails the case when nothing was asked. */
function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0]
if (call === undefined) throw new Error('no interrogation was recorded')
return call
}
/** The first recorded settings write; fails the case when nothing was written. */
function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined
if (call === undefined) throw new Error('no settings write was recorded')
return call
}
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
const scripted = scriptedFace(options)
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
controller,
useSnapshot: bindSnapshotSelector(controller.store),
api: scripted.face as never,
t,
}
render(<ModelsSection {...injected} />)
return scripted
}
/** Open the editor of one configured row and expand its customized fold. */
function openEditor(provider: string): void {
const row = screen.getByText(provider).closest('li')
if (row === null) throw new Error(`no row for ${provider}`)
fireEvent.click(within_(row, en.edit))
const summary = document.querySelector('summary')
if (summary === null) throw new Error('no customized fold')
fireEvent.click(summary)
}
/** Open one model row's advanced fold, where the capacities live. */
function expandModel(index: number): void {
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
}
/** The button carrying `label`, typed so its disabled/title state is readable. */
function buttonNamed(label: string): HTMLButtonElement {
const found = screen.getByText(label)
if (!(found instanceof HTMLButtonElement)) throw new Error(`"${label}" is not a button`)
return found
}
/** Click the button with `label` inside `scope`. */
function within_(scope: HTMLElement, label: string): HTMLElement {
const found = [...scope.querySelectorAll('button')].find(button => button.textContent === label)
if (found === undefined) throw new Error(`no "${label}" button`)
return found
}
describe('protocolChoices', () => {
it('reads the protocols out of the namespace schema and nothing else', async () => {
const { namespace } = scriptedFace()
expect(protocolChoices(namespace)).toEqual(PROTOCOLS)
expect(protocolChoices(undefined)).toEqual([])
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
expect(protocolChoices(plain)).toEqual([])
await Promise.resolve()
})
})
describe('model list editing', () => {
it('adds, edits, and removes rows without storing emptied optional fields', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
// Clearing an optional field must drop it rather than store an empty value.
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate)).toMatchObject({
ns: 'llm-pi-ai',
expectedRevision: 3,
ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536 }] }],
})
})
it('names a duplicate model id in the edit flow too', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'dup' }] } },
})
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'dup' } })
// The create card refuses this in place; an edited route must not have to
// learn it from the host's refusal instead.
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
expect(buttonNamed(en.apply).disabled).toBe(true)
expect(mutate).not.toHaveBeenCalled()
})
it('reads K and M suffixes and keeps the text the user typed', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '1M' } })
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '32K' } })
// The field keeps the spelling rather than snapping to the expansion, and
// a plain count is not rewritten into a suffix mid-word either.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '1000' } })
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('1000')
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
// What lands in settings is always a plain token count.
expect(firstMutate(mutate).ops[0]?.value)
.toEqual([{ id: 'm', contextWindow: 1_000_000, maxTokens: 1000 }])
})
it('refuses to apply while a capacity is unreadable', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: 'abc' } })
// Silently dropping it would store a route sized differently from what the
// field shows, so the text stays put and the write is refused instead.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('abc')
expect(screen.getByText(`${en.model} 1: ${en.modelMaxTokensInvalid}`)).toBeTruthy()
expect(buttonNamed(en.apply).disabled).toBe(true)
expect(mutate).not.toHaveBeenCalled()
})
it('spells a stored capacity back the way it is typed', async () => {
await mountSection({
providers: {
openai: {
baseURL: 'https://proxy.example/v1',
models: [{ id: 'kept', contextWindow: 1_000_000, maxTokens: 256_000 }],
},
},
})
openEditor('openai')
expandModel(1)
// Opening a row reads the stored counts, which are plain integers; showing
// them as such would make an already-configured route look unlike one the
// user just typed, and re-applying would rewrite the field it read.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('256K')
})
it('edits one row of several and lets a cleared capacity leave the profile', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'first' }, { id: 'second' }] } },
})
openEditor('openai')
expandModel(2)
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 2`), { target: { value: '2048' } })
fireEvent.change(screen.getByLabelText(`${en.modelName} 2`), { target: { value: 'Second' } })
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '4096' } })
// Clearing it back to empty must drop the field, not store a zero.
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops[0]?.value).toEqual([
{ id: 'first' },
{ id: 'second', name: 'Second', maxTokens: 2048 },
])
})
it('shows the adapter defaults as inherited until an edit takes them over', async () => {
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
openEditor('openai')
// The user layer names no models, so the list belongs to the adapter and
// says so; taking it over is an explicit act, not a side effect of opening.
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.queryByText(en.resetModels)).toBeNull()
})
it('keeps expansion on the row it belongs to after an earlier one is removed', async () => {
await mountSection({
providers: {
openai: {
baseURL: 'https://proxy.example/v1',
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
},
},
})
openEditor('openai')
// Expansion is keyed by position, so removing an earlier row shifts the
// rest down; without reindexing, row 3 would inherit row 2's open state.
expandModel(2)
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
// 'second' now sits at position 1 and keeps its capacities open; 'third'
// moved to position 2 and stays folded.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('second')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
expect(screen.queryByLabelText(`${en.modelContextWindow} 2`)).toBeNull()
})
it('leaves an earlier row expanded and forgets the removed row\u2019s own state', async () => {
await mountSection({
providers: {
openai: {
baseURL: 'https://proxy.example/v1',
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
},
},
})
openEditor('openai')
// A row before the removal keeps its own position and stays open.
expandModel(1)
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
// Removing the expanded row itself drops that state rather than handing it
// to whichever row slides into the position.
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('third')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
})
it('separates emptying the list from restoring the adapter defaults', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept' }] } },
})
openEditor('openai')
// An empty override is a route that serves no models — a different intent
// from handing the catalog back, which is what the reset affordance does.
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
fireEvent.click(screen.getByText(en.resetModels))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops)
.toContainEqual({ op: 'unset', path: ['providers', 'openai', 'models'] })
})
})
describe('capacity spellings', () => {
it.each([
['', undefined],
['65536', 65_536],
['256K', 256_000],
['1m', 1_000_000],
// A decimal multiple is exact in intent but not in binary floating point,
// so an integral result snaps back instead of landing a few ULPs high.
['2.3M', 2_300_000],
// Not an integral count: kept as written rather than silently rounded.
['1.0005K', 1000.5],
])('reads %j as %j', (text, expected) => {
expect(parseCapacity(text)).toBe(expected)
})
it.each(['abc', '12x', '1 000', '-5', ''])('refuses %j rather than guessing', (text) => {
const parsed = parseCapacity(text)
expect(parsed === undefined || Number.isNaN(parsed)).toBe(true)
})
it.each([
[1_000_000, '1M'],
[256_000, '256K'],
[65_536, '65536'],
// Never a spelling that would not survive being read back.
[0, '0'],
[1.5, '1.5'],
])('spells %j as %j', (value, expected) => {
expect(formatCapacity(value)).toBe(expected)
})
it('round-trips every spelling it produces', () => {
for (const value of [1_000_000, 256_000, 65_536, 4096, 1000]) {
expect(parseCapacity(formatCapacity(value))).toBe(value)
}
})
})
describe('endpoint interrogation', () => {
it('asks the endpoint the form shows, with a key that is not yet stored', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] })))
await mountSection({ discover })
openEditor('openai')
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'typed-not-saved' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://edited.example/v1' } })
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({
settingsNs: 'llm-pi-ai',
// The route is named, so an adapter that already describes it answers
// from its own registry rather than the endpoint.
provider: 'openai',
baseURL: 'https://edited.example/v1',
apiKey: 'typed-not-saved',
})
})
it('carries the protocol the profile already names', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [] })))
await mountSection({
discover,
providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } },
})
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({
settingsNs: 'llm-pi-ai',
provider: 'openai',
baseURL: 'https://proxy.example/v1',
api: 'openai-responses',
})
})
it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
const discover = vi.fn(() => Promise.resolve(ok({
models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }],
})))
const { mutate } = await mountSection({
discover,
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } },
})
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
// The already-configured row starts unchecked; the new one starts checked.
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
expect(boxes.map(box => box.checked)).toEqual([false, true])
fireEvent.click(screen.getByText(en.fetchAdopt))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops[0]?.value).toEqual([
{ id: 'kept', contextWindow: 111 },
{ id: 'fresh', contextWindow: 4096, name: 'Fresh' },
])
})
it('keeps the rows editable when the provider cannot be interrogated', async () => {
const discover = vi.fn(() => Promise.resolve(
fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'),
))
await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(/answered 401; check the API key/)
// The failure is a detour, not a dead end: hand-entry is still offered.
expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy()
})
it('reports an empty listing and a rejected transport', async () => {
const empty = vi.fn(() => Promise.resolve(ok({ models: [] })))
await mountSection({ discover: empty })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchEmpty)
cleanup()
const rejected = vi.fn(() => Promise.reject(new Error('carrier down')))
await mountSection({ discover: rejected })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText('carrier down')
})
it('can be asked for a configured route even with no endpoint', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] })))
await mountSection({ discover, providers: { openai: {} } })
openEditor('openai')
// A route the adapter already describes needs no endpoint at all.
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
})
it('keeps the create card asking only once it has an endpoint', () => {
// A provider being declared has no route yet, so the endpoint is the only
// thing an interrogation could go on.
const scripted = scriptedFace()
render(
<CustomProviderCard
taken={[]} protocols={PROTOCOLS} revision={7} api={scripted.face as never}
t={t} readOnly={false} onClose={vi.fn()}
/>,
)
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
expect(buttonNamed(en.fetchModels).title).toBe(en.fetchNeedsBaseUrl)
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
fireEvent.click(screen.getByText(en.fetchModels))
// A provider being declared names no route, so only the endpoint travels.
expect(firstProbe(scripted.discover)).toEqual({
settingsNs: 'llm-pi-ai',
baseURL: 'https://acme.test/v1',
api: 'openai-completions',
})
})
it('folds a row\u2019s capacities away until they are asked for', async () => {
await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'only' }] } },
})
openEditor('openai')
// The row shows what identifies a model; capacities are the exception.
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
expandModel(1)
expect(screen.getByLabelText(`${en.modelContextWindow} 1`)).toBeTruthy()
expandModel(1)
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
})
it('closes the picker without adopting anything on cancel', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] })))
const { mutate } = await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
const dialog = await screen.findByRole('dialog')
// The editor card carries a Cancel of its own; this one is the dialog's.
fireEvent.click(within_(dialog, en.cancel))
await waitFor(() => { expect(screen.queryByText(en.fetchTitle)).toBeNull() })
expect(mutate).not.toHaveBeenCalled()
})
it('toggles a candidate off and back on before adopting', async () => {
const discover = vi.fn(() => Promise.resolve(ok({
models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }],
})))
const { mutate } = await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
const first = boxes[0] as HTMLInputElement
fireEvent.click(first)
fireEvent.click(first)
fireEvent.click(screen.getByText(en.fetchAdopt))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
// A disclosed output cap rides along with the candidate that has one.
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }])
})
})
describe('hand-declared providers', () => {
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
const scripted = scriptedFace()
const onClose = vi.fn()
render(
<CustomProviderCard
taken={['openai']}
protocols={PROTOCOLS}
revision={7}
api={scripted.face as never}
t={t}
readOnly={false}
onClose={onClose}
{...overrides}
/>,
)
return { ...scripted, onClose }
}
it('writes the whole profile and the key under the derived reference', async () => {
const { mutate, set, onClose } = mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme Gateway' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(mutate)).toEqual({
ns: 'llm-pi-ai',
ops: [{
op: 'set',
path: ['providers', 'acme-gateway'],
value: {
displayName: 'Acme Gateway',
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{ id: 'acme-large', contextWindow: 65_536 }],
},
}],
// The section this card was drafted over: a route another tab declared
// meanwhile makes this a conflict rather than an overwrite.
expectedRevision: 7,
})
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
})
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
// Endpoint first: the gate names the one thing standing in the way.
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
// Satisfied: the shared line disappears rather than rendering empty.
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('refuses to create while a capacity is unreadable', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '64 KiB' } })
expect(screen.getByText(`${en.model} 1: ${en.modelContextInvalid}`)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
})
it('keeps each half-typed capacity with its own row across a removal', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
for (const [at, id] of [[1, 'first'], [2, 'second'], [3, 'third']] as const) {
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} ${String(at)}`), { target: { value: id } })
expandModel(at)
// Deliberately mid-word: the buffer exists so text like this survives.
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} ${String(at)}`),
{ target: { value: `${String(at)}.` } })
}
// Removing the middle row: the one before keeps its position and text, the
// one after moves down carrying its own, and the removed row's text goes.
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1.')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('third')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('3.')
})
it('refuses two models sharing one id', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'same' } })
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'same' } })
// The adapter refuses a duplicate outright, so the form must not offer to
// write one.
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'other' } })
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('creates a model with no capacities, which the route\u2019s fallbacks size', async () => {
const { mutate, onClose } = mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'bare' } })
// A listing that discloses nothing but ids is enough to create a working
// provider; the adapter sizes what configuration leaves out.
expect(buttonNamed(en.create).disabled).toBe(false)
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ models: [{ id: 'bare' }] })
})
it('refuses to create until the route, endpoint, and a model are usable', () => {
mountCard()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'Acme Gateway' } })
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'openai' } })
expect(screen.getByText(en.customRouteTaken)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
// A model row with no id is not a model.
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('surfaces a refused write and a rejected transport without closing', async () => {
const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected')))
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await screen.findByText('read-only settings')
expect(onClose).not.toHaveBeenCalled()
})
it('surfaces a rejected transport during create', async () => {
const rejecting = vi.fn(() => Promise.reject(new Error('carrier down')))
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await screen.findByText('carrier down')
expect(onClose).not.toHaveBeenCalled()
})
it('reports a stored profile whose key write was refused', async () => {
const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected')))
const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'k' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await screen.findByText('credential is read-only')
expect(onClose).not.toHaveBeenCalled()
})
it('creates with the chosen protocol and no display name', async () => {
const { mutate, onClose } = mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'anthropic-messages' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
// No display name configured means none stored; the route id is the name.
expect(firstMutate(mutate).ops[0]?.value).toEqual({
apiKeyEnv: 'ACME_API_KEY',
api: 'anthropic-messages',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm' }],
})
})
it('offers no protocol when the namespace declares none', () => {
mountCard({ protocols: [] })
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
})
it('closes without writing on cancel, and honors a read-only deployment', () => {
const { onClose, mutate } = mountCard()
fireEvent.click(screen.getByText(en.cancel))
expect(onClose).toHaveBeenCalledWith(false)
expect(mutate).not.toHaveBeenCalled()
cleanup()
mountCard({ readOnly: true })
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
expect(buttonNamed(en.create).disabled).toBe(true)
})
it('closes the create card when an existing row is opened for editing', async () => {
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
// Two cards at once would each be closable by the other: whichever one is
// dismissed clears the shared state and discards the other's draft.
openEditor('openai')
expect(screen.queryByText(en.customTitle)).toBeNull()
})
it('reaches the card from the section and returns to the button on cancel', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
fireEvent.click(screen.getByText(en.cancel))
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
})
})

View File

@@ -1,12 +1,25 @@
import { readFileSync } from 'node:fs'
/**
* Models section stylesheet contract, asserted against the CSS text on disk.
*
* The section paints in both themes, and a `--dsw-*` name the theme does not
* declare fails silently: the browser takes the `var()` fallback, so the sheet
* still renders and only the dark theme looks wrong. Checking the names against
* the sheet that declares them is what turns that into a test failure.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
const tokens = readFileSync(
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
'utf8',
)
// The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
// stay on the source plane rather than needing a build.
// Every theme sheet, not just the platform tokens: font and scrollbar
// variables are declared in siblings, and a gate reading one file would call
// their names undeclared.
const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
.filter(name => name.endsWith('.css'))
.map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
.join('\n')
/** The declarations of one top-level rule, by selector. */
function block(selector: string): string {
@@ -21,12 +34,24 @@ describe('ModelsSection theme styles', () => {
// resolves to whatever literal sits in its fallback slot, which is how this
// section stayed light under the dark theme before. Undeclared names have
// no fallback at all and inherit, so both spellings must fail here.
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
// Every theme-variable prefix the sheets actually use, not just `--dsw-`:
// a `--dsh-` name reads as a plausible sibling and would otherwise slip
// past this gate into a fallback literal.
const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
expect(undeclared).toEqual([])
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
})
it('closes every block, so no rule is swallowed by the one above it', () => {
// A missing `}` on an `@media` block is not a parse error: every rule after
// it silently becomes conditional, and the whole fetch dialog once painted
// unstyled for anyone whose system does not ask for reduced motion. Nothing
// downstream reports this — the sheet loads and the classes still attach.
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
})
it('separates the row card from the editor it expands into', () => {
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
// under the dark theme, so filling the row with either erases the nested
@@ -35,4 +60,10 @@ describe('ModelsSection theme styles', () => {
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
})
it('never falls back to a literal colour', () => {
// A token that resolves is never the problem; an undeclared one takes this
// branch, and a literal here is a single colour for both themes.
expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
})
})

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-primitives/README.md
README.md: c64e86152737fd55c49ac39cc2e7b523e0323774
README.zh.md: 29123c3570122bc0fe6a1808a75c5bc659315eaa
README.md: 385730c94831d2fd4af83f9eca0f55941551c796
README.zh.md: b8a75dbffc6549f6294dfda5988c67d6569386c9

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
@@ -42,6 +42,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. Inline links and references resolved within one parse are unaffected.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出
@@ -42,6 +42,7 @@
## 已知限制与暂缓事项
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。内联链接以及在同一次解析内完成解析的引用不受影响。
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。

View File

@@ -21,25 +21,24 @@
"license": "BSD-3-Clause",
"dependencies": {
"@shikijs/langs": "^4.3.1",
"@types/mdast": "^4.0.4",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-core-commonmark": "^2.0.3",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-classify-character": "^2.0.1",
"micromark-util-sanitize-uri": "^2.0.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,175 +1,164 @@
import { isValidElement, useMemo, type ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import { remarkCjkFriendlyStrong } from './remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
/**
* Untrusted assistant-Markdown renderer over the direct mdast pipeline:
* `parse.ts` grammars, the incremental streaming parser, and `render.tsx`.
* While a message streams, all but the trailing two blocks freeze as cached
* React elements and only the source tail behind them re-parses per chunk,
* so per-chunk work tracks the tail size instead of the whole reply. Frozen
* blocks keep their source-offset keys when they cross the freeze boundary,
* so React reconciles instead of remounting. Known deviation while
* streaming: a reference-style link or footnote whose definition sits on the
* other side of the freeze boundary renders literally until the settled
* full parse self-heals it.
*/
import { memo, useMemo, useRef } from 'react'
import type { ReactNode } from 'react'
import { IncrementalMarkdownParser } from './incremental.ts'
import { parseGfm, parseGfmWithMath } from './parse.ts'
import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const streamingRemarkPlugins = [remarkGfm, remarkCjkFriendlyStrong]
const settledRemarkPlugins = [
remarkGfm,
remarkCjkFriendlyStrong,
remarkMathCompatibility,
remarkMath,
]
const settledRehypePlugins = [rehypeKatex]
export type { MarkdownCodeLabels } from './render.tsx'
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
/** One settled full render: parse with math, resolve references, append the footnote section. */
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
collectReferenceTargets(root.children, targets)
const context: MarkdownRenderContext = {
streaming: false,
codeLabels,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
function renderSafeLink(href: string, children: ReactNode): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
const blocks = wrapBlockChildren(
renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
false,
)
const section = renderFootnoteSection(context)
return section === null ? blocks : [...blocks, '\n', section]
}
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
return undefined
/**
* Streaming render state for one growing message: the incremental parser,
* the frozen blocks' cached elements, and the reference/footnote state their
* rendering consumed (footnote numbering assigned to frozen references is
* final, so the tail continues from a copy of it each frame).
*/
class StreamingRenderer {
private readonly parser = new IncrementalMarkdownParser(parseGfm)
private generation = -1
private frozenCount = 0
private frozenElements: ReactNode[] = []
private frozenTargets: ReferenceTargets = createReferenceTargets()
private frozenFootnoteOrder: string[] = []
private frozenFootnoteCounts = new Map<string, number>()
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
* may re-execute the calling render freely.
* @param text - The full accumulated markdown source.
* @returns Frozen elements, re-rendered tail, and the footnote section.
*/
render(text: string): ReactNode[] {
if (text === this.lastText) return this.lastRendered
const { frozen, tail, generation } = this.parser.update(text)
if (generation !== this.generation) {
this.generation = generation
this.frozenCount = 0
this.frozenElements = []
this.frozenTargets = createReferenceTargets()
this.frozenFootnoteOrder = []
this.frozenFootnoteCounts = new Map()
}
const newlyFrozen = frozen.slice(this.frozenCount)
collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets)
// Targets visible this frame: everything frozen so far plus the current
// tail parse — a newly frozen block's references resolved against the
// same parse tree its definitions came from.
const frameTargets: ReferenceTargets = {
definitions: new Map(this.frozenTargets.definitions),
footnotes: new Map(this.frozenTargets.footnotes),
}
collectReferenceTargets(tail.map(block => block.node), frameTargets)
if (newlyFrozen.length > 0) {
const frozenContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
}
// Separator newlines are cached alongside the elements so the
// assembled children match the settled pipeline's block wrapping.
const batch = [...this.frozenElements]
for (const element of renderBlocks(newlyFrozen, frozenContext)) {
if (batch.length > 0) batch.push('\n')
batch.push(element)
}
this.frozenElements = batch
this.frozenCount = frozen.length
}
const tailContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
}
const children = [...this.frozenElements]
for (const element of renderBlocks(tail, tailContext)) {
if (children.length > 0) children.push('\n')
children.push(element)
}
const section = renderFootnoteSection(tailContext)
if (section !== null) children.push('\n', section)
this.lastText = text
this.lastRendered = children
return this.lastRendered
}
}
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
return undefined
}
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
a: ({ href = '', children }) => renderSafeLink(href, children),
code: ({ className, children }) => {
const href = typeof children === 'string' ? inlineCodeHttpUrl(children) : undefined
return <code className={className}>{href === undefined ? children : renderSafeLink(href, children)}</code>
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
return (
<img
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
},
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the <code> path (the :not(pre) rule
// styles it), with a safe anchor only for complete HTTP(S) values. While
// the message streams, the fence renders the plain arm — retokenizing a
// growing fence on every chunk is quadratic main-thread work; the
// finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return (
<CodeBlock
code={raw}
lang={streaming ? undefined : lang}
copyLabel={codeLabels?.copyLabel}
copiedLabel={codeLabels?.copiedLabel}
/>
)
},
}
}
const staticComponents = buildComponents(false)
const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on
* the finalize swap) and parses incrementally across chunks; `codeLabels`
* forwards localized copy-button labels to fence CodeBlocks — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled; complete HTTP(S)
* inline-code values become safe external links, while absolute HTTP(S)
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
// The label-free tables stay module-level singletons so the common case
// keeps referential stability across renders without a hook.
const components = useMemo(() => {
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
return buildComponents(streaming, codeLabels)
}, [streaming, codeLabels])
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
rehypePlugins={streaming ? undefined : settledRehypePlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, codeLabels)
}
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
streamRef.current = new StreamingRenderer(codeLabels)
streamLabelsRef.current = codeLabels
}
return streamRef.current.render(text)
}, [text, streaming, codeLabels])
return <div className={css.markdown}>{children}</div>
})

View File

@@ -6,10 +6,6 @@ import { classifyCharacter } from 'micromark-util-classify-character'
import { codes, constants } from 'micromark-util-symbol'
import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types'
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const cjkCharacter = new RegExp([
'\\p{Script_Extensions=Han}',
'\\p{Script_Extensions=Hiragana}',
@@ -73,16 +69,15 @@ const cjkFriendlyAttention: Construct = {
tokenize: tokenizeCjkFriendlyAttention,
}
const cjkFriendlyStrong: Extension = {
const cjkFriendlyStrongExtension: Extension = {
text: { [codes.asterisk]: cjkFriendlyAttention },
}
/**
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK prose.
* @returns Nothing.
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK
* prose, as a micromark syntax extension for `fromMarkdown`.
* @returns The micromark syntax extension.
*/
export function remarkCjkFriendlyStrong(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(cjkFriendlyStrong)
export function cjkFriendlyStrong(): Extension {
return cjkFriendlyStrongExtension
}

View File

@@ -0,0 +1,130 @@
/**
* Incremental block-level markdown parsing for an append-only text stream.
*
* Re-parsing the whole accumulated document on every streaming chunk is
* quadratic in the final reply length. CommonMark block parsing is line-based
* and appended text can only reshape the parse frontier — the last top-level
* block (a paragraph becoming a setext heading or a table, a list continuing
* after a blank line, an unclosed fence swallowing lines) — so earlier blocks
* are final. This parser therefore freezes all but the trailing
* {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
* behind them: each source region is parsed O(1) times over the stream
* instead of once per chunk.
*
* The freeze boundary comes from the parser's own `position` offsets, never
* from custom source scanning. The cut sits at the *end offset* of the last
* frozen block (not the next block's start): a following block's start offset
* excludes up to three spaces of insignificant leading indentation, which is
* harmless to drop, but cutting at the previous end also keeps the
* inter-block blank lines in the tail so the sliced source stays verbatim.
*
* Known deviation, shared with any prefix-freeze scheme: micromark resolves
* reference-style links and footnotes document-wide at parse time, so a
* reference whose definition lands on the other side of the freeze boundary
* renders literally until the settled full parse self-heals it.
*/
import type { Root, RootContent } from 'mdast'
/**
* Trailing blocks kept unstable. Appended text reshapes at most the last
* block; the second-to-last is retained as safety margin so a freeze decision
* never has to reason about the parse frontier.
*/
const UNSTABLE_TAIL_BLOCKS = 2
/** A top-level mdast block plus a render key that is stable across chunks. */
export interface PositionedBlock {
/** The parsed block. Positions inside it are relative to its parse slice. */
readonly node: RootContent
/**
* The block's start offset in the full source text. Stable from the frame
* a block first appears through freezing, so React reconciles rather than
* remounts when a block crosses the freeze boundary.
*/
readonly key: number
}
/** One {@link IncrementalMarkdownParser.update} result. */
export interface IncrementalBlocks {
/** Blocks that can no longer change; grows monotonically per generation. */
readonly frozen: readonly PositionedBlock[]
/** The re-parsed unstable tail (at most {@link UNSTABLE_TAIL_BLOCKS} blocks plus growth). */
readonly tail: readonly PositionedBlock[]
/** Bumped whenever non-append input discards the frozen prefix; callers drop caches keyed on it. */
readonly generation: number
}
/**
* A block's render key: its absolute source start offset. A position-less
* node (a grammar is free to omit positions) falls back to a negative
* list-index key — unique within one update's tail, which is the only place
* the fallback can occur: freezing requires the cut block's position, so a
* position-less parse keeps every block in the tail (real grammars always
* stamp positions and never take this path).
*/
function blockKey(node: RootContent, base: number, index: number): number {
const offset = node.position?.start.offset
return offset === undefined ? -(index + 1) : base + offset
}
/**
* Append-only incremental parser over a caller-supplied grammar. One instance
* accumulates one streaming document; non-append input resets it.
*/
export class IncrementalMarkdownParser {
private prevText = ''
private tailStart = 0
private frozen: PositionedBlock[] = []
private generation = 0
private cached: IncrementalBlocks | null = null
/** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
constructor(private readonly parse: (text: string) => Root) {}
/**
* Fold the current accumulated text and return the frozen/tail split.
* Idempotent for identical input (the previous result is returned as-is),
* so callers may invoke it from render paths that re-execute.
* @param text - The full accumulated markdown source.
* @returns Frozen and tail blocks with stream-stable render keys.
*/
update(text: string): IncrementalBlocks {
if (this.cached !== null && text === this.prevText) return this.cached
// Deliberate O(prefix) memcmp per update: sound divergence detection has
// to verify the whole retained prefix, and startsWith compares bytes two
// orders of magnitude faster than parsing them — the cost this class
// exists to remove. Passing append/reset deltas instead would push
// append bookkeeping across the session-projection seam for a check
// that stays sub-millisecond at realistic reply sizes.
if (!text.startsWith(this.prevText)) {
this.prevText = ''
this.tailStart = 0
this.frozen = []
this.generation += 1
}
this.prevText = text
const base = this.tailStart
const blocks = this.parse(text.slice(base)).children
let firstUnstable = Math.max(0, blocks.length - UNSTABLE_TAIL_BLOCKS)
if (firstUnstable > 0) {
const cutEnd = blocks[firstUnstable - 1]?.position?.end.offset
if (cutEnd === undefined) {
// A grammar that omits positions leaves nothing to cut at; keep the
// whole parse in the tail rather than guessing a boundary.
firstUnstable = 0
} else {
for (const node of blocks.slice(0, firstUnstable)) {
this.frozen.push({ node, key: blockKey(node, base, this.frozen.length) })
}
this.tailStart = base + cutEnd
}
}
const tail = blocks.slice(firstUnstable).map((node, index) => ({
node,
key: blockKey(node, base, index),
}))
this.cached = { frozen: [...this.frozen], tail, generation: this.generation }
return this.cached
}
}

View File

@@ -0,0 +1,90 @@
/**
* TeX-to-React via KaTeX, replicating the rehype-katex pipeline this renderer
* replaced: the same three-arm error chain (strict render, `strict: 'ignore'`
* retry, error span) and a DOM-identical element tree, so settled math keeps
* its exact markup. KaTeX emits an HTML string; the browser's own HTML parser
* (`DOMParser`, applying the spec's SVG/MathML foreign-content attribute
* adjustments KaTeX output relies on) turns it into a tree this module maps
* onto React elements — KaTeX output is a static span/MathML/SVG vocabulary
* with no raw user HTML, the same trust shiki's tree gets in CodeBlock.
*
* React 18 has no MathML support, so the `.katex-mathml` subtree's elements
* land in the HTML namespace — exactly as they did under the replaced
* hast-util-to-jsx-runtime pipeline. The visual arm is the `.katex-html`
* span tree; the MathML arm serves assistive technology, which reads it by
* tag name regardless of namespace.
*/
import { createElement } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import katex from 'katex'
/**
* Convert one inline `style` attribute string into React's style object.
* KaTeX emits only plain kebab-case declarations (no custom properties and no
* nameless declarations), so camel-casing the property is the whole mapping.
*/
function styleObject(css: string): CSSProperties {
const style: Record<string, string> = {}
for (const declaration of css.split(';')) {
const colon = declaration.indexOf(':')
if (colon === -1) continue
const name = declaration.slice(0, colon).trim()
const key = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
style[key] = declaration.slice(colon + 1).trim()
}
return style
}
/** Map one parsed DOM node onto a React element (text nodes pass through). */
function domToReact(node: ChildNode, key: number): ReactNode {
if (node.nodeType === Node.TEXT_NODE) return node.textContent
/* v8 ignore next 2 -- KaTeX output holds only elements and text; other
node kinds cannot appear in its serialized vocabulary. */
if (node.nodeType !== Node.ELEMENT_NODE) return null
const element = node as Element
const props: Record<string, unknown> = { key }
for (const attribute of element.attributes) {
if (attribute.name === 'class') props['className'] = attribute.value
else if (attribute.name === 'style') props['style'] = styleObject(attribute.value)
else props[attribute.name] = attribute.value
}
const children = [...element.childNodes].map(domToReact)
return children.length === 0
? createElement(element.localName, props)
: createElement(element.localName, props, ...children)
}
/**
* Render TeX source to React elements through KaTeX.
* @param value - The TeX source (math node value; fenced `math` blocks append
* their trailing newline to match the replaced pipeline's text extraction).
* @param displayMode - Display (block) versus inline rendering.
* @returns KaTeX's element tree, or the error span when the source does not
* parse (colored with KaTeX's stock `errorColor`, matching rehype-katex).
*/
export function renderTexToReact(value: string, displayMode: boolean): ReactNode {
let html: string
try {
html = katex.renderToString(value, { displayMode, throwOnError: true })
} catch (error) {
try {
html = katex.renderToString(value, { displayMode, strict: 'ignore', throwOnError: false })
} catch {
// KaTeX renders ParseErrors itself under throwOnError: false; only its
// internal errors reach here, so mirror rehype-katex's manual span.
/* v8 ignore next 8 */
return (
<span
className="katex-error"
style={{ color: '#cc0000' }}
title={String(error)}
>
{value}
</span>
)
}
}
const parsed = new DOMParser().parseFromString(html, 'text/html')
return [...parsed.body.childNodes].map(domToReact)
}

View File

@@ -8,10 +8,6 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
@@ -342,12 +338,12 @@ const backslashMath: Extension = {
}
/**
* Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
* The same processor must register remark-math to compile the emitted math tokens.
* @returns Nothing.
* TeX backslash delimiters and same-line display-dollar blocks as a micromark
* syntax extension reusing `micromark-extension-math`'s token vocabulary; the
* caller must also register `math()` on the same parse so the emitted tokens
* compile to standard math nodes.
* @returns The micromark syntax extension.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
export function mathCompatibility(): Extension {
return backslashMath
}

View File

@@ -0,0 +1,44 @@
/**
* The markdown renderer's two mdast grammars, one per rendering arm. Each
* arm is internally consistent — the incremental tail parses, the one-shot
* parses, and the plain-text projection of a given grammar always agree on
* where blocks start and end — and the settled grammar is the streaming one
* plus the math extensions, so the arms differ only where TeX delimiters
* begin a math construct (a `$$` block is a paragraph while streaming and a
* math block once settled, by design).
*/
import type { Root } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { mathFromMarkdown } from 'mdast-util-math'
import { gfm } from 'micromark-extension-gfm'
import { math } from 'micromark-extension-math'
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
import { mathCompatibility } from './mathCompatibility.ts'
/**
* Parse GFM markdown (the streaming arm's grammar: no math, so incomplete
* TeX never flashes KaTeX errors mid-stream).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfm(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), cjkFriendlyStrong()],
mdastExtensions: [gfmFromMarkdown()],
})
}
/**
* Parse GFM markdown plus TeX math with the compatibility delimiters
* (the settled arm's grammar).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfmWithMath(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
})
}

View File

@@ -1,12 +1,12 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML stays literal, links
* keep their labels, images keep alt text, and code keeps its source text.
* Parsing shares the renderer's streaming GFM grammar ({@link parseGfm}), so
* the projection strips exactly the markup the renderer would draw; raw HTML
* stays literal, links keep their labels, images keep alt text, and code
* keeps its source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import { parseGfm } from './parse.ts'
/** Amount of parsed Markdown content returned by the extractor. */
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
@@ -108,10 +108,7 @@ export function extractMarkdownPlainText(
options: MarkdownPlainTextOptions = {},
): string {
const { mode = 'all' } = options
const root = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
}) as MarkdownNode
const root = parseGfm(markdown) as MarkdownNode
const all = fullText(root)
switch (mode) {
case 'all':

View File

@@ -0,0 +1,544 @@
/**
* Direct mdast→React markdown renderer. Replaces the react-markdown /
* remark-rehype pipeline with one switch over parsed nodes so streaming can
* cache frozen blocks as React elements; the rendered DOM is pinned
* byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
*
* Untrusted-output policy (unchanged from the replaced pipeline): link and
* image destinations pass a protocol allowlist, images additionally require
* absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
* DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
* the allowlist, so footnote references and back-references render as plain
* text rather than in-page links.
*
* Merge-extensible node unions fall through the documented default (render
* nothing) rather than ending in assertNever: grammars registered elsewhere
* may add node types this renderer has no mapping for.
*/
import { Fragment, createElement } from 'react'
import type { Key, ReactNode } from 'react'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import { CodeBlock } from './CodeBlock.tsx'
import { renderTexToReact } from './katex.tsx'
import type { PositionedBlock } from './incremental.ts'
import css from './MarkdownText.module.css'
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
// Relative and otherwise unparsable destinations are disallowed alongside
// disallowed protocols; new URL() has no other failure mode for strings.
return ''
}
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
// Same single failure mode as above: not an absolute URL.
return undefined
}
}
/** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */
export interface ReferenceTargets {
/** Link/image definitions keyed by upper-cased identifier. */
definitions: Map<string, Md.Definition>
/** Footnote definitions keyed by upper-cased identifier. */
footnotes: Map<string, Md.FootnoteDefinition>
}
/**
* Create an empty {@link ReferenceTargets}.
* @returns Fresh empty maps.
*/
export function createReferenceTargets(): ReferenceTargets {
return { definitions: new Map(), footnotes: new Map() }
}
/**
* Record every definition and footnote definition under `nodes` into
* `targets`, depth-first, keeping the first definition per identifier.
* @param nodes - Subtrees to walk (top-level blocks or any nested children).
* @param targets - Accumulator, typically shared across incremental segments.
*/
export function collectReferenceTargets(
nodes: readonly Md.RootContent[],
targets: ReferenceTargets,
): void {
for (const node of nodes) {
if (node.type === 'definition') {
const id = node.identifier.toUpperCase()
if (!targets.definitions.has(id)) targets.definitions.set(id, node)
} else if (node.type === 'footnoteDefinition') {
const id = node.identifier.toUpperCase()
if (!targets.footnotes.has(id)) targets.footnotes.set(id, node)
}
if ('children' in node) collectReferenceTargets(node.children, targets)
}
}
/**
* One render pass's state: immutable options and targets plus the footnote
* numbering accumulated in document order while references render.
*/
export interface MarkdownRenderContext {
/** Streaming arm: fences render plain and TeX stays literal. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
/** Reference targets visible to this pass. */
readonly targets: ReferenceTargets
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
readonly footnoteOrder: string[]
/** References rendered per identifier; drives the section's back-reference count. */
readonly footnoteCounts: Map<string, number>
}
/**
* Render top-level blocks. Nodes that render nothing (definitions, unmapped
* types) are dropped rather than kept as null placeholders, matching the
* replaced pipeline's child lists so separator newlines land identically.
* @param blocks - Blocks with their stream-stable render keys.
* @param context - The pass state; footnote numbering mutates in document order.
* @returns One React node per rendered block.
*/
export function renderBlocks(
blocks: readonly PositionedBlock[],
context: MarkdownRenderContext,
): ReactNode[] {
return blocks
.map(block => renderNode(block.node, block.key, context))
.filter(element => element !== null)
}
/**
* Interleave the newline text nodes the replaced pipeline emitted between
* block-level children. They are invisible between elements but coalesce
* into adjacent literal raw-HTML text, where the DOM parity fixtures pin
* them.
* @param elements - Rendered block children with empty renders already dropped.
* @param edges - Also emit the leading and trailing newline (hast's loose wrap).
* @returns The interleaved children.
*/
export function wrapBlockChildren(elements: readonly ReactNode[], edges: boolean): ReactNode[] {
const wrapped: ReactNode[] = []
for (const element of elements) {
if (edges || wrapped.length > 0) wrapped.push('\n')
wrapped.push(element)
}
if (edges && elements.length > 0) wrapped.push('\n')
return wrapped
}
/**
* A block child rendered for a parent that must tell paragraphs apart from
* other blocks (list items unwrap them when tight; footnote bodies receive
* their back-references inside the trailing paragraph).
*/
type BlockEntry = { paragraph: ReactNode[] } | { element: ReactNode }
/** Render container children into {@link BlockEntry} values, dropping empty renders. */
function renderBlockEntries(
blocks: readonly Md.RootContent[],
context: MarkdownRenderContext,
): BlockEntry[] {
const entries: BlockEntry[] = []
for (const [index, block] of blocks.entries()) {
if (block.type === 'paragraph') {
entries.push({ paragraph: renderChildren(block.children, context) })
} else {
const element = renderNode(block, index, context)
if (element !== null) entries.push({ element })
}
}
return entries
}
function renderChildren(
nodes: readonly Md.RootContent[],
context: MarkdownRenderContext,
): ReactNode[] {
return nodes.map((node, index) => renderNode(node, index, context))
}
function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderContext): ReactNode {
switch (node.type) {
case 'text':
return node.value
case 'paragraph':
return <p key={key}>{renderChildren(node.children, context)}</p>
case 'heading':
return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context))
case 'blockquote':
return (
<blockquote key={key}>
{wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
</blockquote>
)
case 'thematicBreak':
return <hr key={key} />
case 'break':
// The replaced pipeline emitted a newline text node after each <br>.
return <Fragment key={key}><br />{'\n'}</Fragment>
case 'strong':
return <strong key={key}>{renderChildren(node.children, context)}</strong>
case 'emphasis':
return <em key={key}>{renderChildren(node.children, context)}</em>
case 'delete':
return <del key={key}>{renderChildren(node.children, context)}</del>
case 'inlineCode': {
// Parity with mdast-util-to-hast: inline code renders line endings as spaces.
const value = node.value.replace(/\r?\n|\r/g, ' ')
// An inline-code token that is entirely an absolute HTTP(S) URL keeps
// its code chrome and gains the same safe external anchor as a link;
// commands, partial URLs, and other schemes stay inert. The value is
// authored text, not a parsed destination, so no normalizeUri: port,
// path, and query render unchanged.
const href = inlineCodeHttpUrl(value)
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
}
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
return node.value
case 'code':
return renderCode(node, key, context)
case 'math':
return <Fragment key={key}>{renderTexToReact(node.value, true)}</Fragment>
case 'inlineMath':
return <Fragment key={key}>{renderTexToReact(node.value, false)}</Fragment>
case 'list':
return renderList(node, key, context)
case 'listItem':
// Reachable only in hand-built trees: the grammar emits items inside lists.
return renderListItem(node, listItemLoose(node), key, context)
case 'table':
return renderTable(node, key, context)
case 'link':
return renderAnchor(node.url, renderChildren(node.children, context), key)
case 'linkReference':
return renderLinkReference(node, key, context)
case 'image':
return renderImage(node.url, node.alt ?? '', key)
case 'imageReference':
return renderImageReference(node, key, context)
case 'footnoteReference':
return renderFootnoteReference(node, key, context)
case 'definition':
case 'footnoteDefinition':
// Targets render elsewhere: definitions resolve references in place;
// footnote bodies render in the trailing section.
return null
default:
// Documented default for the merge-extensible union: node types without
// a mapping (tableRow/tableCell outside a table, frontmatter, future
// grammar contributions) render nothing.
return null
}
}
function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
const language = node.lang ?? undefined
if (node.value === '') {
// Parity: the replaced pipeline kept the stock <pre> for an empty fence.
return (
<pre key={key}>
<code className={language === undefined ? undefined : `language-${language}`} />
</pre>
)
}
// The replaced pipeline recovered the grammar id from the hast class with
// /language-([\w-]+)/, which truncates at the first non-word character.
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
if (!context.streaming && lang === 'math') {
// ```math fences render as display TeX once settled (rehype-katex parity);
// its text extraction saw the code block's trailing newline.
return <Fragment key={key}>{renderTexToReact(`${node.value}\n`, true)}</Fragment>
}
return (
<CodeBlock
key={key}
// The replaced hast pipeline appended one synthetic newline that
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
lang={context.streaming ? undefined : lang}
copyLabel={context.codeLabels?.copyLabel}
copiedLabel={context.codeLabels?.copiedLabel}
/>
)
}
/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
function listLoose(list: Md.List): boolean {
return (list.spread ?? false) || list.children.some(listItemLoose)
}
function listItemLoose(item: Md.ListItem): boolean {
return item.spread ?? item.children.length > 1
}
function renderList(node: Md.List, key: Key, context: MarkdownRenderContext): ReactNode {
const loose = listLoose(node)
const properties: { start?: number; className?: string } = {}
if (typeof node.start === 'number' && node.start !== 1) properties.start = node.start
if (node.children.some(item => typeof item.checked === 'boolean')) {
properties.className = 'contains-task-list'
}
return createElement(
node.ordered === true ? 'ol' : 'ul',
{ key, ...properties },
...node.children.map((item, index) => renderListItem(item, loose, index, context)),
)
}
function renderListItem(
item: Md.ListItem,
loose: boolean,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const entries = renderBlockEntries(item.children, context)
const task = typeof item.checked === 'boolean'
if (task) {
const checkbox = <input key="task-checkbox" type="checkbox" checked={item.checked === true} disabled />
const head = entries[0]
if (head !== undefined && 'paragraph' in head) {
head.paragraph = head.paragraph.length > 0 ? [checkbox, ' ', ...head.paragraph] : [checkbox]
} else {
entries.unshift({ paragraph: [checkbox] })
}
}
// Newline placement and tight-paragraph unwrapping mirror
// mdast-util-to-hast's list-item handler: a newline before every child
// except a tight leading paragraph, and after a trailing non-paragraph
// (or any trailing child when loose).
const parts: ReactNode[] = []
for (const [index, entry] of entries.entries()) {
const isParagraph = 'paragraph' in entry
if (loose || index !== 0 || !isParagraph) parts.push('\n')
if (!isParagraph) parts.push(entry.element)
else if (loose) parts.push(<p key={`p-${index}`}>{entry.paragraph}</p>)
else parts.push(<Fragment key={`p-${index}`}>{entry.paragraph}</Fragment>)
}
const tail = entries[entries.length - 1]
if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n')
return (
<li key={key} className={task ? 'task-list-item' : undefined}>
{parts}
</li>
)
}
function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
const align = node.align ?? null
const [headRow, ...bodyRows] = node.children
return (
<div key={key} className={css.tableScroll}>
<table>
{headRow !== undefined && <thead>{renderTableRow(headRow, 'th', align, 0, context)}</thead>}
{bodyRows.length > 0 && (
<tbody>
{bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))}
</tbody>
)}
</table>
</div>
)
}
function renderTableRow(
row: Md.TableRow,
cellTag: 'th' | 'td',
align: readonly Md.AlignType[] | null,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
// With column alignment present, every row renders exactly one cell per
// column, padding or truncating the row (mdast-util-to-hast parity).
const length = align === null ? row.children.length : align.length
const cells: ReactNode[] = []
for (let index = 0; index < length; index++) {
const cell = row.children[index]
const alignValue = align?.[index]
cells.push(createElement(
cellTag,
// hast-util-to-jsx-runtime's default tableCellAlignToStyle turned the
// deprecated align attribute into an inline style; keep that DOM.
{ key: index, style: alignValue == null ? undefined : { textAlign: alignValue } },
...(cell === undefined ? [] : renderChildren(cell.children, context)),
))
}
return <tr key={key}>{cells}</tr>
}
/** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */
function renderSafeLink(href: string, children: ReactNode[], key: Key): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <Fragment key={key}>{children}</Fragment>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
key={key}
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
}
/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */
function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
return renderSafeLink(normalizeUri(url), children, key)
}
/**
* The complete inline-code value when it is exactly an absolute HTTP(S) URL
* (no surrounding whitespace); anything else stays inert code.
*/
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
// Not an absolute URL at all — the only way new URL() rejects a string.
return undefined
}
}
function renderImage(url: string, alt: string, key: Key): ReactNode {
const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
if (imageSrc === undefined) {
return <span key={key} className={css.imageAlt}>{alt}</span>
}
return (
<img
key={key}
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
}
/** The bracketed source text a reference reverts to when its definition is missing. */
function referenceSuffix(node: Md.LinkReference | Md.ImageReference): string {
if (node.referenceType === 'collapsed') return '][]'
if (node.referenceType === 'full') return `][${node.label ?? node.identifier}]`
return ']'
}
function renderLinkReference(
node: Md.LinkReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
const children = renderChildren(node.children, context)
if (definition === undefined) {
// The grammar only emits references whose definitions exist somewhere in
// the same parse, but incremental segments and hand-built trees may still
// present unresolved ones: revert to the bracketed source text.
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
}
return renderAnchor(definition.url, children, key)
}
function renderImageReference(
node: Md.ImageReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
return renderImage(definition.url, node.alt ?? '', key)
}
function renderFootnoteReference(
node: Md.FootnoteReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const id = node.identifier.toUpperCase()
const seen = context.footnoteCounts.get(id)
if (seen === undefined) context.footnoteOrder.push(id)
context.footnoteCounts.set(id, (seen ?? 0) + 1)
// The in-page anchor fails the protocol allowlist, so only the numbered
// superscript renders (matching the replaced pipeline's unwrapped link).
return <sup key={key}>{String(context.footnoteOrder.indexOf(id) + 1)}</sup>
}
/**
* Render the trailing footnote section for every footnote referenced during
* the pass, in first-reference order, with one plain-text back-reference
* marker per rendered reference.
* @param context - The pass state after all blocks rendered.
* @returns The section, or null when no referenced footnote has a definition.
*/
export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode | null {
const items: ReactNode[] = []
for (const id of context.footnoteOrder) {
const definition = context.targets.footnotes.get(id)
if (definition === undefined) continue
const count = context.footnoteCounts.get(id) ?? 0
const backrefs: ReactNode[] = []
for (let reference = 1; reference <= count; reference++) {
if (backrefs.length > 0) backrefs.push(' ')
backrefs.push('↩')
if (reference > 1) backrefs.push(<sup key={`re-${reference}`}>{String(reference)}</sup>)
}
const entries = renderBlockEntries(definition.children, context)
const tail = entries[entries.length - 1]
const body: ReactNode[] = entries.map((entry, index) => (
'paragraph' in entry
? (
<p key={`p-${index}`}>
{entry.paragraph}
{entry === tail && <>{' '}{backrefs}</>}
</p>
)
: entry.element
))
// Without a trailing paragraph the back-references join the block list
// itself (and pick up the wrap newlines), as in the replaced pipeline.
if (tail === undefined || !('paragraph' in tail)) body.push(...backrefs)
items.push(
<li key={id} id={`user-content-fn-${normalizeUri(id.toLowerCase())}`}>
{wrapBlockChildren(body, true)}
</li>,
)
}
if (items.length === 0) return null
return (
<section key="footnotes" data-footnotes className="footnotes">
<h2 id="footnote-label" className="sr-only">Footnotes</h2>
<ol>{items}</ol>
</section>
)
}

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<blockquote>
<p>
#text "level one\nstill one"
<blockquote>
<p>
#text "nested"
<ul>
<li>
#text "quoted list"
<p>
#text "after"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<blockquote>
<p>
#text "level one\nstill one"
<blockquote>
<p>
#text "nested"
<ul>
<li>
#text "quoted list"
<p>
#text "after"

View File

@@ -0,0 +1,20 @@
<div class="_markdown_404681">
<p>
<strong>
#text "注意:"
#text "内容在标点后直接闭合。"
<p>
#text "**Notice:**text keeps upstream parsing."
<p>
#text "*提醒!*单星号也保持上游行为。"
<p>
<code>
<a href="https://example.com/preview?q=one%20two#result" rel="noopener noreferrer" target="_blank">
#text "https://example.com/preview?q=one%20two#result"
#text " 与 "
<code>
#text "curl http://127.0.0.1:3199/"
#text " 以及 "
<code>
#text "javascript:alert(1)"
#text "。"

View File

@@ -0,0 +1,20 @@
<div class="_markdown_404681">
<p>
<strong>
#text "注意:"
#text "内容在标点后直接闭合。"
<p>
#text "**Notice:**text keeps upstream parsing."
<p>
#text "*提醒!*单星号也保持上游行为。"
<p>
<code>
<a href="https://example.com/preview?q=one%20two#result" rel="noopener noreferrer" target="_blank">
#text "https://example.com/preview?q=one%20two#result"
#text " 与 "
<code>
#text "curl http://127.0.0.1:3199/"
#text " 以及 "
<code>
#text "javascript:alert(1)"
#text "。"

View File

@@ -0,0 +1,78 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " answer"
<span style="color:var(--shiki-token-keyword)">
#text ":"
<span style="color:var(--shiki-token-constant)">
#text " number"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " withMeta"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"

View File

@@ -0,0 +1,53 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const answer: number = 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const withMeta = true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"

View File

@@ -0,0 +1 @@
<div class="_markdown_404681">

View File

@@ -0,0 +1 @@
<div class="_markdown_404681">

View File

@@ -0,0 +1,3 @@
<div class="_markdown_404681">
<p>
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."

View File

@@ -0,0 +1,3 @@
<div class="_markdown_404681">
<p>
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."

View File

@@ -0,0 +1,37 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " doubled"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<p>
#text "after"

View File

@@ -0,0 +1,23 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const doubled = true\n\n"
<p>
#text "after"

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "First use"
<sup>
#text "1"
#text " and reuse"
<sup>
#text "1"
#text " and another"
<sup>
#text "2"
#text "."
<section class="footnotes" data-footnotes="true">
<h2 class="sr-only" id="footnote-label">
#text "Footnotes"
<ol>
<li id="user-content-fn-a">
<p>
#text "Footnote a body with "
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
#text ". ↩ ↩"
<sup>
#text "2"
<li id="user-content-fn-b">
<p>
#text "Footnote b first paragraph."
<p>
#text "Second paragraph of b. ↩"

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "First use"
<sup>
#text "1"
#text " and reuse"
<sup>
#text "1"
#text " and another"
<sup>
#text "2"
#text "."
<section class="footnotes" data-footnotes="true">
<h2 class="sr-only" id="footnote-label">
#text "Footnotes"
<ol>
<li id="user-content-fn-a">
<p>
#text "Footnote a body with "
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
#text ". ↩ ↩"
<sup>
#text "2"
<li id="user-content-fn-b">
<p>
#text "Footnote b first paragraph."
<p>
#text "Second paragraph of b. ↩"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "Mixed "
<del>
#text "gone"
#text " text with "
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
#text "www.example.com"
#text " literal and "
<a href="mailto:user@example.com">
#text "user@example.com"
#text " email."

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "Mixed "
<del>
#text "gone"
#text " text with "
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
#text "www.example.com"
#text " literal and "
<a href="mailto:user@example.com">
#text "user@example.com"
#text " email."

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "two-space break"
<br>
#text "\nafter break"
<p>
#text "backslash break"
<br>
#text "\nafter backslash"
<hr>
<p>
#text "tail"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "two-space break"
<br>
#text "\nafter break"
<p>
#text "backslash break"
<br>
#text "\nafter backslash"
<hr>
<p>
#text "tail"

View File

@@ -0,0 +1,15 @@
<div class="_markdown_404681">
<h4>
#text "Small heading"
<ul>
<li>
#text "one"
<li>
#text "two"
<h5>
#text "Next"
<ol>
<li>
#text "a"
<li>
#text "b"

View File

@@ -0,0 +1,15 @@
<div class="_markdown_404681">
<h4>
#text "Small heading"
<ul>
<li>
#text "one"
<li>
#text "two"
<h5>
#text "Next"
<ol>
<li>
#text "a"
<li>
#text "b"

View File

@@ -0,0 +1,33 @@
<div class="_markdown_404681">
<h1>
#text "H1 with "
<code>
#text "code"
<h2>
#text "H2"
<h3>
#text "H3"
<h4>
#text "H4"
<h5>
#text "H5"
<h6>
#text "H6"
<p>
#text "Paragraph one with "
<strong>
#text "strong"
#text ", "
<em>
#text "emphasis"
#text ", "
<del>
#text "strike"
#text ", and "
<code>
#text "inline"
#text "."
<h1>
#text "Setext title"
<h2>
#text "Second setext"

View File

@@ -0,0 +1,33 @@
<div class="_markdown_404681">
<h1>
#text "H1 with "
<code>
#text "code"
<h2>
#text "H2"
<h3>
#text "H3"
<h4>
#text "H4"
<h5>
#text "H5"
<h6>
#text "H6"
<p>
#text "Paragraph one with "
<strong>
#text "strong"
#text ", "
<em>
#text "emphasis"
#text ", "
<del>
#text "strike"
#text ", and "
<code>
#text "inline"
#text "."
<h1>
#text "Setext title"
<h2>
#text "Second setext"

View File

@@ -0,0 +1,14 @@
<div class="_markdown_404681">
<p>
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
<p>
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
<p>
<span class="_imageAlt_404681">
#text "relative dropped"
#text " and inline "
<span class="_imageAlt_404681">
#text "bad scheme"
#text " end."
<p>
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">

View File

@@ -0,0 +1,14 @@
<div class="_markdown_404681">
<p>
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
<p>
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
<p>
<span class="_imageAlt_404681">
#text "relative dropped"
#text " and inline "
<span class="_imageAlt_404681">
#text "bad scheme"
#text " end."
<p>
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">

View File

@@ -0,0 +1,6 @@
<div class="_markdown_404681">
<p>
#text "Spans "
<code>
#text "a b"
#text " across a line."

View File

@@ -0,0 +1,6 @@
<div class="_markdown_404681">
<p>
#text "Spans "
<code>
#text "a b"
#text " across a line."

View File

@@ -0,0 +1,25 @@
<div class="_markdown_404681">
<p>
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "https ok"
#text " and "
<a href="mailto:dev@example.com">
#text "mailto ok"
#text "."
<p>
#text "relative dropped and js dropped and "
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
#text "upper kept"
#text "."
<p>
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
#text "https://deepseek.com"
#text " and bare autolink "
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
#text "https://autolink.example.com"
#text " literal."
<p>
#text "[spaces encoded]("
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
#text "https://example.com/a"
#text " b)"

View File

@@ -0,0 +1,25 @@
<div class="_markdown_404681">
<p>
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "https ok"
#text " and "
<a href="mailto:dev@example.com">
#text "mailto ok"
#text "."
<p>
#text "relative dropped and js dropped and "
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
#text "upper kept"
#text "."
<p>
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
#text "https://deepseek.com"
#text " and bare autolink "
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
#text "https://autolink.example.com"
#text " literal."
<p>
#text "[spaces encoded]("
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
#text "https://example.com/a"
#text " b)"

View File

@@ -0,0 +1,44 @@
<div class="_markdown_404681">
<ul>
<li>
#text "tight one"
<li>
#text "tight two\n"
<ul>
<li>
#text "child"
<ol>
<li>
<p>
#text "first"
<li>
<p>
#text "second"
<li>
<p>
#text "ordered with start"
<li>
<p>
#text "next"
<ul>
<li>
<p>
#text "loose item one"
<li>
<p>
#text "loose item two"
<p>
#text "second paragraph of loose item"
<li>
<p>
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"

View File

@@ -0,0 +1,44 @@
<div class="_markdown_404681">
<ul>
<li>
#text "tight one"
<li>
#text "tight two\n"
<ul>
<li>
#text "child"
<ol>
<li>
<p>
#text "first"
<li>
<p>
#text "second"
<li>
<p>
#text "ordered with start"
<li>
<p>
#text "next"
<ul>
<li>
<p>
#text "loose item one"
<li>
<p>
#text "loose item two"
<p>
#text "second paragraph of loose item"
<li>
<p>
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"

View File

@@ -0,0 +1,125 @@
<div class="_markdown_404681">
<p>
#text "Trusted commands stay off: "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mstyle mathcolor="#cc0000">
<mtext>
#text "\\href"
<annotation encoding="application/x-tex">
#text "\\href{javascript:alert(1)}{unsafe}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mord text" style="color: rgb(204, 0, 0);">
<span class="mord" style="color: rgb(204, 0, 0);">
#text "\\href"
#text "."
<p>
#text "Unbalanced errors render the error arm: "
<span class="katex-error" style="color: rgb(204, 0, 0);" title="ParseError: KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{">
#text "\\frac{"
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "Symbol"
<th>
#text "Value"
<tbody>
<tr>
<td>
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "θ"
<annotation encoding="application/x-tex">
#text "\\theta"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 0.6944em;">
<span class="mord mathnormal" style="margin-right: 0.0278em;">
#text "θ"
<td>
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mn>
#text "1"
<mn>
#text "5"
<annotation encoding="application/x-tex">
#text "\\frac{1}{5}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.8451em;">
<span style="top: -2.655em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "5"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.394em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.345em;">
<span>
<span class="mclose nulldelimiter">
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<msqrt>
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "\\sqrt{2}\n"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.04em; vertical-align: -0.0839em;">
<span class="mord sqrt">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.9561em;">
<span class="svg-align" style="top: -3em;">
<span class="pstrut" style="height: 3em;">
<span class="mord" style="padding-left: 0.833em;">
<span class="mord">
#text "2"
<span style="top: -2.9161em;">
<span class="pstrut" style="height: 3em;">
<span class="hide-tail" style="min-width: 0.853em; height: 1.08em;">
<svg height="1.08em" preserveAspectRatio="xMinYMin slice" viewBox="0 0 400000 1080" width="400em" xmlns="http://www.w3.org/2000/svg">
<path d="M95,702\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl0 -0\nc5.3,-9.3,12,-14,20,-14\nH400000v40H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM834 80h400000v40h-400000z">
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.0839em;">
<span>

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."
<p>
#text "Unbalanced errors render the error arm: $\\frac{$"
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "Symbol"
<th>
#text "Value"
<tbody>
<tr>
<td>
#text "$\\theta$"
<td>
#text "(\\frac{1}{5})"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "\\sqrt{2}"

View File

@@ -0,0 +1,320 @@
<div class="_markdown_404681">
<p>
#text "Einstein wrote "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "E"
<mo>
#text "="
<mi>
#text "m"
<msup>
<mi>
#text "c"
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "E = mc^2"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 0.6833em;">
<span class="mord mathnormal" style="margin-right: 0.0576em;">
#text "E"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "="
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 0.8141em;">
<span class="mord mathnormal">
#text "m"
<span class="mord">
<span class="mord mathnormal">
#text "c"
<span class="msupsub">
<span class="vlist-t">
<span class="vlist-r">
<span class="vlist" style="height: 0.8141em;">
<span style="top: -3.063em; margin-right: 0.05em;">
<span class="pstrut" style="height: 2.7em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
#text "2"
#text " inline."
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mrow>
<mi mathvariant="normal">
#text "∂"
<mi mathvariant="bold">
#text "u"
<mrow>
<mi mathvariant="normal">
#text "∂"
<mi>
#text "t"
<mo>
#text "+"
<mo stretchy="false">
#text "("
<mi mathvariant="bold">
#text "u"
<mo>
#text "⋅"
<mi mathvariant="normal">
#text "∇"
<mo stretchy="false">
#text ")"
<mi mathvariant="bold">
#text "u"
<mo>
#text "="
<mo>
#text ""
<mfrac>
<mn>
#text "1"
<mi>
#text "ρ"
<mi mathvariant="normal">
#text "∇"
<mi>
#text "p"
<annotation encoding="application/x-tex">
#text "\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 2.0574em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.3714em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord" style="margin-right: 0.0556em;">
#text "∂"
<span class="mord mathnormal">
#text "t"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord" style="margin-right: 0.0556em;">
#text "∂"
<span class="mord mathbf">
#text "u"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "+"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mopen">
#text "("
<span class="mord mathbf">
#text "u"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "⋅"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mord">
#text "∇"
<span class="mclose">
#text ")"
<span class="mord mathbf">
#text "u"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "="
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 2.2019em; vertical-align: -0.8804em;">
<span class="mord">
#text ""
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.3214em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal">
#text "ρ"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.8804em;">
<span>
<span class="mclose nulldelimiter">
<span class="mord">
#text "∇"
<span class="mord mathnormal">
#text "p"
<p>
#text "Backslash inline "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mn>
#text "1"
<mn>
#text "5"
<annotation encoding="application/x-tex">
#text "\\frac{1}{5}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.8451em;">
<span style="top: -2.655em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "5"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.394em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.345em;">
<span>
<span class="mclose nulldelimiter">
#text " and display:"
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mi>
#text "π"
<mn>
#text "4"
<mo>
#text "<"
<mi>
#text "θ"
<mo>
#text "<"
<mfrac>
<mi>
#text "π"
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.1076em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "4"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "π"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "<"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 0.7335em; vertical-align: -0.0391em;">
<span class="mord mathnormal" style="margin-right: 0.0278em;">
#text "θ"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "<"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.1076em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "2"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "π"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">

View File

@@ -0,0 +1,9 @@
<div class="_markdown_404681">
<p>
#text "Einstein wrote $E = mc^2$ inline."
<p>
#text "$$\n\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p\n$$"
<p>
#text "Backslash inline (\\frac{1}{5}) and display:"
<p>
#text "[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}]"

View File

@@ -0,0 +1,7 @@
<div class="_markdown_404681">
#text "<script>globalThis.compromised = true</script>\n"
<p>
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
<p>
#text "after"

View File

@@ -0,0 +1,7 @@
<div class="_markdown_404681">
#text "<script>globalThis.compromised = true</script>\n"
<p>
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
<p>
#text "after"

View File

@@ -0,0 +1,16 @@
<div class="_markdown_404681">
<p>
#text "A "
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
#text "full"
#text " reference, a "
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
#text "collapsed"
#text " one, and a "
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
#text "shortcut"
#text " one."
<p>
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
<p>
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">

View File

@@ -0,0 +1,16 @@
<div class="_markdown_404681">
<p>
#text "A "
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
#text "full"
#text " reference, a "
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
#text "collapsed"
#text " one, and a "
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
#text "shortcut"
#text " one."
<p>
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
<p>
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">

View File

@@ -0,0 +1,8 @@
<div class="_markdown_404681">
<h2>
#text "Streaming"
<ul>
<li>
#text "first"
<li>
#text "**unfinished"

View File

@@ -0,0 +1,8 @@
<div class="_markdown_404681">
<h2>
#text "Streaming"
<ul>
<li>
#text "first"
<li>
#text "**unfinished"

View File

@@ -0,0 +1,11 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "a"
<th>
#text "b"
<p>
#text "after"

View File

@@ -0,0 +1,11 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "a"
<th>
#text "b"
<p>
#text "after"

View File

@@ -0,0 +1,35 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th style="text-align: left;">
#text "Left"
<th style="text-align: center;">
#text "Center"
<th style="text-align: right;">
#text "Right"
<th>
#text "None"
<tbody>
<tr>
<td style="text-align: left;">
#text "a"
<td style="text-align: center;">
#text "b"
<td style="text-align: right;">
#text "c"
<td>
<code>
#text "code"
<tr>
<td style="text-align: left;">
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
<td style="text-align: center;">
<em>
#text "em"
<td style="text-align: right;">
#text "1"
<td>
#text "2"

View File

@@ -0,0 +1,35 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th style="text-align: left;">
#text "Left"
<th style="text-align: center;">
#text "Center"
<th style="text-align: right;">
#text "Right"
<th>
#text "None"
<tbody>
<tr>
<td style="text-align: left;">
#text "a"
<td style="text-align: center;">
#text "b"
<td style="text-align: right;">
#text "c"
<td>
<code>
#text "code"
<tr>
<td style="text-align: left;">
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
<td style="text-align: center;">
<em>
#text "em"
<td style="text-align: right;">
#text "1"
<td>
#text "2"

View File

@@ -0,0 +1,19 @@
<div class="_markdown_404681">
<ul class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " done with "
<strong>
#text "strong"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " pending"
<li>
#text "plain sibling"
<ol class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " ordered done"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " ordered pending"

View File

@@ -0,0 +1,19 @@
<div class="_markdown_404681">
<ul class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " done with "
<strong>
#text "strong"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " pending"
<li>
#text "plain sibling"
<ol class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " ordered done"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " ordered pending"

View File

@@ -0,0 +1,265 @@
// @vitest-environment jsdom
// DOM-parity contract for MarkdownText: every corpus document's rendered DOM
// is pinned as a file snapshot. The fixtures were recorded from the
// react-markdown implementation this renderer replaced; the custom mdast
// renderer must reproduce them byte-for-byte (after whitespace
// normalization), so a fixture diff means a user-visible markdown style
// change and must be reviewed as such — never re-record to silence a
// refactor.
//
// Provenance is reproducible: the replaced pipeline last lived at commit
// 9e8101b800 (origin/master before the renderer swap merged). Checking out
// that ref in a worktree, copying this spec, and running it records all
// fixtures from react-markdown byte-identical to the ones committed here:
// git worktree add /tmp/parity origin/master --detach && cd /tmp/parity
// pnpm install && cp <this spec> packages/client/ui-primitives/tests/
// npx vitest run packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx
// diff -r <recorded fixtures> <this branch's fixtures> # byte-identical
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
/**
* Serialize rendered DOM deterministically: adjacent text nodes coalesced
* (React renders adjacent string children as separate DOM text nodes while
* hast merges them — invisible either way), whitespace-only runs dropped
* outside `pre` (the markdown pipeline injects cosmetic newlines between
* blocks that HTML rendering collapses), attributes sorted by name, children
* indented for reviewable diffs.
*/
function serialize(node: Node, indent: string, inPre: boolean): string {
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const element = node as Element
const attrs = [...element.attributes]
.map(attr => `${attr.name}=${JSON.stringify(attr.value)}`)
.sort()
.join(' ')
const open = attrs === '' ? element.tagName.toLowerCase() : `${element.tagName.toLowerCase()} ${attrs}`
const nowInPre = inPre || element.tagName === 'PRE'
return `${indent}<${open}>\n${serializeChildren(element, `${indent} `, nowInPre)}`
}
function serializeChildren(element: Element, indent: string, inPre: boolean): string {
let out = ''
let textRun = ''
const flush = (): void => {
if (textRun !== '' && (inPre || textRun.trim() !== '')) {
out += `${indent}#text ${JSON.stringify(textRun)}\n`
}
textRun = ''
}
for (const child of element.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
textRun += child.textContent ?? ''
continue
}
flush()
out += serialize(child, indent, inPre)
}
flush()
return out
}
/** Render one markdown source through MarkdownText and serialize the DOM. */
function renderCase(text: string, streaming: boolean): string {
const { container, unmount } = render(<MarkdownText text={text} streaming={streaming} />)
const out = [...container.childNodes].map(child => serialize(child, '', false)).join('')
unmount()
return out
}
const CORPUS: Record<string, string> = {
'headings-and-paragraphs': [
'# H1 with `code`',
'',
'## H2',
'',
'### H3',
'',
'#### H4',
'',
'##### H5',
'',
'###### H6',
'',
'Paragraph one with **strong**, *emphasis*, ~~strike~~, and `inline`.',
'',
'Setext title',
'=========',
'',
'Second setext',
'---------',
].join('\n'),
'heading-tight-against-list': '#### Small heading\n\n- one\n- two\n\n##### Next\n\n1. a\n2. b',
'hard-breaks-and-hr': 'two-space break \nafter break\n\nbackslash break\\\nafter backslash\n\n---\n\ntail',
'blockquote-nested': '> level one\n> still one\n>\n> > nested\n>\n> - quoted list\n\nafter',
'lists-tight-loose-nested': [
'- tight one',
'- tight two',
' - child',
'',
'1. first',
'2. second',
'',
'3. ordered with start',
'4. next',
'',
'- loose item one',
'',
'- loose item two',
'',
' second paragraph of loose item',
'',
'- item with nested blocks',
'',
' ```',
' fenced inside list',
' ```',
].join('\n'),
'task-lists': '- [x] done with **strong**\n- [ ] pending\n- plain sibling\n\n1. [x] ordered done\n2. [ ] ordered pending',
'table-with-alignment': [
'| Left | Center | Right | None |',
'| :--- | :---: | ---: | --- |',
'| a | b | c | `code` |',
'| [link](https://example.com) | *em* | 1 | 2 |',
].join('\n'),
'code-fences': [
'```ts',
'const answer: number = 42',
'```',
'',
'```',
'no language',
'```',
'',
'```unknown-lang',
'plain fallback',
'```',
'',
'```ts some=meta',
'const withMeta = true',
'```',
'',
'```',
'```',
'',
' indented code block',
' second line',
].join('\n'),
'fence-trailing-blank-lines': [
'```',
'kept blank line follows',
'',
'```',
'',
'```ts',
'const doubled = true',
'',
'',
'```',
'',
'after',
].join('\n'),
'table-header-only': '| a | b |\n| --- | --- |\n\nafter',
'inline-code-with-newline': 'Spans `a\nb` across a line.',
'links-and-autolinks': [
'[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).',
'',
'[relative dropped](/settings) and [js dropped](javascript:alert(1)) and [upper kept](HTTPS://example.com).',
'',
'<https://deepseek.com> and bare autolink https://autolink.example.com literal.',
'',
'[spaces encoded](https://example.com/a b)',
].join('\n'),
'images': [
'![https image](https://example.com/secure.png "img title")',
'',
'![http image](http://example.com/plain.png)',
'',
'![relative dropped](private.png) and inline ![bad scheme](javascript:alert(1)) end.',
'',
'![](https://example.com/empty-alt.png)',
].join('\n'),
'reference-links-and-images': [
'A [full][ref] reference, a [collapsed][] one, and a [shortcut] one.',
'',
'[missing full][nope], [missing collapsed][], ![missing image][gone].',
'',
'![ref image][imgref]',
'',
'[ref]: https://example.com/ref "ref title"',
'[collapsed]: https://example.com/collapsed',
'[shortcut]: https://example.com/shortcut',
'[imgref]: https://example.com/ref.png',
].join('\n'),
'footnotes': [
'First use[^a] and reuse[^a] and another[^b].',
'',
'[^a]: Footnote a body with [link](https://example.com).',
'',
'[^b]: Footnote b first paragraph.',
'',
' Second paragraph of b.',
].join('\n'),
'raw-html-dropped': [
'<script>globalThis.compromised = true</script>',
'',
'Paragraph with inline <img src="x" onerror="boom"> html and <b>bold tag</b> kept literal?',
'',
'<div class="x">',
'html block content',
'</div>',
'',
'after',
].join('\n'),
'entities-and-escapes': 'AT&amp;T, 3 &lt; 4, \\*not em\\*, backslash \\\\ literal, &copy; entity.',
'math-inline-and-display': [
'Einstein wrote $E = mc^2$ inline.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'Backslash inline \\(\\frac{1}{5}\\) and display:',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
].join('\n'),
'math-edge-cases': [
'Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$.',
'',
'Unbalanced errors render the error arm: $\\frac{$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
'```math',
'\\sqrt{2}',
'```',
].join('\n'),
'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.',
'cjk-strong-and-inline-code-url': [
'**注意:**内容在标点后直接闭合。',
'',
'**Notice:**text keeps upstream parsing.',
'',
'*提醒!*单星号也保持上游行为。',
'',
'`https://example.com/preview?q=one%20two#result` 与 `curl http://127.0.0.1:3199/` 以及 `javascript:alert(1)`。',
].join('\n'),
'definition-only': '[unused]: https://example.com/unused',
'streaming-typical-partial': '## Streaming\n\n- first\n- **unfinished',
}
describe('MarkdownText DOM parity fixtures', () => {
for (const [name, text] of Object.entries(CORPUS)) {
it(`settled: ${name}`, async () => {
await expect(renderCase(text, false)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.settled.txt`)
})
it(`streaming: ${name}`, async () => {
await expect(renderCase(text, true)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.streaming.txt`)
})
}
})

View File

@@ -0,0 +1,427 @@
// @vitest-environment jsdom
// Incremental streaming behavior: a MarkdownText kept mounted across
// append-only rerenders must show, at every step, exactly the DOM a fresh
// mount of the same prefix shows, while reusing the frozen blocks' DOM nodes
// instead of remounting them.
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Root, RootContent } from 'mdast'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
import { parseGfm } from '../src/markdown/parse.ts'
afterEach(cleanup)
/**
* A many-block document exercising every freeze-sensitive construct. The
* prefix-equivalence property below holds only while no reference or
* footnote definition lands on the far side of a freeze boundary from its
* use: a fresh mount parses everything in one tree while the live stream's
* frozen blocks are already baked (the fingerprint test demonstrates the
* documented deviation). Keep definitions adjacent to their references when
* extending this corpus.
*/
const STREAM_DOC = [
'# Title',
'',
'First paragraph with **strong** and `code`.',
'',
'- list item one',
'- list item two',
'',
' continuation of item two',
'',
'Setext heading',
'===',
'',
'| a | b |',
'| --- | --- |',
'| 1 | 2 |',
'',
'```ts',
'const x = 1',
'',
'still inside the fence',
'```',
'',
'> quote with lazy',
'continuation line',
'',
'Uses a footnote[^n] twice[^n].',
'',
'[^n]: The footnote body.',
'',
'Closing paragraph after enough blocks to freeze everything above.',
'',
'One more tail block.',
].join('\n')
describe('incremental streaming rendering', () => {
for (const chunkSize of [1, 3, 7, 16]) {
it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => {
const live = render(<MarkdownText text="" streaming />)
for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
}
it('keeps frozen block DOM nodes across freezes instead of remounting', () => {
const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`)
const first = `${paragraphs[0]}\n\n`
const live = render(<MarkdownText text={first} streaming />)
const firstBlock = live.container.querySelector('p')
expect(firstBlock?.textContent).toBe(paragraphs[0])
live.rerender(<MarkdownText text={paragraphs.join('\n\n')} streaming />)
// Same DOM node instance: the block kept its key across the freeze boundary.
expect(live.container.querySelector('p')).toBe(firstBlock)
expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length)
live.unmount()
})
it('recovers when the text diverges instead of appending', () => {
const live = render(<MarkdownText text={'alpha\n\nbeta\n\ngamma\n\ndelta'} streaming />)
live.rerender(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
const fresh = render(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
live.unmount()
fresh.unmount()
})
it('drops the streaming cache when the copy labels change identity', () => {
const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
live.unmount()
})
it('settles into the full math-enabled render after streaming', () => {
const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.'
const live = render(<MarkdownText text={doc} streaming />)
expect(live.container.querySelector('.katex')).toBeNull()
live.rerender(<MarkdownText text={doc} />)
const settled = render(<MarkdownText text={doc} />)
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
expect(live.container.querySelector('.katex')).not.toBeNull()
live.unmount()
settled.unmount()
})
})
describe('incremental parsing is actually in effect', () => {
it('hands the grammar only the source tail once blocks freeze', () => {
const calls: string[] = []
const recording = (text: string): Root => {
calls.push(text)
return parseGfm(text)
}
const parser = new IncrementalMarkdownParser(recording)
const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`)
let text = ''
for (const paragraph of paragraphs) {
text += `${paragraph}\n\n`
parser.update(text)
}
expect(text.length).toBeGreaterThan(1500)
// Warm-up aside, every parse sees only the unstable tail: bounded by a
// few paragraphs, not the growing document.
const steady = calls.slice(5)
expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200)
expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true)
// Cumulative parsed bytes stay linear in the document; full re-parsing
// would have accumulated ~40/2 times the document length here.
const totalParsed = calls.reduce((sum, call) => sum + call.length, 0)
expect(totalParsed).toBeLessThan(text.length * 5)
})
it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
const doc = [
'[ref]: https://example.com/target',
'',
'Paragraph one keeps the definition company.',
'',
'Paragraph two pushes the freeze boundary.',
'',
'Paragraph three freezes the definition out.',
'',
'See [the link][ref] for details.',
].join('\n')
const head = doc.slice(0, doc.indexOf('See'))
const live = render(<MarkdownText text={head} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
// The tail re-parse cannot see the frozen definition, so the reference
// stays literal — the direct observable that the whole text was NOT
// re-parsed (a one-shot mount of the same text resolves it).
expect(live.container.querySelector('a')).toBeNull()
expect(live.container.textContent).toContain('[the link][ref]')
const fresh = render(<MarkdownText text={doc} streaming />)
expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
fresh.unmount()
// The settled swap re-parses everything and heals the deviation.
live.rerender(<MarkdownText text={doc} />)
expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
live.unmount()
})
})
describe('freeze dynamics around frontier-sensitive constructs', () => {
it('an unclosed fence pins the tail: nothing freezes until it closes', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let text = 'p1.\n\np2.\n\np3.\n\n```ts\n'
const opened = parser.update(text)
const frozenAtOpen = opened.frozen.length
expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code')
for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) {
text += line
const grown = parser.update(text)
// The fence swallows everything appended, so the block census cannot
// grow and the freeze boundary must hold still.
expect(grown.frozen.length).toBe(frozenAtOpen)
expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code')
}
text += '```\n\nafter one.\n\nafter two.\n'
const closed = parser.update(text)
expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen)
const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node
expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
})
it('a list can keep extending across blank lines until it freezes whole', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
const before = parser.update(text)
const frozenBefore = before.frozen.length
text += '\n- item c\n'
const extended = parser.update(text)
expect(extended.frozen.length).toBe(frozenBefore)
const tailList = extended.tail[extended.tail.length - 1]?.node
expect(tailList?.type === 'list' && tailList.children).toHaveLength(3)
text += '\nafter.\n\nmore.\n\nend.\n'
const after = parser.update(text)
const frozenList = after.frozen.find(block => block.node.type === 'list')?.node
expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3)
})
it('keeps every previously frozen key as a stable prefix across the stream', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let previous: readonly number[] = []
for (let end = 7; end < STREAM_DOC.length + 7; end += 7) {
const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)))
const keys = frozen.map(block => block.key)
expect(keys.slice(0, previous.length)).toEqual(previous)
previous = keys
}
expect(previous.length).toBeGreaterThan(4)
})
})
describe('multibyte content', () => {
const CJK_DOC = [
'# 标题 🎉',
'',
'这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。',
'',
'- 列表项一 ✅',
'- 列表项二',
'',
'> 引用一行,带表情 🐟',
'',
'```',
'中文代码 🎯',
'```',
'',
'| 键 | 值 |',
'| --- | --- |',
'| 甲 | 乙 |',
'',
'结尾段落,足够多的块让前面全部冻结。🌊',
].join('\n')
it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => {
const live = render(<MarkdownText text="" streaming />)
for (let end = 1; end < CJK_DOC.length + 1; end += 1) {
const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let result = parser.update(CJK_DOC.slice(0, 3))
for (let end = 6; end < CJK_DOC.length + 3; end += 3) {
result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)))
}
const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset)
expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot)
expect(result.frozen.length).toBeGreaterThan(3)
})
})
describe('streaming composition across freezes', () => {
it('continues footnote numbering from frozen references and lists all definitions', () => {
const doc = [
'Alpha uses a footnote[^a].',
'',
'[^a]: First note body.',
'',
'Filler one.',
'',
'Filler two.',
'',
'Filler three.',
'',
'Beta uses another[^b].',
'',
'[^b]: Second note body.',
].join('\n')
const head = doc.slice(0, doc.indexOf('Beta'))
const live = render(<MarkdownText text={head} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2'])
expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id))
.toEqual(['user-content-fn-a', 'user-content-fn-b'])
expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩')
const fresh = render(<MarkdownText text={doc} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
live.unmount()
})
it('keeps every frozen block DOM node through the rest of the stream', () => {
const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`)
const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n`
const live = render(<MarkdownText text={half} streaming />)
const captured = [...live.container.querySelectorAll('p')]
expect(captured.length).toBe(6)
let text = half
for (const paragraph of paragraphs.slice(6)) {
text += `${paragraph}\n\n`
live.rerender(<MarkdownText text={text} streaming />)
}
const finalNodes = [...live.container.querySelectorAll('p')]
expect(finalNodes.slice(0, 6)).toEqual(captured)
expect(finalNodes).toHaveLength(12)
live.unmount()
})
it('renders an empty document for definition-only streams, including trailing blank lines', () => {
const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4'
const live = render(<MarkdownText text={doc.slice(0, 30)} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
live.rerender(<MarkdownText text={`${doc}\n\n\n`} streaming />)
const fresh = render(<MarkdownText text={`${doc}\n\n\n`} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
expect(live.container.querySelector('div')?.childNodes).toHaveLength(0)
fresh.unmount()
live.unmount()
})
it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => {
const live = render(<MarkdownText text={'a.\n\nb.'} streaming />)
live.rerender(<MarkdownText text={'a.\n\nb.'} />)
const settled = render(<MarkdownText text={'a.\n\nb.'} />)
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
settled.unmount()
live.rerender(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
const fresh = render(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
live.unmount()
})
it('matches fresh renders under irregular deterministic chunk sizes', () => {
let seed = 42
const nextSize = (): number => {
seed = (seed * 1103515245 + 12345) % 2147483648
return 1 + (seed % 13)
}
const live = render(<MarkdownText text="" streaming />)
let end = 0
while (end < STREAM_DOC.length) {
end = Math.min(end + nextSize(), STREAM_DOC.length)
const prefix = STREAM_DOC.slice(0, end)
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
})
describe('IncrementalMarkdownParser', () => {
it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const first = parser.update('a\n\nb\n\nc\n\nd\n\ne')
expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph'])
expect(first.tail).toHaveLength(2)
const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng')
expect(second.frozen).toHaveLength(5)
expect(second.tail).toHaveLength(2)
// Previously returned frozen entries keep their identity and keys.
expect(second.frozen.slice(0, 3)).toEqual(first.frozen)
expect(second.generation).toBe(first.generation)
})
it('holds every block in the tail until more than two exist', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const result = parser.update('only\n\ntwo blocks')
expect(result.frozen).toHaveLength(0)
expect(result.tail).toHaveLength(2)
})
it('returns the cached result for identical input', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const first = parser.update('a\n\nb\n\nc')
expect(parser.update('a\n\nb\n\nc')).toBe(first)
})
it('bumps the generation and discards frozen blocks on non-append input', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const before = parser.update('a\n\nb\n\nc\n\nd')
expect(before.frozen.length).toBeGreaterThan(0)
const after = parser.update('different')
expect(after.generation).toBe(before.generation + 1)
expect(after.frozen).toHaveLength(0)
expect(after.tail.map(b => b.node.type)).toEqual(['paragraph'])
})
it('keys blocks by absolute source offset across freezes', () => {
const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee'
const parser = new IncrementalMarkdownParser(parseGfm)
const grown = parser.update(doc)
const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset)
expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys)
})
it('never freezes under a grammar that omits positions', () => {
const bare = (text: string): Root => {
const root = parseGfm(text)
const strip = (nodes: RootContent[]): void => {
for (const node of nodes) {
delete node.position
if ('children' in node) strip(node.children)
}
}
strip(root.children)
return root
}
const parser = new IncrementalMarkdownParser(bare)
const result = parser.update('a\n\nb\n\nc\n\nd\n\ne')
expect(result.frozen).toHaveLength(0)
expect(result.tail).toHaveLength(5)
// Fallback keys stay unique per sibling.
expect(new Set(result.tail.map(b => b.key)).size).toBe(5)
})
})

View File

@@ -0,0 +1,225 @@
// @vitest-environment jsdom
// Branch coverage for the mdast renderer that real parses cannot reach: the
// grammar only emits references whose definitions exist, always stamps
// positions and align arrays, and never emits bare list items — but the
// renderer is a pure function over mdast, so hand-built trees exercise its
// defensive arms directly.
import { StrictMode } from 'react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type * as Md from 'mdast'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
} from '../src/markdown/render.tsx'
import type { MarkdownRenderContext } from '../src/markdown/render.tsx'
afterEach(cleanup)
function makeContext(): MarkdownRenderContext {
return {
streaming: false,
codeLabels: undefined,
targets: createReferenceTargets(),
footnoteOrder: [],
footnoteCounts: new Map(),
}
}
function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement {
const { container } = render(
<div>{renderBlocks(nodes.map((node, key) => ({ node, key })), context)}</div>,
)
return container
}
const text = (value: string): Md.Text => ({ type: 'text', value })
describe('renderBlocks over hand-built trees', () => {
it('reverts unresolved references to their bracketed source', () => {
const container = renderNodes([
{
type: 'paragraph',
children: [
{ type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] },
{ type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] },
{ type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] },
{ type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' },
{ type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null },
],
},
])
expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]')
expect(container.querySelector('a')).toBeNull()
})
it('keeps the first definition when identifiers repeat', () => {
const targets = createReferenceTargets()
collectReferenceTargets([
{ type: 'definition', identifier: 'dup', url: 'https://example.com/first' },
{ type: 'definition', identifier: 'dup', url: 'https://example.com/second' },
{ type: 'footnoteDefinition', identifier: 'fn', children: [] },
{ type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] },
], targets)
expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first')
expect(targets.footnotes.get('FN')?.children).toEqual([])
})
it('renders a bare list item, computing looseness from the item itself', () => {
const item: Md.ListItem = {
type: 'listItem',
spread: null,
children: [
{ type: 'paragraph', children: [text('alpha')] },
{ type: 'paragraph', children: [text('beta')] },
],
}
const container = renderNodes([item])
// Two block children make the parentless item loose: paragraphs stay wrapped.
expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta'])
})
it('renders spread-null lists and align-less tables', () => {
const container = renderNodes([
{
type: 'list',
ordered: false,
spread: null,
children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }],
},
{
type: 'table',
children: [
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] },
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] },
],
},
])
expect(container.querySelector('li')?.textContent).toBe('solo')
expect(container.querySelector('th')?.getAttribute('style')).toBeNull()
expect(container.querySelector('td')?.textContent).toBe('short')
})
it('pads rows against the alignment width with empty cells', () => {
const container = renderNodes([
{
type: 'table',
align: ['left', 'right'],
children: [
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] },
],
},
])
const cells = [...container.querySelectorAll('th')]
expect(cells).toHaveLength(2)
expect(cells[1]?.textContent).toBe('')
})
it('renders a checked item without any content as a bare checkbox', () => {
const container = renderNodes([
{
type: 'list',
ordered: false,
children: [
{ type: 'listItem', checked: true, children: [] },
{ type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] },
],
},
])
const items = [...container.querySelectorAll('li.task-list-item')]
expect(items).toHaveLength(2)
for (const item of items) {
expect(item.querySelector('input[type="checkbox"]')).not.toBeNull()
expect(item.textContent?.trim()).toBe('')
}
})
it('renders images with a null alt as an empty alt attribute', () => {
const targets = createReferenceTargets()
targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' })
const container = renderNodes([
{ type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] },
{ type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] },
], { ...makeContext(), targets })
const images = [...container.querySelectorAll('img')]
expect(images.map(image => image.getAttribute('alt'))).toEqual(['', ''])
})
it('drops a definition nested in a list item without leaving a separator behind', () => {
const container = renderNodes([
{
type: 'list',
ordered: true,
start: 3,
children: [{
type: 'listItem',
children: [
{ type: 'paragraph', children: [text('body')] },
{ type: 'definition', identifier: 'x', url: 'https://example.com' },
],
}],
},
])
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3')
// The two mdast children make the item loose (wrap newlines around the
// paragraph); the dropped definition contributes nothing else.
expect(container.querySelector('li')?.textContent).toBe('\nbody\n')
})
it('renders nothing for node types without a mapping', () => {
const container = renderNodes([
{ type: 'yaml', value: 'front: matter' },
{ type: 'tableRow', children: [] },
{ type: 'paragraph', children: [text('after')] },
])
expect(container.textContent).toBe('after')
})
})
describe('renderFootnoteSection edge shapes', () => {
it('skips referenced footnotes without definitions and returns null when none remain', () => {
const context = makeContext()
context.footnoteOrder.push('GHOST')
context.footnoteCounts.set('GHOST', 1)
expect(renderFootnoteSection(context)).toBeNull()
})
it('renders no back-reference markers for an uncounted footnote', () => {
const context = makeContext()
context.targets.footnotes.set('Q', {
type: 'footnoteDefinition',
identifier: 'q',
children: [{ type: 'paragraph', children: [text('quiet')] }],
})
context.footnoteOrder.push('Q')
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
expect(container.querySelector('li')?.textContent).toBe('\nquiet \n')
})
it('appends back-references after a non-paragraph body', () => {
const context = makeContext()
context.targets.footnotes.set('N', {
type: 'footnoteDefinition',
identifier: 'n',
children: [{ type: 'code', value: 'code body', lang: null }],
})
context.footnoteOrder.push('N')
context.footnoteCounts.set('N', 1)
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
const item = container.querySelector('li')
expect(item?.querySelector('.md-code-block')).not.toBeNull()
expect(item?.textContent).toContain('↩')
})
})
describe('MarkdownText under StrictMode', () => {
it('streams identically when React double-invokes render work', () => {
const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
strict.rerender(<StrictMode><MarkdownText text={doc} streaming /></StrictMode>)
const plain = render(<MarkdownText text={doc} streaming />)
expect(strict.container.innerHTML).toBe(plain.container.innerHTML)
strict.unmount()
plain.unmount()
})
})

View File

@@ -1,10 +1,9 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
afterEach(cleanup)
@@ -149,14 +148,10 @@ describe('MarkdownText', () => {
expect(container.querySelector('pre code a')).toBeNull()
})
it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
const data: { micromarkExtensions?: Extension[] } = {}
const processor = { data: () => data }
remarkCjkFriendlyStrong.call(processor)
remarkCjkFriendlyStrong.call(processor)
expect(data.micromarkExtensions).toHaveLength(2)
const construct = data.micromarkExtensions?.[0]?.text?.[42]
it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
const extension = cjkFriendlyStrong()
expect(cjkFriendlyStrong()).toBe(extension)
const construct = extension.text?.[42]
const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize
expect(tokenizer).toBeTypeOf('function')
expect(() => tokenizer?.call({
@@ -420,11 +415,11 @@ describe('MarkdownText', () => {
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
})
it('registers the compatibility extension on a bare remark processor', () => {
const data: { micromarkExtensions?: Extension[] } = {}
remarkMathCompatibility.call({ data: () => data })
it('exposes the compatibility syntax as a micromark extension', () => {
const extension = mathCompatibility()
expect(data.micromarkExtensions).toHaveLength(1)
expect(Object.keys(extension)).toEqual(['flow', 'text'])
expect(mathCompatibility()).toBe(extension)
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {