@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,151 @@
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 { ACHIEVEMENTS, checkAchievements, countSpecKinds, emptyState, type AchieveState, type AchievementDef } from './achievements.ts'
14
+
15
+ const STORE_KEY = 'dsh.genui.achievements'
16
+ const SEEN_KEY = 'dsh.genui.achievements.seen'
17
+ const SEEN_MAX = 200
18
+
19
+ interface AchieveStore {
20
+ state: AchieveState
21
+ unlocked: Record<string, number>
22
+ }
23
+
24
+ let store: AchieveStore = loadStore()
25
+ const listeners = new Set<() => void>()
26
+ /** 解锁 toast 队列(FIFO;成就页打开时也读)。 */
27
+ let toastQueue: AchievementDef[] = []
28
+
29
+ function loadStore(): AchieveStore {
30
+ try {
31
+ const raw = localStorage.getItem(STORE_KEY)
32
+ if (raw === null) return { state: emptyState(), unlocked: {} }
33
+ const parsed = JSON.parse(raw) as Partial<AchieveStore>
34
+ const state = { ...emptyState(), ...(parsed.state ?? {}) }
35
+ return { state, unlocked: typeof parsed.unlocked === 'object' && parsed.unlocked !== null ? parsed.unlocked as Record<string, number> : {} }
36
+ } catch {
37
+ return { state: emptyState(), unlocked: {} }
38
+ }
39
+ }
40
+
41
+ function saveStore(): void {
42
+ try {
43
+ localStorage.setItem(STORE_KEY, JSON.stringify(store))
44
+ } catch {
45
+ // Quota / privacy-mode failures are non-fatal: achievements still tick
46
+ // in memory for this page session.
47
+ }
48
+ }
49
+
50
+ function readSeen(): string[] {
51
+ try {
52
+ const raw = localStorage.getItem(SEEN_KEY)
53
+ if (raw === null) return []
54
+ const parsed = JSON.parse(raw) as unknown
55
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ function writeSeen(list: string[]): void {
62
+ try {
63
+ localStorage.setItem(SEEN_KEY, JSON.stringify(list.slice(0, SEEN_MAX)))
64
+ } catch {
65
+ // non-fatal
66
+ }
67
+ }
68
+
69
+ function emit(): void {
70
+ for (const listener of listeners) listener()
71
+ }
72
+
73
+ /** 应用一次状态更新:持久化 + 检查新解锁(进 toast 队列)+ 通知订阅者。 */
74
+ function applyDelta(patch: Partial<AchieveState>): void {
75
+ store.state = { ...store.state, ...patch }
76
+ const fresh = checkAchievements(store.state, store.unlocked)
77
+ for (const ach of fresh) store.unlocked[ach.id] = Date.now()
78
+ if (fresh.length > 0) toastQueue.push(...fresh)
79
+ saveStore()
80
+ emit()
81
+ }
82
+
83
+ /** 53-bit string hash (cyrb53-style): dedupe fingerprints are stored as
84
+ * hashes, never as the serialized spec itself — localStorage must not
85
+ * persist conversation-derived content (table rows, text, …). */
86
+ function fingerprintOf(spec: GenuiSpec): string {
87
+ const s = JSON.stringify(spec)
88
+ let h1 = 0xdeadbeef
89
+ let h2 = 0x41c6ce57
90
+ for (let i = 0; i < s.length; i++) {
91
+ const ch = s.charCodeAt(i)
92
+ h1 = Math.imul(h1 ^ ch, 2654435761)
93
+ h2 = Math.imul(h2 ^ ch, 1597334677)
94
+ }
95
+ return `${(h1 >>> 0).toString(36)}-${(h2 >>> 0).toString(36)}`
96
+ }
97
+
98
+ /** 记录一次「内容不重复」的 fence 渲染。 */
99
+ export function recordFence(spec: GenuiSpec): void {
100
+ const fingerprint = fingerprintOf(spec)
101
+ const seen = readSeen()
102
+ if (seen.includes(fingerprint)) return
103
+ writeSeen([fingerprint, ...seen])
104
+ const kinds = countSpecKinds(spec)
105
+ applyDelta({
106
+ fences: store.state.fences + 1,
107
+ charts: store.state.charts + (kinds.charts > 0 ? 1 : 0),
108
+ advanced: store.state.advanced + (kinds.advanced > 0 ? 1 : 0),
109
+ })
110
+ }
111
+
112
+ /** 记录一次面板 dock 出现(仅会话从无到有时)。 */
113
+ export function recordPanel(): void {
114
+ applyDelta({ panels: store.state.panels + 1 })
115
+ }
116
+
117
+ /** 记录一次组件交互动作回传。 */
118
+ export function recordInteraction(): void {
119
+ applyDelta({ interactions: store.state.interactions + 1 })
120
+ }
121
+
122
+ /** 记录一次模板试用。 */
123
+ export function recordTemplateUse(): void {
124
+ applyDelta({ templates: store.state.templates + 1 })
125
+ }
126
+
127
+ /** 当前状态快照(订阅者读取)。 */
128
+ export function getAchievementSnapshot(): { state: AchieveState, unlocked: Record<string, number> } {
129
+ return store
130
+ }
131
+
132
+ /** 取走新解锁(toast 消费;一次性)。 */
133
+ export function consumeUnlocks(): AchievementDef[] {
134
+ const queue = toastQueue
135
+ toastQueue = []
136
+ return queue
137
+ }
138
+
139
+ /** 订阅状态变化(toast 与成就页)。 */
140
+ export function subscribeAchievements(listener: () => void): () => void {
141
+ listeners.add(listener)
142
+ return () => listeners.delete(listener)
143
+ }
144
+
145
+ /** 成就页 spec 数据源(面板内渲染用)。 */
146
+ export function achievementCount(): { total: number, unlocked: number } {
147
+ return {
148
+ total: ACHIEVEMENTS.length,
149
+ unlocked: ACHIEVEMENTS.filter(a => store.unlocked[a.id] !== undefined).length,
150
+ }
151
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * achievement-toast.tsx — 解锁 toast 栈(右下角,3.6s 自动消退)。
3
+ *
4
+ * 独立 React root(document.body 级):面板/inline fence 任一解锁都会
5
+ * 弹——不依赖面板存在。消费 achievement-store 的新解锁队列。
6
+ */
7
+ import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react'
8
+ import { createRoot } from 'react-dom/client'
9
+ import { consumeUnlocks, subscribeAchievements } from './achievement-store.ts'
10
+ import type { AchievementDef } from './achievements.ts'
11
+ import css from './GenuiBlock.module.css'
12
+
13
+ interface ToastItem {
14
+ key: number
15
+ ach: AchievementDef
16
+ }
17
+
18
+ let toastKey = 0
19
+
20
+ /** 底部固定栈:显示队列中的最后一个(FIFO 展开为纵向栈)。 */
21
+ function AchievementToasts() {
22
+ const [, force] = useSyncExternalStore(subscribeAchievements, () => '')
23
+ const [items, setItems] = useState<ToastItem[]>([])
24
+ const timers = useRef<Map<number, number>>(new Map())
25
+
26
+ useEffect(() => {
27
+ const queue = consumeUnlocks()
28
+ if (queue.length === 0) return
29
+ const next: ToastItem[] = queue.map(ach => ({ key: ++toastKey, ach }))
30
+ setItems(prev => [...prev, ...next])
31
+ for (const item of next) {
32
+ const t = window.setTimeout(() => {
33
+ setItems(prev => prev.filter(i => i.key !== item.key))
34
+ timers.current.delete(item.key)
35
+ }, 3600)
36
+ timers.current.set(item.key, t)
37
+ }
38
+ }, [force])
39
+
40
+ useEffect(() => () => {
41
+ for (const t of timers.current.values()) window.clearTimeout(t)
42
+ }, [])
43
+
44
+ if (items.length === 0) return null
45
+ return createElement('div', { className: css.achToasts, 'data-genui-achievement-toasts': true },
46
+ items.map(item => createElement('div', { key: item.key, className: css.achToast },
47
+ createElement('span', { className: css.achToastBadge, 'aria-hidden': true }, '🏆'),
48
+ createElement('div', { className: css.achToastBody },
49
+ createElement('div', { className: css.achToastName }, `成就解锁:${item.ach.name}`),
50
+ createElement('div', { className: css.achToastDesc }, item.ach.description),
51
+ ),
52
+ )),
53
+ )
54
+ }
55
+
56
+ /** 挂载 toast 栈(apply 调用;返回卸载函数)。 */
57
+ export function mountAchievementToasts(): () => void {
58
+ if (typeof document === 'undefined') return () => {}
59
+ const host = document.createElement('div')
60
+ host.dataset.dshGenuiAchievements = '1'
61
+ document.body.appendChild(host)
62
+ const root = createRoot(host)
63
+ root.render(createElement(AchievementToasts))
64
+ return () => {
65
+ root.unmount()
66
+ host.remove()
67
+ }
68
+ }
@@ -0,0 +1,111 @@
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, GenuiNode } from './spec.ts'
13
+
14
+ /** 累积使用计数(成就的输入)。 */
15
+ export interface AchieveState {
16
+ /** 渲染过的不重复 fence 数。 */
17
+ fences: number
18
+ /** 面板(panel dock)出现过的会话数。 */
19
+ panels: number
20
+ /** 交互组件动作回传次数(去抖后)。 */
21
+ interactions: number
22
+ /** 模板试用次数。 */
23
+ templates: number
24
+ /** 图表节点(chart/plot/echart)出现过的 fence 数。 */
25
+ charts: number
26
+ /** 高级节点(scene3d/mermaid/diagram)出现过的 fence 数。 */
27
+ advanced: number
28
+ }
29
+
30
+ export function emptyState(): AchieveState {
31
+ return { fences: 0, panels: 0, interactions: 0, templates: 0, charts: 0, advanced: 0 }
32
+ }
33
+
34
+ export interface AchievementDef {
35
+ id: string
36
+ name: string
37
+ description: string
38
+ /** 解锁前隐藏名称/描述(彩蛋)。 */
39
+ hidden?: boolean
40
+ /** 稀有度。 */
41
+ rarity: 'common' | 'rare' | 'legendary'
42
+ check: (s: AchieveState) => boolean
43
+ }
44
+
45
+ const fences = (n: number) => (s: AchieveState): boolean => s.fences >= n
46
+
47
+ export const ACHIEVEMENTS: readonly AchievementDef[] = [
48
+ { id: 'first-fence', name: '初次相见', description: '渲染了第一块 GenUI 界面。', rarity: 'common', check: fences(1) },
49
+ { id: 'fence-5', name: '渐入佳境', description: '累计渲染 5 个界面。', rarity: 'common', check: fences(5) },
50
+ { id: 'fence-25', name: '界面编织者', description: '累计渲染 25 个界面。', rarity: 'rare', check: fences(25) },
51
+ { id: 'fence-50', name: '蓝图之魂', description: '累计渲染 50 个界面——你是 GenUI 的老朋友了。', rarity: 'legendary', hidden: true, check: fences(50) },
52
+ { id: 'first-interaction', name: '按钮按得动', description: '触发了一次组件交互(动作回传)。', rarity: 'common', check: s => s.interactions >= 1 },
53
+ { id: 'interaction-10', name: '小小操纵者', description: '累计触发 10 次组件交互。', rarity: 'rare', check: s => s.interactions >= 10 },
54
+ { id: 'first-panel', name: '钉在墙上', description: '第一次把内容钉进了会话面板。', rarity: 'common', check: s => s.panels >= 1 },
55
+ { id: 'panel-5', name: '面板管家', description: '5 个会话都用过面板。', rarity: 'rare', check: s => s.panels >= 5 },
56
+ { id: 'first-chart', name: '图表爱好者', description: '渲染了第一张图表(chart/plot/echart)。', rarity: 'common', check: s => s.charts >= 1 },
57
+ { id: 'chart-10', name: '数据可视化人', description: '10 个界面里出现过图表。', rarity: 'rare', check: s => s.charts >= 10 },
58
+ { id: 'first-advanced', name: '高级玩家', description: '用上了 3D/图表引擎/架构图之一。', rarity: 'rare', check: s => s.advanced >= 1 },
59
+ { id: 'template-1', name: '模板学员', description: '从模板中心试用了一个模板。', rarity: 'common', check: s => s.templates >= 1 },
60
+ ]
61
+
62
+ /** 统计一个 spec 里出现的相关节点类别(与 guard 同口径遍历)。 */
63
+ export function countSpecKinds(spec: GenuiSpec): { charts: number, advanced: number } {
64
+ let charts = 0
65
+ let advanced = 0
66
+ const walk = (list: unknown): void => {
67
+ if (!Array.isArray(list)) return
68
+ for (const item of list) {
69
+ if (typeof item !== 'object' || item === null) continue
70
+ const v = item as Record<string, unknown>
71
+ const type = v.type
72
+ if (type === 'chart' || type === 'plot' || type === 'echart') charts += 1
73
+ if (type === 'scene3d' || type === 'mermaid' || type === 'diagram') advanced += 1
74
+ // 容器与列表项递归
75
+ if (Array.isArray(v.items)) walk(v.items as unknown[])
76
+ if (Array.isArray((v as { tabs?: unknown }).tabs)) {
77
+ for (const tab of (v as { tabs: Array<{ items?: unknown }> }).tabs) walk(tab.items)
78
+ }
79
+ }
80
+ }
81
+ walk(spec.items)
82
+ return { charts, advanced }
83
+ }
84
+
85
+ /** 生成成就页 spec(dsh-ui 渲染):进度 stat + 解锁列表 + 稀有度徽标。 */
86
+ export function buildAchievementsSpec(state: AchieveState, unlocked: Record<string, number>): GenuiSpec {
87
+ const total = ACHIEVEMENTS.length
88
+ const unlockedCount = ACHIEVEMENTS.filter(a => unlocked[a.id] !== undefined).length
89
+ const items: GenuiNode[] = [
90
+ { type: 'grid', cols: 3, items: [
91
+ { type: 'stat', label: '已解锁', value: `${unlockedCount} / ${total}` },
92
+ { type: 'stat', label: '渲染界面', value: String(state.fences) },
93
+ { type: 'stat', label: '组件交互', value: String(state.interactions) },
94
+ ] },
95
+ { type: 'progress', label: '探索进度', value: Math.round(unlockedCount / total * 100), valueLabel: `${Math.round(unlockedCount / total * 100)}%` },
96
+ { type: 'list', items: ACHIEVEMENTS.map(a => ({
97
+ type: 'row',
98
+ items: [
99
+ { type: 'badge', label: a.rarity === 'legendary' ? '传说' : a.rarity === 'rare' ? '稀有' : '普通', tone: unlocked[a.id] !== undefined ? 'success' : 'warn' },
100
+ { type: 'text', size: 'body', content: a.hidden && unlocked[a.id] === undefined ? '?' : a.name },
101
+ { type: 'text', size: 'muted', content: a.hidden && unlocked[a.id] === undefined ? '继续探索以揭示' : a.description },
102
+ ],
103
+ })) },
104
+ ]
105
+ return { title: 'GenUI 探索成就', items }
106
+ }
107
+
108
+ /** 检查给定状态下的新解锁(不受 hidden 限制——规则只管阈值)。 */
109
+ export function checkAchievements(state: AchieveState, unlocked: Record<string, number>): AchievementDef[] {
110
+ return ACHIEVEMENTS.filter(a => unlocked[a.id] === undefined && a.check(state))
111
+ }
@@ -32,6 +32,7 @@ import { renderGenuiFence, type GenuiFenceContext } from './fence-render.tsx'
32
32
  import { createPanelSlashSource } from './panel-command.ts'
33
33
  import { GenuiPanel, type GenuiPanelInjected } from './panel.tsx'
34
34
  import { GenuiToolView } from './toolview.tsx'
35
+ import { mountAchievementToasts } from './achievement-toast.tsx'
35
36
  import type { InputTriggerServiceContract } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
36
37
  import { assetUrl } from './asset-loader.ts'
37
38
 
@@ -77,6 +78,16 @@ function panelActionSend(ctx: Context, sessionId: SessionId): GenuiPanelInjected
77
78
  console.warn(`[genui] 面板动作 "${action}" 发送失败(session ${sessionId}):`, err instanceof Error ? err.message : String(err))
78
79
  })
79
80
  },
81
+ insertTemplate: (text) => {
82
+ // Same insert channel as dsh-memory's organizeMemory: the standard
83
+ // conversation.input.for(scope).setDraft — templates land in the
84
+ // composer draft, not a queued message, so the user can edit before
85
+ // sending.
86
+ const input = (conversation as unknown as {
87
+ input?: { for?(actx: unknown): { setDraft(text: string): void } | undefined }
88
+ } | undefined)?.input?.for?.(scoped)
89
+ input?.setDraft(text)
90
+ },
80
91
  }
81
92
  }
82
93
 
@@ -130,6 +141,8 @@ export function apply(ctx: Context): () => void {
130
141
  // a session usually hits a warm cache instead of paying the fetch on first
131
142
  // use. Never blocks first paint; the loader still handles a miss.
132
143
  prefetchGenuiAssets()
144
+ // Achievement toasts (0.9.5): a body-level stack independent of the panel.
145
+ disposers.push(mountAchievementToasts())
133
146
  // Keyed toolview: the harness dispatches 'tool.call.toolview' by wire tool
134
147
  // name; registering under 'render_ui' gives the tool's result card the
135
148
  // GenUI renderer (reading the repaired spec from result meta). The toolview
@@ -25,11 +25,13 @@
25
25
  */
26
26
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
27
27
  import type {} from '@deepseek-ai/dsh-client-runtime/client'
28
+ import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
28
29
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
29
30
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
30
31
  import { GenuiBlock } from './GenuiBlock.tsx'
31
32
  import { ErrorBoundary } from './ErrorBoundary.tsx'
32
33
  import { panelStateKey } from './interaction-store.ts'
34
+ import { TemplateDrawer } from './TemplateDrawer.tsx'
33
35
  import { clearSessionPanel, getPanelExpandToken, getPanelSpec, setLocalPanel, subscribePanel, subscribePanelExpand } from './panel-store.ts'
34
36
  import css from './GenuiBlock.module.css'
35
37
 
@@ -37,10 +39,20 @@ import css from './GenuiBlock.module.css'
37
39
  const PANEL_HEIGHT_MIN = 120
38
40
  const PANEL_HEIGHT_MAX = 600
39
41
 
42
+ /** First-panel hint dismissal flag (module-level localStorage key):
43
+ * shown once beside the first rendered spec, never again. */
44
+ const ONBOARD_KEY = 'dsh.genui.panel-hint'
45
+
46
+ // Achievement telemetry re-exports (already injected at apply).
47
+ import { recordPanel, recordTemplateUse } from './achievement-store.ts'
48
+
40
49
  /** Injection face built per session in apply (scoped conversation send). */
41
50
  export interface GenuiPanelInjected {
42
51
  sessionId: string
43
52
  sendGenuiAction: GenuiActionHandler
53
+ /** Template center "try it": insert the template instruction into the
54
+ * current composer draft (standard conversation.input.for channel). */
55
+ insertTemplate: (text: string) => void
44
56
  }
45
57
 
46
58
  export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected
@@ -51,7 +63,7 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
51
63
  * default so the dock never steals the message flow's scroll room; the
52
64
  * header always shows the current panel title.
53
65
  */
54
- export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
66
+ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps) {
55
67
  const spec = useSyncExternalStore(subscribePanel, () => getPanelSpec(sessionId))
56
68
  const expandToken = useSyncExternalStore(subscribePanelExpand, () => getPanelExpandToken(sessionId))
57
69
  const [collapsed, setCollapsed] = useState(true)
@@ -60,6 +72,39 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
60
72
  const [resizing, setResizing] = useState(false)
61
73
  const dragStart = useRef<{ y: number; height: number } | null>(null)
62
74
  const bodyRef = useRef<HTMLDivElement | null>(null)
75
+ // Template center (0.9.4) + achievements (0.9.5): the drawer host —
76
+ // null = closed, 'templates' | 'achievements' = open on that tab.
77
+ const [drawer, setDrawer] = useState<null | 'templates' | 'achievements'>(null)
78
+ const [flash, setFlash] = useState<string | null>(null)
79
+ const flashTimer = useRef(0)
80
+ const hasSpec = spec !== null && spec.items.length > 0
81
+ // First-panel hint: shown once beside the first rendered spec (never on the
82
+ // hero — the host composer hero flex-compresses dock items with no content,
83
+ // and the template button is a permanent header entry anyway).
84
+ const [hint, setHint] = useState<boolean>(() => {
85
+ try {
86
+ return hasSpec && localStorage.getItem(ONBOARD_KEY) !== '1'
87
+ } catch {
88
+ return hasSpec
89
+ }
90
+ })
91
+ useEffect(() => {
92
+ if (!hint) return
93
+ const t = window.setTimeout(() => {
94
+ try {
95
+ localStorage.setItem(ONBOARD_KEY, '1')
96
+ } catch {
97
+ // Private-mode storage failures are non-fatal: the hint may reappear.
98
+ }
99
+ setHint(false)
100
+ }, 6000)
101
+ return () => window.clearTimeout(t)
102
+ }, [hint])
103
+ // Achievement telemetry (0.9.5): one panel appearance per dock mount.
104
+ useEffect(() => {
105
+ if (!hasSpec) return
106
+ recordPanel()
107
+ }, [hasSpec])
63
108
 
64
109
  // Explicit expand requests (the /panel command) open the dock even when the
65
110
  // user collapsed it manually: the monotone token guarantees a fresh request
@@ -89,6 +134,9 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
89
134
  }
90
135
  }, [resizing])
91
136
 
137
+ // Template-center flash timer must not outlive the dock.
138
+ useEffect(() => () => window.clearTimeout(flashTimer.current), [])
139
+
92
140
  /** Start a vertical resize drag on the panel's top edge. Dragging UP grows
93
141
  * the panel (the edge moves toward the message flow); dragging down shrinks
94
142
  * it. Pointer capture binds move/up/cancel to the HANDLE, so dragging past
@@ -126,7 +174,14 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
126
174
  handle.addEventListener('pointercancel', onUp)
127
175
  }, [bodyHeight])
128
176
 
129
- if (spec === null || spec.items.length === 0) return null
177
+ if (!hasSpec) return null
178
+
179
+ const showFlash = (text: string): void => {
180
+ setFlash(text)
181
+ window.clearTimeout(flashTimer.current)
182
+ flashTimer.current = window.setTimeout(() => setFlash(null), 2500)
183
+ }
184
+
130
185
  return (
131
186
  <div className={css.panel} data-genui-panel>
132
187
  {/* Resize grip on the panel's TOP EDGE (above the header): the drag
@@ -149,8 +204,33 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
149
204
  onClick={() => setCollapsed(c => !c)}
150
205
  >
151
206
  <span className={css.panelBadge}>面板</span>
152
- <span className={css.panelTitle}>{spec.title ?? 'GenUI 面板'}</span>
153
- <span className={css.panelChevron} aria-hidden>{collapsed ? '▸' : '▾'}</span>
207
+ <span className={css.panelTitle}>{spec?.title ?? (drawer !== null ? 'GenUI 探索' : 'GenUI 面板')}</span>
208
+ <span className={css.panelChevron} aria-hidden>
209
+ {/* Host-style glyphs (same icon set as the TodoDock header) instead of typed arrows. */}
210
+ {collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
211
+ </span>
212
+ </button>
213
+ {/* Template center (0.9.4): one button — the discovery surface for
214
+ new users and a quick-start entry for everyone. */}
215
+ <button
216
+ type="button"
217
+ className={`${css.panelTpl}${drawer === 'templates' ? ` ${css.panelTplActive}` : ''}`}
218
+ aria-label="模板中心"
219
+ title="模板中心:预览并试用 GenUI 示例"
220
+ onClick={() => { setCollapsed(false); setDrawer(d => (d === 'templates' ? null : 'templates')) }}
221
+ >
222
+ 模板
223
+ </button>
224
+ {/* Achievements (0.9.5): the exploration trophies, rendered by GenUI
225
+ itself (dogfooding). */}
226
+ <button
227
+ type="button"
228
+ className={`${css.panelTpl}${drawer === 'achievements' ? ` ${css.panelTplActive}` : ''}`}
229
+ aria-label="探索成就"
230
+ title="探索成就:解锁徽章与进度"
231
+ onClick={() => { setCollapsed(false); setDrawer(d => (d === 'achievements' ? null : 'achievements')) }}
232
+ >
233
+ 成就
154
234
  </button>
155
235
  {/* In-place dismiss (issue #23): the same local override `/panel
156
236
  clear` applies — persists to localStorage, notifies subscribers,
@@ -165,6 +245,11 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
165
245
  <span aria-hidden>✕</span>
166
246
  </button>
167
247
  </div>
248
+ {hint && drawer === null && (
249
+ <div className={css.panelHint} role="status">
250
+ 💡 这是 GenUI 会话面板:点右上角「模板」可浏览并试用示例
251
+ </div>
252
+ )}
168
253
  {!collapsed && (
169
254
  <div
170
255
  ref={bodyRef}
@@ -172,14 +257,28 @@ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
172
257
  data-genui-panel-body
173
258
  style={bodyHeight === null ? undefined : { height: bodyHeight }}
174
259
  >
175
- <GenuiActionContext.Provider value={sendGenuiAction}>
176
- <ErrorBoundary label="面板">
177
- {/* content-fingerprinted: same panel spec re-published restores its state */}
178
- <GenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
179
- </ErrorBoundary>
180
- </GenuiActionContext.Provider>
260
+ {drawer !== null ? (
261
+ <TemplateDrawer
262
+ tab={drawer}
263
+ onUse={(text) => {
264
+ recordTemplateUse()
265
+ insertTemplate(text)
266
+ showFlash('指令已插入输入框,发送即可让模型生成')
267
+ }}
268
+ />
269
+ ) : spec !== null ? (
270
+ <GenuiActionContext.Provider value={sendGenuiAction}>
271
+ <ErrorBoundary label="面板">
272
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
273
+ <GenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
274
+ </ErrorBoundary>
275
+ </GenuiActionContext.Provider>
276
+ ) : null}
181
277
  </div>
182
278
  )}
279
+ {flash !== null && (
280
+ <div className={css.panelFlash} role="status">{flash}</div>
281
+ )}
183
282
  </div>
184
283
  )
185
284
  }