import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { InputTriggerSource, ReferenceInsert, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { QingSelection } from '../contracts.js' export const QING_SELECTION_REFERENCE_SOURCE = 'qingagent-selection' // 选区与批注共用同一段 chip label 预览预算:底层投影 label 定 chip 宽度,显示层 // (12px 覆盖文本「“选段:/✎ 批注:」+完整内容)按此宽度 ellipsis——10 个全角字的 // 16px 底宽≈14 个 12px 显示字,恰好容纳图标+前缀+10 字内容(用户裁定的下限)。 export const CHIP_LABEL_PREVIEW_LENGTH = 10 const CIRCLED_NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳'] as const /** WORD JOINER(U+2060):零宽、禁断行。织入 label 字符间让整枚 chip 成为浏览器眼中 * 的原子词——换行时整体挪行,从根上消灭「chip 中间被拆成两段、覆盖层只画一半」的 * 空白块(用户实测)。零宽不改字符度量,textarea 与镜像同规则;发送展开走 ref, * joiner 永不进入消息正文。 */ export const CHIP_ATOMIC_JOINER = '\u2060' export function weaveAtomicLabel(label: string): string { return Array.from(label).join(CHIP_ATOMIC_JOINER) } export function unweaveAtomicLabel(label: string): string { return label.replaceAll(CHIP_ATOMIC_JOINER, '') } /** 多枚 chip 的投影 label 必须互不为前缀(setDraft diff 歧义会吞相邻 occurrence)。 * 序号前置:首字符即分叉。选段连选相邻内容时前 10 字极易相同,与批注共用这道防线。 */ export function dedupeChipLabel(label: string, takenLabels: readonly string[]): string { const taken = takenLabels.map(unweaveAtomicLabel) if (!taken.some((existing) => existing.startsWith(label) || label.startsWith(existing))) { return label } const first = new Set(taken.map((existing) => existing.charAt(0))) const circled = CIRCLED_NUMBERS.find((mark) => !first.has(mark)) ?? `#${takenLabels.length + 1}` return `${circled}${label}` } /** * 模型侧的选段表示。ref 自身携带文稿、块与字符范围,不能依赖 bridge 中稍后会清掉的 * 单槽 selection,也不能依赖只供复制/持久化的 clipboardText。 */ export function selectionReferenceText( selection: QingSelection, title?: string | null, paragraphOrdinal?: number, ): string { // 展开文本会原样出现在发送气泡里:不带 UUID/块 ID/字符范围等机器噪音(用户实测嫌丑)。 // 但引文全文多处相同时纯文本锚有歧义——补一个人类可读的「第 N 段」定位(有块索引时)。 const documentTitle = title?.trim() || '当前文稿' const locator = paragraphOrdinal && paragraphOrdinal > 0 ? `第${paragraphOrdinal}段` : '' return `[选段]《${documentTitle}》${locator}:「${selection.quote}」` } /** * 解析选段 chip 的文稿名。匹配一律以 activeDoc 自带的 sessionId 为准,绝不信 * activeEngineSessionId:切稿时后者由 binding-changed 同步更新,activeDoc 却要等 * 异步 refresh 才换代——窗口期划词会把刚切出的稿名铸进 chip(评测 r4 席3 实证)。 */ export function resolveSelectionTitle( snapshot: { activeDoc?: { sessionId?: string; title?: string | null } state?: { binding: { docs: ReadonlyArray<{ engineSessionId: string; title?: string | null }> } } }, engineSessionId: string, ): string | undefined { const activeTitle = snapshot.activeDoc?.sessionId === engineSessionId ? snapshot.activeDoc.title : undefined return activeTitle ?? snapshot.state?.binding.docs.find( (doc) => doc.engineSessionId === engineSessionId, )?.title ?? undefined } export interface PmBlockNode { attrs?: { blockId?: string } content?: PmBlockNode[] } /** blockId 命中判定:顶层块自身或其任意后代——选中列表项/表格单元格等嵌套块时, * 「第 N 段」按包含它的顶层块计序(评测 r5 席3 实证:列表项选段丢段号)。 */ export function blockContainsId(block: PmBlockNode, blockId: string): boolean { if (block.attrs?.blockId === blockId) return true return block.content?.some((child) => blockContainsId(child, blockId)) ?? false } /** 底层投影 label 只留引文本体(10 字截断):图标与「选段:」前缀由呈现层覆盖文本 * 提供,底层越短 chip 越紧凑(用户裁定宽度自适应,右侧不留空)。 */ export function selectionReferenceLabel(quote: string): string { const plain = quote.replace(/\s+/g, ' ').trim() return plain.length > CHIP_LABEL_PREVIEW_LENGTH ? `${plain.slice(0, CHIP_LABEL_PREVIEW_LENGTH)}…` : plain } export function createSelectionReference( selection: QingSelection, title?: string | null, paragraphOrdinal?: number, takenLabels: readonly string[] = [], ): ReferenceInsert { const ref = selectionReferenceText(selection, title, paragraphOrdinal) return { source: QING_SELECTION_REFERENCE_SOURCE, ref, label: weaveAtomicLabel(dedupeChipLabel(selectionReferenceLabel(selection.quote), takenLabels)), // 草稿持久化、复制与 owner 暂时不可用时也保留完整选段语义。 clipboardText: ref, } } /** * 选段不是 slash token,使用当前草稿末尾的合法零宽 TokenSpan。事件的同步 true 回值 * 表示 composer 已通过 phase + draftRev CAS 并实际插入 occurrence。 */ export function insertSelectionReference( actx: ClientContext, selection: QingSelection, title?: string | null, paragraphOrdinal?: number, ): boolean { // 连续快速划词的竞态可能送来空引文单槽——铸出来就是一枚空白 chip(用户实测),拒插。 if (!selection.quote.replace(/\s+/gu, ' ').trim()) return false const snapshot = actx.conversation.input.for(actx).state.getSnapshot() const takenLabels = (snapshot.occurrences ?? []).map((occurrence) => occurrence.label) const reference = createSelectionReference(selection, title, paragraphOrdinal, takenLabels) // 幂等守卫:bridge 状态重放/多路订阅可能对同一选段重复触发(用户实测双 chip); // 草稿里已有同 source+ref 的 occurrence 即视为已插入成功,由调用方清 ingress。 if (snapshot.occurrences?.some((occurrence) => occurrence.source === QING_SELECTION_REFERENCE_SOURCE && occurrence.ref === reference.ref)) { return true } const offset = snapshot.draft.length return actx.bail(actx, 'slash/input-insert-reference', { reference, span: { start: offset, end: offset, draftRev: snapshot.draftRev, }, }) === true } /** * occurrence 提交时由 composer 按 source/ref 路由到这里;ref 已经是模型需要看到的 * 完整锚点引文,serializer 只做恒等展开。 */ export const qingSelectionReferenceSource: InputTriggerSource = { trigger: '@', name: QING_SELECTION_REFERENCE_SOURCE, order: Number.MAX_SAFE_INTEGER, candidates: async () => [], onPick: () => undefined, codec: { clipboardText: (ref) => ref, serialize: async (ref) => ref, }, } /** * chip hover 展示原始内容:宿主 chipLabel 定宽硬裁无 tooltip,这里以委托监听为可见 chip * 补 title(浏览器原生悬浮)。chip DOM 顺序与 InputState.occurrences 的 offset 顺序一致, * 按序配对;只给本插件来源的 occurrence 写 title(内容=完整锚点引文)。 * 返回卸载函数,随会话作用域清理。 */ export function installSelectionChipHoverTitles( getOccurrences: () => readonly { source: string; ref: string }[] | undefined, ): () => void { if (typeof document === 'undefined') return () => {} // chip 画在 pointer-events:none 的 backdrop 镜像层,鼠标事件全落在上层透明 textarea—— // 用 mousemove 坐标命中,命中即显示自绘悬浮条(原生 title 有 ~1s 延迟,用户等不到)。 const TIP_ID = 'qingagent-selection-tip' const ensureTip = (): HTMLDivElement => { let tip = document.getElementById(TIP_ID) as HTMLDivElement | null if (!tip) { tip = document.createElement('div') tip.id = TIP_ID tip.style.cssText = [ 'position:fixed', 'z-index:100500', 'display:none', 'pointer-events:none', 'max-width:380px', 'padding:6px 10px', 'border-radius:8px', 'background:var(--dsw-alias-bg-layer-3, #26282c)', 'color:var(--dsw-alias-label-primary, #e8e8ea)', 'border:1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.12))', 'box-shadow:var(--dsw-shadow-lv2, 0 8px 24px rgba(0,0,0,.35))', 'font-size:12px', 'line-height:18px', 'white-space:pre-wrap', 'word-break:break-word', ].join(';') document.body.appendChild(tip) } return tip } const hideTip = () => { const tip = document.getElementById(TIP_ID) if (tip) tip.style.display = 'none' } let raf = 0 const onMove = (event: MouseEvent) => { if (raf) return raf = requestAnimationFrame(() => { raf = 0 const input = (event.target as Element | null)?.closest?.('textarea') if (!input) return hideTip() const scope = input.closest('[class*="card"]') ?? input.parentElement?.parentElement const labels = scope ? [...scope.querySelectorAll('[class*="chipLabel"]')] : [] let hit = -1 let hitRect: DOMRect | undefined for (let index = 0; index < labels.length; index += 1) { const rect = labels[index]!.getBoundingClientRect() if (event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) { hit = index hitRect = rect break } } const occurrence = hit >= 0 ? getOccurrences()?.[hit] : undefined if (!occurrence || occurrence.source !== QING_SELECTION_REFERENCE_SOURCE || !hitRect) return hideTip() const tip = ensureTip() tip.textContent = occurrence.ref tip.style.display = 'block' // 先摆再量:置于 chip 上方,水平钳在视口内。 const width = Math.min(380, tip.offsetWidth || 380) tip.style.left = `${Math.max(8, Math.min(hitRect.left, window.innerWidth - width - 8))}px` tip.style.top = `${Math.max(8, hitRect.top - tip.offsetHeight - 8)}px` }) } const onLeave = () => hideTip() document.addEventListener('mousemove', onMove, { capture: true, passive: true }) document.addEventListener('mousedown', onLeave, { capture: true, passive: true }) return () => { if (raf) cancelAnimationFrame(raf) document.removeEventListener('mousemove', onMove, { capture: true }) document.removeEventListener('mousedown', onLeave, { capture: true }) document.getElementById(TIP_ID)?.remove() } }