@danceiny/gotry 0.0.1-rc.19 → 0.0.1-rc.21
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/README.md +14 -3
- package/README.zh-CN.md +15 -4
- package/bin/gotry-bootstrap.js +136 -45
- package/bin/gotry-inner.js +11 -6
- package/cordis.gotry-patch.yml +19 -8
- package/dist/capabilities/anything.js +62 -29
- package/dist/capabilities/doctor.js +113 -19
- package/dist/capabilities/hbcli.js +28 -2
- package/dist/capabilities/session/health-watch.js +10 -3
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -1
- package/dist/scripts/anything-tests.js +70 -21
- package/dist/scripts/benchmark-environment-bridge-e2e.js +178 -33
- package/dist/scripts/benchmark-environment-bridge-tests.js +1480 -271
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +211 -2
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +132 -2
- package/dist/scripts/bootstrap-tests.js +30 -3
- package/dist/scripts/doctor-tests.js +55 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -1
- package/dist/scripts/hbcli-tests.js +38 -1
- package/dist/scripts/health-watch-cli.js +9 -1
- package/dist/scripts/map-tools-vendor-package-proof.js +276 -0
- package/dist/scripts/persona-surface-guard-tests.js +8 -3
- package/dist/src/benchmark-agent-conformance.js +187 -17
- package/dist/src/benchmark-environment-bridge.js +287 -102
- package/dist/src/booking-surface/dsh-planner.js +24 -8
- package/dist/src/index.js +5 -2
- package/extension/manifest.json +2 -2
- package/package.json +3 -1
- package/ts/capabilities/anything.ts +89 -38
- package/ts/capabilities/hbcli.ts +53 -4
- package/ts/capabilities/session/health-watch.ts +9 -2
- package/ts/dsh-runtime/vendor/README.md +60 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
- package/ts/package.json +17 -1
- package/ts/scripts/map-tools-vendor-package-proof.ts +268 -0
- package/ts/src/benchmark-agent-conformance.ts +175 -13
- package/ts/src/benchmark-environment-bridge.ts +220 -66
- package/ts/src/booking-surface/dsh-planner.ts +25 -13
- package/ts/src/index.ts +4 -2
- package/ts/src/turn-deadline.ts +4 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/** Amap (高德) Web Service API client. */
|
|
2
|
+
import { formatLngLat } from '../types.js';
|
|
3
|
+
const REST = 'https://restapi.amap.com/';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// 配额保护层
|
|
6
|
+
//
|
|
7
|
+
// 高德个人开发者 key 的 QPS 上限极低(常见 3 QPS/秒,超限返回错误码 10021
|
|
8
|
+
// CUQPS_HAS_EXCEEDED_THE_LIMIT),而一次工具调用(尤其 map_transit_route)
|
|
9
|
+
// 内部可能连发 1~5 个请求;多个工具并行时瞬时请求数很容易击穿配额,导致
|
|
10
|
+
// 用户看到一串 10021 报错。这里做三层防护:
|
|
11
|
+
//
|
|
12
|
+
// 1. 限速排队:RateLimiter 把任意两次请求的最小间隔钳制在 1000/maxQps
|
|
13
|
+
// (默认 2 QPS,低于高德常见上限、留余量),并发请求排队等待而不是
|
|
14
|
+
// 同时发出;
|
|
15
|
+
// 2. 结果缓存:geocode / route / POI 按参数做 TTL 内存缓存,同一会话中
|
|
16
|
+
// 相同请求直接命中缓存,不再消耗配额(transit 的 resolveCity 会命中
|
|
17
|
+
// resolve 刚写下的 geocode 缓存,省掉重复请求);
|
|
18
|
+
// 3. 错误分类 + 重试:10020/10021(QPS 超限)标记为可重试,在客户端内
|
|
19
|
+
// 退避重试;10022/10023(日配额超限)重试无意义,直接给出友好中文
|
|
20
|
+
// 提示。上层工具仍可捕获 AmapQuotaError 做降级(见 tools/routes.ts)。
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** 配额类错误(QPS 或日配额超限)。retryable=true 时短期内重试可能成功
|
|
23
|
+
* (QPS 超限),false 表示重试无意义(日配额已用尽)。 */
|
|
24
|
+
export class AmapQuotaError extends Error {
|
|
25
|
+
infocode;
|
|
26
|
+
retryable;
|
|
27
|
+
constructor(infocode, message, retryable) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.infocode = infocode;
|
|
30
|
+
this.retryable = retryable;
|
|
31
|
+
this.name = 'AmapQuotaError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Sleep that honors an AbortSignal (rejects with the abort reason). */
|
|
35
|
+
function sleep(ms, signal) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let timer;
|
|
38
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
39
|
+
const onAbort = () => {
|
|
40
|
+
if (timer)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? 'aborted')));
|
|
44
|
+
};
|
|
45
|
+
if (signal.aborted) {
|
|
46
|
+
onAbort();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve();
|
|
53
|
+
}, ms);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 串行链 + 最小间隔限速器:所有请求排成一条 promise 链,每个请求在拿到
|
|
58
|
+
* 自己的发送窗口前等待(与前一个请求至少间隔 intervalMs)。并发调用
|
|
59
|
+
* acquire 时天然排队,不会同时发出请求。
|
|
60
|
+
*/
|
|
61
|
+
class RateLimiter {
|
|
62
|
+
intervalMs;
|
|
63
|
+
tail = Promise.resolve();
|
|
64
|
+
lastSend = 0;
|
|
65
|
+
constructor(intervalMs) {
|
|
66
|
+
this.intervalMs = intervalMs;
|
|
67
|
+
}
|
|
68
|
+
/** 排队等待自己的发送窗口;signal 中断时抛错。 */
|
|
69
|
+
acquire(signal) {
|
|
70
|
+
const run = this.tail.then(async () => {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const wait = Math.max(0, this.lastSend + this.intervalMs - now);
|
|
73
|
+
if (wait > 0)
|
|
74
|
+
await sleep(wait, signal);
|
|
75
|
+
this.lastSend = Date.now();
|
|
76
|
+
});
|
|
77
|
+
// 链继续推进:某个请求失败(如被 abort)不阻塞后续请求。
|
|
78
|
+
this.tail = run.catch(() => undefined);
|
|
79
|
+
return run;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** 简单的 TTL + 容量上限内存缓存(插入序近似 LRU 淘汰)。 */
|
|
83
|
+
class TtlCache {
|
|
84
|
+
ttlMs;
|
|
85
|
+
maxEntries;
|
|
86
|
+
store = new Map();
|
|
87
|
+
constructor(ttlMs, maxEntries = 300) {
|
|
88
|
+
this.ttlMs = ttlMs;
|
|
89
|
+
this.maxEntries = maxEntries;
|
|
90
|
+
}
|
|
91
|
+
get(key) {
|
|
92
|
+
const entry = this.store.get(key);
|
|
93
|
+
if (!entry)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (entry.expiresAt < Date.now()) {
|
|
96
|
+
this.store.delete(key);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
// LRU touch:删了重插,保持最近使用的在末尾。
|
|
100
|
+
this.store.delete(key);
|
|
101
|
+
this.store.set(key, entry);
|
|
102
|
+
return entry.value;
|
|
103
|
+
}
|
|
104
|
+
set(key, value) {
|
|
105
|
+
if (this.store.size >= this.maxEntries) {
|
|
106
|
+
const oldest = this.store.keys().next().value;
|
|
107
|
+
if (oldest !== undefined)
|
|
108
|
+
this.store.delete(oldest);
|
|
109
|
+
}
|
|
110
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** 各请求类型的缓存 TTL。 */
|
|
114
|
+
const CACHE_TTL = {
|
|
115
|
+
geo: 24 * 60 * 60 * 1000, // 地理编码结果基本不变
|
|
116
|
+
route: 60 * 60 * 1000, // 路线/路况 1 小时内可复用
|
|
117
|
+
poi: 60 * 60 * 1000,
|
|
118
|
+
};
|
|
119
|
+
export class AmapClient {
|
|
120
|
+
opts;
|
|
121
|
+
limiter;
|
|
122
|
+
geoCache = new TtlCache(CACHE_TTL.geo);
|
|
123
|
+
routeCache = new TtlCache(CACHE_TTL.route);
|
|
124
|
+
poiCache = new TtlCache(CACHE_TTL.poi);
|
|
125
|
+
constructor(opts) {
|
|
126
|
+
this.opts = opts;
|
|
127
|
+
// 钳制在 [0.2, 100] QPS:避免配置了 0/负数,也避免测试设极大值时的计时噪声。
|
|
128
|
+
const qps = Math.max(0.2, Math.min(100, opts.maxQps ?? 2));
|
|
129
|
+
this.limiter = new RateLimiter(1000 / qps);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 带配额保护的统一请求入口:限速排队 → 缓存命中 → 请求 → 可重试错误退避
|
|
133
|
+
* 重试 → 写入缓存。
|
|
134
|
+
*/
|
|
135
|
+
async request(path, params, signal, opts = {}) {
|
|
136
|
+
await this.limiter.acquire(signal);
|
|
137
|
+
if (opts.cache && opts.cacheKey) {
|
|
138
|
+
const hit = opts.cache.get(opts.cacheKey);
|
|
139
|
+
if (hit !== undefined)
|
|
140
|
+
return hit;
|
|
141
|
+
}
|
|
142
|
+
const retries = opts.retries ?? 0;
|
|
143
|
+
let attempt = 0;
|
|
144
|
+
for (;;) {
|
|
145
|
+
try {
|
|
146
|
+
const body = await this.rawGet(path, params, signal);
|
|
147
|
+
if (opts.cache && opts.cacheKey)
|
|
148
|
+
opts.cache.set(opts.cacheKey, body);
|
|
149
|
+
return body;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err instanceof AmapQuotaError && err.retryable && attempt < retries) {
|
|
153
|
+
attempt++;
|
|
154
|
+
// 简单线性退避:250ms / 500ms / 750ms …
|
|
155
|
+
await sleep(250 * attempt, signal);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** 裸 GET:拼 URL、带 key、超时 + abort、错误分类。 */
|
|
163
|
+
async rawGet(path, params, signal) {
|
|
164
|
+
const url = new URL(`${REST}${path}`);
|
|
165
|
+
url.searchParams.set('key', this.opts.key);
|
|
166
|
+
for (const [k, v] of Object.entries(params)) {
|
|
167
|
+
if (v !== undefined && v !== '')
|
|
168
|
+
url.searchParams.set(k, v);
|
|
169
|
+
}
|
|
170
|
+
// Combine the caller's abort signal with a per-request timeout.
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
const timer = setTimeout(() => controller.abort(new Error(`Amap request timed out after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs);
|
|
173
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
174
|
+
if (signal.aborted)
|
|
175
|
+
controller.abort(signal.reason);
|
|
176
|
+
else
|
|
177
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
178
|
+
let res;
|
|
179
|
+
try {
|
|
180
|
+
res = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
184
|
+
throw new Error(`高德地图服务请求失败(${reason})。请检查 amapKey 是否有效:https://console.amap.com/dev/key/app`);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
signal.removeEventListener('abort', onAbort);
|
|
189
|
+
}
|
|
190
|
+
if (!res.ok)
|
|
191
|
+
throw new Error(`Amap HTTP ${res.status}: ${res.statusText}`);
|
|
192
|
+
const body = (await res.json());
|
|
193
|
+
if (body.status === '1')
|
|
194
|
+
return body;
|
|
195
|
+
const infocode = body.infocode ?? '?';
|
|
196
|
+
const info = body.info ?? 'unknown';
|
|
197
|
+
// Key-related errors are the most common user mistake — guide them to fix it.
|
|
198
|
+
if (infocode === '10001' || infocode === '10003' || /INVALID_USER_KEY|USER_KEY_PLAT_NOMATCH/i.test(info)) {
|
|
199
|
+
throw new Error(`高德 key 无效或未生效(${infocode}: ${info})。请检查插件配置中的 amapKey 是否正确,或前往 https://console.amap.com/dev/key/app 重新申请。`);
|
|
200
|
+
}
|
|
201
|
+
// 配额类错误:QPS 超限(10020/10021)可重试,日配额超限(10022/10023)不可。
|
|
202
|
+
if (infocode === '10020' || infocode === '10021' || /QPS_HAS_EXCEEDED/i.test(info)) {
|
|
203
|
+
throw new AmapQuotaError(infocode, `高德请求过于频繁(QPS 超限:${info})。插件已自动限速排队并重试;若持续出现,可在插件配置中调低 maxQps 减少并发。`, true);
|
|
204
|
+
}
|
|
205
|
+
if (infocode === '10022' || infocode === '10023' || /QUOTA_HAS_EXCEEDED/i.test(info)) {
|
|
206
|
+
throw new AmapQuotaError(infocode, `高德今日配额已用尽(${info})。请明天再试,或前往 https://console.amap.com/dev/key/app 查看当前配额。`, false);
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`Amap API error ${infocode}: ${info}`);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Plan a route. `mode` maps to the Amap endpoint.
|
|
212
|
+
* Transit requires city1/city2 (origin/destination city names).
|
|
213
|
+
*/
|
|
214
|
+
async route(origin, destination, mode, opts = {}, signal) {
|
|
215
|
+
if (mode === 'transit') {
|
|
216
|
+
return this.transitRoute(origin, destination, opts, signal);
|
|
217
|
+
}
|
|
218
|
+
const path = `v5/direction/${mode}`;
|
|
219
|
+
const cacheKey = `route:${mode}:${formatLngLat(origin)}:${formatLngLat(destination)}`;
|
|
220
|
+
const body = await this.request(path, { origin: formatLngLat(origin), destination: formatLngLat(destination) }, signal, { cache: this.routeCache, cacheKey, retries: 1 });
|
|
221
|
+
const path0 = body.route.paths[0];
|
|
222
|
+
if (!path0)
|
|
223
|
+
throw new Error('Amap returned no route path');
|
|
224
|
+
const steps = (path0.steps ?? []).map((s) => ({
|
|
225
|
+
instruction: s.instruction ?? '',
|
|
226
|
+
distanceM: Number(s.distance ?? 0),
|
|
227
|
+
durationS: Number(s.duration ?? 0),
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
provider: 'amap',
|
|
231
|
+
distanceM: Number(path0.distance ?? 0),
|
|
232
|
+
durationS: Number(path0.duration ?? 0),
|
|
233
|
+
polyline: steps.map((s) => s.instruction).join(' → '),
|
|
234
|
+
points: [origin, destination],
|
|
235
|
+
steps,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async transitRoute(origin, destination, opts, signal) {
|
|
239
|
+
// Amap transit requires city1 (and ideally city2). If resolution failed,
|
|
240
|
+
// surface a readable error instead of a bare INVALID_PARAMS.
|
|
241
|
+
if (!opts.city1 || !opts.city2) {
|
|
242
|
+
const missing = !opts.city1 ? 'city1(起点城市)' : 'city2(终点城市)';
|
|
243
|
+
throw new Error(`公交路径规划需要 ${missing},但城市解析失败。请改用更具体的地址,或直接提供 "lng,lat" 坐标。`);
|
|
244
|
+
}
|
|
245
|
+
const cacheKey = `route:transit:${formatLngLat(origin)}:${formatLngLat(destination)}:${opts.city1}:${opts.city2}`;
|
|
246
|
+
const body = await this.request('v5/direction/transit/integrated', {
|
|
247
|
+
origin: formatLngLat(origin),
|
|
248
|
+
destination: formatLngLat(destination),
|
|
249
|
+
city1: opts.city1 ?? '',
|
|
250
|
+
city2: opts.city2 ?? '',
|
|
251
|
+
}, signal, { cache: this.routeCache, cacheKey, retries: 1 });
|
|
252
|
+
const transit0 = body.route.transits[0];
|
|
253
|
+
if (!transit0)
|
|
254
|
+
throw new Error('Amap returned no transit plan');
|
|
255
|
+
const steps = (transit0.segments ?? []).map((seg, i) => {
|
|
256
|
+
if (seg.bus?.buslines?.length) {
|
|
257
|
+
const line = seg.bus.buslines[0];
|
|
258
|
+
const from = line.departure_stop?.name ?? '';
|
|
259
|
+
const to = line.arrival_stop?.name ?? '';
|
|
260
|
+
return {
|
|
261
|
+
instruction: `乘坐 ${line.name ?? '公交'}(${from} → ${to})`,
|
|
262
|
+
distanceM: 0,
|
|
263
|
+
durationS: 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (seg.walking) {
|
|
267
|
+
return {
|
|
268
|
+
instruction: `步行 ${i + 1}`,
|
|
269
|
+
distanceM: Number(seg.walking.distance ?? 0),
|
|
270
|
+
durationS: Number(seg.walking.duration ?? 0),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { instruction: '换乘', distanceM: 0, durationS: 0 };
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
provider: 'amap',
|
|
277
|
+
distanceM: Number(transit0.distance ?? 0),
|
|
278
|
+
durationS: Number(transit0.duration ?? 0),
|
|
279
|
+
polyline: steps.map((s) => s.instruction).join(' → '),
|
|
280
|
+
points: [origin, destination],
|
|
281
|
+
steps,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** Forward geocode: address → coordinates. */
|
|
285
|
+
async geocode(address, signal) {
|
|
286
|
+
const cacheKey = `geo:${address}`;
|
|
287
|
+
const body = await this.request('v3/geocode/geo', { address }, signal, { cache: this.geoCache, cacheKey });
|
|
288
|
+
const first = body.geocodes?.[0];
|
|
289
|
+
if (!first)
|
|
290
|
+
throw new Error(`Amap could not geocode address: ${address}`);
|
|
291
|
+
const [lng, lat] = first.location.split(',').map(Number);
|
|
292
|
+
if (!Number.isFinite(lng) || !Number.isFinite(lat))
|
|
293
|
+
throw new Error('Amap geocode returned invalid location');
|
|
294
|
+
return {
|
|
295
|
+
provider: 'amap',
|
|
296
|
+
formatted: first.formatted_address ?? address,
|
|
297
|
+
location: [lng, lat],
|
|
298
|
+
city: normalizeCity(first.city, first.province),
|
|
299
|
+
district: first.district,
|
|
300
|
+
adcode: first.adcode,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Reverse geocode: coordinates → address. */
|
|
304
|
+
async reverseGeocode(location, signal) {
|
|
305
|
+
const cacheKey = `regeo:${formatLngLat(location)}`;
|
|
306
|
+
const body = await this.request('v3/geocode/regeo', { location: formatLngLat(location) }, signal, { cache: this.geoCache, cacheKey });
|
|
307
|
+
const re = body.regeocode;
|
|
308
|
+
if (!re)
|
|
309
|
+
throw new Error('Amap reverse geocode returned no result');
|
|
310
|
+
const comp = re.addressComponent;
|
|
311
|
+
return {
|
|
312
|
+
provider: 'amap',
|
|
313
|
+
formatted: re.formatted_address ?? formatLngLat(location),
|
|
314
|
+
location,
|
|
315
|
+
city: normalizeCity(comp?.city, comp?.province),
|
|
316
|
+
district: comp?.district,
|
|
317
|
+
adcode: comp?.adcode,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** POI text search. */
|
|
321
|
+
async poiSearch(keywords, opts, signal) {
|
|
322
|
+
const region = opts.region ?? '';
|
|
323
|
+
const cacheKey = `poi:text:${keywords}:${region}:${opts.cityLimit ? '1' : '0'}`;
|
|
324
|
+
const body = await this.request('v5/place/text', { keywords, region, city_limit: opts.cityLimit ? 'true' : 'false' }, signal, { cache: this.poiCache, cacheKey });
|
|
325
|
+
return (body.pois ?? []).map((p) => {
|
|
326
|
+
const [lng, lat] = p.location.split(',').map(Number);
|
|
327
|
+
return {
|
|
328
|
+
name: p.name,
|
|
329
|
+
location: [lng, lat],
|
|
330
|
+
type: p.type,
|
|
331
|
+
address: p.address,
|
|
332
|
+
tel: p.tel,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** POI around search (nearest first by default). */
|
|
337
|
+
async poiAround(location, keywords, opts, signal) {
|
|
338
|
+
const radius = opts.radiusM ? String(opts.radiusM) : '';
|
|
339
|
+
const types = opts.types ?? '';
|
|
340
|
+
const cacheKey = `poi:around:${formatLngLat(location)}:${keywords}:${radius}:${types}`;
|
|
341
|
+
const body = await this.request('v5/place/around', {
|
|
342
|
+
location: formatLngLat(location),
|
|
343
|
+
keywords,
|
|
344
|
+
radius,
|
|
345
|
+
types,
|
|
346
|
+
}, signal, { cache: this.poiCache, cacheKey });
|
|
347
|
+
return (body.pois ?? []).map((p) => {
|
|
348
|
+
const [lng, lat] = p.location.split(',').map(Number);
|
|
349
|
+
return {
|
|
350
|
+
name: p.name,
|
|
351
|
+
location: [lng, lat],
|
|
352
|
+
type: p.type,
|
|
353
|
+
address: p.address,
|
|
354
|
+
tel: p.tel,
|
|
355
|
+
distanceM: p.distance ? Number(p.distance) : undefined,
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Normalize Amap's `city` field to a plain string.
|
|
362
|
+
*
|
|
363
|
+
* 高德对直辖市(北京/上海/天津/重庆)的 addressComponent.city 返回空数组 [],
|
|
364
|
+
* 城市名实际在 province 字段里。数组取首元素,空值回退 province。
|
|
365
|
+
*/
|
|
366
|
+
function normalizeCity(city, province) {
|
|
367
|
+
const value = Array.isArray(city) ? city[0] : city;
|
|
368
|
+
if (typeof value === 'string' && value)
|
|
369
|
+
return value;
|
|
370
|
+
return typeof province === 'string' && province ? province : '';
|
|
371
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Nominatim (OpenStreetMap) geocoding client — free fallback, 1 QPS policy. */
|
|
2
|
+
const PUBLIC_NOMINATIM = 'https://nominatim.openstreetmap.org';
|
|
3
|
+
export class NominatimClient {
|
|
4
|
+
opts;
|
|
5
|
+
constructor(opts) {
|
|
6
|
+
this.opts = opts;
|
|
7
|
+
}
|
|
8
|
+
async get(path, params, signal) {
|
|
9
|
+
const url = new URL(`${this.opts.baseUrl ?? PUBLIC_NOMINATIM}${path}`);
|
|
10
|
+
for (const [k, v] of Object.entries(params))
|
|
11
|
+
url.searchParams.set(k, v);
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await fetch(url, {
|
|
15
|
+
signal,
|
|
16
|
+
headers: { Accept: 'application/json', 'User-Agent': this.opts.userAgent },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
// Network-layer failure (DNS, timeout, blocked host). On CN networks
|
|
21
|
+
// nominatim.openstreetmap.org is often unreachable — guide the user to
|
|
22
|
+
// the Amap key instead of surfacing a bare `fetch failed`.
|
|
23
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`无法访问 Nominatim 免费地理编码服务(${reason})。部分网络环境下该服务不可达,建议在插件配置中设置高德 amapKey(https://console.amap.com/dev/key/app)以获得稳定的地理编码。`);
|
|
25
|
+
}
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(`Nominatim HTTP ${res.status}: ${res.statusText}`);
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
/** Forward geocode. */
|
|
31
|
+
async geocode(query, signal) {
|
|
32
|
+
const body = (await this.get('/search', { format: 'jsonv2', q: query, limit: '1' }, signal));
|
|
33
|
+
const first = body?.[0];
|
|
34
|
+
if (!first)
|
|
35
|
+
throw new Error(`Nominatim could not geocode: ${query}`);
|
|
36
|
+
const lat = Number(first.lat);
|
|
37
|
+
const lng = Number(first.lon);
|
|
38
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng))
|
|
39
|
+
throw new Error('Nominatim returned invalid coordinates');
|
|
40
|
+
const city = first.address?.city ?? first.address?.town ?? first.address?.village;
|
|
41
|
+
return {
|
|
42
|
+
provider: 'nominatim',
|
|
43
|
+
formatted: first.display_name ?? query,
|
|
44
|
+
location: [lng, lat],
|
|
45
|
+
city,
|
|
46
|
+
district: first.address?.district,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Reverse geocode. */
|
|
50
|
+
async reverseGeocode(location, signal) {
|
|
51
|
+
const body = (await this.get('/reverse', { format: 'jsonv2', lat: String(location[1]), lon: String(location[0]) }, signal));
|
|
52
|
+
if (!body?.display_name)
|
|
53
|
+
throw new Error('Nominatim reverse geocode returned no result');
|
|
54
|
+
const city = body.address?.city ?? body.address?.town ?? body.address?.village;
|
|
55
|
+
return {
|
|
56
|
+
provider: 'nominatim',
|
|
57
|
+
formatted: body.display_name,
|
|
58
|
+
location,
|
|
59
|
+
city,
|
|
60
|
+
district: body.address?.district,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** OSRM public server client — free route fallback (no API key). */
|
|
2
|
+
import { formatLngLat } from '../types.js';
|
|
3
|
+
const PUBLIC_OSRM = 'https://router.project-osrm.org';
|
|
4
|
+
export class OsrmClient {
|
|
5
|
+
opts;
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.opts = opts;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Plan a route. `profile` maps to OSRM profiles: driving | walking | cycling.
|
|
11
|
+
* OSRM has no transit profile — callers must not request transit here.
|
|
12
|
+
*/
|
|
13
|
+
async route(origin, destination, profile, signal) {
|
|
14
|
+
const url = new URL(`${this.opts.baseUrl ?? PUBLIC_OSRM}/route/v1/${profile}/${formatLngLat(origin)};${formatLngLat(destination)}`);
|
|
15
|
+
url.searchParams.set('overview', 'full');
|
|
16
|
+
url.searchParams.set('geometries', 'geojson');
|
|
17
|
+
url.searchParams.set('steps', 'true');
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(url, { signal, headers: { Accept: 'application/json' } });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`无法访问 OSRM 免费路线服务(${reason})。部分网络环境下该服务不可达,建议在插件配置中设置高德 amapKey(https://console.amap.com/dev/key/app)以获得稳定的路线规划。`);
|
|
25
|
+
}
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(`OSRM HTTP ${res.status}: ${res.statusText}`);
|
|
28
|
+
const body = (await res.json());
|
|
29
|
+
if (body.code !== 'Ok' || !body.routes?.length) {
|
|
30
|
+
throw new Error(`OSRM error: ${body.code ?? 'unknown'} (no route between the given points)`);
|
|
31
|
+
}
|
|
32
|
+
const route = body.routes[0];
|
|
33
|
+
const steps = [];
|
|
34
|
+
for (const leg of route.legs ?? []) {
|
|
35
|
+
for (const s of leg.steps ?? []) {
|
|
36
|
+
const modifier = s.maneuver?.modifier ? ` (${s.maneuver.modifier})` : '';
|
|
37
|
+
steps.push({
|
|
38
|
+
instruction: `${s.maneuver?.instruction ?? 'continue'} ${s.name ?? ''}${modifier}`.trim(),
|
|
39
|
+
distanceM: s.distance ?? 0,
|
|
40
|
+
durationS: s.duration ?? 0,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const points = typeof route.geometry === 'string'
|
|
45
|
+
? []
|
|
46
|
+
: (route.geometry.coordinates ?? []).map(([lng, lat]) => [lng, lat]);
|
|
47
|
+
return {
|
|
48
|
+
provider: 'osrm',
|
|
49
|
+
distanceM: route.distance ?? 0,
|
|
50
|
+
durationS: route.duration ?? 0,
|
|
51
|
+
polyline: JSON.stringify(route.geometry),
|
|
52
|
+
points,
|
|
53
|
+
steps,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const PUBLIC_PHOTON = 'https://photon.komoot.io';
|
|
2
|
+
export class PhotonClient {
|
|
3
|
+
opts;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
this.opts = opts;
|
|
6
|
+
}
|
|
7
|
+
async get(path, params, signal) {
|
|
8
|
+
const url = new URL(`${this.opts.baseUrl ?? PUBLIC_PHOTON}${path}`);
|
|
9
|
+
for (const [k, v] of Object.entries(params))
|
|
10
|
+
url.searchParams.set(k, v);
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await fetch(url, { signal, headers: { Accept: 'application/json' } });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
17
|
+
throw new Error(`无法访问 Photon 免费地理编码服务(${reason})。部分网络环境下该服务不可达,建议在插件配置中设置高德 amapKey(https://console.amap.com/dev/key/app)以获得稳定的地理编码。`);
|
|
18
|
+
}
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`Photon HTTP ${res.status}: ${res.statusText}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
/** Forward geocode. */
|
|
24
|
+
async geocode(query, signal) {
|
|
25
|
+
const body = (await this.get('/api/', { q: query, limit: '1', lang: this.opts.lang ?? 'zh' }, signal));
|
|
26
|
+
const first = body.features?.[0];
|
|
27
|
+
if (!first?.geometry?.coordinates) {
|
|
28
|
+
throw new Error(`免费地理编码未找到该地址:${query}。请尝试提供 "lng,lat" 坐标,或配置高德 amapKey 获得更全的地址库。`);
|
|
29
|
+
}
|
|
30
|
+
const [lng, lat] = first.geometry.coordinates;
|
|
31
|
+
const props = first.properties ?? {};
|
|
32
|
+
return {
|
|
33
|
+
provider: 'photon',
|
|
34
|
+
formatted: props.name ?? query,
|
|
35
|
+
location: [lng, lat],
|
|
36
|
+
city: props.city,
|
|
37
|
+
district: props.state,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Reverse geocode. */
|
|
41
|
+
async reverseGeocode(location, signal) {
|
|
42
|
+
const body = (await this.get('/reverse', { lon: String(location[0]), lat: String(location[1]) }, signal));
|
|
43
|
+
const first = body.features?.[0];
|
|
44
|
+
if (!first)
|
|
45
|
+
throw new Error('Photon reverse geocode returned no result');
|
|
46
|
+
const props = first.properties ?? {};
|
|
47
|
+
return {
|
|
48
|
+
provider: 'photon',
|
|
49
|
+
formatted: [props.name, props.city, props.state].filter(Boolean).join(',') || `${location[0]},${location[1]}`,
|
|
50
|
+
location,
|
|
51
|
+
city: props.city,
|
|
52
|
+
district: props.state,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-map-tools persisted config file (~/.dsh-map-tools/config.json).
|
|
3
|
+
*
|
|
4
|
+
* Follows the modlens pattern: the plugin's API keys and provider choice live
|
|
5
|
+
* in its own file (shared across profiles, never in the DSH settings
|
|
6
|
+
* document), and the settings card reads/writes it through a loopback route.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, lstatSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
/** The file the plugin's settings card reads and writes. Overridable for tests. */
|
|
12
|
+
export function configPath() {
|
|
13
|
+
const override = process.env.DSH_MAP_TOOLS_CONFIG;
|
|
14
|
+
return override !== undefined && override !== ''
|
|
15
|
+
? override
|
|
16
|
+
: join(homedir(), '.dsh-map-tools', 'config.json');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read the shared config, or a thrown error. Only a missing file reads as
|
|
20
|
+
* empty: an existing-but-unparsable file is somebody's configuration, and a
|
|
21
|
+
* card that treated it as empty would overwrite it on the next save.
|
|
22
|
+
*/
|
|
23
|
+
export function readConfig() {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = readFileSync(configPath(), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error?.code === 'ENOENT')
|
|
30
|
+
return {};
|
|
31
|
+
throw new Error(`cannot read ${configPath()}: ${error?.message ?? error}`);
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`cannot parse ${configPath()}: ${error?.message ?? error}`);
|
|
39
|
+
}
|
|
40
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
41
|
+
throw new Error(`${configPath()} must hold a JSON object`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
/** Deprecated config keys, purged on every save (Baidu provider dropped in 0.3.0). */
|
|
46
|
+
const DEPRECATED_KEYS = ['baiduAk'];
|
|
47
|
+
/** Persist a patch onto the config file, then return the new whole. */
|
|
48
|
+
export function applyConfig(patch) {
|
|
49
|
+
const current = readConfig();
|
|
50
|
+
for (const key of ['provider', 'amapKey', 'timeoutMs', 'maxQps']) {
|
|
51
|
+
if (patch[key] !== undefined) {
|
|
52
|
+
if (key === 'amapKey' && patch.amapKey === '')
|
|
53
|
+
delete current.amapKey;
|
|
54
|
+
else
|
|
55
|
+
current[key] = patch[key];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Purge deprecated fields (e.g. baiduAk from the pre-0.3.0 Baidu provider)
|
|
59
|
+
// so dead keys don't linger in the config file.
|
|
60
|
+
for (const key of DEPRECATED_KEYS) {
|
|
61
|
+
if (key in current)
|
|
62
|
+
delete current[key];
|
|
63
|
+
}
|
|
64
|
+
mkdirSync(dirname(configPath()), { recursive: true });
|
|
65
|
+
writeFileSync(configPath(), `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
return current;
|
|
67
|
+
}
|
|
68
|
+
/** Whether the config file exists (the card can offer "open config file"). */
|
|
69
|
+
export function configFileExists() {
|
|
70
|
+
try {
|
|
71
|
+
lstatSync(configPath());
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Non-secret summary served to the settings card (keys are never echoed). */
|
|
79
|
+
export function configSummary() {
|
|
80
|
+
const c = readConfig();
|
|
81
|
+
return {
|
|
82
|
+
provider: c.provider,
|
|
83
|
+
timeoutMs: c.timeoutMs,
|
|
84
|
+
maxQps: c.maxQps,
|
|
85
|
+
hasAmapKey: typeof c.amapKey === 'string' && c.amapKey !== '',
|
|
86
|
+
};
|
|
87
|
+
}
|