@huaqiu/dsh-tool-schematic-gen 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.
- package/LICENSE +21 -0
- package/README.md +8 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +106004 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +35 -0
- package/lib/index.mjs +1439 -0
- package/package.json +58 -0
- package/src/client/ecad.ts +147 -0
- package/src/client/hit-card.tsx +378 -0
- package/src/client/i18n.ts +148 -0
- package/src/client/index.ts +176 -0
- package/src/client/login-url.ts +91 -0
- package/src/client/parse.ts +153 -0
- package/src/client/progress.ts +161 -0
- package/src/client/stack-frame.tsx +269 -0
- package/src/client/theme.ts +228 -0
- package/src/client/trace-names.ts +308 -0
- package/src/client/trace-tree.ts +185 -0
- package/src/config.ts +203 -0
- package/src/index.ts +130 -0
- package/src/progress.ts +333 -0
- package/src/routes.ts +80 -0
- package/src/sse.ts +448 -0
- package/src/tools.ts +590 -0
- package/src/trace.ts +336 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the live-progress channel.
|
|
3
|
+
*
|
|
4
|
+
* The node half writes progress into a `ProgressStore` keyed by the tool call
|
|
5
|
+
* id and serves it from a same-origin route; this module polls that route and
|
|
6
|
+
* hands the result to the card. Same-origin, so no token and no CORS.
|
|
7
|
+
*
|
|
8
|
+
* Polling (rather than SSE) is deliberate: the route already exists for the
|
|
9
|
+
* artifact flow, the payload is tiny, and a poll survives a dropped
|
|
10
|
+
* connection without any reconnect logic. The interval is slow because a run
|
|
11
|
+
* lasts minutes, not seconds.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import type { ProgressDoc } from '../progress.js'
|
|
15
|
+
|
|
16
|
+
/** Must match `PROGRESS_ROUTE_PREFIX` in src/routes.ts. */
|
|
17
|
+
export const PROGRESS_ROUTE_PREFIX = '/api/v1/huaqiu/schematic-gen/progress'
|
|
18
|
+
|
|
19
|
+
/** Steady-state poll interval. A run takes minutes; sub-second polling is waste. */
|
|
20
|
+
export const POLL_INTERVAL_MS = 1500
|
|
21
|
+
|
|
22
|
+
/** Longest interval we will back off to after repeated failures. */
|
|
23
|
+
export const MAX_BACKOFF_MS = 15_000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How long to keep asking when the route answers 404.
|
|
27
|
+
*
|
|
28
|
+
* A 404 is normal for the first second or two (the tool body may still be
|
|
29
|
+
* resolving the eda.cn account before it registers), but if it never registers
|
|
30
|
+
* we must stop rather than poll the app for the next ten minutes.
|
|
31
|
+
*/
|
|
32
|
+
export const MISSING_GIVE_UP_MS = 120_000
|
|
33
|
+
|
|
34
|
+
export type ProgressPhase = 'idle' | 'loading' | 'gave-up' | 'live' | 'missing' | 'error'
|
|
35
|
+
|
|
36
|
+
export interface ProgressState {
|
|
37
|
+
phase: ProgressPhase
|
|
38
|
+
doc: ProgressDoc | null
|
|
39
|
+
error: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const IDLE: ProgressState = { phase: 'idle', doc: null, error: null }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Poll one run's progress while `active`.
|
|
46
|
+
*
|
|
47
|
+
* Stops as soon as the run reports a terminal status, and backs off on
|
|
48
|
+
* transport failure so a missing/unreachable route cannot spin the UI.
|
|
49
|
+
*/
|
|
50
|
+
export function useProgress(callId: string | undefined, active: boolean): ProgressState {
|
|
51
|
+
const [state, setState] = useState<ProgressState>(IDLE)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!active || !callId) {
|
|
55
|
+
setState(IDLE)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let cancelled = false
|
|
60
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
let delay = POLL_INTERVAL_MS
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
const startedAt = Date.now()
|
|
64
|
+
let missingSince: number | null = null
|
|
65
|
+
|
|
66
|
+
const schedule = (): void => {
|
|
67
|
+
if (cancelled) return
|
|
68
|
+
timer = setTimeout(run, delay)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const run = async (): Promise<void> => {
|
|
72
|
+
if (cancelled) return
|
|
73
|
+
try {
|
|
74
|
+
const url = `${PROGRESS_ROUTE_PREFIX}/${encodeURIComponent(callId)}`
|
|
75
|
+
const res = await fetch(url, {
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
headers: { accept: 'application/json' },
|
|
78
|
+
credentials: 'same-origin',
|
|
79
|
+
})
|
|
80
|
+
if (cancelled) return
|
|
81
|
+
|
|
82
|
+
if (res.status === 404) {
|
|
83
|
+
// The run has not registered yet (the tool body may still be
|
|
84
|
+
// resolving the account) or has already been swept. Keep trying
|
|
85
|
+
// for a bounded window — just slower.
|
|
86
|
+
if (missingSince === null) missingSince = Date.now()
|
|
87
|
+
if (Date.now() - missingSince > MISSING_GIVE_UP_MS) {
|
|
88
|
+
setState({ phase: 'gave-up', doc: null, error: null })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
delay = Math.min(Math.round(delay * 1.5), MAX_BACKOFF_MS)
|
|
92
|
+
setState({ phase: 'missing', doc: null, error: null })
|
|
93
|
+
schedule()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (!res.ok) throw new Error(`progress route returned HTTP ${res.status}`)
|
|
97
|
+
|
|
98
|
+
const doc = (await res.json()) as ProgressDoc
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
delay = POLL_INTERVAL_MS
|
|
101
|
+
missingSince = null
|
|
102
|
+
setState({ phase: 'live', doc, error: null })
|
|
103
|
+
// Terminal — the card has the real result now; stop polling.
|
|
104
|
+
if (doc.status !== 'running') return
|
|
105
|
+
schedule()
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (cancelled || controller.signal.aborted) return
|
|
108
|
+
// Same bounded window as the 404 path: a route that is simply absent
|
|
109
|
+
// must not spin the UI for the length of the run.
|
|
110
|
+
if (Date.now() - startedAt > MISSING_GIVE_UP_MS) {
|
|
111
|
+
setState({ phase: 'gave-up', doc: null, error: String((err as Error)?.message || err) })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
delay = Math.min(Math.round(delay * 1.5), MAX_BACKOFF_MS)
|
|
115
|
+
setState({ phase: 'error', doc: null, error: String((err as Error)?.message || err) })
|
|
116
|
+
schedule()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setState({ phase: 'loading', doc: null, error: null })
|
|
121
|
+
void run()
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true
|
|
125
|
+
if (timer) clearTimeout(timer)
|
|
126
|
+
controller.abort()
|
|
127
|
+
}
|
|
128
|
+
}, [callId, active])
|
|
129
|
+
|
|
130
|
+
return state
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Shared ticker that re-renders on a fixed cadence while `active`.
|
|
135
|
+
*
|
|
136
|
+
* Lifted out of the stack renderer (a documented limitation of the
|
|
137
|
+
* hq-eda-ai original, which owned its `setInterval` inside the list
|
|
138
|
+
* component) so every frame at any depth can show a live duration without
|
|
139
|
+
* each one running its own timer.
|
|
140
|
+
*/
|
|
141
|
+
export function useNow(active: boolean, intervalMs = 200): number {
|
|
142
|
+
const [now, setNow] = useState<number>(() => Date.now())
|
|
143
|
+
const intervalRef = useRef(intervalMs)
|
|
144
|
+
intervalRef.current = intervalMs
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!active) return
|
|
148
|
+
setNow(Date.now())
|
|
149
|
+
const id = setInterval(() => setNow(Date.now()), intervalRef.current)
|
|
150
|
+
return () => clearInterval(id)
|
|
151
|
+
}, [active])
|
|
152
|
+
|
|
153
|
+
return now
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Wall-clock elapsed time since `startedAt`, ticking while `active`. */
|
|
157
|
+
export function useElapsed(startedAt: number | null | undefined, active: boolean): number {
|
|
158
|
+
const now = useNow(active, 500)
|
|
159
|
+
if (startedAt === null || startedAt === undefined) return 0
|
|
160
|
+
return Math.max(0, now - startedAt)
|
|
161
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live call-stack renderer (browser half).
|
|
3
|
+
*
|
|
4
|
+
* Port of `hq-eda-ai`'s prototype
|
|
5
|
+
* (`docs/prototype/tool_call_stack_prototype.tsx`) onto the paired trace
|
|
6
|
+
* frames our node half produces, plus the coarse stage ladder that keeps the
|
|
7
|
+
* user informed even when the backend emits no trace events at all.
|
|
8
|
+
*
|
|
9
|
+
* Styling goes through the classes in `theme.js` — client bundles here have no
|
|
10
|
+
* CSS pipeline, so the stylesheet is injected once as a `<style>` tag and
|
|
11
|
+
* every visual decision lives in a DSH design token with a literal fallback.
|
|
12
|
+
*/
|
|
13
|
+
import { memo, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
14
|
+
import type { ProgressDoc, ProgressNote, RunKind, TodoItem } from '../progress.js'
|
|
15
|
+
import { buildTree, countStatus, formatDuration, formatElapsed, type StackNode } from './trace-tree.js'
|
|
16
|
+
import { useElapsed, useNow, useProgress } from './progress.js'
|
|
17
|
+
import { useTraceNames } from './trace-names.js'
|
|
18
|
+
import type { Translate } from './i18n.js'
|
|
19
|
+
|
|
20
|
+
// ── one frame ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function dotClass(status: string): string {
|
|
23
|
+
if (status === 'finished') {
|
|
24
|
+
return 'hq-sch__frame-dot hq-sch__frame-dot--finished'
|
|
25
|
+
}
|
|
26
|
+
if (status === 'failed') {
|
|
27
|
+
return 'hq-sch__frame-dot hq-sch__frame-dot--failed'
|
|
28
|
+
}
|
|
29
|
+
return 'hq-sch__frame-dot hq-sch__frame-dot--running'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FrameProps {
|
|
33
|
+
node: StackNode
|
|
34
|
+
now: number
|
|
35
|
+
running: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Identifier → display name. The stack otherwise renders the raw backend
|
|
38
|
+
* identifiers (`node:schematicDesign`, `es_rag_search`) verbatim.
|
|
39
|
+
*/
|
|
40
|
+
resolve: (name: string) => string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const StackFrame = memo(function StackFrame({ node, now, running, resolve }: FrameProps): ReactElement {
|
|
44
|
+
const [open, setOpen] = useState(true)
|
|
45
|
+
const hasChildren = node.children.length > 0
|
|
46
|
+
const counts = countStatus(node)
|
|
47
|
+
const label = resolve(node.name)
|
|
48
|
+
|
|
49
|
+
const isRunning = node.status === 'running'
|
|
50
|
+
const endedAt = node.finishedAt
|
|
51
|
+
const duration = formatDuration(
|
|
52
|
+
endedAt != null ? endedAt - node.startedAt : (running && isRunning ? now - node.startedAt : null),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const nameClass = isRunning
|
|
56
|
+
? 'hq-sch__frame-name hq-sch__frame-name--running'
|
|
57
|
+
: 'hq-sch__frame-name hq-sch__frame-name--done'
|
|
58
|
+
|
|
59
|
+
const row = (
|
|
60
|
+
<div
|
|
61
|
+
className={hasChildren ? 'hq-sch__frame-row' : 'hq-sch__frame-row hq-sch__frame-row--leaf'}
|
|
62
|
+
onClick={hasChildren ? () => setOpen((v) => !v) : undefined}
|
|
63
|
+
role={hasChildren ? 'button' : undefined}
|
|
64
|
+
aria-expanded={hasChildren ? open : undefined}
|
|
65
|
+
>
|
|
66
|
+
<span className="hq-sch__frame-chev">{hasChildren ? (open ? '▾' : '▸') : ''}</span>
|
|
67
|
+
<span className={dotClass(node.status)} />
|
|
68
|
+
<span className={nameClass} title={label}>{label}</span>
|
|
69
|
+
<span className="hq-sch__frame-meta">
|
|
70
|
+
{node.repeat > 1 ? <span className="hq-sch__frame-count">×{node.repeat}</span> : null}
|
|
71
|
+
{hasChildren && counts.total > 0
|
|
72
|
+
? <span className="hq-sch__frame-count">{counts.finished}/{counts.total}</span>
|
|
73
|
+
: null}
|
|
74
|
+
{duration ? <span>{duration}</span> : null}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (!hasChildren || !open) {
|
|
80
|
+
return row
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div>
|
|
85
|
+
{row}
|
|
86
|
+
<div className="hq-sch__frame-children">
|
|
87
|
+
{node.children.map((child) => (
|
|
88
|
+
<StackFrame key={child.id} node={child} now={now} running={running} resolve={resolve} />
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ── the stack ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The frame list, auto-scrolled so the deepest running span stays visible.
|
|
99
|
+
*
|
|
100
|
+
* A 10-minute run can produce hundreds of frames; without this the container
|
|
101
|
+
* (capped at 320px) would sit pinned at the top showing work finished minutes
|
|
102
|
+
* ago.
|
|
103
|
+
*/
|
|
104
|
+
function StackList(
|
|
105
|
+
{ nodes, now, running, resolve }: { nodes: StackNode[]; now: number; running: boolean; resolve: (name: string) => string },
|
|
106
|
+
): ReactElement {
|
|
107
|
+
const ref = useRef<HTMLDivElement | null>(null)
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!running) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const el = ref.current
|
|
114
|
+
if (!el) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
el.scrollTop = el.scrollHeight
|
|
118
|
+
}, [nodes, running])
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="hq-sch__stack" ref={ref}>
|
|
122
|
+
{nodes.map((node) => (
|
|
123
|
+
<StackFrame key={node.id} node={node} now={now} running={running} resolve={resolve} />
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── stage ladder ──────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function stepClass(index: number, active: number, failed: boolean): string {
|
|
132
|
+
const base = 'hq-sch__ladder-step'
|
|
133
|
+
if (index < active) {
|
|
134
|
+
return `${base} hq-sch__ladder-step--done`
|
|
135
|
+
}
|
|
136
|
+
if (index > active) {
|
|
137
|
+
return base
|
|
138
|
+
}
|
|
139
|
+
return failed ? `${base} hq-sch__ladder-step--failed` : `${base} hq-sch__ladder-step--active`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function StageLadder({ stage, failed }: { stage: ProgressDoc['stage']; failed: boolean }): ReactElement | null {
|
|
143
|
+
if (!stage || stage.total <= 0) {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
const steps: ReactElement[] = []
|
|
147
|
+
for (let i = 0; i < stage.total; i++) {
|
|
148
|
+
steps.push(<span key={i} className={stepClass(i, stage.index, failed)} />)
|
|
149
|
+
}
|
|
150
|
+
return <div className="hq-sch__ladder">{steps}</div>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── system-design extras ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The agent's own words about what it is doing.
|
|
157
|
+
*
|
|
158
|
+
* `modular_circuit` publishes these from its `emitWorkflowProgress`
|
|
159
|
+
* middleware — one `start` announcement when a stage opens and one `complete`
|
|
160
|
+
* when it closes. It is the only narrative signal the system agent gives us,
|
|
161
|
+
* and it is what makes a 10-minute wait feel attended to.
|
|
162
|
+
*/
|
|
163
|
+
function NoteLine({ note }: { note: ProgressNote }): ReactElement | null {
|
|
164
|
+
if (!note.message) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const cls = note.phase === 'error'
|
|
168
|
+
? 'hq-sch__note-line hq-sch__note-line--error'
|
|
169
|
+
: note.phase === 'complete'
|
|
170
|
+
? 'hq-sch__note-line hq-sch__note-line--complete'
|
|
171
|
+
: 'hq-sch__note-line'
|
|
172
|
+
return (
|
|
173
|
+
<div className={cls}>
|
|
174
|
+
{note.phase === 'start' ? <span className="hq-sch__note-spin" /> : null}
|
|
175
|
+
<span>{note.message}</span>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Mark glyph per todo status — no icon dependency in a client bundle. */
|
|
181
|
+
function todoMark(status: TodoItem['status']): string {
|
|
182
|
+
if (status === 'completed') {
|
|
183
|
+
return '✓'
|
|
184
|
+
}
|
|
185
|
+
if (status === 'in_progress') {
|
|
186
|
+
return '▸'
|
|
187
|
+
}
|
|
188
|
+
return '·'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function TodoList({ todos }: { todos: TodoItem[] }): ReactElement | null {
|
|
192
|
+
if (todos.length === 0) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<ul className="hq-sch__todos">
|
|
197
|
+
{todos.map((todo, i) => (
|
|
198
|
+
<li key={i} className={`hq-sch__todo hq-sch__todo--${todo.status}`}>
|
|
199
|
+
<span className="hq-sch__todo-mark">{todoMark(todo.status)}</span>
|
|
200
|
+
<span className="hq-sch__todo-text">{todo.content}</span>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
</ul>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── the whole live block ──────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export interface LiveProgressProps {
|
|
210
|
+
/**
|
|
211
|
+
* Tool call id. This is the SAME string `defineTool`'s `ToolRunContext`
|
|
212
|
+
* hands the node half, so it is what correlates node progress to this card.
|
|
213
|
+
*/
|
|
214
|
+
callId?: string
|
|
215
|
+
kind: RunKind
|
|
216
|
+
t: Translate
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Progress surface for a run that has not settled yet.
|
|
221
|
+
*
|
|
222
|
+
* Three layers, each independent, so the user always sees *something*:
|
|
223
|
+
* 1. A label naming the current stage, plus a live `m:ss` timer.
|
|
224
|
+
* 2. A coarse ladder derived from the agent's own state keys — works even
|
|
225
|
+
* when the backend emits no trace events at all.
|
|
226
|
+
* 3. The real call stack, when trace events do arrive.
|
|
227
|
+
*/
|
|
228
|
+
export function LiveProgress({ callId, kind, t }: LiveProgressProps): ReactElement {
|
|
229
|
+
const { doc } = useProgress(callId, true)
|
|
230
|
+
|
|
231
|
+
const running = !doc || doc.status === 'running'
|
|
232
|
+
const now = useNow(running, 250)
|
|
233
|
+
|
|
234
|
+
// Before the first poll lands there is no server timestamp; fall back to the
|
|
235
|
+
// moment this card mounted so the timer is never stuck at 0:00.
|
|
236
|
+
const mountedAtRef = useRef<number>(Date.now())
|
|
237
|
+
const startedAt = doc?.startedAt ?? mountedAtRef.current
|
|
238
|
+
const elapsed = useElapsed(startedAt, running)
|
|
239
|
+
|
|
240
|
+
const tree = useMemo(() => {
|
|
241
|
+
// `doc.frames` is a fresh array on every poll, so depend on `doc` itself.
|
|
242
|
+
void kind
|
|
243
|
+
return buildTree(doc?.frames ?? [])
|
|
244
|
+
}, [doc, kind])
|
|
245
|
+
|
|
246
|
+
// The stack renders the backend's own identifiers (`node:schematicDesign`,
|
|
247
|
+
// `es_rag_search`). Resolve them to copy in the host UI's language. The
|
|
248
|
+
// document knows the real run kind; the prop is only the caller's guess.
|
|
249
|
+
const resolve = useTraceNames(doc?.kind ?? kind)
|
|
250
|
+
|
|
251
|
+
const label = doc?.stage
|
|
252
|
+
? t(`card.stage.${doc.stage.key}`)
|
|
253
|
+
: t('card.progress.waiting')
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div>
|
|
257
|
+
<div className="hq-sch__progress">
|
|
258
|
+
<span>{label}</span>
|
|
259
|
+
<span className="hq-sch__progress-timer">{formatElapsed(elapsed)}</span>
|
|
260
|
+
</div>
|
|
261
|
+
{doc?.note ? <NoteLine note={doc.note} /> : null}
|
|
262
|
+
<StageLadder stage={doc?.stage ?? null} failed={doc?.status === 'failed'} />
|
|
263
|
+
{doc?.todos && doc.todos.length > 0 ? <TodoList todos={doc.todos} /> : null}
|
|
264
|
+
{tree.length > 0
|
|
265
|
+
? <StackList nodes={tree} now={now} running={running} resolve={resolve} />
|
|
266
|
+
: null}
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host UI environment (theme + locale) store and the scoped stylesheet for
|
|
3
|
+
* the schematic/system HIT card.
|
|
4
|
+
*
|
|
5
|
+
* The DSH slot system injects React components with PROPS, not the cordis ctx,
|
|
6
|
+
* so the card cannot reach `ctx.theme` / `ctx.locale` the way a plugin body
|
|
7
|
+
* can. Both services do, however, publish their state into the DOM, and that
|
|
8
|
+
* is what this module reads:
|
|
9
|
+
*
|
|
10
|
+
* - THEME — `ui-layout`'s theme presenter toggles `body[data-ds-dark-theme]`
|
|
11
|
+
* from the resolved snapshot, so the attribute's presence IS the dark
|
|
12
|
+
* palette. `prefers-color-scheme` is deliberately NOT consulted: DSH
|
|
13
|
+
* resolves `system` itself, and an OS-dark / DSH-light combination would
|
|
14
|
+
* then be misdetected.
|
|
15
|
+
* - LOCALE — `dsh-client-locale` writes `<html lang>` on every locale change
|
|
16
|
+
* (`zh-CN` | `en`). `navigator.languages` is the fallback for hosts without
|
|
17
|
+
* that plugin; zh is the last resort because this is a Chinese-first app.
|
|
18
|
+
*
|
|
19
|
+
* Stylesheet: uniquely-prefixed classes only, token colors, no !important, no
|
|
20
|
+
* DSH-internal selectors. The style tag is removed on dispose.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useState } from 'react'
|
|
23
|
+
import { LOGIN_IFRAME_HEIGHT, type AuthLocale } from './login-url.js'
|
|
24
|
+
|
|
25
|
+
export const PLUGIN_ID = '@huaqiu/dsh-tool-schematic-gen'
|
|
26
|
+
export const STYLE_ID = 'hq-schematic-genhit-styles'
|
|
27
|
+
|
|
28
|
+
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
|
29
|
+
|
|
30
|
+
function isDark(): boolean {
|
|
31
|
+
return !!(typeof document !== 'undefined' && document.body && document.body.hasAttribute(DARK_ATTRIBUTE))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `zh-CN`, `zh-Hans`, `en-GB`, … → our locale id (`undefined` = unknown). */
|
|
35
|
+
function localeFromTag(tag: string | null | undefined): AuthLocale | undefined {
|
|
36
|
+
if (!tag) return undefined
|
|
37
|
+
const primary = tag.toLowerCase().split('-')[0]
|
|
38
|
+
return primary === 'zh' || primary === 'en' ? primary : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detectLocale(): AuthLocale {
|
|
42
|
+
if (typeof document !== 'undefined') {
|
|
43
|
+
const fromDocument = localeFromTag(document.documentElement?.getAttribute('lang'))
|
|
44
|
+
if (fromDocument) return fromDocument
|
|
45
|
+
}
|
|
46
|
+
if (typeof navigator !== 'undefined' && typeof window !== 'undefined') {
|
|
47
|
+
// `window` is the browser test: Node exposes a global `navigator`
|
|
48
|
+
// reporting the machine's own language, which would otherwise decide the
|
|
49
|
+
// locale for non-browser runs.
|
|
50
|
+
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
|
|
51
|
+
const match = localeFromTag(tag)
|
|
52
|
+
if (match) return match
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 'zh'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const listeners = new Set<() => void>()
|
|
59
|
+
let themeState = { dark: isDark(), locale: detectLocale() }
|
|
60
|
+
let themeObserver: MutationObserver | null = null
|
|
61
|
+
let localeObserver: MutationObserver | null = null
|
|
62
|
+
|
|
63
|
+
function notify(): void {
|
|
64
|
+
for (const fn of [...listeners]) {
|
|
65
|
+
try { fn() } catch { /* one bad subscriber must not strand the rest */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Re-read the DOM and notify only when something actually changed. */
|
|
70
|
+
function sync(): void {
|
|
71
|
+
let changed = false
|
|
72
|
+
const dark = isDark()
|
|
73
|
+
if (dark !== themeState.dark) {
|
|
74
|
+
themeState.dark = dark
|
|
75
|
+
changed = true
|
|
76
|
+
}
|
|
77
|
+
const locale = detectLocale()
|
|
78
|
+
if (locale !== themeState.locale) {
|
|
79
|
+
themeState.locale = locale
|
|
80
|
+
changed = true
|
|
81
|
+
}
|
|
82
|
+
if (changed) notify()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureThemeObserver(): void {
|
|
86
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
|
|
87
|
+
if (!themeObserver && document.body) {
|
|
88
|
+
themeObserver = new MutationObserver(sync)
|
|
89
|
+
themeObserver.observe(document.body, { attributes: true, attributeFilter: [DARK_ATTRIBUTE] })
|
|
90
|
+
}
|
|
91
|
+
if (!localeObserver && document.documentElement) {
|
|
92
|
+
localeObserver = new MutationObserver(sync)
|
|
93
|
+
localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useTheme(): boolean {
|
|
98
|
+
// Read fresh on every mount. With React 18's batched mount, multiple cards
|
|
99
|
+
// capture the module-level snapshot at the same moment; the *first* one's
|
|
100
|
+
// effect then flips `themeState` via `sync()`, but only that one card ever
|
|
101
|
+
// re-renders because the others' `useState` has already locked onto the
|
|
102
|
+
// initial value. Reading the DOM directly here costs one extra attribute
|
|
103
|
+
// lookup per mount and fixes the silent pinning.
|
|
104
|
+
const [dark, setDark] = useState(() => isDark())
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
ensureThemeObserver()
|
|
107
|
+
setDark(isDark())
|
|
108
|
+
const onChange = () => setDark(themeState.dark)
|
|
109
|
+
listeners.add(onChange)
|
|
110
|
+
sync()
|
|
111
|
+
return () => { listeners.delete(onChange) }
|
|
112
|
+
}, [])
|
|
113
|
+
return dark
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The host UI language (`zh` by default). */
|
|
117
|
+
export function useLocale(): AuthLocale {
|
|
118
|
+
// Same fix as `useTheme` — fresh DOM read on mount, otherwise the second
|
|
119
|
+
// and later cards in a chat render in the module-load locale even though
|
|
120
|
+
// DSH wrote `<html lang>` before any of them mounted.
|
|
121
|
+
const [locale, setLocale] = useState(() => detectLocale())
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
ensureThemeObserver()
|
|
124
|
+
setLocale(detectLocale())
|
|
125
|
+
const onChange = () => setLocale(themeState.locale)
|
|
126
|
+
listeners.add(onChange)
|
|
127
|
+
sync()
|
|
128
|
+
return () => { listeners.delete(onChange) }
|
|
129
|
+
}, [])
|
|
130
|
+
return locale
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Synchronous, always-fresh read of the host locale (for non-React callers).
|
|
135
|
+
* Re-reads the DOM rather than returning the cached snapshot, so it is safe
|
|
136
|
+
* to call before any component has subscribed.
|
|
137
|
+
*/
|
|
138
|
+
export function getLocale(): AuthLocale {
|
|
139
|
+
return detectLocale()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function disposeThemeObserver(): void {
|
|
143
|
+
if (themeObserver) {
|
|
144
|
+
themeObserver.disconnect()
|
|
145
|
+
themeObserver = null
|
|
146
|
+
}
|
|
147
|
+
if (localeObserver) {
|
|
148
|
+
localeObserver.disconnect()
|
|
149
|
+
localeObserver = null
|
|
150
|
+
}
|
|
151
|
+
listeners.clear()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const CSS = `
|
|
155
|
+
.hq-sch { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, transparent); overflow: hidden; }
|
|
156
|
+
.hq-sch__header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
|
|
157
|
+
.hq-sch__icon { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, transparent); color: var(--dsw-alias-label-primary, currentColor); }
|
|
158
|
+
.hq-sch__title { font: var(--dsw-font-s-strong-14, 14px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
159
|
+
.hq-sch__status { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; flex: none; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
|
|
160
|
+
.hq-sch__summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 0 12px 10px; }
|
|
161
|
+
.hq-sch__badge { display: inline-flex; align-items: center; max-width: 240px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
162
|
+
.hq-sch__badge--mono { font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
|
|
163
|
+
.hq-sch__stage { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; box-sizing: border-box; height: 420px; background: var(--dsw-alias-markdown-code-block, #0a1929); overflow: hidden; }
|
|
164
|
+
.hq-sch__canvas { display: block; width: 100%; height: 100%; background: var(--dsw-alias-markdown-code-block, #0a1929); }
|
|
165
|
+
.hq-sch__stage-msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px 14px; box-sizing: border-box; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); text-align: center; }
|
|
166
|
+
.hq-sch__error { margin: 0 12px 10px; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-state-error-primary, rgba(220,50,50,0.3)); color: var(--dsw-alias-state-error-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); white-space: pre-wrap; }
|
|
167
|
+
.hq-sch__note { margin: 0 12px 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); white-space: pre-wrap; }
|
|
168
|
+
.hq-sch__actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 12px 12px; }
|
|
169
|
+
.hq-sch__act { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; padding: 5px 12px; background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); cursor: pointer; }
|
|
170
|
+
.hq-sch__act:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
|
|
171
|
+
.hq-sch__act:disabled { opacity: 0.5; cursor: default; }
|
|
172
|
+
.hq-sch__login { padding: 0 12px 12px; }
|
|
173
|
+
.hq-sch__login-desc { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); line-height: 1.5; }
|
|
174
|
+
.hq-sch__login-status { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); line-height: 1.5; }
|
|
175
|
+
.hq-sch__login-iframe { width: 100%; height: ${LOGIN_IFRAME_HEIGHT}px; border: 0; border-radius: 8px; display: block; }
|
|
176
|
+
|
|
177
|
+
/* ── live call stack (long-running generations) ───────────────────────────── */
|
|
178
|
+
.hq-sch__progress { display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
|
|
179
|
+
.hq-sch__progress-timer { margin-left: auto; flex: none; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
|
|
180
|
+
.hq-sch__ladder { display: flex; gap: 3px; padding: 0 12px 4px; }
|
|
181
|
+
.hq-sch__ladder-step { height: 3px; flex: 1 1 0; border-radius: 3px; background: var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); }
|
|
182
|
+
.hq-sch__ladder-step--done { background: var(--dsw-alias-state-success-primary, #188038); }
|
|
183
|
+
.hq-sch__ladder-step--active { background: var(--dsw-alias-state-running-primary, #1a73e8); }
|
|
184
|
+
.hq-sch__ladder-step--failed { background: var(--dsw-alias-state-error-primary, #d93025); }
|
|
185
|
+
.hq-sch__stack { max-height: 320px; overflow-y: auto; padding: 2px 12px 12px; }
|
|
186
|
+
.hq-sch__frame-row { display: flex; align-items: center; gap: 6px; padding: 2px 6px; border-radius: 6px; cursor: pointer; }
|
|
187
|
+
.hq-sch__frame-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
|
|
188
|
+
.hq-sch__frame-row--leaf { cursor: default; }
|
|
189
|
+
.hq-sch__frame-chev { flex: none; width: 12px; font-size: 9px; line-height: 1; color: var(--dsw-alias-label-tertiary, currentColor); }
|
|
190
|
+
.hq-sch__frame-dot { flex: none; width: 6px; height: 6px; border-radius: 6px; background: var(--dsw-alias-border-l1, rgba(127,127,127,0.35)); }
|
|
191
|
+
.hq-sch__frame-dot--running { background: var(--dsw-alias-state-running-primary, #1a73e8); animation: hq-sch-pulse 1.2s ease-in-out infinite; }
|
|
192
|
+
.hq-sch__frame-dot--finished { background: var(--dsw-alias-state-success-primary, #188038); }
|
|
193
|
+
.hq-sch__frame-dot--failed { background: var(--dsw-alias-state-error-primary, #d93025); }
|
|
194
|
+
.hq-sch__frame-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-primary, currentColor); }
|
|
195
|
+
.hq-sch__frame-name--done { color: var(--dsw-alias-label-secondary, currentColor); }
|
|
196
|
+
.hq-sch__frame-name--running { font-weight: 600; }
|
|
197
|
+
.hq-sch__frame-meta { margin-left: auto; flex: none; display: flex; align-items: center; gap: 6px; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 10px; color: var(--dsw-alias-label-tertiary, currentColor); }
|
|
198
|
+
.hq-sch__frame-count { padding: 0 4px; border-radius: 999px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.14)); }
|
|
199
|
+
.hq-sch__frame-children { border-left: 1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.25)); margin-left: 11px; }
|
|
200
|
+
.hq-sch__progress-empty { padding: 2px 12px 12px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, currentColor); }
|
|
201
|
+
|
|
202
|
+
/* ── system-design: stage announcement + live todo list ──────────────────── */
|
|
203
|
+
.hq-sch__note-line { display: flex; align-items: flex-start; gap: 6px; padding: 0 12px 8px; font: var(--dsw-font-xxs-12, 12px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); }
|
|
204
|
+
.hq-sch__note-line--complete { color: var(--dsw-alias-label-secondary, currentColor); }
|
|
205
|
+
.hq-sch__note-line--error { color: var(--dsw-alias-state-error-primary, #d93025); }
|
|
206
|
+
.hq-sch__note-spin { flex: none; width: 8px; height: 8px; margin-top: 4px; border-radius: 8px; background: var(--dsw-alias-state-running-primary, #1a73e8); animation: hq-sch-pulse 1.2s ease-in-out infinite; }
|
|
207
|
+
.hq-sch__todos { list-style: none; margin: 0; padding: 4px 12px 10px; max-height: 168px; overflow-y: auto; }
|
|
208
|
+
.hq-sch__todo { display: flex; align-items: flex-start; gap: 6px; padding: 2px 0; font: var(--dsw-font-xxs-12, 12px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, currentColor); }
|
|
209
|
+
.hq-sch__todo-mark { flex: none; width: 10px; text-align: center; line-height: 1.5; }
|
|
210
|
+
.hq-sch__todo-text { min-width: 0; overflow-wrap: anywhere; }
|
|
211
|
+
.hq-sch__todo--in_progress { color: var(--dsw-alias-label-primary, currentColor); font-weight: 600; }
|
|
212
|
+
.hq-sch__todo--completed { color: var(--dsw-alias-label-secondary, currentColor); text-decoration: line-through; }
|
|
213
|
+
@keyframes hq-sch-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
214
|
+
`
|
|
215
|
+
|
|
216
|
+
export function injectStyles(): void {
|
|
217
|
+
if (document.getElementById(STYLE_ID)) return
|
|
218
|
+
const style = document.createElement('style')
|
|
219
|
+
style.id = STYLE_ID
|
|
220
|
+
style.setAttribute('data-plugin', PLUGIN_ID)
|
|
221
|
+
style.textContent = CSS
|
|
222
|
+
document.head.appendChild(style)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function removeStyles(): void {
|
|
226
|
+
const style = document.getElementById(STYLE_ID)
|
|
227
|
+
if (style && style.parentNode) style.parentNode.removeChild(style)
|
|
228
|
+
}
|