@chatu-ai/builder-sdk-vue 0.1.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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/reducer.d.ts +46 -0
- package/dist/reducer.js +69 -0
- package/dist/useBuilderChat.d.ts +47 -0
- package/dist/useBuilderChat.js +78 -0
- package/dist/usePreviewUrl.d.ts +13 -0
- package/dist/usePreviewUrl.js +35 -0
- package/dist/useSandboxStatus.d.ts +40 -0
- package/dist/useSandboxStatus.js +67 -0
- package/dist/vue.test.d.ts +1 -0
- package/dist/vue.test.js +79 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chatu-ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @chatu-ai/builder-sdk-vue
|
|
2
|
+
|
|
3
|
+
Vue 3 composables for [`@chatu-ai/builder-sdk`](https://www.npmjs.com/package/@chatu-ai/builder-sdk).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { useSandboxStatus, usePreviewUrl, useBuilderChat } from '@chatu-ai/builder-sdk-vue'
|
|
7
|
+
|
|
8
|
+
const { state, refresh } = useSandboxStatus(client, conversationId) // adaptive polling + heartbeat
|
|
9
|
+
const { url } = usePreviewUrl(client, conversationId) // one-time preview token → iframe src
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Peer dependency: `vue ^3.4`. MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { useBuilderChat } from './useBuilderChat';
|
|
2
|
+
export { useSandboxStatus, type SandboxStatusOptions } from './useSandboxStatus';
|
|
3
|
+
export { usePreviewUrl } from './usePreviewUrl';
|
|
4
|
+
export { createInitialState, reduceEvent, type BuilderUiState, type RoundUi, type TaskCardUi, } from './reducer';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 → UI 状态归约(08 §3 规则的纯函数实现,框架无关、可独立测试)
|
|
3
|
+
* vue 层只做响应式包装。
|
|
4
|
+
*/
|
|
5
|
+
import type { BuilderEvent, SandboxState } from '@chatu-ai/builder-sdk';
|
|
6
|
+
export interface TaskCardUi {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
state: 'running' | 'done' | 'failed';
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RoundUi {
|
|
13
|
+
xid: string;
|
|
14
|
+
/** 本轮用户输入(由 useBuilderChat.send 写入,事件流本身不含) */
|
|
15
|
+
userText?: string;
|
|
16
|
+
text: string;
|
|
17
|
+
taskCards: TaskCardUi[];
|
|
18
|
+
version?: {
|
|
19
|
+
sha: string;
|
|
20
|
+
message: string;
|
|
21
|
+
filesChanged: number;
|
|
22
|
+
};
|
|
23
|
+
done?: {
|
|
24
|
+
state: 'completed' | 'failed' | 'canceled';
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface BuilderUiState {
|
|
29
|
+
agentState: 'idle' | 'streaming' | 'error';
|
|
30
|
+
rounds: RoundUi[];
|
|
31
|
+
sandbox: {
|
|
32
|
+
state: SandboxState | 'unknown';
|
|
33
|
+
previewUrl?: string;
|
|
34
|
+
lastError?: string | null;
|
|
35
|
+
};
|
|
36
|
+
changedPaths: string[];
|
|
37
|
+
versions: {
|
|
38
|
+
sha: string;
|
|
39
|
+
message: string;
|
|
40
|
+
filesChanged: number;
|
|
41
|
+
}[];
|
|
42
|
+
lastSeq: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function createInitialState(): BuilderUiState;
|
|
45
|
+
/** 就地归约(调用方负责传入响应式对象;seq 去重已由 core 保证) */
|
|
46
|
+
export declare function reduceEvent(state: BuilderUiState, ev: BuilderEvent): void;
|
package/dist/reducer.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function createInitialState() {
|
|
2
|
+
return {
|
|
3
|
+
agentState: 'idle',
|
|
4
|
+
rounds: [],
|
|
5
|
+
sandbox: { state: 'unknown' },
|
|
6
|
+
changedPaths: [],
|
|
7
|
+
versions: [],
|
|
8
|
+
lastSeq: 0,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function roundFor(state, xid) {
|
|
12
|
+
let round = state.rounds.find(r => r.xid === xid);
|
|
13
|
+
if (!round) {
|
|
14
|
+
round = { xid, text: '', taskCards: [] };
|
|
15
|
+
state.rounds.push(round);
|
|
16
|
+
}
|
|
17
|
+
return round;
|
|
18
|
+
}
|
|
19
|
+
/** 就地归约(调用方负责传入响应式对象;seq 去重已由 core 保证) */
|
|
20
|
+
export function reduceEvent(state, ev) {
|
|
21
|
+
if (ev.kind !== 'ack')
|
|
22
|
+
state.lastSeq = ev.seq;
|
|
23
|
+
switch (ev.kind) {
|
|
24
|
+
case 'ack':
|
|
25
|
+
state.agentState = 'streaming';
|
|
26
|
+
state.sandbox = { state: ev.sandbox.state, previewUrl: ev.sandbox.previewUrl };
|
|
27
|
+
roundFor(state, ev.xid);
|
|
28
|
+
break;
|
|
29
|
+
case 'message':
|
|
30
|
+
roundFor(state, ev.xid).text += ev.text;
|
|
31
|
+
break;
|
|
32
|
+
case 'taskCard': {
|
|
33
|
+
const cards = roundFor(state, ev.xid).taskCards;
|
|
34
|
+
const existing = cards.find(c => c.id === ev.id);
|
|
35
|
+
if (existing) {
|
|
36
|
+
existing.state = ev.state;
|
|
37
|
+
if (ev.label)
|
|
38
|
+
existing.label = ev.label; // 空 label = 仅状态更新(agent3 tool_result)
|
|
39
|
+
if (ev.detail !== undefined)
|
|
40
|
+
existing.detail = ev.detail;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
cards.push({ id: ev.id, label: ev.label, state: ev.state, detail: ev.detail });
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'fileDiff':
|
|
48
|
+
if (!state.changedPaths.includes(ev.path))
|
|
49
|
+
state.changedPaths.push(ev.path);
|
|
50
|
+
break;
|
|
51
|
+
case 'preview':
|
|
52
|
+
state.sandbox = {
|
|
53
|
+
state: ev.state === 'ready' ? 'ready' : state.sandbox.state,
|
|
54
|
+
previewUrl: ev.url ?? state.sandbox.previewUrl,
|
|
55
|
+
lastError: ev.state === 'crashed' ? (ev.error ?? 'dev server crashed') : null,
|
|
56
|
+
};
|
|
57
|
+
break;
|
|
58
|
+
case 'version': {
|
|
59
|
+
const version = { sha: ev.sha, message: ev.message, filesChanged: ev.filesChanged };
|
|
60
|
+
roundFor(state, ev.xid).version = version;
|
|
61
|
+
state.versions.unshift(version);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'done':
|
|
65
|
+
roundFor(state, ev.xid).done = { state: ev.state, error: ev.error };
|
|
66
|
+
state.agentState = ev.state === 'completed' ? 'idle' : 'error';
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BuilderClient } from '@chatu-ai/builder-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Builder 会话组合式 API(08 §1/§3)
|
|
4
|
+
* 流式消费 client.chat.stream(core 已保证有序无重与断线重连),就地归约到响应式状态。
|
|
5
|
+
*/
|
|
6
|
+
export declare function useBuilderChat(client: BuilderClient, conversationId: string): {
|
|
7
|
+
state: {
|
|
8
|
+
readonly agentState: "idle" | "streaming" | "error";
|
|
9
|
+
readonly rounds: readonly {
|
|
10
|
+
readonly xid: string;
|
|
11
|
+
readonly userText?: string | undefined;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly taskCards: readonly {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly state: "running" | "done" | "failed";
|
|
17
|
+
readonly detail?: string | undefined;
|
|
18
|
+
}[];
|
|
19
|
+
readonly version?: {
|
|
20
|
+
readonly sha: string;
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly filesChanged: number;
|
|
23
|
+
} | undefined;
|
|
24
|
+
readonly done?: {
|
|
25
|
+
readonly state: "completed" | "failed" | "canceled";
|
|
26
|
+
readonly error?: string | undefined;
|
|
27
|
+
} | undefined;
|
|
28
|
+
}[];
|
|
29
|
+
readonly sandbox: {
|
|
30
|
+
readonly state: import("@chatu-ai/builder-sdk").SandboxState | "unknown";
|
|
31
|
+
readonly previewUrl?: string | undefined;
|
|
32
|
+
readonly lastError?: string | null | undefined;
|
|
33
|
+
};
|
|
34
|
+
readonly changedPaths: readonly string[];
|
|
35
|
+
readonly versions: readonly {
|
|
36
|
+
readonly sha: string;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
readonly filesChanged: number;
|
|
39
|
+
}[];
|
|
40
|
+
readonly lastSeq: number;
|
|
41
|
+
};
|
|
42
|
+
streamError: Readonly<import("vue").Ref<string | null, string | null>>;
|
|
43
|
+
isStreaming: import("vue").ComputedRef<boolean>;
|
|
44
|
+
send: (prompt: string) => Promise<void>;
|
|
45
|
+
cancel: () => Promise<void>;
|
|
46
|
+
refreshVersions: () => Promise<void>;
|
|
47
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { computed, reactive, readonly, ref } from 'vue';
|
|
2
|
+
import { createInitialState, reduceEvent } from './reducer';
|
|
3
|
+
/**
|
|
4
|
+
* Builder 会话组合式 API(08 §1/§3)
|
|
5
|
+
* 流式消费 client.chat.stream(core 已保证有序无重与断线重连),就地归约到响应式状态。
|
|
6
|
+
*/
|
|
7
|
+
export function useBuilderChat(client, conversationId) {
|
|
8
|
+
const state = reactive(createInitialState());
|
|
9
|
+
const streamError = ref(null);
|
|
10
|
+
let activeXid = null;
|
|
11
|
+
async function send(prompt) {
|
|
12
|
+
if (state.agentState === 'streaming') {
|
|
13
|
+
throw new Error('BUSY: a generation is already in progress (R11)');
|
|
14
|
+
}
|
|
15
|
+
state.agentState = 'streaming';
|
|
16
|
+
streamError.value = null;
|
|
17
|
+
try {
|
|
18
|
+
let pending = true;
|
|
19
|
+
for await (const ev of client.chat.stream(conversationId, prompt)) {
|
|
20
|
+
if (ev.kind === 'ack')
|
|
21
|
+
activeXid = ev.xid;
|
|
22
|
+
reduceEvent(state, ev);
|
|
23
|
+
// 首个带真实 xid 的事件到达后,把用户输入挂到本轮
|
|
24
|
+
if (pending && ev.xid && ev.xid !== 'pending') {
|
|
25
|
+
const round = state.rounds.find(r => r.xid === ev.xid);
|
|
26
|
+
if (round) {
|
|
27
|
+
round.userText = prompt;
|
|
28
|
+
pending = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
state.agentState = 'error';
|
|
35
|
+
streamError.value = String(err);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
activeXid = null;
|
|
39
|
+
void refreshVersions();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 用 REST 刷新版本列表(每轮收尾服务端才 commit,与 done 事件有毫秒级先后,故延迟重试一次)
|
|
44
|
+
*/
|
|
45
|
+
async function refreshVersions() {
|
|
46
|
+
const load = async () => {
|
|
47
|
+
const list = await client.versions.list(conversationId);
|
|
48
|
+
state.versions.splice(0, state.versions.length, ...list.map(v => ({ sha: v.sha, message: v.message, filesChanged: v.filesChanged })));
|
|
49
|
+
return list.length;
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const before = await load();
|
|
53
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
54
|
+
const after = await load();
|
|
55
|
+
if (after === before) {
|
|
56
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
57
|
+
await load();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// 版本列表非关键路径,静默
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function cancel() {
|
|
65
|
+
if (activeXid)
|
|
66
|
+
await client.chat.cancel(conversationId, activeXid);
|
|
67
|
+
}
|
|
68
|
+
// 初次挂载:加载已有版本(重新打开会话时)
|
|
69
|
+
void refreshVersions();
|
|
70
|
+
return {
|
|
71
|
+
state: readonly(state),
|
|
72
|
+
streamError: readonly(streamError),
|
|
73
|
+
isStreaming: computed(() => state.agentState === 'streaming'),
|
|
74
|
+
send,
|
|
75
|
+
cancel,
|
|
76
|
+
refreshVersions,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Ref } from 'vue';
|
|
2
|
+
import type { BuilderClient } from '@chatu-ai/builder-sdk';
|
|
3
|
+
/**
|
|
4
|
+
* 预览地址(带一次性 token 的 iframe src,06 §6.1):
|
|
5
|
+
* - 沙箱 previewUrl 出现/变化时取一次 token
|
|
6
|
+
* - 授权失效(预览域授权页 postMessage)时调用 refresh() 重取
|
|
7
|
+
* token 一次性:每次 refresh 都签发新 token,旧 iframe 已兑换为 cookie 不受影响
|
|
8
|
+
*/
|
|
9
|
+
export declare function usePreviewUrl(client: BuilderClient, conversationId: string, rawPreviewUrl: Ref<string | undefined>): {
|
|
10
|
+
src: Ref<string | undefined, string | undefined>;
|
|
11
|
+
error: Ref<string | null, string | null>;
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ref, watch } from 'vue';
|
|
2
|
+
/**
|
|
3
|
+
* 预览地址(带一次性 token 的 iframe src,06 §6.1):
|
|
4
|
+
* - 沙箱 previewUrl 出现/变化时取一次 token
|
|
5
|
+
* - 授权失效(预览域授权页 postMessage)时调用 refresh() 重取
|
|
6
|
+
* token 一次性:每次 refresh 都签发新 token,旧 iframe 已兑换为 cookie 不受影响
|
|
7
|
+
*/
|
|
8
|
+
export function usePreviewUrl(client, conversationId, rawPreviewUrl) {
|
|
9
|
+
const src = ref(undefined);
|
|
10
|
+
const error = ref(null);
|
|
11
|
+
let seq = 0;
|
|
12
|
+
async function refresh() {
|
|
13
|
+
if (!rawPreviewUrl.value) {
|
|
14
|
+
src.value = undefined;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const my = ++seq;
|
|
18
|
+
try {
|
|
19
|
+
const { previewUrl } = await client.sandbox.previewToken(conversationId);
|
|
20
|
+
if (my === seq) {
|
|
21
|
+
src.value = previewUrl || rawPreviewUrl.value;
|
|
22
|
+
error.value = previewUrl ? null : 'preview-token 响应缺少 previewUrl';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (my === seq) {
|
|
27
|
+
// 取不到 token 时回退原始地址:iframe 会显示授权页并 postMessage → 触发再次 refresh
|
|
28
|
+
error.value = String(err);
|
|
29
|
+
src.value = rawPreviewUrl.value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
watch(rawPreviewUrl, () => void refresh(), { immediate: true });
|
|
34
|
+
return { src, error, refresh };
|
|
35
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { BuilderClient } from '@chatu-ai/builder-sdk';
|
|
2
|
+
export interface SandboxStatusOptions {
|
|
3
|
+
/** 过渡态(创建/预热/恢复/快照/未知)轮询间隔,默认 3000ms */
|
|
4
|
+
pollMs?: number;
|
|
5
|
+
/** 稳定态(ready/busy)轮询间隔,默认 60000ms——此时状态主要由 SSE 事件与心跳响应维护 */
|
|
6
|
+
idlePollMs?: number;
|
|
7
|
+
/** 休眠态轮询间隔,默认 15000ms(等待唤醒) */
|
|
8
|
+
hibernatedPollMs?: number;
|
|
9
|
+
/** 心跳间隔(08 §4:页面可见才发),默认 30000ms */
|
|
10
|
+
heartbeatMs?: number;
|
|
11
|
+
/** 可见性探针(默认读 document.visibilityState;测试可注入) */
|
|
12
|
+
isVisible?: () => boolean;
|
|
13
|
+
setInterval?: typeof globalThis.setInterval;
|
|
14
|
+
clearInterval?: typeof globalThis.clearInterval;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 沙箱状态 + 心跳组合式 API(08 §4)
|
|
18
|
+
* - 自适应轮询:过渡态密集、稳定态稀疏、休眠态中等;页面隐藏时不轮询、不心跳
|
|
19
|
+
* - 心跳响应携带 state,稳定态下以此为主要状态来源,避免高频打 status
|
|
20
|
+
*/
|
|
21
|
+
export declare function useSandboxStatus(client: BuilderClient, conversationId: string, opts?: SandboxStatusOptions): {
|
|
22
|
+
status: Readonly<import("vue").Ref<{
|
|
23
|
+
readonly state: import("@chatu-ai/builder-sdk").SandboxState;
|
|
24
|
+
readonly previewUrl?: string | undefined;
|
|
25
|
+
readonly devServer?: {
|
|
26
|
+
readonly running: boolean;
|
|
27
|
+
readonly lastError?: string | undefined;
|
|
28
|
+
} | undefined;
|
|
29
|
+
} | null, {
|
|
30
|
+
readonly state: import("@chatu-ai/builder-sdk").SandboxState;
|
|
31
|
+
readonly previewUrl?: string | undefined;
|
|
32
|
+
readonly devServer?: {
|
|
33
|
+
readonly running: boolean;
|
|
34
|
+
readonly lastError?: string | undefined;
|
|
35
|
+
} | undefined;
|
|
36
|
+
} | null>>;
|
|
37
|
+
error: Readonly<import("vue").Ref<string | null, string | null>>;
|
|
38
|
+
refresh: () => Promise<void>;
|
|
39
|
+
stop: () => void;
|
|
40
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { onScopeDispose, readonly, ref } from 'vue';
|
|
2
|
+
const TRANSITIONAL = new Set(['requested', 'creating', 'warming', 'resuming', 'snapshotting']);
|
|
3
|
+
/**
|
|
4
|
+
* 沙箱状态 + 心跳组合式 API(08 §4)
|
|
5
|
+
* - 自适应轮询:过渡态密集、稳定态稀疏、休眠态中等;页面隐藏时不轮询、不心跳
|
|
6
|
+
* - 心跳响应携带 state,稳定态下以此为主要状态来源,避免高频打 status
|
|
7
|
+
*/
|
|
8
|
+
export function useSandboxStatus(client, conversationId, opts = {}) {
|
|
9
|
+
const status = ref(null);
|
|
10
|
+
const error = ref(null);
|
|
11
|
+
const isVisible = opts.isVisible ??
|
|
12
|
+
(() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'));
|
|
13
|
+
const setI = opts.setInterval ?? globalThis.setInterval.bind(globalThis);
|
|
14
|
+
const clearI = opts.clearInterval ?? globalThis.clearInterval.bind(globalThis);
|
|
15
|
+
const pollMs = opts.pollMs ?? 3_000;
|
|
16
|
+
const idlePollMs = opts.idlePollMs ?? 60_000;
|
|
17
|
+
const hibernatedPollMs = opts.hibernatedPollMs ?? 15_000;
|
|
18
|
+
let lastPoll = 0;
|
|
19
|
+
function currentInterval() {
|
|
20
|
+
const s = status.value?.state;
|
|
21
|
+
if (!s || TRANSITIONAL.has(s))
|
|
22
|
+
return pollMs;
|
|
23
|
+
if (s === 'hibernated')
|
|
24
|
+
return hibernatedPollMs;
|
|
25
|
+
return idlePollMs;
|
|
26
|
+
}
|
|
27
|
+
async function refresh() {
|
|
28
|
+
lastPoll = Date.now();
|
|
29
|
+
try {
|
|
30
|
+
status.value = await client.sandbox.status(conversationId);
|
|
31
|
+
error.value = null;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
error.value = String(err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function beatOnce() {
|
|
38
|
+
if (!isVisible())
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
const r = (await client.sandbox.heartbeat(conversationId, { visible: true }));
|
|
42
|
+
// 心跳响应带 state(BuilderController),稳定态下据此更新,免打 status
|
|
43
|
+
if (r && typeof r === 'object' && r.state && status.value) {
|
|
44
|
+
status.value = { ...status.value, state: r.state };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// 心跳失败不打扰用户;状态轮询会暴露真实问题
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 单一 1s tick 调度器:按当前状态决定是否到期轮询(避免多定时器切换)
|
|
52
|
+
const tick = setI(() => {
|
|
53
|
+
if (!isVisible())
|
|
54
|
+
return;
|
|
55
|
+
if (Date.now() - lastPoll >= currentInterval())
|
|
56
|
+
void refresh();
|
|
57
|
+
}, 1_000);
|
|
58
|
+
const heartbeatTimer = setI(() => void beatOnce(), opts.heartbeatMs ?? 30_000);
|
|
59
|
+
function stop() {
|
|
60
|
+
clearI(tick);
|
|
61
|
+
clearI(heartbeatTimer);
|
|
62
|
+
}
|
|
63
|
+
onScopeDispose(stop);
|
|
64
|
+
void refresh();
|
|
65
|
+
void beatOnce();
|
|
66
|
+
return { status: readonly(status), error: readonly(error), refresh, stop };
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/vue.test.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMockBuilderClient, scenario1, scenario2 } from '@chatu-ai/builder-sdk-mock';
|
|
3
|
+
import { useBuilderChat } from './useBuilderChat';
|
|
4
|
+
import { useSandboxStatus } from './useSandboxStatus';
|
|
5
|
+
import { createInitialState, reduceEvent } from './reducer';
|
|
6
|
+
function fastScenario(s) {
|
|
7
|
+
return { ...s, steps: s.steps.map(st => ({ ...st, delayMs: 0 })) };
|
|
8
|
+
}
|
|
9
|
+
describe('reducer', () => {
|
|
10
|
+
it('reduces scenario1 event sequence into complete UI state', () => {
|
|
11
|
+
const state = createInitialState();
|
|
12
|
+
for (const step of scenario1.steps)
|
|
13
|
+
reduceEvent(state, step.event);
|
|
14
|
+
expect(state.agentState).toBe('idle'); // done(completed) 收尾
|
|
15
|
+
expect(state.rounds).toHaveLength(1);
|
|
16
|
+
const round = state.rounds[0];
|
|
17
|
+
expect(round.taskCards.map(c => c.state)).toEqual(['done', 'done', 'done']); // 三张卡全 done(原位更新)
|
|
18
|
+
expect(state.changedPaths).toContain('app/page.tsx');
|
|
19
|
+
expect(state.sandbox.state).toBe('ready');
|
|
20
|
+
expect(state.versions[0]?.sha).toBe('aaa111');
|
|
21
|
+
expect(round.version?.sha).toBe('aaa111');
|
|
22
|
+
});
|
|
23
|
+
it('taskCard upserts by id instead of appending', () => {
|
|
24
|
+
const state = createInitialState();
|
|
25
|
+
reduceEvent(state, { kind: 'taskCard', xid: 'x', seq: 1, id: 't1', label: 'a', state: 'running' });
|
|
26
|
+
reduceEvent(state, { kind: 'taskCard', xid: 'x', seq: 2, id: 't1', label: 'a', state: 'done' });
|
|
27
|
+
expect(state.rounds[0].taskCards).toHaveLength(1);
|
|
28
|
+
expect(state.rounds[0].taskCards[0].state).toBe('done');
|
|
29
|
+
});
|
|
30
|
+
it('preview crashed records lastError; ready clears it', () => {
|
|
31
|
+
const state = createInitialState();
|
|
32
|
+
reduceEvent(state, { kind: 'preview', xid: 'x', seq: 1, state: 'crashed', error: 'boom' });
|
|
33
|
+
expect(state.sandbox.lastError).toBe('boom');
|
|
34
|
+
reduceEvent(state, { kind: 'preview', xid: 'x', seq: 2, state: 'ready', url: 'https://a.b' });
|
|
35
|
+
expect(state.sandbox.lastError).toBeNull();
|
|
36
|
+
expect(state.sandbox.state).toBe('ready');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('useBuilderChat', () => {
|
|
40
|
+
it('streams scenario to completion and rejects concurrent send (R11)', async () => {
|
|
41
|
+
const client = createMockBuilderClient(fastScenario(scenario1));
|
|
42
|
+
const chat = useBuilderChat(client, 'c1');
|
|
43
|
+
const sending = chat.send('make a landing page');
|
|
44
|
+
await expect(chat.send('another')).rejects.toThrow('BUSY');
|
|
45
|
+
await sending;
|
|
46
|
+
expect(chat.state.agentState).toBe('idle');
|
|
47
|
+
expect(chat.state.rounds[0].done?.state).toBe('completed');
|
|
48
|
+
expect(chat.isStreaming.value).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('survives scenario2 disconnect: state complete, no duplicate task cards', async () => {
|
|
51
|
+
const client = createMockBuilderClient(fastScenario(scenario2));
|
|
52
|
+
const chat = useBuilderChat(client, 'c1');
|
|
53
|
+
await chat.send('change color');
|
|
54
|
+
expect(chat.state.rounds[0].taskCards).toHaveLength(1);
|
|
55
|
+
expect(chat.state.versions[0]?.sha).toBe('bbb222');
|
|
56
|
+
expect(chat.state.lastSeq).toBe(6);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('useSandboxStatus', () => {
|
|
60
|
+
it('polls status and sends heartbeat only when visible', async () => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
const client = createMockBuilderClient(fastScenario(scenario1));
|
|
63
|
+
const heartbeat = vi.spyOn(client.sandbox, 'heartbeat');
|
|
64
|
+
let visible = true;
|
|
65
|
+
const s = useSandboxStatus(client, 'c1', {
|
|
66
|
+
isVisible: () => visible,
|
|
67
|
+
heartbeatMs: 1000,
|
|
68
|
+
pollMs: 500,
|
|
69
|
+
});
|
|
70
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
71
|
+
expect(heartbeat).toHaveBeenCalled();
|
|
72
|
+
const callsWhenVisible = heartbeat.mock.calls.length;
|
|
73
|
+
visible = false;
|
|
74
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
75
|
+
expect(heartbeat.mock.calls.length).toBe(callsWhenVisible); // 隐藏后不再心跳
|
|
76
|
+
s.stop();
|
|
77
|
+
vi.useRealTimers();
|
|
78
|
+
});
|
|
79
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chatu-ai/builder-sdk-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vue 3 bindings for @chatu-ai/builder-sdk: useBuilderChat / useSandboxStatus / usePreviewUrl",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/chatu-ai/chatu-builder-sdk.git",
|
|
23
|
+
"directory": "packages/vue"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/chatu-ai/chatu-builder-sdk/tree/main/packages/vue#readme",
|
|
26
|
+
"bugs": "https://github.com/chatu-ai/chatu-builder-sdk/issues",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"chatu",
|
|
29
|
+
"builder",
|
|
30
|
+
"ai",
|
|
31
|
+
"app-builder",
|
|
32
|
+
"sdk",
|
|
33
|
+
"vue",
|
|
34
|
+
"composables"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"provenance": true
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@chatu-ai/builder-sdk": "0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"vue": "^3.4.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"vue": "^3.5.0",
|
|
52
|
+
"vitest": "^3.0.0",
|
|
53
|
+
"@chatu-ai/builder-sdk-mock": "0.1.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc -p tsconfig.json",
|
|
57
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|