refactor(web): transform user text through business node slots

This commit is contained in:
imccyu committed 2026-10-10 22:43:54 +08:00
1 parent b52864330d
commit 1301b26846
54 files changed
+2937 -248

No files matched your search

@@ -0,0 +1,123 @@
# Agent Note: 用户文本线性变换、Facts 依赖与业务节点渲染
Status: implemented
## 问题
用户文本包含普通文字和不同业务的结构化引用。业务需要独立扩展识别逻辑、JSON 数据与显示方式;某个事实字段更新时,无关业务不能重复匹配。处理结果必须确定,且文本处理、缓存和依赖记录不能与 React 组件生命周期耦合。
## 决策
采用“业务注册 → 线性分段变换 → 纯 JSON 节点 → keyed Slot → 业务 Slot 自身 Injection”。现有 UserText 注册机制负责组成流水线,数据层运行器负责求值与增量更新,React 只显示最终节点。
### 线性流水线
非空原文初始化为一个覆盖 `[0, text.length)` 的 Text 分段;空原文对应空数组。注册项仅按 `order` 升序、同序号按稳定注册 ID 排列,不依赖插件加载时机,也不按处理类别分组。每个阶段对前一阶段的结果执行一次同步 `transform`,不另设多轮 pass 或重复执行至稳定点。规则的先后关系由其注册顺序值明确表达。
每一层接收当前完整的只读分段数组。不属于它处理的分段保持原引用;需要变化的部分产生新对象。文本识别针对当前可处理的 Text 分段进行,已形成的业务节点保持原子性并传递到后面。不能让所有规则独立匹配初始全文,再靠全局候选排序代替逐层变换。
原文不被修改,输出保持原始 UTF-16 坐标。每层输出有序、非重叠并覆盖相同原文范围;替换节点保留它消费的范围。业务不能把被结构化节点隔开的文字重新拼成相邻文本,也不能扫描前一层的 JSX 或显示标签。
### Transform 与读取 Context
注册项包含唯一注册 ID、顺序和变换函数。新增业务节点使用该规则 ID;仅加工 Text 显示值的规则不需要对应的业务节点或 renderer。所有规则使用同一套原文覆盖和业务节点保护约束。核心接口为:
```ts
interface UserTextTransformContext {
read<K extends keyof UserTextFacts>(
key: K,
): UserTextFacts[K] | undefined
}
interface UserTextRule<Id extends string = string> {
readonly id: Id
readonly order: number
transform(
segments: readonly UserTextNode[],
context: UserTextTransformContext,
): readonly UserTextNode[]
}
```
Context 是本次同步求值的事实读取器和依赖记录器,不是 Cordis Context、React Context 或服务定位器。它只提供只读 Facts 字段,不提供打开文件、导航、附件加载等动作,也不能保留到本次求值之外使用。
业务不直接获得完整 Facts 对象,不维护独立的 `dependsOn` 声明,也不接收 `previous` 或缓存对象。匹配、正则、查表等是各业务变换函数内部的实现方式,不是公共运行器的业务分支。
### 实际读取依赖
运行器为每次阶段求值创建读取记录。`context.read(key)` 返回本次 Facts 快照中的值,同时记录该字段及其版本或值引用。读取结果为 `undefined` 也建立依赖,字段以后出现时能够重新触发计算。
每次执行重新收集实际读取集合,替换旧记录,不永久累积已经不再读取的字段。业务可以在不相关的输入上提前返回;未执行 `read('skillNames')` 的阶段不因该字段变化而失效。
依赖粒度是 Facts 字段。读取 `skillNames` 表示依赖这个字段,不自动追踪数组中的单个元素。字段以不可变数据更新;外层 Facts 对象换引用不等于全部字段变化。
### 增量更新与缓存
运行器为每份文档、每个阶段保存输入分段、实际读取的字段及其版本、规则版本和输出分段,并维护“Facts 字段 → 读取它的阶段”的反向索引。线性顺序已经表达上游关系,不建立任意形状的通用依赖图。
Facts 更新时,只将实际读取了变化字段的阶段标记为失效。运行器按流水线顺序执行:阶段自身依赖变化、规则变化或上游输出变化时才重算;否则直接复用缓存。某层输出不变时,停止由它引起的下游失效,但后面直接读取了其他变化字段的阶段仍然执行。
原文变化使全部阶段重新计算,从单一初始 Text 分段开始,不要求字符级增量解析。规则增加、删除、重排或实现更新时,从最早受影响的阶段重新求值;此前未受影响的缓存可以复用。
缓存由文档所属的数据层求值实例拥有。注册表创建文档求值回调但不保存该回调或文档,也不为文档增加注册表订阅;显示绑定释放回调后,文档缓存随之释放。不同文本实例不共享计算器,不按原文字符串跨 Session 共享缓存。未挂载历史内容不需要为显示规则更新重新读取 Session 日志。
### 引用复用与确定性
输入数组、节点和 Facts 均只读。禁止原地 `splice` 输入、修改节点 payload 或通过保留引用写入后续结果。不变的输入节点继续复用;结果完全不变时保留数组引用,不以全量深拷贝作为隔离方法。
运行器内部保存上一轮输出,用于对齐重算结果。业务产生的新节点与旧节点在 ID、原文范围及 JSON 数据上等价时,可以复用旧对象;全部结果元素相同时复用旧数组。比较只发生在需要重算的阶段,先使用引用相等的快速路径,不在每次刷新时序列化整条流水线。
旧结果只用于运行器复用对象,不参与业务判断。相同原文、规则顺序和 Facts 快照,无论首次计算、重复计算还是增量更新,必须得到相同的 JSON 内容。
### Facts 与显示 Props
文档运行器只接收 `{ text, facts }`。Facts 只保存 transform 实际需要的业务上下文和处理参数,通过 `Context.read` 消费;不因为某个值属于配置就把它放入 Facts。UserText 的交互、CSS 省略等纯显示行为使用普通 React Props,运行器不读取它们。核心不按正文、队列或 Goal 等场景名称分支。
节点外壳为 `{ id, start, end, data }`。`id` 是业务类型,对应节点 Slot 的 key;`start`、`end` 表示原文的非空半开 UTF-16 范围;`data` 是该业务的 JSON payload。Text 的 `data.text` 始终保留原文,`data.display` 是可选显示文字。普通 transform 可以写入显示值或进行合法 Text 分段变换,但不能改变原文、破坏覆盖或消费已有业务节点。节点实例身份由文档内的业务 ID 与原文位置表达,不能只用业务 ID 或数组下标。
节点和 Facts 不包含函数、ReactNode、服务句柄、DOM、observable、Date 或 Map。`UserTextDataMap` 与 `UserTextFacts` 是公开 `/client` 入口上的可扩展声明实体,内部类型引用同一实体;业务声明合并必须保留在发布的类型文件中。JSON 兼容性由类型约束与单测保证,可信同进程输入不逐次做整树序列化校验。
业务 Facts 由调用者提供,不是运行器自行查询的全局状态:skill 使用已确认的名字,会话引用使用关联标签,命令使用真实执行范围,附件使用明确范围和资源引用。普通文件路径可以只依据文本识别;附件显示名不等于文件路径,不据此猜 attachmentId。
`stripEmoji`、`collapseWhitespace`、`trimWhitespace` 分别控制三个文本显示变换,缺省均不启用。每条规则只读取自己的开关并使用前一层的显示结果;emoji 按完整字素处理,空白折叠跨连续 Text 分段延续但不跨业务节点,trim 只处理整份文档两端而不删除引用之间的正常空格。内置规则用普通 `order` 值将这些加工安排在既有引用识别之后,保留引用内部字符和身份;不存在额外的类别优先级。
`interactive` 和 CSS `ellipsis` 由 React Props 传递,不属于 Facts。省略不删除原文或限制传入字符数。队列用 Facts 启用空白折叠与 trim,用显示 Props 启用省略;消息正文与 Goal 保持完整展示。调用者不再保留 `previewOf`、200 字符预截断或未知块占位拼接,非文本附件继续走既有附件展示。
引用摘要从 `UserContentData.referenceLabels` 获取显示数据,不读取 Facts。会话引用变换仍通过 `Context.read('sessionLabels')` 获取识别输入。调用者把同一标签数组按引用分别传给这两个消费者,不复制数组,也不建立第二份状态。
### 渲染与交互所有权
`conversation.user.text` 是 session 作用域 Factory,独立拥有 session/keyed 的 `conversation.user.text.node`。UserText 顺序遍历最终节点,以节点 ID 选择 renderer,不包含 skill、file 或 attachment 的集中渲染分支。默认条目使用 `priority: 100`,外部业务按既有 Slot 机制覆盖对应 key。
`text` 和会话标签由 `ui-conversation` 提供,skill 由 `ui-skill` 提供,普通文件及目录引用由 `ui-reference` 提供,明确附件引用由 `ui-attachment` 提供,Goal 命令标记由 `ui-goal` 提供。业务自己的 Slot 注册声明操作依赖;解析数据不携带回调,上层布局不转交这些节点专用动作。
显示侧将 `interactive` Prop 连同原文片段 `raw` 传给节点 renderer;这些都是普通数据,不是执行能力。只有 `interactive` 为 true 时允许显示引用操作。队列、提交回显及待接收输入保持不可点击;会话、目录和命令默认仍为标签。文件附件保持名称和大小呈现,图片读取在显示层按用户操作发生,继续使用既有 Session 授权缓存。
文件导航失败由文件引用业务处理,不回到 Chat 动作聚合层。局部 Modal 提供重试和关闭;同步导航不增加请求序号或虚构 abort。文件读取错误仍由文档预览处理,Sidebar 继续使用现有公共定位语义。Assistant Markdown 的既有反馈与组件分工独立保留。
行内节点 Slot 使用 `inline: true` 的 span 锚点及行内错误占位,保留 `data-slot` 和 `display: contents`;其他 Slot 默认 DOM 不变。文字选择、键盘激活、双击不误打开、换行和队列省略策略保留。这里不引入业务 Context、React 变换阶段或通用注入继承。
### 发布与失败
只有完整成功的求值结果可以发布。失败不发布部分分段,显示层保留原文并报告业务 ID;失败执行中已读取的字段仍用于后续重试依赖。缺少节点 renderer 时回退到该节点的原文片段,组件异常使用框架既有隔离机制。
React 通过现有框架获知规则版本变化,将当前原文与 Facts 交给独立的文档求值回调,只消费最终节点结果。阶段依赖、缓存更新和结果对齐全部在数据层;显示层不通过 effect 回传输入或分段,节点数组未变时复用渲染元素。规则卸载撤销其注册,文档下次求值移除相应依赖;事实读取 Context 不得泄漏到节点或显示组件。
### 范围
UserContent 保留附件、正文容器、未知内容和引用摘要,消息外壳继续拥有 key、时间、复制与 pending/echo。Queue、Goal 与 Chat 复用 UserText 的数据处理和渲染入口。原生附件列表的 loader、草稿编辑模型、Session 持久化原文及 Assistant 分工不归本流水线重构。
## 验证要求
- 注册新业务只需提供变换、JSON 类型及 keyed renderer,公共运行器和 UserText 无需新增业务分支。
- 前置规则认领文本后,后续规则以实际剩余分段计算,不使用初始全文候选冒充当前匹配结果。
- 未读取的 Facts 字段变化不会执行该阶段;读取缺失字段后,该字段出现能够触发重算;条件读取关系可以增加或撤销。
- 某阶段读到的 Facts 变化但输出等价时,旧节点和数组引用得到复用,无关下游不执行;直接依赖其他变化字段的后续阶段仍然执行。
- 原文变更完整执行流水线;增量结果与相同输入下的新建求值实例结果一致。
- 冻结输入的测试能发现数组或 payload 原地修改;节点输出有序、完整覆盖原文、无重叠并可作 JSON round-trip。
- 顺序仅由 `order/id` 决定;内置显示变换的普通注册顺序、独立开关、相邻分段空白、文档边缘 trim、emoji 字素和引用原子性均由变换单测覆盖。
- 文本处理不执行 UI 操作;preview 不因 Slot 注入了操作能力而变成可点击;注册与卸载不泄漏求值状态或订阅。
## 影响
字段级依赖不承诺字段内部逐元素失效;某层读取的字段变化仍可能要求该层执行。结果对齐也有比较成本,不能将引用复用称为零计算。任意规则更新可能影响之前的普通文字,不能仅按已有业务节点 ID 过滤历史更新。各业务必须遵守只读输入和同步确定性协议;外部可变状态、隐藏读取或异步使用 Context 会破坏依赖正确性。文件反馈归属改变和行内 Slot 行为需要独立的显示层验证,不以数据层测试代替。
@@ -29,6 +29,7 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-chat",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-renderer",
@@ -47,6 +48,7 @@
"@deepseek-ai/cordis": "workspace:~",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:*",
@@ -0,0 +1,5 @@
.loading {
display: flex;
justify-content: center;
padding-block: 24px;
}
@@ -0,0 +1,84 @@
/** Inline attachment labels; image reads start only after activation and reuse the Session-owned cache. */
import { useCallback, useEffect, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, ImageLightbox, Modal, ReferenceChip, StateDot, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ImageLoader } from '../MessageImage.tsx'
import type { NS } from './locales.ts'
import type {} from './user-text-rules.ts'
import css from './AttachmentReference.module.css'
/** Image loading is authorized by this reference's Session, independently of its text host. */
export interface AttachmentReferenceInjected {
readonly loadImage: ImageLoader
}
type AttachmentReferenceProps = UserTextNodeProps<'attachment'>
& InjectFace<AttachmentReferenceInjected> & PropsLocale<typeof NS>
/**
* Keep file attachments read-only and open image previews only after an interactive activation.
* @param props - explicit attachment node, interaction policy, authorized loader, and locale seat.
* @returns an inline reference; image loading and preview dialogs render through body portals.
*/
export function AttachmentReference({ node, raw, interactive, loadImage, t }: AttachmentReferenceProps) {
const { attachment } = node.data
const [previewId, setPreviewId] = useState<ImageAttachmentRef['attachmentId'] | null>(null)
const close = useCallback(() => { setPreviewId(null) }, [])
if (attachment.type === 'file') {
return <ReferenceChip appearance="file" title={raw} label={t('file.label', {
name: attachment.ref.name, size: fileSizeText(attachment.ref.bytes),
})} />
}
const label = attachment.ref.name ?? t('image.label')
return <>
<ReferenceChip appearance="file" label={label} title={raw}
{...interactive ? { onActivate: () => { setPreviewId(attachment.ref.attachmentId) } } : {}} />
{interactive && previewId === attachment.ref.attachmentId && <ImageReferencePreview
key={attachment.ref.attachmentId}
attachment={attachment.ref} label={label} loadImage={loadImage} t={t} onClose={close}
/>}
</>
}
type ImagePreviewState = { readonly status: 'loading' | 'failed' } | { readonly status: 'open'; readonly url: string }
type ImageReferencePreviewProps = Pick<AttachmentReferenceProps, 'loadImage' | 't'> & {
readonly attachment: ImageAttachmentRef
readonly label: string
readonly onClose: () => void
}
/** Mounted only by a user gesture; closing ignores pending settlement without aborting the shared read. */
function ImageReferencePreview({ attachment, label, loadImage, t, onClose }: ImageReferencePreviewProps) {
const [preview, setPreview] = useState<ImagePreviewState>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let live = true
const cached = loadImage.peek?.(attachment)
setPreview(cached === undefined ? { status: 'loading' } : { status: 'open', url: cached })
void loadImage(attachment).then(
(url) => { if (live) setPreview({ status: 'open', url }) },
() => { if (live) setPreview({ status: 'failed' }) },
)
return () => { live = false }
}, [attachment, loadImage, attempt])
if (preview.status === 'open') {
return <ImageLightbox src={preview.url} alt={label} onClose={onClose}
labels={{ dialog: t('image.preview'), close: t('image.closePreview') }} />
}
return <Modal open onClose={onClose} title={t('image.preview')} closeLabel={t('close')}
footer={<>
<Button variant="outline" onClick={onClose}>{t('close')}</Button>
{preview.status === 'failed' && <Button variant="primary" onClick={() => { setAttempt(value => value + 1) }}>
{t('retry')}
</Button>}
</>}
>
{preview.status === 'failed'
? <p role="alert">{t('image.failed', { name: label })}</p>
: <span className={css.loading} role="status" aria-label={t('image.loading')}><StateDot state="ongoing" /></span>}
</Modal>
}
@@ -1,18 +1,38 @@
/** Browser attachment plugin: fills conversation's composer and image slots. */
/** Browser attachment plugin: composer, image galleries, and explicit inline attachment references. */
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { ComposerAttachments } from './ComposerAttachments.tsx'
import { AttachmentReference, type AttachmentReferenceInjected } from './AttachmentReference.tsx'
import { attachmentReferenceRule } from './user-text-rules.ts'
export type {} from './user-text-rules.ts'
import { en, NS, zh } from './locales.ts'
import { MessageImages, MessageImagesContent } from './MessageImages.tsx'
/** Slot registry required by this presentation plugin. */
export const inject = ['slots']
/** Slot assembly, pure text rules, Session image loading, and attachment-owned copy. */
export const inject = ['slots', 'uiConversation', 'locale']
/** Register attachment presentation without exporting React components as package values. */
/**
* Register attachment presentation and the explicit-range user-text provider.
* @param ctx - client plugin context with Session-authorized image loading.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-attachment: dictionaries')
ctx.effect(() => ctx.uiConversation.userText.register(attachmentReferenceRule), 'ui-attachment: text rule')
ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
name: 'conversation.user.text.node', key: 'attachment', priority: 100, locale: NS,
inject: (sessionId: UserTextNodeProps<'attachment'>['sessionId']): AttachmentReferenceInjected => ({
loadImage: Object.assign(
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
{ peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
),
}),
}, AttachmentReference))
ctx.slots.registerFactory({
name: 'conversation.message.images.content', scope: 'root', locale: 'conversation',
}, MessageImagesContent)
@@ -0,0 +1,38 @@
/** Localized copy for explicit inline attachment references and their image preview. */
import type {} from '@deepseek-ai/dsh-client-ui-slots'
/** Namespace owned by attachment reference renderers. */
export const NS = 'attachment'
/** Simplified Chinese attachment-reference copy. */
export const zh = {
'file.label': '{name} · {size}',
'image.label': '图片',
'image.loading': '正在加载图片',
'image.failed': '无法加载“{name}”,请重试或关闭此提示',
'image.preview': '图片预览',
'image.closePreview': '关闭图片预览',
'close': '关闭',
'retry': '重试',
} satisfies Record<string, string>
/** Keys required by both attachment dictionaries. */
export type AttachmentKey = keyof typeof zh
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
attachment: AttachmentKey
}
}
/** English attachment-reference copy. */
export const en = {
'file.label': '{name} · {size}',
'image.label': 'Image',
'image.loading': 'Loading image',
'image.failed': 'Couldn’t load “{name}”. Try again or close this message.',
'image.preview': 'Image preview',
'image.closePreview': 'Close image preview',
'close': 'Close',
'retry': 'Retry',
} satisfies Record<AttachmentKey, string>
@@ -0,0 +1,84 @@
/** Pure attachment references use only caller-confirmed ranges and durable identities. */
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
/** A durable attachment identity, never a path inferred from its display name. */
export type UserTextAttachment =
| { readonly type: 'image'; readonly ref: ImageAttachmentRef }
| { readonly type: 'file'; readonly ref: FileAttachmentRef }
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
attachment: { readonly attachment: UserTextAttachment }
}
interface UserTextFacts {
readonly attachments: readonly {
readonly start: number
readonly end: number
readonly attachment: UserTextAttachment
}[]
}
}
function appendText(nodes: UserTextNode[], segment: UserTextNode<'text'>, start: number, end: number): void {
if (start < end) {
nodes.push({
id: 'text', start, end,
data: { text: segment.data.text.slice(start - segment.start, end - segment.start) },
})
}
}
/**
* Read explicit attachment facts only when Text remains; never infer references from names.
* Invalid or overlapping fact ranges throw; valid ranges outside a single Text segment stay unclaimed.
*/
export const attachmentReferenceRule: UserTextRule<'attachment'> = {
id: 'attachment',
order: 15,
transform(segments, context) {
const lastSegment = segments.at(-1)
if (!lastSegment || !segments.some(segment => segment.id === 'text')) return segments
const attachments = context.read('attachments')
if (!attachments?.length) return segments
const end = lastSegment.end
for (const range of attachments) {
if (!Number.isInteger(range.start) || !Number.isInteger(range.end)
|| range.start < 0 || range.end <= range.start || range.end > end) {
throw new RangeError(`invalid attachment range [${range.start}, ${range.end}); expected a nonempty UTF-16 range within [0, ${end})`)
}
}
const ranges = [...attachments].sort((left, right) => left.start - right.start)
let previousEnd = 0
for (const range of ranges) {
if (range.start < previousEnd) {
throw new RangeError(`overlapping attachment range [${range.start}, ${range.end})`)
}
previousEnd = range.end
}
const nodes: UserTextNode[] = []
let changed = false
let rangeIndex = 0
for (const segment of segments) {
if (segment.id !== 'text') {
nodes.push(segment)
continue
}
let cursor = segment.start
for (let range = ranges[rangeIndex]; range && range.start < segment.end; range = ranges[++rangeIndex]) {
if (range.start < segment.start || range.end > segment.end) continue
appendText(nodes, segment, cursor, range.start)
nodes.push({ id: 'attachment', start: range.start, end: range.end, data: { attachment: range.attachment } })
cursor = range.end
}
if (cursor === segment.start) {
nodes.push(segment)
} else {
changed = true
appendText(nodes, segment, cursor, segment.end)
}
}
return changed ? nodes : segments
},
}
@@ -0,0 +1,121 @@
import { Context } from '@deepseek-ai/cordis'
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { attachmentReferenceRule } from '../src/client/user-text-rules.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
'test.attachment.blocker': { readonly label: string }
}
}
function setup(rules: readonly UserTextRule[] = [attachmentReferenceRule]) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const registry = new UserTextRegistry(ctx)
for (const rule of rules) registry.register(rule)
return registry.createEvaluator()
}
describe('attachmentReferenceRule', () => {
it('does not read attachment facts when an earlier rule leaves no Text', () => {
const read = vi.fn()
const transform = vi.fn<UserTextRule['transform']>((segments, context) => attachmentReferenceRule.transform(segments, {
read: (key) => { read(key); return context.read(key) },
}))
const blocker: UserTextNode<'test.attachment.blocker'> = {
id: 'test.attachment.blocker', start: 0, end: 5, data: { label: 'claimed' },
}
const evaluate = setup([
{ id: 'test.attachment.blocker', order: 0, transform: () => [blocker] },
{ ...attachmentReferenceRule, transform },
])
const before = evaluate({ text: 'image', facts: {} })
expect(before).toEqual([blocker])
expect(evaluate({ text: 'image', facts: { attachments: [] } })).toBe(before)
expect(read).not.toHaveBeenCalled()
expect(transform).toHaveBeenCalledOnce()
})
it('rejects invalid and overlapping fact ranges with its rule ID and cause', () => {
const evaluate = setup()
const attachment = { type: 'file' as const, ref: { attachmentId: AttachmentId('file'), name: 'a', bytes: 1 } }
for (const ranges of [
[{ start: -1, end: 1 }], [{ start: 0, end: 0 }], [{ start: 0, end: 5 }],
[{ start: 0.5, end: 1 }], [{ start: 0, end: Infinity }],
[{ start: 0, end: 3 }, { start: 2, end: 4 }],
]) {
let failure: unknown
try {
evaluate({ text: 'abcd', facts: { attachments: ranges.map(range => ({ ...range, attachment })) } })
} catch (error) { failure = error }
expect(failure).toHaveProperty('ruleId', 'attachment')
expect(failure).toHaveProperty('cause', expect.any(RangeError))
}
})
it('preserves earlier business nodes and claims only fact ranges inside remaining Text', () => {
const blocker: UserTextNode<'test.attachment.blocker'> = {
id: 'test.attachment.blocker', start: 1, end: 4, data: { label: 'claimed' },
}
const evaluate = setup([
{
id: 'test.attachment.blocker', order: 0,
transform: () => [
{ id: 'text', start: 0, end: 1, data: { text: 'a' } }, blocker,
{ id: 'text', start: 4, end: 6, data: { text: 'ef' } },
],
},
attachmentReferenceRule,
])
const attachment = { type: 'file' as const, ref: { attachmentId: AttachmentId('file'), name: 'a', bytes: 1 } }
const nodes = evaluate({
text: 'abcdef', facts: { attachments: [{ start: 1, end: 4, attachment }, { start: 4, end: 6, attachment }] },
})
expect(nodes[1]).toBe(blocker)
expect(nodes.filter(node => node.id === 'attachment').map(node => [node.start, node.end])).toEqual([[4, 6]])
const crossing = evaluate({ text: 'abcdef', facts: { attachments: [{ start: 0, end: 5, attachment }] } })
expect(crossing[1]).toBe(blocker)
expect(crossing.filter(node => node.id === 'attachment')).toEqual([])
})
it('carries only explicitly located image and file references as JSON', () => {
const evaluate = setup()
const image: ImageAttachmentRef = {
attachmentId: AttachmentId('image-id'), mediaType: 'image/png', bytes: 13,
width: 10, height: 20, name: 'photo.png', originalDimensions: { width: 100, height: 200 },
}
const file: FileAttachmentRef = { attachmentId: AttachmentId('file-id'), name: 'report.pdf', bytes: 42 }
const imageAttachment = Object.freeze({ type: 'image' as const, ref: Object.freeze(image) })
const fileAttachment = Object.freeze({ type: 'file' as const, ref: Object.freeze(file) })
const input = Object.freeze({
text: '🙂 image file',
facts: Object.freeze({ attachments: Object.freeze([
Object.freeze({ start: 3, end: 8, attachment: imageAttachment }),
Object.freeze({ start: 9, end: 13, attachment: fileAttachment }),
]) }),
})
const matches = evaluate(input).filter(node => node.id === 'attachment')
expect(attachmentReferenceRule.id).toBe('attachment')
expect(matches).toEqual([
{ id: 'attachment', start: 3, end: 8, data: { attachment: imageAttachment } },
{ id: 'attachment', start: 9, end: 13, data: { attachment: fileAttachment } },
])
expect(JSON.parse(JSON.stringify(matches))).toEqual(matches)
})
it('never treats a display name as a path or infers a second same-name reference', () => {
const evaluate = setup()
const text = '@report.pdf and @report.pdf'
const ref: FileAttachmentRef = { attachmentId: AttachmentId('chosen-file'), name: 'report.pdf', bytes: 5 }
expect(evaluate({ text, facts: {} }).filter(node => node.id === 'attachment')).toEqual([])
expect(evaluate({ text, facts: { attachments: [] } }).filter(node => node.id === 'attachment')).toEqual([])
const start = text.lastIndexOf('@report.pdf')
expect(evaluate({
text, facts: { attachments: [{ start, end: text.length, attachment: { type: 'file', ref } }] },
}).filter(node => node.id === 'attachment')).toEqual([{ id: 'attachment', start, end: text.length, data: { attachment: { type: 'file', ref } } }])
})
})
@@ -34,6 +34,9 @@
},
{
"path": "../ui-primitives"
},
{
"path": "../locale/tsconfig.client.json"
}
]
}
@@ -267,11 +267,6 @@ export function apply(ctx: Context): void {
else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
await Promise.resolve()
},
openSkill: (name) => {
const scope = ctx.sessions.scope(sessionId)
if (scope === undefined) return
ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` })
},
openExternalLink: (url) => {
if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) {
ctx.sidebarRight.openTab('browser', { params: { url } })
@@ -13,7 +13,7 @@ export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation,
useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction,
cwd, openFile, openSkill, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile, inspectCall, forkAt, loadImage, fileMentions,
}: ChatFlowSlotProps) {
const nodeStore = useChat(snapshot => snapshot.nodes)
const running = useSession(snapshot => snapshot.running)
@@ -27,7 +27,7 @@ export const ChatFlow = memo(function ChatFlow({
const seatProps = {
nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation,
useStore, actions, renderSlot, t, useGroupAction, deferCollapse,
cwd, openFile, openSkill, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile, inspectCall, forkAt, loadImage, fileMentions,
}
const rows = entries.map((entry) => {
switch (entry.kind) {
@@ -47,7 +47,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
*/
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
cwd, openFile, openSkill, inspectCall, forkAt,
cwd, openFile, inspectCall, forkAt,
loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction,
}: ChatNodeSeatProps) {
const bottom = useChatNodeBottom(nodeKey)
@@ -127,14 +127,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
...groupPart === undefined ? {} : { groupPart },
cwd,
openFile,
openSkill,
inspectCall,
forkAt,
loadImage,
fileMentions,
turnProcess,
}, [
node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt,
node, groupPart, cwd, openFile, inspectCall, forkAt,
loadImage, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
@@ -59,7 +59,7 @@ const MOTION_TAIL_MS = 800
*/
export function ChatView({
useSession, useChat, useConversation, useSessions, renderSlot,
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
usePresentation, useProjection, t,
}: ChatViewSlotProps) {
const order = useChat(s => s.order)
@@ -226,7 +226,7 @@ export function ChatView({
<MarkdownDelegateProvider openFile={requestOpenFile} openExternalLink={openExternalLink} fileImages={fileImages}>
{renderSlot('conversation.chat.flow', {
entries, pendingInputs, lastInputTurn, deferCollapse,
cwd, openFile: requestOpenFile, openSkill, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions,
}, { hookContext: flowContext })}
</MarkdownDelegateProvider>
{/* No pending placeholders: questions (ui-user-questions) and approvals
@@ -2,14 +2,19 @@ import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots'
import type { UserTextFacts } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { JsonBlock, StateDot, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, UserContentAttachment, UserContentData } from '../contract/slots.ts'
import type {
ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, UserContentAttachment, UserContentData, UserContentInput,
} from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
const EMPTY_TEXT_FACTS: Readonly<Partial<UserTextFacts>> = {}
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
type UserFile = Extract<UserMessageNode['content'][number], { type: 'file' }>
function contentParts(content: readonly unknown[]): {
@@ -157,7 +162,7 @@ function TurnMaxTokensItem({ t }: {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, loadImage, renderFactorySlot, actions, pending = false, echo = false, referenceLabels, skillNames,
previewAttachments, references,
previewAttachments, facts: inputFacts = EMPTY_TEXT_FACTS, interactive = false,
}: {
content: readonly unknown[]
renderFactorySlot: PropsRenderFactories['renderFactorySlot']
@@ -173,16 +178,21 @@ function UserStyleBubble({
skillNames?: readonly string[]
/** Local submission-echo attachments replacing the content-derived attachment sequence. */
previewAttachments?: readonly UserContentAttachment[]
references?: Pick<ChatNodeOwnerProps, 'openFile' | 'openSkill'>
} & Pick<ChatNodeOwnerProps, 'loadImage'>): ReactNode {
/** Inputs used by text transforms, separate from row and rendering props. */
facts?: Readonly<Partial<UserTextFacts>>
} & Pick<ChatNodeOwnerProps, 'loadImage'> & Pick<UserContentInput, 'interactive'>): ReactNode {
const parts = useMemo(() => contentParts(content), [content])
const prepared = useMemo<UserContentData>(() => ({
text: parts.text,
attachments: previewAttachments ?? parts.attachments,
extraBlocks: parts.rest,
...(referenceLabels === undefined ? {} : { referenceLabels }),
}), [parts, previewAttachments, referenceLabels])
const facts = useMemo<Readonly<Partial<UserTextFacts>>>(() => ({
...inputFacts,
...(referenceLabels === undefined ? {} : { sessionLabels: referenceLabels }),
...(skillNames === undefined ? {} : { skillNames }),
}), [parts, previewAttachments, referenceLabels, skillNames])
}), [inputFacts, referenceLabels, skillNames])
return (
<div
className={css.userRow}
@@ -192,7 +202,8 @@ function UserStyleBubble({
{renderFactorySlot('conversation.chat.user.content', {
content: prepared,
loadImage,
...(references === undefined ? {} : { references }),
facts,
interactive,
})}
{actions?.(prepared.text)}
</div>
@@ -284,13 +295,13 @@ export function PendingSubmissionBubble({ submission, loadImage, renderFactorySl
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, renderFactorySlot, loadImage, openFile, openSkill, t,
node, renderFactorySlot, loadImage, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
return (
<UserStyleBubble
content={data.content}
references={{ openFile, openSkill }}
interactive
renderFactorySlot={renderFactorySlot}
loadImage={loadImage}
{...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
@@ -1,7 +1,7 @@
/** Reusable user contents inside a caller-owned message row. */
import { Fragment } from 'react'
import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots'
import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText } from '@deepseek-ai/dsh-client-ui-primitives'
import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
const EMPTY_LABELS: readonly string[] = []
@@ -9,12 +9,12 @@ const EMPTY_LABELS: readonly string[] = []
/**
* Render prepared text, ordered attachments, and references without message chrome.
* The caller owns row identity, pending/echo state, time, and message actions.
* @param props - prepared content, explicit capabilities, and framework Factory/locale seats.
* @param props - prepared content, facts, authorized image loader, and framework Factory/locale seats.
* @returns the content stack using the existing user-message layout.
*/
export function UserContent({ content, loadImage, references, renderFactorySlot, t }:
export function UserContent({ content, loadImage, facts, interactive = false, ellipsis = false, renderFactorySlot, t }:
FactoryComponentPropsOf<'conversation.chat.user.content'>) {
const { text, attachments, extraBlocks, referenceLabels = EMPTY_LABELS, skillNames = EMPTY_LABELS } = content
const { text, attachments, extraBlocks, referenceLabels = EMPTY_LABELS } = content
const compactImages = attachments.length > 1
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || extraBlocks.length > 0
@@ -45,7 +45,7 @@ FactoryComponentPropsOf<'conversation.chat.user.content'>) {
</div>
)}
{showBubble && <div className={css.bubble}>
{projectUserText(text, referenceLabels, skillNames, 'skill', references)}
{renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })}
{extraBlocks.map((block, index) => (
<JsonBlock key={index} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))}
@@ -8,7 +8,7 @@ import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type {
CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
ConversationGroupData, GroupSnapshot,
MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation,
MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderEntry, TurnLocation, UserTextFacts, UserTextInput,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
HostObservable, InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime,
@@ -210,8 +210,6 @@ export interface ChatNodeOwnerProps {
/** Renderer-owned Node portion selected by the grouping Definition. */
groupPart?: string
cwd?: string | undefined
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
openFile: (path: string, options?: OpenFileOptions) => void
inspectCall: ((callId: ToolCallId) => void) | undefined
forkAt: (seq: number) => void
@@ -233,14 +231,16 @@ export interface UserContentData {
readonly attachments: readonly UserContentAttachment[]
/** Blocks not represented by text or attachment presentation. */
readonly extraBlocks: readonly unknown[]
/** Labels displayed in the caller-provided reference summary. */
readonly referenceLabels?: readonly string[]
readonly skillNames?: readonly string[]
}
/** Public content rendering input; absent reference actions leave mentions non-interactive. */
export interface UserContentInput extends Pick<ChatNodeOwnerProps, 'loadImage'> {
/** Public content rendering input; reference renderers own their injected actions. */
export interface UserContentInput extends Pick<ChatNodeOwnerProps, 'loadImage'>,
Pick<UserTextInput, 'interactive' | 'ellipsis'> {
readonly content: UserContentData
readonly references?: Pick<ChatNodeOwnerProps, 'openFile' | 'openSkill'>
/** Transform inputs passed unchanged to UserText, separate from summary and rendering props. */
readonly facts: Readonly<Partial<UserTextFacts>>
}
/** Shared presentation state for one Turn-process answer generation. */
@@ -308,8 +308,6 @@ export interface ChatViewInjected {
/** Resolve one optional group without subscribing the root View to its data. */
chatGroup: (key: string) => ObservableSnapshot<GroupSnapshot<ConversationGroupData<'chat'>> | undefined> | undefined
}
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
/** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */
openExternalLink: (url: string) => void
openFile: (path: string, options?: OpenFileOptions) => Promise<void>
@@ -330,7 +328,7 @@ export type ChatFlowDataInjected = Pick<ChatViewInjected, 'hooks' | 'keyedHooks'
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick<ChatNodeOwnerProps,
'cwd' | 'openSkill' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
readonly entries: readonly RenderEntry[]
readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[]
readonly lastInputTurn: number | undefined
@@ -14,10 +14,13 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
import { UiConversation } from './conversation/assembly.ts'
import { UserText, PlainUserText, SessionReference } from './user-text/UserText.tsx'
import { sessionReferenceRule } from './user-text/session-reference-rule.ts'
import { collapseWhitespaceRule, stripEmojiRule, trimWhitespaceRule } from './user-text/display-rules.ts'
import type { ViewTab } from './contract/views.ts'
import type {
ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected, DraftFileUploads,
ConversationSessionInjected, DraftFileUploads, UserTextInjected,
} from './contract/slots.ts'
import type { InputNotice } from './contract/input.ts'
import type { ReferenceInsert } from './contract/draft-editor.ts'
@@ -156,6 +159,31 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number
const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation
const uiConversation = new UiConversation(ctx, sessions)
uiConversation.userText.register(sessionReferenceRule)
uiConversation.userText.register(stripEmojiRule)
uiConversation.userText.register(collapseWhitespaceRule)
uiConversation.userText.register(trimWhitespaceRule)
slots.registerFactory({
name: 'conversation.user.text',
scope: 'session',
children: {
'conversation.user.text.node': { kind: 'keyed', scope: 'session', inline: true },
},
inject: (): UserTextInjected => ({
hooks: {
userTextRevision: {
getSnapshot: () => uiConversation.userText.getSnapshot(),
subscribe: listener => uiConversation.userText.subscribe(listener),
},
},
createUserTextEvaluator: () => uiConversation.userText.createEvaluator(),
reportTransformFailure: (error) => { console.error('[ui-conversation] user-text transformation failed:', error) },
}),
}, UserText)
slots.inject('conversation.user.text.node', function* () {
yield slots.register({ name: 'conversation.user.text.node', key: 'text', priority: 100 }, PlainUserText)
yield slots.register({ name: 'conversation.user.text.node', key: 'session', priority: 100 }, SessionReference)
})
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
const t = ctx.locale.bind(NS)
@@ -23,6 +23,7 @@ import type { createConversationStore } from '../stores.ts'
import type { BusyEnterBehavior } from './composer-submission.ts'
import type { ConversationSnapshot } from './snapshot.ts'
import type { ViewTab } from './views.ts'
import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode } from './user-text.ts'
/** Browser-owned draft attachment that has not crossed the durable Host boundary. */
export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment
@@ -139,6 +140,33 @@ export interface MessageImagesContentInput extends MessageImagesOwnerProps {
readonly labels?: MessageImageLabels
}
/** Transform inputs and independently controlled rendering behavior. */
export interface UserTextInput extends UserTextDocumentInput {
/** Enable renderer-owned reference actions; omitted values keep references read-only. */
readonly interactive?: boolean
/** Use CSS single-line overflow ellipsis without shortening source text. */
readonly ellipsis?: boolean
}
/** Rule invalidation and document evaluation supplied by the data layer. */
export interface UserTextInjected {
hooks: { userTextRevision: ObservableSnapshot<number> }
/** Each invocation creates a caller-owned data-layer cache without registry subscriptions. */
createUserTextEvaluator: () => UserTextEvaluator
/** Report a failed rule while the original text remains visible. */
reportTransformFailure: (error: unknown) => void
}
/** JSON-only owner data common to every business text node. */
export interface UserTextNodeOwnerProps {
readonly raw: string
readonly interactive: boolean
}
/** Complete props for the selected business node, including its Session scope. */
export type UserTextNodeProps<Id extends UserTextId = UserTextId> =
PropsRuntime<'conversation.user.text.node', Id>
/** Selector hook over the current Session's assembled Conversation. */
export type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** Selector hook over the registered Conversation View roster. */
@@ -146,6 +174,15 @@ export type UseConversationViews = SnapshotSelectorHook<readonly ViewTab[]>
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotFactoryMap {
/** Parse one original text and render its business nodes through the owned keyed Slot. */
'conversation.user.text': {
scope: 'session'
props: UserTextInput
inject: UserTextInjected
children: {
'conversation.user.text.node': { kind: 'keyed'; scope: 'session'; inline: true }
}
}
/** Official reusable gallery, with explicit authorized loading and optional caller copy. */
'conversation.message.images.content': {
scope: 'root'
@@ -155,6 +192,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
interface SlotMap {
/** One business-owned inline renderer for a pure JSON text node. */
'conversation.user.text.node': {
kind: 'keyed'
scope: 'session'
inline: true
owner: UserTextNodeOwnerProps
keyProps: { [Id in UserTextId]: { node: UserTextNode<Id> } }
}
/** Conversation shell beneath its root-scoped main-panel entry. */
'main.conversation': { kind: 'single'; scope: 'session-maybe' }
/** Strict per-Session Conversation body. */
@@ -0,0 +1,64 @@
/** Synchronous ordered transformations over immutable JSON nodes and caller-supplied facts. */
import type { UserTextDataMap, UserTextFacts } from '../index.ts'
/** Registered business IDs plus the document-owned `text` ID. */
export type UserTextId = Extract<keyof UserTextDataMap, string>
type JsonPayload<T> = T extends string | number | boolean | null ? T
: T extends (...args: never[]) => unknown ? never
: T extends readonly unknown[] ? { readonly [K in keyof T]: JsonPayload<T[K]> }
: T extends object ? { readonly [K in keyof T]: JsonPayload<T[K]> }
: never
/** Original UTF-16 text and immutable facts; absence is observable through `context.read`. */
export interface UserTextDocumentInput {
readonly text: string
readonly facts: Readonly<Partial<UserTextFacts>>
}
/** Discriminated JSON node over a nonempty half-open UTF-16 range in the original text. */
export type UserTextNode<K extends UserTextId = UserTextId> = {
[Id in K]: {
readonly id: Id
readonly start: number
readonly end: number
readonly data: JsonPayload<UserTextDataMap[Id]>
}
}[K]
/** Facts reader valid only during its owning synchronous transform call; never retain it. */
export interface UserTextTransformContext {
/**
* Record a field dependency, including an absent value; throws after the transform returns or throws.
* @param key - field of the caller's immutable facts snapshot.
* @returns the borrowed read-only value, or undefined when absent.
*/
read<K extends keyof UserTextFacts>(key: K): UserTextFacts[K] | undefined
}
/** One synchronous linear stage; rule IDs need not be node kinds, but `text` remains reserved. */
export interface UserTextRule<Id extends string = string> {
readonly id: Id
/** Finite order, ascending; ties use code-unit ID ordering, not registration order. */
readonly order: number
/**
* May split or merge adjacent Text nodes, update their `data.display`, and create business nodes using this rule's ID.
* Text `data.text` always retains its original substring; display transforms compose from `display ?? text`.
* Must not mutate inputs or consume or rewrite existing business nodes.
* @param segments - complete, frozen upstream nodes in original UTF-16 coordinates.
* @param context - synchronous facts reader; reads replace this stage's previous dependencies.
* @returns full ordered source coverage; new business nodes stay within one input Text segment.
* Preserve unchanged input objects and return the input array when nothing changes.
*/
transform(segments: readonly UserTextNode[], context: UserTextTransformContext): readonly UserTextNode[]
}
/**
* Evaluate one document with current registered rules; the caller alone owns its cache lifetime.
* Reentrant calls throw. A failed stage throws an Error carrying ruleId and cause, never partial output;
* its failure and reads are cached until its definition, upstream input, text or a read field changes.
* @param input - original text and immutable JSON facts; fields are compared with Object.is.
* @returns frozen nodes covering the text, or [] for empty text; equivalent results retain identities.
*/
export type UserTextEvaluator = (input: UserTextDocumentInput) => readonly UserTextNode[]
@@ -24,6 +24,7 @@ import { ConversationEventRegistry } from './event-registry.ts'
import { HistoricalImageCache } from './historical-images.ts'
import { ConversationViewRegistry } from './view-registry.ts'
import { ConversationGroupRegistry } from './group-registry.ts'
import { UserTextRegistry } from './user-text-registry.ts'
/** Observable faces published for one Session's Conversation assembly. */
export interface ConversationBinding {
@@ -190,6 +191,8 @@ export class UiConversation extends Service {
readonly views: ConversationViewRegistry
/** Business grouping rules over already materialized target Nodes. */
readonly groups: ConversationGroupRegistry
/** Pure text rules and parser snapshots, independent of durable Session assembly. */
readonly userText: UserTextRegistry
private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
private readonly images: HistoricalImageCache
@@ -202,6 +205,7 @@ export class UiConversation extends Service {
this.events = new ConversationEventRegistry(ctx)
this.views = new ConversationViewRegistry(ctx)
this.groups = new ConversationGroupRegistry(ctx, this.views)
this.userText = new UserTextRegistry(ctx)
this.images = new HistoricalImageCache(ctx, sessions)
const rebuild = (): void => {
for (const record of this.bindings.values) record.binding.rebuild()
@@ -0,0 +1,225 @@
/** Per-document linear evaluation, dynamic fact dependencies, failure caching and JSON identity reuse. */
import type { UserTextFacts } from '../index.ts'
import type {
UserTextDocumentInput, UserTextNode, UserTextRule, UserTextTransformContext,
} from '../contract/user-text.ts'
type FactKey = keyof UserTextFacts
class UserTextTransformError extends Error {
constructor(readonly ruleId: string, cause: unknown) {
super(`user-text rule "${ruleId}" failed to transform`, { cause })
this.name = 'UserTextTransformError'
}
}
interface Stage {
definition: UserTextRule
input: readonly UserTextNode[]
/** Most recent valid nodes, initially the input, retained for reuse after a failed attempt. */
output: readonly UserTextNode[]
error: UserTextTransformError | undefined
reads: ReadonlyMap<FactKey, unknown>
dirty: boolean
}
/** Internal cache owned by one evaluator; no registry or React lifetime is retained here. */
export class UserTextDocument {
private text: string | undefined
private initial: readonly UserTextNode[] = Object.freeze([])
private rules: readonly UserTextRule[] | undefined
private readonly stages = new Map<string, Stage>()
private readonly readers = new Map<FactKey, Set<Stage>>()
private readonly frozen = new WeakSet<object>()
private evaluating = false
/**
* Evaluate current ordered definitions, retaining only this document's stage state.
* @param input - original text and immutable caller facts.
* @param rules - immutable definitions sorted by order and ID.
* @returns complete immutable nodes; failures throw with ruleId and cause, without partial publication.
*/
evaluate(input: UserTextDocumentInput, rules: readonly UserTextRule[]): readonly UserTextNode[] {
if (this.evaluating) throw new Error('user-text document evaluation cannot be reentrant')
this.evaluating = true
try {
this.prepare(input, rules)
let segments = this.initial
for (const definition of rules) {
let stage = this.stages.get(definition.id)
if (!stage) {
stage = { definition, input: segments, output: segments, error: undefined, reads: new Map(), dirty: true }
this.stages.set(definition.id, stage)
}
if (stage.dirty || stage.definition !== definition || stage.input !== segments) {
this.run(stage, definition, segments, input)
}
if (stage.error) throw stage.error
segments = stage.output
}
return segments
} finally {
this.evaluating = false
}
}
private prepare(input: UserTextDocumentInput, rules: readonly UserTextRule[]): void {
if (this.text !== input.text) {
this.text = input.text
this.stages.clear()
this.readers.clear()
this.initial = input.text.length === 0 ? [] : [
{ id: 'text', start: 0, end: input.text.length, data: { text: input.text } },
]
this.freeze(this.initial)
}
if (this.rules !== rules) {
const ids = new Set(rules.map(rule => rule.id))
for (const [id, stage] of this.stages) {
if (ids.has(id)) continue
this.replaceReads(stage, new Map())
this.stages.delete(id)
}
this.rules = rules
}
// Dirty flags persist until their stage executes, including stages blocked by an upstream failure.
for (const [key, stages] of this.readers) {
const value = input.facts[key]
for (const stage of stages) {
if (!Object.is(stage.reads.get(key), value)) stage.dirty = true
}
}
}
private run(stage: Stage, definition: UserTextRule, segments: readonly UserTextNode[], input: UserTextDocumentInput): void {
const reads = new Map<FactKey, unknown>()
let active = true
const context: UserTextTransformContext = Object.freeze({
read: <K extends FactKey>(key: K): UserTextFacts[K] | undefined => {
if (!active) throw new Error('user-text transform context is closed; read is synchronous only')
const value = input.facts[key]
reads.set(key, value)
return value
},
})
stage.definition = definition
stage.input = segments
stage.dirty = false
stage.error = undefined
try {
let output: readonly UserTextNode[]
try {
output = definition.transform(segments, context)
} finally {
active = false
}
validateOutput(input.text, definition.id, segments, output)
const aligned = alignNodes(output, stage.output, segments)
this.freeze(aligned)
stage.output = aligned
} catch (cause) {
stage.error = new UserTextTransformError(definition.id, cause)
} finally {
this.replaceReads(stage, reads)
}
}
private replaceReads(stage: Stage, reads: ReadonlyMap<FactKey, unknown>): void {
for (const key of stage.reads.keys()) {
const readers = this.readers.get(key)
if (readers) {
readers.delete(stage)
if (readers.size === 0) this.readers.delete(key)
}
}
stage.reads = reads
for (const key of reads.keys()) {
let readers = this.readers.get(key)
if (!readers) this.readers.set(key, readers = new Set())
readers.add(stage)
}
}
private freeze(value: unknown): void {
if (value === null || typeof value !== 'object' || this.frozen.has(value)) return
for (const child of Object.values(value)) this.freeze(child)
Object.freeze(value)
this.frozen.add(value)
}
}
function assertSynchronousOutput(output: unknown): void {
if (!Array.isArray(output)) throw new Error('transform must return a synchronous node array')
}
function validateOutput(
text: string, ruleId: string, input: readonly UserTextNode[], output: readonly UserTextNode[],
): void {
assertSynchronousOutput(output)
if (output === input) return
let cursor = 0
const sources = input.values()
let source = sources.next().value
for (const node of output) {
if (!Number.isInteger(node.start) || !Number.isInteger(node.end)
|| node.start !== cursor || node.end <= node.start || node.end > text.length) {
throw new Error(`invalid range [${node.start}, ${node.end}); expected contiguous coverage from ${cursor} to ${text.length}`)
}
if (!source) throw new Error('output exceeds input text coverage')
if (source.id !== 'text') {
if (!equalNode(node, source)) throw new Error(`cannot replace atomic node "${source.id}" at ${source.start}`)
} else if (node.id === 'text') {
if (node.data.text !== text.slice(node.start, node.end)) throw new Error('text node must preserve its original substring')
while (node.end > source.end) {
source = sources.next().value
if (!source || source.id !== 'text') throw new Error('text node cannot consume an atomic node or exceed input coverage')
}
} else if (node.id !== ruleId || node.end > source.end) {
throw new Error(`new business node must use "${ruleId}" within one input text segment`)
}
cursor = node.end
if (node.end === source.end) source = sources.next().value
}
if (cursor !== text.length) throw new Error(`incomplete text coverage: ended at ${cursor}, expected ${text.length}`)
}
function alignNodes(
output: readonly UserTextNode[], previous: readonly UserTextNode[] | undefined, input: readonly UserTextNode[],
): readonly UserTextNode[] {
if (output === previous || output === input) return output
let previousIndex = 0
let inputIndex = 0
let aligned: UserTextNode[] | undefined
for (const [index, node] of output.entries()) {
let inputNode = input[inputIndex]
while (inputNode && inputNode.start < node.start) inputNode = input[++inputIndex]
let previousNode = previous?.[previousIndex]
while (previousNode && previousNode.start < node.start) previousNode = previous?.[++previousIndex]
const reused = inputNode && equalNode(node, inputNode) ? inputNode
: previousNode && equalNode(node, previousNode) ? previousNode : node
if (reused !== node && !aligned) aligned = output.slice(0, index)
aligned?.push(reused)
}
const nodes = aligned ?? output
if (previous && equalElements(nodes, previous)) return previous
return equalElements(nodes, input) ? input : nodes
}
function equalElements(left: readonly UserTextNode[], right: readonly UserTextNode[]): boolean {
return left === right || (left.length === right.length && left.every((node, index) => node === right[index]))
}
function equalNode(left: UserTextNode, right: UserTextNode): boolean {
return Object.is(left, right) || (left.id === right.id && left.start === right.start && left.end === right.end
&& equalJson(left.data, right.data))
}
function equalJson(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false
if (Array.isArray(left) !== Array.isArray(right)) return false
const keys = Object.keys(left)
if (keys.length !== Object.keys(right).length) return false
return keys.every(key => Object.hasOwn(right, key)
&& equalJson(Reflect.get(left, key), Reflect.get(right, key)))
}
@@ -0,0 +1,48 @@
/** Effect-owned user-text rules and their revision; document caches belong only to evaluator callers. */
import type { UserTextEvaluator, UserTextRule } from '../contract/user-text.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
import { UserTextDocument } from './user-text-document.ts'
/** Observable rule revision with no retained documents or document subscriptions. */
export class UserTextRegistry extends ConversationDefinitionRegistry<UserTextRule> {
private rules: readonly UserTextRule[] = Object.freeze([])
private revision = 0
/**
* Register a uniquely named rule; invalid IDs, duplicate IDs and non-finite order throw before publication.
* @param rule - immutable synchronous transform with a nonempty ID other than the reserved `text` ID.
* @returns idempotent disposer; removal synchronously publishes a new revision.
*/
register(rule: UserTextRule): () => void {
if (rule.id.length === 0) throw new Error('user-text rule ID must not be empty')
if (rule.id === 'text') throw new Error('user-text rule "text" is reserved for original text')
if (!Number.isFinite(rule.order)) throw new Error(`user-text rule "${rule.id}" order must be finite`)
return this.registerDefinition(
rule.id,
Object.freeze(rule),
`user-text rule "${rule.id}" is already registered`,
`uiConversation.userText.register(${JSON.stringify(rule.id)})`,
)
}
/**
* Create an independent document evaluator; it reads current rules at each call and never subscribes.
* @returns callback owning its document cache, released when the caller releases the callback.
*/
createEvaluator(): UserTextEvaluator {
const document = new UserTextDocument()
return input => document.evaluate(input, this.rules)
}
/** @returns monotonic revision, stable until a successful registration or disposal. */
getSnapshot(): number {
return this.revision
}
protected override refresh(): void {
this.rules = Object.freeze([...this.definitions.values()].sort((a, b) =>
a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)))
this.revision++
super.refresh()
}
}
@@ -1,4 +1,30 @@
/** Browser Conversation assemble core, React adapter, shell, and input plugin. */
/** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */
export type {} from './user-text/session-reference-rule.ts'
/** Declaration-merged node payloads; IDs also identify the corresponding keyed renderer. */
export interface UserTextDataMap {
text: {
/** Exact original substring; display transforms never change source text or UTF-16 coordinates. */
readonly text: string
/** Optional display-only text; absence renders the original substring, while an empty string hides it. */
readonly display?: string
}
}
/** Caller-supplied JSON business facts and text-transform flags, never services or actions; absent flags are disabled. */
export interface UserTextFacts {
/** Collapse whitespace runs in ordinary display text to one space. */
readonly collapseWhitespace: boolean
/** Remove display whitespace only at the document's outer edges, without crossing business nodes. */
readonly trimWhitespace: boolean
/** Remove complete emoji graphemes from ordinary display text. */
readonly stripEmoji: boolean
}
export type {
UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode, UserTextRule, UserTextTransformContext,
} from './contract/user-text.ts'
export type { UserTextRegistry } from './conversation/user-text-registry.ts'
export { apply, Config, inject } from './apply.ts'
export type { Config as ConversationConfig } from './apply.ts'
export { UiConversation } from './conversation/assembly.ts'
@@ -68,6 +94,7 @@ export type {
ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
MessageImageLabels, MessageImageLoader, MessageImageSource, MessageImagesContentInput, MessageImagesOwnerProps, UseConversation,
UserTextInput, UserTextInjected, UserTextNodeOwnerProps, UserTextNodeProps,
UseConversationViews,
} from './contract/slots.ts'
export type {
@@ -199,14 +199,7 @@
min-width: 0;
font: var(--dsw-font-xs-13);
font-family: Inter, var(--dsw-font-family);
}
.preview {
overflow: hidden;
color: var(--dsw-alias-label-primary-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.actions {
@@ -1,29 +1,24 @@
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useMemo, useState } from 'react'
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
FileTypeIcon, fileSizeText, IconEditOutlineRegular, InlineEditor, IconQueueOutlineRegular, IconSendOutlineRegular,
IconTrashOutlineRegular, projectUserText, Tooltip,
IconTrashOutlineRegular, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { UserTextFacts } from '../index.ts'
import { NS } from '../locales.ts'
import css from './QueueDock.module.css'
const EMPTY_QUEUE = [] as const
const QUEUE_PREVIEW_CHARS = 200
function previewOf(content: InboxState['next-turn'][number]['content']): string {
const flat = content
.filter(block => block.type !== 'image' && block.type !== 'file')
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
const QUEUE_TEXT_FACTS: Readonly<Partial<UserTextFacts>> = {
collapseWhitespace: true,
trimWhitespace: true,
}
function textOf(content: InboxState['next-turn'][number]['content']): string | null {
@@ -100,13 +95,14 @@ function QueueThumb({ attachment, loadImage, label }: {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
& PropsRenderFactories
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing. Local queued submissions
* show sending status and disabled actions until their Host queue rows arrive.
*/
export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) {
export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, renderFactorySlot, t }: QueueDockProps) {
const inbox = useProjection('inbox') as unknown as InboxState | undefined
const pendingSubmissions = useSession(s => s.pendingSubmissions)
const queue = useMemo(() => {
@@ -231,7 +227,10 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
))}
</span>
)}
<span className={css.preview}>{projectUserText(previewOf(row.content), [])}</span>
<span className={css.preview}>{renderFactorySlot('conversation.user.text', {
text: row.content.filter(block => block.type === 'text').map(block => block.text).join(''),
facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true,
})}</span>
</>
)}
{queueMutable && <div className={css.actions}>
@@ -345,7 +344,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
))}
</span>
)}
<span className={css.preview}>{projectUserText(submission.text, [])}</span>
<span className={css.preview}>{renderFactorySlot('conversation.user.text', {
text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true,
})}</span>
<span className={css.status} role="status">{t('queue.sending')}</span>
{queueMutable && <div className={css.actions}>
<button
@@ -0,0 +1,12 @@
.root {
display: contents;
}
.ellipsis {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,64 @@
/** User-text presentation delegates evaluation to its data-layer document and renders JSON business nodes. */
import { Fragment, useEffect, useMemo } from 'react'
import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots'
import { ReferenceChip, UserTextRun } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UserTextNodeProps } from '../contract/slots.ts'
import type { UserTextNode } from '../contract/user-text.ts'
import css from './UserText.module.css'
type UserTextOwner = Parameters<FactoryComponentPropsOf<'conversation.user.text'>['renderSlot']>[1]
/**
* Render original text through pure matching rules and business-owned node Slots.
* A failed parse retains the entire original text and reports the rule error.
* @param props - original text, confirmed facts, display policy, and framework seats.
* @returns ordered inline nodes with raw-text fallbacks for missing registrations.
*/
export function UserText({
text, facts, interactive = false, ellipsis = false,
useUserTextRevision, createUserTextEvaluator, reportTransformFailure, renderSlot,
}: FactoryComponentPropsOf<'conversation.user.text'>) {
useUserTextRevision(revision => revision)
const evaluate = useMemo(createUserTextEvaluator, [createUserTextEvaluator])
let nodes: readonly UserTextNode[] | undefined
let error: unknown
try {
nodes = evaluate({ text, facts })
} catch (cause: unknown) {
error = cause
}
const failed = nodes === undefined
useEffect(() => {
if (failed) reportTransformFailure(error)
}, [failed, error, reportTransformFailure])
const content = useMemo(() => nodes === undefined ? <UserTextRun text={text} /> : <>{nodes.map((node) => {
const raw = text.slice(node.start, node.end)
// The node ID determines its payload; TypeScript does not distribute this enclosing owner over the node union.
const owner = { node, raw, interactive } as UserTextOwner
return <Fragment key={`${node.id}:${node.start}`}>
{renderSlot('conversation.user.text.node', owner, {
entryKey: node.id,
fallback: <UserTextRun text={node.id === 'text' ? node.data.display ?? raw : raw} />,
})}
</Fragment>
})}</>, [nodes, text, interactive, renderSlot])
return <span className={ellipsis ? css.ellipsis : css.root}>{content}</span>
}
/**
* Display an unmatched substring without business interpretation.
* @param props - parser-produced plain-text node.
* @returns its inline text.
*/
export function PlainUserText({ node }: UserTextNodeProps<'text'>) {
return <UserTextRun text={node.data.display ?? node.data.text} />
}
/**
* Display a confirmed Session label without inventing navigation behavior.
* @param props - Session reference JSON and its original spelling.
* @returns the existing non-interactive Session chip.
*/
export function SessionReference({ node, raw }: UserTextNodeProps<'session'>) {
return <ReferenceChip appearance="session" label={node.data.label} title={raw} />
}
@@ -0,0 +1,89 @@
/** Display-only text transforms; source coordinates and business payloads remain unchanged. */
import type { UserTextNode, UserTextRule } from '../contract/user-text.ts'
function withDisplay(node: UserTextNode<'text'>, display: string): UserTextNode<'text'> {
if (display === (node.data.display ?? node.data.text)) return node
return { ...node, data: { ...node.data, display } }
}
function mapTextDisplay(
segments: readonly UserTextNode[], transform: (display: string) => string,
): readonly UserTextNode[] {
let result: UserTextNode[] | undefined
for (const [index, node] of segments.entries()) {
const updated = node.id === 'text' ? withDisplay(node, transform(node.data.display ?? node.data.text)) : node
if (updated !== node) result ??= segments.slice(0, index)
result?.push(updated)
}
return result ?? segments
}
/** Remove whole emoji graphemes only when `stripEmoji` is true; ordinary letters and digits are preserved. */
export const stripEmojiRule: UserTextRule<'text.strip-emoji'> = {
id: 'text.strip-emoji',
order: 1000,
transform(segments, context) {
if (!context.read('stripEmoji')) return segments
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
const emoji = /[\p{Extended_Pictographic}\p{Regional_Indicator}\p{Emoji_Presentation}]|^[0-9#*]\ufe0f?\u20e3/u
return mapTextDisplay(segments, (display) => {
let result = ''
for (const { segment } of segmenter.segment(display)) {
if (!emoji.test(segment)) result += segment
}
return result
})
},
}
/** Collapse whitespace across adjacent Text displays when enabled; business nodes separate runs. */
export const collapseWhitespaceRule: UserTextRule<'text.collapse-whitespace'> = {
id: 'text.collapse-whitespace',
order: 1010,
transform(segments, context) {
if (!context.read('collapseWhitespace')) return segments
let result: UserTextNode[] | undefined
let trailingSpace = false
for (const [index, node] of segments.entries()) {
if (node.id !== 'text') {
trailingSpace = false
result?.push(node)
continue
}
let display = (node.data.display ?? node.data.text).replace(/\s+/gu, ' ')
if (trailingSpace && display.startsWith(' ')) display = display.slice(1)
if (display.length > 0) trailingSpace = display.endsWith(' ')
const updated = withDisplay(node, display)
if (updated !== node) result ??= segments.slice(0, index)
result?.push(updated)
}
return result ?? segments
},
}
/** Trim document-edge display whitespace when enabled, skipping empty Text displays but never crossing a business node. */
export const trimWhitespaceRule: UserTextRule<'text.trim-whitespace'> = {
id: 'text.trim-whitespace',
order: 1020,
transform(segments, context) {
if (!context.read('trimWhitespace')) return segments
let result: UserTextNode[] | undefined
for (const direction of [1, -1]) {
let index = direction === 1 ? 0 : segments.length - 1
while (index >= 0 && index < segments.length) {
const node = (result ?? segments)[index]
if (!node || node.id !== 'text') break
const display = node.data.display ?? node.data.text
const trimmed = direction === 1 ? display.trimStart() : display.trimEnd()
const updated = withDisplay(node, trimmed)
if (updated !== node) {
result ??= [...segments]
result[index] = updated
}
if (trimmed.length > 0) break
index += direction
}
}
return result ?? segments
},
}
@@ -0,0 +1,78 @@
/** Session references transform only remaining Text segments; wire references are resolved before bare labels. */
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
session: { readonly label: string }
}
interface UserTextFacts {
sessionLabels: readonly string[]
}
}
interface SessionRange {
readonly start: number
readonly end: number
readonly label: string
}
function replaceSessions(
segments: readonly UserTextNode[],
rangesOf: (text: string) => readonly SessionRange[],
): readonly UserTextNode[] {
let result: UserTextNode[] | undefined
for (const [index, segment] of segments.entries()) {
if (segment.id !== 'text') {
result?.push(segment)
continue
}
const ranges = rangesOf(segment.data.text)
if (ranges.length === 0) {
result?.push(segment)
continue
}
result ??= segments.slice(0, index)
let cursor = 0
for (const range of ranges) {
if (range.start < cursor) continue
if (range.start > cursor) {
result.push({ id: 'text', start: segment.start + cursor, end: segment.start + range.start,
data: { text: segment.data.text.slice(cursor, range.start) } })
}
result.push({ id: 'session', start: segment.start + range.start, end: segment.start + range.end,
data: { label: range.label } })
cursor = range.end
}
if (cursor < segment.data.text.length) {
result.push({ id: 'text', start: segment.start + cursor, end: segment.end,
data: { text: segment.data.text.slice(cursor) } })
}
}
return result ?? segments
}
/** Session wire forms need no Facts; bare labels read their dependency only while unclaimed @ text remains. */
export const sessionReferenceRule: UserTextRule<'session'> = {
id: 'session',
order: 10,
transform(segments, context) {
const wire = replaceSessions(segments, text => [...text.matchAll(/@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu)]
.map(match => ({ start: match.index, end: match.index + match[0].length, label: match[1] as string })))
if (!wire.some(segment => segment.id === 'text' && segment.data.text.includes('@'))) return wire
const labels = context.read('sessionLabels')
if (labels === undefined || labels.length === 0) return wire
const unique = [...new Set(labels)]
return replaceSessions(wire, (text) => {
const ranges: SessionRange[] = []
for (const label of unique) {
const spelling = `@${label}`
let start = text.indexOf(spelling)
while (start >= 0) {
ranges.push({ start, end: start + spelling.length, label })
start = text.indexOf(spelling, start + spelling.length)
}
}
return ranges.sort((a, b) => a.start - b.start || b.end - a.end)
})
},
}
@@ -0,0 +1,295 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest'
import type {
UserTextFacts, UserTextNode, UserTextRule, UserTextTransformContext,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts'
import {
collapseWhitespaceRule, stripEmojiRule, trimWhitespaceRule,
} from '../src/client/user-text/display-rules.ts'
import { sessionReferenceRule } from '../src/client/user-text/session-reference-rule.ts'
const displayRules = [trimWhitespaceRule, collapseWhitespaceRule, stripEmojiRule]
function track(rule: UserTextRule) {
return vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => rule.transform(segments, context))
}
function setup(rules: readonly UserTextRule[] = displayRules): UserTextRegistry {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const registry = new UserTextRegistry(ctx)
for (const rule of rules) registry.register(rule)
return registry
}
function visible(nodes: readonly UserTextNode[]): string {
return nodes.map(node => node.id === 'text' ? node.data.display ?? node.data.text : `[${node.id}]`).join('')
}
function assertSource(text: string, nodes: readonly UserTextNode[]): void {
let cursor = 0
for (const node of nodes) {
expect(node.start).toBe(cursor)
expect(node.end).toBeGreaterThan(cursor)
if (node.id === 'text') expect(node.data.text).toBe(text.slice(node.start, node.end))
expect(Object.isFrozen(node)).toBe(true)
expect(Object.isFrozen(node.data)).toBe(true)
cursor = node.end
}
expect(cursor).toBe(text.length)
expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes)
}
function splitText(parts: readonly string[]): UserTextRule<'test.split-text'> {
return {
id: 'test.split-text', order: 0,
transform() {
let cursor = 0
return parts.map((text): UserTextNode<'text'> => {
const start = cursor
cursor += text.length
return { id: 'text', start, end: cursor, data: { text } }
})
},
}
}
describe('user-text display facts', () => {
it('declares independent boolean facts and an optional display string without new node kinds', () => {
expectTypeOf<UserTextFacts['collapseWhitespace']>().toEqualTypeOf<boolean>()
expectTypeOf<UserTextFacts['trimWhitespace']>().toEqualTypeOf<boolean>()
expectTypeOf<UserTextFacts['stripEmoji']>().toEqualTypeOf<boolean>()
expectTypeOf<UserTextFacts>().not.toHaveProperty('ellipsis')
expectTypeOf<UserTextFacts>().not.toHaveProperty('interactive')
expectTypeOf<UserTextFacts['sessionLabels']>().toEqualTypeOf<readonly string[]>()
expectTypeOf<UserTextNode<'text'>['data']['display']>().toEqualTypeOf<string | undefined>()
})
it('keeps absent options as an identity no-op, including empty input', () => {
const first = vi.fn((segments: readonly UserTextNode[]) => segments)
const registry = setup([{ id: 'test.observe', order: 0, transform: first }, ...displayRules])
const evaluate = registry.createEvaluator()
const text = ' 原文\r\n\t🙂e\u0301 '
const nodes = evaluate({ text, facts: {} })
expect(nodes).toBe(first.mock.lastCall?.[0])
expect(evaluate({ text, facts: {} })).toBe(nodes)
expect(nodes).toEqual([{ id: 'text', start: 0, end: text.length, data: { text } }])
expect(evaluate({ text: '', facts: {} })).toBe(first.mock.lastCall?.[0])
expect(evaluate({ text: '', facts: { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true } })).toEqual([])
})
it.each([
['collapseWhitespace', collapseWhitespaceRule, ' \t甲\r\n乙\u00a0 ', ' 甲 乙 '],
['trimWhitespace', trimWhitespaceRule, ' \t甲\r\n乙\u00a0 ', '甲\r\n乙'],
['stripEmoji', stripEmojiRule, ' 甲🙂乙 ', ' 甲乙 '],
] as const)('reads only %s, recomputes missing-to-true and restores the source when withdrawn', (flag, rule, text, display) => {
const reads: (keyof UserTextFacts)[] = []
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => rule.transform(segments, {
read(key) {
reads.push(key)
return context.read(key)
},
}))
const registry = setup([{ ...rule, transform }])
const evaluate = registry.createEvaluator()
const original = evaluate({ text, facts: {} })
expect(evaluate({ text, facts: { [flag]: true } })).toEqual([
{ id: 'text', start: 0, end: text.length, data: { text, display } },
])
expect(evaluate({ text, facts: {} })).toBe(original)
expect(evaluate({ text, facts: { [flag]: false } })).toBe(original)
expect(transform).toHaveBeenCalledTimes(4)
expect(reads).toEqual([flag, flag, flag, flag])
})
it('ignores unread business facts without truncating long display text', () => {
const transforms = displayRules.map(track)
const registry = setup(displayRules.map((rule, index) => ({ ...rule, transform: transforms[index]! })))
const evaluate = registry.createEvaluator()
const text = ` ${'原文🙂\n'.repeat(100)} `
const nodes = evaluate({ text, facts: {} })
expect(evaluate({ text, facts: { sessionLabels: ['first'] } })).toBe(nodes)
expect(evaluate({ text, facts: { sessionLabels: ['second'] } })).toBe(nodes)
expect(visible(nodes)).toBe(text)
for (const transform of transforms) expect(transform).toHaveBeenCalledOnce()
})
it('orders default display rules after the existing reference rule and preserves its atoms', () => {
expect([stripEmojiRule.order, collapseWhitespaceRule.order, trimWhitespaceRule.order]).toEqual([1000, 1010, 1020])
expect(sessionReferenceRule.order).toBeLessThan(stripEmojiRule.order)
const content = track(sessionReferenceRule)
const registry = setup([...displayRules, { ...sessionReferenceRule, transform: content }])
const evaluate = registry.createEvaluator()
const wire = '@[🧑‍🚀 引用](dsh-session:abc)'
const text = ` \t🙂甲\n ${wire} \r\n乙🇨🇳 \t`
const original = evaluate({ text, facts: {} })
const nodes = evaluate({ text, facts: { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true } })
expect(visible(nodes)).toBe('甲 [session] 乙')
expect(nodes[1]).toBe(original[1])
expect(nodes[1]).toEqual({ id: 'session', start: text.indexOf(wire), end: text.indexOf(wire) + wire.length,
data: { label: '🧑‍🚀 引用' } })
expect(content).toHaveBeenCalledOnce()
assertSource(text, nodes)
expect(registry.createEvaluator()({ text, facts: { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true } })).toEqual(nodes)
})
it('allows a display rule to run before reference matching when its ordinary order is lower', () => {
const observed: string[] = []
const registry = setup([
{ ...sessionReferenceRule, order: 0, transform: (segments, context) => {
observed.push(visible(segments))
return sessionReferenceRule.transform(segments, context)
} },
{ ...stripEmojiRule, order: -1 },
])
const text = '@[🙂ref](dsh-session:x)'
const input = { text, facts: { stripEmoji: true } }
const nodes = registry.createEvaluator()(input)
expect(observed).toEqual(['@[ref](dsh-session:x)'])
expect(nodes).toEqual([{ id: 'session', start: 0, end: text.length, data: { label: '🙂ref' } }])
assertSource(text, nodes)
})
it('allows custom transforms between and after the default display orders', () => {
const observed: string[] = []
const registry = setup([
...displayRules,
{ id: 'custom.between', order: 1005, transform: (segments) => {
observed.push(visible(segments))
return segments.map(node => node.id === 'text'
? { ...node, data: { ...node.data, display: `${node.data.display ?? node.data.text}\t乙 ` } } : node)
} },
{ id: 'custom.after', order: 1021, transform: (segments) => {
observed.push(visible(segments))
return segments
} },
])
const text = ' 🙂 甲 '
const facts = { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true }
const nodes = registry.createEvaluator()({ text, facts })
expect(observed).toEqual([' 甲 ', '甲 乙'])
expect(visible(nodes)).toBe('甲 乙')
assertSource(text, nodes)
})
it('applies strip, collapse and document trim to the preceding display value in ordinary order', () => {
const evaluate = setup().createEvaluator()
const text = ' \t🙂 \n甲 👨‍👩‍👧‍👦\t 乙🇨🇳 \t'
const facts = { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true }
expect(visible(evaluate({ text, facts }))).toBe('甲 乙')
expect(visible(evaluate({ text, facts: { ...facts, stripEmoji: false } }))).toBe('🙂 甲 👨‍👩‍👧‍👦 乙🇨🇳')
expect(visible(evaluate({ text, facts: { ...facts, collapseWhitespace: false } }))).toBe('甲 \t 乙')
expect(visible(evaluate({ text, facts: { ...facts, trimWhitespace: false } }))).toBe(' 甲 乙 ')
expect(visible(evaluate({ text, facts: {} }))).toBe(text)
})
it.each([
{ parts: ['a ', ' \nb'], expected: 'a b' },
{ parts: ['a ', '😀', ' b'], expected: 'a b' },
{ parts: ['a ', '😀', '\t', '😀', ' b'], expected: 'a b' },
{ parts: ['\t', '\n', 'a'], expected: ' a' },
{ parts: ['a ', '\n', '😀'], expected: 'a ' },
{ parts: ['a', '😀', 'b'], expected: 'ab' },
])('collapses contiguous display whitespace across adjacent Text nodes: $parts', ({ parts, expected }) => {
const registry = setup([splitText(parts), stripEmojiRule, collapseWhitespaceRule])
const evaluate = registry.createEvaluator()
const text = parts.join('')
const original = evaluate({ text, facts: { stripEmoji: true } })
const nodes = evaluate({ text, facts: { stripEmoji: true, collapseWhitespace: true } })
expect(visible(nodes)).toBe(expected)
expect(nodes).toHaveLength(parts.length)
if (parts[0]?.startsWith('a')) expect(nodes[0]).toBe(original[0])
expect(evaluate({ text, facts: { stripEmoji: true } })).toBe(original)
assertSource(text, nodes)
})
it('resets whitespace collapsing at each atomic reference and keeps spaces on both sides', () => {
const parts = ['a ', '@[😀 ref](dsh-session:x)', ' ', ' \nb ', '@[tail](dsh-session:y)', ' ', '\tc']
const registry = setup([splitText(parts), sessionReferenceRule, stripEmojiRule, collapseWhitespaceRule])
const evaluate = registry.createEvaluator()
const text = parts.join('')
const original = evaluate({ text, facts: { stripEmoji: true } })
const nodes = evaluate({ text, facts: { stripEmoji: true, collapseWhitespace: true } })
expect(visible(nodes)).toBe('a [session] b [session] c')
expect(nodes).toHaveLength(original.length)
expect(nodes[0]).toBe(original[0])
for (const [index, node] of nodes.entries()) {
if (node.id === 'session') expect(node).toBe(original[index])
}
assertSource(text, nodes)
})
it.each([
{ parts: [' \t', '🙂', '\n ', '甲 ', ' 乙', ' \t', '🙂', '\n '], expected: '甲 乙' },
{ parts: [' \t', '🙂', '\n '], expected: '' },
{ parts: ['甲 ', ' 乙'], expected: '甲 乙' },
])('trims only document edges across empty text displays: $parts', ({ parts, expected }) => {
const registry = setup([splitText(parts), stripEmojiRule, trimWhitespaceRule])
const text = parts.join('')
const evaluate = registry.createEvaluator()
const original = evaluate({ text, facts: { stripEmoji: true } })
const nodes = evaluate({ text, facts: { stripEmoji: true, trimWhitespace: true } })
expect(visible(nodes)).toBe(expected)
expect(nodes).toHaveLength(parts.length)
for (let index = 0; index < parts.length; index++) {
if (parts[index] === '甲 ' || parts[index] === ' 乙') expect(nodes[index]).toBe(original[index])
}
assertSource(text, nodes)
})
it.each([
[' \n🙂@[a](dsh-session:x) \t @[b](dsh-session:y)🙂 \t', '[session] \t [session]'],
['@[a](dsh-session:x) \t 甲 \n @[b](dsh-session:y)', '[session] \t 甲 \n [session]'],
['@[a](dsh-session:x) \t 甲 \n', '[session] \t 甲'],
[' \t 甲 \n @[b](dsh-session:y)', '甲 \n [session]'],
])('does not trim through visible business nodes or remove reference spacing: %s', (text, expected) => {
const registry = setup([sessionReferenceRule, stripEmojiRule, trimWhitespaceRule])
const nodes = registry.createEvaluator()({ text, facts: { stripEmoji: true, trimWhitespace: true } })
expect(visible(nodes)).toBe(expected)
assertSource(text, nodes)
})
it('removes whole emoji graphemes without losing Han, ASCII numbers, combining text or joiners in ordinary text', () => {
const text = '甲0 123 #* e\u0301 क्‍ष 👨‍👩‍👧‍👦👍🏽🇨🇳🇺🇳1️⃣2⃣#️⃣*️⃣🏳️‍🌈❤️🧑🏾‍🚀乙'
const nodes = setup().createEvaluator()({ text, facts: { stripEmoji: true } })
expect(visible(nodes)).toBe('甲0 123 #* e\u0301 क्‍ष 乙')
assertSource(text, nodes)
})
it('does not classify enclosing-keycap marks on Han or letters as emoji sequences', () => {
const text = '甲\u20e3 e\u20e3 123 7\ufe0f 1️⃣'
const nodes = setup().createEvaluator()({ text, facts: { stripEmoji: true } })
expect(visible(nodes)).toBe('甲\u20e3 e\u20e3 123 7\ufe0f ')
assertSource(text, nodes)
})
it('preserves input identities when enabled transforms have no visible change', () => {
const first = vi.fn((segments: readonly UserTextNode[]) => segments)
const registry = setup([{ id: 'test.observe', order: 0, transform: first }, ...displayRules])
const evaluate = registry.createEvaluator()
const text = '甲 乙123'
const original = evaluate({ text, facts: {} })
expect(evaluate({ text, facts: { stripEmoji: true, collapseWhitespace: true, trimWhitespace: true } })).toBe(original)
expect(original).toBe(first.mock.lastCall?.[0])
})
it('reuses equivalent display output and stops downstream invalidation', () => {
const first = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
context.read('sessionLabels')
return collapseWhitespaceRule.transform(segments, context).map(node => node.id === 'text'
? { ...node, data: { ...node.data } } : node)
})
const last = track(trimWhitespaceRule)
const registry = setup([{ ...collapseWhitespaceRule, transform: first }, { ...trimWhitespaceRule, transform: last }])
const evaluate = registry.createEvaluator()
const text = '甲\t乙'
const facts = { collapseWhitespace: true, trimWhitespace: true }
const original = evaluate({ text, facts })
expect(evaluate({ text, facts: { ...facts, sessionLabels: ['first'] } })).toBe(original)
expect(first).toHaveBeenCalledTimes(2)
expect(last).toHaveBeenCalledOnce()
expect(evaluate({ text, facts: { ...facts, sessionLabels: ['second'], trimWhitespace: false } })).toBe(original)
expect(last).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,713 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, expectTypeOf, it, onTestFinished, vi } from 'vitest'
import type {
UserTextDataMap, UserTextDocumentInput, UserTextEvaluator, UserTextNode, UserTextRule, UserTextTransformContext,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { UserTextRegistry } from '../src/client/conversation/user-text-registry.ts'
interface AnnotationData {
readonly label: string
readonly nested?: { readonly values: readonly (string | number | boolean | null)[] }
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
'test.annotation': AnnotationData
'test.A': { readonly label: string }
'test.a': { readonly label: string }
'test.z': { readonly label: string }
'': { readonly label: string }
'test.function': { readonly callback: () => void }
'test.date': { readonly date: Date }
'test.map': { readonly map: Map<string, string> }
'test.symbol': { readonly symbol: symbol }
'test.bigint': { readonly bigint: bigint }
'test.unknown': { readonly value: unknown }
'test.nested': { readonly values: readonly { readonly action: () => void }[] }
}
interface UserTextFacts {
readonly testEnabled: boolean
readonly testLabel: string
readonly testOther: number
readonly skillNames: readonly string[]
}
}
declare module '@deepseek-ai/cordis' {
interface Context { userTextTestRegistry: UserTextRegistry }
}
function setup(): { ctx: Context; registry: UserTextRegistry } {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
return { ctx, registry: new UserTextRegistry(ctx) }
}
type TestId = 'test.A' | 'test.a' | 'test.z' | ''
function plain(text: string, start = 0): UserTextNode<'text'> {
return { id: 'text', start, end: start + text.length, data: { text } }
}
function claim(segments: readonly UserTextNode[], id: TestId, needle: string, label: string = id): readonly UserTextNode[] {
let changed = false
const result = segments.flatMap((node): UserTextNode[] => {
if (node.id !== 'text') return [node]
const index = node.data.text.indexOf(needle)
if (index < 0) return [node]
changed = true
const start = node.start + index
const end = start + needle.length
return [
...(index > 0 ? [plain(node.data.text.slice(0, index), node.start)] : []),
{ id, start, end, data: { label } },
...(end < node.end ? [plain(node.data.text.slice(index + needle.length), end)] : []),
]
})
return changed ? result : segments
}
function rule(id: TestId, order: number, needle: string): UserTextRule<TestId> {
return { id, order, transform: segments => claim(segments, id, needle) }
}
function assertCoverage(text: string, nodes: readonly UserTextNode[]): void {
let cursor = 0
for (const node of nodes) {
expect(node.start).toBe(cursor)
expect(node.end).toBeGreaterThan(node.start)
if (node.id === 'text') expect(node.data.text).toBe(text.slice(node.start, node.end))
cursor = node.end
}
expect(cursor).toBe(text.length)
expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes)
}
describe('UserTextRegistry linear transforms', () => {
it('initializes empty text as [] and nonempty UTF-16 text as one verbatim segment', () => {
const { registry } = setup()
const transform = vi.fn((segments: readonly UserTextNode[]) => segments)
registry.register({ id: 'test.a', order: 0, transform })
const evaluate = registry.createEvaluator()
expectTypeOf(evaluate).toEqualTypeOf<UserTextEvaluator>()
expect(evaluate({ text: '', facts: {} })).toEqual([])
const text = ' 原文\r\n\t🙂e\u0301 /path @name '
const nodes = evaluate({ text, facts: {} })
expect(nodes).toEqual([plain(text)])
expect(transform.mock.lastCall?.[0]).toBe(nodes)
assertCoverage(text, nodes)
})
it('runs B against remaining de after A claims bc, rather than filtering whole-text candidates', () => {
const { registry } = setup()
const first = vi.fn((segments: readonly UserTextNode[]) => claim(segments, 'test.a', 'bc'))
const second = vi.fn((segments: readonly UserTextNode[]) => {
expect(segments.map(node => node.id === 'text' ? node.data.text : node.id)).toEqual(['a', 'test.a', 'de'])
return claim(segments, 'test.z', 'de')
})
registry.register({ id: 'test.z', order: 1, transform: second })
registry.register({ id: 'test.a', order: 0, transform: first })
const nodes = registry.createEvaluator()({ text: 'abcde', facts: {} })
expect(nodes).toEqual([
plain('a'), { id: 'test.a', start: 1, end: 3, data: { label: 'test.a' } },
{ id: 'test.z', start: 3, end: 5, data: { label: 'test.z' } },
])
expect(first).toHaveBeenCalledOnce()
expect(second).toHaveBeenCalledOnce()
})
it.each([
[rule('test.a', 10, 'bc'), rule('test.z', -1, 'cde'), 'test.z'],
[rule('test.a', -1, 'bc'), rule('test.z', 0, 'bcde'), 'test.a'],
[rule('test.a', 0, 'bc'), rule('test.z', 0, 'bcde'), 'test.a'],
[rule('test.A', 0, 'bc'), rule('test.a', 0, 'bcde'), 'test.A'],
] as const)('sorts by order then code-unit ID independently of load order (%j, %j)', (a, z, id) => {
const { registry: forward } = setup()
const { registry: reverse } = setup()
forward.register(a)
forward.register(z)
reverse.register(z)
reverse.register(a)
const input = { text: 'abcde', facts: {} }
const nodes = forward.createEvaluator()(input)
expect(nodes.filter(node => node.id !== 'text').map(node => node.id)).toEqual([id])
expect(reverse.createEvaluator()(input)).toEqual(nodes)
})
it('sorts arbitrary business labels only by order and code-unit ID', () => {
const { registry } = setup()
const seen: string[] = []
for (const definition of [
{ id: 'display.z', order: -100 },
{ id: 'content.late', order: 10_000 },
{ id: 'display.A', order: -100 },
{ id: 'content.default', order: 0 },
{ id: 'display.first', order: -101 },
]) {
registry.register({ ...definition, transform: (segments) => { seen.push(definition.id); return segments } })
}
registry.createEvaluator()({ text: 'abc', facts: {} })
expect(seen).toEqual(['display.first', 'display.A', 'display.z', 'content.default', 'content.late'])
expectTypeOf<UserTextRule>().not.toHaveProperty('phase')
})
it.each([-100, 100])('allows ordinary display updates at order %s before or after business claims', (order) => {
const { registry } = setup()
registry.register({ id: 'test.split', order: -200, transform: () => [plain('a'), plain('b', 1), plain('c', 2)] })
registry.register(rule('test.a', 0, 'b'))
const seen: string[][] = []
registry.register({
id: 'test.presentation', order,
transform: (segments) => {
seen.push(segments.map(node => node.id))
return segments.map(node => node.id === 'text'
? { ...node, data: { ...node.data, display: node.data.text.toUpperCase() } } : node)
},
})
const nodes = registry.createEvaluator()({ text: 'abc', facts: {} })
expect(seen).toEqual([order < 0 ? ['text', 'text', 'text'] : ['text', 'test.a', 'text']])
expect(nodes).toEqual([
{ ...plain('a'), data: { text: 'a', display: 'A' } },
{ id: 'test.a', start: 1, end: 2, data: { label: 'test.a' } },
{ ...plain('c', 2), data: { text: 'c', display: 'C' } },
])
assertCoverage('abc', nodes)
})
it('allows one transform to split Text, update display and create its own business node', () => {
const { registry } = setup()
registry.register({
id: 'test.a', order: 0,
transform: () => [
{ ...plain('a'), data: { text: 'a', display: 'A' } },
{ id: 'test.a', start: 1, end: 2, data: { label: 'claimed' } },
{ ...plain('c', 2), data: { text: 'c', display: '' } },
],
})
const nodes = registry.createEvaluator()({ text: 'abc', facts: {} })
expect(nodes.map(node => node.data)).toEqual([{ text: 'a', display: 'A' }, { label: 'claimed' }, { text: 'c', display: '' }])
assertCoverage('abc', nodes)
})
it('allows display-bearing Text splits and merges with equal fresh and incremental results', () => {
const { registry } = setup()
registry.register({
id: 'test.split', order: 0,
transform: () => [
{ ...plain('a'), data: { text: 'a', display: 'A' } },
{ ...plain('bc', 1), data: { text: 'bc', display: 'BC' } },
],
})
registry.register({
id: 'test.merge', order: 1,
transform: (segments, context) => context.read('testEnabled')
? [{ ...plain('abc'), data: { text: 'abc', display: segments.map(node =>
node.id === 'text' ? node.data.display ?? node.data.text : '').join('') } }] : segments,
})
const evaluate = registry.createEvaluator()
for (const testEnabled of [false, true, false]) {
const input = { text: 'abc', facts: { testEnabled } }
const nodes = evaluate(input)
expect(nodes).toHaveLength(testEnabled ? 1 : 2)
expect(nodes).toEqual(registry.createEvaluator()(input))
assertCoverage(input.text, nodes)
}
})
it('updates display through read dependencies, reuses atoms and matches fresh evaluation', () => {
const { registry } = setup()
registry.register(rule('test.a', 0, 'b'))
const evaluate = registry.createEvaluator()
const original = evaluate({ text: 'abc', facts: {} })
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
const label = context.read('testLabel')
return label === undefined ? segments : segments.map(node => node.id === 'text'
? { ...node, data: { ...node.data, display: label + node.data.text } } : node)
})
registry.register({ id: 'test.presentation', order: 1, transform })
const before = evaluate({ text: 'abc', facts: { testLabel: 'first' } })
expect(evaluate({ text: 'abc', facts: { testLabel: 'first', testOther: 1 } })).toBe(before)
expect(transform).toHaveBeenCalledOnce()
for (const facts of [{ testLabel: 'second' }, { testLabel: '' }, {}]) {
const input = { text: 'abc', facts }
const nodes = evaluate(input)
expect(nodes).toEqual(registry.createEvaluator()(input))
expect(nodes[1]).toBe(original[1])
assertCoverage(input.text, nodes)
}
expect(evaluate({ text: 'abc', facts: {} })).toBe(original)
})
it('constrains declaration-merged JSON payloads recursively', () => {
expectTypeOf<UserTextNode<'test.annotation'>['data']>().toExtend<AnnotationData>()
expectTypeOf<UserTextDataMap['test.function']>().not.toExtend<UserTextNode<'test.function'>['data']>()
expectTypeOf<UserTextDataMap['test.date']>().not.toExtend<UserTextNode<'test.date'>['data']>()
expectTypeOf<UserTextDataMap['test.map']>().not.toExtend<UserTextNode<'test.map'>['data']>()
expectTypeOf<UserTextDataMap['test.symbol']>().not.toExtend<UserTextNode<'test.symbol'>['data']>()
expectTypeOf<UserTextDataMap['test.bigint']>().not.toExtend<UserTextNode<'test.bigint'>['data']>()
expectTypeOf<UserTextDataMap['test.unknown']>().not.toExtend<UserTextNode<'test.unknown'>['data']>()
expectTypeOf<UserTextDataMap['test.nested']>().not.toExtend<UserTextNode<'test.nested'>['data']>()
})
it('performs zero transforms for unread facts and a replaced outer facts object', () => {
const { registry } = setup()
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
if (!segments.some(node => node.id === 'text' && node.data.text.includes('/'))) return segments
context.read('skillNames')
return segments
})
registry.register({ id: 'test.a', order: 0, transform })
const evaluate = registry.createEvaluator()
const previous = evaluate({ text: 'no candidate', facts: {} })
expect(evaluate({ text: 'no candidate', facts: { skillNames: ['new'], testOther: 1 } })).toBe(previous)
expect(transform).toHaveBeenCalledOnce()
const skillNames = ['x']
evaluate({ text: '/x', facts: { skillNames } })
transform.mockClear()
evaluate({ text: '/x', facts: { skillNames, testOther: 2 } })
expect(transform).not.toHaveBeenCalled()
})
it('borrows fact fields without traversing or freezing the caller values', () => {
const { registry } = setup()
const skillNames = ['x']
registry.register({
id: 'test.a', order: 0,
transform: (segments, context) => {
expect(context.read('skillNames')).toBe(skillNames)
return segments
},
})
registry.createEvaluator()({ text: '/x', facts: { skillNames } })
expect(Object.isFrozen(skillNames)).toBe(false)
})
it('tracks missing fields and replaces conditional read sets after each execution', () => {
const { registry } = setup()
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
if (!context.read('testEnabled')) return segments
return claim(segments, 'test.a', 'x', context.read('testLabel') ?? 'missing')
})
registry.register({ id: 'test.a', order: 0, transform })
const evaluate = registry.createEvaluator()
evaluate({ text: 'x', facts: {} })
expect(evaluate({ text: 'x', facts: { testEnabled: true } })[0]?.data).toEqual({ label: 'missing' })
expect(evaluate({ text: 'x', facts: { testEnabled: true, testLabel: 'ready' } })[0]?.data).toEqual({ label: 'ready' })
evaluate({ text: 'x', facts: { testEnabled: false, testLabel: 'ready' } })
transform.mockClear()
evaluate({ text: 'x', facts: { testEnabled: false, testLabel: 'changed' } })
expect(transform).not.toHaveBeenCalled()
})
it('updates skillNames without running independent stages when output stays equivalent', () => {
const { registry } = setup()
const first = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
context.read('testOther')
return segments
})
const skill = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
context.read('skillNames')
return claim(segments, 'test.a', 'x', 'same')
})
const last = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
context.read('testLabel')
return segments
})
registry.register({ id: 'test.A', order: -1, transform: first })
registry.register({ id: 'test.a', order: 0, transform: skill })
registry.register({ id: 'test.z', order: 1, transform: last })
const evaluate = registry.createEvaluator()
const before = evaluate({ text: 'x!', facts: { skillNames: ['a'], testLabel: 'old' } })
const after = evaluate({ text: 'x!', facts: { skillNames: ['b'], testLabel: 'old' } })
expect(after).toBe(before)
expect(after[0]).toBe(before[0])
expect(after[1]).toBe(before[1])
expect(first).toHaveBeenCalledOnce()
expect(skill).toHaveBeenCalledTimes(2)
expect(last).toHaveBeenCalledOnce()
expect(evaluate({ text: 'x!', facts: { skillNames: ['c'], testLabel: 'new' } })).toBe(before)
expect(last).toHaveBeenCalledTimes(2)
})
it('aligns equal new nodes to input identities and keeps unchanged nodes when their indices move', () => {
const { registry } = setup()
const pass = vi.fn((segments: readonly UserTextNode[]) => segments.map(node => ({ ...node, data: { ...node.data } }) as UserTextNode))
registry.register({ id: 'test.a', order: 0, transform: pass })
const evaluate = registry.createEvaluator()
const nodes = evaluate({ text: 'xyz', facts: {} })
expect(nodes).toBe(pass.mock.calls[0]?.[0])
registry.register({ id: 'test.z', order: 1, transform: segments => claim(segments, 'test.z', 'z') })
const withLast = evaluate({ text: 'xyz', facts: {} })
registry.register({ id: 'test.A', order: -1, transform: segments => claim(segments, 'test.A', 'x') })
const withFirst = evaluate({ text: 'xyz', facts: {} })
expect(withFirst[2]).toBe(withLast[1])
})
it('compares nested JSON by value, ignores object key order, and propagates actual payload changes', () => {
const { registry } = setup()
registry.register({
id: 'test.annotation', order: 0,
transform: (_segments, context) => {
const label = context.read('testLabel') ?? 'same'
const version = context.read('testOther') ?? 0
const nested = { values: ['文字', 3, true, null] }
const data = version % 2 ? { nested, label } : { label, nested }
return [{ id: 'test.annotation', start: 0, end: 3, data }]
},
})
const last = vi.fn((segments: readonly UserTextNode[]) => segments)
registry.register({ id: 'test.z', order: 1, transform: last })
const evaluate = registry.createEvaluator()
const original = evaluate({ text: 'abc', facts: {} })
expect(evaluate({ text: 'abc', facts: { testOther: 1 } })).toBe(original)
expect(last).toHaveBeenCalledOnce()
const changed = evaluate({ text: 'abc', facts: { testOther: 2, testLabel: 'changed' } })
expect(changed).not.toBe(original)
expect(changed[0]?.data).toMatchObject({ label: 'changed' })
expect(last).toHaveBeenCalledTimes(2)
expect(evaluate({ text: 'abc', facts: { testOther: 3, testLabel: 'changed' } })).toBe(changed)
expect(last).toHaveBeenCalledTimes(2)
})
it('invalidates every stage on text changes and keeps evaluators isolated', () => {
const { registry } = setup()
const a = vi.fn((segments: readonly UserTextNode[]) => claim(segments, 'test.a', 'x'))
const z = vi.fn((segments: readonly UserTextNode[]) => segments)
registry.register({ id: 'test.a', order: 0, transform: a })
registry.register({ id: 'test.z', order: 1, transform: z })
const left = registry.createEvaluator()
const right = registry.createEvaluator()
const input = { text: 'x', facts: {} }
const first = left(input)
expect(right(input)).not.toBe(first)
expect(left(input)).toBe(first)
expect(a).toHaveBeenCalledTimes(2)
left({ text: 'xx', facts: {} })
expect(a).toHaveBeenCalledTimes(3)
expect(z).toHaveBeenCalledTimes(3)
expect(right(input)).toEqual(first)
expect(a).toHaveBeenCalledTimes(3)
})
it('handles insertion, removal, definition replacement and reordering against fresh evaluation', () => {
const { registry } = setup()
const a = vi.fn((segments: readonly UserTextNode[]) => claim(segments, 'test.a', 'bc'))
const disposeA = registry.register({ id: 'test.a', order: 0, transform: a })
const evaluate = registry.createEvaluator()
const input = { text: 'abcde', facts: {} }
evaluate(input)
let disposeZ = registry.register(rule('test.z', 1, 'de'))
const inserted = evaluate(input)
expect(a).toHaveBeenCalledOnce()
expect(inserted).toEqual(registry.createEvaluator()(input))
disposeZ()
expect(evaluate(input)).toEqual(registry.createEvaluator()(input))
disposeZ = registry.register(rule('test.z', -1, 'cde'))
expect(evaluate(input)).toEqual(registry.createEvaluator()(input))
disposeA()
registry.register(rule('test.a', -2, 'ab'))
expect(evaluate(input)).toEqual(registry.createEvaluator()(input))
disposeZ()
expect(evaluate(input)).toEqual(registry.createEvaluator()(input))
})
it('keeps incremental results deeply equal to fresh documents over facts and text changes', () => {
const { registry } = setup()
registry.register({
id: 'test.a', order: 0,
transform: (segments, context) => context.read('testEnabled')
? claim(segments, 'test.a', 'x', context.read('testLabel') ?? 'missing') : segments,
})
registry.register(rule('test.z', 1, 'yz'))
const evaluate = registry.createEvaluator()
for (const text of ['xyz', '🙂xyz', '', 'xyz']) {
for (const facts of [{}, { testEnabled: true }, { testEnabled: true, testLabel: 'new' }, { testEnabled: false }]) {
const input: UserTextDocumentInput = { text, facts }
const nodes = evaluate(input)
expect(nodes).toEqual(registry.createEvaluator()(input))
assertCoverage(text, nodes)
}
}
})
it.each([
[-1, 1], [0, 0], [2, 1], [0, 4], [0.5, 1], [0, 1.5], [NaN, 1], [0, Infinity],
])('rejects invalid UTF-16 range [%s, %s)', (start, end) => {
const { registry } = setup()
registry.register({ id: 'test.a', order: 0, transform: () => [{ id: 'test.a', start, end, data: { label: 'bad' } }] })
expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/test\.a/)
})
it.each([
{ name: 'gap', output: [plain('a'), plain('c', 2)] },
{ name: 'overlap', output: [plain('ab'), plain('bc', 1)] },
{ name: 'out of order', output: [plain('bc', 1), plain('a')] },
{ name: 'missing tail', output: [plain('a')] },
{ name: 'wrong text payload', output: [plain('xyz')] },
{ name: 'wrong text with display', output: [{ ...plain('xyz'), data: { text: 'xyz', display: 'abc' } }] },
{ name: 'missing tail with display', output: [{ ...plain('ab'), data: { text: 'ab', display: 'abc' } }] },
{ name: 'foreign business ID', output: [{ id: 'test.z' as const, start: 0, end: 3, data: { label: 'z' } }] },
])('rejects $name instead of publishing partial coverage', ({ output }) => {
const { registry } = setup()
registry.register({ id: 'test.a', order: 0, transform: () => output })
expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/test\.a/)
})
it('rejects a new business node spanning adjacent input Text segments', () => {
const { registry } = setup()
registry.register({ id: 'test.split', order: 0, transform: () => [plain('a'), plain('bc', 1)] })
registry.register({
id: 'test.a', order: 1,
transform: () => [{ id: 'test.a', start: 0, end: 3, data: { label: 'claimed' } }],
})
expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/test\.a/)
})
it.each(['consume', 'rewrite', 'flatten'] as const)('preserves upstream atomic nodes against %s', (operation) => {
const { registry } = setup()
registry.register(rule('test.a', 0, 'b'))
registry.register({
id: 'test.z', order: 1,
transform: (segments) => {
if (operation === 'consume') return [{ id: 'test.z', start: 0, end: 3, data: { label: 'z' } }]
if (operation === 'flatten') return [plain('abc')]
return segments.map(node => node.id === 'test.a' ? { ...node, data: { label: 'changed' } } : node)
},
})
expect(() => registry.createEvaluator()({ text: 'abc', facts: {} })).toThrow(/test\.z/)
})
it.each(['array', 'node', 'payload'] as const)('freezes %s input against mutation', (target) => {
const { registry } = setup()
registry.register({
id: 'test.a', order: 0,
transform: (segments) => {
expect(Object.isFrozen(segments)).toBe(true)
expect(Object.isFrozen(segments[0])).toBe(true)
expect(Object.isFrozen(segments[0]?.data)).toBe(true)
if (target === 'array') Object.assign(segments, { 0: plain('bad') })
else if (target === 'node') Object.assign(segments[0]!, { end: 2 })
else Object.assign(segments[0]!.data, { text: 'bad' })
return segments
},
})
expect(() => registry.createEvaluator()({ text: 'abc', facts: Object.freeze({}) })).toThrow(/test\.a/)
})
it('closes escaped read contexts after both success and failure', () => {
const { registry } = setup()
let escaped: UserTextTransformContext | undefined
registry.register({
id: 'test.a', order: 0,
transform: (segments, context) => {
escaped = context
if (context.read('testEnabled')) throw new Error('failed')
return segments
},
})
const evaluate = registry.createEvaluator()
evaluate({ text: 'x', facts: {} })
expect(() => escaped?.read('testLabel')).toThrow(/closed|active|synchronous/)
expect(() => evaluate({ text: 'x', facts: { testEnabled: true } })).toThrow(/test\.a/)
expect(() => escaped?.read('testLabel')).toThrow(/closed|active|synchronous/)
})
it('rejects reentrant evaluation without corrupting a later retry', () => {
const { registry } = setup()
const evaluate = registry.createEvaluator()
registry.register({
id: 'test.a', order: 0,
transform: (segments, context) => {
if (context.read('testEnabled')) evaluate({ text: 'nested', facts: {} })
return segments
},
})
expect(() => evaluate({ text: 'outer', facts: { testEnabled: true } })).toThrow(/test\.a/)
expect(evaluate({ text: 'outer', facts: { testEnabled: false } })).toEqual([plain('outer')])
})
it('caches failures with ruleId/cause and retries when their missing dependency appears', () => {
const { registry } = setup()
const cause = new Error('not ready')
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
const label = context.read('testLabel')
if (label === undefined) throw cause
return claim(segments, 'test.a', 'x', label)
})
const last = vi.fn((segments: readonly UserTextNode[]) => segments)
registry.register({ id: 'test.a', order: 0, transform })
registry.register({ id: 'test.z', order: 1, transform: last })
const evaluate = registry.createEvaluator()
let failure: unknown
try { evaluate({ text: 'x', facts: {} }) } catch (error) { failure = error }
expect(failure).toBeInstanceOf(Error)
expect(failure).toHaveProperty('ruleId', 'test.a')
expect(failure).toHaveProperty('cause', cause)
expect(() => evaluate({ text: 'x', facts: { testOther: 1 } })).toThrow(failure)
expect(transform).toHaveBeenCalledOnce()
expect(last).not.toHaveBeenCalled()
expect(evaluate({ text: 'x', facts: { testLabel: 'ready' } })[0]?.data).toEqual({ label: 'ready' })
expect(transform).toHaveBeenCalledTimes(2)
expect(last).toHaveBeenCalledOnce()
})
it('replaces reads after failure, reuses recovered output, and removes failed stages', () => {
const { registry } = setup()
const transform = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
if (context.read('testEnabled')) throw new Error('blocked')
return claim(segments, 'test.a', 'x', context.read('testLabel') ?? 'same')
})
const dispose = registry.register({ id: 'test.a', order: 0, transform })
const evaluate = registry.createEvaluator()
const original = evaluate({ text: 'x', facts: { testEnabled: false } })
expect(() => evaluate({ text: 'x', facts: { testEnabled: true } })).toThrow(/test\.a/)
expect(() => evaluate({ text: 'x', facts: { testEnabled: true, testLabel: 'same' } })).toThrow(/test\.a/)
expect(transform).toHaveBeenCalledTimes(2)
expect(evaluate({ text: 'x', facts: { testEnabled: false, testLabel: 'same' } })).toBe(original)
expect(() => evaluate({ text: 'x', facts: { testEnabled: true } })).toThrow(/test\.a/)
dispose()
expect(evaluate({ text: 'x', facts: { testEnabled: true } })).toEqual([plain('x')])
registry.register(rule('test.a', 0, 'x'))
expect(evaluate({ text: 'x', facts: {} })[0]?.id).toBe('test.a')
})
it('rejects asynchronous output and closes its read context immediately', () => {
const { registry } = setup()
let escaped: UserTextTransformContext | undefined
registry.register({
id: 'test.a', order: 0,
// @ts-expect-error Transforms cannot return promises, even when already resolved.
transform: (segments, context) => {
escaped = context
return Promise.resolve(segments)
},
})
expect(() => registry.createEvaluator()({ text: 'x', facts: {} })).toThrow(/test\.a/)
expect(() => escaped?.read('testLabel')).toThrow(/closed|synchronous/)
})
it('retains downstream dirty dependencies across a failed upstream evaluation', () => {
const { registry } = setup()
registry.register({
id: 'test.a', order: 0,
transform: (segments, context) => {
if (context.read('testEnabled')) throw new Error('blocked')
return segments
},
})
const last = vi.fn((segments: readonly UserTextNode[], context: UserTextTransformContext) => {
return claim(segments, 'test.z', 'x', context.read('testLabel') ?? 'old')
})
registry.register({ id: 'test.z', order: 1, transform: last })
const evaluate = registry.createEvaluator()
evaluate({ text: 'x', facts: { testEnabled: false, testLabel: 'old' } })
expect(() => evaluate({ text: 'x', facts: { testEnabled: true, testLabel: 'new' } })).toThrow(/test\.a/)
expect(() => evaluate({ text: 'x', facts: { testEnabled: true, testLabel: 'new' } })).toThrow(/test\.a/)
expect(evaluate({ text: 'x', facts: { testEnabled: false, testLabel: 'new' } })[0]?.data).toEqual({ label: 'new' })
expect(last).toHaveBeenCalledTimes(2)
})
it.each([Infinity, -Infinity, NaN])('rejects invalid order %s before publication', (order) => {
const { registry } = setup()
const revision = registry.getSnapshot()
const listener = vi.fn()
registry.subscribe(listener)
expect(() => registry.register(rule('test.a', order, 'x'))).toThrow(/test\.a/)
expect(registry.getSnapshot()).toBe(revision)
expect(listener).not.toHaveBeenCalled()
})
it('rejects empty, reserved and duplicate IDs without changing revision', () => {
const { registry } = setup()
registry.register(rule('test.a', 0, 'x'))
const revision = registry.getSnapshot()
expect(() => registry.register(rule('', 0, 'x'))).toThrow(/id|ID/)
expect(() => registry.register({ id: 'text', order: 0, transform: segments => segments })).toThrow(/text/)
expect(() => registry.register(rule('test.a', 1, 'x'))).toThrow(/test\.a/)
expect(registry.getSnapshot()).toBe(revision)
})
it('publishes monotonic revisions and evaluates current rules without document subscriptions', () => {
const { registry } = setup()
const subscribe = vi.spyOn(registry, 'subscribe')
const evaluate = registry.createEvaluator()
expect(subscribe).not.toHaveBeenCalled()
const revision = registry.getSnapshot()
expectTypeOf(revision).toEqualTypeOf<number>()
const seen: number[] = []
const unsubscribe = registry.subscribe(() => { seen.push(registry.getSnapshot()) })
const dispose = registry.register(rule('test.a', 0, 'a'))
expect(registry.getSnapshot()).toBeGreaterThan(revision)
const input = { text: 'abc', facts: {} }
expect(evaluate(input)[0]?.id).toBe('test.a')
dispose()
expect(evaluate(input)).toEqual([plain('abc')])
expect(seen[1]).toBeGreaterThan(seen[0]!)
dispose()
expect(seen).toHaveLength(2)
unsubscribe()
unsubscribe()
registry.register(rule('test.a', 0, 'b'))
dispose()
expect(evaluate(input)[1]?.id).toBe('test.a')
expect(seen).toHaveLength(2)
})
it('removes only the contributing fiber rules and cleans root registrations on disposal', async () => {
const { ctx, registry } = setup()
ctx.provide('userTextTestRegistry', registry)
registry.register(rule('test.a', 0, 'a'))
const feature = ctx.inject(['userTextTestRegistry'], (featureCtx) => {
featureCtx.userTextTestRegistry.register(rule('test.z', 0, 'b'))
})
await feature.await()
const evaluate = registry.createEvaluator()
expect(evaluate({ text: 'ab', facts: {} }).map(node => node.id)).toEqual(['test.a', 'test.z'])
await feature.dispose()
expect(evaluate({ text: 'ab', facts: {} }).map(node => node.id)).toEqual(['test.a', 'text'])
await ctx.fiber.dispose()
expect(evaluate({ text: 'ab', facts: {} })).toEqual([plain('ab')])
})
})
describe('sessionReferenceRule', () => {
it('folds wire syntax to JSON label data while retaining the full original range', async () => {
const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts')
const { registry } = setup()
registry.register(sessionReferenceRule)
const wire = '@[查看并分析图片](dsh-session:InNlc3Npb24tNDM0)'
const text = `看看 ${wire} 的结论`
const nodes = registry.createEvaluator()({ text, facts: {} })
expect(nodes).toEqual([
plain('看看 '), { id: 'session', start: 3, end: 3 + wire.length, data: { label: '查看并分析图片' } },
plain(' 的结论', 3 + wire.length),
])
assertCoverage(text, nodes)
})
it('uses exact supplied labels, keeps the longest spelling and preserves repeated occurrences', async () => {
const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts')
const { registry } = setup()
registry.register(sessionReferenceRule)
const evaluate = registry.createEvaluator()
const text = '@会话一 收尾 @会话一, @会话 @未知'
const nodes = evaluate({ text, facts: { sessionLabels: ['会话', '会话一', '会话一'] } })
expect(nodes.filter(node => node.id === 'session').map(node => ({ raw: text.slice(node.start, node.end), data: node.data })))
.toEqual([
{ raw: '@会话一', data: { label: '会话一' } }, { raw: '@会话一', data: { label: '会话一' } },
{ raw: '@会话', data: { label: '会话' } },
])
assertCoverage(text, nodes)
expect(evaluate({ text: '@会话', facts: {} })).toEqual([plain('@会话')])
})
it('prefers complete wire syntax over an overlapping supplied label', async () => {
const { sessionReferenceRule } = await import('../src/client/user-text/session-reference-rule.ts')
const { registry } = setup()
registry.register(sessionReferenceRule)
const text = '@[a](dsh-session:x)'
expect(registry.createEvaluator()({ text, facts: { sessionLabels: ['[a]'] } })).toEqual([
{ id: 'session', start: 0, end: text.length, data: { label: 'a' } },
])
})
})
@@ -1,12 +1,12 @@
import { memo } from 'react'
import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts'
import css from './GoalCommandInputView.module.css'
type GoalCommandInputViewProps =
PropsRuntime<'conversation.chat.node', 'command-input'>
& PropsLocale<'goal'>
& PropsRenderFactories
/**
* Right-aligned `/goal` input bubble without ordinary message actions. The
@@ -15,14 +15,16 @@ type GoalCommandInputViewProps =
* the objective, `/goal` mentions included, as plain text.
*/
export const GoalCommandInputView = memo(function GoalCommandInputView({
node, t,
node, t, renderFactorySlot,
}: GoalCommandInputViewProps) {
const data: GoalCommandInputData = node.data
// Only the leading token is the executed command; the rest of the line is
// the objective, where a further `/goal` is prose.
const split = data.text.search(/\s/u)
const head = split === -1 ? data.text : data.text.slice(0, split)
const rest = split === -1 ? '' : data.text.slice(split)
const commands = head === `/${GOAL_COMMAND}`
? [{ start: 0, end: head.length, name: GOAL_COMMAND }]
: []
return (
<div
className={css.row}
@@ -32,8 +34,11 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({
>
<div className={css.stack}>
<div className={css.bubble}>
{projectUserText(head, [], [GOAL_COMMAND], 'command')}
{rest !== '' && projectUserText(rest, [])}
{renderFactorySlot('conversation.user.text', {
text: data.text,
facts: { commands },
interactive: false,
})}
</div>
</div>
</div>
@@ -0,0 +1,12 @@
/** Display-only command references; command execution stays with the original command input. */
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Render one confirmed command token without adding an activation action.
* @param props - resolved command node and its original spelling.
* @returns a noninteractive inline command chip.
*/
export function GoalCommandReference({ node, raw }: UserTextNodeProps<'command'>) {
return <ReferenceChip appearance="command" label={`/${node.data.name}`} title={raw} />
}
@@ -31,7 +31,10 @@ import type { GoalActionResult, GoalBarInjected } from './slots.ts'
import { createGoalActivationSource } from './activation-source.ts'
import { GoalDock } from './GoalBar.tsx'
import { GoalCommandInputView } from './GoalCommandInputView.tsx'
import { GoalCommandReference } from './GoalCommandReference.tsx'
import { goalCommandInputDefinition } from './goal-command-input.ts'
import { goalCommandReferenceRule } from './user-text-rules.ts'
export type {} from './user-text-rules.ts'
import { en, zh, type GoalKey } from './locales.ts'
export { GoalBar, GoalDock } from './GoalBar.tsx'
@@ -66,8 +69,15 @@ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale',
*/
export function apply(ctx: ClientContext): void {
ctx.uiConversation.events.register(goalCommandInputDefinition)
ctx.effect(() => ctx.uiConversation.userText.register(goalCommandReferenceRule), 'ui-goal: user-text rule')
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
name: 'conversation.user.text.node',
key: 'command',
priority: 100,
}, GoalCommandReference))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'command-input',
@@ -0,0 +1,60 @@
/** Pure command-reference transforms restricted to confirmed ranges within current Text segments. */
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
/** The executed command name, without the leading slash. */
command: { readonly name: string }
}
interface UserTextFacts {
/** Confirmed command ranges; repeated command spelling outside them stays plain text. */
readonly commands: readonly { readonly start: number; readonly end: number; readonly name: string }[]
}
}
function textSlice(segment: UserTextNode<'text'>, start: number, end: number): UserTextNode<'text'> {
return {
id: 'text', start, end,
data: { text: segment.data.text.slice(start - segment.start, end - segment.start) },
}
}
/** Replace exact confirmed command ranges; facts are read only for Text with a slash-token candidate. */
export const goalCommandReferenceRule: UserTextRule<'command'> = {
id: 'command',
order: 20,
transform(segments, context) {
if (!segments.some(segment => segment.id === 'text' && /(?:^|\s)\//u.test(segment.data.text))) return segments
const commands = context.read('commands')
if (commands === undefined || commands.length === 0) return segments
const ordered = [...commands].sort((a, b) => a.start - b.start || a.end - b.end)
let output: UserTextNode[] | undefined
for (const [index, segment] of segments.entries()) {
if (segment.id !== 'text') {
output?.push(segment)
continue
}
const text = segment.data.text
let cursor = segment.start
for (const { start, end, name } of ordered) {
if (start >= segment.end) break
if (start < cursor || end > segment.end) continue
const localStart = start - segment.start
const localEnd = end - segment.start
if (text.slice(localStart, localEnd) !== `/${name}`
|| (localStart !== 0 && !/\s/u.test(text.slice(localStart - 1, localStart)))
|| (localEnd !== text.length && !/\s/u.test(text.slice(localEnd, localEnd + 1)))) continue
output ??= segments.slice(0, index)
if (cursor < start) output.push(textSlice(segment, cursor, start))
output.push({ id: 'command', start, end, data: { name } })
cursor = end
}
if (output !== undefined) {
if (cursor === segment.start) output.push(segment)
else if (cursor < segment.end) output.push(textSlice(segment, cursor, segment.end))
}
}
return output ?? segments
},
}
@@ -0,0 +1,74 @@
import { Context } from '@deepseek-ai/cordis'
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { goalCommandReferenceRule } from '../src/client/user-text-rules.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
'test.command.blocker': { readonly label: string }
}
}
function setup(rules: readonly UserTextRule[] = [goalCommandReferenceRule]) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const registry = new UserTextRegistry(ctx)
for (const rule of rules) registry.register(rule)
return registry.createEvaluator()
}
describe('goalCommandReferenceRule', () => {
it('does not read command facts or rerun without a slash-token candidate', () => {
const read = vi.fn()
const transform = vi.fn<UserTextRule['transform']>((segments, context) => goalCommandReferenceRule.transform(segments, {
read: (key) => { read(key); return context.read(key) },
}))
const evaluate = setup([{ ...goalCommandReferenceRule, transform }])
const text = 'plain text prefix/goal'
const before = evaluate({ text, facts: {} })
expect(evaluate({ text, facts: { commands: [{ start: 0, end: 5, name: 'goal' }] } })).toBe(before)
expect(read).not.toHaveBeenCalled()
expect(transform).toHaveBeenCalledOnce()
})
it('uses only supplied ranges in current Text and never consumes an earlier business node', () => {
const text = '/goal /goal /goal'
const blocker: UserTextNode<'test.command.blocker'> = {
id: 'test.command.blocker', start: 0, end: 5, data: { label: 'claimed' },
}
const evaluate = setup([
{
id: 'test.command.blocker', order: 0,
transform: () => [blocker, { id: 'text', start: 5, end: text.length, data: { text: text.slice(5) } }],
},
goalCommandReferenceRule,
])
const nodes = evaluate({
text, facts: { commands: [{ start: 0, end: 5, name: 'goal' }, { start: 6, end: 11, name: 'goal' }] },
})
expect(nodes[0]).toBe(blocker)
expect(nodes.filter(node => node.id === 'command').map(node => [node.start, node.end])).toEqual([[6, 11]])
expect(nodes.at(-1)).toEqual({ id: 'text', start: 11, end: text.length, data: { text: ' /goal' } })
expect(evaluate({ text, facts: { commands: [{ start: 6, end: 10, name: 'goal' }] } }).filter(node => node.id === 'command')).toEqual([])
})
it('marks only the actual leading command range, not repeated command text in the body', () => {
const evaluate = setup()
const text = '/goal ship it\nthen discuss /goal again'
const input = Object.freeze({ text, facts: Object.freeze({ commands: Object.freeze([{ start: 0, end: 5, name: 'goal' }]) }) })
const matches = evaluate(input).filter(node => node.id === 'command')
expect(goalCommandReferenceRule.id).toBe('command')
expect(matches).toEqual([{ id: 'command', start: 0, end: 5, data: { name: 'goal' } }])
expect(JSON.parse(JSON.stringify(matches))).toEqual(matches)
expect(evaluate({ text, facts: {} }).filter(node => node.id === 'command')).toEqual([])
expect(evaluate({ text, facts: { commands: [] } }).filter(node => node.id === 'command')).toEqual([])
})
it('uses caller-provided UTF-16 ranges instead of searching or guessing names', () => {
const evaluate = setup()
const text = '🙂 /goal body /goal'
const input = { text, facts: { commands: [{ start: 3, end: 8, name: 'goal' }] } }
expect(evaluate(input).filter(node => node.id === 'command')).toEqual([{ id: 'command', start: 3, end: 8, data: { name: 'goal' } }])
})
})
+1 -1
View File
@@ -59,7 +59,7 @@ export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.ts
export type {
CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext,
} from './FileTypeIcon.tsx'
export { projectUserText, type UserTextReferences } from './user-text.tsx'
export { UserTextRun, ReferenceChip, type UserTextRunProps, type ReferenceChipProps } from './user-text.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { pointerModality } from './input-modality.ts'
+46 -146
View File
@@ -1,156 +1,56 @@
/**
* Display projection of reference forms in sent user text (bubble and queue
* rows). The logged model text remains the single truth; this is presentation
* only. Inline references follow the consumer's wrapping policy and keep long
* labels within its width. Four decoration sources, by precedence: the wire session form
* `@[label](dsh-session:...)` folds to its label; exact session labels
* supplied by an adjacent recall decorate their bare `@label` mention; plain
* `@name` word-boundary tokens decorate by shape alone; and a plain `/name`
* token decorates only when the caller names it — a skill the host actually
* loaded for that message (ui-chat reads the step's `skill-invocation`
* injections) or the command a command-input bubble echoes — so `/123` or a
* stray `/word` stays plain text. A `/name` token is whitespace-bounded like
* the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the
* text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued
* tokens (`/plan。`) stay plain even for a loaded name.
*/
/** Inline text and reference presentation; business rules supply resolved JSON data and actions. */
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { ReferenceIconRegular } from './ReferenceIcon.tsx'
import { ReferenceIconRegular, type ReferenceIconKind } from './ReferenceIcon.tsx'
import css from './user-text.module.css'
import markdownCss from './markdown/MarkdownText.module.css'
/** The wire form a session chip serializes to; label is the display text. */
const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */
const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u
interface DecorationRange {
readonly start: number
readonly end: number
/** Matched source text (hover title). */
readonly label: string
readonly kind: 'session' | 'plain'
/** Pre-resolved display text (wire folds); derived from label when absent. */
readonly display?: string
}
/** Optional navigation supplied by consumers that can preview references. */
export interface UserTextReferences {
/** Open a file path decoded from an `@` mention. */
openFile: (path: string) => void
/** Open the source of a skill loaded for this message. */
openSkill: (name: string) => void
/** Plain text follows the wrapping and typography of its host. */
export interface UserTextRunProps {
readonly text: string
}
/**
* Split one sent text into inline plain runs and reference chips.
* @param text - the logged model text of the message or queue row.
* @param sessionLabels - exact session mention labels associated by an adjacent recall.
* @param slashNames - names a `/name` token may decorate as: the skills the
* host loaded for this message, or the command a command bubble echoes
* (unsent queue rows pass none).
* @param slashKind - the chip kind those tokens render as.
* @param references - optional file and skill preview actions; session and command tokens stay labels.
* @returns inline nodes covering the whole text.
* Render an uninterpreted inline text run.
* @param props - text retained verbatim from the caller's document.
* @returns one inline run without Markdown or reference recognition.
*/
export function projectUserText(
text: string,
sessionLabels: readonly string[],
slashNames: readonly string[] = [],
slashKind: 'skill' | 'command' = 'skill',
references?: UserTextReferences,
): ReactNode {
const ranges: DecorationRange[] = []
SESSION_WIRE_RE.lastIndex = 0
let wire: RegExpExecArray | null
while ((wire = SESSION_WIRE_RE.exec(text)) !== null) {
ranges.push({
start: wire.index,
end: wire.index + wire[0].length,
label: wire[0],
kind: 'session',
display: wire[1] as string, // non-optional capture in SESSION_WIRE_RE
})
}
for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) {
const label = `@${rawLabel}`
let start = text.indexOf(label)
while (start >= 0) {
ranges.push({ start, end: start + label.length, label, kind: 'session' })
start = text.indexOf(label, start + label.length)
}
}
// A `/` token ends at whitespace or the text end like the host skill
// gesture; only `@` tokens shed sentence punctuation below.
const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start
const rawLabel = m[2] as string // non-optional alternation capture
const label = rawLabel.startsWith('@"')
? rawLabel
: rawLabel.replace(TRAILING_PUNCTUATION_RE, '')
if (label.length <= 1) continue
if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
}
const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1
ranges.sort((a, b) => a.start - b.start || rankOf(a) - rankOf(b) || b.end - a.end)
const parts: ReactNode[] = []
let cursor = 0
const pushPlain = (from: number, to: number): void => {
parts.push(<span key={`t${from}`} className={css.plainRun}>{text.slice(from, to)}</span>)
}
for (const range of ranges) {
if (range.start < cursor) continue
const { start: tokenStart, end, label, kind } = range
if (tokenStart > cursor) pushPlain(cursor, tokenStart)
const referenceKind = kind === 'session'
? 'session'
: label.startsWith('@')
? label.replace(/^@"|"$/gu, '').endsWith('/') ? 'folder' : 'file'
: undefined
const displayLabel = range.display
?? (referenceKind === undefined
? label
: referenceKind === 'session'
? label.slice(1)
: label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1))
const contents = <>
{referenceKind !== undefined && (
<ReferenceIconRegular kind={referenceKind} size={16} className={css.refIcon} />
)}
{displayLabel}
</>
const open = references === undefined ? undefined
: referenceKind === 'file'
? () => { references.openFile(label.slice(1).replace(/^"|"$/gu, '')) }
: referenceKind === undefined && slashKind === 'skill'
? () => { references.openSkill(label.slice(1)) }
: undefined
const className = clsx(css.refChip, referenceKind === undefined && css.slashChip)
parts.push(open === undefined
? <span key={tokenStart} className={className} data-ref-chip={referenceKind ?? slashKind} title={label}>
{contents}
</span>
: <button
key={tokenStart}
type="button"
className={clsx(className, markdownCss.fileMention)}
data-ref-chip={referenceKind ?? slashKind}
title={label}
onClick={(event) => {
if (event.detail > 1 || (event.detail !== 0 && event.currentTarget.ownerDocument.getSelection()?.isCollapsed === false)) return
open()
}}
>
{contents}
</button>)
cursor = end
}
if (parts.length === 0) return <span className={css.plainRun}>{text}</span>
if (cursor < text.length) pushPlain(cursor, text.length)
return <>{parts}</>
export function UserTextRun({ text }: UserTextRunProps): ReactNode {
return <span className={css.plainRun}>{text}</span>
}
/** Presentation selected by a reference renderer, independent of its matching grammar. */
export interface ReferenceChipProps {
readonly appearance: ReferenceIconKind | 'skill' | 'command'
readonly label: string
readonly title: string
/** Absent for display-only references and previews. */
readonly onActivate?: () => void
}
/**
* Render a reference label or its explicitly supplied activation action.
* Text selection and repeated pointer clicks do not activate the reference.
* @param props - resolved visual fields and optional action from the owning renderer.
* @returns an inline label, or a keyboard-accessible button when activation is supplied.
*/
export function ReferenceChip({ appearance, label, title, onActivate }: ReferenceChipProps): ReactNode {
const slash = appearance === 'skill' || appearance === 'command'
const contents = <>
{!slash && <ReferenceIconRegular kind={appearance} size={16} className={css.refIcon} />}
{label}
</>
const className = clsx(css.refChip, slash && css.slashChip)
return onActivate === undefined
? <span className={className} data-ref-chip={appearance} title={title}>{contents}</span>
: <button
type="button"
className={clsx(className, markdownCss.fileMention)}
data-ref-chip={appearance}
title={title}
onClick={(event) => {
if (event.detail > 1 || (event.detail !== 0 && event.currentTarget.ownerDocument.getSelection()?.isCollapsed === false)) return
onActivate()
}}
>{contents}</button>
}
@@ -33,6 +33,8 @@
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-input-trigger",
"@deepseek-ai/dsh-client-ui-sidebar-right"
],
@@ -53,8 +55,12 @@
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-file-reference": "workspace:*",
"@deepseek-ai/dsh-session-reference": "workspace:*",
@@ -0,0 +1,62 @@
/** File navigation feedback belongs to the individual inline reference that requested it. */
import { useState } from 'react'
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Modal, ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { NS } from './locales.ts'
import type {} from './user-text-rules.ts'
/** Session-authorized synchronous navigation; document reading belongs to the preview host. */
export interface FileReferenceInjected {
/**
* Open or reveal a file in the current Sidebar using the originating Session's authority.
* @param path - decoded path from the reference.
* @throws if navigation fails; the reference keeps a local retry/close dialog.
*/
readonly openFile: (path: string) => void
}
type FileReferenceProps = UserTextNodeProps<'file'> & InjectFace<FileReferenceInjected> & PropsLocale<typeof NS>
/**
* Render a file chip and contain synchronous navigation failures without hiding the reference.
* @param props - JSON node, interaction policy, Session-authorized navigation, and locale seat.
* @returns an inline chip and, after a navigation failure, its body-portaled dialog.
*/
export function FileReference({ node, raw, interactive, openFile, t }: FileReferenceProps) {
const [failedPath, setFailedPath] = useState<string | null>(null)
const { path, label } = node.data
const close = () => { setFailedPath(null) }
const open = () => {
try {
openFile(path)
} catch {
setFailedPath(path)
return
}
setFailedPath(null)
}
return <>
<ReferenceChip appearance="file" label={label} title={raw} {...interactive ? { onActivate: open } : {}} />
{interactive && failedPath === path && <Modal
open
onClose={close}
closeLabel={t('fileOpen.close')}
title={t('fileOpen.title')}
description={t('fileOpen.failed', { path })}
footer={<>
<Button variant="outline" onClick={close}>{t('fileOpen.close')}</Button>
<Button variant="primary" onClick={open}>{t('fileOpen.retry')}</Button>
</>}
/>}
</>
}
/**
* Render a directory as a label, even when the containing text permits file navigation.
* @param props - resolved directory data and the original reference spelling.
* @returns an inline non-interactive chip.
*/
export function FolderReference({ node, raw }: UserTextNodeProps<'folder'>) {
return <ReferenceChip appearance="folder" label={node.data.label} title={raw} />
}
@@ -18,9 +18,14 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { FileReference, FolderReference, type FileReferenceInjected } from './FileReference.tsx'
import { fileReferenceRule, folderReferenceRule } from './user-text-rules.ts'
export type {} from './user-text-rules.ts'
import type {
ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource,
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -40,17 +45,31 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' {
/** Required services: the trigger registry, the Remote namespaces, and the copy. */
export const inject = [
'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences',
'remote.sessionReferenceResolver', 'sidebarRight',
'remote.sessionReferenceResolver', 'sidebarRight', 'slots', 'uiConversation',
]
/**
* Register the combined `@file` / `@session` source.
* Register the combined `@file` / `@session` source and independent file/folder text providers.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries')
const t = ctx.locale.bind(NS)
const sessions = ctx.get('sessions') as ISessions
ctx.effect(() => ctx.uiConversation.userText.register(fileReferenceRule), 'ui-reference: file text rule')
ctx.effect(() => ctx.uiConversation.userText.register(folderReferenceRule), 'ui-reference: folder text rule')
ctx.slots.inject('conversation.user.text.node', function* () {
yield ctx.slots.register({
name: 'conversation.user.text.node', key: 'file', priority: 100, locale: NS,
inject: (sessionId: UserTextNodeProps<'file'>['sessionId']): FileReferenceInjected => ({
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))
},
}),
}, FileReference)
yield ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder', priority: 100 }, FolderReference)
})
const source: InputTriggerSource = {
trigger: '@',
name: 'reference',
@@ -1,4 +1,4 @@
/** `reference` namespace dictionaries for the unified `@` source. */
/** `reference` dictionaries for the unified `@` source and inline file navigation. */
import type {} from '@deepseek-ai/dsh-client-ui-slots'
@@ -18,6 +18,10 @@ export const zh = {
'section.sessions': '对话',
'candidate.noCwd': '(无工作目录)',
'crumb.root': '工作区',
'fileOpen.title': '无法打开文件',
'fileOpen.failed': '无法打开“{path}”,请重试或关闭此提示',
'fileOpen.close': '关闭',
'fileOpen.retry': '重试',
'time.now': '刚刚',
'time.minutes': '{n}分钟',
'time.hours': '{n}小时',
@@ -31,7 +35,7 @@ export type ReferenceKey = keyof typeof zh
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The unified `@` reference menu's copy. */
/** Reference menu and per-file navigation feedback. */
reference: ReferenceKey
}
}
@@ -43,6 +47,10 @@ export const en = {
'section.sessions': 'Sessions',
'candidate.noCwd': '(no cwd)',
'crumb.root': 'Workspace',
'fileOpen.title': 'Couldn’t open file',
'fileOpen.failed': 'Couldn’t open “{path}”. Try again or close this message.',
'fileOpen.close': 'Close',
'fileOpen.retry': 'Retry',
'time.now': 'now',
'time.minutes': '{n}min',
'time.hours': '{n}h',
@@ -0,0 +1,70 @@
/** Pure file and folder transforms scan current Text segments without discovery or facts reads. */
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
interface PathReferenceData {
readonly path: string
readonly label: string
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
file: PathReferenceData
folder: PathReferenceData
}
}
function appendText(nodes: UserTextNode[], segment: UserTextNode<'text'>, start: number, end: number): void {
if (start < end) {
nodes.push({
id: 'text', start: segment.start + start, end: segment.start + end,
data: { text: segment.data.text.slice(start, end) },
})
}
}
function transformPaths(segments: readonly UserTextNode[], id: 'file' | 'folder'): readonly UserTextNode[] {
const nodes: UserTextNode[] = []
let changed = false
for (const segment of segments) {
if (segment.id !== 'text') {
nodes.push(segment)
continue
}
const tokens = /(^|\s)(@"[^"\n]+"|@[^\s]+)/gu
let cursor = 0
for (const match of segment.data.text.matchAll(tokens)) {
const prefix = match[1] as string // The boundary capture is empty at the start of text.
const token = match[2] as string // Every match contains the reference capture.
const start = match.index + prefix.length
const raw = token.startsWith('@"') ? token : token.replace(/[.,;:!?,。;:!?]+$/u, '')
if (raw.length <= 1) continue
const path = raw.slice(1).replace(/^"|"$/gu, '')
if (path.endsWith('/') !== (id === 'folder')) continue
const label = path.split(/[\\/]/u).filter(Boolean).at(-1) ?? raw.slice(1)
appendText(nodes, segment, cursor, start)
nodes.push({ id, start: segment.start + start, end: segment.start + start + raw.length, data: { path, label } })
cursor = start + raw.length
}
if (cursor === 0) {
nodes.push(segment)
} else {
changed = true
appendText(nodes, segment, cursor, segment.data.text.length)
}
}
return changed ? nodes : segments
}
/** Recognize whitespace-bounded `@` file paths, retaining quoted punctuation and source ranges. */
export const fileReferenceRule: UserTextRule<'file'> = {
id: 'file',
order: 30,
transform: segments => transformPaths(segments, 'file'),
}
/** Recognize trailing-slash directories with the same grammar and root-label fallback as files. */
export const folderReferenceRule: UserTextRule<'folder'> = {
id: 'folder',
order: 30,
transform: segments => transformPaths(segments, 'folder'),
}
@@ -0,0 +1,76 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
import { fileReferenceRule, folderReferenceRule } from '../src/client/user-text-rules.ts'
function setup(rules: readonly UserTextRule[] = [folderReferenceRule, fileReferenceRule]) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const registry = new UserTextRegistry(ctx)
for (const rule of rules) registry.register(rule)
return registry.createEvaluator()
}
describe('fileReferenceRule and folderReferenceRule', () => {
it('reads no facts and reuses the entire result across fact updates', () => {
const read = vi.fn()
const file = vi.fn<UserTextRule['transform']>((segments, context) => fileReferenceRule.transform(segments, {
read: (key) => { read(key); return context.read(key) },
}))
const folder = vi.fn<UserTextRule['transform']>((segments, context) => folderReferenceRule.transform(segments, {
read: (key) => { read(key); return context.read(key) },
}))
const evaluate = setup([{ ...fileReferenceRule, transform: file }, { ...folderReferenceRule, transform: folder }])
const text = '@src/a.ts @docs/'
const before = evaluate({ text, facts: {} })
expect(before.filter(node => node.id !== 'text').map(node => node.id)).toEqual(['file', 'folder'])
expect(evaluate({ text, facts: { sessionLabels: ['new label'] } })).toBe(before)
expect(read).not.toHaveBeenCalled()
expect(file).toHaveBeenCalledOnce()
expect(folder).toHaveBeenCalledOnce()
})
it('separates ordinary files from directories and decodes quoted paths', () => {
const evaluate = setup()
const text = '@src/deep/file.txt 与 @dir/ 与 @"a b.md" 与 @"dir a/" 与 @C:\\src\\a.ts'
const input = Object.freeze({ text, facts: Object.freeze({}) })
const nodes = evaluate(input)
expect(nodes.filter(node => node.id === 'file').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([
{ raw: '@src/deep/file.txt', data: { path: 'src/deep/file.txt', label: 'file.txt' } },
{ raw: '@"a b.md"', data: { path: 'a b.md', label: 'a b.md' } },
{ raw: '@C:\\src\\a.ts', data: { path: 'C:\\src\\a.ts', label: 'a.ts' } },
])
expect(nodes.filter(node => node.id === 'folder').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([
{ raw: '@dir/', data: { path: 'dir/', label: 'dir' } },
{ raw: '@"dir a/"', data: { path: 'dir a/', label: 'dir a' } },
])
expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes)
})
it('leaves trailing sentence punctuation outside ranges without stripping quoted filename punctuation', () => {
const evaluate = setup()
const text = '@a.ts,。;:!? @b.md.,;:!? @"a!.md", @dir/。'
const nodes = evaluate({ text, facts: {} })
expect(nodes.filter(node => node.id === 'file').map(node => ({ raw: text.slice(node.start, node.end), data: node.data })))
.toEqual([
{ raw: '@a.ts', data: { path: 'a.ts', label: 'a.ts' } },
{ raw: '@b.md', data: { path: 'b.md', label: 'b.md' } },
{ raw: '@"a!.md"', data: { path: 'a!.md', label: 'a!.md' } },
])
expect(nodes.filter(node => node.id === 'folder').map(node => text.slice(node.start, node.end))).toEqual(['@dir/'])
})
it('keeps root-directory display fallback and requires a token boundary', () => {
const evaluate = setup()
const text = '@"/" @/ mail@host [email protected] @。 @'
const nodes = evaluate({ text, facts: {} })
expect(nodes.filter(node => node.id === 'folder').map(node => node.data)).toEqual([
{ path: '/', label: '"/"' }, { path: '/', label: '/' },
])
expect(nodes.filter(node => node.id === 'file')).toEqual([])
expect(evaluate({ text: '@name', facts: {} })).toEqual([
{ id: 'file', start: 0, end: 5, data: { path: 'name', label: 'name' } },
])
})
})
@@ -41,6 +41,12 @@
{
"path": "../ui-primitives"
},
{
"path": "../ui-conversation/tsconfig.client.json"
},
{
"path": "../ui-renderer"
},
{
"path": "../ui-slots"
},
@@ -10,7 +10,7 @@ import {
SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderFactorySlot, type RenderOpts,
type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
type SlotEntryDef, type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type SlotSpec, type StandardSourceBinding,
type StoredEntry, type StoredFactory, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -21,6 +21,7 @@ import {
import { SlotAssemblyError } from './errors.ts'
type InjectedProps = Record<string, unknown>
type SlotAnchorTag = 'div' | 'span'
type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown
type SlotHookFactories = Readonly<Record<string, SlotHookFactory>>
@@ -79,7 +80,7 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot
if (declared.kind === 'chain') {
throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} declaredInline={declared.inline === true} />
}
renderSlotCache.set(entry, binding)
}
@@ -108,7 +109,7 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
if (declared.kind !== 'chain') {
throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} declaredInline={declared.inline === true} />
}
renderSlotChainCache.set(entry, binding)
}
@@ -128,7 +129,7 @@ function boundFactoryRenderSlot(host: SlotRendererHost, definition: StoredFactor
const declared = definition.children?.[key]
if (declared === undefined) throw new SlotOwnershipError(`slot '${key}' is not declared by this Factory`)
if (declared.kind === 'chain') throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} declaredInline={declared.inline === true} />
}
factoryRenderSlotCache.set(definition, binding)
return binding
@@ -146,7 +147,7 @@ function boundFactoryRenderSlotChain(host: SlotRendererHost, definition: StoredF
if (declared.kind !== 'chain') {
throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} declaredInline={declared.inline === true} />
}
factoryRenderSlotChainCache.set(definition, binding)
return binding
@@ -402,7 +403,7 @@ function sessionGenerationKeyOf(binding: ScopedStandardSourceBinding): number {
* the outlet then owns the crash face).
*/
class SlotErrorBoundary extends Component<
{ slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
{ slotKey: string; anchor?: SlotAnchorTag; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
> {
override state = { failed: false }
static getDerivedStateFromError(error: unknown): { failed: boolean } {
@@ -414,7 +415,8 @@ class SlotErrorBoundary extends Component<
this.props.onEntryError(error)
}
override render(): ReactNode {
if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
const Anchor = this.props.anchor ?? 'div'
if (this.state.failed) return <Anchor data-slot-error={this.props.slotKey} />
return this.props.children
}
}
@@ -1024,8 +1026,9 @@ function FactoryOutlet({ name, inputProps, slots: selected = EMPTY_FACTORY_SELEC
)
}
function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
function StrictSessionEntry({ slotKey, anchor, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
slotKey: string
anchor: SlotAnchorTag
entry: StoredEntry
ownerProps: object
slotInjected: BoundSlotInject
@@ -1041,7 +1044,7 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
// Per-session remount rides this key; per-entry remount rides the outer
// element's entry-identity key (the outlet's guarded() call).
return (
<SlotErrorBoundary slotKey={slotKey} key={sessionGenerationKeyOf(scopedBinding)} onEntryError={onEntryError}>
<SlotErrorBoundary slotKey={slotKey} anchor={anchor} key={sessionGenerationKeyOf(scopedBinding)} onEntryError={onEntryError}>
<SessionEntry
entry={entry}
ownerProps={ownerProps}
@@ -1063,10 +1066,11 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
*/
const ANCHOR_STYLE = { display: 'contents' } as const
function SlotOutlet({ slotKey, ownerProps, opts }: {
function SlotOutlet({ slotKey, ownerProps, opts, declaredInline }: {
slotKey: string
ownerProps: object
opts?: (RenderOpts & ChainRenderOpts) | undefined
declaredInline: boolean
}) {
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
@@ -1078,6 +1082,10 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// bodies re-derive their `t` seat at the new revision (fresh identity).
useLocaleRevision(host.locale)
const scopeBinding = useScopeBinding()
const spec = host.specOf(slotKey)
// Retained elements keep their declaration's anchor after unload, but never
// use that declaration to dispatch entries while the live spec is absent.
const Anchor = (spec === undefined ? declaredInline : spec.inline === true) ? 'span' : 'div'
// Anchor contract: every slot render site exposes a stable
// `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
// target — and `display:contents` keeps it layout-neutral. The wrapper
@@ -1085,9 +1093,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// undeclared-empty states all render inside it, so the anchor's presence
// never flickers with registration churn.
return (
<div data-slot={slotKey} style={ANCHOR_STYLE}>
{renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)}
</div>
<Anchor data-slot={slotKey} style={ANCHOR_STYLE}>
{spec === undefined ? null : renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding, spec)}
</Anchor>
)
}
@@ -1098,14 +1106,11 @@ function renderOutletContent(
ownerProps: object,
opts: (RenderOpts & ChainRenderOpts) | undefined,
scopeBinding: StandardSourceBinding,
spec: SlotSpec<SlotEntryDef>,
): ReactNode {
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
// unload returns the slot to the undeclared state while retained elements
// may still be mounted — natural empty, not an ownership failure.
if (!spec) return null
const Anchor = spec.inline === true ? 'span' : 'div'
if (spec.kind === 'chain' && opts?.fallbackOnly === true) {
return renderChainResult(slotKey, null, opts)
return renderChainResult(slotKey, null, opts, Anchor)
}
if (spec.scope === 'session' && scopeBinding.key === undefined) {
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
@@ -1130,6 +1135,7 @@ function renderOutletContent(
? (
<StrictSessionEntry
slotKey={slotKey}
anchor={Anchor}
entry={entry}
ownerProps={owner}
slotInjected={slotInjected}
@@ -1140,7 +1146,7 @@ function renderOutletContent(
/>
)
: (
<SlotErrorBoundary slotKey={slotKey} key={key} onEntryError={onEntryError}>
<SlotErrorBoundary slotKey={slotKey} anchor={Anchor} key={key} onEntryError={onEntryError}>
{spec.scope === 'session-maybe'
? (
<SessionMaybeEntry
@@ -1168,7 +1174,7 @@ function renderOutletContent(
// A cell whose every registration abdicated keeps the crash face: the
// shadowing collapse ran out of survivors, which is a failure state, not
// the owner's natural-empty fallback.
const deadCell = () => <div data-slot-error={slotKey} />
const deadCell = () => <Anchor data-slot-error={slotKey} />
if (spec.kind === 'single') {
const entry = host.entriesOfSlot(slotKey)[0]
@@ -1211,7 +1217,7 @@ function renderOutletContent(
break
}
}
return renderChainResult(slotKey, elected, opts)
return renderChainResult(slotKey, elected, opts, Anchor)
}
// list: one row per id cell — the cell's shadowing winner, or the crash
// face once every entry of the cell abdicated (a dry cell must not
@@ -1239,7 +1245,7 @@ function renderOutletContent(
<>
{list.map((item, i) => item.entry !== undefined
? guarded(item.entry, `e${entryKeyOf(item.entry)}`)
: <div data-slot-error={slotKey} key={`x${item.id ?? i}`} />)}
: <Anchor data-slot-error={slotKey} key={`x${item.id ?? i}`} />)}
</>
)
}
@@ -1249,16 +1255,17 @@ function renderChainResult(
slotKey: string,
elected: ReactNode,
opts: (RenderOpts & ChainRenderOpts) | undefined,
Anchor: SlotAnchorTag,
): ReactNode {
if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null}</>
return (
<>
<div
<Anchor
data-chain-overlay-fallback={slotKey}
style={{ display: elected === null ? 'contents' : 'none' }}
>
{opts.fallback ?? null}
</div>
</Anchor>
{elected}
</>
)
+2
View File
@@ -31,6 +31,7 @@
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-input-trigger",
@@ -54,6 +55,7 @@
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
@@ -0,0 +1,28 @@
/** Skill-reference presentation with activation owned by this keyed entry's Session injection. */
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ReferenceChip } from '@deepseek-ai/dsh-client-ui-primitives'
/** Session-bound preview action; accepting a preview is not a completion promise. */
export interface SkillReferenceInjected {
/**
* Ask the skill source to preview a confirmed name in this entry's Session.
* @param name - skill name without its slash.
*/
readonly openSkill: (name: string) => void
}
type SkillReferenceProps = UserTextNodeProps<'skill'> & SkillReferenceInjected
/**
* Render a skill chip; previews omit activation even when the entry has an injected action.
* @param props - resolved node, raw token, interaction mode, and Session-bound preview action.
* @returns an inline skill label or interactive chip.
*/
export function SkillReference({ node, raw, interactive, openSkill }: SkillReferenceProps) {
return <ReferenceChip
appearance="skill"
label={`/${node.data.name}`}
title={raw}
{...(interactive ? { onActivate: () => { openSkill(node.data.name) } } : {})}
/>
}
+21 -3
View File
@@ -44,7 +44,11 @@ import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SkillRow } from './SkillRow.tsx'
import { SkillReference, type SkillReferenceInjected } from './SkillReference.tsx'
import { skillReferenceRule } from './user-text-rules.ts'
export type {} from './user-text-rules.ts'
import { en, NS, zh, type SkillKey } from './locales.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
@@ -70,7 +74,7 @@ interface CatalogFetch {
}
/** Required services: reference source faces plus the tool-row and locale registries. */
export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight']
export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills', 'sidebarRight', 'uiConversation']
/**
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
@@ -78,6 +82,7 @@ export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote',
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries')
ctx.effect(() => ctx.uiConversation.userText.register(skillReferenceRule), 'ui-skill: user-text rule')
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
{ name: 'tool.call.toolview', key: 'skill', locale: NS },
SkillRow,
@@ -156,7 +161,7 @@ export function apply(ctx: ClientContext): void {
// locale service's own fallback ladder; candidate-time reads stay plain text.
const t = ctx.locale.bind(NS)
const source: InputTriggerSource = {
const source = {
trigger: '/',
name: 'skill',
order: 2,
@@ -223,7 +228,20 @@ export function apply(ctx: ClientContext): void {
// line client-side before it ever becomes a prompt.
return { text: `/${candidate.name} ` }
},
}
} satisfies InputTriggerSource
ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
name: 'conversation.user.text.node',
key: 'skill',
priority: 100,
inject: (sessionId): SkillReferenceInjected => ({
openSkill: (name) => {
if (sessions.scope(sessionId) === undefined) return
source.openReference({ sessionId }, { ref: `/${name}` })
},
}),
}, SkillReference))
const inputTriggers = ctx.get('inputTriggers') as InputTriggerServiceContract
// A preset decides which skill providers an agent reads, so a switched
// session's cached catalog belongs to the composition it no longer runs.
@@ -0,0 +1,60 @@
/** Pure skill-reference transforms over current Text segments and caller-confirmed names. */
import type { UserTextNode, UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface UserTextDataMap {
/** A skill name confirmed for this message, without the leading slash. */
skill: { readonly name: string }
}
interface UserTextFacts {
/** Resolved skill names; absence supplies no skill references. */
readonly skillNames: readonly string[]
}
}
function textSlice(segment: UserTextNode<'text'>, start: number, end: number): UserTextNode<'text'> {
return {
id: 'text', start, end,
data: { text: segment.data.text.slice(start - segment.start, end - segment.start) },
}
}
/** Replace complete slash tokens; confirmed names are read only after a syntactically valid candidate. */
export const skillReferenceRule: UserTextRule<'skill'> = {
id: 'skill',
order: 20,
transform(segments, context) {
let names: ReadonlySet<string> | undefined
let output: UserTextNode[] | undefined
for (const [index, segment] of segments.entries()) {
if (segment.id !== 'text') {
output?.push(segment)
continue
}
let cursor = segment.start
for (const token of segment.data.text.matchAll(/\S+/gu)) {
const raw = token[0]
if (!/^\/[\w-]+$/u.test(raw)) continue
if (names === undefined) {
const confirmed = context.read('skillNames')
if (confirmed === undefined || confirmed.length === 0) return segments
names = new Set(confirmed)
}
const name = raw.slice(1)
if (!names.has(name)) continue
const start = segment.start + token.index
const end = start + raw.length
output ??= segments.slice(0, index)
if (cursor < start) output.push(textSlice(segment, cursor, start))
output.push({ id: 'skill', start, end, data: { name } })
cursor = end
}
if (output !== undefined) {
if (cursor === segment.start) output.push(segment)
else if (cursor < segment.end) output.push(textSlice(segment, cursor, segment.end))
}
}
return output ?? segments
},
}
@@ -0,0 +1,72 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
import { skillReferenceRule } from '../src/client/user-text-rules.ts'
function setup(rule: UserTextRule = skillReferenceRule) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const registry = new UserTextRegistry(ctx)
registry.register(rule)
return registry.createEvaluator()
}
describe('skillReferenceRule', () => {
it('does not read names or rerun when text has no slash-token candidate', () => {
const read = vi.fn()
const transform = vi.fn<UserTextRule['transform']>((segments, context) => skillReferenceRule.transform(segments, {
read: (key) => { read(key); return context.read(key) },
}))
const evaluate = setup({ ...skillReferenceRule, transform })
const text = 'plain text /review.md /path/to/file'
const before = evaluate({ text, facts: {} })
expect(evaluate({ text, facts: { skillNames: ['review', 'path'] } })).toBe(before)
expect(read).not.toHaveBeenCalled()
expect(transform).toHaveBeenCalledOnce()
})
it('reacts to missing names appearing and retains node identities when unrelated names are added', () => {
const transform = vi.fn<UserTextRule['transform']>((segments, context) => skillReferenceRule.transform(segments, context))
const evaluate = setup({ ...skillReferenceRule, transform })
const text = 'use /review now'
expect(evaluate({ text, facts: {} }).every(node => node.id === 'text')).toBe(true)
const before = evaluate({ text, facts: { skillNames: ['review'] } })
const after = evaluate({ text, facts: { skillNames: ['review', 'unrelated'] } })
expect(after).toBe(before)
expect(after.find(node => node.id === 'skill')).toBe(before.find(node => node.id === 'skill'))
expect(transform).toHaveBeenCalledTimes(3)
})
it('recognizes only names explicitly resolved for this message', () => {
const evaluate = setup()
const text = '用 /review 再 /test-skill\n/review'
const input = Object.freeze({ text, facts: Object.freeze({ skillNames: Object.freeze(['review', 'test-skill', 'review']) }) })
const nodes = evaluate(input)
expect(nodes.filter(node => node.id === 'skill').map(node => ({ raw: text.slice(node.start, node.end), data: node.data }))).toEqual([
{ raw: '/review', data: { name: 'review' } }, { raw: '/test-skill', data: { name: 'test-skill' } },
{ raw: '/review', data: { name: 'review' } },
])
expect(JSON.parse(JSON.stringify(nodes))).toEqual(nodes)
expect(evaluate({ text, facts: {} })).toEqual([{ id: 'text', start: 0, end: text.length, data: { text } }])
expect(evaluate({ text, facts: { skillNames: [] } }).every(node => node.id === 'text')).toBe(true)
})
it('requires whitespace or text boundaries and never strips attached punctuation', () => {
const evaluate = setup()
const text = '/x /x.md /x/path /path /x。 /x, (/x) prefix/x\t/x\n/x'
const nodes = evaluate({ text, facts: { skillNames: ['x'] } })
expect(nodes.filter(node => node.id === 'skill').map(node => [node.start, node.end, text.slice(node.start, node.end)])).toEqual([
[0, 2, '/x'], [text.indexOf('\t/x') + 1, text.indexOf('\t/x') + 3, '/x'],
[text.lastIndexOf('/x'), text.length, '/x'],
])
for (const input of [
{ text: '/path /x.md /x', facts: {} },
{ text: '/path /x.md /x。', facts: { skillNames: ['x'] } },
{ text: '/nfs-hg/path /root-dir/ /plan.md /plan。', facts: { skillNames: ['nfs-hg', 'root-dir', 'plan'] } },
{ text: '/plan.md /nfs-hg/path /plan。', facts: { skillNames: ['plan.md', 'nfs-hg/path', 'plan。'] } },
]) {
expect(evaluate(input).every(node => node.id === 'text')).toBe(true)
}
})
})
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../ui-renderer"
},
{
"path": "../ui-conversation/tsconfig.client.json"
},
{
"path": "../ui-tool"
},
+4
View File
@@ -116,6 +116,8 @@ export interface SlotScopeTargetMap {}
export interface SlotEntryDef {
kind: SlotKind
scope: SlotScope
/** Use span anchors and error placeholders for inline content; omitted uses div. */
inline?: true
owner?: object
/**
* Optional keyed-entry prop table. A keyed registration contributes one
@@ -163,6 +165,8 @@ export interface SlotFactoryDef {
export type SlotSpec<E extends SlotEntryDef> = {
kind: E['kind']
scope: E['scope']
/** Use span anchors and error placeholders for inline content; omitted uses div. */
inline?: true
} & ('inject' extends keyof E
? E extends { inject: infer Injected extends object }
? { inject: Injected }
+18
View File
@@ -3318,6 +3318,9 @@ importers:
'@deepseek-ai/dsh-attachment':
specifier: workspace:*
version: link:../../attachment/attachment
'@deepseek-ai/dsh-client-locale':
specifier: workspace:*
version: link:../locale
'@deepseek-ai/dsh-client-ui-chat':
specifier: workspace:*
version: link:../ui-chat
@@ -4645,12 +4648,18 @@ importers:
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:*
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:*
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-input-trigger':
specifier: workspace:*
version: link:../ui-input-trigger
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:*
version: link:../ui-renderer
'@deepseek-ai/dsh-client-ui-sidebar-right':
specifier: workspace:*
version: link:../ui-sidebar-right
@@ -4669,6 +4678,12 @@ importers:
'@deepseek-ai/dsh-util-workspace-path':
specifier: workspace:*
version: link:../../util/workspace-path
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-renderer:
devDependencies:
@@ -5853,6 +5868,9 @@ importers:
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:*
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:*
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-input-trigger':
specifier: workspace:*
version: link:../ui-input-trigger