@huaqiu/component-gen-app 0.3.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.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@huaqiu/component-gen-app",
3
+ "version": "0.3.6",
4
+ "type": "module",
5
+ "main": "./lib/index.js",
6
+ "types": "./lib/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./dist/*": "./dist/*",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": "^18",
22
+ "react-dom": "^18"
23
+ },
24
+ "dependencies": {
25
+ "@huaqiu/ecad-renderer": "^0.2.4",
26
+ "@huaqiu/kicad-sexpr-parser": "^0.1.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^18.3.31",
30
+ "@types/react-dom": "^18.3.7",
31
+ "react": "^18.3.1",
32
+ "react-dom": "^18.3.1",
33
+ "tsdown": "^0.22.14",
34
+ "typescript": "^5.9.0",
35
+ "vite": "^8.2.2",
36
+ "vitest": "^4.1.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "build": "tsdown && vite build",
44
+ "build:lib": "tsdown",
45
+ "build:standalone": "vite build",
46
+ "test": "vitest run"
47
+ }
48
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — the app shell.
3
+ *
4
+ * Renders one of the two generation pages (the host opens the page directly).
5
+ * History lives in a modal dialog opened from the header's "View history"
6
+ * action (mirroring dsh's settings-dialog header action beside the close
7
+ * button); reopening an entry closes the dialog and loads the artifact back
8
+ * into the page. Completely DSH-agnostic — everything the app needs comes
9
+ * through `ComponentGenPorts`.
10
+ */
11
+ import { useMemo, useState, type ReactElement } from 'react'
12
+ import type { ComponentGenPage, ComponentGenPorts, ReopenRequest } from './ports.js'
13
+ import { translateFor, type Translate } from './copy/index.js'
14
+ import { SymbolGenPage } from './pages/SymbolGenPage.js'
15
+ import { FootprintGenPage } from './pages/FootprintGenPage.js'
16
+ import { HistoryPanel } from './components/HistoryPanel.js'
17
+
18
+ export interface ComponentGenAppProps {
19
+ ports: ComponentGenPorts
20
+ page: ComponentGenPage
21
+ /** host UI language: 'zh' | 'en' (default zh). */
22
+ lang?: string
23
+ onClose?: () => void
24
+ }
25
+
26
+ /** Modal history dialog — chrome follows dsh's Modal primitive. */
27
+ function HistoryDialog({
28
+ ports, page, t, onReopen, onClose,
29
+ }: {
30
+ ports: ComponentGenPorts
31
+ page: ComponentGenPage
32
+ t: Translate
33
+ onReopen: (entry: ReopenRequest['entry']) => void
34
+ onClose: () => void
35
+ }): ReactElement {
36
+ return (
37
+ <div className="cga-history-dialog" role="presentation">
38
+ <div className="cga-history-dialog__mask" aria-hidden="true" onClick={onClose} />
39
+ <div className="cga-history-dialog__panel" role="dialog" aria-modal="true" aria-label={t('history.title')}>
40
+ <div className="cga-history-dialog__head">
41
+ <span className="cga-history-dialog__title">{t('history.title')}</span>
42
+ <button type="button" className="cga-app__head-close" aria-label={t('app.close')} onClick={onClose}>
43
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
44
+ <path d="M4 4l8 8M12 4l-8 8" />
45
+ </svg>
46
+ </button>
47
+ </div>
48
+ <div className="cga-history-dialog__body">
49
+ <HistoryPanel ports={ports} t={t} activeKind={page} onReopen={onReopen} />
50
+ </div>
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ export function ComponentGenApp(props: ComponentGenAppProps): ReactElement {
57
+ const { ports, page, lang, onClose } = props
58
+ const t: Translate = useMemo(() => translateFor(lang), [lang])
59
+ const [historyOpen, setHistoryOpen] = useState(false)
60
+ const [reopenReq, setReopenReq] = useState<ReopenRequest | null>(null)
61
+ // Only forward a reopen request whose kind matches the active page (history
62
+ // is already filtered by activeKind, but the request must not leak across a
63
+ // tab switch).
64
+ const pageReopen = reopenReq && reopenReq.entry.kind === page ? reopenReq : null
65
+
66
+ const openHistory = (entry: ReopenRequest['entry']): void => {
67
+ // Reopening lands back in the workspace, so drop the dialog too.
68
+ setHistoryOpen(false)
69
+ setReopenReq((prev) => ({ n: (prev?.n ?? 0) + 1, entry }))
70
+ }
71
+
72
+ return (
73
+ <div className="cga-app">
74
+ <div className="cga-app__head">
75
+ <span className="cga-app__head-title">
76
+ {page === 'symbol' ? t('app.symbolTitle') : t('app.footprintTitle')}
77
+ </span>
78
+ <div className="cga-app__head-actions">
79
+ <button type="button" className="cga-btn cga-btn--outline" onClick={() => setHistoryOpen(true)}>
80
+ {t('history.view')}
81
+ </button>
82
+ {onClose ? (
83
+ <button type="button" className="cga-app__head-close" aria-label={t('app.close')} onClick={onClose}>
84
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
85
+ <path d="M4 4l8 8M12 4l-8 8" />
86
+ </svg>
87
+ </button>
88
+ ) : null}
89
+ </div>
90
+ </div>
91
+
92
+ {page === 'symbol'
93
+ ? <SymbolGenPage ports={ports} t={t} reopen={pageReopen} />
94
+ : <FootprintGenPage ports={ports} t={t} reopen={pageReopen} />}
95
+
96
+ {historyOpen
97
+ ? <HistoryDialog ports={ports} page={page} t={t} onReopen={openHistory} onClose={() => setHistoryOpen(false)} />
98
+ : null}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — the single HTTP/SSE client implementing
3
+ * `ComponentGenPorts` against the component-gen server API.
4
+ *
5
+ * The base is injectable: the DSH adapter points it at the same-origin
6
+ * `/api/v1/huaqiu/component-gen` (plugin webServer route), the standalone
7
+ * adapter points it at `http://localhost:<port>/api/v1/huaqiu/component-gen`.
8
+ * No CORS needed in either case — generation is server-side and same-origin.
9
+ */
10
+ import type {
11
+ ComponentGenAuthPort,
12
+ ComponentGenConfig,
13
+ ComponentGenPorts,
14
+ HistoryEntry,
15
+ HistoryPage,
16
+ HistoryPatch,
17
+ HistoryQuery,
18
+ JobEvent,
19
+ JobState,
20
+ StartJobRequest,
21
+ } from '../ports.js'
22
+
23
+ const DEFAULT_LIMITS = { imageBytes: 4 * 1024 * 1024 }
24
+
25
+ async function readJson<T>(res: Response): Promise<T> {
26
+ if (!res.ok) {
27
+ let detail = `HTTP ${res.status}`
28
+ try {
29
+ const body = (await res.json()) as { error?: string; detail?: unknown }
30
+ if (body?.error) detail = body.error
31
+ if (body?.detail) detail = `${detail}: ${String(body.detail)}`
32
+ } catch {
33
+ /* non-JSON error body — keep the status message */
34
+ }
35
+ throw new Error(detail)
36
+ }
37
+ return (await res.json()) as T
38
+ }
39
+
40
+ export interface HttpPortsOptions {
41
+ /** component-gen API base, e.g. `/api/v1/huaqiu/component-gen`. */
42
+ base: string
43
+ /** artifacts API base, e.g. `/api/v1/huaqiu/artifacts` (same origin). */
44
+ artifactsBase?: string
45
+ doFetch?: typeof fetch
46
+ auth?: ComponentGenAuthPort
47
+ }
48
+
49
+ /** The shared fetch client. */
50
+ export function createHttpPorts(options: HttpPortsOptions): ComponentGenPorts {
51
+ const base = options.base.replace(/\/+$/, '')
52
+ const doFetch = options.doFetch ?? globalThis.fetch.bind(globalThis)
53
+ // Artifacts live under their own prefix on the same origin; derive it from
54
+ // `base` unless the host overrides it.
55
+ const artifactsBase = (options.artifactsBase ?? defaultArtifactsBase(base)).replace(/\/+$/, '')
56
+
57
+ const url = (p: string): string => `${base}${p}`
58
+ const artifactUrl = (p: string): string => `${artifactsBase}${p}`
59
+
60
+ return {
61
+ async config(): Promise<ComponentGenConfig> {
62
+ const res = await doFetch(url('/config'), { headers: { accept: 'application/json' } })
63
+ const cfg = await readJson<Partial<ComponentGenConfig>>(res)
64
+ return {
65
+ hostMode: cfg.hostMode ?? false,
66
+ capabilities: cfg.capabilities ?? { symbol: true, footprint: true },
67
+ limits: { ...DEFAULT_LIMITS, ...(cfg.limits ?? {}) },
68
+ }
69
+ },
70
+
71
+ async startJob(req: StartJobRequest, signal?: AbortSignal): Promise<JobState> {
72
+ const res = await doFetch(url('/jobs'), {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify(req),
76
+ signal,
77
+ })
78
+ if (res.status === 202) {
79
+ const body = (await res.json()) as { jobId?: string }
80
+ // Return a minimal queued JobState; the caller follows /jobs/:id.
81
+ return {
82
+ id: String(body.jobId ?? ''),
83
+ kind: req.kind,
84
+ status: 'queued',
85
+ createdAt: new Date().toISOString(),
86
+ updatedAt: new Date().toISOString(),
87
+ }
88
+ }
89
+ return readJson<JobState>(res)
90
+ },
91
+
92
+ jobEvents(jobId: string, onEvent: (e: JobEvent) => void): () => void {
93
+ const controller = new AbortController()
94
+ const run = async (): Promise<void> => {
95
+ try {
96
+ const res = await doFetch(url(`/jobs/${encodeURIComponent(jobId)}/events`), {
97
+ headers: { accept: 'text/event-stream' },
98
+ signal: controller.signal,
99
+ })
100
+ if (!res.ok || !res.body) {
101
+ onEvent({ type: 'failed', error: `events HTTP ${res.status}`, at: new Date().toISOString() })
102
+ return
103
+ }
104
+ const reader = res.body.getReader()
105
+ const decoder = new TextDecoder()
106
+ let buf = ''
107
+ for (;;) {
108
+ const { done, value } = await reader.read()
109
+ if (done) break
110
+ buf += decoder.decode(value, { stream: true })
111
+ let idx: number
112
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
113
+ const frame = buf.slice(0, idx)
114
+ buf = buf.slice(idx + 2)
115
+ const event = parseEvent(frame)
116
+ if (event) onEvent(event)
117
+ }
118
+ }
119
+ } catch (err) {
120
+ if ((err as Error)?.name === 'AbortError') return
121
+ onEvent({ type: 'failed', error: String((err as Error)?.message || err), at: new Date().toISOString() })
122
+ }
123
+ }
124
+ void run()
125
+ return () => controller.abort()
126
+ },
127
+
128
+ async abortJob(jobId: string): Promise<void> {
129
+ await doFetch(url(`/jobs/${encodeURIComponent(jobId)}`), { method: 'DELETE' })
130
+ },
131
+
132
+ async history(query: HistoryQuery): Promise<HistoryPage> {
133
+ const params = new URLSearchParams()
134
+ if (query.limit !== undefined) params.set('limit', String(query.limit))
135
+ if (query.cursor) params.set('cursor', query.cursor)
136
+ const qs = params.toString()
137
+ const res = await doFetch(url(`/history${qs ? `?${qs}` : ''}`), { headers: { accept: 'application/json' } })
138
+ return readJson<HistoryPage>(res)
139
+ },
140
+
141
+ async historyEntry(id: string): Promise<HistoryEntry | null> {
142
+ const res = await doFetch(url(`/history/${encodeURIComponent(id)}`), { headers: { accept: 'application/json' } })
143
+ if (res.status === 404) return null
144
+ return readJson<HistoryEntry>(res)
145
+ },
146
+
147
+ async patchHistory(id: string, patch: HistoryPatch): Promise<HistoryEntry> {
148
+ const res = await doFetch(url(`/history/${encodeURIComponent(id)}`), {
149
+ method: 'PATCH',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify(patch),
152
+ })
153
+ return readJson<HistoryEntry>(res)
154
+ },
155
+
156
+ async deleteHistory(id: string): Promise<void> {
157
+ await doFetch(url(`/history/${encodeURIComponent(id)}`), { method: 'DELETE' })
158
+ },
159
+
160
+ async artifactContent(artifactId: string): Promise<string> {
161
+ const res = await doFetch(artifactUrl(`/${encodeURIComponent(artifactId)}/content`), {
162
+ headers: { accept: 'text/plain' },
163
+ })
164
+ if (!res.ok) throw new Error(`artifact content HTTP ${res.status}`)
165
+ return res.text()
166
+ },
167
+
168
+ async inputImage(imageId: string): Promise<string> {
169
+ const res = await doFetch(url(`/history/${encodeURIComponent(imageId)}/image`), {
170
+ headers: { accept: 'image/*' },
171
+ })
172
+ if (!res.ok) throw new Error(`input image HTTP ${res.status}`)
173
+ const blob = await res.blob()
174
+ return new Promise((resolve, reject) => {
175
+ const reader = new FileReader()
176
+ reader.onload = () => resolve(String(reader.result))
177
+ reader.onerror = () => reject(new Error('failed to read input image'))
178
+ reader.readAsDataURL(blob)
179
+ })
180
+ },
181
+
182
+ auth: options.auth ?? createPassthroughAuth(),
183
+ }
184
+ }
185
+
186
+ /** Derive the artifacts base from the component-gen base path. */
187
+ export function defaultArtifactsBase(componentGenBase: string): string {
188
+ // /api/v1/huaqiu/component-gen → /api/v1/huaqiu/artifacts
189
+ return componentGenBase.replace(/\/component-gen\/?$/, '/artifacts')
190
+ }
191
+
192
+ /** Parse one SSE frame (event: / data: lines) into a JobEvent. */
193
+ export function parseEvent(frame: string): JobEvent | null {
194
+ let eventName = 'message'
195
+ const dataLines: string[] = []
196
+ for (const line of frame.split('\n')) {
197
+ if (line.startsWith('event:')) eventName = line.slice(6).trim()
198
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
199
+ }
200
+ const data = dataLines.join('\n')
201
+ if (!data) return null
202
+ try {
203
+ const raw = JSON.parse(data) as JobEvent
204
+ if (eventName === 'needs_confirmation' && 'dimensions' in raw) return raw as JobEvent
205
+ if (eventName === 'completed' && 'job' in raw) return raw as JobEvent
206
+ if (eventName === 'failed') return raw as JobEvent
207
+ if (eventName === 'cancelled') return raw as JobEvent
208
+ return raw as JobEvent
209
+ } catch {
210
+ return null
211
+ }
212
+ }
213
+
214
+ /** Default auth port: optimistic (used when the host supplies no auth). */
215
+ function createPassthroughAuth(): ComponentGenAuthPort {
216
+ return {
217
+ async isAuthenticated() { return true },
218
+ async getUserInfo() { return null },
219
+ async login() { /* no-op */ },
220
+ onAuthStateChanged() { return () => {} },
221
+ }
222
+ }
223
+
224
+ /** Keep the exported type visible for adapters. */
225
+ export type { HistoryEntry, JobState }