@changfenhuang/dsh-genui 0.9.3 → 0.9.6

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.
@@ -0,0 +1,7 @@
1
+ export interface TemplateDrawerProps {
2
+ /** 试用:把模板指令插入当前输入框草稿。 */
3
+ onUse: (instruction: string) => void;
4
+ /** 当前面板(模板中心/成就页),由面板 header 按钮控制。 */
5
+ tab: 'templates' | 'achievements';
6
+ }
7
+ export declare function TemplateDrawer({ onUse, tab }: TemplateDrawerProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * achievement-store.ts — 成就状态存储与埋点入口(模块级单例)。
3
+ *
4
+ * 只存计数与解锁时间戳;绝不含消息/内容。localStorage 持久化,
5
+ * 订阅接口供 toast 与成就页消费。埋点入口:
6
+ * - recordFence(spec):一次「内容不重复」的界面渲染(按 spec 指纹去重,
7
+ * LRU 指纹表跨刷新防重放重复计数)
8
+ * - recordPanel():面板 dock 出现
9
+ * - recordInteraction():组件动作回传(去抖后)
10
+ * - recordTemplateUse():模板中心试用
11
+ */
12
+ import type { GenuiSpec } from './spec.ts';
13
+ import { type AchieveState, type AchievementDef } from './achievements.ts';
14
+ /** 记录一次「内容不重复」的 fence 渲染。 */
15
+ export declare function recordFence(spec: GenuiSpec): void;
16
+ /** 记录一次面板 dock 出现(仅会话从无到有时)。 */
17
+ export declare function recordPanel(): void;
18
+ /** 记录一次组件交互动作回传。 */
19
+ export declare function recordInteraction(): void;
20
+ /** 记录一次模板试用。 */
21
+ export declare function recordTemplateUse(): void;
22
+ /** 当前状态快照(订阅者读取)。 */
23
+ export declare function getAchievementSnapshot(): {
24
+ state: AchieveState;
25
+ unlocked: Record<string, number>;
26
+ };
27
+ /** 取走新解锁(toast 消费;一次性)。 */
28
+ export declare function consumeUnlocks(): AchievementDef[];
29
+ /** 订阅状态变化(toast 与成就页)。 */
30
+ export declare function subscribeAchievements(listener: () => void): () => void;
31
+ /** 成就页 spec 数据源(面板内渲染用)。 */
32
+ export declare function achievementCount(): {
33
+ total: number;
34
+ unlocked: number;
35
+ };
@@ -0,0 +1,2 @@
1
+ /** 挂载 toast 栈(apply 调用;返回卸载函数)。 */
2
+ export declare function mountAchievementToasts(): () => void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * achievements.ts — GenUI 探索成就(0.9.5)。
3
+ *
4
+ * 轻量本地成就:只统计「使用事件计数」(渲染过的 fence、面板出现、
5
+ * 交互回传、模板试用),绝不读消息/内容;状态存 localStorage,跨会话
6
+ * 保留。解锁时进 toast 队列(achievement-toast 消费),面板「成就」
7
+ * tab 用 dsh-ui 渲染自己的成就页(dogfooding)。
8
+ *
9
+ * 设计与 dsh-achievements 的分层快照思路一致,但零宿主改动:埋点全部
10
+ * 在本包的渲染/交互路径上(GenuiBlock/TemplateDrawer/GenuiPanel)。
11
+ */
12
+ import type { GenuiSpec } from './spec.ts';
13
+ /** 累积使用计数(成就的输入)。 */
14
+ export interface AchieveState {
15
+ /** 渲染过的不重复 fence 数。 */
16
+ fences: number;
17
+ /** 面板(panel dock)出现过的会话数。 */
18
+ panels: number;
19
+ /** 交互组件动作回传次数(去抖后)。 */
20
+ interactions: number;
21
+ /** 模板试用次数。 */
22
+ templates: number;
23
+ /** 图表节点(chart/plot/echart)出现过的 fence 数。 */
24
+ charts: number;
25
+ /** 高级节点(scene3d/mermaid/diagram)出现过的 fence 数。 */
26
+ advanced: number;
27
+ }
28
+ export declare function emptyState(): AchieveState;
29
+ export interface AchievementDef {
30
+ id: string;
31
+ name: string;
32
+ description: string;
33
+ /** 解锁前隐藏名称/描述(彩蛋)。 */
34
+ hidden?: boolean;
35
+ /** 稀有度。 */
36
+ rarity: 'common' | 'rare' | 'legendary';
37
+ check: (s: AchieveState) => boolean;
38
+ }
39
+ export declare const ACHIEVEMENTS: readonly AchievementDef[];
40
+ /** 统计一个 spec 里出现的相关节点类别(与 guard 同口径遍历)。 */
41
+ export declare function countSpecKinds(spec: GenuiSpec): {
42
+ charts: number;
43
+ advanced: number;
44
+ };
45
+ /** 生成成就页 spec(dsh-ui 渲染):进度 stat + 解锁列表 + 稀有度徽标。 */
46
+ export declare function buildAchievementsSpec(state: AchieveState, unlocked: Record<string, number>): GenuiSpec;
47
+ /** 检查给定状态下的新解锁(不受 hidden 限制——规则只管阈值)。 */
48
+ export declare function checkAchievements(state: AchieveState, unlocked: Record<string, number>): AchievementDef[];
@@ -4,6 +4,9 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
4
4
  export interface GenuiPanelInjected {
5
5
  sessionId: string;
6
6
  sendGenuiAction: GenuiActionHandler;
7
+ /** Template center "try it": insert the template instruction into the
8
+ * current composer draft (standard conversation.input.for channel). */
9
+ insertTemplate: (text: string) => void;
7
10
  }
8
11
  export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected;
9
12
  /**
@@ -12,4 +15,4 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
12
15
  * default so the dock never steals the message flow's scroll room; the
13
16
  * header always shows the current panel title.
14
17
  */
15
- export declare function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps): import("react").JSX.Element | null;
18
+ export declare function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps): import("react").JSX.Element | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * templates.ts — GenUI 模板中心数据(0.9.4)。
3
+ *
4
+ * 每个模板 = 一段自然语言 `instruction`(试用时插入输入框,模型按 genui
5
+ * skill 生成对应界面)+ 一份**合法**的 `demo` spec(预览时由 GenuiBlock
6
+ * 直接渲染,用户先看到效果再决定试用)。字段对齐 spec.ts / guard.ts 的
7
+ * 实际 schema(tests/templates.spec.ts 用 validateGenuiSpec 逐条校验)。
8
+ *
9
+ * 模板即说明书:覆盖布局/数据/图表/交互/测验/高级各能力面。
10
+ */
11
+ import type { GenuiSpec } from './spec.ts';
12
+ export interface GenuiTemplate {
13
+ id: string;
14
+ category: '仪表盘' | '数据' | '流程' | '图表' | '交互' | '测验' | '高级';
15
+ name: string;
16
+ description: string;
17
+ /** 试用时插入输入框的自然语言指令。 */
18
+ instruction: string;
19
+ /** 预览用的合法 spec(≤200 节点、嵌套 ≤8 层)。 */
20
+ demo: GenuiSpec;
21
+ }
22
+ export declare const GENUI_TEMPLATES: readonly GenuiTemplate[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@changfenhuang/dsh-genui",
3
3
  "description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
4
- "version": "0.9.3",
4
+ "version": "0.9.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/plugin/index.d.ts",
@@ -16,8 +16,13 @@
16
16
  literals here are only last-resort fallbacks for hosts without the token
17
17
  sheets. */
18
18
 
19
- .block {
20
- /* ── design tokens (design system v2) ── */
19
+ .block,
20
+ .panel {
21
+ /* ── design tokens (design system v2) ──
22
+ Declared on both the inline fence root and the session panel root: the
23
+ panel header sits OUTSIDE the inner .block, so without the shared
24
+ declaration its --dsl-g-* references resolved to undefined (the badge
25
+ gap/padding collapsed to zero). */
21
26
  --dsl-g-radius-surface: 12px;
22
27
  --dsl-g-radius-control: 8px;
23
28
  --dsl-g-radius-pill: 999px;
@@ -46,7 +51,9 @@
46
51
  --dsl-g-tint: 8%;
47
52
  --dsl-g-tint-strong: 14%;
48
53
  --dsl-g-font-mono: var(--ds-font-family-code, ui-monospace, 'SF Mono', monospace);
54
+ }
49
55
 
56
+ .block {
50
57
  /* Inline in the conversation: no canvas, no border, no padding — the
51
58
  components themselves (card/quiz/plot/…) carry the surfaces. A slab
52
59
  around every fence was tried and rejected: a lone stat or text node
@@ -956,8 +963,11 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
956
963
  }
957
964
 
958
965
  /* "✓ 已触发" confirmation chip: appears next to the button without layout
959
- shift risk (it inherits the flex row's gap). */
960
- .btnSent { animation: genuiPopIn 0.2s ease both; }
966
+ shift risk (it inherits the flex row's gap). `backwards`, not `both`: the
967
+ chip's natural (post-animation) style is already visible/no-transform, so
968
+ pinning the end keyframe would only keep a translateY(0) transform layer
969
+ alive forever — the exact mechanism behind issues #55/#69. */
970
+ .btnSent { animation: genuiPopIn 0.2s ease backwards; }
961
971
 
962
972
  /* 尊重用户的减弱动效偏好 */
963
973
  @media (prefers-reduced-motion: reduce) {
@@ -1149,9 +1159,16 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1149
1159
  .panel {
1150
1160
  /* Distinct from ordinary inline fences: accent border + badge header so a
1151
1161
  session panel is recognizable at a glance. */
1152
- margin: 10px 14px 2px;
1162
+ /* Align with the host's composer width axis (same geometry as the TodoDock
1163
+ cards it shares conversation.input.dock with): centered, inset by the
1164
+ side clearance and the dock inset chain, capped at the composer card
1165
+ width. Falls back to safe literals when the host does not define the
1166
+ --dsh-composer-* tokens. */
1167
+ margin: 10px auto 2px;
1168
+ width: calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4);
1169
+ max-width: calc(var(--dsh-composer-card-max-width, 780px) - var(--dsh-composer-dock-inset, 8px) * 4);
1153
1170
  border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
1154
- border-radius: 14px;
1171
+ border-radius: 12px;
1155
1172
  background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.04));
1156
1173
  overflow: hidden;
1157
1174
  }
@@ -1159,12 +1176,18 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1159
1176
  .panelHeader {
1160
1177
  display: flex;
1161
1178
  align-items: center;
1179
+ /* Header/body divider so the panel reads as a card with a title bar, not a
1180
+ slab with text glued to it (user report: "body 缺少内边框"). */
1181
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, 0.25));
1162
1182
  }
1163
1183
 
1164
1184
  .panelToggle {
1165
1185
  display: flex;
1166
1186
  align-items: center;
1167
- gap: var(--dsl-g-gap-sm);
1187
+ /* 10px matches the TodoDock header rhythm (gap: 10px) — the badge and the
1188
+ title no longer collapse together once the shared --dsl-g-gap-sm token
1189
+ reaches this element. */
1190
+ gap: 10px;
1168
1191
  flex: 1 1 auto;
1169
1192
  min-width: 0;
1170
1193
  padding: 9px 14px;
@@ -1211,6 +1234,112 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1211
1234
  opacity: 1;
1212
1235
  }
1213
1236
 
1237
+ /* ---- 0.9.4 template center & onboarding ---- */
1238
+ .panelTpl {
1239
+ flex: 0 0 auto;
1240
+ height: 26px;
1241
+ margin-right: 4px;
1242
+ padding: 0 10px;
1243
+ display: flex;
1244
+ align-items: center;
1245
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1246
+ border-radius: 8px;
1247
+ background: transparent;
1248
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1249
+ font-size: 12px;
1250
+ line-height: 1.5;
1251
+ cursor: pointer;
1252
+ opacity: 0.85;
1253
+ }
1254
+
1255
+ .panelTpl:hover {
1256
+ background: rgba(255, 255, 255, 0.08);
1257
+ opacity: 1;
1258
+ }
1259
+
1260
+ .panelTplActive {
1261
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
1262
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1263
+ }
1264
+
1265
+ .panelOnboard .panelHeader {
1266
+ cursor: default;
1267
+ }
1268
+
1269
+ .panelOnboardText {
1270
+ flex: 1;
1271
+ min-width: 0;
1272
+ padding: 0 8px;
1273
+ font-size: 12px;
1274
+ line-height: 1.5;
1275
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1276
+ }
1277
+
1278
+ .panelFlash {
1279
+ padding: 6px 12px;
1280
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1281
+ font-size: 12px;
1282
+ line-height: 1.5;
1283
+ color: var(--dsw-alias-state-success-primary, #6ccb5f);
1284
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #6ccb5f) 8%, transparent);
1285
+ }
1286
+
1287
+ .panelHint {
1288
+ padding: 6px 14px;
1289
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1290
+ font-size: 12px;
1291
+ line-height: 1.5;
1292
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1293
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 8%, transparent);
1294
+ }
1295
+
1296
+ /* ---- 0.9.5 achievement toasts ---- */
1297
+ .achToasts {
1298
+ position: fixed;
1299
+ right: 16px;
1300
+ bottom: 16px;
1301
+ display: flex;
1302
+ flex-direction: column;
1303
+ gap: 8px;
1304
+ z-index: 9999;
1305
+ pointer-events: none;
1306
+ }
1307
+
1308
+ .achToast {
1309
+ display: flex;
1310
+ align-items: flex-start;
1311
+ gap: 10px;
1312
+ width: 300px;
1313
+ padding: 10px 12px;
1314
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
1315
+ border-radius: var(--dsl-g-radius-surface, 12px);
1316
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.85));
1317
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
1318
+ }
1319
+
1320
+ .achToastBadge {
1321
+ flex: none;
1322
+ font-size: 18px;
1323
+ line-height: 1.2;
1324
+ }
1325
+
1326
+ .achToastBody {
1327
+ min-width: 0;
1328
+ }
1329
+
1330
+ .achToastName {
1331
+ font-size: 13px;
1332
+ font-weight: 600;
1333
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1334
+ }
1335
+
1336
+ .achToastDesc {
1337
+ margin-top: 2px;
1338
+ font-size: 12px;
1339
+ line-height: 1.5;
1340
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1341
+ }
1342
+
1214
1343
  .panelBadge {
1215
1344
  flex: none;
1216
1345
  font-size: 11px;
@@ -1235,9 +1364,13 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1235
1364
  }
1236
1365
 
1237
1366
  .panelChevron {
1367
+ /* Same glyph cell + color as the TodoDock header chevron (grid-centered,
1368
+ label-tertiary), so the arrow reads as a host control, not typed text. */
1369
+ display: grid;
1370
+ place-items: center;
1371
+ flex: none;
1238
1372
  margin-left: auto;
1239
- color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.55));
1240
- font-size: var(--dsl-g-font-meta);
1373
+ color: var(--dsw-alias-label-tertiary);
1241
1374
  }
1242
1375
 
1243
1376
  .panelBody {
@@ -10,6 +10,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
10
10
  import { useGenuiAction } from './action-context.ts'
11
11
  import css from './GenuiBlock.module.css'
12
12
  import { loadBlockState, saveBlockState } from './interaction-store.ts'
13
+ import { recordFence, recordInteraction } from './achievement-store.ts'
13
14
  import { renderNode } from './blocks/render-node.tsx'
14
15
  import type { AnswersState, GenuiBlockProps, QuestionMeta } from './blocks/state.ts'
15
16
  import type { GenuiSpec } from './spec.ts'
@@ -127,6 +128,15 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
127
128
  }),
128
129
  [answers, fields, secretFields, meta, locked, round, setAnswer, setField, registerSecretField, registerMeta, clear],
129
130
  )
131
+ // Achievement telemetry: every emitted action counts as one interaction
132
+ // (the debounced emit fires once per real user action).
133
+ const trackedAction = useMemo(() => {
134
+ if (onAction === undefined) return undefined
135
+ return (action: string, payload: Record<string, unknown>): void => {
136
+ recordInteraction()
137
+ onAction(action, payload)
138
+ }
139
+ }, [onAction])
130
140
  // Durable save (debounced 300ms — typing in a field fires per keystroke).
131
141
  // Secret field values are stripped before writing: passwords never persist.
132
142
  useEffect(() => {
@@ -143,6 +153,11 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
143
153
  }, 300)
144
154
  return () => clearTimeout(timer)
145
155
  }, [stateKey, answers, locked, fields, secretFields])
156
+ // Achievement telemetry (0.9.5): the store dedupes by spec fingerprint, so
157
+ // streaming re-renders and replays count once per distinct content.
158
+ useEffect(() => {
159
+ recordFence(spec)
160
+ }, [spec])
146
161
  return (
147
162
  <div className={css.block} data-genui>
148
163
  {spec.title !== undefined && <div className={css.banner}>{spec.title}</div>}
@@ -157,7 +172,7 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
157
172
  className={css.reveal}
158
173
  style={{ animationDelay: `${Math.min(i * 90, 720)}ms` }}
159
174
  >
160
- {renderNode(c, i, onAction, 0, answersState)}
175
+ {renderNode(c, i, trackedAction, 0, answersState)}
161
176
  </div>
162
177
  ))}
163
178
  </div>
@@ -0,0 +1,152 @@
1
+ /* TemplateDrawer: the GenUI template center inside the session panel body.
2
+ Tokens mirror GenuiBlock.module.css (--dsw-alias-* sheets, fallbacks last). */
3
+
4
+ .wrap {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 12px;
8
+ padding: 12px 14px;
9
+ min-height: 0;
10
+ overflow: auto;
11
+ }
12
+
13
+ /* ---- achievements tab ---- */
14
+ .achievements {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 8px;
18
+ min-height: 0;
19
+ overflow: auto;
20
+ }
21
+
22
+ /* ---- category chips ---- */
23
+ .cats {
24
+ display: flex;
25
+ flex-wrap: wrap;
26
+ gap: 8px;
27
+ }
28
+
29
+ .cat {
30
+ padding: 4px 12px;
31
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
32
+ border-radius: 999px;
33
+ background: transparent;
34
+ color: var(--dsw-alias-label-secondary, var(--dsl-g-font-muted-fallback, rgba(255, 255, 255, 0.6)));
35
+ font-size: 12px;
36
+ line-height: 1.5;
37
+ cursor: pointer;
38
+ }
39
+
40
+ .cat:hover {
41
+ background: var(--dsw-alias-fill-hover, rgba(255, 255, 255, 0.06));
42
+ }
43
+
44
+ .catActive {
45
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
46
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
47
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
48
+ }
49
+
50
+ /* ---- template cards ---- */
51
+ .list {
52
+ display: grid;
53
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
54
+ gap: 8px;
55
+ }
56
+
57
+ .card {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 4px;
61
+ padding: 10px 12px;
62
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
63
+ border-radius: var(--dsl-g-radius-surface, 12px);
64
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.04));
65
+ color: var(--dsw-alias-label-primary);
66
+ text-align: left;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .card:hover {
71
+ border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.2));
72
+ }
73
+
74
+ .cardActive {
75
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
76
+ }
77
+
78
+ .cardName {
79
+ font-size: 13px;
80
+ font-weight: 600;
81
+ line-height: 1.5;
82
+ }
83
+
84
+ .cardMeta {
85
+ font-size: 11px;
86
+ color: var(--dsw-alias-label-tertiary, rgba(255, 255, 255, 0.45));
87
+ }
88
+
89
+ .cardDesc {
90
+ font-size: 12px;
91
+ line-height: 1.5;
92
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
93
+ }
94
+
95
+ /* ---- detail preview ---- */
96
+ .detail {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 8px;
100
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
101
+ padding-top: 12px;
102
+ }
103
+
104
+ .toolbar {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 8px;
108
+ flex-wrap: wrap;
109
+ }
110
+
111
+ .toolbarTitle {
112
+ flex: 1;
113
+ min-width: 0;
114
+ font-size: 13px;
115
+ font-weight: 600;
116
+ color: var(--dsw-alias-label-primary);
117
+ }
118
+
119
+ .try,
120
+ .copy {
121
+ padding: 5px 12px;
122
+ border-radius: var(--dsl-g-radius-control, 8px);
123
+ border: 1px solid transparent;
124
+ font-size: 12px;
125
+ line-height: 1.5;
126
+ cursor: pointer;
127
+ }
128
+
129
+ .try {
130
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
131
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
132
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
133
+ }
134
+
135
+ .try:hover {
136
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 22%, transparent);
137
+ }
138
+
139
+ .copy {
140
+ border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
141
+ background: transparent;
142
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
143
+ }
144
+
145
+ .preview {
146
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
147
+ border-radius: var(--dsl-g-radius-surface, 12px);
148
+ padding: 12px;
149
+ min-height: 0;
150
+ overflow: auto;
151
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.08));
152
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * TemplateDrawer — GenUI 模板中心(0.9.4 新手引导)。
3
+ *
4
+ * 分类浏览 → 点击卡片 → 内嵌预览(demo spec 由 GenuiBlock 直接渲染,
5
+ * dogfooding)+「试用」插入输入框 /「复制指令」。用户先看到效果,
6
+ * 再决定把指令发给模型——genui 能力说明书。
7
+ */
8
+ import { useMemo, useRef, useState } from 'react'
9
+ import { GENUI_TEMPLATES, type GenuiTemplate } from './templates.ts'
10
+ import { GenuiBlock } from './GenuiBlock.tsx'
11
+ import { ErrorBoundary } from './ErrorBoundary.tsx'
12
+ import { panelStateKey } from './interaction-store.ts'
13
+ import { buildAchievementsSpec } from './achievements.ts'
14
+ import { getAchievementSnapshot, subscribeAchievements } from './achievement-store.ts'
15
+ import { useSyncExternalStore } from 'react'
16
+ import css from './TemplateDrawer.module.css'
17
+
18
+ export interface TemplateDrawerProps {
19
+ /** 试用:把模板指令插入当前输入框草稿。 */
20
+ onUse: (instruction: string) => void
21
+ /** 当前面板(模板中心/成就页),由面板 header 按钮控制。 */
22
+ tab: 'templates' | 'achievements'
23
+ }
24
+
25
+ const CATEGORIES = ['全部', '仪表盘', '数据', '流程', '图表', '交互', '测验', '高级'] as const
26
+ type Category = (typeof CATEGORIES)[number]
27
+
28
+ /** Copy to clipboard with a legacy fallback (like GenuiCopy). */
29
+ function copyText(text: string): Promise<void> {
30
+ if (navigator.clipboard !== undefined) return navigator.clipboard.writeText(text)
31
+ return new Promise((resolve, reject) => {
32
+ try {
33
+ const ta = document.createElement('textarea')
34
+ ta.value = text
35
+ ta.style.position = 'fixed'
36
+ ta.style.opacity = '0'
37
+ document.body.appendChild(ta)
38
+ ta.select()
39
+ document.execCommand('copy')
40
+ ta.remove()
41
+ resolve()
42
+ } catch (error) {
43
+ reject(error instanceof Error ? error : new Error(String(error)))
44
+ }
45
+ })
46
+ }
47
+
48
+ export function TemplateDrawer({ onUse, tab }: TemplateDrawerProps) {
49
+ const [category, setCategory] = useState<Category>('全部')
50
+ const [selected, setSelected] = useState<GenuiTemplate | null>(null)
51
+ const [copied, setCopied] = useState(false)
52
+ const timer = useRef(0)
53
+ // Achievements tab: live state snapshot.
54
+ const achievements = useSyncExternalStore(subscribeAchievements, () => getAchievementSnapshot())
55
+ const achievementSpec = useMemo(
56
+ () => buildAchievementsSpec(achievements.state, achievements.unlocked),
57
+ [achievements],
58
+ )
59
+
60
+ const items = useMemo(
61
+ () => (category === '全部' ? [...GENUI_TEMPLATES] : GENUI_TEMPLATES.filter(t => t.category === category)),
62
+ [category],
63
+ )
64
+
65
+ const copy = async (text: string): Promise<void> => {
66
+ try {
67
+ await copyText(text)
68
+ setCopied(true)
69
+ window.clearTimeout(timer.current)
70
+ timer.current = window.setTimeout(() => setCopied(false), 1500)
71
+ } catch {
72
+ // Clipboard denied: the instruction stays in the drawer; nothing else
73
+ // to do — the use flow does not depend on it.
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className={css.wrap} data-genui-templates={tab === 'templates' ? undefined : 'achievements'}>
79
+ {tab === 'achievements' ? (
80
+ <div className={css.achievements} data-genui-achievements>
81
+ <ErrorBoundary label="成就页">
82
+ <GenuiBlock spec={achievementSpec} stateKey={panelStateKey('genui-achievements', JSON.stringify(achievementSpec))} />
83
+ </ErrorBoundary>
84
+ </div>
85
+ ) : (
86
+ <>
87
+ <div className={css.cats} role="tablist" aria-label="模板分类">
88
+ {CATEGORIES.map(c => (
89
+ <button
90
+ key={c}
91
+ type="button"
92
+ role="tab"
93
+ aria-selected={category === c}
94
+ className={`${css.cat}${category === c ? ` ${css.catActive}` : ''}`}
95
+ onClick={() => { setCategory(c); setSelected(null) }}
96
+ >
97
+ {c}
98
+ </button>
99
+ ))}
100
+ </div>
101
+ <div className={css.list}>
102
+ {items.map(tpl => (
103
+ <button
104
+ key={tpl.id}
105
+ type="button"
106
+ className={`${css.card}${selected?.id === tpl.id ? ` ${css.cardActive}` : ''}`}
107
+ onClick={() => setSelected(prev => (prev?.id === tpl.id ? null : tpl))}
108
+ >
109
+ <span className={css.cardName}>{tpl.name}</span>
110
+ <span className={css.cardMeta}>{tpl.category}</span>
111
+ <span className={css.cardDesc}>{tpl.description}</span>
112
+ </button>
113
+ ))}
114
+ </div>
115
+ {selected !== null && (
116
+ <div className={css.detail} data-genui-template-preview>
117
+ <div className={css.toolbar}>
118
+ <span className={css.toolbarTitle}>{selected.name}</span>
119
+ <button type="button" className={css.try} onClick={() => onUse(selected.instruction)}>
120
+ 试用:插入输入框
121
+ </button>
122
+ <button type="button" className={css.copy} onClick={() => void copy(selected.instruction)}>
123
+ {copied ? '✓ 已复制' : '复制指令'}
124
+ </button>
125
+ </div>
126
+ <div className={css.preview}>
127
+ <ErrorBoundary label="模板预览">
128
+ <GenuiBlock spec={selected.demo} stateKey={panelStateKey('genui-tpl', selected.id)} />
129
+ </ErrorBoundary>
130
+ </div>
131
+ </div>
132
+ )}
133
+ </>
134
+ )}
135
+ </div>
136
+ )
137
+ }