@chatu-ai/builder-sdk 0.9.6 → 0.10.0

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/dist/client.d.ts CHANGED
@@ -269,6 +269,22 @@ export type ExecStreamEvent = {
269
269
  type: 'result';
270
270
  result: ExecResult;
271
271
  };
272
+ /** 一次生成(run,技术方案 22 Phase 2):创建即返回,事件另行订阅 */
273
+ export interface RunMeta {
274
+ ok: boolean;
275
+ runId: string;
276
+ /** pending(准备沙箱中)| running | finished | failed */
277
+ state: string;
278
+ xid?: string | null;
279
+ error?: string | null;
280
+ createdAt?: string;
281
+ }
282
+ /** run 事件帧:id 用于断线续传(Last-Event-ID 语义),data 为原始载荷(与 v1 完全一致) */
283
+ export interface RunEventFrame {
284
+ id: string;
285
+ event: string | null;
286
+ data: unknown;
287
+ }
272
288
  export interface DeployInput {
273
289
  provider: 'edgeone';
274
290
  projectName: string;
@@ -435,6 +451,46 @@ export interface BuilderClient {
435
451
  signal?: AbortSignal;
436
452
  }): AsyncIterable<FunctionDeployStreamEvent>;
437
453
  };
454
+ /**
455
+ * v2 生成接口(技术方案 22 Phase 2):创建 run 立即返回,不等沙箱冷启动;
456
+ * 事件用 `runs.events()` 订阅,断线自动从上次 id 续传。v1 的 create/connect/send 仍可用。
457
+ */
458
+ runs: {
459
+ /** 受理一次生成,立即返回 runId(沙箱准备过程会作为 run.phase 事件下发) */
460
+ start(input: {
461
+ conversationId: string;
462
+ prompt?: string;
463
+ message?: string;
464
+ } & Record<string, unknown>): Promise<RunMeta>;
465
+ /** run 状态(轮询兜底/调试) */
466
+ status(runId: string): Promise<RunMeta>;
467
+ /** 订阅事件;断线自动带上最后一帧的 id 重连(默认重试 5 次,退避 1s→8s) */
468
+ events(runId: string, opts?: {
469
+ after?: string | null;
470
+ signal?: AbortSignal;
471
+ maxRetries?: number;
472
+ retryBaseMs?: number;
473
+ }): AsyncIterable<RunEventFrame>;
474
+ /** 向进行中的 run 追加消息 */
475
+ send(runId: string, input: Record<string, unknown>): Promise<{
476
+ ok: boolean;
477
+ statusCode?: number;
478
+ }>;
479
+ /** 取消 run */
480
+ cancel(runId: string): Promise<{
481
+ ok: boolean;
482
+ cancelled?: boolean;
483
+ }>;
484
+ };
485
+ /**
486
+ * 生成事件里的单条消息全文(技术方案 22 S2):平台把超大的工具结果改为"预览 + 引用"下发,
487
+ * 前端展开时用 `chatuRef`(`{xid}/{seq}`)回取完整内容。
488
+ */
489
+ runMessage(conversationId: string, xid: string, seq: number): Promise<{
490
+ ok: boolean;
491
+ error?: string;
492
+ message?: Record<string, unknown>;
493
+ }>;
438
494
  /** 沙箱终端(001/09 P2-A):执行一条命令并流式回输出;AI 生成中会被拒绝(409) */
439
495
  exec(conversationId: string, command: string, opts?: {
440
496
  timeoutMs?: number;
package/dist/client.js CHANGED
@@ -105,6 +105,16 @@ export function createBuilderClient(options) {
105
105
  deployFunctionStream: (id, input, o) => readNamedSse(`${restBase}/${id}/export/deploy-function/stream`, auth.apply({ method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify(input), signal: o?.signal }), doFetch),
106
106
  deployStream: (id, input, o) => readNamedSse(`${restBase}/${id}/export/deploy/stream`, auth.apply({ method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify(input), signal: o?.signal }), doFetch),
107
107
  },
108
+ runs: {
109
+ start: input => req(`/runs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
110
+ status: runId => req(`/runs/${encPath(runId)}`),
111
+ send: (runId, input) => req(`/runs/${encPath(runId)}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
112
+ cancel: runId => req(`/runs/${encPath(runId)}/cancel`, { method: 'POST' }),
113
+ events: (runId, opts) => ({
114
+ [Symbol.asyncIterator]: () => readRunEvents(`${restBase}/runs/${encPath(runId)}/events`, auth, doFetch, opts),
115
+ }),
116
+ },
117
+ runMessage: (id, xid, seq) => req(`/${id}/runs/${encPath(xid)}/messages/${seq}`),
108
118
  exec: (id, command, o) => readNamedSse(`${restBase}/${id}/exec/stream`, auth.apply({ method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ command, timeoutMs: o?.timeoutMs }), signal: o?.signal }), doFetch),
109
119
  data: {
110
120
  access: id => req(`/${id}/data-access`),
@@ -180,6 +190,93 @@ export class BuilderApiError extends Error {
180
190
  }
181
191
  }
182
192
  /** 读取带 event: 名的 SSE(log / result) */
193
+ /**
194
+ * 订阅 run 事件:逐帧产出 { id, event, data },断线后带 `after={最后一帧 id}` 自动重连。
195
+ * 这是 v2 相对 v1 的关键差别——v1 断线要靠调用方自己记 checkpoint 再发一次 connect。
196
+ */
197
+ async function* readRunEvents(url, auth, doFetch, opts) {
198
+ let cursor = opts?.after ?? null;
199
+ const maxRetries = opts?.maxRetries ?? 5;
200
+ const retryBaseMs = opts?.retryBaseMs ?? 1000;
201
+ // 连续"没拿到任何新帧"的重连次数:有进展就清零,这样长任务可以无限续订,
202
+ // 而"连上就断且没数据"的坏情况不会变成死循环
203
+ let attempt = 0;
204
+ for (;;) {
205
+ const target = cursor ? `${url}?after=${encodeURIComponent(cursor)}` : url;
206
+ let res;
207
+ try {
208
+ res = await doFetch(target, auth.apply({ headers: { accept: 'text/event-stream' }, signal: opts?.signal }));
209
+ }
210
+ catch (err) {
211
+ if (opts?.signal?.aborted || attempt >= maxRetries)
212
+ throw err;
213
+ await new Promise(r => setTimeout(r, Math.min(8000, retryBaseMs * 2 ** attempt++)));
214
+ continue;
215
+ }
216
+ if (!res.ok || !res.body)
217
+ throw new BuilderApiError(res.status, await res.text().catch(() => ''));
218
+ const reader = res.body.getReader();
219
+ const decoder = new TextDecoder();
220
+ let buffer = '';
221
+ let id = null;
222
+ let eventName = null;
223
+ let ended = false;
224
+ let delivered = 0;
225
+ try {
226
+ for (;;) {
227
+ const { value, done } = await reader.read();
228
+ if (done)
229
+ break;
230
+ buffer += decoder.decode(value, { stream: true });
231
+ let nl;
232
+ while ((nl = buffer.indexOf('\n')) >= 0) {
233
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
234
+ buffer = buffer.slice(nl + 1);
235
+ if (line.startsWith(':'))
236
+ continue; // 心跳
237
+ if (line.startsWith('id:')) {
238
+ id = line.slice(3).trim();
239
+ continue;
240
+ }
241
+ if (line.startsWith('event:')) {
242
+ eventName = line.slice(6).trim();
243
+ continue;
244
+ }
245
+ if (!line.startsWith('data:'))
246
+ continue;
247
+ const raw = line.slice(5).trim();
248
+ if (!raw)
249
+ continue;
250
+ let data = raw;
251
+ try {
252
+ data = JSON.parse(raw);
253
+ }
254
+ catch { /* 原样 */ }
255
+ if (id)
256
+ cursor = id;
257
+ delivered++;
258
+ yield { id: id ?? '', event: eventName, data };
259
+ if (eventName === 'done')
260
+ ended = true;
261
+ id = null;
262
+ eventName = null;
263
+ }
264
+ if (ended)
265
+ break;
266
+ }
267
+ }
268
+ finally {
269
+ reader.releaseLock();
270
+ }
271
+ if (ended || opts?.signal?.aborted)
272
+ return;
273
+ // 服务端把连接断了但 run 还没结束(部署/重启/网关超时):带着 cursor 续订
274
+ attempt = delivered > 0 ? 0 : attempt + 1;
275
+ if (attempt > maxRetries)
276
+ return;
277
+ await new Promise(r => setTimeout(r, Math.min(8000, retryBaseMs * 2 ** Math.max(0, attempt - 1))));
278
+ }
279
+ }
183
280
  async function* readNamedSse(url, init, doFetch) {
184
281
  const res = await doFetch(url, init);
185
282
  if (!res.ok || !res.body)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createBuilderClient, CookieAuth } from './index';
3
+ /** 把若干行组成一个 SSE 响应体 */
4
+ const sse = (body) => new Response(new ReadableStream({
5
+ start(controller) {
6
+ controller.enqueue(new TextEncoder().encode(body));
7
+ controller.close();
8
+ },
9
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } });
10
+ const frame = (id, data, event) => `${event ? `event:${event}\n` : ''}id:${id}\ndata:${JSON.stringify(data)}\n\n`;
11
+ function clientWith(handler) {
12
+ const urls = [];
13
+ const client = createBuilderClient({
14
+ restBase: 'https://api.test/web/Builder',
15
+ auth: new CookieAuth(),
16
+ transport: { stream: async function* () { }, resubscribe: async function* () { }, cancel: async () => { } },
17
+ fetchImpl: (async (url) => {
18
+ urls.push(String(url));
19
+ return handler(String(url));
20
+ }),
21
+ });
22
+ return { client, urls };
23
+ }
24
+ describe('runs.events(v2 事件订阅,技术方案 22 Phase 2)', () => {
25
+ it('逐帧产出 id/event/data,遇到 done 结束', async () => {
26
+ const { client } = clientWith(() => sse(frame('1-0', { type: 'run.phase', phase: 'sandbox' }) +
27
+ frame('2-0', { type: 'created-response', xid: 'x1' }) +
28
+ frame('3-0', { type: 'run.finished' }, 'done')));
29
+ const got = [];
30
+ for await (const f of client.runs.events('run-1'))
31
+ got.push(f);
32
+ expect(got.map(f => f.id)).toEqual(['1-0', '2-0', '3-0']);
33
+ expect(got[2].event).toBe('done');
34
+ expect(got[1].data.xid).toBe('x1');
35
+ });
36
+ it('中途断流会带着最后一帧的 id 续订(不重复投递已收到的帧)', async () => {
37
+ let call = 0;
38
+ const { client, urls } = clientWith(() => {
39
+ call += 1;
40
+ // 第一次:只吐两帧就结束(模拟 Web 重启/网关超时),没有 done
41
+ if (call === 1)
42
+ return sse(frame('1-0', { i: 1 }) + frame('2-0', { i: 2 }));
43
+ return sse(frame('3-0', { i: 3 }) + frame('4-0', { type: 'run.finished' }, 'done'));
44
+ });
45
+ const got = [];
46
+ for await (const f of client.runs.events('run-1', { maxRetries: 2, retryBaseMs: 1 }))
47
+ got.push(f);
48
+ expect(got.map(f => f.data.i ?? 'done')).toEqual([1, 2, 3, 'done']);
49
+ expect(urls[0]).not.toContain('after=');
50
+ expect(urls[1]).toContain('after=2-0');
51
+ });
52
+ it('可以从指定 id 开始订阅(页面刷新后接着看)', async () => {
53
+ const { client, urls } = clientWith(() => sse(frame('9-0', { i: 9 }, 'done')));
54
+ for await (const _ of client.runs.events('run-1', { after: '8-0' })) { /* drain */ }
55
+ expect(urls[0]).toContain('after=8-0');
56
+ });
57
+ it('心跳注释行被忽略', async () => {
58
+ const { client } = clientWith(() => sse(': ping\n\n' + frame('1-0', { i: 1 }, 'done')));
59
+ const got = [];
60
+ for await (const f of client.runs.events('run-1'))
61
+ got.push(f);
62
+ expect(got).toHaveLength(1);
63
+ });
64
+ it('一直有新帧就一直续订(长任务不该被重试次数掐断)', async () => {
65
+ let call = 0;
66
+ const { client } = clientWith(() => {
67
+ call += 1;
68
+ // 每次连上都给一帧新数据然后断开;第 4 次给 done
69
+ return call >= 4
70
+ ? sse(frame(`${call}-0`, { type: 'run.finished' }, 'done'))
71
+ : sse(frame(`${call}-0`, { i: call }));
72
+ });
73
+ const got = [];
74
+ for await (const f of client.runs.events('run-1', { maxRetries: 1, retryBaseMs: 1 }))
75
+ got.push(f);
76
+ expect(got).toHaveLength(4);
77
+ });
78
+ it('连上就断且没有任何数据时,重试用尽即结束(不死循环)', async () => {
79
+ const { client, urls } = clientWith(() => sse(''));
80
+ const got = [];
81
+ for await (const f of client.runs.events('run-1', { maxRetries: 2, retryBaseMs: 1 }))
82
+ got.push(f);
83
+ expect(got).toHaveLength(0);
84
+ expect(urls.length).toBe(3); // 首次 + 2 次重试
85
+ });
86
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatu-ai/builder-sdk",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "description": "ChatU Builder client SDK core: REST + streaming client, zod event schemas, seq resume",
5
5
  "license": "MIT",
6
6
  "type": "module",