@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.31

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/client/client.js CHANGED
@@ -12,6 +12,174 @@ 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
+ artifacts: 'GoTry artifacts', artifactsHint: 'Browse saved itineraries and planning deliveries',
30
+ search: 'Search artifacts', searchPlaceholder: 'Title or filename', refresh: 'Refresh',
31
+ empty: 'No saved artifacts in this workspace.', noMatches: 'No matching artifacts.', noSession: 'Open a conversation to browse its workspace.',
32
+ previous: 'Previous', next: 'Next', html: 'Open HTML preview', open: 'Open preview',
33
+ htmlHint: 'HTML previews may run the document’s scripts.', failed: 'Could not read the current state. Please retry.',
34
+ },
35
+ zh: {
36
+ title: 'GoTry', description: '旅行数据源与已保存行程', flyai: 'FlyAI(飞猪)',
37
+ loading: '正在读取…', unavailable: '配置暂时不可用,请重新读取当前状态。',
38
+ readOnly: '当前 Key 来自启动环境变量,或此平台无法在这里安全保存。',
39
+ key: 'API Key', keyHint: '粘贴飞猪控制台中的 Key,凭据不会进入对话。',
40
+ configured: '已配置 Key', anonymous: '匿名试用 · 共享额度', verified: '当前配置已验证',
41
+ unverified: '已配置 · 尚未验证通过', debug: '调试服务地址', console: '打开飞猪控制台',
42
+ save: '验证并保存', saving: '正在验证…', saveFailed: '新 Key 未保存,旧配置已保留。',
43
+ saved: '验证通过,已保存。', clear: '清除本机 Key', cleared: '本机 Key 已清除,已恢复匿名试用。',
44
+ retry: '重新读取状态', source: '来源', config: '本机配置', env: '环境变量',
45
+ 'env-debug': '调试环境变量', none: '共享试用',
46
+ artifacts: 'GoTry 产物', artifactsHint: '查看已保存行程和规划交付物',
47
+ search: '搜索产物', searchPlaceholder: '标题或文件名', refresh: '刷新',
48
+ empty: '这个工作区尚无已保存产物。', noMatches: '没有匹配的产物。', noSession: '打开一个对话后即可查看其工作区产物。',
49
+ previous: '上一页', next: '下一页', html: '打开 HTML 预览', open: '打开预览',
50
+ htmlHint: 'HTML 预览可能运行文档中的脚本。', failed: '无法读取当前状态,请重试。',
51
+ },
52
+ }
53
+
54
+ async function webRequest(path, options) {
55
+ var response = await fetch('api/gotry/' + path, Object.assign({ credentials: 'same-origin' }, options))
56
+ var value = await response.json()
57
+ if (!value || typeof value !== 'object') throw new Error('Invalid Web response')
58
+ if (!response.ok || value.ok === false) throw new Error(value.error || 'Request failed')
59
+ return value
60
+ }
61
+
62
+ function FlyaiSettings(props) {
63
+ var t = props.t
64
+ var state = React.useState(null), status = state[0], setStatus = state[1]
65
+ var draft = React.useState(''), key = draft[0], setKey = draft[1]
66
+ var noticeState = React.useState(''), notice = noticeState[0], setNotice = noticeState[1]
67
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
68
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
69
+ var reloadState = React.useState(0), reload = reloadState[0], setReload = reloadState[1]
70
+ var lifetime = React.useRef(null), activeRequest = React.useRef(false)
71
+ React.useEffect(function () {
72
+ var controller = new AbortController()
73
+ lifetime.current = controller
74
+ setError('')
75
+ webRequest('flyai', { signal: controller.signal }).then(function (value) {
76
+ if (!controller.signal.aborted) setStatus(value)
77
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
78
+ return function () { controller.abort(); lifetime.current = null }
79
+ }, [reload])
80
+ async function write(action) {
81
+ if (activeRequest.current || !lifetime.current || !status?.writable) return
82
+ var controller = lifetime.current
83
+ activeRequest.current = true
84
+ setBusy(true); setError(''); setNotice('')
85
+ var candidate = key
86
+ setKey('')
87
+ try {
88
+ var value = await webRequest('flyai/write', { method: 'POST', signal: controller.signal,
89
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(action === 'save' ? { action: action, key: candidate } : { action: action }) })
90
+ if (!controller.signal.aborted) { setStatus(value); setNotice(t(action === 'save' ? 'saved' : 'cleared')) }
91
+ } catch (failure) {
92
+ if (!controller.signal.aborted) setError(failure.message || t('saveFailed'))
93
+ } finally {
94
+ activeRequest.current = false
95
+ if (!controller.signal.aborted) setBusy(false)
96
+ }
97
+ }
98
+ if (props.view === 'summary') return t('description')
99
+ var disabled = busy || !status?.writable
100
+ var stateLabel = !status ? t('loading') : !status.configured ? t('anonymous') : status.verified ? t('verified') : t('unverified')
101
+ return element('section', { 'data-gotry-flyai-settings': '', style: { display: 'grid', gap: '16px', maxWidth: '640px' } }, [
102
+ element('h3', { key: 'heading', style: { margin: 0 } }, t('flyai')),
103
+ element('div', { key: 'status', role: 'status', 'data-gotry-flyai-status': '', style: { display: 'grid', gap: '6px' } }, [
104
+ element(UI.Tag, { key: 'badge', tone: status?.verified ? 'success' : 'neutral' }, stateLabel),
105
+ status ? element('span', { key: 'source', style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('source') + ':' + t(status.source)) : null,
106
+ status?.endpointDebug ? element('span', { key: 'debug' }, t('debug') + ':' + status.endpoint) : null,
107
+ ]),
108
+ element('a', { key: 'console', href: 'https://flyai.open.fliggy.com/console', target: '_blank', rel: 'noopener noreferrer' }, t('console')),
109
+ element(UI.SettingsForm, { key: 'form', labels: { unavailable: t('loading'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') },
110
+ state: { available: Boolean(status), writable: Boolean(status?.writable), dirty: key.trim().length > 0, invalid: false, saving: busy, failed: false },
111
+ onSave: function () { void write('save') }, onDiscard: function () { setKey('') } }, [
112
+ element(UI.SettingsSecretField, { key: 'key', id: 'gotry-flyai-key', label: t('key'), hint: t('keyHint'), text: key,
113
+ configured: Boolean(status?.configured), stateLabel: t(status?.configured ? 'configured' : 'anonymous'), disabled: disabled,
114
+ onEdit: function (value) { setKey(value); setError(''); setNotice('') } }),
115
+ ]),
116
+ error ? element('p', { key: 'error', role: 'alert', style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
117
+ notice ? element('p', { key: 'notice', role: 'status', style: { margin: 0 } }, notice) : null,
118
+ element('div', { key: 'actions', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } }, [
119
+ element(UI.Button, { key: 'retry', type: 'button', variant: 'outline', disabled: busy,
120
+ onClick: function () { setReload(function (value) { return value + 1 }) } }, t('retry')),
121
+ status?.configured && status.writable ? element(UI.Button, { key: 'clear', type: 'button', disabled: busy,
122
+ onClick: function () { void write('clear') } }, t('clear')) : null,
123
+ ]),
124
+ ])
125
+ }
126
+
127
+ function ArtifactBrowser(props) {
128
+ var t = props.t, info = props.useTabInfo(), sessionId = props.sessionId
129
+ var cwd = props.useSessions(function (sessions) { return sessions.byId[sessionId]?.cwd })
130
+ var state = React.useState(null), result = state[0], setResult = state[1]
131
+ var queryState = React.useState(''), search = queryState[0], setSearch = queryState[1]
132
+ var pageState = React.useState(0), offset = pageState[0], setOffset = pageState[1]
133
+ var refreshState = React.useState(0), refresh = refreshState[0], setRefresh = refreshState[1]
134
+ var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
135
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
136
+ React.useEffect(function () {
137
+ if (!sessionId || !info.tab.visible) return
138
+ var controller = new AbortController()
139
+ setBusy(true); setError('')
140
+ var query = new URLSearchParams({ sessionId: sessionId, search: search, offset: String(offset), limit: '20' })
141
+ webRequest('artifacts?' + query, { signal: controller.signal }).then(function (value) {
142
+ if (!controller.signal.aborted) setResult(value)
143
+ }).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
144
+ .finally(function () { if (!controller.signal.aborted) setBusy(false) })
145
+ return function () { controller.abort() }
146
+ }, [sessionId, info.tab.visible, search, offset, refresh])
147
+ function open(path) {
148
+ var normalized = String(path).replace(/\\/g, '/')
149
+ var root = String(cwd || '').replace(/\\/g, '/').replace(/\/$/, '')
150
+ var relative = root && normalized.startsWith(root + '/') ? normalized.slice(root.length + 1) : normalized
151
+ info.tab.actions.openResource('dsh-resource://file/session/' + encodeURIComponent(sessionId) + '/' + relative.split('/').map(encodeURIComponent).join('/'))
152
+ }
153
+ var entries = result?.artifacts || []
154
+ 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 } }, [
155
+ element('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 } }, [
156
+ element(UI.Input, { key: 'search', 'aria-label': t('search'), placeholder: t('searchPlaceholder'), value: search,
157
+ style: { minWidth: 0, width: '100%' }, onChange: function (event) { setSearch(event.target.value); setOffset(0) } }),
158
+ element(UI.Button, { key: 'refresh', size: 'sm', disabled: busy || !sessionId,
159
+ onClick: function () { setRefresh(function (value) { return value + 1 }) } }, busy ? t('loading') : t('refresh')),
160
+ ]),
161
+ error ? element('p', { key: 'error', role: 'alert' }, error) : null,
162
+ !sessionId ? element('p', { key: 'no-session' }, t('noSession')) : !busy && entries.length === 0 ? element('p', { key: 'empty' }, t(search ? 'noMatches' : 'empty')) : null,
163
+ element('div', { key: 'list', 'aria-busy': busy, style: { display: 'grid', gap: '8px' } }, entries.map(function (entry) {
164
+ var html = /\.html?$/i.test(entry.path)
165
+ return element(UI.Button, { key: entry.path, variant: 'outline', 'data-gotry-artifact-entry': entry.path,
166
+ title: html ? t('htmlHint') : entry.path, onClick: function () { open(entry.path) },
167
+ style: { width: '100%', height: 'auto', padding: '12px', textAlign: 'left', justifyContent: 'flex-start', minWidth: 0 } }, [
168
+ element(UI.FileTypeIcon, { key: 'icon', path: entry.path }),
169
+ element('span', { key: 'body', style: { display: 'grid', gap: '4px', minWidth: 0, flex: 1 } }, [
170
+ element('span', { key: 'title', style: { overflowWrap: 'anywhere' } }, entry.title),
171
+ element('span', { key: 'meta', style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', overflowWrap: 'anywhere' } },
172
+ [entry.status, entry.updated ? new Date(entry.updated).toLocaleString() : '', html ? t('html') : t('open')].filter(Boolean).join(' · ')),
173
+ ]),
174
+ ])
175
+ })),
176
+ result?.total > 0 ? element('div', { key: 'pagination', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } }, [
177
+ element(UI.Button, { key: 'previous', size: 'sm', disabled: busy || offset === 0, onClick: function () { setOffset(Math.max(0, offset - 20)) } }, t('previous')),
178
+ element('span', { key: 'count', role: 'status', style: { fontSize: '12px' } }, (entries.length ? offset + 1 : 0) + '–' + (offset + entries.length) + ' / ' + result.total),
179
+ element(UI.Button, { key: 'next', size: 'sm', disabled: busy || !result.truncated, onClick: function () { setOffset(result.nextOffset) } }, t('next')),
180
+ ]) : null,
181
+ ])
182
+ }
15
183
 
16
184
  function element(type, props, children) {
17
185
  return React.createElement(type, props, ...(Array.isArray(children) ? children : [children]))
@@ -194,6 +362,21 @@ window.__ModuleLoader__.load({
194
362
  return scope.slots.register({ name: 'tool.call.toolview', key: 'gotry_artifacts_read' }, ArtifactToolView)
195
363
  })
196
364
  })
365
+ ctx.inject(['slots', 'locale'], function (scope) {
366
+ var ns = 'gotry.web'
367
+ var t = scope.locale.bind(ns)
368
+ scope.effect(function () { return scope.locale.register(ns, webLocale) })
369
+ scope.slots.inject('plugins.item', function () {
370
+ return scope.slots.register({ name: 'plugins.item', id: 'gotry', order: 50, label: function () { return t('title') }, locale: ns }, FlyaiSettings)
371
+ })
372
+ scope.inject(['sidebarRightTabs'], function (sidebar) {
373
+ sidebar.effect(function () { return sidebar.sidebarRightTabs.register({ id: '@danceiny/gotry:artifacts', kind: 'gotry-artifacts',
374
+ title: function () { return t('artifacts') }, guide: [{ id: 'gotry-artifacts', order: 15, title: function () { return t('artifacts') }, description: function () { return t('artifactsHint') } }] }) })
375
+ sidebar.slots.inject('sidebar.right.pane.tab', function () {
376
+ return sidebar.slots.register({ name: 'sidebar.right.pane.tab', key: '@danceiny/gotry:artifacts', locale: ns }, ArtifactBrowser)
377
+ })
378
+ })
379
+ })
197
380
  }
198
381
 
199
382
  exports.apply = apply
@@ -19,6 +19,7 @@ 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';
22
23
  import { readLatestChannelEvents } from './channel-health.js';
23
24
  import { displayEndpoint, endpointFingerprint, readFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, } from './flyai-config.js';
24
25
  /** hbcli 已知安装位(与 capabilities/hbcli.ts hbcliBinCandidates 同清单) */
@@ -124,17 +125,8 @@ export async function runDoctorChecks(opts = {}) {
124
125
  items.push(nodeOk(nodeVersion)
125
126
  ? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
126
127
  : { 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
- });
128
+ // 1. CLI 与工具层共用只读检测:安装、落位、连接是不同证据。
129
+ items.push({ id: 'extension', label: 'Stai Travel Bridge 扩展', ...await checkExtensionStatus({ homeDir: home, env, ports: opts.extensionPorts }) });
138
130
  // 2. agent-reach(网页/社媒读取;.venv 装在包内,与 agent-reach.ts venvPython 同位)
139
131
  const venvPython = join(repoRoot, '.venv/bin/python');
140
132
  const reachBin = join(repoRoot, '.venv/bin/agent-reach');
@@ -229,7 +221,7 @@ export async function runDoctorChecks(opts = {}) {
229
221
  items.push({
230
222
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
231
223
  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',
224
+ fix: 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`:① 打开 https://flyai.open.fliggy.com/console 复制 API Key;② 在终端隐藏粘贴;③ 验证后保存。也可运行 `npx @danceiny/gotry doctor --fix` 按提示配置',
233
225
  });
234
226
  }
235
227
  else {
@@ -254,7 +246,7 @@ export async function runDoctorChecks(opts = {}) {
254
246
  items.push({
255
247
  id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
256
248
  detail: `${reason}(${sourceText};endpoint ${displayEndpoint(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})——非空不等于鉴权通过`,
257
- fix: '本机运行 `gotry setup flyai` 重新验证后保存;`gotry setup flyai --clear` 回退匿名',
249
+ fix: 'Web 打开「插件 → GoTry → FlyAI」重新验证;或运行 `npx @danceiny/gotry setup flyai`;`npx @danceiny/gotry setup flyai --clear` 回退匿名',
258
250
  });
259
251
  }
260
252
  }
@@ -264,7 +256,7 @@ export async function runDoctorChecks(opts = {}) {
264
256
  ? { id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'ok', detail: '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' }
265
257
  : {
266
258
  id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'missing',
267
- detail: '未安装——dsh web 无右侧工作台,产物与 doctor 报告只能在对话里看(gotry_artifacts_list)',
259
+ detail: '未安装此增强组件——Web 自带 GoTry 产物页与原生预览;可选补装以增强文件浏览',
268
260
  fix: 'npx @danceiny/gotry doctor --fix',
269
261
  });
270
262
  // 6. dsh-calendar(patch 分发面宿主插件;D-9 拍板:默认不挂载)
@@ -371,7 +363,7 @@ export async function runDoctorChecks(opts = {}) {
371
363
  const broken = items.filter(i => i.status !== 'ok' && i.id !== 'llm-key');
372
364
  const summary = broken.length === 0
373
365
  ? `体检通过:${items.length} 项全部就绪(可选依赖齐,LLM key 归 dsh 宿主管)。`
374
- : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
366
+ : `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : i.id === 'extension' ? '待确认' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
375
367
  return { ok: broken.length === 0, items, summary };
376
368
  }
377
369
  /** 修复指引入表:命令类才加反引号(prose 类如「到控制台申请 key」原样) */
@@ -36,13 +36,14 @@ const fakeReact = {
36
36
  useState(initial) { return [initial, () => { }]; },
37
37
  };
38
38
  const clientExports = registrations[0].factory(name => {
39
- assert.equal(name, 'react');
40
- return fakeReact;
39
+ assert.ok(['react', '@deepseek-ai/dsh-client-ui-primitives'].includes(name));
40
+ return name === 'react' ? fakeReact : {};
41
41
  });
42
42
  assert.deepEqual(Object.keys(clientExports), ['apply', 'inject']);
43
43
  const slotRegistrations = [];
44
44
  const registrationContext = {
45
- inject(_deps, callback) { callback(registrationContext); },
45
+ inject(deps, callback) { if (deps.every(name => name in registrationContext))
46
+ callback(registrationContext); },
46
47
  slots: {
47
48
  inject(_slot, callback) { callback(); },
48
49
  register(definition, view) {
@@ -0,0 +1,31 @@
1
+ /** Successful bounded renders produce native delivery events; failures and escaped paths never do. */
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ const moduleUrl = new URL('../src/artifact-delivery.js', import.meta.url).href;
7
+ const delivery = await import(moduleUrl).catch(() => undefined);
8
+ assert.equal(typeof delivery?.registerArtifactDeliveries, 'function', 'bounded render tools must publish native file deliveries');
9
+ const root = mkdtempSync(join(tmpdir(), 'gotry-artifact-delivery-'));
10
+ try {
11
+ const path = join(root, 'trip.html');
12
+ writeFileSync(path, '<h1>Trip</h1>');
13
+ let observe = () => { throw new Error('observer absent'); };
14
+ const events = [];
15
+ const session = { header: { cwd: root }, view: () => ({ getSnapshot: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
16
+ append: (name, value) => events.push({ name, value }) };
17
+ delivery.registerArtifactDeliveries({ get: () => ({ stateOf: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
18
+ on: (name, fn) => { assert.equal(name, 'tools/result'); observe = fn; } });
19
+ const exec = { name: 'gotry_itinerary_render', callId: 'render-1', agent: { session }, signal: new AbortController().signal };
20
+ observe(exec, { isError: false, value: { ok: true, path } });
21
+ assert.deepEqual(events, [{ name: 'deliverables/presented', value: { turn: 7, callId: 'render-1', files: [{ path, description: 'GoTry itinerary' }] } }]);
22
+ for (const value of [{ ok: false, path }, { ok: true, path: join(root, 'missing.html') }, { ok: true, path: join(root, '..', 'private.html') }])
23
+ observe(exec, { isError: false, value });
24
+ observe({ ...exec, name: 'gotry_artifacts_read' }, { isError: false, value: { ok: true, path } });
25
+ observe(exec, { isError: true, value: { ok: true, path } });
26
+ assert.equal(events.length, 1);
27
+ console.log('GoTry native artifact deliveries: real bounded render only PASS');
28
+ }
29
+ finally {
30
+ rmSync(root, { recursive: true, force: true });
31
+ }
@@ -21,9 +21,14 @@ import { EventEmitter } from 'node:events';
21
21
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
22
22
  import { tmpdir } from 'node:os';
23
23
  import { join, dirname, delimiter } from 'node:path';
24
- import { runDoctorChecks } from '../capabilities/doctor.js';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { runDoctorChecks as diagnose } from '../capabilities/doctor.js';
25
26
  const repoRoot = join(import.meta.dirname, '..', '..');
26
27
  const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
28
+ const offlinePreload = pathToFileURL(join(repoRoot, 'scripts', 'doctor-test-isolation.mjs')).href;
29
+ process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --import=${offlinePreload}`.trim();
30
+ process.env.GOTRY_TEST_DOCTOR_PORT = '0';
31
+ const runDoctorChecks = (opts = {}) => diagnose({ ...opts, extensionPorts: [] });
27
32
  function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
28
33
  try {
29
34
  const out = execFileSync('node', [bootstrapPath, ...extraArgs], {
@@ -156,7 +161,7 @@ console.log('9. calendar 子命令(setup 状态面 on/off/status + doctor 三态
156
161
  assert.ok(!r.stdout.includes('[gotry-doctor]'), 'summary 模式零 header(stdout 静默)');
157
162
  assert.match(r.stderr, /\[gotry\] doctor: \d+ 项待处理/, '隔离 HOME 有缺失项 → 一行摘要进 stderr');
158
163
  assert.match(r.stderr, /gotry_doctor/, '摘要带对话内指路');
159
- assert.match(r.stderr, /扩展=缺/, '缺失项人话=缺(降级类=半可用)');
164
+ assert.match(r.stderr, /扩展=待确认/, '没有安装或连接证据时,摘要显示待确认');
160
165
  }
161
166
  console.log('10. 启动一次性 doctor 摘要(--summary:stderr 一行/零写盘/恒 exit 0)OK');
162
167
  // 11. setupSidebar 落盘状态复核(2026-09-08 安装链修复):pnpm ≥10.5 在 profile 目录
@@ -388,6 +393,9 @@ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_O
388
393
  const fixture = mkdtempSync(join(tmpdir(), 'gotry-onboard-pkg-'));
389
394
  mkdirSync(join(fixture, 'bin'), { recursive: true });
390
395
  copyFileSync(bootstrap, join(fixture, 'bin', 'gotry-bootstrap.js'));
396
+ copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(fixture, 'bin', 'gotry-flyai-verification.js'));
397
+ copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(fixture, 'bin', 'gotry-process-liveness.js'));
398
+ copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(fixture, 'bin', 'gotry-extension-status.js'));
391
399
  copyFileSync(join(repoRoot, 'package.json'), join(fixture, 'package.json'));
392
400
  const vendorDir = join(fixture, 'ts', 'dsh-runtime', 'vendor');
393
401
  mkdirSync(vendorDir, { recursive: true });
@@ -637,6 +645,8 @@ function buildPkgFixture(tmpRoot) {
637
645
  writeFileSync(join(pkgRoot, 'package.json'), `${JSON.stringify({ name: 'gotry', version: '0.0.1-test', type: 'module' })}\n`);
638
646
  copyFileSync(join(repoRoot, 'bin', 'gotry-inner.js'), join(pkgBin, 'gotry-inner.js'));
639
647
  copyFileSync(join(repoRoot, 'bin', 'gotry-bootstrap.js'), join(pkgBin, 'gotry-bootstrap.js'));
648
+ copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(pkgBin, 'gotry-flyai-verification.js'));
649
+ copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(pkgBin, 'gotry-extension-status.js'));
640
650
  copyFileSync(join(repoRoot, 'bin', 'gotry-runtime-resolution.js'), join(pkgBin, 'gotry-runtime-resolution.js'));
641
651
  copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(pkgBin, 'gotry-process-liveness.js'));
642
652
  copyFileSync(join(repoRoot, 'cordis.gotry-patch.yml'), join(pkgRoot, 'cordis.gotry-patch.yml'));
@@ -699,8 +709,8 @@ echo "bash-finished $$" >> "$GOTRY_TEST_INSTALLER_MARKER"
699
709
  }
700
710
  const closedFixtures = new WeakSet();
701
711
  function spawnInner(fixture, opts) {
702
- // NODE_OPTIONS:TTY 路径挂 fixture-local preload;非 TTY 路径清空(移除 tsx loader,temp 包无 tsx 会崩)。
703
- const nodeOptions = opts.tty ? `--require ${fixture.preloadPath}` : '';
712
+ // 保留离线诊断 preload,但移除父进程 tsx loader;临时包没有 tsx。
713
+ const nodeOptions = `--import=${offlinePreload}${opts.tty ? ` --require ${fixture.preloadPath}` : ''}`;
704
714
  const child = spawn(process.execPath, [join(fixture.pkgBin, 'gotry-inner.js'), 'web', '--no-open'], {
705
715
  env: {
706
716
  ...process.env,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * doctor 能力层测试(离线,注入 repoRoot/homeDir/env,零安装零网络写路径):
3
- * 1. 空 tmp 环境 → agent-reach=missing / 扩展=missing / flyai=degraded(无 key)/ LLM key 恒 ok 且不进 broken
3
+ * 1. 空 tmp 环境 → agent-reach=missing / 扩展=degraded(连接未确认) / flyai=degraded(无 key)/ LLM key 恒 ok 且不进 broken
4
4
  * 2. 补齐假 .venv 双文件 → agent-reach=ok;只补 python → degraded(半可用态被显式区分)
5
5
  * 3. FLYAI_API_KEY 仅注入 → flyai=degraded(未验证);匹配验证回执 → flyai=ok
6
6
  * 4. nodeOk 边界(22.14/22.15/23.0)
@@ -25,9 +25,13 @@ import { existsSync } from 'node:fs';
25
25
  import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
26
26
  import { tmpdir } from 'node:os';
27
27
  import { dirname, join } from 'node:path';
28
- import { runDoctorChecks, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
28
+ import { runDoctorChecks as diagnose, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
29
29
  import { endpointFingerprint, maskFlyaiKey, resolveFlyaiEndpoint, sha256Hex, writeFlyaiVerification } from '../capabilities/flyai-config.js';
30
30
  import { apply } from '../src/index.js';
31
+ const runDoctorChecks = (opts = {}) => diagnose({ ...opts, extensionPorts: [] });
32
+ // The registered model tool calls diagnose internally rather than this wrapper.
33
+ // Isolate its HTTP diagnostic seam too, without changing the product config.
34
+ await import(new URL('../../scripts/doctor-test-isolation.mjs', import.meta.url).href);
31
35
  const tmp = await mkdtemp(join(tmpdir(), 'gotry-doctor-test-'));
32
36
  const emptyRepo = join(tmp, 'repo-empty');
33
37
  const emptyHome = join(tmp, 'home-empty');
@@ -39,11 +43,10 @@ const byId = (id) => r1.items.find(i => i.id === id);
39
43
  assert.ok(byId('agent-reach'), 'agent-reach 项存在');
40
44
  assert.equal(byId('agent-reach').status, 'missing', `空仓 agent-reach=missing,实际 ${byId('agent-reach').status}`);
41
45
  assert.match(byId('agent-reach').fix ?? '', /npx @danceiny\/gotry doctor --fix/, 'missing 项带精确补装指引');
42
- assert.equal(byId('extension').status, 'missing', '空 home 扩展=missing');
43
- // #559 B 步:扩展 missing 必须显式说明「哪些工具不可用、哪些不受影响」
44
- assert.match(byId('extension').detail, /影响面/, '扩展 missing detail 必带影响面行(issue #559 B 步)');
45
- assert.match(byId('extension').detail, /gotry_session_search/, '扩展 missing detail 必显挂的工具名');
46
- assert.match(byId('extension').detail, /其它工具/, '扩展 missing detail 必显「其它不受影响」');
46
+ assert.equal(byId('extension').status, 'degraded', '空 home 无法断言扩展未安装');
47
+ assert.match(byId('extension').detail, /影响面/, '扩展 detail 必说明影响面');
48
+ assert.match(byId('extension').fix ?? '', /chrome:\/\/extensions/, '扩展未确认时可检查浏览器安装/启用状态');
49
+ assert.match(byId('extension').detail, /其它工具/, '扩展 detail 必说明其它工具不受影响');
47
50
  assert.equal(byId('flyai').status, 'degraded', '无 key flyai=degraded(有共享试用,非缺失)');
48
51
  // #559 B 步:无 key flyai 必须显式说明「匿名共享额度」影响面
49
52
  assert.match(byId('flyai').detail, /影响面/, 'flyai 无 key detail 必带影响面行(issue #559 B 步)');
@@ -155,6 +158,32 @@ console.log('5b. npm 提升布局解析链(map-tools/ask-user 误报修复)OK');
155
158
  await rm(vend, { recursive: true, force: true });
156
159
  }
157
160
  console.log('5c. 随包 vendor 布局(map-tools tarball 分发面)OK');
161
+ // #651: the model-facing diagnostic must recognize the same Chrome Store
162
+ // installation as the CLI without requiring the local unpacked directory.
163
+ {
164
+ const home = join(tmp, 'chrome-store-home');
165
+ const browserRoot = process.platform === 'darwin'
166
+ ? join(home, 'Library', 'Application Support', 'Google', 'Chrome')
167
+ : process.platform === 'win32'
168
+ ? join(home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
169
+ : join(home, '.config', 'google-chrome');
170
+ const profile = join(browserRoot, 'Profile 3');
171
+ const id = 'oeajpiccmonococjcegddlooeeohlbgd';
172
+ const manifest = join(profile, 'Extensions', id, '0.2.0.27_0', 'manifest.json');
173
+ await mkdir(dirname(manifest), { recursive: true });
174
+ await writeFile(manifest, JSON.stringify({ manifest_version: 3, version: '0.2.0.27', name: 'Stai Travel Bridge' }));
175
+ const prefs = join(profile, 'Secure Preferences');
176
+ await writeFile(prefs, JSON.stringify({ extensions: { settings: { [id]: { disable_reasons: [] } } } }));
177
+ const report = await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} });
178
+ const extension = report.items.find(item => item.id === 'extension');
179
+ assert.equal(extension.status, 'degraded');
180
+ assert.match(extension.detail, /已安装/);
181
+ assert.match(report.summary, /Stai Travel Bridge 扩展\(待确认\)/);
182
+ await writeFile(prefs, JSON.stringify({ extensions: { settings: { [id]: { state: 0 } } } }));
183
+ const disabled = (await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} })).items.find(item => item.id === 'extension');
184
+ assert.match(disabled.detail, /已安装.*停用/);
185
+ }
186
+ console.log('5d. Chrome 商店安装与停用状态,工具层不误报 missing/ready OK');
158
187
  // 6. MCP 工具面:gotry_doctor 注册 + isolated stateRoot 报告落盘
159
188
  const smokeRoot = await mkdtemp(join(tmpdir(), 'gotry-doctor-state-'));
160
189
  const registered = [];