dsh-codinput 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +26 -12
  4. package/README.md +26 -12
  5. package/TESTING.md +86 -6
  6. package/assets/donate-qr.jpg +0 -0
  7. package/lib/client.js +2875 -98
  8. package/package.json +5 -2
  9. package/src/client/cm.ts +19 -1
  10. package/src/client/command.ts +39 -8
  11. package/src/client/components/CodinputSurface.tsx +80 -27
  12. package/src/client/components/EditorPane.tsx +3 -1
  13. package/src/client/components/InputSlotSeat.tsx +56 -1
  14. package/src/client/components/SlashMenu.tsx +294 -41
  15. package/src/client/components/StatsRow.tsx +40 -23
  16. package/src/client/components/Toolbar.tsx +284 -48
  17. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  18. package/src/client/components/VoiceActivity.tsx +30 -69
  19. package/src/client/components/slotEntry.tsx +37 -0
  20. package/src/client/host-types.ts +11 -3
  21. package/src/client/hotkey.ts +38 -0
  22. package/src/client/i18n.ts +18 -4
  23. package/src/client/index.tsx +9 -0
  24. package/src/client/markdown/code.ts +281 -0
  25. package/src/client/markdown/extensions.ts +576 -0
  26. package/src/client/markdown/heavy-assets.d.ts +11 -0
  27. package/src/client/markdown/heavy-shared.ts +50 -0
  28. package/src/client/markdown/heavy.ts +159 -0
  29. package/src/client/markdown/index.ts +289 -0
  30. package/src/client/markdown/math.ts +134 -0
  31. package/src/client/markdown/mermaid.ts +358 -0
  32. package/src/client/markdown/sanitize.ts +103 -0
  33. package/src/client/markdown/styles.ts +171 -0
  34. package/src/client/prefs.ts +206 -23
  35. package/src/client/settings.tsx +7 -0
  36. package/src/client/stats.ts +92 -20
  37. package/src/client/styles.ts +55 -30
@@ -3,10 +3,12 @@
3
3
  * 遮蔽条目(prefs.userMarkdown 开启时挂载,关闭即卸载还座官方)。
4
4
  *
5
5
  * 官方 UserStyleBubble 复刻:右对齐气泡(附件行 + 气泡 + 引用摘要行 + 操作行),
6
- * 气泡文本在官方纯文本排版之上按 Markdown 渲染(marked gfm+breaks + DOMPurify,
7
- * 与编辑器预览同一管线),Markdown 样式逐条移植官方 MarkdownText.module.css。
8
- * @/ 引用 chip、/技能 chip、复制钮、时间戳、点开文件/技能等行为逐值对齐官方
9
- * projectUserText(dsh-client-ui-primitives,MIT,见 LICENSE 归属段)。
6
+ * 气泡文本按 Markdown 渲染。v0.6 起渲染统一走 `src/client/markdown/`(与编辑器
7
+ * 预览同一函数 `renderMarkdown`,输出逐字节一致);会话引用占位 span
8
+ * (data-dci-wire)在下面的装饰期换成官方引用 chip。Markdown 样式逐条移植官方
9
+ * MarkdownText.module.css。@/ 引用 chip、/技能 chip、复制钮、时间戳、点开文件/
10
+ * 技能等行为逐值对齐官方 projectUserText(dsh-client-ui-primitives,MIT,见
11
+ * LICENSE 归属段)。
10
12
  *
11
13
  * 挂载面(v0.3.7 取证,宿主 0.2.x web):chat.view 的 flowItem 渲染点
12
14
  * `renderSlot("conversation.chat.node", owner, { entryKey: routedNode.kind })`,
@@ -15,18 +17,35 @@
15
17
  * 发送瞬间的本地回显(PendingSubmissionBubble)不经本槽位,保持官方纯文本,
16
18
  * 落库后即换 Markdown 形态。
17
19
  *
20
+ * 宿主代际(0.2.0 → 0.2.2):0.2.2 的 user 节点视图改由「Factory + 键控座」
21
+ * 组合渲染,官方 UserContent(conversation.chat.user.content)只负责附件行 /
22
+ * 文本 / 额外块 / 引用摘要。本组件保留 Markdown 文本接管,但把**附件行**整段
23
+ * 委托给该 Factory(content.text 置空 → 官方只出附件行):图片经
24
+ * conversation.chat.user.images → MessageImages 得到官方缩略图与点击大图灯箱。
25
+ * 旧 0.2.0 宿主没有该 Factory,FactoryOutlet 会渲染 options.fallback,回落到
26
+ * 该宿主专有的 renderMessageImages 座位;再没有则保留文件卡降级(不静默丢内容)。
27
+ * 两条附带约束:question-reply 来源的消息让位官方 question-reply 卡片;原始
28
+ * HTML 一律按文本转义(data-dci-wire 只能由本文件的 md 扩展产生)。
29
+ *
18
30
  * 已知边界(刻意不做):KaTeX 数学公式、Shiki 代码高亮与 CodeBlock 语言横幅、
19
31
  * 链接分类图标不渲染(官方 MarkdownText 的完整面超出自含范围);未知内容块
20
32
  * 出极简 JSON 块(官方 JsonBlock 的降级形态)。
21
33
  */
22
34
 
23
- import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
- import { Marked, type MarkedExtension } from 'marked';
25
- import * as DOMPurifyModule from 'dompurify';
35
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
26
36
  import type { SlotRegisterOptions } from '../host-types';
27
37
  import { diagError } from '../diag';
38
+ import { loadPrefs, subscribePrefs } from '../prefs';
28
39
  import { bindForeign, t, useT, type TranslateParams } from '../i18n';
40
+ import {
41
+ activateMarkdown,
42
+ copyTextToClipboard,
43
+ handleMarkdownClick,
44
+ renderMarkdown,
45
+ type MarkdownImageTarget,
46
+ } from '../markdown';
29
47
  import { FileGlyph, fileExtension, fileSizeText } from './AttachmentRail';
48
+ import { ImageLightbox } from './ImageLightbox';
30
49
 
31
50
  /* ---------- 注册 ---------- */
32
51
 
@@ -63,12 +82,32 @@ interface UserMessageData {
63
82
  referenceLabels?: readonly string[];
64
83
  skillNames?: readonly string[];
65
84
  time?: unknown;
85
+ /** 官方 messageDefinition 落在 user/steering 节点上的消息源(鸭子切面)。 */
86
+ source?: unknown;
66
87
  }
67
88
 
89
+ /** 0.2.0 宿主座位:官方 MessageImages(缩略图 + 灯箱)的便携 prop 形态。 */
90
+ type RenderMessageImages = (args: { images: readonly unknown[]; align: string; compact?: boolean }) => ReactNode;
91
+
92
+ /** 0.2.2 宿主座位:键控条目 kit 固定下发的 Factory 渲染器(boundRenderFactorySlot)。 */
93
+ type RenderFactorySlot = (
94
+ name: string,
95
+ props: Record<string, unknown>,
96
+ options?: { fallback?: ReactNode },
97
+ ) => ReactNode;
98
+
68
99
  export interface UserMessageMarkdownProps {
69
- node?: { data?: UserMessageData } | undefined;
70
- /** 官方 renderMessageImages 座位 prop(图片缩略/灯箱全官方)。 */
71
- renderMessageImages?: ((args: { images: readonly unknown[]; align: string; compact?: boolean }) => React.ReactNode) | undefined;
100
+ node?: { kind?: string; data?: UserMessageData } | undefined;
101
+ /** 0.2.0 宿主:图片缩略/灯箱官方座位(0.2.2 已移除此 prop,改走 Factory)。 */
102
+ renderMessageImages?: RenderMessageImages | undefined;
103
+ /**
104
+ * 0.2.2 宿主:官方 UserMessageNodeView 同座下发的 Factory seat。附件行委托
105
+ * conversation.chat.user.content;旧宿主没有该 Factory 时 FactoryOutlet 会渲染
106
+ * options.fallback(本组件传入旧座位/文件卡链路,不静默丢内容)。
107
+ */
108
+ renderFactorySlot?: RenderFactorySlot | undefined;
109
+ /** 0.2.2 用户节点 owner props 直传的授权图片加载器(官方同座)。 */
110
+ loadImage?: unknown;
72
111
  openFile?: ((path: string) => void) | undefined;
73
112
  openSkill?: ((name: string) => void) | undefined;
74
113
  }
@@ -97,69 +136,10 @@ function contentParts(content: readonly unknown[] | undefined): ContentParts {
97
136
  return parts;
98
137
  }
99
138
 
100
- /* ---------- Markdown 管线(marked + DOMPurify,与编辑器预览同源) ---------- */
101
-
102
- /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
103
- const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
104
- DOMPurifyModule) as unknown as { sanitize(html: string, options?: Record<string, unknown>): string };
105
-
106
- /** 用户消息是「不可信 Markdown」面:marked 会原样透传原始 HTML,官方 MarkdownText 则是
107
- * mdast→React 逐节点渲染且链接/图片只放行 http(s)/mailto。这里用 DOMPurify 白名单收敛到
108
- * Markdown 产出面——禁 style/form/控件/媒体等原始 HTML(<style> 一旦进 DOM 会全局生效),
109
- * URI 只留 http(s)/mailto,行内 style 一律去掉。 */
110
- const SANITIZE_OPTIONS = {
111
- ALLOWED_TAGS: [
112
- 'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'a', 'img',
113
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input', 'span',
114
- ],
115
- ALLOWED_ATTR: [
116
- 'href', 'src', 'alt', 'title', 'target', 'rel', 'type', 'checked', 'disabled', 'start',
117
- 'colspan', 'rowspan', 'align', 'data-dci-wire',
118
- ],
119
- ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
120
- FORBID_ATTR: ['style'],
121
- };
122
-
123
- function escapeHtml(text: string): string {
124
- return text.replace(
125
- /[&<>"']/g,
126
- (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
127
- );
128
- }
139
+ /* ---------- Markdown 管线 ---------- */
129
140
 
130
- /** 会话引用的落库线格式:@[标签](dsh-session:…)(官方 SESSION_WIRE_RE)。 */
131
- const SESSION_WIRE_SRC = /^@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/;
132
-
133
- /** marked 扩展:线格式不进通用链接解析(DOMPurify 会剥掉 dsh-session: href),
134
- * 渲染成带 data 的占位 span,装饰期换成会话 chip。 */
135
- const sessionWireExtension: NonNullable<MarkedExtension['extensions']>[number] = {
136
- name: 'dshSessionWire',
137
- level: 'inline',
138
- start(src: string): number | undefined {
139
- const i = src.indexOf('@[');
140
- return i < 0 ? undefined : i;
141
- },
142
- tokenizer(src: string) {
143
- const m = SESSION_WIRE_SRC.exec(src);
144
- if (!m) return undefined;
145
- return { type: 'dshSessionWire', raw: m[0], label: m[1] } as never;
146
- },
147
- renderer(token) {
148
- const label = String((token as { label?: unknown }).label ?? '');
149
- return `<span data-dci-wire="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
150
- },
151
- };
152
-
153
- const mdParser = new Marked({ gfm: true, breaks: true, extensions: [sessionWireExtension] });
154
-
155
- function renderMarkdown(text: string): string {
156
- try {
157
- return DOMPurify.sanitize(mdParser.parse(text) as string, SANITIZE_OPTIONS);
158
- } catch (error) {
159
- diagError('umsg.markdown', error);
160
- return `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
161
- }
162
- }
141
+ /* 统一管线在 src/client/markdown/:renderMarkdown 产出 sanitized HTML;会话引用线格式
142
+ @[...](dsh-session:…) 的占位 span 由管线扩展产生,下面的装饰期把占位换成官方引用 chip。*/
163
143
 
164
144
  /* ---------- 引用 chip(官方 projectUserText 逐值复刻) ---------- */
165
145
 
@@ -391,9 +371,11 @@ function decorateMarkdownRoot(root: HTMLElement | null, actionsRef: React.Mutabl
391
371
  if (!root) return;
392
372
  try {
393
373
  // 官方 sanitizeUrl 只放行 http(s)/mailto:其余链接(含 DOMPurify 已剥掉 href 的残链)
394
- // 没有可点面,拆回纯文本;http(s) 统一新开页。
374
+ // 没有可点面,拆回纯文本;http(s) 统一新开页。页内锚点(#…)是统一管线的
375
+ // 脚注上标/回跳,必须保留可点。
395
376
  for (const anchor of Array.from(root.querySelectorAll('a'))) {
396
377
  const href = anchor.getAttribute('href') ?? '';
378
+ if (href.startsWith('#')) continue;
397
379
  if (/^https?:/i.test(href)) {
398
380
  anchor.setAttribute('target', '_blank');
399
381
  anchor.setAttribute('rel', 'noreferrer noopener');
@@ -486,27 +468,6 @@ function messageClock(time: unknown, now = Date.now()): string {
486
468
  return `${officialText(d.getFullYear() === n.getFullYear() ? 'clock.md' : 'clock.ymd', d.getFullYear() === n.getFullYear() ? 'umsg.clock.md' : 'umsg.clock.ymd', params)} ${clock}`;
487
469
  }
488
470
 
489
- async function writeClipboard(text: string): Promise<boolean> {
490
- try {
491
- await navigator.clipboard.writeText(text);
492
- return true;
493
- } catch {
494
- try {
495
- const area = document.createElement('textarea');
496
- area.value = text;
497
- area.style.position = 'fixed';
498
- area.style.opacity = '0';
499
- document.body.appendChild(area);
500
- area.select();
501
- const ok = document.execCommand('copy');
502
- area.remove();
503
- return ok;
504
- } catch {
505
- return false;
506
- }
507
- }
508
- }
509
-
510
471
  /* ---------- 图标(官方 MessageIconActions glyph 逐字路径) ---------- */
511
472
 
512
473
  function CopyGlyph(): JSX.Element {
@@ -568,8 +529,38 @@ function MessageImageFallback({ image }: { image: { attachment: unknown } }): JS
568
529
  );
569
530
  }
570
531
 
532
+ /**
533
+ * 附件行回退:0.2.2 主路径由官方 content Factory 渲染(见 view),只有旧宿主的
534
+ * FactoryOutlet fallback 才会走到这里——0.2.0 宿主用其 renderMessageImages 座位
535
+ * (官方缩略图 + 灯箱),再缺则保留文件卡降级,保证内容不静默丢失。
536
+ */
537
+ function AttachmentRowFallback({
538
+ attachments,
539
+ renderMessageImages,
540
+ }: {
541
+ attachments: ContentParts['attachments'];
542
+ renderMessageImages?: RenderMessageImages | undefined;
543
+ }): JSX.Element {
544
+ const compact = attachments.length > 1;
545
+ return (
546
+ <div className="dci-umsg-attrow" data-message-attachments="true">
547
+ {attachments.map((att, index) =>
548
+ att.type === 'image' ? (
549
+ <Fragment key={`image:${index}`}>
550
+ {renderMessageImages
551
+ ? renderMessageImages({ images: [att.image], align: 'end', compact })
552
+ : <MessageImageFallback image={att.image} />}
553
+ </Fragment>
554
+ ) : (
555
+ <MessageFileCard key={`file:${index}`} file={att.file} />
556
+ ),
557
+ )}
558
+ </div>
559
+ );
560
+ }
561
+
571
562
  function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element | null {
572
- const { node, renderMessageImages, openFile, openSkill } = props;
563
+ const { node, renderMessageImages, renderFactorySlot, loadImage, openFile, openSkill } = props;
573
564
  // 订阅宿主 locale 快照:语言切换时复制钮/引用摘要等文案即时刷新(其它组件同款)。
574
565
  useT();
575
566
  const data = (node?.data ?? {}) as UserMessageData;
@@ -577,17 +568,37 @@ function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element |
577
568
  const referenceLabels = useMemo(() => (Array.isArray(data.referenceLabels) ? data.referenceLabels : []), [data.referenceLabels]);
578
569
  const skillNames = useMemo(() => (Array.isArray(data.skillNames) ? data.skillNames : []), [data.skillNames]);
579
570
  const { text, attachments, rest } = useMemo(() => contentParts(content), [content]);
580
- const compactImages = attachments.length > 1;
581
571
  const showBubble = text !== '' || rest.length > 0;
572
+ // 官方 UserStyleBubble 的 facts 形状(0.2.2):引用标签/技能名随 content 传给
573
+ // 官方 content Factory;text 为空时它不渲染文本,facts 只为同构保留。
574
+ const factoryFacts = useMemo(
575
+ () => ({
576
+ ...(referenceLabels.length > 0 ? { sessionLabels: referenceLabels } : {}),
577
+ ...(skillNames.length > 0 ? { skillNames } : {}),
578
+ }),
579
+ [referenceLabels, skillNames],
580
+ );
581
+ const sourceKind = (data.source as { kind?: unknown } | null | undefined)?.kind;
582
582
 
583
- const html = useMemo(() => (text === '' ? '' : renderMarkdown(text)), [text]);
583
+ /** Markdown 正文等宽连字(prefs.markdownMono):即时跟随设置变化。 */
584
+ const [markdownMono, setMarkdownMono] = useState<boolean>(() => loadPrefs().markdownMono);
585
+ useEffect(() => subscribePrefs(() => setMarkdownMono(loadPrefs().markdownMono)), []);
586
+
587
+ const html = useMemo(() => (text === '' ? '' : renderMarkdown(text, { surface: 'user-message' })), [text]);
584
588
  const bodyRef = useRef<HTMLDivElement | null>(null);
589
+ /** Markdown 内 http(s) 图片点击后的大图(用户消息里的 Markdown 图片与预览一致走灯箱)。 */
590
+ const [lightbox, setLightbox] = useState<MarkdownImageTarget | null>(null);
591
+ const onMarkdownClick = useCallback((event: React.MouseEvent<HTMLDivElement>): void => {
592
+ handleMarkdownClick(event, { onImage: setLightbox });
593
+ }, []);
585
594
  const actionsRef = useRef<ChipActions>({ openFile, openSkill, skillNames, referenceLabels });
586
595
  actionsRef.current = { openFile, openSkill, skillNames, referenceLabels };
587
596
  // 引用标签 / 技能名单后到(投影刷新)时重跑装饰,补齐 @引用与 /技能 chip。
588
597
  useEffect(() => {
589
598
  decorateMarkdownRoot(bodyRef.current, actionsRef);
590
599
  }, [html, referenceLabels, skillNames]);
600
+ // 扩展渲染 扩展(代码高亮 / KaTeX / Mermaid)的 DOM 激活口;本批未注册扩展时是 no-op。
601
+ useEffect(() => activateMarkdown(bodyRef.current, 'user-message'), [html]);
591
602
 
592
603
  const [copied, setCopied] = useState(false);
593
604
  const copyTimer = useRef<number | null>(null);
@@ -600,7 +611,7 @@ function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element |
600
611
  );
601
612
  const onCopy = useCallback(() => {
602
613
  if (copied || text === '') return;
603
- void writeClipboard(text).then((ok) => {
614
+ void copyTextToClipboard(text).then((ok) => {
604
615
  if (!ok) return;
605
616
  setCopied(true);
606
617
  if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
@@ -611,32 +622,56 @@ function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element |
611
622
  });
612
623
  }, [copied, text]);
613
624
 
625
+ // 附件行:0.2.2 起整体委托官方 content Factory(text 置空 → 官方只出附件行;
626
+ // extraBlocks 空、referenceLabels 空数组避免与下方自有引用摘要重复)。图片经
627
+ // conversation.chat.user.images → MessageImages 走官方缩略图 + 点击大图灯箱。
628
+ // 旧宿主没有 conversation.chat.user.content Factory 时,FactoryOutlet 渲染
629
+ // options.fallback:0.2.0 的 renderMessageImages 座位 → 文件卡降级。
630
+ const attachmentRow =
631
+ attachments.length === 0 ? null : typeof renderFactorySlot === 'function' ? (
632
+ renderFactorySlot(
633
+ 'conversation.chat.user.content',
634
+ {
635
+ content: { text: '', attachments, extraBlocks: [], referenceLabels: [] },
636
+ loadImage,
637
+ facts: factoryFacts,
638
+ interactive: true,
639
+ },
640
+ {
641
+ fallback: <AttachmentRowFallback attachments={attachments} renderMessageImages={renderMessageImages} />,
642
+ },
643
+ )
644
+ ) : (
645
+ <AttachmentRowFallback attachments={attachments} renderMessageImages={renderMessageImages} />
646
+ );
647
+
648
+ // 问题 B:问答回复(source.kind === 'user-question-reply')在官方 0.2.2 由
649
+ // question-reply 键控卡片([data-question-reply],官方 UserMessageBubble)呈现。
650
+ // 该消息源若再以 user/steering 节点落到本座(历史投影/旧宿主兜底形态),本组件
651
+ // 再渲染一份就会让同一条问答在消息流里出现两次——这里直接让位官方卡片。
652
+ if (sourceKind === 'user-question-reply') return null;
653
+
614
654
  if (!showBubble && attachments.length === 0 && referenceLabels.length === 0 && data.time === undefined) {
615
655
  // 空消息:不渲染任何东西(避免空操作行突兀)。
616
656
  return null;
617
657
  }
618
658
 
619
659
  return (
620
- <div className="dci-umsg-row">
660
+ <div className="dci-umsg-row" data-dci-node={node?.kind} data-dci-src={typeof sourceKind === 'string' ? sourceKind : undefined}>
621
661
  <div className="dci-umsg-stack">
622
- {attachments.length > 0 && (
623
- <div className="dci-umsg-attrow" data-message-attachments="true">
624
- {attachments.map((att, index) =>
625
- att.type === 'image' ? (
626
- <Fragment key={`image:${index}`}>
627
- {renderMessageImages
628
- ? renderMessageImages({ images: [att.image], align: 'end', compact: compactImages })
629
- : <MessageImageFallback image={att.image} />}
630
- </Fragment>
631
- ) : (
632
- <MessageFileCard key={`file:${index}`} file={att.file} />
633
- ),
634
- )}
635
- </div>
636
- )}
662
+ {attachmentRow}
637
663
  {showBubble && (
638
664
  <div className="dci-umsg-bubble">
639
- {text !== '' && <div className="dci-umsg-md" ref={bodyRef} dangerouslySetInnerHTML={{ __html: html }} />}
665
+ {text !== '' && (
666
+ <div
667
+ className="dci-umsg-md dci-md"
668
+ data-dci-md="user-message"
669
+ data-mono={markdownMono ? 'true' : undefined}
670
+ ref={bodyRef}
671
+ onClick={onMarkdownClick}
672
+ dangerouslySetInnerHTML={{ __html: html }}
673
+ />
674
+ )}
640
675
  {rest.map((block, index) => (
641
676
  <pre key={index} className="dci-umsg-extra">
642
677
  {officialText('message.extraBlock', 'umsg.extraBlock')}
@@ -667,6 +702,14 @@ function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element |
667
702
  {copied ? <CheckGlyph /> : <CopyGlyph />}
668
703
  </button>
669
704
  </div>
705
+ {lightbox ? (
706
+ <ImageLightbox
707
+ src={lightbox.src}
708
+ alt={lightbox.alt}
709
+ labels={{ dialog: t('image.preview'), close: t('image.closePreview') }}
710
+ onClose={() => setLightbox(null)}
711
+ />
712
+ ) : null}
670
713
  </div>
671
714
  </div>
672
715
  );
@@ -9,68 +9,29 @@
9
9
  * 渲染。条目出现/消失经 slots.subscribe 响应式跟随(用户开关语音插件即时生效)。
10
10
  *
11
11
  * props 组装复刻官方 renderer 的合成公式:
12
- * {...standard(sessionId/inputActions), ...entry.inject()(其 hooks 子表的
13
- * speechReadiness 按 standardHookPropName 变成 useSpeechReadiness 选择器钩子),
14
- * ...ownerProps(locked/onActiveChange), t = locale.bind(entry.locale)}
15
- * inject 结果按(条目 × 会话)缓存——官方 renderer 同款缓存轴,避免每次渲染
16
- * 重建对象让钩子身份漂移。宿主未就绪(slots 无检视面 / inject 不可调 /
17
- * locale 缺席)一律不渲染,绝不半组装。
12
+ * {...standard(sessionId/inputActions), ...entry.inject()(hooks 子表的每个
13
+ * 快照 store 按 standardHookPropName 折成 use<Name> 选择器钩子),
14
+ * ...t = locale.bind(entry.locale), ...ownerProps(locked/onActiveChange)}
15
+ * 折叠走 slotEntry.entryInjectProps(= 官方 bindInjectSources 的插件侧复刻,
16
+ * 按条目 × 会话缓存 inject 面、按 store 身份缓存钩子)——**不能只折已知钩子**:
17
+ * 0.2.2 的 voice-input inject 返回
18
+ * { openSettings, hooks: { speechReadiness, microphoneDevice, pluginNavigation },
19
+ * selectMicrophone, createRecording, transcribe, ... }
20
+ * 而 VoiceInput 组件会调用 useSpeechReadiness / useMicrophoneDevice /
21
+ * usePluginNavigation 三个钩子;旧实现只绑了 speechReadiness,另外两个未定义
22
+ * 直接抛错,被 EntryBoundary 静默吞掉 → 麦克风整体不渲染(0.2.2 回归根因)。
23
+ * 通用折叠后旧宿主(hooks 表只有 speechReadiness 的旧条目)同样成立。
24
+ *
25
+ * 宿主未就绪(slots 无检视面 / inject 不可调 / locale 缺席)一律不渲染,
26
+ * 绝不半组装;条目崩溃仍由 EntryBoundary 隔离(只丢本座位)。
18
27
  */
19
28
 
20
- import { createElement, useMemo, useSyncExternalStore } from 'react';
21
- import type { HostObservable, SlotEntryLike } from '../host-types';
22
- import { diagError } from '../diag';
23
- import { EntryBoundary, firstComponentEntry, translatorFor, useSlotEntries } from './slotEntry';
29
+ import { createElement, useMemo } from 'react';
30
+ import { EntryBoundary, entryInjectProps, firstComponentEntry, translatorFor, useSlotEntries } from './slotEntry';
24
31
 
25
32
  const ACTIVITY_SLOT = 'conversation.input.activity';
26
33
  const VOICE_NS_FALLBACK = 'voice-input';
27
34
 
28
- type ReadinessSelector = (value: unknown) => unknown;
29
- type ReadinessHook = (selector: ReadinessSelector) => unknown;
30
-
31
- /** observableHook 同款缓存轴:钩子身份按 readiness store 稳定,不逐渲染重订。 */
32
- const readinessHookCache = new WeakMap<object, ReadinessHook>();
33
- /** 官方 renderer 的 inject 缓存轴:条目 × 会话绑定。 */
34
- const injectCache = new WeakMap<object, Map<string, Record<string, unknown> | null>>();
35
-
36
- /** 调条目 inject 工厂取 props 面(session 槽位以 sessionId 调用,官方同参)。 */
37
- function resolveInjected(entry: SlotEntryLike, sessionId: string): Record<string, unknown> | null {
38
- if (typeof entry.inject !== 'function') return null;
39
- let perSession = injectCache.get(entry);
40
- if (!perSession) {
41
- perSession = new Map();
42
- injectCache.set(entry, perSession);
43
- }
44
- if (!perSession.has(sessionId)) {
45
- let face: Record<string, unknown> | null = null;
46
- try {
47
- face = (entry.inject as (key: string) => Record<string, unknown>)(sessionId) ?? null;
48
- } catch (error) {
49
- diagError('voice.inject', error);
50
- face = null;
51
- }
52
- perSession.set(sessionId, face);
53
- }
54
- return perSession.get(sessionId) ?? null;
55
- }
56
-
57
- /** hooks.speechReadiness(快照 store)→ use<Name> 选择器钩子(observableHook 同款)。 */
58
- function readinessHookFor(source: unknown): ReadinessHook | null {
59
- const face = source as HostObservable<unknown> | undefined;
60
- if (!face || typeof face.subscribe !== 'function' || typeof face.getSnapshot !== 'function') return null;
61
- let hook = readinessHookCache.get(face);
62
- if (!hook) {
63
- hook = (selector: ReadinessSelector): unknown =>
64
- useSyncExternalStore(
65
- (fn: () => void) => face.subscribe(fn) ?? (() => {}),
66
- () => selector(face.getSnapshot()),
67
- () => selector(face.getSnapshot()),
68
- );
69
- readinessHookCache.set(face, hook);
70
- }
71
- return hook;
72
- }
73
-
74
35
  export interface VoiceActivityProps {
75
36
  sessionId: string | undefined;
76
37
  /** 官方输入机动作面(bar 槽位 standard props 里的 inputActions 原样透传)。 */
@@ -88,28 +49,28 @@ export function VoiceActivity(props: VoiceActivityProps): JSX.Element | null {
88
49
  // single 槽位赢家 = 优先级最低者 = entries 首个(register 即按优先级排序)。
89
50
  const entry = useMemo(() => firstComponentEntry(entries), [entries]);
90
51
 
52
+ // inject 面:通用 hooks→use<Name> 折叠加其余字段原样(entryInjectProps 自带
53
+ // 条目 × 会话缓存与失败标记,这里不再自建缓存)。
91
54
  const injected = useMemo(
92
- () => (entry && sessionId ? resolveInjected(entry, sessionId) : null),
55
+ () => (entry && sessionId ? entryInjectProps(entry, sessionId) : null),
93
56
  [entry, sessionId],
94
57
  );
95
58
  const translator = useMemo(() => (entry ? translatorFor(entry, VOICE_NS_FALLBACK) : null), [entry]);
96
- const readinessSource = (injected?.['hooks'] as { speechReadiness?: unknown } | undefined)?.speechReadiness;
97
- const useReadiness = useMemo(() => readinessHookFor(readinessSource), [readinessSource]);
98
59
 
99
- if (!entry || !sessionId || !injected || !translator || !useReadiness) return null;
60
+ if (!entry || !sessionId || !injected || !translator) return null;
100
61
 
101
- // hooks 子表已折成 use* 钩子(bindInjectSources 同款),其余 props 原样下传。
102
- const { hooks: _hooks, ...actions } = injected;
103
- void _hooks;
104
- return createElement(EntryBoundary, {
105
- children: createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
62
+ // 官方 renderEntry 展开顺序:standard kit → inject 面 → 条目词典 t → owner props。
63
+ // owner props 最后展开(与官方一致:条目 inject 不得覆盖 locked/onActiveChange)。
64
+ return createElement(
65
+ EntryBoundary,
66
+ null,
67
+ createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
106
68
  sessionId,
107
69
  inputActions,
70
+ ...injected,
71
+ t: translator,
108
72
  locked,
109
73
  onActiveChange,
110
- ...actions,
111
- useSpeechReadiness: useReadiness,
112
- t: translator,
113
74
  }),
114
- });
75
+ );
115
76
  }
@@ -56,6 +56,43 @@ const RENDERABLE_OBJECT_TYPES: readonly symbol[] = [
56
56
  Symbol.for('react.lazy'),
57
57
  ];
58
58
 
59
+ /**
60
+ * 槽位声明态(官方 SlotCore 检视面可区分的三种事实):
61
+ * - `declared`:槽位键已声明(entries 返回数组,哪怕为空 = 已声明无占用者);
62
+ * - `undeclared`:槽位键未声明(entries 抛错)——旧宿主没有该子槽位;
63
+ * - `unknown`:检视面本身缺席(更老的宿主没有 entries 方法)或读值异常。
64
+ *
65
+ * 与 `useSlotEntries`(无条目一律归一成空数组)分开:permission/plan 这类
66
+ * **新槽位**要求「已声明但无人占用 → 按官方留空」,而「宿主未声明 → 回退
67
+ * 自绘控件」,两者不能混。getSnapshot 返回字符串常量,快照身份按值稳定。
68
+ */
69
+ export type SlotDeclaration = 'declared' | 'undeclared' | 'unknown';
70
+
71
+ export function useSlotDeclaration(key: string): SlotDeclaration {
72
+ const slots = stash.slots;
73
+ const subscribe = useCallback(
74
+ (fn: () => void) => {
75
+ if (typeof slots?.subscribe !== 'function') return () => {};
76
+ try {
77
+ return slots.subscribe(key, fn) ?? (() => {});
78
+ } catch {
79
+ return () => {};
80
+ }
81
+ },
82
+ [slots, key],
83
+ );
84
+ const getSnapshot = useCallback((): SlotDeclaration => {
85
+ if (!slots || typeof slots.entries !== 'function') return 'unknown';
86
+ try {
87
+ slots.entries(key);
88
+ return 'declared';
89
+ } catch {
90
+ return 'undeclared';
91
+ }
92
+ }, [slots, key]);
93
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
94
+ }
95
+
59
96
  /** 组件位是否为 React 可渲染类型(函数组件 / memo / forwardRef / lazy 包装)。 */
60
97
  export function isRenderableComponent(value: unknown): boolean {
61
98
  if (typeof value === 'function') return true;
@@ -46,8 +46,10 @@ export interface SlotsService {
46
46
  inject(slotName: string, factory: () => Generator<unknown> | (() => unknown)): void;
47
47
  /**
48
48
  * 槽位检视面(0.2.x 起的 SlotCore 公开方法,鸭子探测):entries 返回条目
49
- * 快照(引用在变更之间稳定,可作 uSES getSnapshot 源;未声明槽返回稳定
50
- * 空数组);subscribe 订阅该槽条目变更。
49
+ * 快照(引用在变更之间稳定,可作 uSES getSnapshot 源)。**抛错 = 该槽位
50
+ * 未声明**(旧宿主,或本宿主尚未声明该子槽位);返回空数组 = 已声明但
51
+ * 当前无占用者——两者语义不同,见 slotEntry.useSlotDeclaration。
52
+ * subscribe 订阅该槽条目变更(未声明键可能同样抛错)。
51
53
  */
52
54
  entries?(key: string): readonly unknown[];
53
55
  subscribe?(key: string, fn: () => void): () => void;
@@ -75,7 +77,13 @@ export interface SlotEntryLike {
75
77
  // ---------- input machine ----------
76
78
 
77
79
  export type DraftAttachmentId = string;
78
- export type InputPhase = 'plain' | 'adjudicating' | 'claimed' | 'submitting';
80
+ /**
81
+ * 输入机提交阶段(官方 0.2.2 contract/input.d.ts 逐值):
82
+ * `adjudicating`(`/` 行命令裁决中)、`submitting`(已 claim 的命令执行中)、
83
+ * `sending`(普通消息等待 Host 接收;0.2.2 新增)。三者都是 busy:
84
+ * 编辑器/附件只读,发送主按钮不可重复提交。
85
+ */
86
+ export type InputPhase = 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending';
79
87
 
80
88
  export interface InputState {
81
89
  readonly draft: string;
@@ -6,6 +6,44 @@
6
6
 
7
7
  const MODIFIERS = new Set(['ctrl', 'alt', 'shift', 'meta', 'mod']);
8
8
 
9
+ /** 规范化输出时修饰键的固定顺序(与 comboFromEvent 一致)。 */
10
+ const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta'] as const;
11
+
12
+ /** 命名键(enter/escape/arrowup/f12…):字母开头、字母数字组成。 */
13
+ const NAMED_KEY = /^[a-z][a-z0-9]{0,15}$/;
14
+
15
+ /** 主键 token 是否可解析:单字符(非空白/加号)或命名键。 */
16
+ function isKeyToken(token: string): boolean {
17
+ return token.length === 1 ? /[^\s+]/.test(token) : NAMED_KEY.test(token);
18
+ }
19
+
20
+ /**
21
+ * 存储的组合串是否基本合法:非空字符串,恰好一个可解析主键,无空/重复
22
+ * token;`mod` 不与 ctrl/meta 混用(否则 matchEvent 语义有歧义)。
23
+ */
24
+ export function isValidHotkey(value: unknown): value is string {
25
+ if (typeof value !== 'string') return false;
26
+ if (value.length === 0 || value.length > 64) return false;
27
+ const tokens = value.toLowerCase().split('+');
28
+ if (tokens.some((token) => token.length === 0)) return false;
29
+ const unique = new Set(tokens);
30
+ if (unique.size !== tokens.length) return false;
31
+ const keys = tokens.filter((token) => !MODIFIERS.has(token));
32
+ if (keys.length !== 1 || keys[0] === undefined || !isKeyToken(keys[0])) return false;
33
+ return !(unique.has('mod') && (unique.has('ctrl') || unique.has('meta')));
34
+ }
35
+
36
+ /**
37
+ * 组合串规范化:小写 + 修饰键固定顺序(ctrl+alt+shift+meta,mod 收尾)+ 主键。
38
+ * 调用方应先用 isValidHotkey 校验;非法输入按原 token 尽力拼接。
39
+ */
40
+ export function normalizeHotkey(combo: string): string {
41
+ const tokens = combo.toLowerCase().split('+').filter(Boolean);
42
+ const modifiers = MODIFIER_ORDER.filter((token) => tokens.includes(token));
43
+ const key = tokens.find((token) => !MODIFIERS.has(token));
44
+ return [...modifiers, ...(tokens.includes('mod') ? ['mod'] : []), ...(key === undefined ? [] : [key])].join('+');
45
+ }
46
+
9
47
  /** 事件 → 规范化组合串(如 "ctrl+shift+enter");纯修饰键按下返回 null。 */
10
48
  export function comboFromEvent(event: KeyboardEvent): string | null {
11
49
  const raw = event.key;