@helixlife-ai/xiantao 0.1.14 → 0.1.16

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 CHANGED
@@ -54,6 +54,13 @@ For agent workflows, do not use `xt tool run`. Treat `xt tool run` as the human-
54
54
  - `xt logout`
55
55
  - `xt tool login`
56
56
 
57
+ ### History
58
+
59
+ - `xt history list`
60
+ - `xt history download`
61
+ - `xt history rename`
62
+ - `xt history delete`
63
+
57
64
  ### Tool Run
58
65
 
59
66
  - `xt tool search`
@@ -80,6 +87,8 @@ The CLI also loads a project-root `.env` file on startup. You can set `XIANTAO_B
80
87
 
81
88
  `xt tool run <tool_id> ... --interactive` prompts for dynamic `args_main` values before the final submit, prints reusable `--set` flags, and supports `<` for the previous item plus `/skip` for the current section.
82
89
 
90
+ `xt history list` lists the current profile's history records and shows the latest `fuid`, status, module, and time for each record. `xt history download <fuid> --device pdf` downloads an existing history artifact by `fuid`; when a record exposes multiple files, pass `--device` or use an `--output` path with a target extension. `xt history rename <fuid> <name>` renames a record, and `xt history delete <fuid>` deletes it.
91
+
83
92
  ## Development
84
93
 
85
94
  ```bash
@@ -0,0 +1,38 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { XtzCommand } from '../../base-command.js';
3
+ import { getAuthContext } from '../../lib/auth.js';
4
+ import { deleteXiantaoHistoryRecord, findXiantaoHistoryRecordByFuid } from '../../lib/history.js';
5
+ import { globalFlags } from '../../lib/flags.js';
6
+ export default class HistoryDelete extends XtzCommand {
7
+ static description = 'Delete a Xiantao history record';
8
+ static args = {
9
+ fuid: Args.string({
10
+ description: 'History record fuid',
11
+ required: true,
12
+ }),
13
+ };
14
+ static flags = {
15
+ ...globalFlags,
16
+ token: Flags.string({
17
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
18
+ }),
19
+ };
20
+ async run() {
21
+ const { args, flags } = await this.parse(HistoryDelete);
22
+ const agent = await this.getProfile(flags.profile);
23
+ const auth = await getAuthContext(agent, flags.token);
24
+ const before = await findXiantaoHistoryRecordByFuid(auth, args.fuid);
25
+ await deleteXiantaoHistoryRecord(auth, { fuid: args.fuid });
26
+ const output = {
27
+ agent,
28
+ fuid: args.fuid,
29
+ module_id: before?.module_id?.trim() ?? '',
30
+ module_name: before?.module_name?.trim() ?? '',
31
+ name: before?.name?.trim() ?? '',
32
+ };
33
+ this.print(flags, output, args.fuid, {
34
+ command: 'history delete',
35
+ profile: agent,
36
+ });
37
+ }
38
+ }
@@ -0,0 +1,99 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { XtzCommand } from '../../base-command.js';
3
+ import { getAuthContext } from '../../lib/auth.js';
4
+ import { XIANTAO_HISTORY_UUID } from '../../lib/constants.js';
5
+ import { downloadToPath } from '../../lib/download.js';
6
+ import { XtzError } from '../../lib/errors.js';
7
+ import { fetchXiantaoHistoryDownloadButtons, findXiantaoHistoryRecordByFuid } from '../../lib/history.js';
8
+ import { globalFlags } from '../../lib/flags.js';
9
+ import { resolveXiantaoDownload } from '../../lib/xiantao-plot.js';
10
+ export default class HistoryDownload extends XtzCommand {
11
+ static description = 'Download a file from a Xiantao history record';
12
+ static args = {
13
+ fuid: Args.string({
14
+ description: 'History record fuid',
15
+ required: true,
16
+ }),
17
+ };
18
+ static flags = {
19
+ ...globalFlags,
20
+ device: Flags.string({
21
+ description: 'Preferred device/format, e.g. pdf, xlsx, zip',
22
+ }),
23
+ output: Flags.string({
24
+ char: 'o',
25
+ description: 'Destination path; defaults to ~/Downloads/<file name>',
26
+ }),
27
+ token: Flags.string({
28
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
29
+ }),
30
+ };
31
+ async run() {
32
+ const { args, flags } = await this.parse(HistoryDownload);
33
+ const agent = await this.getProfile(flags.profile);
34
+ const auth = await getAuthContext(agent, flags.token);
35
+ const record = await findXiantaoHistoryRecordByFuid(auth, args.fuid);
36
+ if (!record?.module_id?.trim()) {
37
+ throw new XtzError(`未找到 fuid 为 ${args.fuid} 的历史记录。`);
38
+ }
39
+ const result = await fetchXiantaoHistoryDownloadButtons(auth, { fuid: args.fuid });
40
+ const item = this.pickDownloadItem(result.downloads, flags.device, flags.output);
41
+ if (!item) {
42
+ throw new XtzError('当前历史记录返回多个可下载结果,请传入 `--device`,或将 `--output` 改为带目标扩展名的文件名。');
43
+ }
44
+ const resolved = await resolveXiantaoDownload(auth, {
45
+ item,
46
+ moduleId: record.module_id.trim(),
47
+ uuid: XIANTAO_HISTORY_UUID,
48
+ });
49
+ const filePath = await downloadToPath(resolved.url, flags.output);
50
+ const output = {
51
+ agent,
52
+ device: item.device,
53
+ endpoint: item.endpoint,
54
+ file_name: resolved.fileName ?? item.label,
55
+ fuid: args.fuid,
56
+ module_id: record.module_id.trim(),
57
+ output: filePath,
58
+ url: resolved.url,
59
+ };
60
+ this.print(flags, output, filePath, {
61
+ command: 'history download',
62
+ profile: agent,
63
+ });
64
+ }
65
+ pickDownloadItem(downloads, expectedDevice, outputPath) {
66
+ const normalizedExpectedDevice = this.normalizeDevice(expectedDevice ?? this.inferDeviceFromOutputPath(outputPath));
67
+ if (normalizedExpectedDevice) {
68
+ const matched = downloads.filter((item) => this.normalizeDevice(item.device) === normalizedExpectedDevice);
69
+ if (matched.length === 1) {
70
+ return matched[0];
71
+ }
72
+ }
73
+ const results = downloads.filter((item) => item.endpoint === 'public.output.download-result');
74
+ if (results.length === 1) {
75
+ return results[0];
76
+ }
77
+ if (downloads.length === 1) {
78
+ return downloads[0];
79
+ }
80
+ return undefined;
81
+ }
82
+ inferDeviceFromOutputPath(outputPath) {
83
+ const extension = outputPath?.trim().split('.').pop()?.toLowerCase();
84
+ return extension ? this.normalizeDevice(extension) : undefined;
85
+ }
86
+ normalizeDevice(device) {
87
+ const normalized = device?.trim().toLowerCase();
88
+ if (!normalized) {
89
+ return undefined;
90
+ }
91
+ if (normalized === 'tif') {
92
+ return 'tiff';
93
+ }
94
+ if (normalized === 'jpg') {
95
+ return 'jpeg';
96
+ }
97
+ return normalized;
98
+ }
99
+ }
@@ -0,0 +1,72 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { XtzCommand } from '../../base-command.js';
3
+ import { getAuthContext } from '../../lib/auth.js';
4
+ import { fetchXiantaoHistoryRecords, getXiantaoHistoryRecordStatus } from '../../lib/history.js';
5
+ import { globalFlags } from '../../lib/flags.js';
6
+ export default class HistoryList extends XtzCommand {
7
+ static description = 'List Xiantao history records';
8
+ static flags = {
9
+ ...globalFlags,
10
+ page: Flags.integer({
11
+ default: 1,
12
+ description: 'History page number',
13
+ min: 1,
14
+ }),
15
+ token: Flags.string({
16
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
17
+ }),
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(HistoryList);
21
+ const agent = await this.getProfile(flags.profile);
22
+ const auth = await getAuthContext(agent, flags.token);
23
+ const page = await fetchXiantaoHistoryRecords(auth, { currentPage: flags.page });
24
+ const records = page.records.map((record) => ({
25
+ fuid: record.fuid?.trim() ?? '',
26
+ id: typeof record.id === 'number' ? record.id : null,
27
+ module_id: record.module_id?.trim() ?? '',
28
+ module_name: record.module_name?.trim() ?? '',
29
+ name: record.name?.trim() ?? '',
30
+ status: getXiantaoHistoryRecordStatus(record),
31
+ time: record.time?.trim() ?? '',
32
+ type: record.type?.trim() ?? '',
33
+ }));
34
+ const output = {
35
+ agent,
36
+ current_page: page.currentPage,
37
+ info: page.info,
38
+ last_page: page.lastPage,
39
+ per_page: page.perPage,
40
+ records,
41
+ total: page.total,
42
+ };
43
+ this.print(flags, output, this.formatHistoryText(page.currentPage, page.lastPage, page.total, records, page.info), {
44
+ command: 'history list',
45
+ profile: agent,
46
+ });
47
+ }
48
+ formatHistoryText(currentPage, lastPage, total, records, info) {
49
+ const lines = [`历史记录 ${total} 条,第 ${currentPage}/${lastPage} 页`];
50
+ if (records.length === 0) {
51
+ lines.push('');
52
+ lines.push('暂无历史记录');
53
+ return lines.join('\n');
54
+ }
55
+ for (const record of records) {
56
+ lines.push('');
57
+ lines.push(`${record.module_id || '-'} ${record.module_name || '-'}`);
58
+ lines.push(` status: ${record.status}`);
59
+ lines.push(` time: ${record.time || '-'}`);
60
+ lines.push(` type: ${record.type || '-'}`);
61
+ lines.push(` fuid: ${record.fuid || '-'}`);
62
+ if (record.name) {
63
+ lines.push(` name: ${record.name}`);
64
+ }
65
+ }
66
+ if (info) {
67
+ lines.push('');
68
+ lines.push(info);
69
+ }
70
+ return lines.join('\n');
71
+ }
72
+ }
@@ -0,0 +1,45 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { XtzCommand } from '../../base-command.js';
3
+ import { getAuthContext } from '../../lib/auth.js';
4
+ import { findXiantaoHistoryRecordByFuid, renameXiantaoHistoryRecord } from '../../lib/history.js';
5
+ import { globalFlags } from '../../lib/flags.js';
6
+ export default class HistoryRename extends XtzCommand {
7
+ static description = 'Rename a Xiantao history record';
8
+ static args = {
9
+ fuid: Args.string({
10
+ description: 'History record fuid',
11
+ required: true,
12
+ }),
13
+ name: Args.string({
14
+ description: 'New history record name',
15
+ required: true,
16
+ }),
17
+ };
18
+ static flags = {
19
+ ...globalFlags,
20
+ token: Flags.string({
21
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
22
+ }),
23
+ };
24
+ async run() {
25
+ const { args, flags } = await this.parse(HistoryRename);
26
+ const agent = await this.getProfile(flags.profile);
27
+ const auth = await getAuthContext(agent, flags.token);
28
+ const before = await findXiantaoHistoryRecordByFuid(auth, args.fuid);
29
+ await renameXiantaoHistoryRecord(auth, {
30
+ fuid: args.fuid,
31
+ newName: args.name,
32
+ });
33
+ const output = {
34
+ agent,
35
+ fuid: args.fuid,
36
+ module_id: before?.module_id?.trim() ?? '',
37
+ module_name: before?.module_name?.trim() ?? '',
38
+ name: args.name,
39
+ };
40
+ this.print(flags, output, `${args.fuid} -> ${args.name}`, {
41
+ command: 'history rename',
42
+ profile: agent,
43
+ });
44
+ }
45
+ }
@@ -15,7 +15,7 @@ export default class PlotFetchAll extends XtzCommand {
15
15
  static flags = {
16
16
  ...globalFlags,
17
17
  token: Flags.string({
18
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
18
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
19
19
  }),
20
20
  uuid: Flags.string({
21
21
  description: 'Tool UUID used in the public.args.fetch-all payload',
@@ -15,7 +15,7 @@ export default class PlotMenu extends XtzCommand {
15
15
  static flags = {
16
16
  ...globalFlags,
17
17
  token: Flags.string({
18
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
18
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
19
19
  }),
20
20
  toolProductUuid: Flags.string({
21
21
  description: 'tool_product_uuid used in the menu filter payload; defaults to the main Xiantao product UUID',
@@ -9,7 +9,7 @@ export default class PlotMenus extends XtzCommand {
9
9
  static flags = {
10
10
  ...globalFlags,
11
11
  token: Flags.string({
12
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
12
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
13
13
  }),
14
14
  toolProductUuid: Flags.string({
15
15
  description: 'tool_product_uuid used in the menu filter payload; defaults to the main Xiantao product UUID',
@@ -15,7 +15,7 @@ export default class PlotResolve extends XtzCommand {
15
15
  static flags = {
16
16
  ...globalFlags,
17
17
  token: Flags.string({
18
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
18
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
19
19
  }),
20
20
  toolProductUuid: Flags.string({
21
21
  description: 'tool_product_uuid from the /products/apply/:uuid URL; defaults to the main Xiantao product UUID',
@@ -1,9 +1,11 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { XtzCommand } from '../../base-command.js';
3
3
  import { getAuthContext } from '../../lib/auth.js';
4
+ import { XIANTAO_HISTORY_UUID } from '../../lib/constants.js';
4
5
  import { downloadToPath } from '../../lib/download.js';
5
6
  import { XtzError } from '../../lib/errors.js';
6
7
  import { globalFlags } from '../../lib/flags.js';
8
+ import { fetchXiantaoHistoryDownloadButtons, getXiantaoHistoryRecordStatus, waitForLatestXiantaoHistoryRecord, waitForXiantaoHistoryRecordCompletion, } from '../../lib/history.js';
7
9
  import { deepMerge, isJsonObject } from '../../lib/json.js';
8
10
  import { confirmPlotArgsMain, promptPlotArgsMain, promptPlotFilePath, promptPlotInputSource, promptPlotMenuSelection, promptPlotResultSelection, } from '../../lib/plot-interactive.js';
9
11
  import { uploadFile } from '../../lib/plot.js';
@@ -61,6 +63,7 @@ export class ToolRunCommandBase extends XtzCommand {
61
63
  argsMain = await promptPlotArgsMain(prepared.schema ?? [], argsMain);
62
64
  await confirmPlotArgsMain(argsMain);
63
65
  }
66
+ const submittedAtMs = Date.now();
64
67
  const result = await submitXiantaoPlot(prepared.auth, {
65
68
  argsMain,
66
69
  duid: prepared.duid,
@@ -69,6 +72,9 @@ export class ToolRunCommandBase extends XtzCommand {
69
72
  upload: prepared.upload,
70
73
  uuid: prepared.uuid,
71
74
  });
75
+ if (result.asyncSubmission) {
76
+ return this.resolveAsyncToolRun(prepared, argsMain, flags, submittedAtMs, result.message);
77
+ }
72
78
  const downloadedPaths = flags.download
73
79
  ? await this.downloadResult(prepared.auth, {
74
80
  downloadPath: flags.download,
@@ -102,16 +108,177 @@ export class ToolRunCommandBase extends XtzCommand {
102
108
  file_name: prepared.upload.fileName,
103
109
  file_oss: prepared.upload.fileOss,
104
110
  file_size: prepared.upload.fileSize,
111
+ history: null,
112
+ history_hint: null,
105
113
  mode: prepared.inputMode,
106
114
  module_id: prepared.moduleId,
107
115
  notice: prepared.notice,
116
+ result_source: 'direct',
108
117
  route: prepared.route,
118
+ submission_message: result.message,
109
119
  tool_product_uuid: prepared.toolProductUuid,
110
120
  uuid: prepared.uuid,
111
121
  },
112
122
  text: this.formatResultText(result, downloadedPaths, flags.interactive && !flags.download),
113
123
  };
114
124
  }
125
+ async resolveAsyncToolRun(prepared, argsMain, flags, submittedAtMs, submissionMessage) {
126
+ const historyRecord = await waitForLatestXiantaoHistoryRecord(prepared.auth, {
127
+ intervalMs: 3000,
128
+ moduleId: prepared.moduleId,
129
+ submittedAtMs,
130
+ timeoutMs: flags.download ? 60_000 : 12_000,
131
+ });
132
+ const recordedHistory = historyRecord ? this.toHistorySummary(historyRecord, prepared.moduleId) : null;
133
+ if (!flags.download) {
134
+ return {
135
+ data: {
136
+ agent: prepared.agent,
137
+ args_main: argsMain,
138
+ body: '',
139
+ download_path: undefined,
140
+ download_paths: [],
141
+ downloads: [],
142
+ duid: prepared.duid,
143
+ file: null,
144
+ file_name: prepared.upload.fileName,
145
+ file_oss: prepared.upload.fileOss,
146
+ file_size: prepared.upload.fileSize,
147
+ history: recordedHistory,
148
+ history_hint: this.buildHistoryHint(recordedHistory),
149
+ mode: prepared.inputMode,
150
+ module_id: prepared.moduleId,
151
+ notice: prepared.notice,
152
+ result_source: 'history',
153
+ route: prepared.route,
154
+ submission_message: submissionMessage,
155
+ tool_product_uuid: prepared.toolProductUuid,
156
+ uuid: prepared.uuid,
157
+ },
158
+ text: this.formatAsyncHistoryText(recordedHistory, submissionMessage),
159
+ };
160
+ }
161
+ if (!historyRecord?.fuid?.trim()) {
162
+ return {
163
+ data: {
164
+ agent: prepared.agent,
165
+ args_main: argsMain,
166
+ body: '',
167
+ download_path: undefined,
168
+ download_paths: [],
169
+ downloads: [],
170
+ duid: prepared.duid,
171
+ file: null,
172
+ file_name: prepared.upload.fileName,
173
+ file_oss: prepared.upload.fileOss,
174
+ file_size: prepared.upload.fileSize,
175
+ history: recordedHistory,
176
+ history_hint: this.buildHistoryHint(recordedHistory),
177
+ mode: prepared.inputMode,
178
+ module_id: prepared.moduleId,
179
+ notice: prepared.notice,
180
+ result_source: 'history',
181
+ route: prepared.route,
182
+ submission_message: submissionMessage,
183
+ tool_product_uuid: prepared.toolProductUuid,
184
+ uuid: prepared.uuid,
185
+ },
186
+ text: this.formatAsyncHistoryText(recordedHistory, `${submissionMessage},但暂未定位到历史记录。`),
187
+ };
188
+ }
189
+ const completedRecord = await waitForXiantaoHistoryRecordCompletion(prepared.auth, {
190
+ fuid: historyRecord.fuid.trim(),
191
+ intervalMs: 3000,
192
+ timeoutMs: 60_000,
193
+ });
194
+ const finalHistory = completedRecord ? this.toHistorySummary(completedRecord, prepared.moduleId) : recordedHistory;
195
+ if (!completedRecord?.fuid?.trim()) {
196
+ return {
197
+ data: {
198
+ agent: prepared.agent,
199
+ args_main: argsMain,
200
+ body: '',
201
+ download_path: undefined,
202
+ download_paths: [],
203
+ downloads: [],
204
+ duid: prepared.duid,
205
+ file: null,
206
+ file_name: prepared.upload.fileName,
207
+ file_oss: prepared.upload.fileOss,
208
+ file_size: prepared.upload.fileSize,
209
+ history: finalHistory,
210
+ history_hint: this.buildHistoryHint(finalHistory),
211
+ mode: prepared.inputMode,
212
+ module_id: prepared.moduleId,
213
+ notice: prepared.notice,
214
+ result_source: 'history',
215
+ route: prepared.route,
216
+ submission_message: `${submissionMessage},但等待历史记录完成超时。`,
217
+ tool_product_uuid: prepared.toolProductUuid,
218
+ uuid: prepared.uuid,
219
+ },
220
+ text: this.formatAsyncHistoryText(finalHistory, `${submissionMessage},但等待历史记录完成超时。`),
221
+ };
222
+ }
223
+ const { body, downloads, paths: downloadedPaths } = await this.downloadAsyncHistoryResult(prepared.auth, {
224
+ downloadPath: flags.download,
225
+ fuid: completedRecord.fuid.trim(),
226
+ moduleId: prepared.moduleId,
227
+ });
228
+ return {
229
+ data: {
230
+ agent: prepared.agent,
231
+ args_main: argsMain,
232
+ body,
233
+ download_path: downloadedPaths[0],
234
+ download_paths: downloadedPaths,
235
+ downloads,
236
+ duid: prepared.duid,
237
+ file: null,
238
+ file_name: prepared.upload.fileName,
239
+ file_oss: prepared.upload.fileOss,
240
+ file_size: prepared.upload.fileSize,
241
+ history: finalHistory,
242
+ history_hint: this.buildHistoryHint(finalHistory),
243
+ mode: prepared.inputMode,
244
+ module_id: prepared.moduleId,
245
+ notice: prepared.notice,
246
+ result_source: 'history',
247
+ route: prepared.route,
248
+ submission_message: submissionMessage,
249
+ tool_product_uuid: prepared.toolProductUuid,
250
+ uuid: prepared.uuid,
251
+ },
252
+ text: this.formatAsyncHistoryDownloadText(finalHistory, downloadedPaths),
253
+ };
254
+ }
255
+ async downloadAsyncHistoryResult(auth, input) {
256
+ let lastError;
257
+ for (let attempt = 0; attempt < 4; attempt += 1) {
258
+ const historyResult = await fetchXiantaoHistoryDownloadButtons(auth, { fuid: input.fuid });
259
+ try {
260
+ const paths = await this.downloadResult(auth, {
261
+ downloadPath: input.downloadPath,
262
+ downloads: historyResult.downloads,
263
+ moduleId: input.moduleId,
264
+ uuid: XIANTAO_HISTORY_UUID,
265
+ });
266
+ return {
267
+ body: historyResult.body,
268
+ downloads: historyResult.downloads,
269
+ paths,
270
+ };
271
+ }
272
+ catch (error) {
273
+ if (!(error instanceof XtzError) || !this.isRetryableAsyncDownloadError(error) || attempt === 3) {
274
+ throw error;
275
+ }
276
+ lastError = error;
277
+ await this.sleep(3000);
278
+ }
279
+ }
280
+ throw lastError ?? new XtzError('下载失败,请稍后重试');
281
+ }
115
282
  formatResultText(result, downloadPaths, promptedResultSelection) {
116
283
  const lines = [];
117
284
  if (result.file) {
@@ -133,6 +300,24 @@ export class ToolRunCommandBase extends XtzCommand {
133
300
  }
134
301
  return lines.join('\n');
135
302
  }
303
+ formatAsyncHistoryText(history, message) {
304
+ const lines = [message];
305
+ if (history) {
306
+ lines.push(`历史记录: ${history.fuid} (${history.status})`);
307
+ }
308
+ lines.push(this.buildHistoryHint(history));
309
+ return lines.join('\n');
310
+ }
311
+ formatAsyncHistoryDownloadText(history, downloadPaths) {
312
+ const lines = [];
313
+ if (history) {
314
+ lines.push(`历史记录: ${history.fuid} (${history.status})`);
315
+ }
316
+ for (const item of downloadPaths) {
317
+ lines.push(item);
318
+ }
319
+ return lines.join('\n');
320
+ }
136
321
  async downloadResult(auth, input) {
137
322
  if (input.downloads.length > 0) {
138
323
  const item = this.pickDownloadItem(input.downloads, input.downloadPath);
@@ -188,6 +373,28 @@ export class ToolRunCommandBase extends XtzCommand {
188
373
  }
189
374
  return normalized;
190
375
  }
376
+ toHistorySummary(record, fallbackModuleId) {
377
+ const fuid = record.fuid?.trim();
378
+ if (!fuid) {
379
+ return null;
380
+ }
381
+ return {
382
+ fuid,
383
+ id: typeof record.id === 'number' ? record.id : null,
384
+ module_id: record.module_id?.trim() || fallbackModuleId,
385
+ module_name: record.module_name?.trim() || '',
386
+ name: record.name?.trim() || '',
387
+ status: getXiantaoHistoryRecordStatus(record),
388
+ time: record.time?.trim() || '',
389
+ type: record.type?.trim() || '',
390
+ };
391
+ }
392
+ buildHistoryHint(history) {
393
+ if (!history) {
394
+ return '可使用 `xt history list` 查看任务进度';
395
+ }
396
+ return `可使用 \`xt history download ${history.fuid} --device pdf\` 或 \`xt history list\` 查看结果`;
397
+ }
191
398
  async downloadSelectedResults(auth, input) {
192
399
  const paths = [];
193
400
  for (const item of input.downloads) {
@@ -214,6 +421,12 @@ export class ToolRunCommandBase extends XtzCommand {
214
421
  }
215
422
  return paths;
216
423
  }
424
+ isRetryableAsyncDownloadError(error) {
425
+ return error.message.includes('下载失败') || error.message.includes('未获取到下载链接');
426
+ }
427
+ async sleep(ms) {
428
+ await new Promise((resolve) => setTimeout(resolve, ms));
429
+ }
217
430
  async resolveInputMode(fetchAllResult, input) {
218
431
  if (input.demo) {
219
432
  return { mode: 'demo' };
@@ -408,7 +621,7 @@ export default class PlotRun extends ToolRunCommandBase {
408
621
  description: 'Tool route like app1.violin_flat in generic mode',
409
622
  }),
410
623
  token: Flags.string({
411
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
624
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
412
625
  }),
413
626
  toolProductUuid: Flags.string({
414
627
  description: 'tool_product_uuid used to auto-resolve module UUID and route in generic mode; defaults to the main Xiantao product UUID',
@@ -40,7 +40,7 @@ export default class ToolExec extends ToolRunCommandBase {
40
40
  description: 'Tool route like app1.violin_flat in generic mode',
41
41
  }),
42
42
  token: Flags.string({
43
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
43
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
44
44
  }),
45
45
  toolProductUuid: Flags.string({
46
46
  description: 'tool_product_uuid used to auto-resolve module UUID and route in generic mode; defaults to the main Xiantao product UUID',
@@ -17,7 +17,7 @@ export default class ToolInspect extends XtzCommand {
17
17
  static flags = {
18
18
  ...globalFlags,
19
19
  token: Flags.string({
20
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
20
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
21
21
  }),
22
22
  toolProductUuid: Flags.string({
23
23
  description: 'tool_product_uuid used in the menu filter payload; defaults to the main Xiantao product UUID',
@@ -38,7 +38,7 @@ export default class ToolPrepare extends ToolRunCommandBase {
38
38
  description: 'Tool route like app1.violin_flat in generic mode',
39
39
  }),
40
40
  token: Flags.string({
41
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
41
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
42
42
  }),
43
43
  toolProductUuid: Flags.string({
44
44
  description: 'tool_product_uuid used to auto-resolve module UUID and route in generic mode; defaults to the main Xiantao product UUID',
@@ -15,7 +15,7 @@ export default class ToolSearch extends XtzCommand {
15
15
  static flags = {
16
16
  ...globalFlags,
17
17
  token: Flags.string({
18
- description: 'Bearer token for agent.helixlife.net xiantao APIs; falls back to the stored agent token',
18
+ description: 'Bearer token for agent.helixlife.cn xiantao APIs; falls back to the stored agent token',
19
19
  }),
20
20
  toolProductUuid: Flags.string({
21
21
  description: 'tool_product_uuid used in the menu filter payload; defaults to the main Xiantao product UUID',
@@ -1,11 +1,12 @@
1
1
  export const DEFAULT_AGENT = 'opencode';
2
2
  export const XTZ_CONFIG_PATH = '.config/xtz/config.json';
3
3
  export const XTZ_UPDATE_CHECK_PATH = '.config/xtz/update-check.json';
4
- export const XIANTAO_BASE_URL = (process.env.XIANTAO_BASE_URL?.trim() || 'https://agent.helixlife.net').replace(/\/+$/, '');
4
+ export const XIANTAO_BASE_URL = (process.env.XIANTAO_BASE_URL?.trim() || 'https://agent.helixlife.cn').replace(/\/+$/, '');
5
5
  export const AUTH_URL_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/skills/auth-url`;
6
6
  export const XIANTAO_BIO_API_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/bio-api`;
7
7
  export const XIANTAO_TOOL_MENUS_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/tool/menus`;
8
8
  export const DEFAULT_XIANTAO_TOOL_PRODUCT_UUID = 'c0b6febb-52dd-4525-970a-61bbe9e263ff';
9
+ export const XIANTAO_HISTORY_UUID = '2d491694-8c8d-11ee-9409-00163e118c99';
9
10
  export const CHECK_TOKEN_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/skills/check-token`;
10
11
  export const UPLOAD_CONFIG_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/upload/config?type=bio&oss_type=bio`;
11
12
  export const BIO_API_ENDPOINT = `${XIANTAO_BASE_URL}/api/v1/agent/bio-api`;
@@ -0,0 +1,200 @@
1
+ import { XIANTAO_BIO_API_ENDPOINT, XIANTAO_HISTORY_UUID } from './constants.js';
2
+ import { XtzError } from './errors.js';
3
+ import { requestJson } from './http.js';
4
+ import { buildXiantaoHeaders } from './xiantao.js';
5
+ export async function fetchXiantaoHistoryRecords(auth, input = {}) {
6
+ const response = await requestJson(XIANTAO_BIO_API_ENDPOINT, {
7
+ body: JSON.stringify({
8
+ version: 'v3',
9
+ name: 'public.records.fetch',
10
+ args: {
11
+ id: {
12
+ uuid: XIANTAO_HISTORY_UUID,
13
+ },
14
+ current_page: input.currentPage ?? 1,
15
+ },
16
+ }),
17
+ headers: buildXiantaoHeaders(auth, true),
18
+ method: 'POST',
19
+ }, { agent: auth.agent });
20
+ return {
21
+ currentPage: response.data.current_page ?? input.currentPage ?? 1,
22
+ info: response.data.info?.trim() ?? '',
23
+ lastPage: response.data.last_page ?? 1,
24
+ perPage: response.data.per_page ?? 10,
25
+ records: response.data.data_records ?? [],
26
+ total: response.data.total ?? 0,
27
+ };
28
+ }
29
+ export async function fetchXiantaoHistoryDownloadButtons(auth, input) {
30
+ const response = await requestJson(XIANTAO_BIO_API_ENDPOINT, {
31
+ body: JSON.stringify({
32
+ version: 'v3',
33
+ name: 'public.records.download-buttons',
34
+ args: {
35
+ id: {
36
+ fuid: input.fuid,
37
+ uuid: XIANTAO_HISTORY_UUID,
38
+ },
39
+ },
40
+ }),
41
+ headers: buildXiantaoHeaders(auth, true),
42
+ method: 'POST',
43
+ }, { agent: auth.agent });
44
+ return {
45
+ body: response.data.body ?? '',
46
+ downloads: collectHistoryDownloadItems(response.data.data ?? []),
47
+ };
48
+ }
49
+ export async function renameXiantaoHistoryRecord(auth, input) {
50
+ await requestJson(XIANTAO_BIO_API_ENDPOINT, {
51
+ body: JSON.stringify({
52
+ version: 'v3',
53
+ name: 'public.records.rename',
54
+ args: {
55
+ id: {
56
+ fuid: input.fuid,
57
+ uuid: XIANTAO_HISTORY_UUID,
58
+ },
59
+ new_name: input.newName,
60
+ },
61
+ }),
62
+ headers: buildXiantaoHeaders(auth, true),
63
+ method: 'POST',
64
+ }, { agent: auth.agent });
65
+ }
66
+ export async function deleteXiantaoHistoryRecord(auth, input) {
67
+ await requestJson(XIANTAO_BIO_API_ENDPOINT, {
68
+ body: JSON.stringify({
69
+ version: 'v3',
70
+ name: 'public.records.delete',
71
+ args: {
72
+ id: {
73
+ fuid: input.fuid,
74
+ uuid: XIANTAO_HISTORY_UUID,
75
+ },
76
+ },
77
+ }),
78
+ headers: buildXiantaoHeaders(auth, true),
79
+ method: 'POST',
80
+ }, { agent: auth.agent });
81
+ }
82
+ export async function fetchXiantaoHistoryModify(auth, input) {
83
+ const response = await requestJson(XIANTAO_BIO_API_ENDPOINT, {
84
+ body: JSON.stringify({
85
+ version: 'v3',
86
+ name: 'public.records.modify-fetch',
87
+ args: {
88
+ id: {
89
+ fuid: input.fuid,
90
+ uuid: XIANTAO_HISTORY_UUID,
91
+ },
92
+ },
93
+ }),
94
+ headers: buildXiantaoHeaders(auth, true),
95
+ method: 'POST',
96
+ }, { agent: auth.agent });
97
+ return response.data;
98
+ }
99
+ export async function waitForLatestXiantaoHistoryRecord(auth, input) {
100
+ const deadline = Date.now() + input.timeoutMs;
101
+ while (Date.now() <= deadline) {
102
+ const page = await fetchXiantaoHistoryRecords(auth);
103
+ const record = findLatestHistoryRecord(page.records, input.moduleId, input.submittedAtMs);
104
+ if (record) {
105
+ return record;
106
+ }
107
+ await sleep(input.intervalMs);
108
+ }
109
+ return null;
110
+ }
111
+ export async function findXiantaoHistoryRecordByFuid(auth, fuid) {
112
+ const normalizedFuid = fuid.trim();
113
+ if (!normalizedFuid) {
114
+ return null;
115
+ }
116
+ let currentPage = 1;
117
+ let lastPage = 1;
118
+ do {
119
+ const page = await fetchXiantaoHistoryRecords(auth, { currentPage });
120
+ const record = page.records.find((item) => item.fuid?.trim() === normalizedFuid);
121
+ if (record) {
122
+ return record;
123
+ }
124
+ currentPage += 1;
125
+ lastPage = page.lastPage;
126
+ } while (currentPage <= lastPage);
127
+ return null;
128
+ }
129
+ export async function waitForXiantaoHistoryRecordCompletion(auth, input) {
130
+ const deadline = Date.now() + input.timeoutMs;
131
+ while (Date.now() <= deadline) {
132
+ const page = await fetchXiantaoHistoryRecords(auth);
133
+ const record = page.records.find((item) => item.fuid?.trim() === input.fuid);
134
+ if (!record) {
135
+ await sleep(input.intervalMs);
136
+ continue;
137
+ }
138
+ const statusText = getXiantaoHistoryRecordStatus(record);
139
+ if (isCompletedHistoryStatus(statusText)) {
140
+ return record;
141
+ }
142
+ if (isFailedHistoryStatus(statusText)) {
143
+ throw new XtzError(`历史记录任务执行失败:${statusText}`);
144
+ }
145
+ await sleep(input.intervalMs);
146
+ }
147
+ return null;
148
+ }
149
+ export function getXiantaoHistoryRecordStatus(record) {
150
+ return stripHtml(record.status ?? '').trim() || '未知';
151
+ }
152
+ function findLatestHistoryRecord(records, moduleId, submittedAtMs) {
153
+ const normalizedModuleId = moduleId.trim();
154
+ const threshold = submittedAtMs - 120_000;
155
+ const candidates = records
156
+ .filter((record) => record.module_id?.trim() === normalizedModuleId)
157
+ .sort((left, right) => parseHistoryTime(right.time) - parseHistoryTime(left.time));
158
+ return candidates.find((record) => parseHistoryTime(record.time) >= threshold);
159
+ }
160
+ function collectHistoryDownloadItems(items) {
161
+ return items.flatMap((item) => {
162
+ if (item.tag !== 'download'
163
+ || !item.device
164
+ || !item.endpoint
165
+ || !item.fuid
166
+ || !item.label) {
167
+ return [];
168
+ }
169
+ return [{
170
+ device: item.device,
171
+ endpoint: item.endpoint,
172
+ fuid: item.fuid,
173
+ index: item.index,
174
+ info: item.info,
175
+ label: item.label,
176
+ pos: item.pos,
177
+ sign: item.sign,
178
+ }];
179
+ });
180
+ }
181
+ function parseHistoryTime(value) {
182
+ const normalized = value?.trim();
183
+ if (!normalized) {
184
+ return 0;
185
+ }
186
+ const parsed = Date.parse(normalized.replace(' ', 'T'));
187
+ return Number.isNaN(parsed) ? 0 : parsed;
188
+ }
189
+ function stripHtml(value) {
190
+ return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
191
+ }
192
+ function isCompletedHistoryStatus(status) {
193
+ return status.includes('完成');
194
+ }
195
+ function isFailedHistoryStatus(status) {
196
+ return ['失败', '异常', '取消', '终止'].some((keyword) => status.includes(keyword));
197
+ }
198
+ async function sleep(ms) {
199
+ await new Promise((resolve) => setTimeout(resolve, ms));
200
+ }
package/dist/lib/http.js CHANGED
@@ -9,6 +9,9 @@ export async function requestJson(url, init = {}, options = {}) {
9
9
  }
10
10
  const body = await response.text();
11
11
  const payload = parseEnvelope(response, body);
12
+ if (options.allowErrorPayload?.(payload, response)) {
13
+ return payload;
14
+ }
12
15
  if (!response.ok || payload.success === false) {
13
16
  await raiseApiError(payload, options);
14
17
  }
@@ -3,6 +3,7 @@ import { stdin as input, stdout as output } from 'node:process';
3
3
  import { XtzError } from './errors.js';
4
4
  import { isJsonObject } from './json.js';
5
5
  import { formatSetOverrides } from './plot-options.js';
6
+ import { getBrowsableToolMenuChildren, isRunnableToolMenu } from './xiantao.js';
6
7
  export async function promptPlotArgsMain(schema, seed) {
7
8
  ensureInteractiveSession();
8
9
  const rl = readline.createInterface({ input, output });
@@ -109,7 +110,7 @@ export async function promptPlotMenuSelection(menus) {
109
110
  output.write(`\n[${currentTitle}]\n`);
110
111
  currentMenus.forEach((menu, index) => {
111
112
  const label = formatMenuLabel(menu);
112
- const suffix = isMenuDirectory(menu) ? ' >' : ` (${menu.code?.trim() ?? '-'})`;
113
+ const suffix = getBrowsableToolMenuChildren(menu).length > 0 ? ' >' : ` (${menu.code?.trim() ?? '-'})`;
113
114
  output.write(` ${index + 1}. ${label}${suffix}\n`);
114
115
  });
115
116
  const hint = stack.length > 0 ? '输入编号,< 返回上一级,回车取消: ' : '输入编号,回车取消: ';
@@ -133,16 +134,16 @@ export async function promptPlotMenuSelection(menus) {
133
134
  throw new XtzError(`无效选项: ${answer}`);
134
135
  }
135
136
  const selected = currentMenus[index - 1];
136
- const children = (selected.children ?? []).filter((menu) => hasMenuLabel(menu));
137
- if (children.length > 0 || isMenuDirectory(selected)) {
138
- if (children.length === 0) {
139
- throw new XtzError(`${formatMenuLabel(selected)} 下没有可选工具。`);
140
- }
137
+ const children = getBrowsableToolMenuChildren(selected);
138
+ if (children.length > 0) {
141
139
  stack.push({ menus: currentMenus, title: currentTitle });
142
140
  currentMenus = children;
143
141
  currentTitle = formatMenuLabel(selected);
144
142
  continue;
145
143
  }
144
+ if (!isRunnableToolMenu(selected)) {
145
+ throw new XtzError(`${formatMenuLabel(selected)} 缺少可执行信息,请改用显式 module_id/uuid。`);
146
+ }
146
147
  return resolveInteractiveToolMenu(selected);
147
148
  }
148
149
  catch (error) {
@@ -562,9 +563,6 @@ function hasMenuLabel(menu) {
562
563
  function formatMenuLabel(menu) {
563
564
  return menu.title?.trim() || menu.code?.trim() || '未命名';
564
565
  }
565
- function isMenuDirectory(menu) {
566
- return menu.is_directory === true;
567
- }
568
566
  function resolveInteractiveToolMenu(menu) {
569
567
  const code = menu.code?.trim();
570
568
  const route = menu.route?.trim();
@@ -68,19 +68,34 @@ export async function submitXiantaoPlot(auth, input) {
68
68
  }),
69
69
  headers: buildXiantaoHeaders(auth, true),
70
70
  method: 'POST',
71
- }, { agent: auth.agent });
71
+ }, {
72
+ agent: auth.agent,
73
+ allowErrorPayload: (payload) => isAsyncSubmissionMessage(payload.message),
74
+ });
72
75
  const downloads = collectDownloadItems(response.data.data);
73
76
  const file = typeof response.data.file === 'string' && response.data.file.trim()
74
77
  ? response.data.file.trim()
75
78
  : undefined;
76
79
  const body = response.data.body ?? '';
80
+ const message = response.message ?? '';
77
81
  if (!file && !body && downloads.length === 0) {
78
- throw new XtzError(response.message ?? '未获取到可用结果');
82
+ if (isAsyncSubmissionMessage(message)) {
83
+ return {
84
+ asyncSubmission: true,
85
+ body,
86
+ downloads,
87
+ file,
88
+ message,
89
+ };
90
+ }
91
+ throw new XtzError(message || '未获取到可用结果');
79
92
  }
80
93
  return {
94
+ asyncSubmission: false,
81
95
  body,
82
96
  downloads,
83
97
  file,
98
+ message,
84
99
  };
85
100
  }
86
101
  export async function resolveXiantaoDownload(auth, input) {
@@ -148,3 +163,6 @@ function getXiantaoFileOss(upload) {
148
163
  function getXiantaoFileSize(upload) {
149
164
  return upload.fileSizeForXiantao ?? upload.fileSize;
150
165
  }
166
+ function isAsyncSubmissionMessage(message) {
167
+ return Boolean(message?.includes('任务提交成功'));
168
+ }
@@ -79,11 +79,17 @@ export async function fetchAllPlotArgs(auth, input) {
79
79
  }, { agent: auth.agent });
80
80
  return response.data;
81
81
  }
82
+ export function getBrowsableToolMenuChildren(menu) {
83
+ return (menu.children ?? []).filter((item) => Boolean(item.title?.trim() || item.code?.trim()));
84
+ }
85
+ export function isRunnableToolMenu(menu) {
86
+ return Boolean(menu.code?.trim() && menu.route?.trim() && menu.title?.trim() && menu.uuid?.trim());
87
+ }
82
88
  function collectToolMenuMatches(menus, code) {
83
89
  const matches = [];
84
90
  for (const menu of menus) {
85
91
  const menuCode = menu.code?.trim();
86
- if ((!code || menuCode === code) && !menu.is_directory) {
92
+ if ((!code || menuCode === code) && isRunnableToolMenu(menu)) {
87
93
  const route = menu.route?.trim();
88
94
  const title = menu.title?.trim();
89
95
  const uuid = menu.uuid?.trim();
@@ -97,8 +103,9 @@ function collectToolMenuMatches(menus, code) {
97
103
  });
98
104
  }
99
105
  }
100
- if (menu.children?.length) {
101
- matches.push(...collectToolMenuMatches(menu.children, code));
106
+ const children = getBrowsableToolMenuChildren(menu);
107
+ if (children.length > 0) {
108
+ matches.push(...collectToolMenuMatches(children, code));
102
109
  }
103
110
  }
104
111
  return matches;
@@ -109,8 +116,9 @@ function findToolMenu(menus, target) {
109
116
  if (menu.uuid?.trim() === normalizedTarget || String(menu.id ?? '').trim() === normalizedTarget) {
110
117
  return menu;
111
118
  }
112
- if (menu.children?.length) {
113
- const nested = findToolMenu(menu.children, normalizedTarget);
119
+ const children = getBrowsableToolMenuChildren(menu);
120
+ if (children.length > 0) {
121
+ const nested = findToolMenu(children, normalizedTarget);
114
122
  if (nested) {
115
123
  return nested;
116
124
  }
package/dist/xtz-help.js CHANGED
@@ -22,6 +22,22 @@ export default class XtzHelp extends Help {
22
22
  description: 'Delete the stored token for the current profile',
23
23
  id: 'logout',
24
24
  },
25
+ {
26
+ description: 'List history records for the current profile',
27
+ id: 'history:list',
28
+ },
29
+ {
30
+ description: 'Download a file from a history record',
31
+ id: 'history:download',
32
+ },
33
+ {
34
+ description: 'Rename a history record',
35
+ id: 'history:rename',
36
+ },
37
+ {
38
+ description: 'Delete a history record',
39
+ id: 'history:delete',
40
+ },
25
41
  {
26
42
  description: 'Alias of xt login under the tool topic',
27
43
  id: 'tool:login',
@@ -54,6 +70,7 @@ export default class XtzHelp extends Help {
54
70
  this.log('');
55
71
  this.log(this.section('LOW-LEVEL AND COMPATIBILITY', [
56
72
  ['xt tool --help', 'Show the full tool command set'],
73
+ ['xt history --help', 'Show the history command set'],
57
74
  ['xt tool resolve', 'Resolve a tool id to UUID and route'],
58
75
  ['xt tool menu', 'Fetch one raw tool menu by UUID'],
59
76
  ['xt tool menus', 'Fetch all raw tool menus for a product'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helixlife-ai/xiantao",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "CLI for Xiantao bioinformatics tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -49,6 +49,9 @@
49
49
  "tool": {
50
50
  "description": "Xiantao bioinformatics tool commands"
51
51
  },
52
+ "history": {
53
+ "description": "Xiantao history record commands"
54
+ },
52
55
  "plot": {
53
56
  "description": "Compatibility commands for the legacy plot topic"
54
57
  }