@huaqiu/dsh-tool-pcb-viewer 0.4.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.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Browser half entry — DSH client plugin (turn-tail card + singleton panel).
3
+ * Everything lives in ./panel.tsx; this entry exists so the tsdown client
4
+ * bundle has a stable, conventional entry path (matches the other plugins).
5
+ */
6
+ export { apply, inject } from './panel.js'
@@ -0,0 +1,496 @@
1
+ // kicad-3d-viewer — DSH 浏览器半边(client 源)。
2
+ //
3
+ // 交互形态(按用户要求):
4
+ // - **模型推送的卡片**:pcb_preview 所在的回合末尾(turn tail,正文流里、不悬浮)
5
+ // 出现一张卡片:板子实时 2D 缩略图 + 文件名 + 参数 + 「显示 PCB / 显示 3D /
6
+ // 浏览器打开」三个按钮。
7
+ // - 数据通路:
8
+ // 1) 会话观察器(挂在 conversation.session.header.actions,渲染 null)持续扫会话
9
+ // 快照,维护 回合号 → 预览 的映射(模块级 store)。
10
+ // 2) 卡片注册在 conversation.chat.turnTail(chain 槽位,官方 deliverables 卡片同一行),
11
+ // select 用回合号查映射表自我选举(priority -1 = 先于 deliverables;该回合没有
12
+ // 预览时让位给它)。
13
+ // 3) 大屏面板仍是 fixed 悬浮层,由卡片按钮打开;单例(模块级状态)。
14
+ //
15
+ // 本文件经 scripts/build-client.mjs 打包为 client.js(ModuleLoader 单文件 CJS)。
16
+
17
+ import React, { useEffect, useRef, useState } from 'react'
18
+ import { createViewer, type ViewMode } from '../viewer.js'
19
+
20
+ // ---------------------------------------------------------------- 宿主平台的客户端类型
21
+ // DSH 的 client 类型没有以包的形式安装;这里按实际用到的形状做最小本地声明,
22
+ // ctx / owner / slot props 一律窄化到局部接口,不做全局假设。
23
+ interface ClientContext {
24
+ slots: {
25
+ inject(name: string, cb: () => unknown): void
26
+ register(spec: SlotSpec, component: React.ComponentType<never> | ((props: never) => unknown)): unknown
27
+ }
28
+ }
29
+ interface SlotSpec {
30
+ name: string
31
+ id?: string
32
+ order?: number
33
+ priority?: number
34
+ select?: (owner: SessionOwner) => PreviewPayload | null
35
+ inject?: () => Record<string, unknown>
36
+ }
37
+ type SessionNode = Record<string, unknown>
38
+ interface SessionSnapshot {
39
+ nodes?: SessionNode[]
40
+ }
41
+ /** turnTail 的 select 收到的宿主侧 owner(只用 turn.turn)。 */
42
+ interface SessionOwner {
43
+ turn?: { turn?: number }
44
+ }
45
+ /** 会话快照的 useSession 选择器(宿主注入)。 */
46
+ type UseSession = <T>(selector: (snapshot: SessionSnapshot | undefined) => T) => T
47
+
48
+ const inject = ['slots']
49
+
50
+ const EMPTY_NODES: SessionNode[] = []
51
+ const MAX_WALK_DEPTH = 100
52
+ const TOOL_NAME = 'pcb_preview'
53
+
54
+ // ---------------------------------------------------------------- 工具结果解析
55
+ interface PreviewStats {
56
+ sizeKB?: number
57
+ widthMM?: number
58
+ heightMM?: number
59
+ layers?: number
60
+ comps?: number
61
+ pads?: number
62
+ traces?: number
63
+ vias?: number
64
+ }
65
+ interface PreviewPayload {
66
+ ok: boolean
67
+ key?: string
68
+ viewUrl?: string
69
+ name?: string
70
+ stats?: PreviewStats
71
+ }
72
+ /** tool-result 节点里承载 JSON 的块。 */
73
+ interface TextBlock {
74
+ type?: string
75
+ text?: string
76
+ }
77
+
78
+ /**
79
+ * 从一个 tool-result 节点里取出预览载荷。
80
+ * 先逐块 JSON.parse(host 的 render 会额外回一块 JSON);失败则正则兜底。
81
+ */
82
+ function pickPreview(node: SessionNode): PreviewPayload | null {
83
+ const blocks = (Array.isArray(node.content) ? (node.content as TextBlock[]) : [])
84
+ .filter((b) => b?.type === 'text')
85
+ for (const b of blocks) {
86
+ try {
87
+ const v = JSON.parse(String(b.text)) as PreviewPayload | null
88
+ if (v && v.ok === true && typeof v.viewUrl === 'string') return v
89
+ } catch { /* not this block */ }
90
+ }
91
+ const joined = blocks.map((b) => b.text).join('\n')
92
+ const m = joined.match(/\/pcb-viewer\/api\/file\?key=([\w.-]+)/)
93
+ if (m) {
94
+ const name = (joined.match(/([\w.-]+\.kicad_pcb)/) ?? [])[1] ?? 'board.kicad_pcb'
95
+ return { ok: true, key: m[1], viewUrl: m[0], name }
96
+ }
97
+ return null
98
+ }
99
+
100
+ function walkAll(nodes: SessionNode[], visit: (node: SessionNode) => void): void {
101
+ const visited = new Set<SessionNode>()
102
+ const walk = (node: unknown, depth: number): void => {
103
+ if (!node || typeof node !== 'object' || depth > MAX_WALK_DEPTH || visited.has(node as SessionNode)) return
104
+ const n = node as SessionNode
105
+ visited.add(n)
106
+ visit(n)
107
+ const kids = (n.children ?? n.nodes ?? []) as SessionNode[]
108
+ for (const k of kids) walk(k, depth + 1)
109
+ if (Array.isArray(n.subCalls)) for (const k of n.subCalls as SessionNode[]) walk(k, depth + 1)
110
+ }
111
+ for (const n of nodes) walk(n, 0)
112
+ }
113
+
114
+ /** 会话快照 → { byTurn: Map<turnNumber, preview>, latest: preview|null } */
115
+ interface PreviewIndex {
116
+ byTurn: Map<number, PreviewPayload>
117
+ latest: PreviewPayload | null
118
+ }
119
+
120
+ function indexPreviews(nodes: SessionNode[]): PreviewIndex {
121
+ const byCallId = new Map<string, PreviewPayload>()
122
+ let latest: PreviewPayload | null = null
123
+ walkAll(nodes, (n) => {
124
+ const call = n.call as { name?: string } | undefined
125
+ if (n.kind === 'tool-result' && call?.name === TOOL_NAME && !n.isError) {
126
+ const p = pickPreview(n)
127
+ if (p) {
128
+ if (n.callId != null) byCallId.set(String(n.callId), p)
129
+ latest = p
130
+ }
131
+ }
132
+ })
133
+ const byTurn = new Map<number, PreviewPayload>()
134
+ walkAll(nodes, (n) => {
135
+ if (n.kind !== 'assistant') return
136
+ for (const b of (n.blocks ?? []) as Record<string, unknown>[]) {
137
+ if (b?.kind === 'tool-call' && b.name === TOOL_NAME) {
138
+ const p = (b.callId != null && byCallId.get(String(b.callId))) || null
139
+ if (p && typeof n.turn === 'number') byTurn.set(n.turn, p)
140
+ }
141
+ }
142
+ })
143
+ return { byTurn, latest }
144
+ }
145
+
146
+ // ---------------------------------------------------------------- 板文件文本缓存(带上限,超限逐出最旧)
147
+ const textCache = new Map<string, string>()
148
+ let textCacheBytes = 0
149
+ const TEXT_CACHE_CAP = 250 * 1024 * 1024
150
+ function loadBoardText(preview: PreviewPayload | null | undefined): Promise<string> {
151
+ const key = preview?.key ?? preview?.viewUrl
152
+ if (!key) return Promise.reject(new Error('no preview'))
153
+ const cached = textCache.get(key)
154
+ if (cached !== undefined) return Promise.resolve(cached)
155
+ return fetch(preview!.viewUrl ?? key)
156
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text() })
157
+ .then((text) => {
158
+ const sz = text.length
159
+ while (textCacheBytes + sz > TEXT_CACHE_CAP && textCache.size) {
160
+ const oldest = textCache.keys().next().value as string
161
+ textCacheBytes -= (textCache.get(oldest) ?? '').length
162
+ textCache.delete(oldest)
163
+ }
164
+ textCache.set(key, text)
165
+ textCacheBytes += sz
166
+ return text
167
+ })
168
+ }
169
+ const THUMB_MAX_KB = 8 * 1024
170
+
171
+ // ---------------------------------------------------------------- 面板单例状态
172
+ interface PanelStore {
173
+ open: boolean
174
+ mode: ViewMode
175
+ preview: PreviewPayload | null
176
+ token: number
177
+ }
178
+ type PanelPatch = Partial<Omit<PanelStore, 'token'>>
179
+ const panelStore: PanelStore = { open: false, mode: 'split', preview: null, token: 0 }
180
+ const panelListeners = new Set<(token: number) => void>()
181
+ function setPanel(patch: PanelPatch): void {
182
+ Object.assign(panelStore, patch)
183
+ panelStore.token += 1
184
+ for (const fn of panelListeners) fn(panelStore.token)
185
+ }
186
+ function usePanel(): PanelStore {
187
+ const [, force] = useState(0)
188
+ useEffect(() => {
189
+ const fn = (): void => force((v) => v + 1)
190
+ panelListeners.add(fn)
191
+ return () => { panelListeners.delete(fn) }
192
+ }, [])
193
+ return panelStore
194
+ }
195
+
196
+ // 会话预览索引(观察器写入;turnTail 的 select 是纯函数,只能读模块级状态)
197
+ const previewIndex: PreviewIndex = { byTurn: new Map<number, PreviewPayload>(), latest: null }
198
+
199
+ // ---------------------------------------------------------------- 样式
200
+ const S: Record<string, React.CSSProperties> = {
201
+ card: {
202
+ display: 'flex', flexDirection: 'column', gap: 9, marginTop: 6, marginBottom: 6,
203
+ padding: 11, borderRadius: 10, maxWidth: 320,
204
+ background: 'var(--dsw-alias-bg-layer-3, rgba(255,255,255,.03))',
205
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))',
206
+ width: 'fit-content',
207
+ },
208
+ thumbWrap: { position: 'relative', borderRadius: 7, overflow: 'hidden', background: '#0b0e13', border: '1px solid rgba(95,224,205,.18)' },
209
+ thumbHint: { position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#7d8b9a', font: '10px ui-monospace, Menlo, monospace', letterSpacing: '0.14em' },
210
+ cardTitle: { font: '600 12px ui-monospace, Menlo, monospace', color: 'var(--dsw-alias-label-primary, #e6e9ef)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 290 },
211
+ cardMeta: { font: '10px ui-monospace, Menlo, monospace', color: 'var(--dsw-alias-label-tertiary, #93a1b0)', letterSpacing: '0.04em' },
212
+ strip: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
213
+ btn: {
214
+ display: 'inline-flex', alignItems: 'center', gap: 5,
215
+ font: '600 11px ui-monospace, Menlo, monospace', letterSpacing: '0.06em',
216
+ color: 'var(--dsw-alias-label-primary, #e6e9ef)',
217
+ background: 'var(--dsw-alias-fill-tsp-secondary, rgba(95,224,205,.08))',
218
+ border: '1px solid rgba(95,224,205,.38)', borderRadius: 6,
219
+ padding: '3px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
220
+ },
221
+ btnOn: { borderColor: '#5fe0cd', color: '#5fe0cd', boxShadow: '0 0 10px rgba(95,224,205,.25)' },
222
+ panel: {
223
+ position: 'fixed', top: 0, right: 0, height: '100vh', zIndex: 200,
224
+ background: '#090b0f', borderLeft: '1px solid rgba(95,224,205,.25)',
225
+ boxShadow: '-18px 0 60px rgba(0,0,0,.55)', display: 'flex', flexDirection: 'column',
226
+ transition: 'width .25s ease',
227
+ },
228
+ head: {
229
+ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px',
230
+ borderBottom: '1px solid rgba(95,224,205,.14)', flex: '0 0 auto',
231
+ font: '11px ui-monospace, Menlo, monospace', color: '#cdd8e4', userSelect: 'none',
232
+ },
233
+ title: { color: '#5fe0cd', letterSpacing: '0.18em', fontWeight: 700, fontSize: 11 },
234
+ stats: { opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 },
235
+ headBtn: {
236
+ font: '600 11px ui-monospace, Menlo, monospace', color: '#5fe0cd',
237
+ background: 'transparent', border: '1px solid rgba(95,224,205,.35)',
238
+ borderRadius: 5, padding: '3px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
239
+ },
240
+ body: { flex: 1, position: 'relative', minHeight: 0 },
241
+ state: {
242
+ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
243
+ color: '#9fb2c4', font: '12px ui-monospace, Menlo, monospace', letterSpacing: '0.12em',
244
+ },
245
+ }
246
+
247
+ function webUrl(preview: PreviewPayload): string {
248
+ return `/pcb-viewer/view?key=${encodeURIComponent(preview.key ?? '')}&name=${encodeURIComponent(preview.name ?? 'board.kicad_pcb')}`
249
+ }
250
+
251
+ // ---------------------------------------------------------------- 缩略图
252
+ interface BoardThumbProps {
253
+ preview: PreviewPayload | null
254
+ width?: number
255
+ height?: number
256
+ }
257
+
258
+ function BoardThumb({ preview, width = 292, height = 158 }: BoardThumbProps) {
259
+ const hostRef = useRef<HTMLDivElement | null>(null)
260
+ const [state, setState] = useState('loading')
261
+ const sizeKB = preview?.stats?.sizeKB ?? 0
262
+ const big = sizeKB > THUMB_MAX_KB
263
+
264
+ useEffect(() => {
265
+ if (!preview) return undefined
266
+ let viewer: { dispose?: () => void } | null = null
267
+ let disposed = false
268
+ // 预加载:卡片一出现就后台拉板文本;大文件的几秒拉取发生在用户看卡片的间隙
269
+ loadBoardText(preview)
270
+ .then((text) => {
271
+ if (disposed || !hostRef.current) return
272
+ try {
273
+ // 板文本直接交给 viewer:createViewer 内部走 BoardParser().parse() → adaptBoard()
274
+ // (与旧 parseKicad 完全同一条流水线,行为不变)
275
+ viewer = createViewer(hostRef.current, {
276
+ board: text,
277
+ cacheKey: preview.key ?? preview.viewUrl,
278
+ boardName: preview.name,
279
+ brand: 'PCB',
280
+ mode: '3d',
281
+ hud: false,
282
+ post: false,
283
+ interactive: false,
284
+ // 大文件轻渲染:只建几何、跳过全部 canvas 纹理(省掉建模的大头)
285
+ textures: !big,
286
+ })
287
+ setState('ready')
288
+ } catch { setState('error') }
289
+ })
290
+ .catch(() => { if (!disposed) setState('error') })
291
+ return () => { disposed = true; viewer?.dispose?.(); viewer = null }
292
+ }, [preview?.key]) // eslint-disable-line react-hooks/exhaustive-deps
293
+
294
+ return (
295
+ <div style={{ ...S.thumbWrap, width, height }}>
296
+ <div ref={hostRef} style={{ position: 'absolute', inset: 0 }} />
297
+ {state !== 'ready' && (
298
+ <div style={S.thumbHint}>
299
+ {state === 'loading' ? (big ? '预加载中…' : 'RENDERING…') : '预览不可用'}
300
+ </div>
301
+ )}
302
+ </div>
303
+ )
304
+ }
305
+
306
+ // ---------------------------------------------------------------- 大屏面板(单例)
307
+ interface PcbPanelProps {
308
+ preview: PreviewPayload | null
309
+ mode: ViewMode
310
+ expanded: boolean
311
+ onMode: (mode: ViewMode) => void
312
+ onToggleExpand: () => void
313
+ onClose: () => void
314
+ }
315
+
316
+ function PcbPanel({ preview, mode, expanded, onMode, onToggleExpand, onClose }: PcbPanelProps) {
317
+ const bodyRef = useRef<HTMLDivElement | null>(null)
318
+ const viewerRef = useRef<ReturnType<typeof createViewer> | null>(null)
319
+ const [phase, setPhase] = useState('loading')
320
+ const [error, setError] = useState('')
321
+ const s = preview?.stats ?? {}
322
+
323
+ useEffect(() => {
324
+ if (!preview) return undefined
325
+ let disposed = false
326
+ setPhase('loading')
327
+ setError('')
328
+ loadBoardText(preview)
329
+ .then((text) => {
330
+ if (disposed || !bodyRef.current) return
331
+ viewerRef.current = createViewer(bodyRef.current, {
332
+ board: text,
333
+ cacheKey: preview.key ?? preview.viewUrl,
334
+ boardName: preview.name,
335
+ brand: 'PCB · 3D 预览',
336
+ brandSub: '2D LAYOUT + 3D RENDER',
337
+ mode,
338
+ })
339
+ setPhase('ready')
340
+ })
341
+ .catch((e) => { if (!disposed) { setPhase('error'); setError(String(e)) } })
342
+ return () => {
343
+ disposed = true
344
+ viewerRef.current?.dispose?.()
345
+ viewerRef.current = null
346
+ }
347
+ }, [preview?.key]) // eslint-disable-line react-hooks/exhaustive-deps
348
+
349
+ useEffect(() => {
350
+ viewerRef.current?.setMode?.(mode)
351
+ }, [mode, phase])
352
+
353
+ const modeBtn = (m: ViewMode, label: string) => (
354
+ <button type="button" style={{ ...S.headBtn, ...(mode === m ? S.btnOn : null) }} onClick={() => onMode(m)}>{label}</button>
355
+ )
356
+ const wide = mode === 'split' ? (expanded ? '100vw' : 'min(78vw, 1500px)') : (expanded ? '100vw' : 'min(86vw, 1700px)')
357
+
358
+ return (
359
+ <div style={{ ...S.panel, width: wide }}>
360
+ <div style={S.head}>
361
+ <span style={S.title}>PCB 3D 预览</span>
362
+ <span style={S.stats}>
363
+ {preview?.name ?? ''}
364
+ {s.widthMM ? ` · ${s.widthMM}×${s.heightMM}mm · ${s.layers}层 · ${s.comps}器件 · ${s.traces}走线` : ''}
365
+ </span>
366
+ {modeBtn('2d', 'PCB')}
367
+ {modeBtn('3d', '3D')}
368
+ {modeBtn('split', '并排')}
369
+ <button type="button" style={S.headBtn} onClick={onToggleExpand}>{expanded ? '⇲ 还原' : '⇱ 放大'}</button>
370
+ <button type="button" style={S.headBtn} onClick={onClose}>✕ 关闭</button>
371
+ </div>
372
+ <div style={S.body}>
373
+ <div ref={bodyRef} style={{ position: 'absolute', inset: 0 }} />
374
+ {phase === 'loading' && <div style={S.state}>LOADING BOARD…</div>}
375
+ {phase === 'error' && <div style={S.state}>加载失败:{error}</div>}
376
+ </div>
377
+ </div>
378
+ )
379
+ }
380
+
381
+ // ---------------------------------------------------------------- 卡片(回合末尾,正文流)
382
+ interface PcbTailCardProps {
383
+ matched?: PreviewPayload | null
384
+ useSession?: UseSession
385
+ }
386
+
387
+ function PcbTailCard(props: PcbTailCardProps) {
388
+ const preview = props.matched ?? null
389
+ const [expanded, setExpanded] = useState(false)
390
+ const st = usePanel()
391
+ const s = preview?.stats ?? {}
392
+ const isCurrent = st.preview?.key === preview?.key
393
+ const shown = st.open && isCurrent
394
+ const openWith = (mode: ViewMode) => setPanel({ preview, mode, open: true })
395
+
396
+ if (!preview) return null
397
+ return (
398
+ <>
399
+ <div style={S.card}>
400
+ <BoardThumb preview={preview} />
401
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
402
+ <span style={S.cardTitle}>{preview.name}</span>
403
+ <span style={S.cardMeta}>
404
+ {s.widthMM ? `${s.widthMM}×${s.heightMM}mm · ${s.layers}层 · ${s.comps}器件 · ${s.traces}走线` : 'PCB'}
405
+ </span>
406
+ </div>
407
+ <span style={S.strip}>
408
+ <button
409
+ type="button"
410
+ style={{ ...S.btn, ...(shown && st.mode === '2d' ? S.btnOn : null) }}
411
+ onClick={() => (shown && st.mode === '2d' ? setPanel({ open: false }) : openWith('2d'))}
412
+ >
413
+ <svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
414
+ <rect x="1" y="1" width="12" height="12" rx="2" /><path d="M2 9l3-3 2.5 2.5L10 6l2 2" />
415
+ </svg>
416
+ 显示 PCB
417
+ </button>
418
+ <button
419
+ type="button"
420
+ style={{ ...S.btn, ...(shown && st.mode === '3d' ? S.btnOn : null) }}
421
+ onClick={() => (shown && st.mode === '3d' ? setPanel({ open: false }) : openWith('3d'))}
422
+ >
423
+ <svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
424
+ <path d="M7 1.5l5 2.8v5.4L7 12.5 2 9.7V4.3z" /><path d="M2 4.3l5 2.8 5-2.8M7 7.1v5.4" />
425
+ </svg>
426
+ 显示 3D
427
+ </button>
428
+ <button
429
+ type="button"
430
+ style={S.btn}
431
+ title="在新标签页打开整页(左板子 + 右 3D)"
432
+ onClick={() => window.open(webUrl(preview), '_blank', 'noopener')}
433
+ >
434
+ <svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
435
+ <path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v6A1.5 1.5 0 0 0 3.5 12h6A1.5 1.5 0 0 0 11 10.5V8" />
436
+ <path d="M8.5 2H12v3.5M12 2L6.5 7.5" />
437
+ </svg>
438
+ 浏览器打开
439
+ </button>
440
+ </span>
441
+ </div>
442
+ {shown && (
443
+ <PcbPanel
444
+ preview={preview}
445
+ mode={st.mode}
446
+ expanded={expanded}
447
+ onMode={(m) => setPanel({ mode: m })}
448
+ onToggleExpand={() => setExpanded((v) => !v)}
449
+ onClose={() => { setExpanded(false); setPanel({ open: false }) }}
450
+ />
451
+ )}
452
+ </>
453
+ )
454
+ }
455
+
456
+ // ---------------------------------------------------------------- 会话观察器(渲染 null)
457
+ interface PreviewWatcherProps {
458
+ useSession?: UseSession
459
+ }
460
+
461
+ function PreviewWatcher({ useSession }: PreviewWatcherProps) {
462
+ const nodes = useSession ? useSession((s) => (s && s.nodes) || EMPTY_NODES) : EMPTY_NODES
463
+ useEffect(() => {
464
+ const idx = indexPreviews(nodes)
465
+ previewIndex.byTurn = idx.byTurn
466
+ previewIndex.latest = idx.latest
467
+ }, [nodes])
468
+ return null
469
+ }
470
+
471
+ export function apply(ctx: ClientContext): void {
472
+ // 观察器:常驻、无渲染,只维护 回合号→预览 映射
473
+ ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
474
+ name: 'conversation.session.header.actions',
475
+ id: 'kicad-3d-viewer-watcher',
476
+ order: 999,
477
+ inject: () => ({}),
478
+ }, PreviewWatcher))
479
+
480
+ // 卡片:官方 deliverables 同一行(turn tail chain,正文流里,不悬浮)。
481
+ // select 按回合号自我选举;priority -1 = 先于官方 deliverables(该回合有预览时优先出卡片),
482
+ // 没有预览的回合 select 返回 null → 让位给官方行。
483
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
484
+ name: 'conversation.chat.turnTail',
485
+ priority: -1,
486
+ select: (owner: SessionOwner) => {
487
+ const turn = owner?.turn?.turn
488
+ return typeof turn === 'number' ? previewIndex.byTurn.get(turn) ?? null : null
489
+ },
490
+ inject: () => ({}),
491
+ }, PcbTailCard))
492
+ }
493
+
494
+ // 渲染发生在浏览器半边(本文件即入口);host 半边只负责取板子路径与统计。
495
+ export { inject }
496
+ export const internals = Object.freeze({ pickPreview, indexPreviews })
@@ -0,0 +1,35 @@
1
+ // @huaqiu/dsh-tool-pcb-viewer — 独立整页入口(「浏览器打开」用)。
2
+ //
3
+ // 由 host 路由 /pcb-viewer/view 提供的极简页面加载:读 URL 上的 key/name,
4
+ // 向 /pcb-viewer/api/file 拉板文件文本,渲染「左 2D 走线 + 右 3D」整页视图。
5
+ // 布局与面板内一致(split),放大到满屏。
6
+ import { createViewer, type ViewMode } from '../viewer.js'
7
+
8
+ const app = document.getElementById('app') as HTMLElement
9
+ const q = new URLSearchParams(location.search)
10
+ const key = q.get('key') ?? ''
11
+ const name = q.get('name') ?? 'board.kicad_pcb'
12
+ const modeParam = q.get('mode')
13
+ const mode: ViewMode = modeParam === '2d' || modeParam === '3d' ? modeParam : 'split'
14
+
15
+ function fail(message: string): void {
16
+ app.innerHTML = `<div style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#9fb2c4;font:13px ui-monospace,Menlo,monospace;letter-spacing:.12em">${message}</div>`
17
+ }
18
+
19
+ if (!key) {
20
+ fail('缺少 key 参数 —— 请从 DSH 会话里点「浏览器打开」进入')
21
+ } else {
22
+ document.title = name + ' · PCB 3D'
23
+ fetch(`/pcb-viewer/api/file?key=${encodeURIComponent(key)}`)
24
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text() })
25
+ .then((text) => {
26
+ createViewer(app, {
27
+ board: text,
28
+ boardName: name,
29
+ brand: 'PCB · 3D',
30
+ brandSub: '2D LAYOUT + 3D RENDER',
31
+ mode,
32
+ })
33
+ })
34
+ .catch((e) => fail('加载失败:' + String(e instanceof Error ? e.message : e)))
35
+ }