@windypro-rourou/dsh-eda 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,1119 @@
1
+ /**
2
+ * dsh-lichuang-eda — host half.
3
+ *
4
+ * 嘉立创 EDA 助手:面向 0 基础开发者,自动生成嘉立创 EDA(专业版/标准版)
5
+ * 原理图 / PCB 草图的 DSH 插件。
6
+ *
7
+ * Based on the OFFICIAL EasyEDA ecosystem only:
8
+ * - offline generator (标准版 JSON): 中文需求/模板 → design → 原理图 JSON,
9
+ * 带结构 + 连通性校验(src/validate.js);
10
+ * - live bridge (OFFICIAL): easyeda-api-skill 的官方 Bridge Server
11
+ * (scripts/bridge-server.mjs,端口 49620-49629)+ 官方扩展 Run API Gateway
12
+ * (EasyEDA Pro 内,需勾「允许外部交互」),动作 = 官方 eda.* API 代码执行;
13
+ * - routes: /status /templates /bridge /install /generate;
14
+ * - system-prompt announcement + browser panel (./client).
15
+ *
16
+ * 所有外部后端均来自官方(easyeda/easyeda-api-skill 与 easyeda/eext-run-api-gateway),
17
+ * 无任何第三方后端(easyeda-agent / jlcmcp / easyeda-mcp-pro 已弃用)。
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs'
21
+ import { promises as fsp } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { defineTool } from '@deepseek-ai/dsh-tools'
25
+
26
+ import { EdaBackend, OFFICIAL_CODES, BRIDGE_PORTS } from './backend.js'
27
+ import { installOfficialBridge, isBridgeInstalled, bridgeDir, bridgeScriptPath, OFFICIAL_EXT_URL } from './installer.js'
28
+ import { createSnapshot } from './snapshot.js'
29
+ import { pickSpots } from './layout.js'
30
+ import { ActivityLog } from './activity.js'
31
+ import { CAPABILITIES } from './capabilities.js'
32
+ import { generateSchematic, wrapAsProject } from '../src/json-gen.js'
33
+ import { validateSchematic, checkConnectivity, deriveNetlist } from '../src/validate.js'
34
+ import { translateRequest, TEMPLATE_CATALOG, describeDesign } from '../src/nl-to-design.js'
35
+
36
+ /** This package's manifest (name + version), read from the installed location. */
37
+ const OWN_MANIFEST = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
38
+
39
+ /** Stable cordis plugin name. */
40
+ export const name = 'eda'
41
+
42
+ /** Services required before the eda surfaces can mount. */
43
+ export const inject = ['webServer', 'tools', 'systemPrompt']
44
+
45
+ /** Services this plugin provides on ctx (ctx.eda). */
46
+ export const provide = ['eda']
47
+
48
+ /** Order of the announcement section within the tool-guidance band. */
49
+ const SECTION_ORDER = 153
50
+
51
+ /** Where generated files are written (stable across install locations). */
52
+ function outputRoot() {
53
+ return join(homedir(), '.dsh', 'eda', 'output')
54
+ }
55
+
56
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
57
+ export const EDA_GUIDANCE =
58
+ '本机已安装 dsh-lichuang-eda 插件(嘉立创 EDA 助手):本插件是「平台层」——提供官方桥的装/启/连/状态,以及执行官方 eda.* API 的双手;「生成电路图」这类编排请在对话里直接进行(skill 式直觉)。' +
59
+ '当用户说「嘉立创EDA,启动!」/「帮我画一个…电路」且已连接时,按此流程做:① `eda_status` 确认官方桥 connected(未连则 `eda_backend_connect`);② 用 `eda_exec` 逐步执行官方 eda.* API 在用户的云端画板上实时生成(放元件→连线→网络标签→DRC/网表/BOM),' +
60
+ '官方案例/参数在 ~/.dsh/eda/bridge/(docs、user-guide、guide、format 目录为官方文档,先用 read 查阅再调用);③ 每步 `eda_exec` 后返回结果/错误,必要时 `eda_sch_drc` 校验。' +
61
+ '工具:eda_status(状态)、eda_bridge_install(一键装官方桥)、eda_backend_connect(启动/连接官方桥)、eda_exec(执行任意官方 eda.* 代码——云端实时生成的双手)、eda_pick_spot(**框内定位**:读页面尺寸+已有图元→返回互不冲突的框内网格空位;放元件前先调用)、eda_snapshot(**紧急保存**:画板现场 .epro2 + 预览 SVG + 网表/BOM + agent 动作日志 → ~/.dsh/eda/snapshots/,断连也能留档)、' +
62
+ 'eda_sch_drc / eda_get_netlist / eda_get_bom(校验/导出,需连接)、eda_generate_schematic_json(**离线兜底**:本地生成可导入的标准版 JSON,写入 ~/.dsh/eda/output/;仅在无法连接画板时使用,连接时一律走官方 API 云端实时)、' +
63
+ 'eda_template_list / eda_translate_request(模板与需求翻译,供离线/兜底使用)。' +
64
+ '**放置定位(硬规则,禁止乱放)**:原理图坐标单位 10mil、图纸 A4≈1170×825,放置前必须先 `eda_exec` 读页面尺寸(dmt_Project.getCurrentProjectInfo()→titleBlockData.Width/Height)与已有图元位置(sch_PrimitiveComponent.getAll()+getState_X/Y 在代码内映射),只放**图框内边距≥80、网格 100 步进、与已有图元间距≥150**的空位(或直接调 eda_pick_spot);导线用 getAllPinsByPrimitiveId 取真实引脚坐标后画水平/垂直线段(只连引脚)。PCB 坐标单位 1mil,同样读已有焊盘(getAllPins)后居中布件、焊盘级走线。' +
65
+ '**已知缺陷与技巧(实测,勿踩坑)**:`pcb_Document.importChanges` 与 `pcb_Net.setNetlist` 实测不可靠(勿用于网表同步);`sch_Netlist.getNetlist` 与 `pcb_Net.getNetlist("EasyEDA")` 会挂起(网表一律用 `sch_ManufactureData.getNetlistFile` 并 `await f.text()`);`getBomFile` 返回二进制 xlsx(arrayBuffer→base64 处理);DRC 用 `check(true,false,true)` 拿详情;`sch_PrimitiveComponent.getAll` 间歇失败需重试;PCB 板框(layer 11)官方无绘制 API(人工/导入)。' +
66
+ '**主动协作(能力入口)**:你能主动为用户完成——读工程/文档、放任意大类元件(搜库→定位→放置)、引脚级连线、网络标志/端口、DRC、网表/BOM 导出、PCB 元件/过孔/走线、现场截图、紧急保存。规划任务时若不确定某个 API 是否存在或怎么调,先调 `eda_capabilities`(结构化能力清单+注意事项+实测片段),再用 `eda_exec` 落地;用户提到画板相关需求时主动提议并执行,不要等用户一字一句教。' +
67
+ '限制:v0 离线生成器仅支持 电阻/LED;云端实时生成能力由官方 eda.* API 决定(以 ~/.dsh/eda/bridge 内官方文档为准)。' +
68
+ '用户提到「嘉立创 / 立创EDA / 原理图 / PCB草图 / 生成电路图」时即指本插件,请据此主动协作。'
69
+
70
+ /** -------------------------------------------------------------- helpers */
71
+
72
+ const API_BASE = '/api/dsh-eda'
73
+
74
+ /** -------------------------------------------------------------- activity */
75
+
76
+ /**
77
+ * Record-style timeline (参考 DSH 轨迹): every step is one entry, persisted to
78
+ * ~/.dsh/eda/activity.jsonl (survives restarts — the panel is never empty),
79
+ * with optional per-step revoke data (the primitives that step created).
80
+ * Wrappers keep historical call sites; `activityLog` is bound at apply().
81
+ */
82
+ let activityLog = null
83
+ function pushActivity(entry) { return activityLog?.push(entry) ?? 0 }
84
+ function pushPendingActivity(entry) { return activityLog?.push({ status: 'pending', durationMs: 0, ...entry }) ?? 0 }
85
+ function updateActivity(id, patch) { activityLog?.update(id, patch) }
86
+ function activityFeed(opts) { return activityLog?.feed(opts) ?? { currentSid: '', sessions: [], activities: [] } }
87
+
88
+ /** Snapshot the current document's primitive ids (for 撤回 diff). */
89
+ async function captureDocumentIds(backend) {
90
+ const CODE = `return await (async () => {
91
+ const info = await eda.dmt_SelectControl.getCurrentDocumentInfo();
92
+ const domain = info?.documentType === 3 ? 'pcb' : 'sch';
93
+ const out = { domain };
94
+ try {
95
+ if (domain === 'sch') {
96
+ out.component = await eda.sch_PrimitiveComponent.getAllPrimitiveId();
97
+ out.wire = await eda.sch_PrimitiveWire.getAllPrimitiveId();
98
+ } else {
99
+ out.component = await eda.pcb_PrimitiveComponent.getAllPrimitiveId();
100
+ out.via = await eda.pcb_PrimitiveVia.getAllPrimitiveId();
101
+ out.line = await eda.pcb_PrimitiveLine.getAllPrimitiveId();
102
+ }
103
+ } catch { /* partial snapshot is fine */ }
104
+ return out;
105
+ })();`
106
+ const r = await backend.execute(CODE, 25000)
107
+ if (!r.ok) return null
108
+ try { return JSON.parse(r.result) } catch { return null }
109
+ }
110
+
111
+ /** Diff two id snapshots → created ids + deleted count (for 撤回). */
112
+ export function diffIds(before, after) {
113
+ if (!before || !after) return null
114
+ const keys = ['component', 'wire', 'via', 'line']
115
+ const created = []
116
+ let deletedCount = 0
117
+ for (const key of keys) {
118
+ const a = before[key] ?? []
119
+ const b = after[key] ?? []
120
+ const setA = new Set(a)
121
+ const setB = new Set(b)
122
+ for (const id of b) if (!setA.has(id)) created.push({ type: key, id })
123
+ for (const id of a) if (!setB.has(id)) deletedCount++
124
+ }
125
+ return { domain: after.domain ?? before.domain ?? 'sch', created, deletedCount }
126
+ }
127
+
128
+ /**
129
+ * Session id of the agent that triggered one tool call. The tool runtime hands
130
+ * every `execute(args, exec)` a ToolRunContext carrying `agent.id`
131
+ * (= its SessionId), so the monitor can answer 「仅当前会话」honestly.
132
+ * '' = unknown / panel-clicked (platform-level).
133
+ */
134
+ function sessionIdOf(exec) {
135
+ try {
136
+ const id = exec?.agent?.id ?? exec?.agent?.session?.id
137
+ return typeof id === 'string' && id !== '' ? id : ''
138
+ } catch {
139
+ return ''
140
+ }
141
+ }
142
+
143
+ /** Human-readable (Chinese) label for an official eda.* code snippet. */
144
+ function describeEdaAction(code) {
145
+ const m = /eda\.([a-zA-Z_]+)\.([a-zA-Z_]+)/.exec(code ?? '')
146
+ if (!m) return '执行官方代码'
147
+ const [, mod, fn] = m
148
+ const map = {
149
+ 'dmt_Project.getCurrentProjectInfo': '读取当前工程',
150
+ 'dmt_Project.openProject': '打开工程',
151
+ 'dmt_Schematic.createSchematic': '新建原理图',
152
+ 'dmt_Schematic.createSchematicPage': '新建原理图页',
153
+ 'dmt_EditorControl.openDocument': '打开文档',
154
+ 'dmt_EditorControl.activateDocument': '激活文档',
155
+ 'dmt_SelectControl.getCurrentDocumentInfo': '读取当前文档信息',
156
+ 'sch_PrimitiveComponent.create': '放置元件',
157
+ 'sch_PrimitiveComponent.createNetFlag': '放置网络标志',
158
+ 'sch_PrimitiveComponent.createNetPort': '放置网络端口',
159
+ 'sch_PrimitiveWire.create': '画导线',
160
+ 'sch_Document.save': '保存原理图',
161
+ 'sch_Drc.check': '原理图 DRC',
162
+ 'sch_ManufactureData.getNetlistFile': '导出网表',
163
+ 'sch_ManufactureData.getBomFile': '导出 BOM',
164
+ 'pcb_Document.importAutoRouteJsonFile': '导入自动布线',
165
+ 'pcb_Drc.check': 'PCB DRC',
166
+ 'lib_Device.searchByProperties': '按料号搜索器件',
167
+ 'lib_Device.search': '搜索器件',
168
+ }
169
+ return map[`${mod}.${fn}`] ?? `调用 ${mod}.${fn}()`
170
+ }
171
+
172
+ /** Loopback-only guard (simplified from dsh-logcat): refuses non-local callers. */
173
+ function isLoopbackRequest(req) {
174
+ const address = req.socket?.remoteAddress ?? ''
175
+ if (address === '') return true // no socket info (tests / some proxies) — allow
176
+ if (address === '::1' || address === '127.0.0.1') return true
177
+ return address.startsWith('::ffff:127.') || address.startsWith('127.')
178
+ }
179
+
180
+ function writeJson(res, status, body) {
181
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
182
+ res.end(JSON.stringify(body))
183
+ }
184
+
185
+ async function readJsonBody(req, limitBytes = 64 * 1024) {
186
+ const chunks = []
187
+ let size = 0
188
+ for await (const chunk of req) {
189
+ size += chunk.length
190
+ if (size > limitBytes) throw new Error('body too large')
191
+ chunks.push(chunk)
192
+ }
193
+ if (chunks.length === 0) return {}
194
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return {} }
195
+ }
196
+
197
+ /** Shared offline generation pipeline (tool + route). */
198
+ async function generateSchematicJson({ description = '', template = '' } = {}) {
199
+ const t = translateRequest(description, { template })
200
+ if (!t.ok) return { ok: false, error: t.note ?? '无法翻译需求' }
201
+ let sheet
202
+ try {
203
+ sheet = generateSchematic(t.design)
204
+ } catch (error) {
205
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
206
+ }
207
+ const project = wrapAsProject(sheet, { title: t.design.name, description: t.note ?? '' })
208
+ const struct = validateSchematic(sheet)
209
+ const conn = checkConnectivity(t.design)
210
+ const netlist = deriveNetlist(t.design)
211
+
212
+ const files = []
213
+ try {
214
+ const dir = outputRoot()
215
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
216
+ const base = `${t.design.name}-${stamp}`
217
+ await fsp.mkdir(dir, { recursive: true })
218
+ const flatPath = join(dir, base + '.json')
219
+ const projPath = join(dir, base + '-project.json')
220
+ await fsp.writeFile(flatPath, JSON.stringify(sheet, null, 2) + '\n', 'utf8')
221
+ await fsp.writeFile(projPath, JSON.stringify(project, null, 2) + '\n', 'utf8')
222
+ files.push(flatPath, projPath)
223
+ } catch { /* file write is best-effort */ }
224
+
225
+ return {
226
+ ok: true,
227
+ title: t.title,
228
+ note: t.note ?? '',
229
+ components: t.design.components.map((c) => `${c.ref}: ${c.type} ${c.value}`),
230
+ netSummary: describeDesign(t.design),
231
+ nets: netlist.nets.filter((n) => n.name !== null).map((n) => n.name),
232
+ structureOk: struct.ok,
233
+ connectivityOk: conn.ok,
234
+ errors: [...struct.errors, ...conn.errors].slice(0, 20),
235
+ json: JSON.stringify(sheet),
236
+ projectJson: JSON.stringify(project),
237
+ files,
238
+ }
239
+ }
240
+
241
+ /** Enriched status snapshot for the handle / routes / eda_status tool. */
242
+ function statusOf(backend) {
243
+ const b = backend.status()
244
+ return {
245
+ ready: b.running && b.connected,
246
+ backend: b.backend,
247
+ state: b.state,
248
+ connected: b.running && b.connected,
249
+ port: b.port ?? 0,
250
+ health: b.health ?? '',
251
+ offlineCapable: true,
252
+ supportedTypes: ['resistor', 'led'],
253
+ bridgeInstalled: isBridgeInstalled(),
254
+ bridgeScript: isBridgeInstalled() ? bridgeScriptPath() : '',
255
+ extUrl: OFFICIAL_EXT_URL,
256
+ error: b.error ?? '',
257
+ version: OWN_MANIFEST.version,
258
+ note: b.connected
259
+ ? `官方桥已连接(127.0.0.1:${b.port}):可实时执行官方 eda.* API(放元件/连线/DRC/网表/BOM)`
260
+ : '官方桥未连接(离线生成仍可用)。一键流程:①「一键安装官方桥」→ ②「启动桥」→ ③ EasyEDA Pro 装官方扩展 Run API Gateway 并勾选「允许外部交互」→ ④ 状态变绿。',
261
+ }
262
+ }
263
+
264
+ /** Start (or reuse) the official bridge; shared by the tool and the /bridge route. */
265
+ async function startBridge(backend) {
266
+ if (!isBridgeInstalled()) {
267
+ return { ok: false, state: 'idle', error: '官方桥未安装:请先用 eda_bridge_install(或面板「一键安装官方桥」)下载官方 easyeda-api-skill。', note: '' }
268
+ }
269
+ if (backend.isRunning()) return { ok: true, state: backend.state, error: '', note: '官方桥已在运行' }
270
+ const st = await backend.start()
271
+ return {
272
+ ok: st.running,
273
+ state: st.state,
274
+ error: st.error ?? '',
275
+ note: st.running
276
+ ? `官方桥已启动(${st.port ? '127.0.0.1:' + st.port : '等待 49620-49629'})。请确认 EasyEDA Pro 已安装官方扩展「Run API Gateway」并勾选「允许外部交互」。`
277
+ : '官方桥未启动:请先「一键安装官方桥」(eda_bridge_install),并确认 EasyEDA Pro 已装 Run API Gateway 扩展 + 勾选「允许外部交互」。',
278
+ }
279
+ }
280
+
281
+ /** -------------------------------------------------------------- routes */
282
+
283
+ function makeRoutes(handle) {
284
+ const routes = [
285
+ {
286
+ kind: 'exact',
287
+ path: API_BASE + '/status',
288
+ handler: async (req, res) => {
289
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
290
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
291
+ await handle.backend.refresh() // live health snapshot (not stale)
292
+ writeJson(res, 200, handle.status())
293
+ },
294
+ },
295
+ {
296
+ kind: 'exact',
297
+ path: API_BASE + '/activity',
298
+ handler: async (req, res) => {
299
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
300
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
301
+ const query = (req.url ?? '').split('?')[1] ?? ''
302
+ const sid = new URLSearchParams(query).get('sid') ?? ''
303
+ writeJson(res, 200, activityFeed({ sid }))
304
+ },
305
+ },
306
+ {
307
+ kind: 'exact',
308
+ path: API_BASE + '/activity/clear',
309
+ handler: async (req, res) => {
310
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
311
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
312
+ try {
313
+ await handle.clearActivity()
314
+ pushActivity({ sid: '', tool: 'panel', action: '清空记录', ok: true, result: '时间线已清空' })
315
+ writeJson(res, 200, { ok: true })
316
+ } catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
317
+ },
318
+ },
319
+ {
320
+ kind: 'exact',
321
+ path: API_BASE + '/activity/revoke',
322
+ handler: async (req, res) => {
323
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
324
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
325
+ try {
326
+ const query = (req.url ?? '').split('?')[1] ?? ''
327
+ const id = Number(new URLSearchParams(query).get('id') ?? 0)
328
+ writeJson(res, 200, await handle.revokeActivity(id))
329
+ } catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
330
+ },
331
+ },
332
+ {
333
+ kind: 'exact',
334
+ path: API_BASE + '/templates',
335
+ handler: async (req, res) => {
336
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
337
+ if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
338
+ writeJson(res, 200, { templates: TEMPLATE_CATALOG })
339
+ },
340
+ },
341
+ {
342
+ kind: 'exact',
343
+ path: API_BASE + '/bridge',
344
+ handler: async (req, res) => {
345
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
346
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
347
+ try {
348
+ const t0 = Date.now()
349
+ const out = await startBridge(handle.backend)
350
+ pushActivity({
351
+ sid: '', tool: 'panel',
352
+ action: out.ok ? '启动官方桥(面板)' : '启动官方桥(面板失败)',
353
+ ok: out.ok, error: out.ok ? '' : (out.error ?? out.note ?? ''),
354
+ durationMs: Date.now() - t0,
355
+ })
356
+ writeJson(res, 200, out)
357
+ } catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
358
+ },
359
+ },
360
+ {
361
+ kind: 'exact',
362
+ path: API_BASE + '/install',
363
+ handler: async (req, res) => {
364
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
365
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
366
+ try {
367
+ const out = await handle.bridgeInstall(await readJsonBody(req))
368
+ pushActivity({
369
+ sid: '', tool: 'panel',
370
+ action: out.ok ? '一键安装官方桥(面板)' : '一键安装官方桥(面板失败)',
371
+ ok: out.ok === true,
372
+ error: out.ok ? '' : String(out.error ?? ''),
373
+ result: out.ok ? String(out.script ?? '') : '',
374
+ })
375
+ writeJson(res, 200, out)
376
+ } catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
377
+ },
378
+ },
379
+ {
380
+ kind: 'exact',
381
+ path: API_BASE + '/generate',
382
+ handler: async (req, res) => {
383
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
384
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
385
+ try { writeJson(res, 200, await generateSchematicJson(await readJsonBody(req))) }
386
+ catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
387
+ },
388
+ },
389
+ {
390
+ kind: 'exact',
391
+ path: API_BASE + '/snapshot',
392
+ handler: async (req, res) => {
393
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
394
+ if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return }
395
+ try {
396
+ const t0 = Date.now()
397
+ const out = await handle.snapshot(await readJsonBody(req))
398
+ pushActivity({
399
+ sid: '', tool: 'panel',
400
+ action: out.connected ? '紧急保存(画板现场+日志)' : '紧急保存(仅动作日志)',
401
+ ok: out.ok === true,
402
+ result: out.ok ? `${out.dir}(${out.files.length} 个文件${out.errors.length > 0 ? `,${out.errors.length} 项降级` : ''})` : '',
403
+ error: out.ok ? '' : String(out.error ?? ''),
404
+ durationMs: Date.now() - t0,
405
+ })
406
+ writeJson(res, 200, out)
407
+ } catch (error) { writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
408
+ },
409
+ },
410
+ ]
411
+ return { routes }
412
+ }
413
+
414
+ /** -------------------------------------------------------------- tools */
415
+
416
+ function edaStatusTool(handle) {
417
+ return defineTool({
418
+ name: 'eda_status',
419
+ description: 'Check the dsh-lichuang-eda (嘉立创 EDA 助手) plugin status: official bridge state, offline generator capability. ' +
420
+ 'Triggers: check eda plugin status, ask whether the 嘉立创 EDA 后端已接入, decide whether schematic / PCB generation is available.',
421
+ parameters: {},
422
+ output: {
423
+ schema: {
424
+ type: 'object',
425
+ additionalProperties: false,
426
+ properties: {
427
+ ready: { type: 'boolean', required: true },
428
+ backend: { type: 'string', required: true },
429
+ state: { type: 'string' },
430
+ connected: { type: 'boolean' },
431
+ port: { type: 'integer' },
432
+ health: { type: 'string' },
433
+ offlineCapable: { type: 'boolean' },
434
+ supportedTypes: { type: 'array', items: { type: 'string' } },
435
+ bridgeInstalled: { type: 'boolean' },
436
+ bridgeScript: { type: 'string' },
437
+ extUrl: { type: 'string' },
438
+ error: { type: 'string' },
439
+ version: { type: 'string' },
440
+ note: { type: 'string' },
441
+ },
442
+ },
443
+ render: (_args, value) => {
444
+ const ready = value?.ready === true
445
+ const lines = [
446
+ `嘉立创 EDA 助手: ${ready ? '就绪' : '未就绪'}`,
447
+ `backend: ${value?.backend ?? 'unknown'}`,
448
+ `官方桥: ${value?.bridgeInstalled ? '已安装' : '未安装'}${value?.port ? ' · 端口 ' + value.port : ''}`,
449
+ `离线生成: ${value?.offlineCapable ? '可用' : '不可用'}`,
450
+ ]
451
+ if (value?.note !== '' && value?.note != null) lines.push(`note: ${value.note}`)
452
+ if (value?.version != null && value.version !== '') lines.push(`version: ${value.version}`)
453
+ return [{ type: 'text', text: lines.join('\n') }]
454
+ },
455
+ },
456
+ async execute() {
457
+ await handle.backend.refresh() // live health snapshot (not stale)
458
+ return handle.status()
459
+ },
460
+ })
461
+ }
462
+
463
+ function edaTemplateListTool() {
464
+ return defineTool({
465
+ name: 'eda_template_list',
466
+ description: 'List the common-circuit template cards (built-in pointing-and-choosing wall) with their supported flag. ' +
467
+ 'Triggers: which templates exist, 有哪些模板, 能生成什么电路.',
468
+ parameters: {},
469
+ output: {
470
+ schema: {
471
+ type: 'object',
472
+ additionalProperties: false,
473
+ properties: {
474
+ templates: {
475
+ type: 'array',
476
+ required: true,
477
+ items: {
478
+ type: 'object',
479
+ additionalProperties: false,
480
+ properties: {
481
+ id: { type: 'string', required: true },
482
+ ico: { type: 'string' },
483
+ name: { type: 'string', required: true },
484
+ desc: { type: 'string' },
485
+ supported: { type: 'boolean' },
486
+ },
487
+ },
488
+ },
489
+ },
490
+ },
491
+ render: (_args, value) => {
492
+ const rows = (value?.templates ?? []).map((t) => `${t.supported ? '✅' : '⏳'} ${t.name}(${t.id}):${t.desc ?? ''}`)
493
+ return [{ type: 'text', text: rows.length > 0 ? rows.join('\n') : '(no templates)' }]
494
+ },
495
+ },
496
+ async execute() {
497
+ return { templates: TEMPLATE_CATALOG.map((t) => ({ id: t.id, ico: t.ico, name: t.name, desc: t.desc, supported: t.supported === true })) }
498
+ },
499
+ })
500
+ }
501
+
502
+ function edaTranslateRequestTool() {
503
+ return defineTool({
504
+ name: 'eda_translate_request',
505
+ description: 'Translate a Chinese circuit request into a structured design draft (components + wires + nets) WITHOUT generating. ' +
506
+ 'Use first to preview what the plugin understood. Triggers: 把需求翻译成电路设计草稿, preview design before generate.',
507
+ parameters: {
508
+ description: { type: 'string', description: '中文需求,例如:一个 LED 点亮电路 / 5V→3.3V 电阻分压' },
509
+ template: { type: 'string', description: '可选:模板 ID(见 eda_template_list)' },
510
+ },
511
+ output: {
512
+ schema: {
513
+ type: 'object',
514
+ additionalProperties: false,
515
+ properties: {
516
+ ok: { type: 'boolean', required: true },
517
+ title: { type: 'string', required: true },
518
+ note: { type: 'string' },
519
+ summary: { type: 'string' },
520
+ },
521
+ },
522
+ render: (_args, value) => {
523
+ const lines = [`翻译: ${value?.ok ? '成功' : '未命中'}`]
524
+ if (value?.title != null) lines.push(`标题: ${value.title}`)
525
+ if (value?.summary != null) lines.push(value.summary)
526
+ if (value?.note != null && value.note !== '') lines.push(`提示: ${value.note}`)
527
+ return [{ type: 'text', text: lines.join('\n') }]
528
+ },
529
+ },
530
+ async execute(args) {
531
+ const t = translateRequest(args?.description ?? '', { template: args?.template })
532
+ if (!t.ok) return { ok: false, title: t.title, note: t.note }
533
+ return { ok: true, title: t.title, summary: describeDesign(t.design), note: t.note ?? '' }
534
+ },
535
+ })
536
+ }
537
+
538
+ function edaGenerateSchematicJsonTool() {
539
+ return defineTool({
540
+ name: 'eda_generate_schematic_json',
541
+ description: 'Generate an importable 嘉立创 EDA 标准版 (EasyEDA Standard v6) schematic JSON from a Chinese request or template id. ' +
542
+ 'Runs offline (no board connection needed); validates structure + connectivity; writes files to ~/.dsh/eda/output/. ' +
543
+ 'Triggers: 生成原理图 JSON, 生成能导入嘉立创的电路图文件.',
544
+ parameters: {
545
+ description: { type: 'string', description: '中文需求(与 template 二选一)' },
546
+ template: { type: 'string', description: '模板 ID(见 eda_template_list;与 description 二选一)' },
547
+ },
548
+ output: {
549
+ schema: {
550
+ type: 'object',
551
+ additionalProperties: false,
552
+ properties: {
553
+ ok: { type: 'boolean', required: true },
554
+ title: { type: 'string' },
555
+ note: { type: 'string' },
556
+ components: { type: 'array', items: { type: 'string' } },
557
+ nets: { type: 'array', items: { type: 'string' } },
558
+ structureOk: { type: 'boolean' },
559
+ connectivityOk: { type: 'boolean' },
560
+ errors: { type: 'array', items: { type: 'string' } },
561
+ json: { type: 'string' },
562
+ projectJson: { type: 'string' },
563
+ files: { type: 'array', items: { type: 'string' } },
564
+ error: { type: 'string' },
565
+ },
566
+ },
567
+ render: (_args, value) => {
568
+ const lines = value?.ok === false
569
+ ? [`生成失败: ${value?.error ?? '?'}`]
570
+ : [
571
+ `✅ 已生成「${value?.title ?? ''}」标准版原理图 JSON`,
572
+ `元件: ${(value?.components ?? []).join(', ') || '无'}`,
573
+ `网络: ${(value?.nets ?? []).join(', ') || '无'}`,
574
+ `结构校验: ${value?.structureOk ? '通过' : '失败'} · 连通性: ${value?.connectivityOk ? '通过' : '失败'}`,
575
+ ]
576
+ if ((value?.errors ?? []).length > 0) lines.push(`校验错误: ${value.errors.slice(0, 5).join('; ')}`)
577
+ if ((value?.files ?? []).length > 0) lines.push(`已写入: ${value.files.join(', ')}`)
578
+ if (value?.note != null && value.note !== '') lines.push(`提示: ${value.note}`)
579
+ return [{ type: 'text', text: lines.join('\n') }]
580
+ },
581
+ },
582
+ async execute(args) {
583
+ return generateSchematicJson({ description: args?.description, template: args?.template })
584
+ },
585
+ })
586
+ }
587
+
588
+ /** One-click download/install of the OFFICIAL bridge (easyeda-api-skill). */
589
+ function edaBridgeInstallTool(backend) {
590
+ return defineTool({
591
+ name: 'eda_bridge_install',
592
+ description: 'One-click download + install the OFFICIAL EasyEDA bridge (easyeda/easyeda-api-skill) into ~/.dsh/eda/bridge/ ' +
593
+ '(source archive → extract → npm install → scripts/bridge-server.mjs). No third-party backend involved. ' +
594
+ 'Triggers: 一键安装官方桥, download official bridge, 装官方桥.',
595
+ parameters: {
596
+ version: { type: 'string', description: '可选:branch/tag,默认 main(官方仓库)' },
597
+ },
598
+ output: {
599
+ schema: {
600
+ type: 'object',
601
+ additionalProperties: false,
602
+ properties: {
603
+ ok: { type: 'boolean', required: true },
604
+ message: { type: 'string' },
605
+ script: { type: 'string' },
606
+ extUrl: { type: 'string' },
607
+ error: { type: 'string' },
608
+ },
609
+ },
610
+ render: (_args, value) => ({ type: 'text', text: value?.ok ? (value.message ?? '安装完成') : `安装失败: ${value?.error ?? '?'}` }),
611
+ },
612
+ async execute(args, exec) {
613
+ const sid = sessionIdOf(exec)
614
+ const id = pushPendingActivity({ sid, tool: 'eda_bridge_install', action: '一键安装官方桥(进行中)' })
615
+ try {
616
+ const t0 = Date.now()
617
+ const r = await installOfficialBridge({ version: args?.version })
618
+ updateActivity(id, { status: 'done', ok: true, result: r.script, durationMs: Date.now() - t0 })
619
+ return {
620
+ ok: true,
621
+ message: `官方桥已安装: ${r.script}\n请在 EasyEDA Pro「扩展管理」安装官方扩展 Run API Gateway(${r.extUrl})并勾选「允许外部交互」。`,
622
+ script: r.script,
623
+ extUrl: r.extUrl,
624
+ error: '',
625
+ }
626
+ } catch (error) {
627
+ updateActivity(id, { status: 'error', ok: false, error: error instanceof Error ? error.message : String(error) })
628
+ return { ok: false, message: '', script: '', extUrl: OFFICIAL_EXT_URL, error: error instanceof Error ? error.message : String(error) }
629
+ }
630
+ },
631
+ })
632
+ }
633
+
634
+ function edaBackendConnectTool(handle, backend) {
635
+ return defineTool({
636
+ name: 'eda_backend_connect',
637
+ description: 'Start the OFFICIAL EasyEDA bridge server (easyeda-api-skill scripts/bridge-server.mjs, listens 127.0.0.1:49620-49629) and wait for health. ' +
638
+ 'Requires the official extension Run API Gateway in EasyEDA Pro with 「允许外部交互」 enabled. ' +
639
+ 'Triggers: 连接专业版画板, connect the board, 启动官方桥, start bridge.',
640
+ parameters: {},
641
+ output: {
642
+ schema: {
643
+ type: 'object',
644
+ additionalProperties: false,
645
+ properties: {
646
+ ok: { type: 'boolean', required: true },
647
+ state: { type: 'string' },
648
+ error: { type: 'string' },
649
+ note: { type: 'string' },
650
+ },
651
+ },
652
+ render: (_args, value) => ({
653
+ type: 'text',
654
+ text: value?.ok === false
655
+ ? `启动失败: ${value?.error ?? '?'}\n${value?.note ?? ''}`
656
+ : `官方桥: ${value?.state ?? '?'}\n${value?.note ?? ''}`,
657
+ }),
658
+ },
659
+ async execute(_args, exec) {
660
+ const sid = sessionIdOf(exec)
661
+ const id = pushPendingActivity({ sid, tool: 'eda_backend_connect', action: '启动/连接官方桥(进行中)' })
662
+ const t0 = Date.now()
663
+ const st = await startBridge(backend)
664
+ updateActivity(id, {
665
+ status: st.ok ? 'done' : 'error',
666
+ ok: st.ok,
667
+ action: st.ok ? '启动/连接官方桥' : '启动官方桥(失败)',
668
+ error: st.ok ? '' : (st.error ?? st.note ?? ''),
669
+ durationMs: Date.now() - t0,
670
+ })
671
+ return { ok: st.ok, state: st.state, error: st.error, note: st.note }
672
+ },
673
+ })
674
+ }
675
+
676
+ /** Bridge-gated validation tools over the OFFICIAL eda.* API (execute snippets). */
677
+ function bridgeTool(handle, backend, spec) {
678
+ const { name, description, bridgeToolName, renderSummary } = spec
679
+ return defineTool({
680
+ name,
681
+ description,
682
+ parameters: {
683
+ args: { type: 'string', description: '可选:JSON 参数串(如 {"code":"return await eda.xxx()"},用官方 eda.* 代码覆盖默认映射)' },
684
+ },
685
+ output: {
686
+ schema: {
687
+ type: 'object',
688
+ additionalProperties: false,
689
+ properties: {
690
+ ok: { type: 'boolean', required: true },
691
+ result: { type: 'string' },
692
+ error: { type: 'string' },
693
+ },
694
+ },
695
+ render: (_args, value) => ({ type: 'text', text: value?.ok ? renderSummary(value.result) : `失败: ${value?.error ?? '?'}` }),
696
+ },
697
+ async execute(args, exec) {
698
+ const sid = sessionIdOf(exec)
699
+ let params = {}
700
+ if (typeof args?.args === 'string' && args.args !== '') {
701
+ try { params = JSON.parse(args.args) } catch { /* ignore bad JSON */ }
702
+ }
703
+ const codeUsed = params?.code ?? OFFICIAL_CODES[bridgeToolName] ?? ''
704
+ const label = describeEdaAction(codeUsed) + `(${name})`
705
+ await backend.refresh() // live facts before gating
706
+ const st = statusOf(backend)
707
+ if (!st.connected) {
708
+ pushActivity({ sid, tool: name, action: label, code: codeUsed, ok: false, error: '官方桥未连接(门控拦截)' })
709
+ return { ok: false, error: '官方桥未连接:请先一键安装/启动官方桥,并确认 EasyEDA Pro 已装 Run API Gateway 扩展、勾选「允许外部交互」。' }
710
+ }
711
+ const id = pushPendingActivity({ sid, tool: name, action: label, code: codeUsed })
712
+ const t0 = Date.now()
713
+ const res = await backend.callTool(bridgeToolName, params)
714
+ updateActivity(id, {
715
+ status: res.ok ? 'done' : 'error',
716
+ ok: res.ok,
717
+ result: res.ok ? res.result : '',
718
+ error: res.ok ? '' : res.error,
719
+ durationMs: Date.now() - t0,
720
+ })
721
+ return res.ok ? { ok: true, result: res.result } : { ok: false, error: res.error }
722
+ },
723
+ })
724
+ }
725
+
726
+ /** 撤回一个步骤:删除该步新建的图元(按类型分域调用官方 delete)。 */
727
+ async function revokeActivityEntry(backend, id) {
728
+ const entry = activityLog?.get(Number(id))
729
+ if (!entry) return { ok: false, error: `步骤 #${id} 不存在` }
730
+ const rv = entry.revoke
731
+ if (!rv || !Array.isArray(rv.created) || rv.created.length === 0) {
732
+ return { ok: false, error: '该步骤没有可撤回的新建图元(仅 eda_exec/创建类步骤可撤回)' }
733
+ }
734
+ const byType = {}
735
+ for (const c of rv.created) (byType[c.type] ??= []).push(c.id)
736
+ const domain = rv.domain === 'pcb' ? 'pcb' : 'sch'
737
+ const calls = []
738
+ if (domain === 'sch') {
739
+ if (byType.component?.length) calls.push(`await eda.sch_PrimitiveComponent.delete(${JSON.stringify(byType.component)});`)
740
+ if (byType.wire?.length) calls.push(`await eda.sch_PrimitiveWire.delete(${JSON.stringify(byType.wire)});`)
741
+ } else {
742
+ if (byType.component?.length) calls.push(`await eda.pcb_PrimitiveComponent.delete(${JSON.stringify(byType.component)});`)
743
+ if (byType.via?.length) calls.push(`await eda.pcb_PrimitiveVia.delete(${JSON.stringify(byType.via)});`)
744
+ if (byType.line?.length) calls.push(`await eda.pcb_PrimitiveLine.delete(${JSON.stringify(byType.line)});`)
745
+ }
746
+ if (calls.length === 0) return { ok: false, error: '无可撤回的图元类型' }
747
+ const r = await backend.execute(`return await (async () => { ${calls.join(' ')} return true; })();`, 30000)
748
+ if (!r.ok) return { ok: false, error: r.error }
749
+ const removed = rv.created.length
750
+ const note = rv.deletedCount > 0 ? `(该步另删除 ${rv.deletedCount} 个图元,无法自动恢复)` : ''
751
+ pushActivity({ sid: '', tool: 'panel', action: `撤回步骤 #${entry.id}「${entry.action ?? '官方 API 调用'}」`, ok: true, result: `已删除 ${removed} 个新建图元${note}` })
752
+ return { ok: true, removed, note: note || '已删除该步新建的全部图元' }
753
+ }
754
+
755
+ /** The hands for the conversation-driven (skill-style) generation: execute any
756
+ * official `eda.*` code through the connected bridge, right in the user's
757
+ * cloud board. */
758
+ function edaExecTool(backend) {
759
+ return defineTool({
760
+ name: 'eda_exec',
761
+ description: 'Execute official 嘉立创 EDA eda.* API code in the user\'s connected cloud board (via the official bridge). ' +
762
+ 'This is THE tool to draw/edit/route in the live board — place components, wires, net labels, run DRC, export, etc. ' +
763
+ 'Reference docs are vendored at ~/.dsh/eda/bridge/ (docs/ user-guide/ guide/ format/ — read first, then call). ' +
764
+ 'Triggers: 在画板上放元件, draw wire, 云端画图, 执行 eda.* 代码, exec eda.',
765
+ parameters: {
766
+ code: { type: 'string', description: '官方 eda.* JS 代码,例如 return await eda.dmt_Project.getCurrentProjectInfo();' },
767
+ args: { type: 'string', description: '可选:附加 JSON(未来扩展位;可忽略)' },
768
+ },
769
+ output: {
770
+ schema: {
771
+ type: 'object',
772
+ additionalProperties: false,
773
+ properties: {
774
+ ok: { type: 'boolean', required: true },
775
+ result: { type: 'string' },
776
+ error: { type: 'string' },
777
+ },
778
+ },
779
+ render: (_args, value) => ({ type: 'text', text: value?.ok ? `执行结果:\n${value.result ?? ''}` : `执行失败: ${value?.error ?? '?'}` }),
780
+ },
781
+ async execute(args, exec) {
782
+ const sid = sessionIdOf(exec)
783
+ const code = typeof args?.code === 'string' ? args.code.trim() : ''
784
+ if (code === '') return { ok: false, error: 'code 不能为空(例如 return await eda.dmt_Project.getCurrentProjectInfo();)' }
785
+ await backend.refresh() // live facts before gating
786
+ const st = statusOf(backend)
787
+ if (!st.connected) {
788
+ pushActivity({ sid, tool: 'eda_exec', action: '执行官方代码', code: code.slice(0, 120), ok: false, error: '官方桥未连接(门控拦截)' })
789
+ return { ok: false, error: '官方桥未连接:请先 eda_backend_connect(并确认网页版扩展 Run API Gateway 已装且开启「外部交互」)。' }
790
+ }
791
+ const id = pushPendingActivity({ sid, tool: 'eda_exec', action: describeEdaAction(code), code })
792
+ const t0 = Date.now()
793
+ const before = await captureDocumentIds(backend) // best-effort (for 撤回)
794
+ const r = await backend.execute(code)
795
+ const after = await captureDocumentIds(backend)
796
+ updateActivity(id, {
797
+ status: r.ok ? 'done' : 'error',
798
+ ok: r.ok,
799
+ action: describeEdaAction(code),
800
+ result: r.ok ? r.result : '',
801
+ error: r.ok ? '' : r.error,
802
+ durationMs: Date.now() - t0,
803
+ revoke: diffIds(before, after) ?? undefined,
804
+ })
805
+ return r.ok ? { ok: true, result: r.result } : { ok: false, error: r.error }
806
+ },
807
+ })
808
+ }
809
+
810
+ /** 读取当前原理图页的尺寸 + 已有图元分布(桥端映射;getAll 间歇失败→重试)。 */
811
+ async function readSchLayout(backend) {
812
+ const CODE = `return await (async () => {
813
+ const proj = await eda.dmt_Project.getCurrentProjectInfo();
814
+ const page = proj?.data?.[0]?.schematic?.page?.[0];
815
+ let comps = null; let wires = null;
816
+ for (let i = 0; i < 3 && (comps == null || wires == null); i++) {
817
+ try { comps = await eda.sch_PrimitiveComponent.getAll(); } catch (e) { comps = null; }
818
+ try { wires = await eda.sch_PrimitiveWire.getAll(); } catch (e) { wires = null; }
819
+ if (comps == null || wires == null) await new Promise(r => setTimeout(r, 700));
820
+ }
821
+ const used = [];
822
+ for (const c of (comps || [])) { try { used.push({ x: c.getState_X(), y: c.getState_Y() }); } catch (e) {} }
823
+ for (const w of (wires || [])) { try { const l = w.getState_Line(); for (let i = 0; i + 1 < l.length; i += 2) used.push({ x: l[i], y: l[i + 1] }); } catch (e) {} }
824
+ return { name: page?.name ?? '', width: Number(page?.titleBlockData?.Width?.value ?? 1170), height: Number(page?.titleBlockData?.Height?.value ?? 825), used };
825
+ })();`
826
+ const r = await backend.execute(CODE, 30000)
827
+ if (!r.ok) return { ok: false, error: r.error }
828
+ try { return { ok: true, ...JSON.parse(r.result) } } catch { return { ok: false, error: 'layout read failed' } }
829
+ }
830
+
831
+ /** 框内定位:读页面尺寸+已有图元 → 返回互不冲突的框内网格空位(10mil)。 */
832
+ function edaPickSpotTool(backend) {
833
+ return defineTool({
834
+ name: 'eda_pick_spot',
835
+ description: 'Pick in-frame empty grid spots on the CURRENT schematic page (units 10mil; A4 ≈ 1170 x 825). ' +
836
+ 'Reads the page size + existing primitives via the OFFICIAL API and returns collision-free points (frame margin 80, grid 100, min gap 150). ' +
837
+ 'USE BEFORE placing any component: get spots, then sch_PrimitiveComponent.create(dev, spot.x, spot.y) — never place outside the frame. ' +
838
+ 'Triggers: 在哪里放, where to place, 框内定位, pick a spot.',
839
+ parameters: {
840
+ count: { type: 'integer', description: '需要的空位数量(默认 1)' },
841
+ },
842
+ output: {
843
+ schema: {
844
+ type: 'object',
845
+ additionalProperties: false,
846
+ properties: {
847
+ ok: { type: 'boolean', required: true },
848
+ page: { type: 'string' },
849
+ size: { type: 'string' },
850
+ usedCount: { type: 'integer' },
851
+ spots: {
852
+ type: 'array',
853
+ items: {
854
+ type: 'object',
855
+ additionalProperties: false,
856
+ properties: {
857
+ x: { type: 'integer', required: true },
858
+ y: { type: 'integer', required: true },
859
+ inside: { type: 'boolean' },
860
+ },
861
+ },
862
+ },
863
+ note: { type: 'string' },
864
+ error: { type: 'string' },
865
+ },
866
+ },
867
+ render: (_args, value) => {
868
+ if (value?.ok !== true) return { type: 'text', text: `框内定位失败: ${value?.error ?? '?'}` }
869
+ const lines = [
870
+ `页面: ${value.page ?? '?'}(${value.size ?? '?'})· 已有图元采样 ${value.usedCount ?? 0}`,
871
+ ]
872
+ for (const s of value.spots ?? []) lines.push(` 空位: (${s.x}, ${s.y})${s.inside ? '' : ' ⚠框外'}`)
873
+ if (value.note) lines.push(`提示: ${value.note}`)
874
+ return { type: 'text', text: lines.join('\n') }
875
+ },
876
+ },
877
+ async execute(args) {
878
+ const count = Math.max(1, Math.min(50, Number(args?.count) || 1))
879
+ const layout = await readSchLayout(backend)
880
+ if (!layout.ok) return { ok: false, error: layout.error, page: '', size: '', usedCount: 0, spots: [], note: '', }
881
+ const spots = pickSpots({ pageWidth: layout.width, pageHeight: layout.height, used: layout.used, count })
882
+ return {
883
+ ok: spots.length > 0,
884
+ page: layout.name,
885
+ size: `${layout.width} x ${layout.height} (10mil)`,
886
+ usedCount: layout.used.length,
887
+ spots,
888
+ note: spots.length < count ? `仅找到 ${spots.length} 个空位(页面空间不足,建议换一页或改用更小间距)` : `框内空位推荐(边距 80 / 网格 100 / 与已有图元间距 ≥150)`,
889
+ error: '',
890
+ }
891
+ },
892
+ })
893
+ }
894
+
895
+ /** 官方 API 能力清单:agent 规划时主动查 -> 用 eda_exec 落地。 */
896
+ function edaCapabilitiesTool() {
897
+ return defineTool({
898
+ name: 'eda_capabilities',
899
+ description: 'List what the OFFICIAL eda.* API can do (structured capability catalogue: domains, methods, usage notes, known pitfalls, verified snippets). ' +
900
+ 'Call this when planning a board task and unsure which API exists or how to call it — then use eda_exec with the suggested snippets. ' +
901
+ 'Triggers: 有什么API, what can I do on the board, 能力清单, how to place a part.',
902
+ parameters: {},
903
+ output: {
904
+ schema: {
905
+ type: 'object',
906
+ additionalProperties: false,
907
+ properties: {
908
+ count: { type: 'integer' },
909
+ capabilities: {
910
+ type: 'array',
911
+ items: {
912
+ type: 'object',
913
+ additionalProperties: false,
914
+ properties: {
915
+ domain: { type: 'string', required: true },
916
+ note: { type: 'string' },
917
+ methods: {
918
+ type: 'array',
919
+ items: {
920
+ type: 'object',
921
+ additionalProperties: false,
922
+ properties: {
923
+ name: { type: 'string', required: true },
924
+ desc: { type: 'string' },
925
+ },
926
+ },
927
+ },
928
+ },
929
+ },
930
+ },
931
+ },
932
+ },
933
+ render: (_args, value) => {
934
+ const sections = (value?.capabilities ?? []).map((c) =>
935
+ `## ${c.domain}${c.note ? `(${c.note})` : ''}\n` + (c.methods ?? []).map((m) => `- ${m.name} — ${m.desc}`).join('\n'),
936
+ )
937
+ return [{ type: 'text', text: sections.join('\n\n') || '(no capabilities)' }]
938
+ },
939
+ },
940
+ async execute() {
941
+ return { count: CAPABILITIES.length, capabilities: CAPABILITIES }
942
+ },
943
+ })
944
+ }
945
+
946
+ /** 紧急保存:把 agent 在画板上做的内容抓到本地(断连也能保留最后步骤)。 */function edaSnapshotTool(handle, backend) {
947
+ return defineTool({
948
+ name: 'eda_snapshot',
949
+ description: 'Emergency-save the current work to ~/.dsh/eda/snapshots/: board state via the OFFICIAL API (native .epro2 + universal SVG preview + netlist/BOM JSON) plus the agent action log. ' +
950
+ 'Use when the user wants to 保留最后的工程 / 紧急保存 / 防断连丢档, or when the cloud sync may have failed. ' +
951
+ 'Triggers: 紧急保存, save my work locally, 把画板存本地.',
952
+ parameters: {},
953
+ output: {
954
+ schema: {
955
+ type: 'object',
956
+ additionalProperties: false,
957
+ properties: {
958
+ ok: { type: 'boolean', required: true },
959
+ dir: { type: 'string' },
960
+ files: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', required: true }, size: { type: 'integer' } } } },
961
+ errors: { type: 'array', items: { type: 'string' } },
962
+ connected: { type: 'boolean' },
963
+ docFile: { type: 'string' },
964
+ preview: { type: 'string' },
965
+ },
966
+ },
967
+ render: (_args, value) => {
968
+ if (value?.ok !== true) return { type: 'text', text: `紧急保存失败: ${value?.error ?? '?'}` }
969
+ const lines = [
970
+ `✅ 已紧急保存 → ${value.dir ?? ''}`,
971
+ `文件: ${(value?.files ?? []).map((f) => f.name).join(', ') || '无'}`,
972
+ value?.docFile ? `专业版恢复: ${value.docFile}` : '',
973
+ value?.preview ? `通用预览: ${value.preview}` : '',
974
+ ].filter((s) => s !== '')
975
+ if ((value?.errors ?? []).length > 0) lines.push(`降级项: ${value.errors.join('; ')}`)
976
+ return { type: 'text', text: lines.join('\n') }
977
+ },
978
+ },
979
+ async execute(_args, exec) {
980
+ const sid = sessionIdOf(exec)
981
+ const id = pushPendingActivity({ sid, tool: 'eda_snapshot', action: '紧急保存(进行中)' })
982
+ await handle.backend.refresh()
983
+ const t0 = Date.now()
984
+ try {
985
+ const out = await handle.snapshot()
986
+ updateActivity(id, {
987
+ status: out.ok ? 'done' : 'error',
988
+ ok: out.ok === true,
989
+ action: out.connected ? '紧急保存(画板现场+日志)' : '紧急保存(仅动作日志)',
990
+ result: out.dir,
991
+ error: out.errors?.length > 0 ? `${out.errors.length} 项降级:${out.errors.slice(0, 3).join('; ')}` : '',
992
+ durationMs: Date.now() - t0,
993
+ })
994
+ return out
995
+ } catch (error) {
996
+ updateActivity(id, { status: 'error', ok: false, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - t0 })
997
+ return { ok: false, error: error instanceof Error ? error.message : String(error), files: [], errors: [] }
998
+ }
999
+ },
1000
+ })
1001
+ }
1002
+
1003
+ /** -------------------------------------------------------------- apply */
1004
+
1005
+ export function apply(ctx, config) {
1006
+ const resolve = () => ({
1007
+ enabled: config?.enabled ?? true,
1008
+ announceToAgent: config?.announceToAgent ?? true,
1009
+ })
1010
+
1011
+ // Record-style timeline: persisted to disk (panel is never empty; history
1012
+ // survives restarts). `activityFile` is injectable for tests.
1013
+ activityLog = new ActivityLog({ file: config?.activityFile }).loadSync()
1014
+
1015
+ const backend = new EdaBackend({
1016
+ bridgeDir: isBridgeInstalled() ? bridgeDir() : '',
1017
+ spawnBridge: isBridgeInstalled(),
1018
+ })
1019
+ const autoStartBridge = config?.autoStartBridge ?? true
1020
+
1021
+ // Observable handle for diagnostics and self-checks.
1022
+ const edaHandle = {
1023
+ status: () => statusOf(backend),
1024
+ generate: generateSchematicJson,
1025
+ snapshot: (opts = {}) => createSnapshot({
1026
+ execute: (code, timeoutMs) => backend.execute(code, timeoutMs),
1027
+ activities: () => activityLog?.entries.slice().reverse() ?? [],
1028
+ connected: backend.status().connected,
1029
+ ...opts,
1030
+ }),
1031
+ activity: (opts) => activityFeed(opts),
1032
+ clearActivity: async () => { await activityLog?.clear(); return { ok: true } },
1033
+ revokeActivity: (id) => revokeActivityEntry(backend, id),
1034
+ backend,
1035
+ templates: () => TEMPLATE_CATALOG,
1036
+ bridgeInstall: installOfficialBridge, // injectable for tests (see test/generate.test.mjs)
1037
+ }
1038
+ if (typeof ctx.provide === 'function') ctx.provide('eda', edaHandle)
1039
+ else ctx.eda = edaHandle
1040
+
1041
+ const { routes } = makeRoutes(edaHandle)
1042
+ let disposeRoutes
1043
+ let disposeTools
1044
+ let disposeSection
1045
+
1046
+ const sync = () => {
1047
+ const value = resolve()
1048
+ if (disposeSection !== undefined) { disposeSection(); disposeSection = undefined }
1049
+ if (disposeRoutes !== undefined) { disposeRoutes(); disposeRoutes = undefined }
1050
+ if (disposeTools !== undefined) { disposeTools(); disposeTools = undefined }
1051
+ if (!value.enabled) return
1052
+ if (value.announceToAgent) {
1053
+ disposeSection = ctx.systemPrompt.section({
1054
+ name: 'plugin:dsh-eda',
1055
+ order: SECTION_ORDER,
1056
+ text: EDA_GUIDANCE,
1057
+ })
1058
+ }
1059
+ disposeRoutes = ctx.effect(() => {
1060
+ const disposers = routes.map((route) => ctx.webServer.register(route))
1061
+ return () => { for (const dispose of disposers) dispose() }
1062
+ }, 'dsh-eda: routes')
1063
+ disposeTools = ctx.effect(() => {
1064
+ const disposers = [
1065
+ edaStatusTool(edaHandle),
1066
+ edaTemplateListTool(),
1067
+ edaTranslateRequestTool(),
1068
+ edaGenerateSchematicJsonTool(),
1069
+ edaBridgeInstallTool(backend),
1070
+ edaBackendConnectTool(edaHandle, backend),
1071
+ edaExecTool(backend),
1072
+ edaPickSpotTool(backend),
1073
+ edaCapabilitiesTool(),
1074
+ edaSnapshotTool(edaHandle, backend),
1075
+ bridgeTool(edaHandle, backend, {
1076
+ name: 'eda_sch_drc',
1077
+ description: 'Run the schematic DRC via the OFFICIAL API (eda.sch_Drc.check) on the current document — returns detailed errors. ' +
1078
+ 'Requires the official bridge connected. Triggers: 跑原理图 DRC, check design rules, 检查电路错误.',
1079
+ bridgeToolName: 'sch.drc',
1080
+ renderSummary: (r) => `DRC: ${typeof r === 'string' ? r : JSON.stringify(r)}`,
1081
+ }),
1082
+ bridgeTool(edaHandle, backend, {
1083
+ name: 'eda_get_netlist',
1084
+ description: 'Export the current schematic netlist via the OFFICIAL API (SCH_ManufactureData.getNetlistFile) to verify connectivity. ' +
1085
+ 'Requires the official bridge connected. Triggers: 导网表, get netlist, 核对网络.',
1086
+ bridgeToolName: 'sch.netlist',
1087
+ renderSummary: (r) => `网表: ${typeof r === 'string' ? r : JSON.stringify(r)}`,
1088
+ }),
1089
+ bridgeTool(edaHandle, backend, {
1090
+ name: 'eda_get_bom',
1091
+ description: 'Export the current schematic BOM via the OFFICIAL API (SCH_ManufactureData.getBomFile) with refdes/packages. ' +
1092
+ 'Requires the official bridge connected. Triggers: 导 BOM, get bom, 导出物料清单.',
1093
+ bridgeToolName: 'sch.bom',
1094
+ renderSummary: (r) => {
1095
+ let parsed = r
1096
+ if (typeof r === 'string') { try { parsed = JSON.parse(r) } catch { /* raw */ } }
1097
+ if (parsed != null && typeof parsed === 'object' && typeof parsed.b64 === 'string') {
1098
+ return `BOM: ${parsed.name ?? 'Export_BOM.xlsx'}(${((parsed.size ?? 0) / 1024).toFixed(1)}KB,二进制 xlsx)`
1099
+ }
1100
+ return `BOM: ${typeof r === 'string' ? r.slice(0, 400) : ''}`
1101
+ },
1102
+ }),
1103
+ ].map((tool) => ctx.tools.register(tool))
1104
+ return () => { for (const dispose of disposers) dispose() }
1105
+ }, 'dsh-eda: tools')
1106
+ }
1107
+
1108
+ sync()
1109
+
1110
+ // FULLY-SELF-CONTAINED lifecycle: if the official bridge is installed, the
1111
+ // plugin starts it and connects by itself — no external babysitting.
1112
+ if (autoStartBridge && resolve().enabled && isBridgeInstalled()) {
1113
+ void backend.start().catch(() => { /* status() surfaces the error */ })
1114
+ }
1115
+
1116
+ ctx.effect(() => () => {
1117
+ backend.dispose()
1118
+ }, 'dsh-eda: backend')
1119
+ }