diff --git a/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md new file mode 100644 index 0000000000..f6d9a4aab6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md @@ -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( + key: K, + ): UserTextFacts[K] | undefined +} + +interface UserTextRule { + 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 行为需要独立的显示层验证,不以数据层测试代替。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 560c06b817..4937ea4263 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -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:*", diff --git a/packages/client/ui-attachment/src/client/AttachmentReference.module.css b/packages/client/ui-attachment/src/client/AttachmentReference.module.css new file mode 100644 index 0000000000..5069e88249 --- /dev/null +++ b/packages/client/ui-attachment/src/client/AttachmentReference.module.css @@ -0,0 +1,5 @@ +.loading { + display: flex; + justify-content: center; + padding-block: 24px; +} diff --git a/packages/client/ui-attachment/src/client/AttachmentReference.tsx b/packages/client/ui-attachment/src/client/AttachmentReference.tsx new file mode 100644 index 0000000000..e66b7bdec7 --- /dev/null +++ b/packages/client/ui-attachment/src/client/AttachmentReference.tsx @@ -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 & PropsLocale + +/** + * 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(null) + const close = useCallback(() => { setPreviewId(null) }, []) + if (attachment.type === 'file') { + return + } + const label = attachment.ref.name ?? t('image.label') + return <> + { setPreviewId(attachment.ref.attachmentId) } } : {}} /> + {interactive && previewId === attachment.ref.attachmentId && } + +} + +type ImagePreviewState = { readonly status: 'loading' | 'failed' } | { readonly status: 'open'; readonly url: string } + +type ImageReferencePreviewProps = Pick & { + 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({ 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 + } + return + + {preview.status === 'failed' && } + } + > + {preview.status === 'failed' + ?

{t('image.failed', { name: label })}

+ : } +
+} diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index da308d4a0e..8fe0f56ede 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -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) diff --git a/packages/client/ui-attachment/src/client/locales.ts b/packages/client/ui-attachment/src/client/locales.ts new file mode 100644 index 0000000000..606db9bdd3 --- /dev/null +++ b/packages/client/ui-attachment/src/client/locales.ts @@ -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 + +/** 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 diff --git a/packages/client/ui-attachment/src/client/user-text-rules.ts b/packages/client/ui-attachment/src/client/user-text-rules.ts new file mode 100644 index 0000000000..d0563d8ce6 --- /dev/null +++ b/packages/client/ui-attachment/src/client/user-text-rules.ts @@ -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 + }, +} diff --git a/packages/client/ui-attachment/tests/user-text-rules.spec.ts b/packages/client/ui-attachment/tests/user-text-rules.spec.ts new file mode 100644 index 0000000000..ac1cc9f147 --- /dev/null +++ b/packages/client/ui-attachment/tests/user-text-rules.spec.ts @@ -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((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 } } }]) + }) +}) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 1f5b771ac1..136e1ebb26 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-primitives" + }, + { + "path": "../locale/tsconfig.client.json" } ] } diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index ea9de7cfa4..a26a2bfdc6 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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 } }) diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 601ee3c65e..c54e8a108f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -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) { diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 504c5c496c..9b076678a3 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -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 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 9f384ef83c..3b528dc47a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -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({ {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 })} {/* No pending placeholders: questions (ui-user-questions) and approvals diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 5760740c0a..a2d394c106 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -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> = {} + type UserImage = Extract type UserFile = Extract 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 -} & Pick): ReactNode { + /** Inputs used by text transforms, separate from row and rendering props. */ + facts?: Readonly> +} & Pick & Pick): ReactNode { const parts = useMemo(() => contentParts(content), [content]) const prepared = useMemo(() => ({ text: parts.text, attachments: previewAttachments ?? parts.attachments, extraBlocks: parts.rest, ...(referenceLabels === undefined ? {} : { referenceLabels }), + }), [parts, previewAttachments, referenceLabels]) + const facts = useMemo>>(() => ({ + ...inputFacts, + ...(referenceLabels === undefined ? {} : { sessionLabels: referenceLabels }), ...(skillNames === undefined ? {} : { skillNames }), - }), [parts, previewAttachments, referenceLabels, skillNames]) + }), [inputFacts, referenceLabels, skillNames]) return (
@@ -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 ( ) { - 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'>) {
)} {showBubble &&
- {projectUserText(text, referenceLabels, skillNames, 'skill', references)} + {renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })} {extraBlocks.map((block, index) => ( ))} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index e42257db3a..82d262a502 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -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 { +/** Public content rendering input; reference renderers own their injected actions. */ +export interface UserContentInput extends Pick, + Pick { readonly content: UserContentData - readonly references?: Pick + /** Transform inputs passed unchanged to UserText, separate from summary and rendering props. */ + readonly facts: Readonly> } /** 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> | 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 @@ -330,7 +328,7 @@ export type ChatFlowDataInjected = Pick { + 'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> { readonly entries: readonly RenderEntry[] readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[] readonly lastInputTurn: number | undefined diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9994c4e9ae..ff1912e13c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 0567fba58a..9f6410a0c5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 } + /** 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 = + PropsRuntime<'conversation.user.text.node', Id> + /** Selector hook over the current Session's assembled Conversation. */ export type UseConversation = SnapshotSelectorHook /** Selector hook over the registered Conversation View roster. */ @@ -146,6 +174,15 @@ export type UseConversationViews = SnapshotSelectorHook 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 } } + } /** Conversation shell beneath its root-scoped main-panel entry. */ 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ diff --git a/packages/client/ui-conversation/src/client/contract/user-text.ts b/packages/client/ui-conversation/src/client/contract/user-text.ts new file mode 100644 index 0000000000..6c3a8dcc57 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/user-text.ts @@ -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 + +type JsonPayload = T extends string | number | boolean | null ? T + : T extends (...args: never[]) => unknown ? never + : T extends readonly unknown[] ? { readonly [K in keyof T]: JsonPayload } + : T extends object ? { readonly [K in keyof T]: JsonPayload } + : never + +/** Original UTF-16 text and immutable facts; absence is observable through `context.read`. */ +export interface UserTextDocumentInput { + readonly text: string + readonly facts: Readonly> +} + +/** Discriminated JSON node over a nonempty half-open UTF-16 range in the original text. */ +export type UserTextNode = { + [Id in K]: { + readonly id: Id + readonly start: number + readonly end: number + readonly data: JsonPayload + } +}[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(key: K): UserTextFacts[K] | undefined +} + +/** One synchronous linear stage; rule IDs need not be node kinds, but `text` remains reserved. */ +export interface UserTextRule { + 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[] diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index edc182f530..b6cfcd6318 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -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() 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() diff --git a/packages/client/ui-conversation/src/client/conversation/user-text-document.ts b/packages/client/ui-conversation/src/client/conversation/user-text-document.ts new file mode 100644 index 0000000000..a5f07155e1 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/user-text-document.ts @@ -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 + 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() + private readonly readers = new Map>() + private readonly frozen = new WeakSet() + 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() + let active = true + const context: UserTextTransformContext = Object.freeze({ + read: (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): 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))) +} diff --git a/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts b/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts new file mode 100644 index 0000000000..27dd3b7ff7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/user-text-registry.ts @@ -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 { + 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() + } +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 505f65fdab..9d93b53bf8 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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 { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index f5c12385d0..59d0bfdf6a 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -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 { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index e98187bb1d..2ce7a20eef 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -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> = { + 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 ))} )} - {projectUserText(previewOf(row.content), [])} + {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, + })} )} {queueMutable &&
@@ -345,7 +344,9 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ))} )} - {projectUserText(submission.text, [])} + {renderFactorySlot('conversation.user.text', { + text: submission.text, facts: QUEUE_TEXT_FACTS, interactive: false, ellipsis: true, + })} {t('queue.sending')} {queueMutable &&
) - cursor = end - } - if (parts.length === 0) return {text} - if (cursor < text.length) pushPlain(cursor, text.length) - return <>{parts} +export function UserTextRun({ text }: UserTextRunProps): ReactNode { + return {text} +} + +/** 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 && } + {label} + + const className = clsx(css.refChip, slash && css.slashChip) + return onActivate === undefined + ? {contents} + : } diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 7d6d03e936..fad641b3fe 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -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:*", diff --git a/packages/client/ui-reference/src/client/FileReference.tsx b/packages/client/ui-reference/src/client/FileReference.tsx new file mode 100644 index 0000000000..7e0e4ffa52 --- /dev/null +++ b/packages/client/ui-reference/src/client/FileReference.tsx @@ -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 & PropsLocale + +/** + * 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(null) + const { path, label } = node.data + const close = () => { setFailedPath(null) } + const open = () => { + try { + openFile(path) + } catch { + setFailedPath(path) + return + } + setFailedPath(null) + } + return <> + + {interactive && failedPath === path && + + + } + />} + +} + +/** + * 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 +} diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 6204478f75..46f0dcdc03 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -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', diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index 7d185550cc..2d48030d5e 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-reference/src/client/user-text-rules.ts b/packages/client/ui-reference/src/client/user-text-rules.ts new file mode 100644 index 0000000000..7d756063fb --- /dev/null +++ b/packages/client/ui-reference/src/client/user-text-rules.ts @@ -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'), +} diff --git a/packages/client/ui-reference/tests/user-text-rules.spec.ts b/packages/client/ui-reference/tests/user-text-rules.spec.ts new file mode 100644 index 0000000000..96d6f7cdca --- /dev/null +++ b/packages/client/ui-reference/tests/user-text-rules.spec.ts @@ -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((segments, context) => fileReferenceRule.transform(segments, { + read: (key) => { read(key); return context.read(key) }, + })) + const folder = vi.fn((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 prefix@a.ts @。 @' + 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' } }, + ]) + }) +}) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 88de84671c..5c126676ae 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -41,6 +41,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, + { + "path": "../ui-renderer" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx index 24bb7f6bd3..89ab67fb19 100644 --- a/packages/client/ui-renderer/src/client/scoped-slots.tsx +++ b/packages/client/ui-renderer/src/client/scoped-slots.tsx @@ -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 +type SlotAnchorTag = 'div' | 'span' type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown type SlotHookFactories = Readonly> @@ -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 + return } 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 + return } 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 + return } 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 + return } 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
+ const Anchor = this.props.anchor ?? 'div' + if (this.state.failed) return 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 ( - + - {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)} -
+ + {spec === undefined ? null : renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding, spec)} + ) } @@ -1098,14 +1106,11 @@ function renderOutletContent( ownerProps: object, opts: (RenderOpts & ChainRenderOpts) | undefined, scopeBinding: StandardSourceBinding, + spec: SlotSpec, ): 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( ? ( ) : ( - + {spec.scope === 'session-maybe' ? (
+ const deadCell = () => 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)}`) - :
)} + : )} ) } @@ -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 ( <> -
{opts.fallback ?? null} -
+
{elected} ) diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 63cca5c51f..78a34d32ad 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -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:*", diff --git a/packages/client/ui-skill/src/client/SkillReference.tsx b/packages/client/ui-skill/src/client/SkillReference.tsx new file mode 100644 index 0000000000..fef9d71a3e --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillReference.tsx @@ -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 { openSkill(node.data.name) } } : {})} + /> +} diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 2b6cd12ea2..a97fa7a84c 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -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. diff --git a/packages/client/ui-skill/src/client/user-text-rules.ts b/packages/client/ui-skill/src/client/user-text-rules.ts new file mode 100644 index 0000000000..f642cac7a9 --- /dev/null +++ b/packages/client/ui-skill/src/client/user-text-rules.ts @@ -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 | 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 + }, +} diff --git a/packages/client/ui-skill/tests/user-text-rules.spec.ts b/packages/client/ui-skill/tests/user-text-rules.spec.ts new file mode 100644 index 0000000000..4ceae12d25 --- /dev/null +++ b/packages/client/ui-skill/tests/user-text-rules.spec.ts @@ -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((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((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) + } + }) +}) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 18b2f4d062..276da77105 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-renderer" }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, { "path": "../ui-tool" }, diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 6e94643107..85c7d676cc 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -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 = { 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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7e6215387..25600b8e14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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