refactor(web): transform user text through business node slots
This commit is contained in:
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' } }])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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) } } : {})}
|
||||
/>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-tool"
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Generated
+18
@@ -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
|
||||
|
||||
Reference in new issue
Block a user