@huaqiu/dsh-tool-quote 0.0.0-stage → 0.5.4
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/cordis.patch.yml +5 -0
- package/lib/index.d.mts +64 -0
- package/lib/index.mjs +382 -0
- package/package.json +37 -3
- package/src/client.ts +154 -0
- package/src/config.ts +57 -0
- package/src/index.ts +117 -0
- package/src/tools.ts +257 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 深圳华秋智联股份有限公司
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/cordis.patch.yml
ADDED
package/lib/index.d.mts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/config.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Runtime config resolution for `@huaqiu/dsh-tool-quote`.
|
|
5
|
+
*
|
|
6
|
+
* The plugin never opens a direct connection to the price providers — it only
|
|
7
|
+
* talks to `hq-edge`, which owns the quote capability (hq.fab.v1.QuoteService
|
|
8
|
+
* bridged over HTTP, see apps/server/src/routes/quote.ts on hq-edge). The base
|
|
9
|
+
* URL is delivered by the hq-edge supervisor as overlay config
|
|
10
|
+
* (`hqEdgeBaseUrl`), with `HQ_EDGE_BASE_URL` as env fallback — the same
|
|
11
|
+
* convention as `@huaqiu/dsh-eda-host` / `@huaqiu/dsh-auth`.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
interface QuoteToolConfig {
|
|
16
|
+
/** HQ Edge base URL, e.g. "http://localhost:18080". Absent → no host. */
|
|
17
|
+
hqEdgeBaseUrl?: string;
|
|
18
|
+
/** Path prefix on the host; default "/api/v1/quote". */
|
|
19
|
+
quotePathPrefix?: string;
|
|
20
|
+
/** End-to-end budget for one quote request, in milliseconds (default 30_000). */
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/client.d.ts
|
|
25
|
+
/** Semantic failure of a quote request, mirroring hq-edge's envelope kinds. */
|
|
26
|
+
declare class QuoteToolError extends Error {
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
readonly status: number;
|
|
29
|
+
readonly detail?: string | undefined;
|
|
30
|
+
readonly code?: string | number | undefined;
|
|
31
|
+
constructor(kind: string, message: string, status: number, detail?: string | undefined, code?: string | number | undefined);
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/index.d.ts
|
|
35
|
+
/** Plugin id — matches package.json. */
|
|
36
|
+
declare const name = "@huaqiu/dsh-tool-quote";
|
|
37
|
+
/** Cordis services this half depends on: the node tool registry + hq-edge
|
|
38
|
+
* bridge (REQUIRED — quote cannot work standalone). */
|
|
39
|
+
declare const inject: readonly ["hqEdge", "tools"];
|
|
40
|
+
declare module '@deepseek-ai/cordis' {
|
|
41
|
+
interface Context {
|
|
42
|
+
/**
|
|
43
|
+
* Provided by the hq-edge `edge-bridge` plugin (the HOST). `baseUrl` is
|
|
44
|
+
* the loopback HQ Edge endpoint, e.g. "http://localhost:18080". Read
|
|
45
|
+
* lazily on every request; REQUIRED for this plugin to load.
|
|
46
|
+
*/
|
|
47
|
+
hqEdge: {
|
|
48
|
+
baseUrl?: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Host plugin body — register the two quote tools.
|
|
54
|
+
*
|
|
55
|
+
* The HQ Edge endpoint is resolved lazily at request time from the
|
|
56
|
+
* `ctx.hqEdge` service (edge-bridge), falling back to the overlay config /
|
|
57
|
+
* `HQ_EDGE_BASE_URL` env (same convention as `@huaqiu/dsh-eda-host`).
|
|
58
|
+
*
|
|
59
|
+
* @param ctx - real cordis context (node side).
|
|
60
|
+
* @returns disposer — unregisters the tools on plugin dispose.
|
|
61
|
+
*/
|
|
62
|
+
declare function apply(ctx: Context, config?: Partial<QuoteToolConfig>): () => void;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { type QuoteToolConfig, QuoteToolError, apply, inject, name };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { getLogger } from "@huaqiu/dsh-plugin-log";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
4
|
+
function resolveQuoteToolConfig(config, env = process.env) {
|
|
5
|
+
const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "";
|
|
6
|
+
const pathPrefix = config?.quotePathPrefix ?? env.HQ_EDGE_QUOTE_PATH ?? "/api/v1/quote";
|
|
7
|
+
const timeoutRaw = config?.requestTimeoutMs ?? env.HQ_EDGE_REQUEST_TIMEOUT_MS;
|
|
8
|
+
const timeout = Number.parseInt(String(timeoutRaw ?? ""), 10);
|
|
9
|
+
return {
|
|
10
|
+
hqEdgeBaseUrl: baseUrl,
|
|
11
|
+
quotePathPrefix: pathPrefix,
|
|
12
|
+
requestTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** True when a host base URL is available (host mode). */
|
|
16
|
+
function hasHost(config) {
|
|
17
|
+
return typeof config.hqEdgeBaseUrl === "string" && config.hqEdgeBaseUrl.trim().length > 0;
|
|
18
|
+
}
|
|
19
|
+
/** Build the absolute URL for one quote kind route ("pcb" | "smt"). */
|
|
20
|
+
function quoteUrlOf(config, kind) {
|
|
21
|
+
return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.quotePathPrefix ?? "/api/v1/quote").replace(/^\/+|\/+$/g, "")}/${kind}`;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/client.ts
|
|
25
|
+
/** Semantic failure of a quote request, mirroring hq-edge's envelope kinds. */
|
|
26
|
+
var QuoteToolError = class extends Error {
|
|
27
|
+
kind;
|
|
28
|
+
status;
|
|
29
|
+
detail;
|
|
30
|
+
code;
|
|
31
|
+
constructor(kind, message, status, detail, code) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.kind = kind;
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.detail = detail;
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "QuoteToolError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** HTTP status → semantic kind (hq-edge envelope, mirrors eda-host mapping). */
|
|
41
|
+
function statusToKind(status) {
|
|
42
|
+
if (status === 400) return "VALIDATION";
|
|
43
|
+
if (status === 501) return "UNIMPLEMENTED";
|
|
44
|
+
if (status === 503) return "UNAVAILABLE";
|
|
45
|
+
if (status === 504) return "DEADLINE_EXCEEDED";
|
|
46
|
+
return "INTERNAL";
|
|
47
|
+
}
|
|
48
|
+
function isAbortError(err) {
|
|
49
|
+
const name = err?.name;
|
|
50
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
51
|
+
}
|
|
52
|
+
function resolveSignal(deadlineMs, caller) {
|
|
53
|
+
const signals = [caller, deadlineMs > 0 ? AbortSignal.timeout(deadlineMs) : void 0].filter((s) => Boolean(s));
|
|
54
|
+
if (signals.length === 0) return void 0;
|
|
55
|
+
if (signals.length === 1) return signals[0];
|
|
56
|
+
const anyFn = AbortSignal.any;
|
|
57
|
+
return typeof anyFn === "function" ? anyFn.call(AbortSignal, signals) : signals[0];
|
|
58
|
+
}
|
|
59
|
+
function createQuoteToolClient(config, deps = {}) {
|
|
60
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
61
|
+
const resolveBaseUrl = () => {
|
|
62
|
+
const fromService = deps.baseUrlResolver?.();
|
|
63
|
+
const base = fromService?.trim().length ? fromService : config.hqEdgeBaseUrl;
|
|
64
|
+
if (!base || base.trim().length === 0) throw new QuoteToolError("FAILED_PRECONDITION", "quote: no hq-edge base URL available (edge-bridge service or HQ_EDGE_BASE_URL)", 412);
|
|
65
|
+
return base.trim();
|
|
66
|
+
};
|
|
67
|
+
const request = async (kind, payload, options = {}) => {
|
|
68
|
+
const base = resolveBaseUrl();
|
|
69
|
+
const url = quoteUrlOf({
|
|
70
|
+
...config,
|
|
71
|
+
hqEdgeBaseUrl: base
|
|
72
|
+
}, kind);
|
|
73
|
+
const signal = resolveSignal(config.requestTimeoutMs ?? 0, options.signal);
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetchImpl(url, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify(payload),
|
|
79
|
+
signal
|
|
80
|
+
});
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
let body;
|
|
83
|
+
try {
|
|
84
|
+
body = text.length > 0 ? JSON.parse(text) : null;
|
|
85
|
+
} catch {
|
|
86
|
+
body = null;
|
|
87
|
+
}
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const envelope = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
90
|
+
throw new QuoteToolError(typeof envelope.error === "string" ? envelope.error : statusToKind(response.status), typeof envelope.detail === "string" ? envelope.detail : "quote request failed", response.status, typeof envelope.detail === "string" ? envelope.detail : void 0, typeof envelope.code === "string" || typeof envelope.code === "number" ? envelope.code : void 0);
|
|
91
|
+
}
|
|
92
|
+
return body;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof QuoteToolError) throw err;
|
|
95
|
+
if (isAbortError(err)) throw new QuoteToolError("DEADLINE_EXCEEDED", "quote request timed out", 504);
|
|
96
|
+
throw new QuoteToolError("INTERNAL", `quote request failed: ${String(err)}`, 500);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
quotePcb: (payload, options) => request("pcb", payload, options),
|
|
101
|
+
quoteSmt: (payload, options) => request("smt", payload, options)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/tools.ts
|
|
106
|
+
/**
|
|
107
|
+
* Agent tools for `@huaqiu/dsh-tool-quote`.
|
|
108
|
+
*
|
|
109
|
+
* quote_pcb real-time PCB fabrication price for the requested region
|
|
110
|
+
* quote_smt real-time SMT assembly price (single-side / double-side)
|
|
111
|
+
*
|
|
112
|
+
* The tools are pure pass-throughs: they read validated arguments, build the
|
|
113
|
+
* canonical quote request (matching hq.fab.v1.QuoteService / the hq-edge
|
|
114
|
+
* router contract), call hq-edge via the client, and return the normalized
|
|
115
|
+
* QuotePrice as lossless JSON. Errors carry a semantic `kind`
|
|
116
|
+
* (FAILED_PRECONDITION / VALIDATION / UNIMPLEMENTED / UPSTREAM_REJECTED /
|
|
117
|
+
* DEADLINE_EXCEEDED / INTERNAL) — the agent sees why a quote failed, not a
|
|
118
|
+
* bare HTTP status.
|
|
119
|
+
*
|
|
120
|
+
* Note on enums: only the enumerated choices are accepted, mirroring the
|
|
121
|
+
* proto/type definitions on hq-edge. Unknown fields are rejected upstream
|
|
122
|
+
* (Zod .strict()), so the tools never send unsupported keys.
|
|
123
|
+
*
|
|
124
|
+
* @module
|
|
125
|
+
*/
|
|
126
|
+
function asJson(value) {
|
|
127
|
+
return JSON.parse(JSON.stringify(value));
|
|
128
|
+
}
|
|
129
|
+
function renderJson(_args, value) {
|
|
130
|
+
return [{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: JSON.stringify(value)
|
|
133
|
+
}];
|
|
134
|
+
}
|
|
135
|
+
/** Pick the EDA derivation flags (only when explicitly true). */
|
|
136
|
+
function edaOf(args) {
|
|
137
|
+
const eda = {};
|
|
138
|
+
if (args.derive_board_size === true) eda.deriveBoardSize = true;
|
|
139
|
+
if (args.derive_board_stackup === true) eda.deriveBoardStackup = true;
|
|
140
|
+
return Object.keys(eda).length > 0 ? eda : void 0;
|
|
141
|
+
}
|
|
142
|
+
function createQuoteTools(env) {
|
|
143
|
+
const { client } = env;
|
|
144
|
+
/** Build the canonical PCB form from the flat tool args. */
|
|
145
|
+
const pcbFormOf = (a) => {
|
|
146
|
+
const form = {};
|
|
147
|
+
if (a.bcount !== void 0) form.bcount = a.bcount;
|
|
148
|
+
if (a.blayer !== void 0) form.blayer = a.blayer;
|
|
149
|
+
if (a.blength !== void 0) form.blength = a.blength;
|
|
150
|
+
if (a.bwidth !== void 0) form.bwidth = a.bwidth;
|
|
151
|
+
if (a.board_tg !== void 0) form.boardTg = a.board_tg;
|
|
152
|
+
if (a.color !== void 0) form.color = a.color;
|
|
153
|
+
if (a.cover !== void 0) form.cover = a.cover;
|
|
154
|
+
if (a.spray !== void 0) form.spray = a.spray;
|
|
155
|
+
return Object.keys(form).length > 0 ? form : void 0;
|
|
156
|
+
};
|
|
157
|
+
/** Optional EDA-host derivation flags shared by both tools. */
|
|
158
|
+
const edaFlagProps = {
|
|
159
|
+
derive_board_size: {
|
|
160
|
+
type: "boolean",
|
|
161
|
+
description: "When true, ask hq-edge to derive board length/width from the current PCB board (approximate bounding box of footprints; only when a board is open in the host)."
|
|
162
|
+
},
|
|
163
|
+
derive_board_stackup: {
|
|
164
|
+
type: "boolean",
|
|
165
|
+
description: "NOT SUPPORTED (explicit capability gap): hq-edge has no board-properties source, so stack-up is never derived or fabricated. Provide blayer/bheight explicitly instead."
|
|
166
|
+
},
|
|
167
|
+
include_raw_response: {
|
|
168
|
+
type: "boolean",
|
|
169
|
+
description: "Include the bounded raw upstream payload for diagnostics (default false)."
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
return [defineTool({
|
|
173
|
+
name: "quote_pcb",
|
|
174
|
+
description: "Get a real-time PCB fabrication price quote (multi-layer, panel pricing, delivery options) for the requested region through Huaqiu/NextPCB. Use when the user asks how much a PCB would cost, \"报价\", \"价格\", delivery time estimates, or comparing fab options. Returns the itemized price breakdown, total price, and delivery alternatives (name + build time in days + total price) with currency symbol (¥ CN / ¥ intl). When derive_board_size / derive_board_stackup is set, the response also carries the effective quote form: EDA-extracted board facts (length/width from the board outline, layer count + thickness from the stack-up in KiCad) merged with the caller overrides — plus server defaults applied on the wire (e.g. bcount default 5, endpoint-required pbnum=1 / insidecopper). Inspect \"form\" in the result, adjust any parameter the user wants to change (e.g. color, blayer, board_tg), and re-query with the corrected form rather than guessing values. Progressive usage: quote_pcb for PCB fabrication, quote_smt for assembly, then the user places the order in the EDA host. This is a quote-only query — no order is created. All regions are anonymous-queryable (no login required). The pricing region is auto-detected by hq-edge from the trusted request context (CN / JP / INTERNATIONAL) — do NOT force a region unless the user explicitly asks for one.",
|
|
175
|
+
parameters: {
|
|
176
|
+
region: {
|
|
177
|
+
type: "string",
|
|
178
|
+
enum: [
|
|
179
|
+
"cn",
|
|
180
|
+
"eu_us",
|
|
181
|
+
"jp"
|
|
182
|
+
],
|
|
183
|
+
description: "Optional explicit override. When omitted, hq-edge auto-detects the pricing region from the trusted request context (CN / JP / INTERNATIONAL). \"cn\" = Huaqiu China (eda.cn, ¥), \"eu_us\" = NextPCB EU/US, \"jp\" = NextPCB Japan (both ¥)."
|
|
184
|
+
},
|
|
185
|
+
bcount: {
|
|
186
|
+
type: "integer",
|
|
187
|
+
description: "Panel quantity (boards per panel), >= 1. Default 5."
|
|
188
|
+
},
|
|
189
|
+
blayer: {
|
|
190
|
+
type: "integer",
|
|
191
|
+
description: "PCB layer count, 1-64. Default 4."
|
|
192
|
+
},
|
|
193
|
+
blength: {
|
|
194
|
+
type: "number",
|
|
195
|
+
description: "Board length in mm, > 0 (canonical mm; converted to cm on the wire)."
|
|
196
|
+
},
|
|
197
|
+
bwidth: {
|
|
198
|
+
type: "number",
|
|
199
|
+
description: "Board width in mm, > 0."
|
|
200
|
+
},
|
|
201
|
+
board_tg: {
|
|
202
|
+
type: "string",
|
|
203
|
+
enum: [
|
|
204
|
+
"tg130",
|
|
205
|
+
"tg150",
|
|
206
|
+
"tg170"
|
|
207
|
+
],
|
|
208
|
+
description: "Glass transition temperature grade (4+ layers only)."
|
|
209
|
+
},
|
|
210
|
+
color: {
|
|
211
|
+
type: "string",
|
|
212
|
+
enum: [
|
|
213
|
+
"green",
|
|
214
|
+
"red",
|
|
215
|
+
"yellow",
|
|
216
|
+
"blue",
|
|
217
|
+
"white",
|
|
218
|
+
"black",
|
|
219
|
+
"matte_black"
|
|
220
|
+
],
|
|
221
|
+
description: "Solder mask color. Default green."
|
|
222
|
+
},
|
|
223
|
+
cover: {
|
|
224
|
+
type: "string",
|
|
225
|
+
enum: [
|
|
226
|
+
"covered",
|
|
227
|
+
"exposed",
|
|
228
|
+
"solder_mask_plug",
|
|
229
|
+
"non_conductive_fill"
|
|
230
|
+
],
|
|
231
|
+
description: "Via process (tenting / open / solder-mask-plug / non-conductive fill). Default covered."
|
|
232
|
+
},
|
|
233
|
+
spray: {
|
|
234
|
+
type: "string",
|
|
235
|
+
enum: [
|
|
236
|
+
"hasl_lead",
|
|
237
|
+
"hasl_lf",
|
|
238
|
+
"enig",
|
|
239
|
+
"osp"
|
|
240
|
+
],
|
|
241
|
+
description: "Surface finish. Default HASL (lead)."
|
|
242
|
+
},
|
|
243
|
+
...edaFlagProps
|
|
244
|
+
},
|
|
245
|
+
output: {
|
|
246
|
+
schema: { type: "json" },
|
|
247
|
+
render: renderJson
|
|
248
|
+
},
|
|
249
|
+
async execute(args, exec) {
|
|
250
|
+
const a = args;
|
|
251
|
+
const payload = {
|
|
252
|
+
region: a.region,
|
|
253
|
+
form: pcbFormOf(a),
|
|
254
|
+
eda: edaOf(a),
|
|
255
|
+
includeRawResponse: a.include_raw_response === true
|
|
256
|
+
};
|
|
257
|
+
return asJson(await client.quotePcb(payload, { signal: exec.signal }));
|
|
258
|
+
}
|
|
259
|
+
}), defineTool({
|
|
260
|
+
name: "quote_smt",
|
|
261
|
+
description: "Get a real-time SMT assembly price quote (single/double side, stencil, X-ray, conformal coating, Huaqiu agent part purchasing) for the requested region through Huaqiu/NextPCB. Use when the user asks about assembly costs, \"贴片价格\", \"SMT 报价\", \"焊接费用\", or delivery time for assembly. Embeds the PCB order (layers/size/quantity) used for the assembly quote. Returns the normalized price with SMT fee detail (no-tax, total, tax) and currency symbol. When derive_board_size / derive_board_stackup is set, the response carries the effective form: EDA-extracted board facts (panel size, embedded PCB length/width/layers/thickness) merged with the caller overrides — inspect \"form\" and re-query with corrected parameters instead of guessing. Quote-only — no order is created. The pricing region is auto-detected by hq-edge from the trusted request context (CN / JP / INTERNATIONAL) — do NOT force a region unless the user explicitly asks.",
|
|
262
|
+
parameters: {
|
|
263
|
+
region: {
|
|
264
|
+
type: "string",
|
|
265
|
+
enum: [
|
|
266
|
+
"cn",
|
|
267
|
+
"eu_us",
|
|
268
|
+
"jp"
|
|
269
|
+
],
|
|
270
|
+
description: "Optional explicit override. When omitted, hq-edge auto-detects the pricing region from the trusted request context (CN / JP / INTERNATIONAL). \"cn\" = Huaqiu China (eda.cn, ¥), \"eu_us\" = NextPCB EU/US, \"jp\" = NextPCB Japan (both ¥)."
|
|
271
|
+
},
|
|
272
|
+
number: {
|
|
273
|
+
type: "string",
|
|
274
|
+
description: "Assembly quantity as string (e.g. \"5\"). Default \"5\"."
|
|
275
|
+
},
|
|
276
|
+
buy_bom: {
|
|
277
|
+
type: "string",
|
|
278
|
+
enum: [
|
|
279
|
+
"hq_select",
|
|
280
|
+
"user_upload",
|
|
281
|
+
"online_match"
|
|
282
|
+
],
|
|
283
|
+
description: "BOM parts source: hq_select = Huaqiu agent purchasing (default), user_upload = customer parts, online_match = online parts matching."
|
|
284
|
+
},
|
|
285
|
+
single_or_double_technique: {
|
|
286
|
+
type: "string",
|
|
287
|
+
enum: ["single", "double"],
|
|
288
|
+
description: "Single-side or double-side assembly. Default double."
|
|
289
|
+
},
|
|
290
|
+
pcb_blayer: {
|
|
291
|
+
type: "integer",
|
|
292
|
+
description: "Embedded PCB layer count, 1-64 (assembly quote depends on the PCB order). Default 4."
|
|
293
|
+
},
|
|
294
|
+
pcb_blength: {
|
|
295
|
+
type: "number",
|
|
296
|
+
description: "Embedded PCB board length in mm, > 0."
|
|
297
|
+
},
|
|
298
|
+
pcb_bwidth: {
|
|
299
|
+
type: "number",
|
|
300
|
+
description: "Embedded PCB board width in mm, > 0."
|
|
301
|
+
},
|
|
302
|
+
pcb_bcount: {
|
|
303
|
+
type: "integer",
|
|
304
|
+
description: "Embedded PCB panel quantity, >= 1. Default 5. NOTE: the embedded board size (pcb_blength/pcb_bwidth) is also used as the assembly panel size (panel = single board) when no explicit panel size is given — the Intl SMT endpoint rejects the request otherwise (\"pcb_width can not empty\", response_code 1003)."
|
|
305
|
+
},
|
|
306
|
+
...edaFlagProps
|
|
307
|
+
},
|
|
308
|
+
output: {
|
|
309
|
+
schema: { type: "json" },
|
|
310
|
+
render: renderJson
|
|
311
|
+
},
|
|
312
|
+
async execute(args, exec) {
|
|
313
|
+
const a = args;
|
|
314
|
+
const form = {};
|
|
315
|
+
if (a.number !== void 0) form.number = a.number;
|
|
316
|
+
if (a.buy_bom !== void 0) form.buyBom = a.buy_bom;
|
|
317
|
+
if (a.single_or_double_technique !== void 0) form.singleOrDoubleTechnique = a.single_or_double_technique;
|
|
318
|
+
const pcb = {};
|
|
319
|
+
if (a.pcb_blayer !== void 0) pcb.blayer = a.pcb_blayer;
|
|
320
|
+
if (a.pcb_blength !== void 0) pcb.blength = a.pcb_blength;
|
|
321
|
+
if (a.pcb_bwidth !== void 0) pcb.bwidth = a.pcb_bwidth;
|
|
322
|
+
if (a.pcb_bcount !== void 0) pcb.bcount = a.pcb_bcount;
|
|
323
|
+
if (Object.keys(pcb).length > 0) form.pcbOrder = pcb;
|
|
324
|
+
const payload = {
|
|
325
|
+
region: a.region,
|
|
326
|
+
form: Object.keys(form).length > 0 ? form : void 0,
|
|
327
|
+
eda: edaOf(a),
|
|
328
|
+
includeRawResponse: a.include_raw_response === true
|
|
329
|
+
};
|
|
330
|
+
return asJson(await client.quoteSmt(payload, { signal: exec.signal }));
|
|
331
|
+
}
|
|
332
|
+
})];
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/index.ts
|
|
336
|
+
/** Plugin id — matches package.json. */
|
|
337
|
+
const name = "@huaqiu/dsh-tool-quote";
|
|
338
|
+
/** Cordis services this half depends on: the node tool registry + hq-edge
|
|
339
|
+
* bridge (REQUIRED — quote cannot work standalone). */
|
|
340
|
+
const inject = ["hqEdge", "tools"];
|
|
341
|
+
/** Shared component name for the unified DSH-plugin log. */
|
|
342
|
+
const COMPONENT = "dsh-tool-quote";
|
|
343
|
+
let _log = null;
|
|
344
|
+
function log() {
|
|
345
|
+
if (_log === null) _log = getLogger(COMPONENT);
|
|
346
|
+
return _log;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Host plugin body — register the two quote tools.
|
|
350
|
+
*
|
|
351
|
+
* The HQ Edge endpoint is resolved lazily at request time from the
|
|
352
|
+
* `ctx.hqEdge` service (edge-bridge), falling back to the overlay config /
|
|
353
|
+
* `HQ_EDGE_BASE_URL` env (same convention as `@huaqiu/dsh-eda-host`).
|
|
354
|
+
*
|
|
355
|
+
* @param ctx - real cordis context (node side).
|
|
356
|
+
* @returns disposer — unregisters the tools on plugin dispose.
|
|
357
|
+
*/
|
|
358
|
+
function apply(ctx, config = {}) {
|
|
359
|
+
if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-tool-quote requires the DSH `tools` service (ctx.tools.register).");
|
|
360
|
+
const resolved = resolveQuoteToolConfig(config);
|
|
361
|
+
const hq = ctx.hqEdge;
|
|
362
|
+
if (!hq || typeof hq.baseUrl !== "string" || hq.baseUrl.trim().length === 0) throw new Error("@huaqiu/dsh-tool-quote requires a usable hq-edge context: the edge-bridge plugin did not provide ctx.hqEdge.baseUrl. Quote tools cannot work without hq-edge — check that the bridge started and that the HQ Edge port is valid.");
|
|
363
|
+
const getHqEdgeBaseUrl = () => {
|
|
364
|
+
const current = ctx.hqEdge;
|
|
365
|
+
return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : void 0;
|
|
366
|
+
};
|
|
367
|
+
log().info("applying dsh-tool-quote node half", {
|
|
368
|
+
hasConfigHost: hasHost(resolved),
|
|
369
|
+
hqEdgeBaseUrlFromConfig: resolved.hqEdgeBaseUrl ?? null,
|
|
370
|
+
quotePathPrefix: resolved.quotePathPrefix,
|
|
371
|
+
requestTimeoutMs: resolved.requestTimeoutMs
|
|
372
|
+
});
|
|
373
|
+
const disposers = createQuoteTools({ client: createQuoteToolClient(resolved, { baseUrlResolver: getHqEdgeBaseUrl }) }).map((tool) => ctx.tools.register(tool));
|
|
374
|
+
log().info("dsh-tool-quote node half ready", { tools: disposers.length });
|
|
375
|
+
return () => {
|
|
376
|
+
for (const dispose of disposers) try {
|
|
377
|
+
dispose();
|
|
378
|
+
} catch {}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
export { QuoteToolError, apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huaqiu/dsh-tool-quote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.mjs",
|
|
6
|
+
"types": "./lib/index.d.mts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.mts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
22
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.0"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@huaqiu/dsh-plugin-log": "0.5.4"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib",
|
|
29
|
+
"src",
|
|
30
|
+
"cordis.patch.yml"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
6
40
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport for `@huaqiu/dsh-tool-quote`.
|
|
3
|
+
*
|
|
4
|
+
* DSH → dsh-tool-quote → hq-edge → price provider is the ONLY production
|
|
5
|
+
* path. This module POSTs the canonical quote request to the hq-edge quote
|
|
6
|
+
* router, maps the stable error envelope back to semantic kinds, and enforces
|
|
7
|
+
* the single end-to-end request budget. It contains no provider logic — that
|
|
8
|
+
* lives in hq-edge (apps/server/src/quote/*).
|
|
9
|
+
*
|
|
10
|
+
* hq-edge error envelope (stable contract):
|
|
11
|
+
*
|
|
12
|
+
* { error, detail, code?, status?, operation? }
|
|
13
|
+
*
|
|
14
|
+
* error → VALIDATION | UNIMPLEMENTED | UPSTREAM_HTTP | UPSTREAM_REJECTED
|
|
15
|
+
* | PARSE | UNAVAILABLE | INTERNAL
|
|
16
|
+
* status → 400 | 501 | 502 | 503 | 504 | 500
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { QuoteToolConfig } from './config.js'
|
|
22
|
+
import { quoteUrlOf } from './config.js'
|
|
23
|
+
|
|
24
|
+
export type QuoteKind = 'pcb' | 'smt'
|
|
25
|
+
|
|
26
|
+
export interface QuoteToolClientDeps {
|
|
27
|
+
fetchImpl?: typeof fetch
|
|
28
|
+
/** Late-bound resolver for the HQ Edge base URL (edge-bridge service). */
|
|
29
|
+
baseUrlResolver?: () => string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QuoteToolRequestOptions {
|
|
33
|
+
signal?: AbortSignal
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Semantic failure of a quote request, mirroring hq-edge's envelope kinds. */
|
|
37
|
+
export class QuoteToolError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
readonly kind: string,
|
|
40
|
+
message: string,
|
|
41
|
+
readonly status: number,
|
|
42
|
+
readonly detail?: string,
|
|
43
|
+
readonly code?: string | number,
|
|
44
|
+
) {
|
|
45
|
+
super(message)
|
|
46
|
+
this.name = 'QuoteToolError'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** HTTP status → semantic kind (hq-edge envelope, mirrors eda-host mapping). */
|
|
51
|
+
function statusToKind(status: number): string {
|
|
52
|
+
if (status === 400) return 'VALIDATION'
|
|
53
|
+
if (status === 501) return 'UNIMPLEMENTED'
|
|
54
|
+
if (status === 503) return 'UNAVAILABLE'
|
|
55
|
+
if (status === 504) return 'DEADLINE_EXCEEDED'
|
|
56
|
+
return 'INTERNAL'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isAbortError(err: unknown): boolean {
|
|
60
|
+
const name = (err as Error | undefined)?.name
|
|
61
|
+
return name === 'AbortError' || name === 'TimeoutError'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveSignal(deadlineMs: number, caller?: AbortSignal): AbortSignal | undefined {
|
|
65
|
+
const budget = deadlineMs > 0 ? AbortSignal.timeout(deadlineMs) : undefined
|
|
66
|
+
const signals = [caller, budget].filter((s): s is AbortSignal => Boolean(s))
|
|
67
|
+
if (signals.length === 0) return undefined
|
|
68
|
+
if (signals.length === 1) return signals[0]
|
|
69
|
+
const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any
|
|
70
|
+
return typeof anyFn === 'function' ? anyFn.call(AbortSignal, signals) : signals[0]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface QuoteToolClient {
|
|
74
|
+
quotePcb(payload: unknown, options?: QuoteToolRequestOptions): Promise<unknown>
|
|
75
|
+
quoteSmt(payload: unknown, options?: QuoteToolRequestOptions): Promise<unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createQuoteToolClient(
|
|
79
|
+
config: QuoteToolConfig,
|
|
80
|
+
deps: QuoteToolClientDeps = {},
|
|
81
|
+
): QuoteToolClient {
|
|
82
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
|
|
83
|
+
|
|
84
|
+
const resolveBaseUrl = (): string => {
|
|
85
|
+
const fromService = deps.baseUrlResolver?.()
|
|
86
|
+
const base = fromService?.trim().length ? fromService : config.hqEdgeBaseUrl
|
|
87
|
+
if (!base || base.trim().length === 0) {
|
|
88
|
+
throw new QuoteToolError(
|
|
89
|
+
'FAILED_PRECONDITION',
|
|
90
|
+
'quote: no hq-edge base URL available (edge-bridge service or HQ_EDGE_BASE_URL)',
|
|
91
|
+
412,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return base.trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const request = async (
|
|
98
|
+
kind: QuoteKind,
|
|
99
|
+
payload: unknown,
|
|
100
|
+
options: QuoteToolRequestOptions = {},
|
|
101
|
+
): Promise<unknown> => {
|
|
102
|
+
const base = resolveBaseUrl()
|
|
103
|
+
const url = quoteUrlOf({ ...config, hqEdgeBaseUrl: base }, kind)
|
|
104
|
+
const signal = resolveSignal(config.requestTimeoutMs ?? 0, options.signal)
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetchImpl(url, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
signal,
|
|
111
|
+
})
|
|
112
|
+
const text = await response.text()
|
|
113
|
+
let body: unknown
|
|
114
|
+
try {
|
|
115
|
+
body = text.length > 0 ? JSON.parse(text) : null
|
|
116
|
+
} catch {
|
|
117
|
+
body = null
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const envelope =
|
|
121
|
+
body && typeof body === 'object' && !Array.isArray(body)
|
|
122
|
+
? (body as Record<string, unknown>)
|
|
123
|
+
: {}
|
|
124
|
+
const kindFromEnvelope =
|
|
125
|
+
typeof envelope.error === 'string' ? envelope.error : statusToKind(response.status)
|
|
126
|
+
throw new QuoteToolError(
|
|
127
|
+
kindFromEnvelope,
|
|
128
|
+
typeof envelope.detail === 'string' ? envelope.detail : 'quote request failed',
|
|
129
|
+
response.status,
|
|
130
|
+
typeof envelope.detail === 'string' ? envelope.detail : undefined,
|
|
131
|
+
typeof envelope.code === 'string' || typeof envelope.code === 'number'
|
|
132
|
+
? envelope.code
|
|
133
|
+
: undefined,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return body
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof QuoteToolError) throw err
|
|
139
|
+
if (isAbortError(err)) {
|
|
140
|
+
throw new QuoteToolError(
|
|
141
|
+
'DEADLINE_EXCEEDED',
|
|
142
|
+
'quote request timed out',
|
|
143
|
+
504,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
throw new QuoteToolError('INTERNAL', `quote request failed: ${String(err)}`, 500)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
quotePcb: (payload, options) => request('pcb', payload, options),
|
|
152
|
+
quoteSmt: (payload, options) => request('smt', payload, options),
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime config resolution for `@huaqiu/dsh-tool-quote`.
|
|
3
|
+
*
|
|
4
|
+
* The plugin never opens a direct connection to the price providers — it only
|
|
5
|
+
* talks to `hq-edge`, which owns the quote capability (hq.fab.v1.QuoteService
|
|
6
|
+
* bridged over HTTP, see apps/server/src/routes/quote.ts on hq-edge). The base
|
|
7
|
+
* URL is delivered by the hq-edge supervisor as overlay config
|
|
8
|
+
* (`hqEdgeBaseUrl`), with `HQ_EDGE_BASE_URL` as env fallback — the same
|
|
9
|
+
* convention as `@huaqiu/dsh-eda-host` / `@huaqiu/dsh-auth`.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface QuoteToolConfig {
|
|
15
|
+
/** HQ Edge base URL, e.g. "http://localhost:18080". Absent → no host. */
|
|
16
|
+
hqEdgeBaseUrl?: string
|
|
17
|
+
/** Path prefix on the host; default "/api/v1/quote". */
|
|
18
|
+
quotePathPrefix?: string
|
|
19
|
+
/** End-to-end budget for one quote request, in milliseconds (default 30_000). */
|
|
20
|
+
requestTimeoutMs?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_QUOTE_PATH_PREFIX = '/api/v1/quote'
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
|
26
|
+
|
|
27
|
+
export function resolveQuoteToolConfig(
|
|
28
|
+
config?: Partial<QuoteToolConfig> | null,
|
|
29
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
30
|
+
): QuoteToolConfig {
|
|
31
|
+
const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? ''
|
|
32
|
+
const pathPrefix =
|
|
33
|
+
config?.quotePathPrefix ?? env.HQ_EDGE_QUOTE_PATH ?? DEFAULT_QUOTE_PATH_PREFIX
|
|
34
|
+
const timeoutRaw = config?.requestTimeoutMs ?? env.HQ_EDGE_REQUEST_TIMEOUT_MS
|
|
35
|
+
const timeout = Number.parseInt(String(timeoutRaw ?? ''), 10)
|
|
36
|
+
return {
|
|
37
|
+
hqEdgeBaseUrl: baseUrl,
|
|
38
|
+
quotePathPrefix: pathPrefix,
|
|
39
|
+
requestTimeoutMs:
|
|
40
|
+
Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when a host base URL is available (host mode). */
|
|
45
|
+
export function hasHost(config: QuoteToolConfig): boolean {
|
|
46
|
+
return typeof config.hqEdgeBaseUrl === 'string' && config.hqEdgeBaseUrl.trim().length > 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build the absolute URL for one quote kind route ("pcb" | "smt"). */
|
|
50
|
+
export function quoteUrlOf(config: QuoteToolConfig, kind: 'pcb' | 'smt'): string {
|
|
51
|
+
const base = (config.hqEdgeBaseUrl ?? '').replace(/\/+$/, '')
|
|
52
|
+
const prefix = (config.quotePathPrefix ?? DEFAULT_QUOTE_PATH_PREFIX).replace(
|
|
53
|
+
/^\/+|\/+$/g,
|
|
54
|
+
'',
|
|
55
|
+
)
|
|
56
|
+
return `${base}/${prefix}/${kind}`
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Huaqiu EDA Quote DSH tool plugin (node half) — `@huaqiu/dsh-tool-quote`.
|
|
3
|
+
*
|
|
4
|
+
* Exposes real-time PCB / SMT price querying as two agent-visible tools:
|
|
5
|
+
*
|
|
6
|
+
* quote_pcb → hq-edge POST /api/v1/quote/pcb
|
|
7
|
+
* quote_smt → hq-edge POST /api/v1/quote/smt
|
|
8
|
+
*
|
|
9
|
+
* ── Architectural boundary ─────────────────────────────────────────────────
|
|
10
|
+
* Quote logic is owned ONCE by hq-edge (hq.fab.v1.QuoteService bridged over
|
|
11
|
+
* HTTP — apps/server/src/quote/*). This plugin is a thin adapter: it
|
|
12
|
+
* translates tool calls into canonical quote requests and returns the
|
|
13
|
+
* normalized QuotePrice. It contains NO provider logic (no eda.cn / nextpcb
|
|
14
|
+
* endpoints, no form serialization, no parsing) and no `@hqedge/*`
|
|
15
|
+
* dependency — it only talks to hq-edge through the edge-bridge.
|
|
16
|
+
*
|
|
17
|
+
* `hqEdge` is a REQUIRED inject: the plugin cannot reach hq-edge without the
|
|
18
|
+
* bridge's `ctx.hqEdge.baseUrl`. This matches the `@huaqiu/dsh-eda-host`
|
|
19
|
+
* contract (docs/tasks/expose-capability.md §3/§7) — quote tools are hq-edge
|
|
20
|
+
* capabilities, not standalone DSH features.
|
|
21
|
+
*
|
|
22
|
+
* @module @huaqiu/dsh-tool-quote
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
26
|
+
import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
|
|
27
|
+
import { createQuoteToolClient } from './client.js'
|
|
28
|
+
import {
|
|
29
|
+
hasHost,
|
|
30
|
+
resolveQuoteToolConfig,
|
|
31
|
+
type QuoteToolConfig,
|
|
32
|
+
} from './config.js'
|
|
33
|
+
import { createQuoteTools } from './tools.js'
|
|
34
|
+
|
|
35
|
+
/** Plugin id — matches package.json. */
|
|
36
|
+
export const name = '@huaqiu/dsh-tool-quote'
|
|
37
|
+
|
|
38
|
+
/** Cordis services this half depends on: the node tool registry + hq-edge
|
|
39
|
+
* bridge (REQUIRED — quote cannot work standalone). */
|
|
40
|
+
export const inject = ['hqEdge', 'tools'] as const
|
|
41
|
+
|
|
42
|
+
export type { QuoteToolConfig } from './config.js'
|
|
43
|
+
export { QuoteToolError } from './client.js'
|
|
44
|
+
|
|
45
|
+
declare module '@deepseek-ai/cordis' {
|
|
46
|
+
interface Context {
|
|
47
|
+
/**
|
|
48
|
+
* Provided by the hq-edge `edge-bridge` plugin (the HOST). `baseUrl` is
|
|
49
|
+
* the loopback HQ Edge endpoint, e.g. "http://localhost:18080". Read
|
|
50
|
+
* lazily on every request; REQUIRED for this plugin to load.
|
|
51
|
+
*/
|
|
52
|
+
hqEdge: { baseUrl?: string }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shared component name for the unified DSH-plugin log. */
|
|
57
|
+
const COMPONENT = 'dsh-tool-quote'
|
|
58
|
+
|
|
59
|
+
let _log: PluginLogger | null = null
|
|
60
|
+
function log(): PluginLogger {
|
|
61
|
+
if (_log === null) _log = getLogger(COMPONENT)
|
|
62
|
+
return _log
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Host plugin body — register the two quote tools.
|
|
67
|
+
*
|
|
68
|
+
* The HQ Edge endpoint is resolved lazily at request time from the
|
|
69
|
+
* `ctx.hqEdge` service (edge-bridge), falling back to the overlay config /
|
|
70
|
+
* `HQ_EDGE_BASE_URL` env (same convention as `@huaqiu/dsh-eda-host`).
|
|
71
|
+
*
|
|
72
|
+
* @param ctx - real cordis context (node side).
|
|
73
|
+
* @returns disposer — unregisters the tools on plugin dispose.
|
|
74
|
+
*/
|
|
75
|
+
export function apply(ctx: Context, config: Partial<QuoteToolConfig> = {}): () => void {
|
|
76
|
+
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
77
|
+
throw new Error('@huaqiu/dsh-tool-quote requires the DSH `tools` service (ctx.tools.register).')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolved = resolveQuoteToolConfig(config)
|
|
81
|
+
|
|
82
|
+
const hq = ctx.hqEdge
|
|
83
|
+
if (!hq || typeof hq.baseUrl !== 'string' || hq.baseUrl.trim().length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'@huaqiu/dsh-tool-quote requires a usable hq-edge context: the edge-bridge ' +
|
|
86
|
+
'plugin did not provide ctx.hqEdge.baseUrl. Quote tools cannot work without ' +
|
|
87
|
+
'hq-edge — check that the bridge started and that the HQ Edge port is valid.',
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const getHqEdgeBaseUrl = (): string | undefined => {
|
|
92
|
+
const current = ctx.hqEdge
|
|
93
|
+
return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
log().info('applying dsh-tool-quote node half', {
|
|
97
|
+
hasConfigHost: hasHost(resolved),
|
|
98
|
+
hqEdgeBaseUrlFromConfig: resolved.hqEdgeBaseUrl ?? null,
|
|
99
|
+
quotePathPrefix: resolved.quotePathPrefix,
|
|
100
|
+
requestTimeoutMs: resolved.requestTimeoutMs,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const client = createQuoteToolClient(resolved, { baseUrlResolver: getHqEdgeBaseUrl })
|
|
104
|
+
const disposers = createQuoteTools({ client }).map((tool) => ctx.tools.register(tool))
|
|
105
|
+
|
|
106
|
+
log().info('dsh-tool-quote node half ready', { tools: disposers.length })
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
for (const dispose of disposers) {
|
|
110
|
+
try {
|
|
111
|
+
dispose()
|
|
112
|
+
} catch {
|
|
113
|
+
// best-effort teardown
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools for `@huaqiu/dsh-tool-quote`.
|
|
3
|
+
*
|
|
4
|
+
* quote_pcb real-time PCB fabrication price for the requested region
|
|
5
|
+
* quote_smt real-time SMT assembly price (single-side / double-side)
|
|
6
|
+
*
|
|
7
|
+
* The tools are pure pass-throughs: they read validated arguments, build the
|
|
8
|
+
* canonical quote request (matching hq.fab.v1.QuoteService / the hq-edge
|
|
9
|
+
* router contract), call hq-edge via the client, and return the normalized
|
|
10
|
+
* QuotePrice as lossless JSON. Errors carry a semantic `kind`
|
|
11
|
+
* (FAILED_PRECONDITION / VALIDATION / UNIMPLEMENTED / UPSTREAM_REJECTED /
|
|
12
|
+
* DEADLINE_EXCEEDED / INTERNAL) — the agent sees why a quote failed, not a
|
|
13
|
+
* bare HTTP status.
|
|
14
|
+
*
|
|
15
|
+
* Note on enums: only the enumerated choices are accepted, mirroring the
|
|
16
|
+
* proto/type definitions on hq-edge. Unknown fields are rejected upstream
|
|
17
|
+
* (Zod .strict()), so the tools never send unsupported keys.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import type { QuoteToolClient } from './client.js'
|
|
24
|
+
|
|
25
|
+
/** Structural alias of the DSH `JsonValue`. */
|
|
26
|
+
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
|
|
27
|
+
|
|
28
|
+
function asJson<T>(value: T): Json {
|
|
29
|
+
return JSON.parse(JSON.stringify(value)) as Json
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderJson(_args: unknown, value: unknown) {
|
|
33
|
+
return [{ type: 'text' as const, text: JSON.stringify(value) }]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Tool execution context (structural view of DSH's ToolRunContext). */
|
|
37
|
+
interface ToolExecLike {
|
|
38
|
+
signal?: AbortSignal
|
|
39
|
+
callId?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface QuoteToolEnv {
|
|
43
|
+
client: QuoteToolClient
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pick the EDA derivation flags (only when explicitly true). */
|
|
47
|
+
function edaOf(args: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
48
|
+
const eda: Record<string, unknown> = {}
|
|
49
|
+
if (args.derive_board_size === true) eda.deriveBoardSize = true
|
|
50
|
+
if (args.derive_board_stackup === true) eda.deriveBoardStackup = true
|
|
51
|
+
return Object.keys(eda).length > 0 ? eda : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createQuoteTools(env: QuoteToolEnv) {
|
|
55
|
+
const { client } = env
|
|
56
|
+
|
|
57
|
+
/** Build the canonical PCB form from the flat tool args. */
|
|
58
|
+
const pcbFormOf = (a: Record<string, unknown>): Record<string, unknown> | undefined => {
|
|
59
|
+
const form: Record<string, unknown> = {}
|
|
60
|
+
if (a.bcount !== undefined) form.bcount = a.bcount
|
|
61
|
+
if (a.blayer !== undefined) form.blayer = a.blayer
|
|
62
|
+
if (a.blength !== undefined) form.blength = a.blength
|
|
63
|
+
if (a.bwidth !== undefined) form.bwidth = a.bwidth
|
|
64
|
+
if (a.board_tg !== undefined) form.boardTg = a.board_tg
|
|
65
|
+
if (a.color !== undefined) form.color = a.color
|
|
66
|
+
if (a.cover !== undefined) form.cover = a.cover
|
|
67
|
+
if (a.spray !== undefined) form.spray = a.spray
|
|
68
|
+
return Object.keys(form).length > 0 ? form : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Optional EDA-host derivation flags shared by both tools. */
|
|
72
|
+
const edaFlagProps = {
|
|
73
|
+
derive_board_size: {
|
|
74
|
+
type: 'boolean' as const,
|
|
75
|
+
description:
|
|
76
|
+
'When true, ask hq-edge to derive board length/width from the current PCB board ' +
|
|
77
|
+
'(approximate bounding box of footprints; only when a board is open in the host).',
|
|
78
|
+
},
|
|
79
|
+
derive_board_stackup: {
|
|
80
|
+
type: 'boolean' as const,
|
|
81
|
+
description:
|
|
82
|
+
'NOT SUPPORTED (explicit capability gap): hq-edge has no board-properties source, ' +
|
|
83
|
+
'so stack-up is never derived or fabricated. Provide blayer/bheight explicitly instead.',
|
|
84
|
+
},
|
|
85
|
+
include_raw_response: {
|
|
86
|
+
type: 'boolean' as const,
|
|
87
|
+
description: 'Include the bounded raw upstream payload for diagnostics (default false).',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
defineTool({
|
|
93
|
+
name: 'quote_pcb',
|
|
94
|
+
description:
|
|
95
|
+
'Get a real-time PCB fabrication price quote (multi-layer, panel pricing, delivery options) ' +
|
|
96
|
+
'for the requested region through Huaqiu/NextPCB. Use when the user asks how much a PCB ' +
|
|
97
|
+
'would cost, "报价", "价格", delivery time estimates, or comparing fab options. ' +
|
|
98
|
+
'Returns the itemized price breakdown, total price, and delivery alternatives ' +
|
|
99
|
+
'(name + build time in days + total price) with currency symbol (¥ CN / ¥ intl). ' +
|
|
100
|
+
'When derive_board_size / derive_board_stackup is set, the response also carries the ' +
|
|
101
|
+
'effective quote form: EDA-extracted board facts (length/width from the board outline, ' +
|
|
102
|
+
'layer count + thickness from the stack-up in KiCad) merged with the caller overrides — ' +
|
|
103
|
+
'plus server defaults applied on the wire (e.g. bcount default 5, endpoint-required ' +
|
|
104
|
+
'pbnum=1 / insidecopper). Inspect "form" in the result, adjust any parameter the user ' +
|
|
105
|
+
'wants to change (e.g. color, blayer, board_tg), and re-query with the corrected form ' +
|
|
106
|
+
'rather than guessing values. Progressive usage: quote_pcb for PCB fabrication, quote_smt ' +
|
|
107
|
+
'for assembly, then the user places the order in the EDA host. This is a quote-only ' +
|
|
108
|
+
'query — no order is created. All regions are anonymous-queryable (no login required). ' +
|
|
109
|
+
'The pricing region is auto-detected by hq-edge from the trusted request context (CN / JP / ' +
|
|
110
|
+
'INTERNATIONAL) — do NOT force a region unless the user explicitly asks for one.',
|
|
111
|
+
parameters: {
|
|
112
|
+
region: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
enum: ['cn', 'eu_us', 'jp'],
|
|
115
|
+
description:
|
|
116
|
+
'Optional explicit override. When omitted, hq-edge auto-detects the pricing region from ' +
|
|
117
|
+
'the trusted request context (CN / JP / INTERNATIONAL). "cn" = Huaqiu China (eda.cn, ¥), ' +
|
|
118
|
+
'"eu_us" = NextPCB EU/US, "jp" = NextPCB Japan (both ¥).',
|
|
119
|
+
},
|
|
120
|
+
bcount: {
|
|
121
|
+
type: 'integer',
|
|
122
|
+
description: 'Panel quantity (boards per panel), >= 1. Default 5.',
|
|
123
|
+
},
|
|
124
|
+
blayer: {
|
|
125
|
+
type: 'integer',
|
|
126
|
+
description: 'PCB layer count, 1-64. Default 4.',
|
|
127
|
+
},
|
|
128
|
+
blength: {
|
|
129
|
+
type: 'number',
|
|
130
|
+
description: 'Board length in mm, > 0 (canonical mm; converted to cm on the wire).',
|
|
131
|
+
},
|
|
132
|
+
bwidth: {
|
|
133
|
+
type: 'number',
|
|
134
|
+
description: 'Board width in mm, > 0.',
|
|
135
|
+
},
|
|
136
|
+
board_tg: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
enum: ['tg130', 'tg150', 'tg170'],
|
|
139
|
+
description: 'Glass transition temperature grade (4+ layers only).',
|
|
140
|
+
},
|
|
141
|
+
color: {
|
|
142
|
+
type: 'string',
|
|
143
|
+
enum: ['green', 'red', 'yellow', 'blue', 'white', 'black', 'matte_black'],
|
|
144
|
+
description: 'Solder mask color. Default green.',
|
|
145
|
+
},
|
|
146
|
+
cover: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
enum: ['covered', 'exposed', 'solder_mask_plug', 'non_conductive_fill'],
|
|
149
|
+
description:
|
|
150
|
+
'Via process (tenting / open / solder-mask-plug / non-conductive fill). Default covered.',
|
|
151
|
+
},
|
|
152
|
+
spray: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
enum: ['hasl_lead', 'hasl_lf', 'enig', 'osp'],
|
|
155
|
+
description: 'Surface finish. Default HASL (lead).',
|
|
156
|
+
},
|
|
157
|
+
...edaFlagProps,
|
|
158
|
+
},
|
|
159
|
+
output: { schema: { type: 'json' }, render: renderJson },
|
|
160
|
+
async execute(args: unknown, exec: ToolExecLike): Promise<Json> {
|
|
161
|
+
const a = args as Record<string, unknown>
|
|
162
|
+
const payload = {
|
|
163
|
+
region: a.region,
|
|
164
|
+
form: pcbFormOf(a),
|
|
165
|
+
eda: edaOf(a),
|
|
166
|
+
includeRawResponse: a.include_raw_response === true,
|
|
167
|
+
}
|
|
168
|
+
const result = await client.quotePcb(payload, { signal: exec.signal })
|
|
169
|
+
return asJson(result)
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
defineTool({
|
|
173
|
+
name: 'quote_smt',
|
|
174
|
+
description:
|
|
175
|
+
'Get a real-time SMT assembly price quote (single/double side, stencil, X-ray, conformal coating, ' +
|
|
176
|
+
'Huaqiu agent part purchasing) for the requested region through Huaqiu/NextPCB. Use when the user ' +
|
|
177
|
+
'asks about assembly costs, "贴片价格", "SMT 报价", "焊接费用", or delivery time for assembly. ' +
|
|
178
|
+
'Embeds the PCB order (layers/size/quantity) used for the assembly quote. Returns the normalized ' +
|
|
179
|
+
'price with SMT fee detail (no-tax, total, tax) and currency symbol. When derive_board_size / ' +
|
|
180
|
+
'derive_board_stackup is set, the response carries the effective form: EDA-extracted board facts ' +
|
|
181
|
+
'(panel size, embedded PCB length/width/layers/thickness) merged with the caller overrides — ' +
|
|
182
|
+
'inspect "form" and re-query with corrected parameters instead of guessing. Quote-only — no ' +
|
|
183
|
+
'order is created. The pricing region is auto-detected by hq-edge from the trusted request ' +
|
|
184
|
+
'context (CN / JP / INTERNATIONAL) — do NOT force a region unless the user explicitly asks.',
|
|
185
|
+
parameters: {
|
|
186
|
+
region: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
enum: ['cn', 'eu_us', 'jp'],
|
|
189
|
+
description:
|
|
190
|
+
'Optional explicit override. When omitted, hq-edge auto-detects the pricing region from ' +
|
|
191
|
+
'the trusted request context (CN / JP / INTERNATIONAL). "cn" = Huaqiu China (eda.cn, ¥), ' +
|
|
192
|
+
'"eu_us" = NextPCB EU/US, "jp" = NextPCB Japan (both ¥).',
|
|
193
|
+
},
|
|
194
|
+
number: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
description: 'Assembly quantity as string (e.g. "5"). Default "5".',
|
|
197
|
+
},
|
|
198
|
+
buy_bom: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
enum: ['hq_select', 'user_upload', 'online_match'],
|
|
201
|
+
description:
|
|
202
|
+
'BOM parts source: hq_select = Huaqiu agent purchasing (default), ' +
|
|
203
|
+
'user_upload = customer parts, online_match = online parts matching.',
|
|
204
|
+
},
|
|
205
|
+
single_or_double_technique: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
enum: ['single', 'double'],
|
|
208
|
+
description: 'Single-side or double-side assembly. Default double.',
|
|
209
|
+
},
|
|
210
|
+
pcb_blayer: {
|
|
211
|
+
type: 'integer',
|
|
212
|
+
description:
|
|
213
|
+
'Embedded PCB layer count, 1-64 (assembly quote depends on the PCB order). Default 4.',
|
|
214
|
+
},
|
|
215
|
+
pcb_blength: {
|
|
216
|
+
type: 'number',
|
|
217
|
+
description: 'Embedded PCB board length in mm, > 0.',
|
|
218
|
+
},
|
|
219
|
+
pcb_bwidth: {
|
|
220
|
+
type: 'number',
|
|
221
|
+
description: 'Embedded PCB board width in mm, > 0.',
|
|
222
|
+
},
|
|
223
|
+
pcb_bcount: {
|
|
224
|
+
type: 'integer',
|
|
225
|
+
description:
|
|
226
|
+
'Embedded PCB panel quantity, >= 1. Default 5. NOTE: the embedded board size ' +
|
|
227
|
+
'(pcb_blength/pcb_bwidth) is also used as the assembly panel size (panel = single ' +
|
|
228
|
+
'board) when no explicit panel size is given — the Intl SMT endpoint rejects the ' +
|
|
229
|
+
'request otherwise ("pcb_width can not empty", response_code 1003).',
|
|
230
|
+
},
|
|
231
|
+
...edaFlagProps,
|
|
232
|
+
},
|
|
233
|
+
output: { schema: { type: 'json' }, render: renderJson }, async execute(args: unknown, exec: ToolExecLike): Promise<Json> {
|
|
234
|
+
const a = args as Record<string, unknown>
|
|
235
|
+
const form: Record<string, unknown> = {}
|
|
236
|
+
if (a.number !== undefined) form.number = a.number
|
|
237
|
+
if (a.buy_bom !== undefined) form.buyBom = a.buy_bom
|
|
238
|
+
if (a.single_or_double_technique !== undefined)
|
|
239
|
+
form.singleOrDoubleTechnique = a.single_or_double_technique
|
|
240
|
+
const pcb: Record<string, unknown> = {}
|
|
241
|
+
if (a.pcb_blayer !== undefined) pcb.blayer = a.pcb_blayer
|
|
242
|
+
if (a.pcb_blength !== undefined) pcb.blength = a.pcb_blength
|
|
243
|
+
if (a.pcb_bwidth !== undefined) pcb.bwidth = a.pcb_bwidth
|
|
244
|
+
if (a.pcb_bcount !== undefined) pcb.bcount = a.pcb_bcount
|
|
245
|
+
if (Object.keys(pcb).length > 0) form.pcbOrder = pcb
|
|
246
|
+
const payload = {
|
|
247
|
+
region: a.region,
|
|
248
|
+
form: Object.keys(form).length > 0 ? form : undefined,
|
|
249
|
+
eda: edaOf(a),
|
|
250
|
+
includeRawResponse: a.include_raw_response === true,
|
|
251
|
+
}
|
|
252
|
+
const result = await client.quoteSmt(payload, { signal: exec.signal })
|
|
253
|
+
return asJson(result)
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
]
|
|
257
|
+
}
|
package/README.md
DELETED