Merge remote-tracking branch 'origin/master' into worktree/provider-credential-lifecycle
# Conflicts: # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.md # packages/client/ui-models/README.zh.md # packages/client/ui-models/src/client/ModelsSection.tsx # packages/client/ui-models/src/client/ProviderEditor.tsx
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: f95e06162bca132a9aa83e0b84e875a81d2f8fc6
|
||||
README.zh.md: 4b1a9dda3bbe02ef9241a8a797a07e5285e6daae
|
||||
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
|
||||
@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## The human transcript
|
||||
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 面向人的 transcript(文本记录)
|
||||
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
|
||||
@@ -48,11 +48,14 @@ export type {
|
||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||
RunningToolCall,
|
||||
TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ContextProvenanceView, ContextRole, KnownContextForm,
|
||||
} from './sessions/context-provenance.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
|
||||
import { SteeringHistory } from '../sessions/steering-history.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
@@ -126,6 +128,7 @@ function materializeNode(
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
steering: boolean,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -133,6 +136,15 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -332,6 +344,11 @@ export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const steeringHistory = new SteeringHistory()
|
||||
const steeringSeqs = new Set<number>()
|
||||
for (const event of events) {
|
||||
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
}
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
@@ -392,6 +409,7 @@ export function projectConversationHistory(
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
steeringSeqs.has(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Context provenance projection: the role and the human-facing producer name
|
||||
// of one logged non-user `user/message`, read from its durable `source` alone.
|
||||
// The client keeps no table of known plugin ids — a renamed or newly mounted
|
||||
// producer must never need a client release to stay identifiable, and a resumed
|
||||
// or foreign log must project the same way as a live one.
|
||||
|
||||
/**
|
||||
* Which model-facing role a logged non-user message plays.
|
||||
*
|
||||
* `recall` marks material lifted out of another session's log; `inject` marks
|
||||
* every other producer-supplied context. Mid-turn steering is the third role
|
||||
* the transcript distinguishes, but it has its own event and node kind
|
||||
* (`steering/message` / `SteeringMessageNode`) and never reaches here.
|
||||
*/
|
||||
export type ContextRole = 'inject' | 'recall'
|
||||
|
||||
/** Role and producer name presented for one logged non-user message. */
|
||||
export interface ContextProvenanceView {
|
||||
/** The role this context plays in the model-facing conversation. */
|
||||
role: ContextRole
|
||||
/**
|
||||
* Producer name for the row header, taken from the durable source: the
|
||||
* instruction paths, the referenced session titles, the plugin id, or the
|
||||
* bare source kind for a producer this UI version does not know. Null only
|
||||
* when the source carries no readable kind at all.
|
||||
*/
|
||||
label: string | null
|
||||
}
|
||||
|
||||
/** One durable source narrowed to the readable-record shape; null for anything else. */
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
/** A record field read as a non-empty string, or null. */
|
||||
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
|
||||
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
|
||||
const list = source[member]
|
||||
if (!Array.isArray(list)) return []
|
||||
const seen: string[] = []
|
||||
for (const entry of list) {
|
||||
const record = asRecord(entry)
|
||||
const value = record === null ? null : readString(record, field)
|
||||
if (value !== null && !seen.includes(value)) seen.push(value)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
/** A collected name list rendered as one label; null when the list is empty. */
|
||||
function joined(names: string[]): string | null {
|
||||
return names.length > 0 ? names.join(', ') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one durable message source onto its transcript role and producer name.
|
||||
*
|
||||
* The source arrives over the wire as opaque JSON (`MessageSource` is
|
||||
* merge-extensible, so no client-side union can be exhaustive), and a durable
|
||||
* log may predate or postdate this UI; every unreadable shape therefore
|
||||
* degrades to `inject` with whatever name the record still carries.
|
||||
* @param source - the logged `user/message` source, exactly as recorded.
|
||||
* @returns the role and producer name to present for this context.
|
||||
*/
|
||||
export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
const record = asRecord(source)
|
||||
const kind = record === null ? null : readString(record, 'kind')
|
||||
if (record === null || kind === null) return { role: 'inject', label: null }
|
||||
switch (kind) {
|
||||
// Cross-session snapshots are the one durable source that carries another
|
||||
// session's material; its references name the sessions they were read from.
|
||||
case 'session-reference':
|
||||
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
|
||||
// Workspace instructions name the files they were reconciled from, which
|
||||
// identifies the producer far better than the plugin id would.
|
||||
case 'workspace-instructions':
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
return { role: 'inject', label: kind }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context forms this UI version renders with a dedicated presentation. The
|
||||
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
|
||||
* unrecognized or absent value degrades to the opaque presentation rather than
|
||||
* dropping the row, so a log written by a newer or foreign producer still
|
||||
* renders.
|
||||
*/
|
||||
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
|
||||
|
||||
/** One durable context form this UI version knows how to present. */
|
||||
export type KnownContextForm = typeof KNOWN_FORMS[number]
|
||||
|
||||
/**
|
||||
* Read the producer-declared form off one durable message source.
|
||||
* @param source - the logged `user/message` source, exactly as recorded.
|
||||
* @returns the form when this UI version presents it, otherwise null (opaque).
|
||||
*/
|
||||
export function contextForm(source: unknown): KnownContextForm | null {
|
||||
const record = asRecord(source)
|
||||
const form = record === null ? null : readString(record, 'form')
|
||||
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
|
||||
? form as KnownContextForm
|
||||
: null
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
@@ -102,6 +103,18 @@ export interface AssistantMessageNode {
|
||||
interrupted?: true
|
||||
}
|
||||
|
||||
/** A human message admitted from the next-step inbox while a turn was running. */
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
/** Stable message identity shared with its pre-admission inbox occurrence. */
|
||||
messageId: MessageId
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** A context/system injection surfaced in the flow. */
|
||||
export interface ContextMessageNode {
|
||||
kind: 'context'
|
||||
@@ -110,6 +123,10 @@ export interface ContextMessageNode {
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
/** Role and producer name projected from `source` ({@link contextProvenance}). */
|
||||
provenance: ContextProvenanceView
|
||||
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
|
||||
form: KnownContextForm | null
|
||||
}
|
||||
|
||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||
@@ -223,6 +240,7 @@ export interface CommandNode {
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
| AssistantMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
type InboxTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Minimal pending identity retained while replaying durable inbox splices. */
|
||||
interface PendingIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/** Client-side structural view of the host-owned inbox event. */
|
||||
interface InboxSplice {
|
||||
readonly target: InboxTarget
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly PendingIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally identifies `user/message` events claimed from the next-step
|
||||
* inbox. The agent loop records all admitted input as `user/message`; the
|
||||
* preceding `agent/inbox/spliced` events preserve whether it came from the
|
||||
* queued-turn list or the next-step list.
|
||||
*/
|
||||
export class SteeringHistory {
|
||||
private readonly inbox: Record<InboxTarget, PendingIdentity[]> = {
|
||||
'next-turn': [],
|
||||
'next-step': [],
|
||||
}
|
||||
|
||||
private readonly claimedNextStep = new Set<string>()
|
||||
|
||||
/** Clear all replay state before rebuilding a history window. */
|
||||
reset(): void {
|
||||
this.inbox['next-turn'] = []
|
||||
this.inbox['next-step'] = []
|
||||
this.claimedNextStep.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one event and report whether it is a durable human steering message.
|
||||
* @param event - next raw session event in sequence order.
|
||||
* @returns true only for a user-origin message previously claimed from `next-step`.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'agent/inbox/spliced') {
|
||||
this.applySplice(event.data as unknown as InboxSplice)
|
||||
return false
|
||||
}
|
||||
if (event.type !== 'user/message') return false
|
||||
const id = event.data.id
|
||||
if (!this.claimedNextStep.delete(id)) return false
|
||||
return event.data.source.kind === 'user'
|
||||
}
|
||||
|
||||
/** Replay one host-validated inbox splice. */
|
||||
private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void {
|
||||
const removed = this.inbox[target].splice(start, removedCount, ...inserted)
|
||||
for (const identity of inserted) this.claimedNextStep.delete(identity.id)
|
||||
if (target !== 'next-step' || outcome === 'canceled') return
|
||||
for (const identity of removed) this.claimedNextStep.add(identity.id)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import { contextForm, contextProvenance } from './context-provenance.ts'
|
||||
import { SteeringHistory } from './steering-history.ts'
|
||||
import type { AssistantStepMetadata } from './assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
|
||||
|
||||
@@ -46,11 +48,12 @@ interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
|
||||
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
steering: boolean,
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
@@ -62,6 +65,15 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -178,6 +190,8 @@ export class TranscriptAdapter {
|
||||
private stepTimings = new Map<string, AssistantStepMetadata>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
|
||||
private readonly steeringHistory = new SteeringHistory()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so it is not a surface
|
||||
@@ -206,6 +220,8 @@ export class TranscriptAdapter {
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
this.steeringHistory.reset()
|
||||
const steeringSeqs = new Set<number>()
|
||||
this.stepTimings = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
@@ -214,13 +230,14 @@ export class TranscriptAdapter {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
}
|
||||
// Indexes first, then project: a tool/result materializes against the
|
||||
// complete call index, and a checkpoint against the complete event index.
|
||||
const projected: ConversationNode[] = []
|
||||
for (const event of events) {
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
|
||||
}
|
||||
this.projected = projected
|
||||
}
|
||||
@@ -237,10 +254,11 @@ export class TranscriptAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
const steering = this.steeringHistory.apply(event)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
this.projected = [...this.projected, this.materialize(event, steering)]
|
||||
this.rev++
|
||||
}
|
||||
|
||||
@@ -273,10 +291,16 @@ export class TranscriptAdapter {
|
||||
}
|
||||
|
||||
/** Materialize one transcript event against the complete current indexes. */
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings)
|
||||
: materializeNode(
|
||||
event,
|
||||
this.callIdx,
|
||||
this.resultViews.get(event.seq) ?? null,
|
||||
steering,
|
||||
this.stepTimings,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
Binary file not shown.
@@ -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). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
@@ -10,6 +10,49 @@ const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
||||
|
||||
describe('projectConversationHistory', () => {
|
||||
it('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the provenance projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
|
||||
@@ -85,22 +85,85 @@ describe('TranscriptAdapter', () => {
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
at(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(3, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(4, 0, 'c1', '结果'),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 0, 'c1', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
@@ -198,10 +261,15 @@ describe('TranscriptAdapter', () => {
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: b262c4f89ebcfb8148a0c9a579efe18c2bd4f7a9
|
||||
README.zh.md: 5a333e84000893040ec26f7c10dbb32cb3e064b7
|
||||
README.md: 7bd0d551fc41967326dd9860f5c31a99ea3c254a
|
||||
README.zh.md: d339f6423d9a9f77c02d86ad0b8e57bd0baba52b
|
||||
|
||||
@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
@@ -36,7 +36,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* Expanded context bodies: one code-block surface shared by every form, so the
|
||||
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Provenance beneath the text: dimmer than the content it describes. */
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 8px 0 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--dsw-alias-line-secondary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldKey {
|
||||
flex: none;
|
||||
min-width: 96px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* instructions: the reconciled files, above their text. */
|
||||
.files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filePath {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fileAction {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* catalog: a replacement notice above one row per published entry. */
|
||||
.catalogNotice {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entryName {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.entryDescription {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* snapshot: one titled block per contributing subsystem. */
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionName {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.sectionText {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* relay: who sent this, above what they said. */
|
||||
.relaySender {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* recall: one row per source session, with how much of it survived. */
|
||||
.recalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.recall {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recallLabel {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recallCounts {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
591
packages/client/ui-conversation/src/client/chat/ContextBody.tsx
Normal file
591
packages/client/ui-conversation/src/client/chat/ContextBody.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
// Expanded bodies for the context disclosure, one per durable context form.
|
||||
// The producer declares the form; this module only chooses a presentation for
|
||||
// it. Every form falls back to OpaqueBody, which is the documented default for
|
||||
// an absent, unknown, or malformed form — a resumed or foreign log must render
|
||||
// even when this UI version has never seen its producer.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import css from './ContextBody.module.css'
|
||||
|
||||
/** Model-facing text stays bounded at the disclosure, not at the producer. */
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
/** Rows a list body materializes before summarizing the remainder. */
|
||||
const MAX_ENTRIES = 200
|
||||
|
||||
type Translate = ChatViewSlotProps['t']
|
||||
|
||||
/** One durable source narrowed to the readable-record shape; null for anything else. */
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
/** One run of the model-facing content: adjacent text, or one unknown block. */
|
||||
type ContentRun = { text: string } | { block: unknown }
|
||||
|
||||
/**
|
||||
* The content blocks as runs, IN THE ORDER the model received them.
|
||||
*
|
||||
* Adjacent text blocks join with no separator, matching how provider adapters
|
||||
* flatten them — inserting a line break would show the reader a line the model
|
||||
* never saw. An unknown block breaks the run and keeps its own fallback rather
|
||||
* than being hoisted past the text around it or vanishing; the block union is
|
||||
* merge-extensible, so a foreign log may interleave shapes this build does not
|
||||
* know.
|
||||
*/
|
||||
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
|
||||
const runs: ContentRun[] = []
|
||||
for (const block of content) {
|
||||
if (block.type !== 'text') {
|
||||
runs.push({ block })
|
||||
continue
|
||||
}
|
||||
const last = runs[runs.length - 1]
|
||||
if (last !== undefined && 'text' in last) last.text += block.text
|
||||
else runs.push({ text: block.text })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Only the blocks this UI version does not know, for bodies that replace the text. */
|
||||
function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
|
||||
return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
|
||||
}
|
||||
|
||||
/** The model-facing text, truncated to the display bound. */
|
||||
function boundedText(text: string, t: Translate): string {
|
||||
return text.length > MAX_CHARS
|
||||
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
|
||||
: text
|
||||
}
|
||||
|
||||
/**
|
||||
* One source field rendered as a value row; nested shapes stay compact JSON.
|
||||
* Bounded on its own, because provenance is as unbounded as the text: an unknown
|
||||
* producer may record an arbitrarily large string or array.
|
||||
*/
|
||||
function fieldValue(value: unknown, t: Translate): string {
|
||||
const text = typeof value === 'string'
|
||||
? value
|
||||
: typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
|
||||
return boundedText(text, t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance fields as a key/value list. `kind` is always omitted because the
|
||||
* row header already names the producer. `form` is omitted only when a
|
||||
* dedicated body rendered for it — then the presentation the reader is looking
|
||||
* at IS that value. On the opaque fallback the declaration is kept, because
|
||||
* that is the one place a form this version cannot present would otherwise
|
||||
* disappear from the UI entirely.
|
||||
*/
|
||||
function SourceFields({ source, formRendered, t }: {
|
||||
source: unknown
|
||||
formRendered: boolean
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const record = asRecord(source)
|
||||
if (record === null) return null
|
||||
const hidden = formRendered ? ['kind', 'form'] : ['kind']
|
||||
const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className={css.fields} data-context-fields>
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className={css.field}>
|
||||
<dt className={css.fieldKey}>{key}</dt>
|
||||
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content blocks this UI version does not know, kept visible rather than
|
||||
* dropped: the block union is merge-extensible, so a newer or foreign log may
|
||||
* carry a shape this build has no presentation for.
|
||||
* @param props - The unrecognized blocks and the locale seat.
|
||||
* @returns One generic JSON block per unknown entry.
|
||||
*/
|
||||
function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => (
|
||||
<JsonBlock
|
||||
key={index}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing content of one context, shared by every form that shows it:
|
||||
* the text with its real line breaks, then any block this UI version does not
|
||||
* know, which keeps its own fallback rather than vanishing.
|
||||
* @param props - Durable content and the locale seat.
|
||||
* @returns The content blocks as the model received them.
|
||||
*/
|
||||
function ModelFacingContent({ content, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{contentRuns(content).map((run, index) => ('text' in run
|
||||
? run.text !== '' && (
|
||||
<pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre>
|
||||
)
|
||||
: (
|
||||
<JsonBlock
|
||||
key={index}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={run.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
)))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default presentation: the model-facing text as text, with its real line
|
||||
* breaks, and the remaining provenance beneath it. This is what every form
|
||||
* this UI version does not recognize renders as.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The opaque context body.
|
||||
*/
|
||||
export function OpaqueBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<ModelFacingContent content={content} t={t} />
|
||||
<SourceFields source={source} formRendered={false} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One reconciled instruction file, as the durable source records it. */
|
||||
interface InstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
path: string
|
||||
digest?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction changes read off the source, or null when the record is not a
|
||||
* usable instruction list.
|
||||
*
|
||||
* The read is all-or-nothing: silently dropping one unreadable entry would show
|
||||
* a confident, incomplete file list for a log this version cannot fully read.
|
||||
* Paths are deduplicated in first-seen order, matching how the header label is
|
||||
* derived from the same array.
|
||||
*/
|
||||
function instructionChanges(source: unknown): InstructionChange[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['changes']
|
||||
if (!Array.isArray(list)) return null
|
||||
const changes: InstructionChange[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const entry of list as readonly unknown[]) {
|
||||
const change = asRecord(entry)
|
||||
if (change === null) return null
|
||||
const path = change['path']
|
||||
if (typeof path !== 'string' || path === '') return null
|
||||
const action = change['action']
|
||||
// The action decides which word the row shows, so an unrecognized one is
|
||||
// not a readable change — it would be presented as loaded or updated.
|
||||
if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
|
||||
const digest = change['digest']
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
|
||||
}
|
||||
return changes.length === 0 ? null : changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale key for one reconciled file. The baseline loads a file; a later delta
|
||||
* distinguishes a newly reconciled path from a rewritten one, which `set` and
|
||||
* `replace` already separate at the producer.
|
||||
* @param action - the durable change action.
|
||||
* @param baseline - whether this context is the startup/resume baseline.
|
||||
* @returns the key naming what happened to that file.
|
||||
*/
|
||||
function instructionAction(
|
||||
action: InstructionChange['action'],
|
||||
baseline: boolean,
|
||||
): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
|
||||
| 'message.context.instructions.added' | 'message.context.instructions.updated' {
|
||||
if (action === 'remove') return 'message.context.instructions.removed'
|
||||
if (baseline) return 'message.context.instructions.loaded'
|
||||
return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
|
||||
}
|
||||
|
||||
/**
|
||||
* `instructions` form: the files this context reconciled, then their text.
|
||||
*
|
||||
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
|
||||
* of what the model read, so hiding it would misreport the request.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The instructions context body, or the opaque body when the change
|
||||
* list is unreadable.
|
||||
*/
|
||||
export function InstructionsBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const changes = instructionChanges(source)
|
||||
if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
const baseline = asRecord(source)?.['baseline'] === true
|
||||
return (
|
||||
<>
|
||||
<ul className={css.files} data-context-files>
|
||||
{changes.map(change => (
|
||||
<li key={change.path} className={css.file} title={change.digest}>
|
||||
<span className={css.filePath}>{change.path}</span>
|
||||
<span className={css.fileAction}>
|
||||
{t(instructionAction(change.action, baseline))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ModelFacingContent content={content} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog entry, as the durable source records it. */
|
||||
interface CatalogEntry {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entries read off the source, or null when the record is not a usable
|
||||
* catalog. All-or-nothing for the same reason as the instruction list: this body
|
||||
* replaces the model-facing text, so a partial list would hide the only complete
|
||||
* account of what the model read.
|
||||
*/
|
||||
function catalogEntries(source: unknown): CatalogEntry[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['entries']
|
||||
if (!Array.isArray(list)) return null
|
||||
const entries: CatalogEntry[] = []
|
||||
for (const item of list as readonly unknown[]) {
|
||||
const entry = asRecord(item)
|
||||
if (entry === null) return null
|
||||
const name = entry['name']
|
||||
const description = entry['description']
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
|
||||
entries.push({ name, description })
|
||||
}
|
||||
// An empty list is a real catalog: a replacement with no entries retires
|
||||
// every earlier name. Only an unreadable shape falls back.
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* `catalog` form: the published entries as a list, read from the source rather
|
||||
* than re-parsed out of the model-facing prose.
|
||||
*
|
||||
* A catalog whose source carries no usable entries falls through to the opaque
|
||||
* body, so an older or hand-edited log still shows its text.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The catalog context body, or the opaque body when the entry list is
|
||||
* unreadable.
|
||||
*/
|
||||
export function CatalogBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const entries = catalogEntries(source)
|
||||
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
const update = asRecord(source)?.['update'] === true
|
||||
// Entry count is unbounded (a provider may publish any number of skills), and
|
||||
// the scrollport bounds height, not node count — so the list bounds itself.
|
||||
const shown = entries.slice(0, MAX_ENTRIES)
|
||||
const rest = unknownBlocks(content)
|
||||
return (
|
||||
<>
|
||||
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
|
||||
<ul className={css.entries} data-context-entries>
|
||||
{shown.map((entry, index) => (
|
||||
// Index key: a hand-edited or foreign log may repeat a name, and a
|
||||
// duplicate React key would drop a row the model did see.
|
||||
<li key={index} className={css.entry}>
|
||||
<code className={css.entryName}>{entry.name}</code>
|
||||
<span className={css.entryDescription}>{entry.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shown.length < entries.length && (
|
||||
<p className={css.catalogNotice} data-context-entries-truncated>
|
||||
{t('message.context.catalog.more', { count: entries.length - shown.length })}
|
||||
</p>
|
||||
)}
|
||||
{/* The block union is merge-extensible: a catalog message carrying an
|
||||
unknown block still shows it rather than dropping model-visible content. */}
|
||||
<UnknownBlocks blocks={rest} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One named contribution to a runtime snapshot, as the durable source records it. */
|
||||
interface SnapshotSection {
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Snapshot sections read off the source, or null when the record is unusable. */
|
||||
function snapshotSections(source: unknown): SnapshotSection[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['sections']
|
||||
if (!Array.isArray(list)) return null
|
||||
const sections: SnapshotSection[] = []
|
||||
for (const item of list as readonly unknown[]) {
|
||||
const section = asRecord(item)
|
||||
if (section === null) return null
|
||||
const name = section['name']
|
||||
const text = section['text']
|
||||
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
|
||||
sections.push({ name, text })
|
||||
}
|
||||
return sections.length === 0 ? null : sections
|
||||
}
|
||||
|
||||
/**
|
||||
* `snapshot` form: the named contributions this snapshot assembled, in order.
|
||||
*
|
||||
* The sections are the same bytes the model read, split at the boundaries the
|
||||
* producer assembled them on, so a reader sees which subsystem contributed
|
||||
* which state instead of one undifferentiated wall.
|
||||
*
|
||||
* One sentence of the model-facing text is NOT in any section: the producer's
|
||||
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
|
||||
* `<system-reminder>` wrapper an instruction context carries — which wraps
|
||||
* content and cannot be separated from it — that line states the form's own
|
||||
* semantics, so the body states them as a caption instead of reprinting the
|
||||
* joined prose beside the sections it was split from.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The snapshot context body, or the opaque body when unreadable.
|
||||
*/
|
||||
export function SnapshotBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const sections = snapshotSections(source)
|
||||
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
|
||||
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
return (
|
||||
<>
|
||||
<p className={css.catalogNotice} data-context-snapshot-supersedes>
|
||||
{t('message.context.snapshot.supersedes')}
|
||||
</p>
|
||||
<dl className={css.sections} data-context-sections>
|
||||
{sections.map((section, index) => (
|
||||
<div key={index} className={css.section}>
|
||||
<dt className={css.sectionName}>{section.name}</dt>
|
||||
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `notice` form: what just happened, with the model-facing text beneath it.
|
||||
*
|
||||
* The one-line account also rides the collapsed row ({@link contextBody}), so a
|
||||
* notice is usually readable without expanding at all.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The notice context body.
|
||||
*/
|
||||
export function NoticeBody({ content, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
return <ModelFacingContent content={content} t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
* `relay` form: which agent sent this, then what it said.
|
||||
*
|
||||
* The sender is an opaque session id; it is shown as provenance rather than a
|
||||
* label, because this client cannot resolve it to a title.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The relay context body.
|
||||
*/
|
||||
export function RelayBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const sender = relaySender(source)
|
||||
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
|
||||
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
return (
|
||||
<>
|
||||
<p className={css.relaySender} data-context-relay-sender>
|
||||
{t('message.context.relay.from', { session: sender })}
|
||||
</p>
|
||||
<ModelFacingContent content={content} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The sending agent's session id, or null when the record does not name one. */
|
||||
function relaySender(source: unknown): string | null {
|
||||
const sender = asRecord(source)?.['senderSessionId']
|
||||
return typeof sender === 'string' && sender !== '' ? sender : null
|
||||
}
|
||||
|
||||
/** One recalled session, as the durable source records it. */
|
||||
interface RecalledSession {
|
||||
label: string
|
||||
retained: number
|
||||
omitted: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Recalled sessions read off the source, or null when the record is unusable. */
|
||||
function recalledSessions(source: unknown): RecalledSession[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['references']
|
||||
if (!Array.isArray(list)) return null
|
||||
const sessions: RecalledSession[] = []
|
||||
for (const item of list as readonly unknown[]) {
|
||||
const reference = asRecord(item)
|
||||
if (reference === null) return null
|
||||
const label = reference['label']
|
||||
const retained = reference['retainedMessages']
|
||||
const omitted = reference['omittedMessages']
|
||||
const truncated = reference['truncated']
|
||||
// Completeness is the fact this card exists to report, so a reference that
|
||||
// cannot state it is not a readable recall — showing the label alone would
|
||||
// present a confident card over unknown loss.
|
||||
if (typeof label !== 'string' || label === ''
|
||||
|| typeof retained !== 'number' || typeof omitted !== 'number'
|
||||
|| typeof truncated !== 'boolean') return null
|
||||
sessions.push({ label, retained, omitted, truncated })
|
||||
}
|
||||
return sessions.length === 0 ? null : sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* `recall` form: which sessions this material came from and how much of each
|
||||
* survived the read, then the material itself.
|
||||
*
|
||||
* Completeness is the fact a reader needs first: recalled context is bounded on
|
||||
* the way in, so a card that hid the omitted count would overstate what the
|
||||
* model received.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The recall context body, or the opaque body when unreadable.
|
||||
*/
|
||||
export function RecallBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
source: unknown
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const sessions = recalledSessions(source)
|
||||
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
return (
|
||||
<>
|
||||
<ul className={css.recalls} data-context-recalls>
|
||||
{sessions.map((session, index) => (
|
||||
<li key={index} className={css.recall}>
|
||||
<span className={css.recallLabel}>{session.label}</span>
|
||||
<span className={css.recallCounts}>
|
||||
{t('message.context.recall.counts', {
|
||||
retained: session.retained,
|
||||
omitted: session.omitted,
|
||||
})}
|
||||
</span>
|
||||
{session.truncated && (
|
||||
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ModelFacingContent content={content} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
|
||||
function noticeSummary(source: unknown): string | null {
|
||||
const summary = asRecord(source)?.['summary']
|
||||
return typeof summary === 'string' && summary !== '' ? summary : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the body for one context node.
|
||||
*
|
||||
* Returns the form the body actually rendered as, which is not always the
|
||||
* declared one: a declared form whose fields are unreadable falls back to
|
||||
* opaque, and the caller labels the row with what it really shows.
|
||||
* `summary` is the collapsed row's one-line account, which only a `notice`
|
||||
* records: its whole point is being readable without expanding.
|
||||
* @param form - the producer-declared form projected onto the node.
|
||||
* @param props - durable content, its source, and the locale seat.
|
||||
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
|
||||
*/
|
||||
export function contextBody(
|
||||
form: ContextMessageNode['form'],
|
||||
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
|
||||
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
|
||||
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
|
||||
switch (form) {
|
||||
case 'instructions':
|
||||
return instructionChanges(props.source) === null
|
||||
? opaque
|
||||
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
|
||||
case 'catalog':
|
||||
return catalogEntries(props.source) === null
|
||||
? opaque
|
||||
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
|
||||
case 'snapshot':
|
||||
return snapshotSections(props.source) === null
|
||||
? opaque
|
||||
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
|
||||
case 'notice': {
|
||||
const summary = noticeSummary(props.source)
|
||||
return summary === null
|
||||
? opaque
|
||||
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
|
||||
}
|
||||
case 'relay':
|
||||
return relaySender(props.source) === null
|
||||
? opaque
|
||||
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
|
||||
case 'recall':
|
||||
return recalledSessions(props.source) === null
|
||||
? opaque
|
||||
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
|
||||
case null:
|
||||
return opaque
|
||||
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
|
||||
KnownContextForm here rather than letting it degrade to opaque silently. */
|
||||
default: {
|
||||
const unreachable: never = form
|
||||
throw new Error(`unreachable context form: ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,40 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Separator and producer name beside the role title: ToolRow's summary geometry,
|
||||
so the two disclosure rows keep one 24px rhythm and one separator shape. */
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.source {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A notice's one-line account: the reason it rarely needs expanding. */
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 22px);
|
||||
@@ -23,7 +57,6 @@
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
|
||||
font: 400 11px/16px var(--ds-font-family-code);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,70 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import { contextBody } from './ContextBody.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
function inlineJson(payload: unknown): string {
|
||||
const raw = JSON.stringify(payload)
|
||||
let formatted = ''
|
||||
let quoted = false
|
||||
let escaped = false
|
||||
|
||||
for (let index = 0; index < raw.length; index++) {
|
||||
const char = raw.charAt(index)
|
||||
if (quoted) {
|
||||
formatted += char
|
||||
if (escaped) escaped = false
|
||||
else if (char === '\\') escaped = true
|
||||
else if (char === '"') quoted = false
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = true
|
||||
formatted += char
|
||||
continue
|
||||
}
|
||||
if (char === '{' || char === '[') {
|
||||
formatted += char
|
||||
const close = char === '{' ? '}' : ']'
|
||||
if (raw[index + 1] !== close) formatted += ' '
|
||||
continue
|
||||
}
|
||||
if (char === '}' || char === ']') {
|
||||
const open = char === '}' ? '{' : '['
|
||||
if (raw[index - 1] !== open) formatted += ' '
|
||||
formatted += char
|
||||
continue
|
||||
}
|
||||
formatted += char === ':' || char === ',' ? `${char} ` : char
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
/** Props for the logged non-user message presentation. */
|
||||
export interface ContextInjectionRowProps {
|
||||
content: ContextMessageNode['content']
|
||||
source: ContextMessageNode['source']
|
||||
/** Role and producer name projected from the durable source. */
|
||||
provenance: ContextMessageNode['provenance']
|
||||
/** Producer-declared information form; null renders the opaque body. */
|
||||
form: ContextMessageNode['form']
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
* Render logged context with the Tool calls disclosure chrome from Figma.
|
||||
* @param props - Durable content and source provenance.
|
||||
* @returns A collapsed context row with a bounded JSON body.
|
||||
*
|
||||
* The header names the role the context plays and, beside it, the producer the
|
||||
* durable source identifies, so a reader can tell an injected skill catalog
|
||||
* from a workspace instruction file or a recalled session without expanding.
|
||||
* The expanded body follows the producer-declared form; an absent or unknown
|
||||
* form renders the opaque body.
|
||||
* @param props - Durable content, its projected provenance and form, and the locale seat.
|
||||
* @returns A collapsed context row with a bounded, form-specific body.
|
||||
*/
|
||||
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
|
||||
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const body = useMemo(() => {
|
||||
if (!open) return ''
|
||||
const text = inlineJson({ content, source })
|
||||
return text.length > MAX_CHARS
|
||||
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
|
||||
: text
|
||||
}, [content, open, source, t])
|
||||
// Resolved rather than declared: a form whose fields are unreadable renders
|
||||
// the opaque body, and the marker must say what the row actually shows.
|
||||
const { rendered, summary, body } = contextBody(form, { content, source, t })
|
||||
|
||||
return (
|
||||
<DisclosureRow
|
||||
className={css.root}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
chevronClassName={css.chevron}
|
||||
title={t('message.contextInjection')}
|
||||
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
|
||||
collapsedContent={provenance.label === null ? undefined : (
|
||||
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
|
||||
stays the two readable parts and the two disclosure rows expose one
|
||||
name shape. A source that names no producer drops the dot with it. */
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.source} data-context-source>{provenance.label}</span>
|
||||
{summary !== null && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary} data-context-summary>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
keepContentWhenOpen
|
||||
open={open}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<pre className={css.body} data-context-injection-body>{body}</pre>
|
||||
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
|
||||
{body}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Steering caption above the bubble: mid-turn interjections carry the same
|
||||
bubble as a turn-opening prompt, so the transcript names which one this is. */
|
||||
.steeringMark {
|
||||
padding-right: 4px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
|
||||
max-width: min(525px, 82%);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// MessageItem: simple chat nodes — user bubbles
|
||||
// (right-aligned, with clock + copy / branch IconActions), pending steering
|
||||
// (copy only), context injection, compaction marker, retry disclosure, and
|
||||
// unknown-surface JSON rows.
|
||||
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
||||
// (right-aligned, with clock + copy / branch IconActions; steering adds the
|
||||
// interjection caption that names it), pending steering (caption + copy only),
|
||||
// context injection, compaction marker, retry disclosure, and unknown-surface
|
||||
// JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode,
|
||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
|
||||
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -19,6 +20,7 @@ import css from './MessageItem.module.css'
|
||||
export interface MessageItemProps {
|
||||
node:
|
||||
| UserMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| CompactionSummaryNode
|
||||
| ModelRetryNode
|
||||
@@ -170,19 +172,22 @@ function projectUserText(text: string): ReactNode {
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, actions, pending = false, t,
|
||||
content, actions, pending = false, steering = false, t,
|
||||
}: {
|
||||
content: readonly unknown[]
|
||||
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
|
||||
actions?: (text: string) => ReactNode
|
||||
/** Whether this is the Host-authoritative pre-admission steering projection. */
|
||||
pending?: boolean
|
||||
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
|
||||
steering?: boolean
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
const { text, rest } = contentText(content)
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
@@ -206,6 +211,7 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
<UserStyleBubble
|
||||
content={content}
|
||||
pending
|
||||
steering
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
@@ -226,9 +232,11 @@ export const MessageItem = memo(function MessageItem({
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={node.content}
|
||||
steering={node.kind === 'steering'}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
@@ -245,7 +253,13 @@ export const MessageItem = memo(function MessageItem({
|
||||
)
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
<ContextInjectionRow
|
||||
content={node.content}
|
||||
source={node.source}
|
||||
provenance={node.provenance}
|
||||
form={node.form}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
case 'compaction':
|
||||
return <CompactionItem node={node} t={t} />
|
||||
|
||||
@@ -86,7 +86,7 @@ export function messageBranchSeqs(
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'user'
|
||||
if (tail?.kind === 'user' || tail?.kind === 'steering'
|
||||
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const zh = {
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
'details.title': '详情',
|
||||
@@ -66,6 +67,18 @@ export const zh = {
|
||||
'chat.toBottom': '回到底部',
|
||||
'message.extraBlock': '附加内容块',
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.contextRecall': '跨会话召回',
|
||||
'message.context.instructions.loaded': '已载入',
|
||||
'message.context.instructions.added': '已新增',
|
||||
'message.context.instructions.updated': '已更新',
|
||||
'message.context.instructions.removed': '已移除',
|
||||
'message.context.catalog.replaced': '替换目录',
|
||||
'message.context.catalog.more': '…还有 {count} 条',
|
||||
'message.context.snapshot.supersedes': '取代先前的快照',
|
||||
'message.context.relay.from': '来自会话 {session}',
|
||||
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
@@ -172,6 +185,7 @@ export const en = {
|
||||
'access.confirm.cancel': 'Cancel',
|
||||
'access.confirm.enable': 'Enable Full access',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.preview': 'Preview',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
'details.title': 'Details',
|
||||
@@ -193,6 +207,18 @@ export const en = {
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'message.extraBlock': 'Extra content block',
|
||||
'message.contextInjection': 'Context injection',
|
||||
'message.contextRecall': 'Session recall',
|
||||
'message.context.instructions.loaded': 'loaded',
|
||||
'message.context.instructions.added': 'added',
|
||||
'message.context.instructions.updated': 'updated',
|
||||
'message.context.instructions.removed': 'removed',
|
||||
'message.context.catalog.replaced': 'Replacement catalog',
|
||||
'message.context.catalog.more': '… {count} more',
|
||||
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
|
||||
'message.context.relay.from': 'From session {session}',
|
||||
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
|
||||
@@ -119,7 +119,8 @@ export function HeroShell({ t, children }: HeroShellProps) {
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
{t('hero.headline')}
|
||||
<span className={css.headlineText}>{t('hero.headline')}</span>
|
||||
<span className={css.previewBadge}>{t('hero.preview')}</span>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
|
||||
|
||||
@@ -23,21 +23,44 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
|
||||
badge is a product addition outside that source and aligns to the title. */
|
||||
.headline {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 34px auto;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.headlineText {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* figma fish fill rides business blue. */
|
||||
.fish {
|
||||
flex: none;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,47 +212,504 @@ describe('MessageItem arms', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const fork = vi.fn()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
onFork={fork}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||||
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(fork).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
content: [{ type: 'text', text: 'line one\n\nline two' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
provenance: { role: 'inject', label: 'fixture' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
// An unknown form renders the opaque body: the model-facing text keeps its
|
||||
// real line breaks instead of being escaped into one JSON line, and the
|
||||
// remaining provenance follows it as fields.
|
||||
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toBe('line one\n\nline two')
|
||||
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'empty', 'list'])
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
it('the instructions form names the files it reconciled above their text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
changes: [
|
||||
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
|
||||
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
|
||||
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
|
||||
// The `<system-reminder>` framing is part of what the model read, so the
|
||||
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent)
|
||||
.toContain('<system-reminder>')
|
||||
})
|
||||
|
||||
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'delta' }],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
changes: [
|
||||
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
|
||||
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
|
||||
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
|
||||
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
|
||||
})
|
||||
|
||||
it('keeps an interleaved unknown block in the order the model received it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'future-block', payload: 1 },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
|
||||
expect(texts).toEqual(['before', 'after'])
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
|
||||
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
|
||||
expect(view.container.querySelector('[data-context-text]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a replacement catalog says so above its entries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
})
|
||||
|
||||
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
|
||||
// All-or-nothing: a body that replaces the model-facing text must not show
|
||||
// a confident, incomplete account of what the model read.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
// The marker reports what rendered, not what was declared.
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
|
||||
// No invented separator: showing a line break the model never saw would
|
||||
// misreport the request.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('bounds an oversized provenance field, not only the model-facing text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'short' }],
|
||||
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
|
||||
// `renderCatalogUpdate` legitimately publishes zero entries when the last
|
||||
// skill disappears; falling back would hide that the catalog was cleared.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBe('catalog')
|
||||
})
|
||||
|
||||
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
|
||||
})
|
||||
|
||||
it('bounds a large catalog and says how many rows it withheld', () => {
|
||||
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
|
||||
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
|
||||
})
|
||||
|
||||
it('a catalog keeps a content block this version does not know', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
|
||||
provenance: { role: 'inject', label: 'skill-catalog' },
|
||||
form: 'catalog',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an instruction change with an unrecognized action falls back whole', () => {
|
||||
// The action decides the word the row shows, so an unknown one cannot be
|
||||
// presented as loaded or updated.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
})
|
||||
|
||||
it('the opaque fallback keeps a form declaration this version cannot present', () => {
|
||||
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
|
||||
provenance: { role: 'inject', label: 'later' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
|
||||
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
|
||||
expect(fields).toEqual(['plugin', 'form'])
|
||||
})
|
||||
|
||||
it('the snapshot form attributes each part to the subsystem that produced it', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: '@deepseek-ai/dsh-system-prompt',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
|
||||
},
|
||||
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
|
||||
})
|
||||
|
||||
it('a notice puts its account on the collapsed row', () => {
|
||||
// The whole point of the form: readable without expanding.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')?.textContent)
|
||||
.toBe('bash pnpm test [status: completed]')
|
||||
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a notice without its account falls back to the opaque body', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
|
||||
provenance: { role: 'inject', label: 'tool-tasks' },
|
||||
form: 'notice',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('each form falls back to the opaque body when its required facts are unreadable', () => {
|
||||
// The fallback chain is the load-bearing wall: every dedicated form must
|
||||
// reach it, and the row marker must not claim a form that did not render.
|
||||
const cases = [
|
||||
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
|
||||
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
|
||||
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
|
||||
] as const
|
||||
for (const { form, source, label } of cases) {
|
||||
cleanup()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
|
||||
source, provenance: { role: 'inject', label }, form,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('a snapshot states the supersession its framing line carries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
|
||||
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: 'snapshot',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
|
||||
.toBe('取代先前的快照')
|
||||
})
|
||||
|
||||
it('a relay names the agent that sent it above what it said', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'child report body' }],
|
||||
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
|
||||
provenance: { role: 'inject', label: 'subagent-report' },
|
||||
form: 'relay',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
|
||||
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
|
||||
})
|
||||
|
||||
it('a recall reports how much of each source session survived the read', () => {
|
||||
// Recalled context is bounded on the way in, so hiding the omitted count
|
||||
// would overstate what the model received.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'recalled material' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
form: 'recall',
|
||||
version: 1,
|
||||
references: [
|
||||
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
|
||||
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
|
||||
form: 'recall',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
|
||||
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
|
||||
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
|
||||
@@ -376,6 +376,9 @@ describe('ChatView', () => {
|
||||
expect(view.queryByText('later')).toBeNull()
|
||||
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
|
||||
expect(pendingBubble).not.toBeNull()
|
||||
// Pending and durable steering carry the same interjection caption, so the
|
||||
// hand-off does not change what the row says it is.
|
||||
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
@@ -388,7 +391,8 @@ describe('ChatView', () => {
|
||||
nodes: [
|
||||
assistant(1, 'working'),
|
||||
{
|
||||
kind: 'user', seq: 2, time: 2_000,
|
||||
kind: 'steering', messageId: pending.messageId,
|
||||
seq: 2, time: 2_000,
|
||||
content: [{ type: 'text', text: 'interrupt now' }], source: null,
|
||||
},
|
||||
],
|
||||
@@ -396,6 +400,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
|
||||
@@ -441,6 +446,8 @@ describe('ChatView', () => {
|
||||
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
|
||||
const context = {
|
||||
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
@@ -12,12 +12,14 @@ import type {
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type {
|
||||
@@ -213,6 +215,14 @@ function mount(
|
||||
}
|
||||
}
|
||||
|
||||
describe('Hero chrome', () => {
|
||||
it('renders the English preview badge through the hero locale seat', () => {
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
|
||||
expect(view.getByText('Let\'s start building')).toBeTruthy()
|
||||
expect(view.getByText('Preview')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
@@ -273,6 +283,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
|
||||
@@ -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: fdbd758e81e9631bf607797bfe2c2385c2b89ac6
|
||||
README.zh.md: 44498d728b4e292c717fa59c466a261b69cfa24b
|
||||
README.md: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
|
||||
README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4
|
||||
|
||||
@@ -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. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `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 its localized confirmation dialog names the provider in the title, description, and final action.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's 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 its localized confirmation dialog names the provider in the title, description, and final action.
|
||||
|
||||
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 settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
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.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **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.
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
|
||||
前序首次使用引导页面完成后,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、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`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 的名字为键。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal file
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal file
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal file
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -295,11 +295,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,
|
||||
@@ -603,3 +618,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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,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 { deriveKeyRef, messageOf } from './store.ts'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -28,7 +29,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
|
||||
}
|
||||
@@ -151,10 +152,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
@@ -201,6 +204,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']}>
|
||||
@@ -275,6 +282,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// 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)
|
||||
}}
|
||||
@@ -354,25 +365,64 @@ 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
|
||||
setSavedTarget(undefined)
|
||||
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={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
setSavedTarget(undefined)
|
||||
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={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
|
||||
@@ -24,6 +24,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'
|
||||
@@ -58,8 +60,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). */
|
||||
@@ -172,6 +174,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
|
||||
@@ -188,10 +206,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
? 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 */
|
||||
@@ -282,6 +305,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
: keyState?.configured === true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** 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']}>
|
||||
@@ -333,22 +367,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>
|
||||
</>
|
||||
@@ -371,24 +403,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,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',
|
||||
@@ -127,6 +150,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: '前往配置',
|
||||
|
||||
@@ -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
|
||||
|
||||
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal file
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 03e7e3649fd0913fb48579aa87634153f67f5baf
|
||||
README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43
|
||||
README.md: 385730c94831d2fd4af83f9eca0f55941551c796
|
||||
README.zh.md: b8a75dbffc6549f6294dfda5988c67d6569386c9
|
||||
|
||||
@@ -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{}`. 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. `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.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`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。
|
||||
|
||||
@@ -21,23 +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": {
|
||||
|
||||
@@ -1,155 +1,164 @@
|
||||
import { isValidElement, useMemo } 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 { 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]
|
||||
const settledRemarkPlugins = [
|
||||
remarkGfm,
|
||||
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 ''
|
||||
/** 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 blocks = wrapBlockChildren(
|
||||
renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
|
||||
false,
|
||||
)
|
||||
const section = renderFootnoteSection(context)
|
||||
return section === null ? blocks : [...blocks, '\n', section]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
/** 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 }) => {
|
||||
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>
|
||||
)
|
||||
},
|
||||
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 default <code> path (the :not(pre)
|
||||
// rule styles it). 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, 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>
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Let asterisk strong emphasis close after punctuation when CJK prose continues without whitespace. */
|
||||
|
||||
import { attention } from 'micromark-core-commonmark'
|
||||
import { unicodePunctuation } from 'micromark-util-character'
|
||||
import { classifyCharacter } from 'micromark-util-classify-character'
|
||||
import { codes, constants } from 'micromark-util-symbol'
|
||||
import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types'
|
||||
|
||||
const cjkCharacter = new RegExp([
|
||||
'\\p{Script_Extensions=Han}',
|
||||
'\\p{Script_Extensions=Hiragana}',
|
||||
'\\p{Script_Extensions=Katakana}',
|
||||
'\\p{Script_Extensions=Hangul}',
|
||||
'\\p{Script_Extensions=Bopomofo}',
|
||||
].join('|'), 'u')
|
||||
|
||||
function isCjkCharacter(code: number | null): boolean {
|
||||
return code !== null && code >= 0 && cjkCharacter.test(String.fromCodePoint(code))
|
||||
}
|
||||
|
||||
const tokenizeCjkFriendlyAttention: Tokenizer = function (effects, ok, nok) {
|
||||
const configuredAttentionMarkers = this.parser.constructs.attentionMarkers.null
|
||||
if (configuredAttentionMarkers === undefined) {
|
||||
throw new Error('micromark CommonMark attention markers are unavailable')
|
||||
}
|
||||
const attentionMarkers = configuredAttentionMarkers
|
||||
const previous = this.previous
|
||||
const before = classifyCharacter(previous)
|
||||
let marker: number | null = codes.eof
|
||||
|
||||
return start
|
||||
|
||||
function start(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- this text construct is dispatched only for an asterisk. */
|
||||
if (code !== codes.asterisk) return nok(code)
|
||||
marker = code
|
||||
effects.enter('attentionSequence')
|
||||
return inside(code)
|
||||
}
|
||||
|
||||
function inside(code: number | null): State | undefined {
|
||||
if (code === marker) {
|
||||
effects.consume(code)
|
||||
return inside
|
||||
}
|
||||
|
||||
const token = effects.exit('attentionSequence')
|
||||
const after = classifyCharacter(code)
|
||||
const open = !after || (after === constants.characterGroupPunctuation && Boolean(before))
|
||||
|| attentionMarkers.includes(code)
|
||||
const commonMarkClose = !before
|
||||
|| (before === constants.characterGroupPunctuation && Boolean(after))
|
||||
|| attentionMarkers.includes(previous)
|
||||
const markerCount = token.end.offset - token.start.offset
|
||||
const cjkStrongClose = markerCount >= 2
|
||||
&& unicodePunctuation(previous)
|
||||
&& isCjkCharacter(code)
|
||||
const close = commonMarkClose || cjkStrongClose
|
||||
|
||||
token._open = open
|
||||
token._close = close
|
||||
return ok(code)
|
||||
}
|
||||
}
|
||||
|
||||
const cjkFriendlyAttention: Construct = {
|
||||
name: 'cjkFriendlyAttention',
|
||||
resolveAll: attention.resolveAll,
|
||||
tokenize: tokenizeCjkFriendlyAttention,
|
||||
}
|
||||
|
||||
const cjkFriendlyStrongExtension: Extension = {
|
||||
text: { [codes.asterisk]: cjkFriendlyAttention },
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK
|
||||
* prose, as a micromark syntax extension for `fromMarkdown`.
|
||||
* @returns The micromark syntax extension.
|
||||
*/
|
||||
export function cjkFriendlyStrong(): Extension {
|
||||
return cjkFriendlyStrongExtension
|
||||
}
|
||||
130
packages/client/ui-primitives/src/markdown/incremental.ts
Normal file
130
packages/client/ui-primitives/src/markdown/incremental.ts
Normal 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
|
||||
}
|
||||
}
|
||||
90
packages/client/ui-primitives/src/markdown/katex.tsx
Normal file
90
packages/client/ui-primitives/src/markdown/katex.tsx
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
44
packages/client/ui-primitives/src/markdown/parse.ts
Normal file
44
packages/client/ui-primitives/src/markdown/parse.ts
Normal 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()],
|
||||
})
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
544
packages/client/ui-primitives/src/markdown/render.tsx
Normal file
544
packages/client/ui-primitives/src/markdown/render.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
vendored
Normal 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"
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
vendored
Normal 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"
|
||||
@@ -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 "。"
|
||||
@@ -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 "。"
|
||||
78
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
vendored
Normal file
78
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
vendored
Normal 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"
|
||||
53
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
vendored
Normal file
53
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
vendored
Normal 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"
|
||||
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt
vendored
Normal file
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<div class="_markdown_404681">
|
||||
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt
vendored
Normal file
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<div class="_markdown_404681">
|
||||
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt
vendored
Normal file
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."
|
||||
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt
vendored
Normal file
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."
|
||||
37
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
vendored
Normal file
37
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
vendored
Normal 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"
|
||||
@@ -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"
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt
vendored
Normal 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. ↩"
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt
vendored
Normal 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. ↩"
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt
vendored
Normal 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"
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt
vendored
Normal 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"
|
||||
15
packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt
vendored
Normal file
15
packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt
vendored
Normal 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"
|
||||
@@ -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"
|
||||
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt
vendored
Normal file
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt
vendored
Normal 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"
|
||||
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt
vendored
Normal file
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt
vendored
Normal 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"
|
||||
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt
vendored
Normal file
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt
vendored
Normal 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">
|
||||
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt
vendored
Normal file
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt
vendored
Normal 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">
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Spans "
|
||||
<code>
|
||||
#text "a b"
|
||||
#text " across a line."
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Spans "
|
||||
<code>
|
||||
#text "a b"
|
||||
#text " across a line."
|
||||
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt
vendored
Normal file
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt
vendored
Normal 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)"
|
||||
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt
vendored
Normal file
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt
vendored
Normal 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)"
|
||||
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
vendored
Normal file
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
vendored
Normal 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"
|
||||
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
vendored
Normal file
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
vendored
Normal 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"
|
||||
125
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt
vendored
Normal file
125
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt
vendored
Normal 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>
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
vendored
Normal 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}"
|
||||
320
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt
vendored
Normal file
320
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt
vendored
Normal 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">
|
||||
@@ -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}]"
|
||||
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt
vendored
Normal file
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt
vendored
Normal 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"
|
||||
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt
vendored
Normal file
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt
vendored
Normal 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"
|
||||
16
packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt
vendored
Normal file
16
packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt
vendored
Normal 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">
|
||||
@@ -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">
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="_markdown_404681">
|
||||
<h2>
|
||||
#text "Streaming"
|
||||
<ul>
|
||||
<li>
|
||||
#text "first"
|
||||
<li>
|
||||
#text "**unfinished"
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="_markdown_404681">
|
||||
<h2>
|
||||
#text "Streaming"
|
||||
<ul>
|
||||
<li>
|
||||
#text "first"
|
||||
<li>
|
||||
#text "**unfinished"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user