dsh-better-display 0.1.1

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 (300) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/DESIGN.md +72 -0
  3. package/LICENSE +24 -0
  4. package/README.en.md +41 -0
  5. package/README.md +41 -0
  6. package/THIRD_PARTY_NOTICES.md +65 -0
  7. package/cordis.patch.yml +5 -0
  8. package/cordis.yml +4 -0
  9. package/dshx.yml +5 -0
  10. package/lib/client.js +49923 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/dsh-better-display.js +256 -0
  13. package/lib/types/client/Blocks.d.ts +45 -0
  14. package/lib/types/client/Blocks.d.ts.map +1 -0
  15. package/lib/types/client/Blocks.js +136 -0
  16. package/lib/types/client/Blocks.js.map +1 -0
  17. package/lib/types/client/ChoreographedFlow.d.ts +22 -0
  18. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -0
  19. package/lib/types/client/ChoreographedFlow.js +170 -0
  20. package/lib/types/client/ChoreographedFlow.js.map +1 -0
  21. package/lib/types/client/ClosedProcessSummary.d.ts +9 -0
  22. package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -0
  23. package/lib/types/client/ClosedProcessSummary.js +15 -0
  24. package/lib/types/client/ClosedProcessSummary.js.map +1 -0
  25. package/lib/types/client/LiveFold.d.ts +10 -0
  26. package/lib/types/client/LiveFold.d.ts.map +1 -0
  27. package/lib/types/client/LiveFold.js +39 -0
  28. package/lib/types/client/LiveFold.js.map +1 -0
  29. package/lib/types/client/McpAppFrame.d.ts +32 -0
  30. package/lib/types/client/McpAppFrame.d.ts.map +1 -0
  31. package/lib/types/client/McpAppFrame.js +193 -0
  32. package/lib/types/client/McpAppFrame.js.map +1 -0
  33. package/lib/types/client/Reader.d.ts +3 -0
  34. package/lib/types/client/Reader.d.ts.map +1 -0
  35. package/lib/types/client/Reader.js +537 -0
  36. package/lib/types/client/Reader.js.map +1 -0
  37. package/lib/types/client/ReasoningCard.d.ts +11 -0
  38. package/lib/types/client/ReasoningCard.d.ts.map +1 -0
  39. package/lib/types/client/ReasoningCard.js +301 -0
  40. package/lib/types/client/ReasoningCard.js.map +1 -0
  41. package/lib/types/client/StickyLane.d.ts +8 -0
  42. package/lib/types/client/StickyLane.d.ts.map +1 -0
  43. package/lib/types/client/StickyLane.js +28 -0
  44. package/lib/types/client/StickyLane.js.map +1 -0
  45. package/lib/types/client/TimelineRail.d.ts +10 -0
  46. package/lib/types/client/TimelineRail.d.ts.map +1 -0
  47. package/lib/types/client/TimelineRail.js +168 -0
  48. package/lib/types/client/TimelineRail.js.map +1 -0
  49. package/lib/types/client/ToolActivity.d.ts +17 -0
  50. package/lib/types/client/ToolActivity.d.ts.map +1 -0
  51. package/lib/types/client/ToolActivity.js +192 -0
  52. package/lib/types/client/ToolActivity.js.map +1 -0
  53. package/lib/types/client/TurnMetrics.d.ts +11 -0
  54. package/lib/types/client/TurnMetrics.d.ts.map +1 -0
  55. package/lib/types/client/TurnMetrics.js +44 -0
  56. package/lib/types/client/TurnMetrics.js.map +1 -0
  57. package/lib/types/client/WaitingStatus.d.ts +6 -0
  58. package/lib/types/client/WaitingStatus.d.ts.map +1 -0
  59. package/lib/types/client/WaitingStatus.js +19 -0
  60. package/lib/types/client/WaitingStatus.js.map +1 -0
  61. package/lib/types/client/conversation-scroll.d.ts +19 -0
  62. package/lib/types/client/conversation-scroll.d.ts.map +1 -0
  63. package/lib/types/client/conversation-scroll.js +25 -0
  64. package/lib/types/client/conversation-scroll.js.map +1 -0
  65. package/lib/types/client/deliverables.d.ts +24 -0
  66. package/lib/types/client/deliverables.d.ts.map +1 -0
  67. package/lib/types/client/deliverables.js +104 -0
  68. package/lib/types/client/deliverables.js.map +1 -0
  69. package/lib/types/client/entry-policy.d.ts +10 -0
  70. package/lib/types/client/entry-policy.d.ts.map +1 -0
  71. package/lib/types/client/entry-policy.js +24 -0
  72. package/lib/types/client/entry-policy.js.map +1 -0
  73. package/lib/types/client/entry.d.ts +4 -0
  74. package/lib/types/client/entry.d.ts.map +1 -0
  75. package/lib/types/client/entry.js +34 -0
  76. package/lib/types/client/entry.js.map +1 -0
  77. package/lib/types/client/fold-choreography.d.ts +28 -0
  78. package/lib/types/client/fold-choreography.d.ts.map +1 -0
  79. package/lib/types/client/fold-choreography.js +31 -0
  80. package/lib/types/client/fold-choreography.js.map +1 -0
  81. package/lib/types/client/index.d.ts +7 -0
  82. package/lib/types/client/index.d.ts.map +1 -0
  83. package/lib/types/client/index.js +153 -0
  84. package/lib/types/client/index.js.map +1 -0
  85. package/lib/types/client/live-turn.d.ts +58 -0
  86. package/lib/types/client/live-turn.d.ts.map +1 -0
  87. package/lib/types/client/live-turn.js +144 -0
  88. package/lib/types/client/live-turn.js.map +1 -0
  89. package/lib/types/client/markdown/MarkdownText.d.ts +39 -0
  90. package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -0
  91. package/lib/types/client/markdown/MarkdownText.js +171 -0
  92. package/lib/types/client/markdown/MarkdownText.js.map +1 -0
  93. package/lib/types/client/markdown/cjkFriendlyStrong.d.ts +9 -0
  94. package/lib/types/client/markdown/cjkFriendlyStrong.d.ts.map +1 -0
  95. package/lib/types/client/markdown/cjkFriendlyStrong.js +73 -0
  96. package/lib/types/client/markdown/cjkFriendlyStrong.js.map +1 -0
  97. package/lib/types/client/markdown/incremental.d.ts +69 -0
  98. package/lib/types/client/markdown/incremental.d.ts.map +1 -0
  99. package/lib/types/client/markdown/incremental.js +108 -0
  100. package/lib/types/client/markdown/incremental.js.map +1 -0
  101. package/lib/types/client/markdown/katex.d.ts +27 -0
  102. package/lib/types/client/markdown/katex.d.ts.map +1 -0
  103. package/lib/types/client/markdown/katex.js +88 -0
  104. package/lib/types/client/markdown/katex.js.map +1 -0
  105. package/lib/types/client/markdown/mathCompatibility.d.ts +11 -0
  106. package/lib/types/client/markdown/mathCompatibility.d.ts.map +1 -0
  107. package/lib/types/client/markdown/mathCompatibility.js +293 -0
  108. package/lib/types/client/markdown/mathCompatibility.js.map +1 -0
  109. package/lib/types/client/markdown/parse.d.ts +25 -0
  110. package/lib/types/client/markdown/parse.d.ts.map +1 -0
  111. package/lib/types/client/markdown/parse.js +42 -0
  112. package/lib/types/client/markdown/parse.js.map +1 -0
  113. package/lib/types/client/markdown/render.d.ts +119 -0
  114. package/lib/types/client/markdown/render.d.ts.map +1 -0
  115. package/lib/types/client/markdown/render.js +435 -0
  116. package/lib/types/client/markdown/render.js.map +1 -0
  117. package/lib/types/client/mcp-app.d.ts +50 -0
  118. package/lib/types/client/mcp-app.d.ts.map +1 -0
  119. package/lib/types/client/mcp-app.js +328 -0
  120. package/lib/types/client/mcp-app.js.map +1 -0
  121. package/lib/types/client/message-chrome.d.ts +19 -0
  122. package/lib/types/client/message-chrome.d.ts.map +1 -0
  123. package/lib/types/client/message-chrome.js +42 -0
  124. package/lib/types/client/message-chrome.js.map +1 -0
  125. package/lib/types/client/motion.d.ts +38 -0
  126. package/lib/types/client/motion.d.ts.map +1 -0
  127. package/lib/types/client/motion.js +367 -0
  128. package/lib/types/client/motion.js.map +1 -0
  129. package/lib/types/client/native/ContextBody.d.ts +130 -0
  130. package/lib/types/client/native/ContextBody.d.ts.map +1 -0
  131. package/lib/types/client/native/ContextBody.js +398 -0
  132. package/lib/types/client/native/ContextBody.js.map +1 -0
  133. package/lib/types/client/native/ContextInjectionRow.d.ts +26 -0
  134. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -0
  135. package/lib/types/client/native/ContextInjectionRow.js +27 -0
  136. package/lib/types/client/native/ContextInjectionRow.js.map +1 -0
  137. package/lib/types/client/native/ReferenceIcon.d.ts +16 -0
  138. package/lib/types/client/native/ReferenceIcon.d.ts.map +1 -0
  139. package/lib/types/client/native/ReferenceIcon.js +16 -0
  140. package/lib/types/client/native/ReferenceIcon.js.map +1 -0
  141. package/lib/types/client/native/tool-call-model.d.ts +58 -0
  142. package/lib/types/client/native/tool-call-model.d.ts.map +1 -0
  143. package/lib/types/client/native/tool-call-model.js +219 -0
  144. package/lib/types/client/native/tool-call-model.js.map +1 -0
  145. package/lib/types/client/open-file.d.ts +24 -0
  146. package/lib/types/client/primitive-labels.d.ts +10 -0
  147. package/lib/types/client/primitive-labels.d.ts.map +1 -0
  148. package/lib/types/client/primitive-labels.js +37 -0
  149. package/lib/types/client/primitive-labels.js.map +1 -0
  150. package/lib/types/client/projection.d.ts +41 -0
  151. package/lib/types/client/projection.d.ts.map +1 -0
  152. package/lib/types/client/projection.js +112 -0
  153. package/lib/types/client/projection.js.map +1 -0
  154. package/lib/types/client/reasoning-follow.d.ts +6 -0
  155. package/lib/types/client/reasoning-follow.d.ts.map +1 -0
  156. package/lib/types/client/reasoning-follow.js +12 -0
  157. package/lib/types/client/reasoning-follow.js.map +1 -0
  158. package/lib/types/client/settings-copy.d.ts +6 -0
  159. package/lib/types/client/settings-slots.d.ts +16 -0
  160. package/lib/types/client/store.d.ts +17 -0
  161. package/lib/types/client/store.d.ts.map +1 -0
  162. package/lib/types/client/store.js +13 -0
  163. package/lib/types/client/store.js.map +1 -0
  164. package/lib/types/client/stream-buffer.d.ts +29 -0
  165. package/lib/types/client/stream-buffer.d.ts.map +1 -0
  166. package/lib/types/client/stream-buffer.js +113 -0
  167. package/lib/types/client/stream-buffer.js.map +1 -0
  168. package/lib/types/client/streaming.d.ts +18 -0
  169. package/lib/types/client/streaming.d.ts.map +1 -0
  170. package/lib/types/client/streaming.js +77 -0
  171. package/lib/types/client/streaming.js.map +1 -0
  172. package/lib/types/client/timeline.d.ts +26 -0
  173. package/lib/types/client/timeline.d.ts.map +1 -0
  174. package/lib/types/client/timeline.js +48 -0
  175. package/lib/types/client/timeline.js.map +1 -0
  176. package/lib/types/client/tool-activity.d.ts +52 -0
  177. package/lib/types/client/tool-activity.d.ts.map +1 -0
  178. package/lib/types/client/tool-activity.js +178 -0
  179. package/lib/types/client/tool-activity.js.map +1 -0
  180. package/lib/types/client/types.d.ts +66 -0
  181. package/lib/types/client/types.d.ts.map +1 -0
  182. package/lib/types/client/types.js +2 -0
  183. package/lib/types/client/types.js.map +1 -0
  184. package/lib/types/client/waiting-clock.d.ts +17 -0
  185. package/lib/types/client/waiting-clock.d.ts.map +1 -0
  186. package/lib/types/client/waiting-clock.js +24 -0
  187. package/lib/types/client/waiting-clock.js.map +1 -0
  188. package/lib/types/client/word-motion.d.ts +16 -0
  189. package/lib/types/client/word-motion.d.ts.map +1 -0
  190. package/lib/types/client/word-motion.js +89 -0
  191. package/lib/types/client/word-motion.js.map +1 -0
  192. package/lib/types/client/word-timeline.d.ts +28 -0
  193. package/lib/types/client/word-timeline.d.ts.map +1 -0
  194. package/lib/types/client/word-timeline.js +100 -0
  195. package/lib/types/client/word-timeline.js.map +1 -0
  196. package/lib/types/dsh-better-display.d.ts +17 -0
  197. package/lib/types/dsh-better-display.d.ts.map +1 -0
  198. package/lib/types/dsh-better-display.js +53 -0
  199. package/lib/types/dsh-better-display.js.map +1 -0
  200. package/package.json +121 -0
  201. package/scripts/build-motion-fixture.mjs +26 -0
  202. package/scripts/link-harness-dependencies.mjs +51 -0
  203. package/skills/generative-mcpapps/SKILL.md +47 -0
  204. package/skills/generative-mcpapps/examples/interactive_quiz.html +153 -0
  205. package/skills/generative-mcpapps/references/html_boilerplate.md +122 -0
  206. package/skills/generative-mcpapps/references/sep1865_protocol.md +54 -0
  207. package/src/client/Blocks.tsx +205 -0
  208. package/src/client/ChoreographedFlow.tsx +182 -0
  209. package/src/client/ClosedProcessSummary.tsx +19 -0
  210. package/src/client/LiveFold.tsx +43 -0
  211. package/src/client/McpAppFrame.module.css +210 -0
  212. package/src/client/McpAppFrame.tsx +317 -0
  213. package/src/client/Reader.module.css +329 -0
  214. package/src/client/Reader.tsx +814 -0
  215. package/src/client/ReasoningCard.tsx +285 -0
  216. package/src/client/SettingsSection.module.css +147 -0
  217. package/src/client/SettingsSection.tsx +117 -0
  218. package/src/client/StickyLane.tsx +24 -0
  219. package/src/client/TimelineRail.module.css +170 -0
  220. package/src/client/TimelineRail.tsx +262 -0
  221. package/src/client/ToolActivity.tsx +214 -0
  222. package/src/client/TurnMetrics.module.css +139 -0
  223. package/src/client/TurnMetrics.tsx +160 -0
  224. package/src/client/WaitingStatus.tsx +22 -0
  225. package/src/client/conversation-scroll.ts +28 -0
  226. package/src/client/deliverables.ts +122 -0
  227. package/src/client/entry-policy.ts +20 -0
  228. package/src/client/entry.tsx +40 -0
  229. package/src/client/fold-choreography.ts +38 -0
  230. package/src/client/index.tsx +198 -0
  231. package/src/client/live-turn.ts +155 -0
  232. package/src/client/markdown/MarkdownText.module.css +304 -0
  233. package/src/client/markdown/MarkdownText.tsx +187 -0
  234. package/src/client/markdown/cjkFriendlyStrong.ts +84 -0
  235. package/src/client/markdown/incremental.ts +131 -0
  236. package/src/client/markdown/katex.tsx +91 -0
  237. package/src/client/markdown/mathCompatibility.ts +350 -0
  238. package/src/client/markdown/parse.ts +45 -0
  239. package/src/client/markdown/render.tsx +630 -0
  240. package/src/client/markdown/upstream.json +14 -0
  241. package/src/client/mcp-app.ts +323 -0
  242. package/src/client/message-chrome.ts +47 -0
  243. package/src/client/motion.tsx +338 -0
  244. package/src/client/native/ContextBody.module.css +161 -0
  245. package/src/client/native/ContextBody.tsx +591 -0
  246. package/src/client/native/ContextInjectionRow.module.css +62 -0
  247. package/src/client/native/ContextInjectionRow.tsx +72 -0
  248. package/src/client/native/ReferenceIcon.tsx +35 -0
  249. package/src/client/native/tool-call-model.ts +248 -0
  250. package/src/client/native/upstream.json +41 -0
  251. package/src/client/open-file.ts +94 -0
  252. package/src/client/pending-submission.ts +49 -0
  253. package/src/client/primitive-labels.ts +53 -0
  254. package/src/client/projection.ts +118 -0
  255. package/src/client/reasoning-follow.ts +12 -0
  256. package/src/client/settings-copy.ts +53 -0
  257. package/src/client/settings-slots.ts +20 -0
  258. package/src/client/settings.tsx +73 -0
  259. package/src/client/skill-status.ts +78 -0
  260. package/src/client/store.ts +30 -0
  261. package/src/client/stream-buffer.ts +100 -0
  262. package/src/client/streaming.tsx +62 -0
  263. package/src/client/style.d.ts +4 -0
  264. package/src/client/timeline.ts +77 -0
  265. package/src/client/tool-activity.ts +152 -0
  266. package/src/client/types.ts +66 -0
  267. package/src/client/waiting-clock.ts +25 -0
  268. package/src/client/word-motion.tsx +91 -0
  269. package/src/client/word-timeline.ts +96 -0
  270. package/src/dsh-better-display.ts +108 -0
  271. package/src/skill-roots.ts +138 -0
  272. package/src/skill-status.ts +81 -0
  273. package/tests/browser-closed-gap.tsx +20 -0
  274. package/tests/browser-motion.tsx +65 -0
  275. package/tests/browser-sticky-lanes.tsx +22 -0
  276. package/tests/browser-waiting-clock.tsx +14 -0
  277. package/tests/chat-flow-hook.test.ts +66 -0
  278. package/tests/cloud-gate.test.ts +34 -0
  279. package/tests/conversation-scroll.test.ts +47 -0
  280. package/tests/deliverables.test.ts +128 -0
  281. package/tests/entry.test.ts +42 -0
  282. package/tests/fixtures/maid-composer-hide.html +31 -0
  283. package/tests/fold-choreography.test.ts +38 -0
  284. package/tests/history-word-regression.test.ts +16 -0
  285. package/tests/live-turn.test.ts +218 -0
  286. package/tests/mcp-apps.test.ts +157 -0
  287. package/tests/message-chrome.test.ts +29 -0
  288. package/tests/open-file.test.ts +103 -0
  289. package/tests/pending-submission.test.ts +45 -0
  290. package/tests/projection.test.ts +184 -0
  291. package/tests/reasoning-follow.test.ts +34 -0
  292. package/tests/settings-copy.test.ts +63 -0
  293. package/tests/skill-status.test.ts +137 -0
  294. package/tests/stock-install.test.ts +50 -0
  295. package/tests/stream-buffer.test.ts +97 -0
  296. package/tests/timeline.test.ts +59 -0
  297. package/tests/waiting-clock.test.ts +24 -0
  298. package/tests/word-timeline.test.ts +136 -0
  299. package/tsconfig.json +11 -0
  300. package/tsdown.config.ts +49 -0
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: generative-mcpapps
3
+ description: "Render interactive HTML/JS widgets, product UI mockups, and clickable prototypes inside the chat (MCP Apps / SEP-1865). Use whenever the user asks to see, preview, compare, or interact with a design (mockup, prototype, 画出来看看): data visualizations, calculators, stateful forms, config wizards, or any UI richer than plain Markdown."
4
+ ---
5
+
6
+ # Generative MCP Apps
7
+
8
+ Create self-contained interactive HTML/JS widgets, UI mockups, and clickable prototypes in chat. This is an Agent Skill with YAML metadata and Markdown instructions; it has no Gemini-specific syntax or model requirement.
9
+
10
+ ## Select the delivery surface
11
+
12
+ - In DSH with Better Display enabled, emit an `mcp-app` fenced code block. The reading view renders it in an iframe with `sandbox="allow-scripts allow-forms"`.
13
+ - In another harness, use only its available, documented UI tool and actual input schema. The Better Display fence and message aliases below are not a universal MCP Apps transport.
14
+ - If no inline renderer is available, save a standalone HTML artifact and explain how to open it. Do not claim that a plain code block was rendered.
15
+
16
+ The harness must discover this skill before automatic selection can work. Keep the whole `generative-mcpapps/` folder together, including references and examples, in a skill directory supported by that harness. Merely placing it inside a plugin repository does not prove that the current agent has loaded it.
17
+
18
+ ## Build the widget
19
+
20
+ 1. Choose local interaction (tabs, filters, calculations) or an explicit submission back to chat. For Better Display, submission fills the composer; the user still sends the message. It does not execute tools or commands automatically.
21
+ 2. Inline critical CSS and JavaScript. Use semantic theme variables with readable fallbacks, a transparent body, and layouts that fit widths from 320px to 1200px. Avoid `100vh` and `min-h-screen`: Better Display measures content height and clamps it to 60–2400px.
22
+ 3. Read [HTML boilerplate and theme tokens](references/html_boilerplate.md) when assembling a card. Read [Better Display host messages](references/sep1865_protocol.md) only when using host communication. The latter documents this plugin's implementation, not a full MCP Apps specification.
23
+ 4. For a selectable local-state example, inspect [interactive_quiz.html](examples/interactive_quiz.html). Adapt its content and controls to the user's request.
24
+
25
+ ## Deliver in Better Display
26
+
27
+ Output the complete HTML document directly in the answer:
28
+
29
+ ````markdown
30
+ ```mcp-app title="交互原型"
31
+ <!DOCTYPE html>
32
+ <html lang="zh-CN">
33
+ <head>
34
+ <meta charset="UTF-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
36
+ <title>交互原型</title>
37
+ </head>
38
+ <body>
39
+ <!-- Include the widget's inline styles, content, and script here. -->
40
+ </body>
41
+ </html>
42
+ ```
43
+ ````
44
+
45
+ ## Verify the result
46
+
47
+ Check the complete document, narrow-width layout, readable theme contrast, and the requested interaction. For a submission widget, verify that the chosen data reaches the chat composer; distinguish that from a sent message or completed agent action. A local-only widget needs no host submission. Report only the rendering and interaction actually observed in the available harness.
@@ -0,0 +1,153 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>双盲方案盲选评测器</title>
7
+ <style>
8
+ :root {
9
+ --app-bg: var(--background, #ffffff);
10
+ --card-bg: var(--card, #f8fafc);
11
+ --border-color: var(--border, #e2e8f0);
12
+ --text-main: var(--foreground, #0f172a);
13
+ --text-muted: var(--muted-foreground, #64748b);
14
+ --primary-color: var(--primary, #2563eb);
15
+ --primary-text: var(--primary-foreground, #ffffff);
16
+ --hover-bg: rgba(148, 163, 184, 0.12);
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --app-bg: var(--background, #0f172a);
21
+ --card-bg: var(--card, #1e293b);
22
+ --border-color: var(--border, #334155);
23
+ --text-main: var(--foreground, #f8fafc);
24
+ --text-muted: var(--muted-foreground, #94a3b8);
25
+ }
26
+ }
27
+ * { box-sizing: border-box; margin: 0; padding: 0; }
28
+ body {
29
+ background: transparent;
30
+ color: var(--text-main);
31
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
32
+ padding: 12px;
33
+ }
34
+ .quiz-card {
35
+ background: var(--card-bg);
36
+ border: 1px solid var(--border-color);
37
+ border-radius: 12px;
38
+ padding: 16px;
39
+ max-width: 680px;
40
+ margin: 0 auto;
41
+ }
42
+ .header {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ align-items: center;
46
+ margin-bottom: 12px;
47
+ border-bottom: 1px solid var(--border-color);
48
+ padding-bottom: 8px;
49
+ }
50
+ .title { font-size: 15px; font-weight: 600; }
51
+ .badge {
52
+ font-size: 11px;
53
+ padding: 2px 8px;
54
+ border-radius: 9999px;
55
+ background: var(--hover-bg);
56
+ color: var(--text-muted);
57
+ }
58
+ .prompt {
59
+ font-size: 13px;
60
+ color: var(--text-muted);
61
+ margin-bottom: 14px;
62
+ line-height: 1.4;
63
+ }
64
+ .options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
65
+ .option-btn {
66
+ text-align: left;
67
+ background: transparent;
68
+ border: 1px solid var(--border-color);
69
+ border-radius: 8px;
70
+ padding: 10px 14px;
71
+ color: var(--text-main);
72
+ font-size: 13px;
73
+ cursor: pointer;
74
+ line-height: 1.4;
75
+ transition: all 0.15s ease;
76
+ }
77
+ .option-btn:hover { background: var(--hover-bg); }
78
+ .option-btn.selected {
79
+ border-color: var(--primary-color);
80
+ background: var(--hover-bg);
81
+ font-weight: 500;
82
+ }
83
+ .footer {
84
+ display: flex;
85
+ justify-content: space-between;
86
+ align-items: center;
87
+ margin-top: 10px;
88
+ }
89
+ .submit-btn {
90
+ background: var(--primary-color);
91
+ color: var(--primary-text);
92
+ border: none;
93
+ border-radius: 6px;
94
+ padding: 7px 16px;
95
+ font-size: 13px;
96
+ cursor: pointer;
97
+ }
98
+ .submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <div class="quiz-card">
103
+ <div class="header">
104
+ <span class="title">⚡ 架构方案盲选评测</span>
105
+ <span class="badge" id="step-indicator">第 1/2 题</span>
106
+ </div>
107
+ <p class="prompt" id="question-text">
108
+ 【场景】:订单超时自动关单,选 Redis 过期监听还是延迟消息队列?
109
+ </p>
110
+
111
+ <div class="options" id="options-container">
112
+ <button class="option-btn" onclick="selectOption('A')">A: 选 Redis 监听,轻量便捷无需额外运维</button>
113
+ <button class="option-btn" onclick="selectOption('B')">B: 坚决选延迟消息队列,Redis 监听会静默丢事件</button>
114
+ </div>
115
+
116
+ <div class="footer">
117
+ <span style="font-size: 12px; color: var(--text-muted);" id="status-text">请选择最符合你直觉的方案</span>
118
+ <button class="submit-btn" id="submit-btn" disabled onclick="submitChoice()">确认选择并提交</button>
119
+ </div>
120
+ </div>
121
+
122
+ <script>
123
+ let currentSelection = null;
124
+
125
+ function selectOption(key) {
126
+ currentSelection = key;
127
+ document.querySelectorAll('.option-btn').forEach((btn, idx) => {
128
+ btn.classList.toggle('selected', (idx === 0 && key === 'A') || (idx === 1 && key === 'B'));
129
+ });
130
+ document.getElementById('submit-btn').disabled = false;
131
+ }
132
+
133
+ function submitChoice() {
134
+ if (!currentSelection) return;
135
+
136
+ document.getElementById('status-text').innerText = "已发送到聊天输入框,请确认并发送。";
137
+ document.getElementById('submit-btn').disabled = true;
138
+
139
+ // 通过 Better Display 适配器将选择填入聊天输入框
140
+ window.parent.postMessage({
141
+ jsonrpc: "2.0",
142
+ id: "eval-" + Date.now(),
143
+ method: "ui/submit",
144
+ params: {
145
+ task: "architecture_quiz_completed",
146
+ choice: currentSelection,
147
+ desc: currentSelection === 'A' ? 'Redis 监听' : '延迟消息队列'
148
+ }
149
+ }, "*");
150
+ }
151
+ </script>
152
+ </body>
153
+ </html>
@@ -0,0 +1,122 @@
1
+ # HTML Boilerplate & Theme Tokens Reference
2
+
3
+ This document provides self-contained HTML/CSS templates optimized for MCP Apps iframes across dark and light modes.
4
+
5
+ ---
6
+
7
+ ## 1. Universal Theme Tokens Mapping
8
+
9
+ Host applications inject different CSS variables into iframes. Use this resilient CSS custom properties cascade to guarantee proper contrast regardless of the host:
10
+
11
+ ```css
12
+ :root {
13
+ /* Surface & Background */
14
+ --app-bg: var(--background, #ffffff);
15
+ --card-bg: var(--card, #f8fafc);
16
+ --border-color: var(--border, #e2e8f0);
17
+
18
+ /* Typography */
19
+ --text-main: var(--foreground, #0f172a);
20
+ --text-muted: var(--muted-foreground, #64748b);
21
+
22
+ /* Accents & States */
23
+ --primary-color: var(--primary, #2563eb);
24
+ --primary-text: var(--primary-foreground, #ffffff);
25
+ --hover-bg: rgba(148, 163, 184, 0.12);
26
+ }
27
+
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --app-bg: var(--background, #0f172a);
31
+ --card-bg: var(--card, #1e293b);
32
+ --border-color: var(--border, #334155);
33
+ --text-main: var(--foreground, #f8fafc);
34
+ --text-muted: var(--muted-foreground, #94a3b8);
35
+ }
36
+ }
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 2. Standard Responsive Card Template
42
+
43
+ ```html
44
+ <!DOCTYPE html>
45
+ <html lang="zh-CN">
46
+ <head>
47
+ <meta charset="UTF-8">
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
49
+ <style>
50
+ /* Insert Theme Tokens here */
51
+ * { box-sizing: border-box; margin: 0; padding: 0; }
52
+ body {
53
+ background: transparent;
54
+ color: var(--text-main);
55
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
56
+ padding: 12px;
57
+ }
58
+ .mcp-card {
59
+ background: var(--card-bg);
60
+ border: 1px solid var(--border-color);
61
+ border-radius: 12px;
62
+ padding: 16px;
63
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
64
+ max-width: 720px;
65
+ margin: 0 auto;
66
+ }
67
+ .mcp-header {
68
+ display: flex;
69
+ justify-content: space-between;
70
+ align-items: center;
71
+ margin-bottom: 12px;
72
+ }
73
+ .mcp-title {
74
+ font-size: 15px;
75
+ font-weight: 600;
76
+ color: var(--text-main);
77
+ }
78
+ .mcp-badge {
79
+ font-size: 11px;
80
+ padding: 2px 8px;
81
+ border-radius: 9999px;
82
+ background: var(--hover-bg);
83
+ color: var(--text-muted);
84
+ }
85
+ .btn-primary {
86
+ background: var(--primary-color);
87
+ color: var(--primary-text);
88
+ border: none;
89
+ border-radius: 6px;
90
+ padding: 8px 14px;
91
+ font-size: 13px;
92
+ font-weight: 500;
93
+ cursor: pointer;
94
+ transition: opacity 0.15s ease;
95
+ }
96
+ .btn-primary:hover { opacity: 0.9; }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <div class="mcp-card">
101
+ <div class="mcp-header">
102
+ <h3 class="mcp-title" id="widget-title">交互组件标题</h3>
103
+ <span class="mcp-badge" id="widget-badge">Interactive</span>
104
+ </div>
105
+ <div id="widget-content">
106
+ <!-- 动态正文内容 -->
107
+ </div>
108
+ </div>
109
+
110
+ <script>
111
+ function notifyHost(action, payload) {
112
+ window.parent.postMessage({
113
+ jsonrpc: "2.0",
114
+ id: "action-" + Date.now(),
115
+ method: "ui/submit",
116
+ params: { action, payload }
117
+ }, "*");
118
+ }
119
+ </script>
120
+ </body>
121
+ </html>
122
+ ```
@@ -0,0 +1,54 @@
1
+ # Better Display host messages
2
+
3
+ This documents the adapter in `src/client/McpAppFrame.tsx`, not the full SEP-1865 specification. Use these messages for Better Display's `mcp-app` iframe. Other hosts require their own documented transport and capabilities.
4
+
5
+ ## Initialization and theme
6
+
7
+ The iframe can send `ui/initialize` with a JSON-RPC request ID. Better Display responds with `result.protocolVersion` and `result.hostContext`, including `theme`, `locale`, and `styles.variables`.
8
+
9
+ For compatibility, Better Display also sends an initial `ui/initialize` notification with those theme fields in `params`. Later theme changes arrive as `ui/notifications/host-context-changed`. The plugin injects a theme and height helper into HTML documents.
10
+
11
+ When handling parent messages, verify the source:
12
+
13
+ ```javascript
14
+ window.addEventListener("message", (event) => {
15
+ if (event.source !== window.parent) return;
16
+ const msg = event.data;
17
+ if (!msg || msg.jsonrpc !== "2.0") return;
18
+ const context = msg.result?.hostContext || msg.params;
19
+ if (context?.theme) {
20
+ document.documentElement.dataset.theme = context.theme;
21
+ }
22
+ });
23
+ ```
24
+
25
+ The sandbox has an opaque origin, so messages to the parent use `"*"`. The host checks that incoming messages originate from this iframe's content window.
26
+
27
+ ## Submit a choice to the composer
28
+
29
+ ```javascript
30
+ window.parent.postMessage({
31
+ jsonrpc: "2.0",
32
+ id: "choice-" + Date.now(),
33
+ method: "ui/submit",
34
+ params: { task: "quiz_completed", choice: "B", desc: "Selected option B" }
35
+ }, "*");
36
+ ```
37
+
38
+ The host displays a receipt and fills the chat composer with a prompt derived from the parameters. The user must send it. `ui/update-model-context` is handled as the same composer action in this adapter; do not assume a silent model-context update. These submit handlers do not send a JSON-RPC success response, so do not wait indefinitely for one.
39
+
40
+ There is no `tools/call` handler in this adapter. Use submission for a requested follow-up instead of claiming that a click executed a tool.
41
+
42
+ ## Readiness and sizing
43
+
44
+ `ui/ready` and `ui/notifications/initialized` mark the iframe ready. The injected ResizeObserver normally handles height automatically. A widget may also send:
45
+
46
+ ```javascript
47
+ window.parent.postMessage({
48
+ jsonrpc: "2.0",
49
+ method: "ui/resize",
50
+ params: { height: document.body.scrollHeight }
51
+ }, "*");
52
+ ```
53
+
54
+ The host accepts positive finite heights and clamps them to 60–2400px. No `ui/updateState` handler is provided by this adapter.
@@ -0,0 +1,205 @@
1
+ import { Component, Fragment, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
4
+ import type { AssistantBlock, UserMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
5
+ import { JsonBlock, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives';
6
+ import { ComposerFillContext, McpAppFrame } from './McpAppFrame.js';
7
+ import { formatMessageClock } from './message-chrome.js';
8
+ import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
9
+ import type { BlockRenderProps, ReaderBlockOwner } from './types.js';
10
+ import { useStreamingText } from './streaming.js';
11
+ import { MotionMarkdown, MotionPlainText } from './word-motion.js';
12
+ import css from './Reader.module.css';
13
+
14
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
15
+
16
+ export class BlockBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
17
+ state = { failed: false };
18
+ static getDerivedStateFromError() { return { failed: true }; }
19
+ render() {
20
+ return this.state.failed ? <div className={css.notice}>此内容暂时无法在阅读页显示;原对话中的记录未受影响。</div> : this.props.children;
21
+ }
22
+ }
23
+
24
+ export const ImageBlock = memo(function ImageBlock({ attachment, loadImage }: {
25
+ attachment: ImageAttachmentRef; loadImage: BlockRenderProps['loadImage'];
26
+ }) {
27
+ const [attempt, setAttempt] = useState(0);
28
+ const [url, setUrl] = useState<string | null>(null);
29
+ const [error, setError] = useState(false);
30
+ const [decoded, setDecoded] = useState(false);
31
+ const dialog = useRef<HTMLDialogElement>(null);
32
+ const opener = useRef<HTMLButtonElement>(null);
33
+ useEffect(() => {
34
+ let active = true;
35
+ let owned: string | null = null;
36
+ const timeout = setTimeout(() => { if (active) { setError(true); active = false; } }, 20000);
37
+ setError(false); setDecoded(false); setUrl(null);
38
+ void loadImage(attachment).then(result => {
39
+ if (!active) return;
40
+ clearTimeout(timeout);
41
+ if (!IMAGE_TYPES.has(result.mediaType)) throw new Error('Unsupported image media type');
42
+ const bytes = Uint8Array.from(result.data);
43
+ owned = URL.createObjectURL(new Blob([bytes.buffer], { type: result.mediaType }));
44
+ setUrl(owned);
45
+ }).catch(() => { clearTimeout(timeout); if (active) setError(true); });
46
+ return () => { active = false; clearTimeout(timeout); if (owned) URL.revokeObjectURL(owned); };
47
+ }, [attachment.attachmentId, attempt, loadImage]);
48
+ const width = Number.isFinite(attachment.width) && attachment.width > 0 ? attachment.width : 4;
49
+ const height = Number.isFinite(attachment.height) && attachment.height > 0 ? attachment.height : 3;
50
+ return <figure className={css.imageFigure} data-reader-image data-image-state={error ? 'error' : decoded ? 'ready' : 'loading'}>
51
+ <div className={css.imageFrame} style={{ aspectRatio: `${width} / ${height}` }}>
52
+ {!error && url && <button ref={opener} type="button" className={css.imageOpen} aria-label={`放大图片${attachment.name ? `:${attachment.name}` : ''}`} onClick={() => dialog.current?.showModal()}>
53
+ <img src={url} alt={attachment.name ?? '会话图片'} width={width} height={height} onLoad={() => setDecoded(true)} onError={() => setError(true)} data-ready={decoded} />
54
+ </button>}
55
+ {(!decoded || error) && <div className={css.imagePlaceholder}>
56
+ <span>{error ? '图片未能加载' : '正在加载图片'}</span>
57
+ {error && <button type="button" className={css.textButton} onClick={() => setAttempt(value => value + 1)}>重试</button>}
58
+ </div>}
59
+ </div>
60
+ {attachment.name && <figcaption>{attachment.name}</figcaption>}
61
+ <dialog ref={dialog} className={css.imageDialog} aria-label="图片预览" onClose={() => opener.current?.focus()} onClick={event => { if (event.target === dialog.current) dialog.current?.close(); }}>
62
+ <button type="button" autoFocus className={css.dialogClose} aria-label="关闭图片预览" onClick={() => dialog.current?.close()}>×</button>
63
+ {url && <img src={url} alt={attachment.name ?? '会话图片'} />}
64
+ </dialog>
65
+ </figure>;
66
+ });
67
+
68
+ export function contentBlocks(content: UserMessageNode['content']): AssistantBlock[] {
69
+ return content.map(block => {
70
+ if (block.type === 'text') return { kind: 'text', text: block.text };
71
+ if (block.type === 'image') return { kind: 'image', attachment: block.attachment };
72
+ return { kind: 'other', block };
73
+ });
74
+ }
75
+
76
+ type TextPresentation = { startedAt?: number; interrupted?: boolean; liveText?: boolean };
77
+
78
+ function ReadingMarkdown({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false, kind = 'body', fileMentions }: { text: string; streaming: boolean; holdFormatting: boolean; kind?: 'body' | 'reasoning'; fileMentions?: BlockRenderProps['fileMentions'] } & TextPresentation) {
79
+ const root = useRef<HTMLDivElement>(null);
80
+ const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
81
+ // Native Markdown changes block keys for its full final parse. Keep the last
82
+ // committed mode while this answer is selected, then finish formatting on
83
+ // deselection. Business status and the source text still update normally.
84
+ const committedMode = useRef(streaming);
85
+ const effectiveMode = holdFormatting ? committedMode.current : presentation.formatStreaming;
86
+ useLayoutEffect(() => { committedMode.current = effectiveMode; }, [effectiveMode]);
87
+ return <div ref={root} className={css.readingText} data-reader-text data-reader-text-kind={kind} data-received-length={text.length} data-shown-length={presentation.text.length}
88
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
89
+ <MotionMarkdown text={presentation.text} streaming={effectiveMode} enabled={liveText && presentation.reveal && effectiveMode} revision={presentation.revision} fileMentions={fileMentions} />
90
+ </div>;
91
+ }
92
+
93
+ function ReadingReasoning({ text, streaming, holdFormatting, startedAt, interrupted = false, liveText = false }: { text: string; streaming: boolean; holdFormatting: boolean } & TextPresentation) {
94
+ const presentation = useStreamingText(text, streaming, { startedAt, interrupted: interrupted || !liveText, selected: holdFormatting });
95
+ return <div className={css.readingText} data-reader-text data-reader-text-kind="reasoning" data-received-length={text.length} data-shown-length={presentation.text.length}
96
+ data-presentation-pending={presentation.pending || undefined} data-motion-style="opacity-blur" data-ud-motion="reader-text-arrival" data-ud-motion-type="reveal" data-ud-motion-no-flash="true">
97
+ <MotionPlainText text={presentation.text} enabled={liveText && presentation.reveal} revision={presentation.revision} />
98
+ </div>;
99
+ }
100
+
101
+ function fallback(block: AssistantBlock, streaming: boolean, source: ReaderBlockOwner['source'], loadImage: BlockRenderProps['loadImage'], fillComposer: BlockRenderProps['fillComposer'], holdFormatting: boolean, presentation: TextPresentation, fileMentions?: BlockRenderProps['fileMentions']): ReactNode {
102
+ switch (block.kind) {
103
+ case 'text': return source === 'user' ? <MarkdownText text={block.text} labels={markdownLabels} /> : <ReadingMarkdown text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} fileMentions={fileMentions} />;
104
+ case 'image': return <ImageBlock attachment={block.attachment} loadImage={loadImage} />;
105
+ case 'reasoning': return <ReadingReasoning text={block.text} streaming={streaming} holdFormatting={holdFormatting} {...presentation} />;
106
+ case 'tool-call': return <JsonBlock label={`工具参数 · ${block.name}`} payload={block.argsRaw} truncatedLabel={truncatedJsonLabel} />;
107
+ case 'other': {
108
+ const raw = block.block;
109
+ if (raw && typeof raw === 'object') {
110
+ const item = raw as Record<string, unknown>;
111
+ if ((item.type === 'mcp-app' || item.type === 'mcpapp' || item.type === 'ui') && typeof item.html === 'string') {
112
+ return <McpAppFrame html={item.html} title={typeof item.title === 'string' ? item.title : undefined} fillComposer={fillComposer} />;
113
+ }
114
+ }
115
+ return <div className={css.unknown}>
116
+ <p>此内容类型尚未接入阅读页,原始内容已保留。</p>
117
+ <JsonBlock label="查看原始内容" payload={block.block} truncatedLabel={truncatedJsonLabel} />
118
+ </div>;
119
+ }
120
+ }
121
+ }
122
+
123
+ export const Blocks = memo(function Blocks({ blocks, streaming = false, source = 'assistant', holdFormatting = false, startedAt, interrupted, liveText, renderSlotChain, loadImage, fillComposer, fileMentions }: BlockRenderProps & TextPresentation & {
124
+ blocks: readonly AssistantBlock[]; streaming?: boolean; source?: ReaderBlockOwner['source']; holdFormatting?: boolean;
125
+ }) {
126
+ return <ComposerFillContext.Provider value={fillComposer}>
127
+ <div className={css.blocks} data-streaming={streaming || undefined}>
128
+ {blocks.map((block, index) => <BlockBoundary key={block.kind === 'image' ? `image:${block.attachment.attachmentId}:${index}` : `${index}:${block.kind}`}>
129
+ <Fragment>{renderSlotChain('dsh-better-display.block', { block, streaming, source }, { fallback: fallback(block, streaming, source, loadImage, fillComposer, holdFormatting, { startedAt, interrupted, liveText }, fileMentions) })}</Fragment>
130
+ </BlockBoundary>)}
131
+ </div>
132
+ </ComposerFillContext.Provider>;
133
+ });
134
+
135
+ import { TurnMetrics } from './TurnMetrics.js';
136
+
137
+ function CopyGlyph() {
138
+ return <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><rect x="5" y="5" width="8" height="8" rx="1.5" /><path d="M3 10H2.8A.8.8 0 0 1 2 9.2V2.8a.8.8 0 0 1 .8-.8h6.4a.8.8 0 0 1 .8.8V3" /></svg>;
139
+ }
140
+
141
+ function useCopyReceipt() {
142
+ const [receipt, setReceipt] = useState('');
143
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
144
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
145
+ const copy = async (text: string) => {
146
+ const accepted = await writeClipboard(text);
147
+ setReceipt(accepted ? '已复制' : '未能复制,请手动选择文字');
148
+ if (timer.current) clearTimeout(timer.current);
149
+ timer.current = setTimeout(() => setReceipt(''), 2000);
150
+ };
151
+ return { receipt, copy };
152
+ }
153
+
154
+ function MessageClock({ time }: { time: number }) {
155
+ return <time className={css.messageClock} dateTime={new Date(time).toISOString()}>{formatMessageClock(time)}</time>;
156
+ }
157
+
158
+ export function CopyAnswer({ blocks, onFork, metrics }: { blocks: readonly AssistantBlock[]; onFork?: () => void; metrics?: BlockRenderProps['metrics'] }) {
159
+ const { receipt, copy } = useCopyReceipt();
160
+ const text = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'text' }> => block.kind === 'text').map(block => block.text).join('\n\n');
161
+ const endedAt = metrics?.endedAt;
162
+ const hasMetrics = metrics !== undefined && (metrics.usage !== undefined || metrics.runMs !== undefined || endedAt !== undefined);
163
+ if (!text.trim() && !onFork && !hasMetrics) return null;
164
+ return <div className={css.answerActions}>
165
+ {text.trim() !== '' && (
166
+ <button type="button" className={css.iconButton} aria-label="复制回答" title="复制回答" onClick={() => { void copy(text); }}>
167
+ <CopyGlyph />
168
+ </button>
169
+ )}
170
+ {onFork && (
171
+ <button
172
+ type="button"
173
+ className={css.iconButton}
174
+ aria-label="以此处为基础创建分叉会话"
175
+ title="以此处为基础创建分叉会话 (Fork)"
176
+ onClick={onFork}
177
+ >
178
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.2">
179
+ <circle cx="4.5" cy="3.5" r="1.8" />
180
+ <circle cx="4.5" cy="12.5" r="1.8" />
181
+ <circle cx="11.5" cy="5.5" r="1.8" />
182
+ <path d="M4.5 5.5v5M4.5 8c2.5 0 4.5-1 7-2.5" strokeLinecap="round" />
183
+ </svg>
184
+ </button>
185
+ )}
186
+ {metrics && <TurnMetrics usage={metrics.usage} runMs={metrics.runMs} tokensPerSecond={metrics.tokensPerSecond} ttftMs={metrics.ttftMs} />}
187
+ {endedAt !== undefined && <MessageClock time={endedAt} />}
188
+ <span role="status" className={css.meta}>{receipt}</span>
189
+ </div>;
190
+ }
191
+
192
+ export function UserMessageActions({ text, time }: { text: string; time?: number }) {
193
+ const { receipt, copy } = useCopyReceipt();
194
+ const hasText = text.trim() !== '';
195
+ if (!hasText && time === undefined) return null;
196
+ return <div className={css.userActions}>
197
+ {time !== undefined && <MessageClock time={time} />}
198
+ {hasText && (
199
+ <button type="button" className={css.iconButton} aria-label="复制消息" title="复制消息" onClick={() => { void copy(text); }}>
200
+ <CopyGlyph />
201
+ </button>
202
+ )}
203
+ <span role="status" className={css.meta}>{receipt}</span>
204
+ </div>;
205
+ }