@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +119 -136
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/bin/gotry-inner.js +2 -1
- package/client/client.js +228 -0
- package/dist/capabilities/doctor.js +14 -23
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +37 -10
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +93 -5
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +119 -0
- package/dist/scripts/gotry-web-client-tests.js +47 -0
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +3 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +219 -0
- package/dist/src/index.js +45 -17
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +189 -0
- package/ts/src/index.ts +46 -18
package/dist/src/index.js
CHANGED
|
@@ -42,6 +42,8 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
|
|
|
42
42
|
import { interpretEffect, declinedObservation } from '../capabilities/effect.js';
|
|
43
43
|
import { customerSendCode, customerLogin } from '../capabilities/hbcli.js';
|
|
44
44
|
import { FLYAI_POI_CATEGORIES } from '../capabilities/flyai.js';
|
|
45
|
+
import { registerGotryWebApi } from './gotry-web-api.js';
|
|
46
|
+
import { registerArtifactDeliveries } from './artifact-delivery.js';
|
|
45
47
|
import { createFlyaiSetupTool } from './flyai-setup-tool.js';
|
|
46
48
|
import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.js';
|
|
47
49
|
import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.js';
|
|
@@ -429,6 +431,10 @@ export function apply(ctx, config, seams = {}) {
|
|
|
429
431
|
// 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
|
|
430
432
|
// 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
|
|
431
433
|
// 必须产出最终交付物,不得再次 handoff(否则无限递归)。
|
|
434
|
+
if (!rawBenchmarkEnvironmentConfigPath) {
|
|
435
|
+
registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin);
|
|
436
|
+
registerArtifactDeliveries(ctx);
|
|
437
|
+
}
|
|
432
438
|
const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
|
|
433
439
|
installTurnDeadline(ctx, handoffChild
|
|
434
440
|
? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
|
|
@@ -1299,10 +1305,15 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1299
1305
|
const resp = itp.result;
|
|
1300
1306
|
const dir = await ensureStateDir(config.stateRoot);
|
|
1301
1307
|
const isLive = resp.via === 'hbcli-realtime';
|
|
1302
|
-
const
|
|
1308
|
+
const staticStays = resp.hotels?.stays;
|
|
1309
|
+
const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0;
|
|
1310
|
+
const liveList = resp.hotels?.list;
|
|
1311
|
+
const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error';
|
|
1312
|
+
const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence;
|
|
1303
1313
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => { });
|
|
1304
1314
|
const payload = {
|
|
1305
|
-
ok:
|
|
1315
|
+
ok: isLive || hasEstimate,
|
|
1316
|
+
verdict,
|
|
1306
1317
|
hotels: resp.hotels ?? null,
|
|
1307
1318
|
evidence,
|
|
1308
1319
|
destination: q.destination,
|
|
@@ -1310,6 +1321,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1310
1321
|
latency_ms: Date.now() - started,
|
|
1311
1322
|
summary: resp.summary,
|
|
1312
1323
|
error: resp.error,
|
|
1324
|
+
...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
|
|
1313
1325
|
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
1314
1326
|
};
|
|
1315
1327
|
return JSON.parse(JSON.stringify(payload));
|
|
@@ -1336,13 +1348,14 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1336
1348
|
};
|
|
1337
1349
|
}
|
|
1338
1350
|
const h = r.hotels;
|
|
1339
|
-
const
|
|
1351
|
+
const liveList = h?.list;
|
|
1352
|
+
const liveCount = Array.isArray(liveList) ? liveList.length : 0;
|
|
1340
1353
|
// 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
|
|
1341
1354
|
const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
|
|
1342
1355
|
const staticCount = Array.isArray(stays) ? stays.length : 0;
|
|
1343
1356
|
const tag = r.via === 'hbcli-realtime'
|
|
1344
|
-
?
|
|
1345
|
-
: staticCount ? `静态包 ${staticCount} 块` :
|
|
1357
|
+
? `实时 ${liveCount} 家`
|
|
1358
|
+
: staticCount ? `静态包 ${staticCount} 块` : '查询未完成';
|
|
1346
1359
|
return {
|
|
1347
1360
|
card: 'generic',
|
|
1348
1361
|
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
@@ -1725,8 +1738,9 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1725
1738
|
description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1726
1739
|
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
1727
1740
|
+ 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
1728
|
-
+ 'kind="flight": from/to
|
|
1729
|
-
+ '
|
|
1741
|
+
+ 'kind="flight": from/to city or airport names (Chinese/English) + date YYYY-MM-DD; unknown names resolve from supplier metadata, ambiguous names require confirmation; optional fromCityCode/toCityCode are explicit Ctrip three-letter codes — sniffs the site search API for structured options. '
|
|
1742
|
+
+ 'ui?="bridge" (default; the user\'s own logged-in session, real member prices) | "gui" (gotry opens its OWN isolated anonymous Chrome window and drives the Ctrip PC search form — roundtrip via backDate? and ANY city by construction: the site\'s own autocomplete resolves cities; anonymous public prices, NOT logged-in prices; verdicts carry anonymous=true; result-binding iteration tracked in #661). Evidence [会话:ctrip-flight@ts] / [会话:ctrip-flight-gui@ts]. '
|
|
1743
|
+
+ 'kind="hotel": to=destination name, country? for same-name disambiguation; cityId? = explicit numeric city= from a Ctrip hotel list URL; otherwise resolves from the hotel destination service, checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1730
1744
|
+ 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
|
|
1731
1745
|
+ 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
|
|
1732
1746
|
+ 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
|
|
@@ -1739,15 +1753,20 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1739
1753
|
// execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
|
|
1740
1754
|
parameters: {
|
|
1741
1755
|
kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
|
|
1742
|
-
from: { type: 'string', description: '
|
|
1756
|
+
from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
|
|
1743
1757
|
to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
|
|
1744
1758
|
date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
|
|
1745
|
-
|
|
1759
|
+
fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1760
|
+
toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1761
|
+
cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
|
|
1762
|
+
country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
|
|
1746
1763
|
checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1747
1764
|
checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1748
1765
|
adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
|
|
1749
1766
|
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1750
1767
|
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
1768
|
+
backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
|
|
1769
|
+
ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
|
|
1751
1770
|
},
|
|
1752
1771
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1753
1772
|
async execute(args, _exec) {
|
|
@@ -1824,17 +1843,17 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1824
1843
|
}
|
|
1825
1844
|
if (q.kind === 'hotel') {
|
|
1826
1845
|
if (!q.to) {
|
|
1827
|
-
return { ok: false, summary: 'kind=hotel 需要 to
|
|
1846
|
+
return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' };
|
|
1828
1847
|
}
|
|
1829
1848
|
const itp = await runSessionEffect({
|
|
1830
|
-
effect: 'SESSION_HOTEL_SEARCH',
|
|
1849
|
+
effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
|
|
1831
1850
|
params: {
|
|
1832
|
-
to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1851
|
+
to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1833
1852
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1834
1853
|
},
|
|
1835
1854
|
});
|
|
1836
1855
|
if (!itp.result)
|
|
1837
|
-
return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace);
|
|
1856
|
+
return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace);
|
|
1838
1857
|
const r = itp.result;
|
|
1839
1858
|
await noteChannel('session:ctrip-hotel', r.verdict);
|
|
1840
1859
|
// D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
|
|
@@ -1856,14 +1875,19 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1856
1875
|
}));
|
|
1857
1876
|
}
|
|
1858
1877
|
if (!q.from || !q.to || !q.date) {
|
|
1859
|
-
return { ok: false, summary: '需要 from/to
|
|
1878
|
+
return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
|
|
1860
1879
|
}
|
|
1861
1880
|
// 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
|
|
1862
1881
|
// 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
|
|
1882
|
+
// GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
|
|
1883
|
+
if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
|
|
1884
|
+
return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
|
|
1885
|
+
}
|
|
1863
1886
|
const itp = await runSessionEffect({
|
|
1864
|
-
effect: 'SESSION_FLIGHT_SEARCH',
|
|
1887
|
+
effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
|
|
1865
1888
|
params: {
|
|
1866
|
-
from: q.from, to: q.to, date: q.date,
|
|
1889
|
+
from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
|
|
1890
|
+
...(q.backDate ? { backDate: q.backDate } : {}),
|
|
1867
1891
|
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1868
1892
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1869
1893
|
},
|
|
@@ -1876,8 +1900,12 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1876
1900
|
const avMapS = await loadAirlineAirportMap();
|
|
1877
1901
|
await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias));
|
|
1878
1902
|
const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
|
|
1903
|
+
const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)';
|
|
1904
|
+
const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`);
|
|
1905
|
+
const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : '';
|
|
1906
|
+
const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : '';
|
|
1879
1907
|
const summary = r.verdict === 'hit'
|
|
1880
|
-
? `${q.from}→${q.to} ${q.date}
|
|
1908
|
+
? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
|
|
1881
1909
|
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
|
|
1882
1910
|
return JSON.parse(JSON.stringify({
|
|
1883
1911
|
...r, summary,
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Stai Travel Bridge",
|
|
4
|
-
"version": "0.2.0.
|
|
4
|
+
"version": "0.2.0.32",
|
|
5
5
|
"description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
|
|
7
7
|
"permissions": [
|
|
@@ -56,5 +56,5 @@
|
|
|
56
56
|
"run_at": "document_start"
|
|
57
57
|
}
|
|
58
58
|
],
|
|
59
|
-
"version_name": "0.2.0-rc.
|
|
59
|
+
"version_name": "0.2.0-rc.32"
|
|
60
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danceiny/gotry",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.32",
|
|
4
4
|
"description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ts/src/index.ts",
|
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
"client": {
|
|
45
45
|
"platform": "web",
|
|
46
46
|
"inject": [
|
|
47
|
-
"@deepseek-ai/dsh-client-ui-tool"
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-tool",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
48
51
|
]
|
|
49
52
|
}
|
|
50
53
|
},
|
|
@@ -57,6 +60,8 @@
|
|
|
57
60
|
"bin/",
|
|
58
61
|
"ts/src/index.ts",
|
|
59
62
|
"ts/src/flyai-setup-tool.ts",
|
|
63
|
+
"ts/src/gotry-web-api.ts",
|
|
64
|
+
"ts/src/artifact-delivery.ts",
|
|
60
65
|
"ts/src/subagent-job-id-guard.ts",
|
|
61
66
|
"ts/src/benchmark-environment-bridge.ts",
|
|
62
67
|
"ts/src/benchmark-tool-isolation.ts",
|
|
@@ -110,6 +115,7 @@
|
|
|
110
115
|
"ts/capabilities/opensky.ts",
|
|
111
116
|
"ts/capabilities/flyai.ts",
|
|
112
117
|
"ts/capabilities/flyai-config.ts",
|
|
118
|
+
"ts/capabilities/hbcli-config.ts",
|
|
113
119
|
"ts/capabilities/session-search.ts",
|
|
114
120
|
"ts/capabilities/session/",
|
|
115
121
|
"extension/",
|
|
@@ -185,22 +185,22 @@ export function verifySecureMode(dir: string, file: string): { ok: boolean; erro
|
|
|
185
185
|
/**
|
|
186
186
|
* 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
|
|
187
187
|
* 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
|
|
188
|
-
* 0700
|
|
188
|
+
* 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
|
|
189
189
|
*/
|
|
190
|
-
function
|
|
190
|
+
function preparePrivateDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
|
|
191
191
|
if (process.platform === 'win32') return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' }
|
|
192
192
|
try {
|
|
193
193
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
194
194
|
const entry = lstatSync(dir)
|
|
195
|
-
if (entry.isSymbolicLink()) return { ok: false, error: '
|
|
196
|
-
if (!entry.isDirectory()) return { ok: false, error: '
|
|
195
|
+
if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
|
|
196
|
+
if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
|
|
197
197
|
if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
|
|
198
|
-
return { ok: false, error: '
|
|
198
|
+
return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
|
|
199
199
|
}
|
|
200
200
|
if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
|
|
201
201
|
const after = lstatSync(dir)
|
|
202
|
-
if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '
|
|
203
|
-
if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '
|
|
202
|
+
if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
|
|
203
|
+
if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
|
|
204
204
|
return verifySecureMode(dir, file)
|
|
205
205
|
} catch (e) {
|
|
206
206
|
return { ok: false, error: (e as Error).message }
|
|
@@ -259,7 +259,7 @@ export function saveFlyaiKey(key: string, opts: FlyaiConfigPaths = {}): SaveFlya
|
|
|
259
259
|
const read = readFlyaiConfig(configPath)
|
|
260
260
|
if (!read.ok) return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` }
|
|
261
261
|
|
|
262
|
-
const configDir =
|
|
262
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath)
|
|
263
263
|
if (!configDir.ok) return { ok: false, error: configDir.error }
|
|
264
264
|
const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text })
|
|
265
265
|
if (!write.ok) return { ok: false, error: write.error }
|
|
@@ -297,7 +297,7 @@ export function clearFlyaiKey(opts: FlyaiConfigPaths = {}): ClearFlyaiKeyResult
|
|
|
297
297
|
return { ok: false, source: 'none', error: `移除文件失败:${(e as Error).message}` }
|
|
298
298
|
}
|
|
299
299
|
} else {
|
|
300
|
-
const configDir =
|
|
300
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath)
|
|
301
301
|
if (!configDir.ok) return { ok: false, source: 'none', error: configDir.error }
|
|
302
302
|
const write = atomicWriteJson(configPath, next)
|
|
303
303
|
if (!write.ok) return { ok: false, source: 'none', error: write.error }
|
|
@@ -355,7 +355,9 @@ export function readFlyaiVerification(homeDir: string): FlyaiVerification | unde
|
|
|
355
355
|
|
|
356
356
|
/** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
|
|
357
357
|
export function writeFlyaiVerification(entry: FlyaiVerification, homeDir: string): boolean {
|
|
358
|
-
|
|
358
|
+
const path = flyaiVerificationPath(homeDir)
|
|
359
|
+
const prepared = preparePrivateDir(dirname(path), path)
|
|
360
|
+
return prepared.ok && atomicWriteJson(path, entry).ok
|
|
359
361
|
}
|
|
360
362
|
|
|
361
363
|
export function removeFlyaiVerification(homeDir: string): void {
|
package/ts/capabilities/flyai.ts
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs'
|
|
33
33
|
import { tmpdir } from 'node:os'
|
|
34
34
|
import { join } from 'node:path'
|
|
35
|
-
import { resolveFlyaiEndpoint, resolveFlyaiKey, type FlyaiKeySource } from './flyai-config.ts'
|
|
35
|
+
import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, type FlyaiKeySource } from './flyai-config.ts'
|
|
36
36
|
import { spawnBounded } from './spawn-bounded.ts'
|
|
37
37
|
|
|
38
38
|
export type FlyaiKind =
|
|
@@ -239,6 +239,8 @@ export interface FlyaiResult {
|
|
|
239
239
|
verdict: FlyaiVerdict
|
|
240
240
|
/** true = 已识别的上游普通网络/HTTP 5xx/429 瞬时失败,可交给 effect 层退避重试 */
|
|
241
241
|
retryable?: boolean
|
|
242
|
+
/** 独立于重试:只在明确上游故障时计熔断;unknown 不增失败,也不宣称恢复。 */
|
|
243
|
+
upstreamHealth?: 'healthy' | 'unhealthy' | 'unknown'
|
|
242
244
|
/** 存在时表示请求未得到可判定的上游响应,应 fail-closed 且只执行一次。 */
|
|
243
245
|
localTermination?: FlyaiLocalTermination
|
|
244
246
|
kind: FlyaiKind
|
|
@@ -411,7 +413,7 @@ export function parseFlyaiItemList(stdout: string): unknown[] {
|
|
|
411
413
|
}
|
|
412
414
|
|
|
413
415
|
/** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
|
|
414
|
-
export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown> {
|
|
416
|
+
export function parseFlyaiJsonEnvelope(stdout: string, accepts: (envelope: Record<string, unknown>) => boolean = () => true): Record<string, unknown> {
|
|
415
417
|
let start = -1
|
|
416
418
|
let depth = 0
|
|
417
419
|
let inString = false
|
|
@@ -435,7 +437,12 @@ export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown>
|
|
|
435
437
|
depth -= 1
|
|
436
438
|
if (depth === 0) {
|
|
437
439
|
const candidate = stdout.slice(start, index + 1)
|
|
438
|
-
try {
|
|
440
|
+
try {
|
|
441
|
+
const parsed = JSON.parse(candidate) as Record<string, unknown>
|
|
442
|
+
if (accepts(parsed)) return parsed
|
|
443
|
+
} catch { /* prefix logs may contain balanced non-JSON braces */ }
|
|
444
|
+
start = -1
|
|
445
|
+
depth = 0
|
|
439
446
|
}
|
|
440
447
|
}
|
|
441
448
|
}
|
|
@@ -749,6 +756,15 @@ const KIND_LABEL: Record<FlyaiKind, string> = {
|
|
|
749
756
|
keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
|
|
750
757
|
}
|
|
751
758
|
|
|
759
|
+
/** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
|
|
760
|
+
export function flyaiBreakerScope(kind: unknown): string {
|
|
761
|
+
const key = resolveFlyaiKey()
|
|
762
|
+
const endpoint = resolveFlyaiEndpoint().url
|
|
763
|
+
const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]))
|
|
764
|
+
const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind'
|
|
765
|
+
return `${safeKind}:${identity}`
|
|
766
|
+
}
|
|
767
|
+
|
|
752
768
|
/**
|
|
753
769
|
* FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
|
|
754
770
|
*/
|
|
@@ -758,9 +774,9 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
758
774
|
const envContext = await runtimeEnvNote()
|
|
759
775
|
const envNote = envContext.note
|
|
760
776
|
const sensitive = envContext.sensitive
|
|
761
|
-
const safe =
|
|
777
|
+
const safe = (result: FlyaiResult): FlyaiResult => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive)
|
|
762
778
|
const built = buildCliArgs(q)
|
|
763
|
-
if (built.error) return { ...badArg(q.kind, built.error, ts), ...envNote }
|
|
779
|
+
if (built.error) return safe({ ...badArg(q.kind, built.error, ts), ...envNote })
|
|
764
780
|
|
|
765
781
|
const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16']
|
|
766
782
|
const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args!], {
|
|
@@ -769,7 +785,6 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
769
785
|
signal: q.signal,
|
|
770
786
|
})
|
|
771
787
|
const latencyMs = Date.now() - started
|
|
772
|
-
const combined = `${r.stderr}\n${r.stdout}`
|
|
773
788
|
|
|
774
789
|
if (r.cancelled) {
|
|
775
790
|
return safe({
|
|
@@ -797,37 +812,55 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
797
812
|
return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote))
|
|
798
813
|
}
|
|
799
814
|
|
|
800
|
-
if (r.error || r.code !== 0 ||
|
|
815
|
+
if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
|
|
801
816
|
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
|
|
802
817
|
}
|
|
803
818
|
|
|
819
|
+
// Validate the response envelope before accepting inventory for any kind.
|
|
820
|
+
// A business status is not an HTTP status; successful data is never scanned
|
|
821
|
+
// for fault words (for example an AI answer quoting an HTTP error).
|
|
822
|
+
let envelope: Record<string, unknown>
|
|
823
|
+
try {
|
|
824
|
+
envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
|
|
825
|
+
|| explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''))
|
|
826
|
+
} catch (e) {
|
|
827
|
+
if (explicitUpstreamFailure(r.stdout)) return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
|
|
828
|
+
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
829
|
+
return safe({
|
|
830
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
831
|
+
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
832
|
+
error: `failed to parse flyai output (${(e as Error).message}): ${raw}`,
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
const systemMessage = nonEmptyText(envelope.systemMessage)
|
|
836
|
+
const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`
|
|
837
|
+
if (explicitUpstreamFailure(envelopeFailure)) {
|
|
838
|
+
return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote))
|
|
839
|
+
}
|
|
840
|
+
if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
|
|
841
|
+
return safe({
|
|
842
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
843
|
+
evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
|
|
844
|
+
error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
|
|
845
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
804
849
|
// ai-search:data 是自由形状,不走 itemList。
|
|
805
850
|
if (q.kind === 'ai') {
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
envelope = parseFlyaiJsonEnvelope(r.stdout)
|
|
809
|
-
} catch (e) {
|
|
810
|
-
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
811
|
-
return safe({
|
|
812
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
813
|
-
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
814
|
-
error: `failed to parse flyai ai output (${(e as Error).message}): ${raw}`,
|
|
815
|
-
})
|
|
816
|
-
}
|
|
817
|
-
const systemMessage = nonEmptyText(envelope.systemMessage)
|
|
818
|
-
if (envelope.status !== undefined && envelope.status !== 0) {
|
|
851
|
+
if (!Object.hasOwn(envelope, 'data')) {
|
|
819
852
|
return safe({
|
|
820
853
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
821
|
-
evidence: `[实时API:flyai@error@${ts}] ai
|
|
822
|
-
error:
|
|
854
|
+
evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
|
|
855
|
+
error: 'FlyAI ai response malformed: missing data member',
|
|
823
856
|
...(systemMessage ? { systemMessage } : {}),
|
|
824
857
|
})
|
|
825
858
|
}
|
|
826
859
|
const aiData = envelope.data
|
|
827
|
-
const empty = aiData ===
|
|
860
|
+
const empty = aiData === null || aiData === ''
|
|
828
861
|
|| (Array.isArray(aiData) && aiData.length === 0)
|
|
829
862
|
return safe(JSON.parse(JSON.stringify({
|
|
830
|
-
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
|
|
863
|
+
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
|
|
831
864
|
verdict: empty ? 'miss' : 'hit',
|
|
832
865
|
...(empty ? {} : { aiData }),
|
|
833
866
|
...(systemMessage ? { systemMessage } : {}),
|
|
@@ -835,22 +868,10 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
835
868
|
})) as FlyaiResult)
|
|
836
869
|
}
|
|
837
870
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
items = parseFlyaiItemList(r.stdout) as RawItem[]
|
|
841
|
-
} catch (e) {
|
|
871
|
+
const data = envelope.data as { itemList?: unknown } | null
|
|
872
|
+
if (!Array.isArray(data?.itemList)) {
|
|
842
873
|
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
843
|
-
|
|
844
|
-
if (/Sentinel/i.test(raw)) {
|
|
845
|
-
return safe({
|
|
846
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
847
|
-
retryable: false,
|
|
848
|
-
evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
|
|
849
|
-
error: `FlyAI Sentinel 限流:${raw}`,
|
|
850
|
-
setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
851
|
-
})
|
|
852
|
-
}
|
|
853
|
-
const reason = (e as Error).message
|
|
874
|
+
const reason = 'no complete FlyAI itemList JSON object'
|
|
854
875
|
return safe({
|
|
855
876
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
856
877
|
retryable: false,
|
|
@@ -859,7 +880,17 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
859
880
|
})
|
|
860
881
|
}
|
|
861
882
|
|
|
862
|
-
return safe(parseItemListResult(q.kind,
|
|
883
|
+
return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Explicit protocol/transport evidence only. Bare business codes and generic
|
|
887
|
+
// words such as "network" or "timeout" do not diagnose an upstream outage.
|
|
888
|
+
const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i
|
|
889
|
+
const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i
|
|
890
|
+
|
|
891
|
+
function explicitUpstreamFailure(text: string): boolean {
|
|
892
|
+
return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
|
|
893
|
+
|| /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text)
|
|
863
894
|
}
|
|
864
895
|
|
|
865
896
|
/** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
|
|
@@ -886,39 +917,47 @@ function classifyUpstreamFailure(
|
|
|
886
917
|
): FlyaiResult {
|
|
887
918
|
const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim()
|
|
888
919
|
const tag = (verdict: FlyaiVerdict, evidence: string, extra: Partial<FlyaiResult>): FlyaiResult =>
|
|
889
|
-
({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra })
|
|
920
|
+
({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra })
|
|
921
|
+
const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1])
|
|
890
922
|
|
|
891
|
-
if (/Invalid API key
|
|
923
|
+
if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
|
|
892
924
|
return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
|
|
893
925
|
retryable: false,
|
|
894
926
|
error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
|
|
895
927
|
setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
|
|
896
928
|
})
|
|
897
929
|
}
|
|
898
|
-
if (
|
|
930
|
+
if (httpStatus === 403) {
|
|
899
931
|
return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
|
|
900
932
|
retryable: false,
|
|
901
933
|
error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
|
|
902
934
|
setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
|
|
903
935
|
})
|
|
904
936
|
}
|
|
905
|
-
if (/Trial limit reached
|
|
937
|
+
if (/Trial limit reached/i.test(upstream)) {
|
|
906
938
|
return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
|
|
907
939
|
retryable: false,
|
|
908
940
|
error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
|
|
909
941
|
setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
|
|
910
942
|
})
|
|
911
943
|
}
|
|
912
|
-
if (/
|
|
944
|
+
if (/SentinelBlockException/i.test(upstream)) {
|
|
945
|
+
return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
|
|
946
|
+
retryable: false, upstreamHealth: 'unhealthy',
|
|
947
|
+
error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
|
|
948
|
+
setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
if (httpStatus === 429) {
|
|
913
952
|
return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
|
|
914
|
-
retryable: true,
|
|
953
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
915
954
|
error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
|
|
916
955
|
setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
|
|
917
956
|
})
|
|
918
957
|
}
|
|
919
|
-
if (
|
|
958
|
+
if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
|
|
920
959
|
return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
|
|
921
|
-
retryable: true,
|
|
960
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
922
961
|
error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
|
|
923
962
|
setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
|
|
924
963
|
})
|
|
@@ -982,7 +1021,7 @@ function parseItemListResult(
|
|
|
982
1021
|
const count = parsed.options.length
|
|
983
1022
|
const verdict: FlyaiVerdict = count > 0 ? 'hit' : 'miss'
|
|
984
1023
|
const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`
|
|
985
|
-
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
|
|
1024
|
+
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, upstreamHealth: 'healthy' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
|
|
986
1025
|
|
|
987
1026
|
switch (kind) {
|
|
988
1027
|
case 'flight':
|
|
@@ -1088,7 +1127,9 @@ function parseHotelItems(items: unknown[]): ParsedItems<FlyaiHotelOption> {
|
|
|
1088
1127
|
}
|
|
1089
1128
|
const it = raw as RawItem
|
|
1090
1129
|
const name = nonEmptyText(it.name)
|
|
1091
|
-
|
|
1130
|
+
// Unrated homestays may return whitespace (live Harbin response).
|
|
1131
|
+
// Only this optional descriptive field permits blank text; types stay strict.
|
|
1132
|
+
const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star')
|
|
1092
1133
|
const rawPrice = stringField(it, 'price')
|
|
1093
1134
|
const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad'
|
|
1094
1135
|
const address = stringField(it, 'address')
|