@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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +119 -136
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-hbcli-release.d.ts +3 -0
  9. package/bin/gotry-hbcli-release.js +4 -0
  10. package/bin/gotry-inner.js +2 -1
  11. package/client/client.js +228 -0
  12. package/dist/capabilities/doctor.js +14 -23
  13. package/dist/capabilities/effect.js +45 -12
  14. package/dist/capabilities/flyai-config.js +12 -10
  15. package/dist/capabilities/flyai.js +87 -56
  16. package/dist/capabilities/hbcli-config.js +156 -0
  17. package/dist/capabilities/hbcli.js +29 -2
  18. package/dist/capabilities/resilience.js +6 -6
  19. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  20. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  21. package/dist/capabilities/session/ctrip-city.js +186 -0
  22. package/dist/capabilities/session/extension-bridge.js +8 -1
  23. package/dist/capabilities/session/gui-flight.js +737 -0
  24. package/dist/capabilities/session/gui-hotel.js +413 -0
  25. package/dist/capabilities/session-search.js +98 -11
  26. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  27. package/dist/scripts/artifact-delivery-tests.js +31 -0
  28. package/dist/scripts/bootstrap-tests.js +37 -10
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/doctor-tests.js +36 -7
  31. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  32. package/dist/scripts/effect-tests.js +602 -504
  33. package/dist/scripts/extension-tests.js +14 -8
  34. package/dist/scripts/flight-malformed-tests.js +1 -1
  35. package/dist/scripts/flyai-setup-tests.js +93 -5
  36. package/dist/scripts/flyai-tests.js +768 -653
  37. package/dist/scripts/gotry-web-api-tests.js +119 -0
  38. package/dist/scripts/gotry-web-client-tests.js +47 -0
  39. package/dist/scripts/gui-flight-live.js +27 -0
  40. package/dist/scripts/hbcli-tests.js +1 -1
  41. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  42. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  43. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  44. package/dist/scripts/hotel-result-tests.js +169 -0
  45. package/dist/scripts/issue-271-liveness-tests.js +3 -1
  46. package/dist/scripts/session-tests.js +177 -6
  47. package/dist/src/artifact-delivery.js +26 -0
  48. package/dist/src/gotry-web-api.js +219 -0
  49. package/dist/src/index.js +45 -17
  50. package/extension/manifest.json +2 -2
  51. package/package.json +8 -2
  52. package/ts/capabilities/flyai-config.ts +12 -10
  53. package/ts/capabilities/flyai.ts +91 -50
  54. package/ts/capabilities/hbcli-config.ts +128 -0
  55. package/ts/capabilities/hbcli.ts +30 -3
  56. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  57. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  58. package/ts/capabilities/session/ctrip-city.ts +158 -0
  59. package/ts/capabilities/session/extension-bridge.ts +9 -2
  60. package/ts/capabilities/session/gui-flight.ts +792 -0
  61. package/ts/capabilities/session/gui-hotel.ts +451 -0
  62. package/ts/capabilities/session-search.ts +115 -15
  63. package/ts/src/artifact-delivery.ts +25 -0
  64. package/ts/src/gotry-web-api.ts +189 -0
  65. package/ts/src/index.ts +46 -18
@@ -0,0 +1,9 @@
1
+ export interface FlyaiCandidateVerification {
2
+ verdict: 'hit' | 'auth-error' | 'forbidden' | 'needs-setup' | 'rate-limited' | 'timeout' | 'cancelled' | 'error'
3
+ count?: number
4
+ error?: string
5
+ }
6
+ export declare function verifyFlyaiCandidate(candidate: string, options?: {
7
+ env?: NodeJS.ProcessEnv
8
+ signal?: AbortSignal
9
+ }): Promise<FlyaiCandidateVerification>
@@ -0,0 +1,63 @@
1
+ /** Shared, bounded, read-only candidate verification for terminal and native Web setup. */
2
+ import { spawnOwnedChild, terminateOwnedChild } from './gotry-process-liveness.js'
3
+
4
+ export async function verifyFlyaiCandidate(candidate, options = {}) {
5
+ const configured = options.env ?? process.env
6
+ if (options.signal?.aborted) return { verdict: 'cancelled', error: '验证已取消。' }
7
+ const env = { ...configured, FLYAI_API_KEY: candidate }
8
+ delete env.DEBUG_FLYAI_API_KEY
9
+ const command = configured.GOTRY_FLYAI_CLI_BIN || 'npx'
10
+ const prefix = configured.GOTRY_FLYAI_CLI_BIN ? [] : ['-y', '@fly-ai/flyai-cli@1.0.16']
11
+ const date = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10)
12
+ const args = [...prefix, 'search-flight', '--origin', '上海', '--destination', '丽江', '--dep-date', date]
13
+ const limit = Number(configured.GOTRY_FLYAI_VERIFY_TIMEOUT_MS ?? 20_000)
14
+ const timeoutMs = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 60_000) : 20_000
15
+ const owned = spawnOwnedChild(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'] })
16
+ let stopReason, timer, stdout = '', stderr = '', bytes = 0
17
+ const stop = (reason) => {
18
+ stopReason ??= reason
19
+ void terminateOwnedChild({ ...owned, signal: 'SIGKILL', termGraceMs: 100, killWaitMs: 1000 })
20
+ }
21
+ const abort = () => stop('cancelled')
22
+ try {
23
+ const result = await new Promise(resolve => {
24
+ let settled = false
25
+ const finish = value => { if (!settled) { settled = true; resolve(value) } }
26
+ timer = setTimeout(() => stop('timeout'), timeoutMs)
27
+ options.signal?.addEventListener('abort', abort, { once: true })
28
+ if (options.signal?.aborted) abort()
29
+ owned.child.stdout?.on('data', chunk => {
30
+ bytes += chunk.length
31
+ if (bytes > 256 * 1024) return stop('output-limit')
32
+ stdout += chunk.toString()
33
+ })
34
+ owned.child.stderr?.on('data', chunk => {
35
+ bytes += chunk.length
36
+ if (bytes > 256 * 1024) return stop('output-limit')
37
+ stderr += chunk.toString()
38
+ })
39
+ owned.child.once('error', () => finish({ verdict: 'error', error: '无法启动验证进程。' }))
40
+ owned.child.once('close', code => {
41
+ if (stopReason) return finish({ verdict: stopReason === 'output-limit' ? 'error' : stopReason,
42
+ error: stopReason === 'cancelled' ? '验证已取消。' : stopReason === 'timeout' ? `验证调用超时 ${timeoutMs}ms。` : '验证响应过大。' })
43
+ const combined = `${stderr}\n${stdout}`
44
+ if (/Invalid API key|HTTP\s*401|\b401\b/.test(combined)) return finish({ verdict: 'auth-error', error: '401 Invalid API key' })
45
+ if (/HTTP\s*403|\b403\b/.test(combined)) return finish({ verdict: 'forbidden', error: 'HTTP 403:无访问权限,请核对控制台权限。' })
46
+ if (/Trial limit reached/.test(combined)) return finish({ verdict: 'needs-setup', error: '试用额度已用尽,请核对控制台 Key。' })
47
+ if (/HTTP\s*429|\b429\b/.test(combined)) return finish({ verdict: 'rate-limited', error: '普通限流,稍后重试。' })
48
+ if (code !== 0) return finish({ verdict: 'error', error: `验证进程失败(exit ${code});请检查网络与服务状态。` })
49
+ try {
50
+ const envelope = JSON.parse(stdout)
51
+ finish(Array.isArray(envelope?.data?.itemList)
52
+ ? { verdict: 'hit', count: envelope.data.itemList.length }
53
+ : { verdict: 'error', error: '响应缺 data.itemList。' })
54
+ } catch { finish({ verdict: 'error', error: '响应不是合法 JSON。' }) }
55
+ })
56
+ })
57
+ return result
58
+ } finally {
59
+ clearTimeout(timer)
60
+ options.signal?.removeEventListener('abort', abort)
61
+ await terminateOwnedChild({ ...owned, signal: 'SIGKILL', termGraceMs: 100, killWaitMs: 1000 })
62
+ }
63
+ }
@@ -0,0 +1,3 @@
1
+ export const HBCLI_RELEASE_VERSION: string
2
+ export const HBCLI_INSTALL_URL: string
3
+ export const HBCLI_INSTALL_CMD: string
@@ -0,0 +1,4 @@
1
+ /** Official stable hbcli baseline. npm's staicli@latest still points to 0.0.3. */
2
+ export const HBCLI_RELEASE_VERSION = '0.0.4'
3
+ export const HBCLI_INSTALL_URL = `https://github.com/hotelbyte-com/docs/releases/download/staicli-v${HBCLI_RELEASE_VERSION}/install.sh`
4
+ export const HBCLI_INSTALL_CMD = `curl -fsSL --connect-timeout 15 --max-time 60 ${HBCLI_INSTALL_URL} | bash -s -- --version ${HBCLI_RELEASE_VERSION}`
@@ -88,8 +88,9 @@ Usage:
88
88
  # 跳过该问: --no-onboarding 或 GOTRY_ONBOARDING_SKIP=1
89
89
  gotry setup # 扩展就位检查/指引(商店一键装)
90
90
  gotry setup calendar # 可选日历(CalDAV 工作窗口)挂载开关:默认关;--off 关闭;--status 查看
91
+ gotry setup flyai # FlyAI 配置向导:控制台取 key → 隐藏粘贴 → 验证后保存;--status 查看;--clear 清除
91
92
  gotry doctor # 可选依赖体检:扩展/agent-reach/hbcli/flyai/sidebar 状态 + 补装指引
92
- gotry doctor --fix # 体检 + 按报告补装(hbcli 官方脚本 / agent-reach pip / sidebar 插件)
93
+ gotry doctor --fix # 补装可选依赖;交互终端可选择接着配置 FlyAI
93
94
  gotry "一段完整任务..." # headless 一问一答
94
95
  gotry help # this help
95
96
  gotry --version # 版本号(bug 报告必填项)
package/client/client.js CHANGED
@@ -12,6 +12,219 @@ window.__ModuleLoader__.load({
12
12
  var module = { exports: {} }
13
13
  var exports = module.exports
14
14
  var React = require('react')
15
+ var UI = require('@deepseek-ai/dsh-client-ui-primitives')
16
+
17
+ var webLocale = {
18
+ en: {
19
+ title: 'GoTry', description: 'Travel data sources and saved itineraries', flyai: 'FlyAI',
20
+ loading: 'Loading…', unavailable: 'Configuration is unavailable. Retry to read its current state.',
21
+ readOnly: 'The current key comes from the launch environment, or this platform cannot safely store it here.',
22
+ key: 'API Key', keyHint: 'Paste the key from the FlyAI console. It never enters the conversation.',
23
+ configured: 'A key is configured', anonymous: 'Anonymous trial · shared quota', verified: 'Current configuration verified',
24
+ unverified: 'Configured · verification required', debug: 'Debug endpoint', console: 'Open FlyAI console',
25
+ save: 'Verify and save', saving: 'Verifying…', saveFailed: 'The new key was not saved; the previous configuration is retained.',
26
+ saved: 'Verified and saved.', clear: 'Clear local key', cleared: 'Local key cleared. Anonymous trial is now active.',
27
+ retry: 'Refresh status', source: 'Source', config: 'Local configuration', env: 'Environment variable',
28
+ 'env-debug': 'Debug environment variable', none: 'Shared trial',
29
+ hbcli: 'HotelByte · sandbox', appKey: 'App Key', appSecret: 'App Secret',
30
+ hbHint: 'Use the credentials supplied with your sandbox account. They never enter the conversation.',
31
+ hbRegistration: 'Apply for a sandbox account', hbUnconfigured: 'Sandbox API credentials are not configured', hbConfigured: 'Sandbox API credentials configured',
32
+ hbReadOnly: 'Credentials come from the launch environment, or the local configuration cannot be safely written.',
33
+ hbSaveFailed: 'Credentials were not saved; the previous configuration is retained.',
34
+ hbSaved: 'Credentials saved and the read-only hotel query completed.', hbClear: 'Clear local sandbox credentials',
35
+ hbCleared: 'Local sandbox API credentials cleared.', hbNoSuppliers: 'Saved · no available suppliers configured',
36
+ hbNoSuppliersHint: 'HotelByte must enable suppliers for your account. This result does not mean the destination has no rooms.',
37
+ hbMiss: 'Query verified · no hotels in this check', hbProbeHint: 'Verification makes one read-only Shanghai hotel query for tomorrow, one night and two adults.',
38
+ artifacts: 'GoTry artifacts', artifactsHint: 'Browse saved itineraries and planning deliveries',
39
+ search: 'Search artifacts', searchPlaceholder: 'Title or filename', refresh: 'Refresh',
40
+ empty: 'No saved artifacts in this workspace.', noMatches: 'No matching artifacts.', noSession: 'Open a conversation to browse its workspace.',
41
+ previous: 'Previous', next: 'Next', html: 'Open HTML preview', open: 'Open preview',
42
+ htmlHint: 'HTML previews may run the document’s scripts.', failed: 'Could not read the current state. Please retry.',
43
+ },
44
+ zh: {
45
+ title: 'GoTry', description: '旅行数据源与已保存行程', flyai: 'FlyAI(飞猪)',
46
+ loading: '正在读取…', unavailable: '配置暂时不可用,请重新读取当前状态。',
47
+ readOnly: '当前 Key 来自启动环境变量,或此平台无法在这里安全保存。',
48
+ key: 'API Key', keyHint: '粘贴飞猪控制台中的 Key,凭据不会进入对话。',
49
+ configured: '已配置 Key', anonymous: '匿名试用 · 共享额度', verified: '当前配置已验证',
50
+ unverified: '已配置 · 尚未验证通过', debug: '调试服务地址', console: '打开飞猪控制台',
51
+ save: '验证并保存', saving: '正在验证…', saveFailed: '新 Key 未保存,旧配置已保留。',
52
+ saved: '验证通过,已保存。', clear: '清除本机 Key', cleared: '本机 Key 已清除,已恢复匿名试用。',
53
+ retry: '重新读取状态', source: '来源', config: '本机配置', env: '环境变量',
54
+ 'env-debug': '调试环境变量', none: '共享试用',
55
+ hbcli: 'HotelByte(沙箱)', appKey: 'App Key', appSecret: 'App Secret',
56
+ hbHint: '填写开通沙箱账号后取得的凭证,内容不会进入对话。',
57
+ hbRegistration: '申请沙箱账号', hbUnconfigured: '尚未配置沙箱 API 凭证', hbConfigured: '已配置沙箱 API 凭证',
58
+ hbReadOnly: '当前凭证来自启动环境变量,或本机配置无法安全写入。',
59
+ hbSaveFailed: '新凭证未保存,旧配置已保留。',
60
+ hbSaved: '凭证已保存,只读酒店查询已完成。', hbClear: '清除本机沙箱凭证',
61
+ hbCleared: '本机沙箱 API 凭证已清除。', hbNoSuppliers: '已保存 · 未配置可用供应商',
62
+ hbNoSuppliersHint: '请联系 HotelByte 开通当前账号的供应商。这次结果不能判断目的地无房。',
63
+ hbMiss: '查询通道已验证 · 本次未返回酒店', hbProbeHint: '验证会只读查询上海明天入住的一晚酒店,两位成人。',
64
+ artifacts: 'GoTry 产物', artifactsHint: '查看已保存行程和规划交付物',
65
+ search: '搜索产物', searchPlaceholder: '标题或文件名', refresh: '刷新',
66
+ empty: '这个工作区尚无已保存产物。', noMatches: '没有匹配的产物。', noSession: '打开一个对话后即可查看其工作区产物。',
67
+ previous: '上一页', next: '下一页', html: '打开 HTML 预览', open: '打开预览',
68
+ htmlHint: 'HTML 预览可能运行文档中的脚本。', failed: '无法读取当前状态,请重试。',
69
+ },
70
+ }
71
+
72
+ async function webRequest(path, options) {
73
+ var response = await fetch('api/gotry/' + path, Object.assign({ credentials: 'same-origin' }, options))
74
+ var value = await response.json()
75
+ if (!value || typeof value !== 'object') throw new Error('Invalid Web response')
76
+ if (!response.ok || value.ok === false) throw new Error(value.error || 'Request failed')
77
+ return value
78
+ }
79
+
80
+ function SourceSettings(props) {
81
+ var t = props.t
82
+ var hotelbyte = props.provider === 'hbcli', provider = hotelbyte ? 'hbcli' : 'flyai'
83
+ var state = React.useState(null), status = state[0], setStatus = state[1]
84
+ var draft = React.useState(''), key = draft[0], setKey = draft[1]
85
+ var secretDraft = React.useState(''), secret = secretDraft[0], setSecret = secretDraft[1]
86
+ var noticeState = React.useState(''), notice = noticeState[0], setNotice = noticeState[1]
87
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
88
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
89
+ var reloadState = React.useState(0), reload = reloadState[0], setReload = reloadState[1]
90
+ var lifetime = React.useRef(null), activeRequest = React.useRef(false)
91
+ React.useEffect(function () {
92
+ var controller = new AbortController()
93
+ lifetime.current = controller
94
+ setError('')
95
+ webRequest(provider, { signal: controller.signal }).then(function (value) {
96
+ if (!controller.signal.aborted) setStatus(value)
97
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
98
+ return function () { controller.abort(); lifetime.current = null }
99
+ }, [reload])
100
+ async function write(action) {
101
+ if (activeRequest.current || !lifetime.current || !status?.writable) return
102
+ var controller = lifetime.current
103
+ activeRequest.current = true
104
+ setBusy(true); setError(''); setNotice('')
105
+ var candidate = key, candidateSecret = secret
106
+ setKey(''); setSecret('')
107
+ try {
108
+ var payload = action === 'clear' ? { action: action } : hotelbyte
109
+ ? { action: action, appKey: candidate, appSecret: candidateSecret } : { action: action, key: candidate }
110
+ var value = await webRequest(provider + '/write', { method: 'POST', signal: controller.signal,
111
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
112
+ if (!controller.signal.aborted) {
113
+ setStatus(value)
114
+ setNotice(hotelbyte && action === 'save' && value.verdict === 'no-suppliers' ? ''
115
+ : t(hotelbyte ? action === 'clear' ? 'hbCleared' : 'hbSaved' : action === 'save' ? 'saved' : 'cleared'))
116
+ }
117
+ } catch (failure) {
118
+ if (!controller.signal.aborted) setError(failure.message || t(hotelbyte ? 'hbSaveFailed' : 'saveFailed'))
119
+ } finally {
120
+ activeRequest.current = false
121
+ if (!controller.signal.aborted) setBusy(false)
122
+ }
123
+ }
124
+ if (props.view === 'summary') return t('description')
125
+ var disabled = busy || !status?.writable
126
+ var stateLabel = !status ? t('loading') : !status.configured ? t(hotelbyte ? 'hbUnconfigured' : 'anonymous')
127
+ : hotelbyte && status.verdict === 'no-suppliers' ? t('hbNoSuppliers') : hotelbyte && status.verdict === 'miss' ? t('hbMiss')
128
+ : status.verified ? t('verified') : t('unverified')
129
+ var sectionProps = { style: { display: 'grid', gap: '16px', maxWidth: '640px' } }
130
+ sectionProps['data-gotry-' + provider + '-settings'] = ''
131
+ var statusProps = { key: 'status', role: 'status', style: { display: 'grid', gap: '6px' } }
132
+ statusProps['data-gotry-' + provider + '-status'] = ''
133
+ return element('section', sectionProps, [
134
+ element('h3', { key: 'heading', style: { margin: 0 } }, t(provider)),
135
+ element('div', statusProps, [
136
+ element(UI.Tag, { key: 'badge', tone: status?.verified ? 'success' : 'neutral' }, stateLabel),
137
+ status ? element('span', { key: 'source', style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('source') + ':' + t(hotelbyte && status.source === 'none' ? 'hbUnconfigured' : status.source)) : null,
138
+ status?.endpointDebug ? element('span', { key: 'debug' }, t('debug') + ':' + status.endpoint) : null,
139
+ ]),
140
+ element('a', { key: 'console', href: hotelbyte ? 'https://hotelbyte.com/zh/guides/sandbox-verification' : 'https://flyai.open.fliggy.com/console', target: '_blank', rel: 'noopener noreferrer' }, t(hotelbyte ? 'hbRegistration' : 'console')),
141
+ hotelbyte ? element('p', { key: 'probe-hint', style: { margin: 0, fontSize: '12px' } }, t('hbProbeHint')) : null,
142
+ hotelbyte && status?.verdict === 'no-suppliers' ? element('p', { key: 'supplier-hint', role: 'status', style: { margin: 0 } }, t('hbNoSuppliersHint')) : null,
143
+ element(UI.SettingsForm, { key: 'form', labels: { unavailable: t('loading'), readOnly: t(hotelbyte ? 'hbReadOnly' : 'readOnly'), saveFailed: t(hotelbyte ? 'hbSaveFailed' : 'saveFailed'), save: t('save'), saving: t('saving') },
144
+ state: { available: Boolean(status), writable: Boolean(status?.writable), dirty: key.trim().length > 0 && (!hotelbyte || secret.trim().length > 0), invalid: false, saving: busy, failed: false },
145
+ onSave: function () { void write('save') }, onDiscard: function () { setKey(''); setSecret('') } }, [
146
+ element(UI.SettingsSecretField, { key: 'key', id: 'gotry-' + provider + '-key', label: t(hotelbyte ? 'appKey' : 'key'), hint: t(hotelbyte ? 'hbHint' : 'keyHint'), text: key,
147
+ configured: Boolean(status?.configured), stateLabel: t(status?.configured ? hotelbyte ? 'hbConfigured' : 'configured' : hotelbyte ? 'hbUnconfigured' : 'anonymous'), disabled: disabled,
148
+ onEdit: function (value) { setKey(value); setError(''); setNotice('') } }),
149
+ hotelbyte ? element(UI.SettingsSecretField, { key: 'secret', id: 'gotry-hbcli-secret', label: t('appSecret'), text: secret,
150
+ configured: Boolean(status?.configured), stateLabel: t(status?.configured ? 'hbConfigured' : 'hbUnconfigured'), disabled: disabled,
151
+ onEdit: function (value) { setSecret(value); setError(''); setNotice('') } }) : null,
152
+ ]),
153
+ error ? element('p', { key: 'error', role: 'alert', style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
154
+ notice ? element('p', { key: 'notice', role: 'status', style: { margin: 0 } }, notice) : null,
155
+ element('div', { key: 'actions', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } }, [
156
+ element(UI.Button, { key: 'retry', type: 'button', variant: 'outline', disabled: busy,
157
+ onClick: function () { setReload(function (value) { return value + 1 }) } }, t('retry')),
158
+ status?.configured && status.writable ? element(UI.Button, { key: 'clear', type: 'button', disabled: busy,
159
+ onClick: function () { void write('clear') } }, t(hotelbyte ? 'hbClear' : 'clear')) : null,
160
+ ]),
161
+ ])
162
+ }
163
+
164
+ function GoTrySettings(props) {
165
+ if (props.view === 'summary') return props.t('description')
166
+ return element('div', { style: { display: 'grid', gap: '32px' } }, [
167
+ element(SourceSettings, Object.assign({}, props, { key: 'flyai', provider: 'flyai' })),
168
+ element(SourceSettings, Object.assign({}, props, { key: 'hbcli', provider: 'hbcli' })),
169
+ ])
170
+ }
171
+
172
+ function ArtifactBrowser(props) {
173
+ var t = props.t, info = props.useTabInfo(), sessionId = props.sessionId
174
+ var cwd = props.useSessions(function (sessions) { return sessions.byId[sessionId]?.cwd })
175
+ var state = React.useState(null), result = state[0], setResult = state[1]
176
+ var queryState = React.useState(''), search = queryState[0], setSearch = queryState[1]
177
+ var pageState = React.useState(0), offset = pageState[0], setOffset = pageState[1]
178
+ var refreshState = React.useState(0), refresh = refreshState[0], setRefresh = refreshState[1]
179
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
180
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
181
+ React.useEffect(function () {
182
+ if (!sessionId || !info.tab.visible) return
183
+ var controller = new AbortController()
184
+ setBusy(true); setError('')
185
+ var query = new URLSearchParams({ sessionId: sessionId, search: search, offset: String(offset), limit: '20' })
186
+ webRequest('artifacts?' + query, { signal: controller.signal }).then(function (value) {
187
+ if (!controller.signal.aborted) setResult(value)
188
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
189
+ .finally(function () { if (!controller.signal.aborted) setBusy(false) })
190
+ return function () { controller.abort() }
191
+ }, [sessionId, info.tab.visible, search, offset, refresh])
192
+ function open(path) {
193
+ var normalized = String(path).replace(/\\/g, '/')
194
+ var root = String(cwd || '').replace(/\\/g, '/').replace(/\/$/, '')
195
+ var relative = root && normalized.startsWith(root + '/') ? normalized.slice(root.length + 1) : normalized
196
+ info.tab.actions.openResource('dsh-resource://file/session/' + encodeURIComponent(sessionId) + '/' + relative.split('/').map(encodeURIComponent).join('/'))
197
+ }
198
+ var entries = result?.artifacts || []
199
+ return element('section', { 'data-gotry-artifacts-browser': '', style: { padding: '16px', display: 'grid', alignContent: 'start', gap: '12px', height: '100%', overflow: 'auto', boxSizing: 'border-box', minWidth: 0 } }, [
200
+ element('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 } }, [
201
+ element(UI.Input, { key: 'search', 'aria-label': t('search'), placeholder: t('searchPlaceholder'), value: search,
202
+ style: { minWidth: 0, width: '100%' }, onChange: function (event) { setSearch(event.target.value); setOffset(0) } }),
203
+ element(UI.Button, { key: 'refresh', size: 'sm', disabled: busy || !sessionId,
204
+ onClick: function () { setRefresh(function (value) { return value + 1 }) } }, busy ? t('loading') : t('refresh')),
205
+ ]),
206
+ error ? element('p', { key: 'error', role: 'alert' }, error) : null,
207
+ !sessionId ? element('p', { key: 'no-session' }, t('noSession')) : !busy && entries.length === 0 ? element('p', { key: 'empty' }, t(search ? 'noMatches' : 'empty')) : null,
208
+ element('div', { key: 'list', 'aria-busy': busy, style: { display: 'grid', gap: '8px' } }, entries.map(function (entry) {
209
+ var html = /\.html?$/i.test(entry.path)
210
+ return element(UI.Button, { key: entry.path, variant: 'outline', 'data-gotry-artifact-entry': entry.path,
211
+ title: html ? t('htmlHint') : entry.path, onClick: function () { open(entry.path) },
212
+ style: { width: '100%', height: 'auto', padding: '12px', textAlign: 'left', justifyContent: 'flex-start', minWidth: 0 } }, [
213
+ element(UI.FileTypeIcon, { key: 'icon', path: entry.path }),
214
+ element('span', { key: 'body', style: { display: 'grid', gap: '4px', minWidth: 0, flex: 1 } }, [
215
+ element('span', { key: 'title', style: { overflowWrap: 'anywhere' } }, entry.title),
216
+ element('span', { key: 'meta', style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', overflowWrap: 'anywhere' } },
217
+ [entry.status, entry.updated ? new Date(entry.updated).toLocaleString() : '', html ? t('html') : t('open')].filter(Boolean).join(' · ')),
218
+ ]),
219
+ ])
220
+ })),
221
+ result?.total > 0 ? element('div', { key: 'pagination', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } }, [
222
+ element(UI.Button, { key: 'previous', size: 'sm', disabled: busy || offset === 0, onClick: function () { setOffset(Math.max(0, offset - 20)) } }, t('previous')),
223
+ element('span', { key: 'count', role: 'status', style: { fontSize: '12px' } }, (entries.length ? offset + 1 : 0) + '–' + (offset + entries.length) + ' / ' + result.total),
224
+ element(UI.Button, { key: 'next', size: 'sm', disabled: busy || !result.truncated, onClick: function () { setOffset(result.nextOffset) } }, t('next')),
225
+ ]) : null,
226
+ ])
227
+ }
15
228
 
16
229
  function element(type, props, children) {
17
230
  return React.createElement(type, props, ...(Array.isArray(children) ? children : [children]))
@@ -194,6 +407,21 @@ window.__ModuleLoader__.load({
194
407
  return scope.slots.register({ name: 'tool.call.toolview', key: 'gotry_artifacts_read' }, ArtifactToolView)
195
408
  })
196
409
  })
410
+ ctx.inject(['slots', 'locale'], function (scope) {
411
+ var ns = 'gotry.web'
412
+ var t = scope.locale.bind(ns)
413
+ scope.effect(function () { return scope.locale.register(ns, webLocale) })
414
+ scope.slots.inject('plugins.item', function () {
415
+ return scope.slots.register({ name: 'plugins.item', id: 'gotry', order: 50, label: function () { return t('title') }, locale: ns }, GoTrySettings)
416
+ })
417
+ scope.inject(['sidebarRightTabs'], function (sidebar) {
418
+ sidebar.effect(function () { return sidebar.sidebarRightTabs.register({ id: '@danceiny/gotry:artifacts', kind: 'gotry-artifacts',
419
+ title: function () { return t('artifacts') }, guide: [{ id: 'gotry-artifacts', order: 15, title: function () { return t('artifacts') }, description: function () { return t('artifactsHint') } }] }) })
420
+ sidebar.slots.inject('sidebar.right.pane.tab', function () {
421
+ return sidebar.slots.register({ name: 'sidebar.right.pane.tab', key: '@danceiny/gotry:artifacts', locale: ns }, ArtifactBrowser)
422
+ })
423
+ })
424
+ })
197
425
  }
198
426
 
199
427
  exports.apply = apply
@@ -19,6 +19,8 @@ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { createRequire } from 'node:module';
20
20
  import { homedir } from 'node:os';
21
21
  import { join, resolve } from 'node:path';
22
+ import { checkExtensionStatus } from '../../bin/gotry-extension-status.js';
23
+ import { HBCLI_RELEASE_VERSION, HBCLI_INSTALL_CMD } from '../../bin/gotry-hbcli-release.js';
22
24
  import { readLatestChannelEvents } from './channel-health.js';
23
25
  import { displayEndpoint, endpointFingerprint, readFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, } from './flyai-config.js';
24
26
  /** hbcli 已知安装位(与 capabilities/hbcli.ts hbcliBinCandidates 同清单) */
@@ -109,10 +111,8 @@ export function nodeOk(version) {
109
111
  const [maj = '0', min = '0'] = version.split('.');
110
112
  return Number(maj) > 22 || (Number(maj) === 22 && Number(min) >= 15);
111
113
  }
112
- /** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖)。0.0.3 修了 portal-ticket
113
- * fallback bug(issue #142)——低于该版本的 hbcli 会把 literal "stored-ticket"
114
- * 当 bearer 发到 hotel-be,让 trade.* / search/checkAvail 全部 401。 */
115
- const MIN_HBCLI_VERSION = process.env.GOTRY_MIN_HBCLI_VERSION ?? '0.0.3';
114
+ /** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖),与 CLI doctor 共用发布基线。 */
115
+ const MIN_HBCLI_VERSION = process.env.GOTRY_MIN_HBCLI_VERSION ?? HBCLI_RELEASE_VERSION;
116
116
  /** 单项检查:全部只读、永不抛错;env/homeDir/repoRoot 可注入(测试确定性) */
117
117
  export async function runDoctorChecks(opts = {}) {
118
118
  const repoRoot = opts.repoRoot ? resolve(opts.repoRoot) : resolve(import.meta.dirname, '..', '..');
@@ -124,17 +124,8 @@ export async function runDoctorChecks(opts = {}) {
124
124
  items.push(nodeOk(nodeVersion)
125
125
  ? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
126
126
  : { id: 'node', label: 'Node 运行时', status: 'missing', detail: `Node ${nodeVersion} 过旧(gotry 需 ≥22.15)`, fix: '升级 Node.js 至 22.15+(https://nodejs.org)' });
127
- // 1. Stai Travel Bridge 扩展(账号会话通道的传输层;装在哪由 bootstrap setup 管理)
128
- // D-24 自适应(#117):离线只能探测本地 unpacked 通道;商店版无本地文件面——
129
- // missing detail 明示「商店版用户可忽略本项」,不再把商店版用户误导成本地通道缺失
130
- const extManifest = join(home, '.gotry', 'extension', 'manifest.json');
131
- items.push(existsSync(extManifest)
132
- ? { id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
133
- : {
134
- id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing',
135
- detail: '本地通道未落位(~/.gotry/extension/manifest.json 不存在)。影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。若你已从 Chrome 商店安装(自动更新,商店版 ID oeajpicc…),本项可忽略——会话检索可用性以运行时为准(gotry_session_search 的 verdict)。若未安装:应用商店一键装即可',
136
- fix: '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd',
137
- });
127
+ // 1. CLI 与工具层共用只读检测:安装、落位、连接是不同证据。
128
+ items.push({ id: 'extension', label: 'Stai Travel Bridge 扩展', ...await checkExtensionStatus({ homeDir: home, env, ports: opts.extensionPorts }) });
138
129
  // 2. agent-reach(网页/社媒读取;.venv 装在包内,与 agent-reach.ts venvPython 同位)
139
130
  const venvPython = join(repoRoot, '.venv/bin/python');
140
131
  const reachBin = join(repoRoot, '.venv/bin/agent-reach');
@@ -157,7 +148,7 @@ export async function runDoctorChecks(opts = {}) {
157
148
  }
158
149
  // 3. hbcli(酒店实时源;静态包自动降级,缺了不致命)
159
150
  // 裸名 'hbcli' 靠 PATH 解析(existsSync 对裸名是 cwd 相对,无意义)——先探测再落位
160
- // 版本 < MIN_HBCLI_VERSION 视为 missing(issue #142:portal-ticket fallback 401)
151
+ // 版本 < MIN_HBCLI_VERSION 视为 missing,修复走版本钉扎的官方安装器
161
152
  let hbPresent = '';
162
153
  for (const p of hbcliCandidates(home)) {
163
154
  if (p !== 'hbcli' && existsSync(p)) {
@@ -175,8 +166,8 @@ export async function runDoctorChecks(opts = {}) {
175
166
  if (v && !versionAtLeast(v, MIN_HBCLI_VERSION)) {
176
167
  items.push({
177
168
  id: 'hbcli', label: 'hbcli(酒店实时源)', status: 'missing',
178
- detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——trade.* / search/checkAvail 会 401(issue #142)`,
179
- fix: 'npm install -g staicli --registry=https://registry.npmjs.org/',
169
+ detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——请升级到支持当前认证与酒店配置的版本`,
170
+ fix: HBCLI_INSTALL_CMD,
180
171
  });
181
172
  }
182
173
  else {
@@ -202,7 +193,7 @@ export async function runDoctorChecks(opts = {}) {
202
193
  : {
203
194
  id: 'hbcli', label: 'hbcli(酒店实时源)', status: 'degraded',
204
195
  detail: '二进制在,但凭证未配置/失效——酒店检索将降级静态包(非实时)',
205
- fix: 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册,需 npm staicli ≥ 0.0.4);B 端租户注册走 hbcli auth register(先 auth send-code);或 hbcli auth set-credentials --app-key hotelbyte_api_demo --app-secret hotelbyte_api_demo(快速试用沙箱;正式 key 向 HotelByte 申请)',
196
+ fix: 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册);B 端租户注册走 hbcli auth register(先 auth send-code);或在 插件 → GoTry → HotelByte 配置沙箱 App Key/App Secret',
206
197
  });
207
198
  }
208
199
  }
@@ -229,7 +220,7 @@ export async function runDoctorChecks(opts = {}) {
229
220
  items.push({
230
221
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
231
222
  detail: `未配置 key——匿名试用中(共享额度易达限;达限报 Trial limit reached)${flyaiQuotaNote}。影响面:共享池额度小,频繁会话易触顶;达限本会话内 gotry_flyai_search(机票/酒店/票务/AI/万豪 8 类)失败,改走 gotry_session_search 账号会话通道。`,
232
- fix: '本机运行 `gotry setup flyai`(隐藏输入,候选 key 先 scrub-env 验证后保存);打开 https://flyai.open.fliggy.com/console,登录后复制 API Key',
223
+ fix: 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`:① 打开 https://flyai.open.fliggy.com/console 复制 API Key;② 在终端隐藏粘贴;③ 验证后保存。也可运行 `npx @danceiny/gotry doctor --fix` 按提示配置',
233
224
  });
234
225
  }
235
226
  else {
@@ -254,7 +245,7 @@ export async function runDoctorChecks(opts = {}) {
254
245
  items.push({
255
246
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
256
247
  detail: `${reason}(${sourceText};endpoint ${displayEndpoint(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})——非空不等于鉴权通过`,
257
- fix: '本机运行 `gotry setup flyai` 重新验证后保存;`gotry setup flyai --clear` 回退匿名',
248
+ fix: 'Web 打开「插件 → GoTry → FlyAI」重新验证;或运行 `npx @danceiny/gotry setup flyai`;`npx @danceiny/gotry setup flyai --clear` 回退匿名',
258
249
  });
259
250
  }
260
251
  }
@@ -264,7 +255,7 @@ export async function runDoctorChecks(opts = {}) {
264
255
  ? { id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'ok', detail: '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' }
265
256
  : {
266
257
  id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'missing',
267
- detail: '未安装——dsh web 无右侧工作台,产物与 doctor 报告只能在对话里看(gotry_artifacts_list)',
258
+ detail: '未安装此增强组件——Web 自带 GoTry 产物页与原生预览;可选补装以增强文件浏览',
268
259
  fix: 'npx @danceiny/gotry doctor --fix',
269
260
  });
270
261
  // 6. dsh-calendar(patch 分发面宿主插件;D-9 拍板:默认不挂载)
@@ -371,7 +362,7 @@ export async function runDoctorChecks(opts = {}) {
371
362
  const broken = items.filter(i => i.status !== 'ok' && i.id !== 'llm-key');
372
363
  const summary = broken.length === 0
373
364
  ? `体检通过:${items.length} 项全部就绪(可选依赖齐,LLM key 归 dsh 宿主管)。`
374
- : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
365
+ : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : i.id === 'extension' ? '待确认' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
375
366
  return { ok: broken.length === 0, items, summary };
376
367
  }
377
368
  /** 修复指引入表:命令类才加反引号(prose 类如「到控制台申请 key」原样) */
@@ -23,9 +23,9 @@
23
23
  * 本层只让「每条通道」更可替换、可熔断、可 mock。
24
24
  */
25
25
  import { CircuitBreaker, withRetry, } from './resilience.js';
26
- import { flyaiSearch } from './flyai.js';
26
+ import { flyaiBreakerScope, flyaiSearch } from './flyai.js';
27
27
  import { checkAvail, hotelRates, searchHotels } from './hbcli.js';
28
- import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch } from './session-search.js';
28
+ import { sessionFlightSearch, sessionFlightGuiSearch, sessionHotelGuiSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch } from './session-search.js';
29
29
  import { geocodePlace, getClimate, getForecast } from './weather.js';
30
30
  import { verifyFlight } from './opensky.js';
31
31
  import { anythingSearch } from './anything.js';
@@ -86,8 +86,12 @@ const DEFAULT_HANDLERS = {
86
86
  HBCLI_CHECK_AVAIL: (p) => checkAvail({ ratePkgId: p.ratePkgId }, { hbcliBin: p.hbcliBin, timeoutMs: p.timeoutMs }),
87
87
  /** 用户本人登录态会话检索(浏览器解译通道:扩展桥默认 + cdp 显式后备;ReadGuard+节律闸) */
88
88
  SESSION_FLIGHT_SEARCH: (p) => sessionFlightSearch(p),
89
+ /** 携程机票 GUI 车道(2026-10-14 实装;gotry 隔离匿名窗口驱动 PC 表单,往返/任意城市原生;匿名公开价) */
90
+ SESSION_FLIGHT_GUI_SEARCH: (p) => sessionFlightGuiSearch(p),
89
91
  /** 用户本人登录态会话酒店检索(2026-09-03 实装;同浏览器解译通道,风控红线同款) */
90
92
  SESSION_HOTEL_SEARCH: (p) => sessionHotelSearch(p),
93
+ /** 携程酒店 GUI 车道(2026-10-14 实装,#664;匿名窗口驱动表单,cityId 码表退出主链路) */
94
+ SESSION_HOTEL_GUI_SEARCH: (p) => sessionHotelGuiSearch(p),
91
95
  /** 12306 余票会话检索(2026-09-03 实装;公开查询面,无登录闸;同浏览器解译通道) */
92
96
  SESSION_TRAIN_SEARCH: (p) => sessionTrainSearch(p),
93
97
  /** Dida 供应商门户实时价会话检索(2026-09-09 实装;hotel-be portal integration 迁移线) */
@@ -133,8 +137,8 @@ const hbcliTimeout = (r) => {
133
137
  const HBCLI_RETRY = { maxAttempts: 2, baseDelayMs: 300, maxDelayMs: 1_000 };
134
138
  /**
135
139
  * 渠道韧性策略表(权威面;docs/design/effect-interpreter.md §3 同表逐行有依据):
136
- * - FLYAI:瞬时代码级错误重试 1 次;连续 3 次 error(含 Sentinel)熔断 60s 保护配额;
137
- * 试用额度达限(429)归 needs-setup,永不重试;
140
+ * - FLYAI:明确瞬时故障重试 1 次;同 kind/endpoint/key 连续 3 次明确
141
+ * 上游健康故障(含 Sentinel)熔断 60s;业务、配置与未知失败不计熔断;
138
142
  * - HBCLI:仅 timeout 类失败重试 1 次(冷启动建后端 session 可超时,重试即恢复);
139
143
  * ENOENT/退码类永不重试(上游契约「候选路径是切换不是重试」),熔断同上;
140
144
  * RATES/CHECK_AVAIL 同族,且价格面无静态降级(fail-closed,不估算房价——
@@ -159,7 +163,12 @@ const SPECS = {
159
163
  // Only explicit transient classification permits retry; terminal failures stay final.
160
164
  return result?.retryable === true;
161
165
  },
162
- isFailure: defaultIsFailure,
166
+ isFailure: r => r?.upstreamHealth === 'unhealthy',
167
+ isNeutral: r => {
168
+ const health = r?.upstreamHealth;
169
+ return health !== 'healthy' && health !== 'unhealthy';
170
+ },
171
+ breakerScope: p => flyaiBreakerScope(p?.kind),
163
172
  },
164
173
  HBCLI_HOTEL_SEARCH: {
165
174
  channel: 'cli',
@@ -190,12 +199,24 @@ const SPECS = {
190
199
  breaker: null,
191
200
  isFailure: defaultIsFailure,
192
201
  },
202
+ SESSION_FLIGHT_GUI_SEARCH: {
203
+ channel: 'browser',
204
+ retry: null,
205
+ breaker: null,
206
+ isFailure: defaultIsFailure,
207
+ },
193
208
  SESSION_HOTEL_SEARCH: {
194
209
  channel: 'browser',
195
210
  retry: null,
196
211
  breaker: null,
197
212
  isFailure: defaultIsFailure,
198
213
  },
214
+ SESSION_HOTEL_GUI_SEARCH: {
215
+ channel: 'browser',
216
+ retry: null,
217
+ breaker: null,
218
+ isFailure: defaultIsFailure,
219
+ },
199
220
  SESSION_TRAIN_SEARCH: {
200
221
  channel: 'browser',
201
222
  retry: null,
@@ -286,9 +307,12 @@ export function declinedObservation(effect, trace) {
286
307
  summary: trace.declined === 'aborted'
287
308
  ? `${effect} 检索已取消,未产生新的查询结果。`
288
309
  : trace.declined === 'circuit-open'
289
- ? `${effect} 通道被断路器开启保护(连续失败达到阈值,冷却中)——不要立即重试,换其他工具或稍后再试;原因见 trace 证据链`
310
+ ? trace.breaker === 'half-open'
311
+ ? `${effect} 通道的恢复探测正在进行;等待当前探测结果后再查询,或使用其他工具。`
312
+ : `${effect} 通道被断路器开启保护(连续上游故障达到阈值,冷却中);${typeof trace.retryAfterMs === 'number' ? `约 ${Math.ceil(trace.retryAfterMs / 1_000)} 秒后可发起恢复探测` : '稍后可发起恢复探测'},当前可使用其他工具。`
290
313
  : `${effect} 未登记效应(effect_interpreter.v1 注册表外,生产解译器拒绝)`,
291
314
  evidence: trace.evidence.join(';'),
315
+ ...(trace.retryAfterMs !== undefined ? { retryAfterMs: trace.retryAfterMs } : {}),
292
316
  };
293
317
  }
294
318
  const defaultBreakers = new Map();
@@ -314,16 +338,18 @@ export function makeProductionInterpreter(opts = {}) {
314
338
  const br = (() => {
315
339
  if (!spec.breaker)
316
340
  return null;
317
- const existing = breakers.get(fx.effect);
341
+ const key = spec.breakerScope ? `${fx.effect}:${spec.breakerScope(fx.params)}` : fx.effect;
342
+ const existing = breakers.get(key);
318
343
  if (existing)
319
344
  return existing;
320
345
  const created = new CircuitBreaker({ ...spec.breaker, ...(opts.now ? { now: opts.now } : {}) });
321
- breakers.set(fx.effect, created);
346
+ breakers.set(key, created);
322
347
  return created;
323
348
  })();
324
349
  const gate = br?.canAttempt() ?? { allowed: true, state: 'closed', token: undefined };
325
350
  if (!gate.allowed) {
326
- const trace = { effect: fx.effect, channel: spec.channel, attempts: 0, backoffMs: 0, breaker: gate.state, declined: 'circuit-open', evidence: [`[效应:${fx.effect}@${ts}] breaker=${gate.state} 拒绝(冷却中,不重试)`] };
351
+ const retryAfterMs = 'retryAfterMs' in gate ? gate.retryAfterMs : undefined;
352
+ const trace = { effect: fx.effect, channel: spec.channel, attempts: 0, backoffMs: 0, breaker: gate.state, declined: 'circuit-open', ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), evidence: [`[效应:${fx.effect}@${ts}] breaker=${gate.state} 拒绝${retryAfterMs !== undefined ? ` retryAfterMs=${retryAfterMs}` : '(恢复探测在途)'}`] };
327
353
  return { result: null, trace };
328
354
  }
329
355
  const policy = spec.retry
@@ -346,9 +372,16 @@ export function makeProductionInterpreter(opts = {}) {
346
372
  };
347
373
  return { result: null, trace: abortedTrace };
348
374
  }
349
- const failed = outcome.error != null || spec.isFailure(outcome.result);
350
- if (br)
351
- failed ? br.onFailure(gate.token) : br.onSuccess(gate.token);
375
+ const neutral = spec.isNeutral?.(outcome.result, outcome.error) === true;
376
+ const failed = !neutral && (outcome.error != null || spec.isFailure(outcome.result));
377
+ if (br) {
378
+ if (neutral)
379
+ br.releaseProbe(gate.token);
380
+ else if (failed)
381
+ br.onFailure(gate.token);
382
+ else
383
+ br.onSuccess(gate.token);
384
+ }
352
385
  const breakerState = br?.state() ?? 'off';
353
386
  const evidence = [`[效应:${fx.effect}@${ts}] attempts=${outcome.attempts} backoff=${outcome.backoffMs}ms breaker=${breakerState}`];
354
387
  return {