@zhin.js/adapter-sandbox 1.0.70 → 1.1.2
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/CHANGELOG.md +1037 -50
- package/README.md +59 -36
- package/adapters/sandbox/index.js +25 -0
- package/adapters/sandbox/index.ts +30 -0
- package/lib/client.d.ts +27 -0
- package/lib/client.js +27 -0
- package/lib/endpoint.d.ts +42 -0
- package/lib/endpoint.js +357 -0
- package/lib/index.d.ts +3 -55
- package/lib/index.js +3 -176
- package/lib/protocol.d.ts +71 -0
- package/lib/protocol.js +295 -0
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +64 -23
- package/pages/index/RichTextEditor.js +366 -0
- package/{client → pages/index}/RichTextEditor.tsx +59 -13
- package/pages/index/SandboxChat.js +615 -0
- package/pages/index/SandboxChat.tsx +1186 -0
- package/pages/index/agentTrace.js +559 -0
- package/pages/index/agentTrace.ts +646 -0
- package/pages/index/index.js +18 -0
- package/pages/index/index.tsx +18 -0
- package/pages/index/playgroundState.js +126 -0
- package/pages/index/playgroundState.ts +172 -0
- package/pages/index/sandboxTransport.js +45 -0
- package/pages/index/sandboxTransport.ts +45 -0
- package/plugin.js +8 -0
- package/schema.json +75 -0
- package/src/client.ts +47 -0
- package/src/endpoint.ts +420 -0
- package/src/index.ts +26 -238
- package/src/protocol.ts +385 -0
- package/src/run-config.ts +41 -0
- package/LICENSE +0 -21
- package/client/Sandbox.tsx +0 -493
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/dist/index.js +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
package/lib/index.d.ts
CHANGED
|
@@ -1,55 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { Router } from "@zhin.js/http";
|
|
5
|
-
export interface SandboxConfig {
|
|
6
|
-
context: "sandbox";
|
|
7
|
-
ws: WebSocket;
|
|
8
|
-
name: string;
|
|
9
|
-
/** 发言者即为 owner(沙箱模式) */
|
|
10
|
-
owner?: string;
|
|
11
|
-
}
|
|
12
|
-
declare module "zhin.js" {
|
|
13
|
-
namespace Plugin {
|
|
14
|
-
interface Contexts {
|
|
15
|
-
router: Router;
|
|
16
|
-
web: any;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
interface Adapters {
|
|
20
|
-
sandbox: SandboxAdapter;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
export declare class SandboxBot extends EventEmitter implements Bot<SandboxConfig, {
|
|
24
|
-
content: MessageElement[];
|
|
25
|
-
ts: number;
|
|
26
|
-
}> {
|
|
27
|
-
adapter: SandboxAdapter;
|
|
28
|
-
$config: SandboxConfig;
|
|
29
|
-
$connected: boolean;
|
|
30
|
-
get $id(): string;
|
|
31
|
-
private logger;
|
|
32
|
-
constructor(adapter: SandboxAdapter, $config: SandboxConfig);
|
|
33
|
-
$connect(): Promise<void>;
|
|
34
|
-
$disconnect(): Promise<void>;
|
|
35
|
-
$formatMessage({ content, type, id, ts }: {
|
|
36
|
-
content: MessageElement[];
|
|
37
|
-
id: string;
|
|
38
|
-
type: MessageType;
|
|
39
|
-
ts: number;
|
|
40
|
-
}): Message<{
|
|
41
|
-
content: MessageElement[];
|
|
42
|
-
ts: number;
|
|
43
|
-
}>;
|
|
44
|
-
$sendMessage(options: SendOptions): Promise<string>;
|
|
45
|
-
$recallMessage(id: string): Promise<void>;
|
|
46
|
-
}
|
|
47
|
-
declare class SandboxAdapter extends Adapter<SandboxBot> {
|
|
48
|
-
wss?: ReturnType<Router["ws"]>;
|
|
49
|
-
constructor(plugin: Plugin);
|
|
50
|
-
createBot(config: SandboxConfig): SandboxBot;
|
|
51
|
-
start(): Promise<void>;
|
|
52
|
-
setupWebSocket(router: Router): Promise<void>;
|
|
53
|
-
}
|
|
54
|
-
export {};
|
|
55
|
-
//# sourceMappingURL=index.d.ts.map
|
|
1
|
+
export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxEndpointConfig, type SandboxWsSocket, } from './protocol.js';
|
|
2
|
+
export { SandboxClient, sandboxClient, type SandboxClientEventMap, type SandboxClientConnection, } from './client.js';
|
|
3
|
+
export { SandboxWsEndpoint, type SandboxEndpointOptions, } from './endpoint.js';
|
package/lib/index.js
CHANGED
|
@@ -1,176 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const plugin = usePlugin();
|
|
5
|
-
const logger = plugin.logger;
|
|
6
|
-
export class SandboxBot extends EventEmitter {
|
|
7
|
-
adapter;
|
|
8
|
-
$config;
|
|
9
|
-
$connected = false;
|
|
10
|
-
get $id() {
|
|
11
|
-
return this.$config.name;
|
|
12
|
-
}
|
|
13
|
-
logger = logger;
|
|
14
|
-
constructor(adapter, $config) {
|
|
15
|
-
super();
|
|
16
|
-
this.adapter = adapter;
|
|
17
|
-
this.$config = $config;
|
|
18
|
-
this.$config.ws.on("message", (data) => {
|
|
19
|
-
const message = JSON.parse(data.toString());
|
|
20
|
-
// 确保 content 是 MessageElement[] 格式
|
|
21
|
-
const content = typeof message.content === 'string'
|
|
22
|
-
? [{ type: 'text', data: { text: message.content } }]
|
|
23
|
-
: message.content;
|
|
24
|
-
this.logger.debug(`${this.$config.name} recv ${message.type}(${message.id}):${segment.raw(content)}`);
|
|
25
|
-
const formattedMessage = this.$formatMessage({ content: content, type: message.type, id: message.id, ts: message.timestamp });
|
|
26
|
-
this.adapter.emit("message.receive", formattedMessage);
|
|
27
|
-
});
|
|
28
|
-
this.$config.ws.on("close", () => {
|
|
29
|
-
this.logger.debug(`Sandbox bot ${this.$config.name} disconnected`);
|
|
30
|
-
this.$connected = false;
|
|
31
|
-
// 从 adapter 中移除 bot
|
|
32
|
-
this.adapter.bots.delete(this.$id);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
async $connect() {
|
|
36
|
-
this.$connected = true;
|
|
37
|
-
}
|
|
38
|
-
async $disconnect() {
|
|
39
|
-
this.$config.ws.close();
|
|
40
|
-
this.$connected = false;
|
|
41
|
-
}
|
|
42
|
-
$formatMessage({ content, type, id, ts }) {
|
|
43
|
-
// 沙箱模式:发言者即为 owner
|
|
44
|
-
if (!this.$config.owner)
|
|
45
|
-
this.$config.owner = id;
|
|
46
|
-
const message = Message.from({ content, ts }, {
|
|
47
|
-
$id: `${ts}`,
|
|
48
|
-
$adapter: "sandbox",
|
|
49
|
-
$bot: `${this.$config.name}`,
|
|
50
|
-
$sender: {
|
|
51
|
-
id: `${id}`,
|
|
52
|
-
name: `mock`,
|
|
53
|
-
},
|
|
54
|
-
$channel: {
|
|
55
|
-
id: `${id}`,
|
|
56
|
-
type: type,
|
|
57
|
-
},
|
|
58
|
-
$content: content,
|
|
59
|
-
$raw: segment.raw(content),
|
|
60
|
-
$timestamp: ts,
|
|
61
|
-
$recall: async () => {
|
|
62
|
-
await this.$recallMessage(message.$id);
|
|
63
|
-
},
|
|
64
|
-
$reply: async (content, quote) => {
|
|
65
|
-
if (!Array.isArray(content))
|
|
66
|
-
content = [content];
|
|
67
|
-
if (quote)
|
|
68
|
-
content.unshift({ type: "reply", data: { id: typeof quote === "boolean" ? message.$id : quote } });
|
|
69
|
-
return await this.adapter.sendMessage({
|
|
70
|
-
...message.$channel,
|
|
71
|
-
context: "sandbox",
|
|
72
|
-
bot: `${this.$config.name}`,
|
|
73
|
-
content,
|
|
74
|
-
});
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
return message;
|
|
78
|
-
}
|
|
79
|
-
async $sendMessage(options) {
|
|
80
|
-
if (!this.$connected)
|
|
81
|
-
return "";
|
|
82
|
-
this.logger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
|
|
83
|
-
options.bot = this.$config.name;
|
|
84
|
-
options.context = "sandbox";
|
|
85
|
-
this.$config.ws.send(JSON.stringify({
|
|
86
|
-
...options,
|
|
87
|
-
content: options.content, // 发送消息段数组
|
|
88
|
-
timestamp: Date.now(),
|
|
89
|
-
}));
|
|
90
|
-
return "";
|
|
91
|
-
}
|
|
92
|
-
async $recallMessage(id) {
|
|
93
|
-
// 沙盒不支持撤回消息
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
class SandboxAdapter extends Adapter {
|
|
97
|
-
wss;
|
|
98
|
-
constructor(plugin) {
|
|
99
|
-
super(plugin, "sandbox", []);
|
|
100
|
-
}
|
|
101
|
-
createBot(config) {
|
|
102
|
-
const bot = new SandboxBot(this, config);
|
|
103
|
-
// 将 bot 添加到 bots Map 中
|
|
104
|
-
this.bots.set(bot.$id, bot);
|
|
105
|
-
return bot;
|
|
106
|
-
}
|
|
107
|
-
async start() {
|
|
108
|
-
// start 方法会在 mounted 时被调用
|
|
109
|
-
// WebSocket server 的创建在 useContext("router") 中处理
|
|
110
|
-
}
|
|
111
|
-
async setupWebSocket(router) {
|
|
112
|
-
if (this.wss)
|
|
113
|
-
return; // 已经设置过了
|
|
114
|
-
// 创建 WebSocket server
|
|
115
|
-
this.wss = router.ws("/sandbox");
|
|
116
|
-
this.wss.on("connection", (ws, req) => {
|
|
117
|
-
// 为每个连接创建一个唯一的 bot 名称
|
|
118
|
-
const botName = `sandbox-${Math.random().toString(36).slice(2, 9)}`;
|
|
119
|
-
logger.debug(`New sandbox connection: ${botName} from ${req.socket.remoteAddress}`);
|
|
120
|
-
// 创建 bot 配置
|
|
121
|
-
const config = {
|
|
122
|
-
context: "sandbox",
|
|
123
|
-
ws,
|
|
124
|
-
name: botName,
|
|
125
|
-
};
|
|
126
|
-
// 创建并连接 bot
|
|
127
|
-
const bot = this.createBot(config);
|
|
128
|
-
bot.$connect();
|
|
129
|
-
// WebSocket 关闭时清理
|
|
130
|
-
ws.on("close", () => {
|
|
131
|
-
logger.debug(`Sandbox connection closed: ${botName}`);
|
|
132
|
-
this.bots.delete(bot.$id);
|
|
133
|
-
});
|
|
134
|
-
ws.on("error", (error) => {
|
|
135
|
-
logger.error(`Sandbox WebSocket error for ${botName}:`, error);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
logger.debug("Sandbox WebSocket server started at /sandbox");
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const { provide } = usePlugin();
|
|
142
|
-
provide({
|
|
143
|
-
name: "sandbox",
|
|
144
|
-
description: "Sandbox Adapter",
|
|
145
|
-
mounted: async (p) => {
|
|
146
|
-
const adapter = new SandboxAdapter(p);
|
|
147
|
-
await adapter.start();
|
|
148
|
-
return adapter;
|
|
149
|
-
},
|
|
150
|
-
dispose: async (adapter) => {
|
|
151
|
-
// 关闭所有 bot 连接
|
|
152
|
-
for (const bot of adapter.bots.values()) {
|
|
153
|
-
await bot.$disconnect();
|
|
154
|
-
}
|
|
155
|
-
// 关闭 WebSocket server
|
|
156
|
-
adapter.wss?.close();
|
|
157
|
-
await adapter.stop();
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
// 使用 router 上下文创建 WebSocket server
|
|
161
|
-
plugin.useContext("router", async (router) => {
|
|
162
|
-
// 等待 sandbox adapter 就绪
|
|
163
|
-
plugin.useContext("sandbox", async (adapter) => {
|
|
164
|
-
await adapter.setupWebSocket(router);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
// 使用 web 上下文注册客户端入口
|
|
168
|
-
plugin.useContext("web", (web) => {
|
|
169
|
-
// 注册 Sandbox 适配器的客户端入口文件
|
|
170
|
-
const dispose = web.addEntry({
|
|
171
|
-
production: path.resolve(import.meta.dirname, "../dist/index.js"),
|
|
172
|
-
development: path.resolve(import.meta.dirname, "../client/index.tsx"),
|
|
173
|
-
});
|
|
174
|
-
return dispose;
|
|
175
|
-
});
|
|
176
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
|
|
2
|
+
export { SandboxClient, sandboxClient, } from './client.js';
|
|
3
|
+
export { SandboxWsEndpoint, } from './endpoint.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
|
|
2
|
+
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
3
|
+
import { type SandboxAgentRunConfig } from './run-config.js';
|
|
4
|
+
export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
|
|
5
|
+
export interface MessageElement {
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly data?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface SandboxWsSocket {
|
|
10
|
+
send(data: string): void;
|
|
11
|
+
close(code?: number, reason?: string): void;
|
|
12
|
+
on?(event: 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
|
|
13
|
+
off?(event: 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
|
|
14
|
+
addEventListener?(type: 'message' | 'close' | 'error', listener: (ev: Event | MessageEvent | CloseEvent) => void): void;
|
|
15
|
+
removeEventListener?(type: 'message' | 'close' | 'error', listener: (ev: Event | MessageEvent | CloseEvent) => void): void;
|
|
16
|
+
}
|
|
17
|
+
export type ResolvedSandboxBot = {
|
|
18
|
+
readonly context: 'sandbox';
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly owner: string;
|
|
21
|
+
readonly randomNamePerConnection: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
24
|
+
export interface SandboxEndpointConfig {
|
|
25
|
+
readonly id?: string;
|
|
26
|
+
readonly owner?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function resolveSandboxEndpoint(config: SandboxEndpointConfig): ResolvedSandboxBot;
|
|
29
|
+
export declare function bindSandboxWsSocket(ws: SandboxWsSocket, handlers: {
|
|
30
|
+
onMessage: (raw: string) => void;
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
onError?: (err: unknown) => void;
|
|
33
|
+
}): () => void;
|
|
34
|
+
export declare function parseSandboxWsPayload(raw: string): {
|
|
35
|
+
type: MessageType;
|
|
36
|
+
id: string;
|
|
37
|
+
messageId?: string;
|
|
38
|
+
content: MessageElement[];
|
|
39
|
+
timestamp: number;
|
|
40
|
+
text: string;
|
|
41
|
+
action?: {
|
|
42
|
+
id: string;
|
|
43
|
+
payload: string;
|
|
44
|
+
};
|
|
45
|
+
agentRun?: SandboxAgentRunConfig;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
|
|
49
|
+
* `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
|
|
50
|
+
* `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
|
|
51
|
+
*/
|
|
52
|
+
export declare function sandboxInboundConversation(endpointKey: string, msg: {
|
|
53
|
+
readonly type: MessageType;
|
|
54
|
+
readonly id: string;
|
|
55
|
+
}): ConversationRef;
|
|
56
|
+
export type SandboxOutboundChannel = {
|
|
57
|
+
readonly type?: string;
|
|
58
|
+
readonly id?: string;
|
|
59
|
+
readonly bot?: string;
|
|
60
|
+
readonly endpoint?: string;
|
|
61
|
+
readonly messageId?: string;
|
|
62
|
+
};
|
|
63
|
+
/** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
|
|
64
|
+
export declare function normalizeSandboxOutboundSegments(segments: readonly unknown[]): unknown[];
|
|
65
|
+
/**
|
|
66
|
+
* Wire-encode an already-rendered outbound payload.
|
|
67
|
+
* Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
|
|
68
|
+
* replies look like they disappeared).
|
|
69
|
+
*/
|
|
70
|
+
export declare function formatSandboxOutbound(payload: unknown, channel?: SandboxOutboundChannel): string;
|
|
71
|
+
export declare function whenWsOpen(ws: SandboxWsSocket, fn: () => void): void;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { isMediaRef } from '@zhin.js/im-contract';
|
|
4
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
5
|
+
import { normalizeSandboxAgentRunConfig, } from './run-config.js';
|
|
6
|
+
const logger = getLogger('sandbox');
|
|
7
|
+
export function resolveSandboxEndpoint(config) {
|
|
8
|
+
const id = optionalEndpointField(config.id) ?? 'sandbox-bot';
|
|
9
|
+
const owner = optionalEndpointField(config.owner) ?? 'sandbox-user';
|
|
10
|
+
return {
|
|
11
|
+
context: 'sandbox',
|
|
12
|
+
id,
|
|
13
|
+
owner,
|
|
14
|
+
// The endpoint id participates in the Agent session key. Keep it stable
|
|
15
|
+
// across browser reconnects and Host restarts so a persisted playground
|
|
16
|
+
// session resumes the same Agent context.
|
|
17
|
+
randomNamePerConnection: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function optionalEndpointField(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function bindSandboxWsSocket(ws, handlers) {
|
|
24
|
+
if (typeof ws.on === 'function') {
|
|
25
|
+
const onMessage = (...args) => {
|
|
26
|
+
const data = args[0];
|
|
27
|
+
const raw = typeof data === 'string'
|
|
28
|
+
? data
|
|
29
|
+
: data instanceof ArrayBuffer
|
|
30
|
+
? new TextDecoder().decode(data)
|
|
31
|
+
: Buffer.isBuffer(data)
|
|
32
|
+
? data.toString()
|
|
33
|
+
: String(data ?? '');
|
|
34
|
+
handlers.onMessage(raw);
|
|
35
|
+
};
|
|
36
|
+
ws.on('message', onMessage);
|
|
37
|
+
ws.on('close', handlers.onClose);
|
|
38
|
+
if (handlers.onError)
|
|
39
|
+
ws.on('error', handlers.onError);
|
|
40
|
+
return () => {
|
|
41
|
+
ws.off?.('message', onMessage);
|
|
42
|
+
ws.off?.('close', handlers.onClose);
|
|
43
|
+
if (handlers.onError)
|
|
44
|
+
ws.off?.('error', handlers.onError);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const onMessage = (ev) => {
|
|
48
|
+
const data = ev.data;
|
|
49
|
+
handlers.onMessage(typeof data === 'string' ? data : '');
|
|
50
|
+
};
|
|
51
|
+
const onClose = () => handlers.onClose();
|
|
52
|
+
const onError = handlers.onError
|
|
53
|
+
? () => handlers.onError?.(new Error('WebSocket error'))
|
|
54
|
+
: undefined;
|
|
55
|
+
ws.addEventListener('message', onMessage);
|
|
56
|
+
ws.addEventListener('close', onClose);
|
|
57
|
+
if (onError)
|
|
58
|
+
ws.addEventListener('error', onError);
|
|
59
|
+
return () => {
|
|
60
|
+
ws.removeEventListener('message', onMessage);
|
|
61
|
+
ws.removeEventListener('close', onClose);
|
|
62
|
+
if (onError)
|
|
63
|
+
ws.removeEventListener('error', onError);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function parseSandboxWsPayload(raw) {
|
|
67
|
+
let payload;
|
|
68
|
+
try {
|
|
69
|
+
payload = JSON.parse(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
payload = { text: raw };
|
|
73
|
+
}
|
|
74
|
+
const type = payload.type ?? 'private';
|
|
75
|
+
const id = payload.id ?? 'sandbox-user';
|
|
76
|
+
const content = typeof payload.content === 'string'
|
|
77
|
+
? [{ type: 'text', data: { text: payload.content } }]
|
|
78
|
+
: Array.isArray(payload.content)
|
|
79
|
+
? payload.content
|
|
80
|
+
: [{ type: 'text', data: { text: payload.text ?? raw } }];
|
|
81
|
+
const actionSegment = content.find((segment) => segment.type === 'action');
|
|
82
|
+
let action;
|
|
83
|
+
if (actionSegment?.data) {
|
|
84
|
+
const actionPayload = typeof actionSegment.data.payload === 'string'
|
|
85
|
+
? actionSegment.data.payload
|
|
86
|
+
: typeof actionSegment.data.id === 'string'
|
|
87
|
+
? actionSegment.data.id
|
|
88
|
+
: '';
|
|
89
|
+
const actionId = typeof actionSegment.data.id === 'string'
|
|
90
|
+
? actionSegment.data.id
|
|
91
|
+
: actionPayload;
|
|
92
|
+
if (actionId || actionPayload) {
|
|
93
|
+
action = { id: actionId || actionPayload, payload: actionPayload || actionId };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let text = content
|
|
97
|
+
.flatMap((segment) => (segment.type === 'text' && typeof segment.data?.text === 'string'
|
|
98
|
+
? [segment.data.text]
|
|
99
|
+
: []))
|
|
100
|
+
.join('\n');
|
|
101
|
+
if (!text.trim()) {
|
|
102
|
+
text = (typeof payload.text === 'string' && payload.text.trim())
|
|
103
|
+
? payload.text
|
|
104
|
+
: action?.payload ?? raw;
|
|
105
|
+
}
|
|
106
|
+
const agentRun = normalizeSandboxAgentRunConfig(payload.agentRun);
|
|
107
|
+
const rawMessageId = typeof payload.messageId === 'string' ? payload.messageId.trim() : '';
|
|
108
|
+
const messageId = /^[A-Za-z0-9._:-]{1,160}$/u.test(rawMessageId) ? rawMessageId : undefined;
|
|
109
|
+
return {
|
|
110
|
+
type,
|
|
111
|
+
id,
|
|
112
|
+
...(messageId ? { messageId } : {}),
|
|
113
|
+
content,
|
|
114
|
+
timestamp: payload.timestamp ?? Date.now(),
|
|
115
|
+
text,
|
|
116
|
+
action,
|
|
117
|
+
...(agentRun ? { agentRun } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
|
|
122
|
+
* `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
|
|
123
|
+
* `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
|
|
124
|
+
*/
|
|
125
|
+
export function sandboxInboundConversation(endpointKey, msg) {
|
|
126
|
+
const kind = msg.type === 'direct'
|
|
127
|
+
? 'private'
|
|
128
|
+
: msg.type === 'guild'
|
|
129
|
+
? 'channel'
|
|
130
|
+
: msg.type;
|
|
131
|
+
return {
|
|
132
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
133
|
+
kind,
|
|
134
|
+
id: msg.id,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const MEDIA_SEGMENT_TYPES = new Set(['image', 'audio', 'video', 'file']);
|
|
138
|
+
/**
|
|
139
|
+
* base64 内联值:Console UI 的 resolveMediaSrc 只识别 data: / base64://
|
|
140
|
+
* 前缀;裸 base64 补前缀(有 mime_type 时拼成可直转 data: URL 的形状)。
|
|
141
|
+
*/
|
|
142
|
+
function toInlineBase64Value(value, mimeType) {
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (trimmed.startsWith('base64://') || trimmed.startsWith('data:'))
|
|
145
|
+
return trimmed;
|
|
146
|
+
return mimeType
|
|
147
|
+
? `base64://${mimeType};base64,${trimmed}`
|
|
148
|
+
: `base64://${trimmed}`;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 出站媒体段归一(canonical MediaRef 唯一来源,不读 legacy url/file/base64/src):
|
|
152
|
+
* - kind=url → 浏览器直连 URL,原样透传;
|
|
153
|
+
* - kind=base64 → 内联直发(补 base64:// 前缀供 Console UI 解析);
|
|
154
|
+
* - kind=path → 读盘物化为 base64 内联(sandbox 无平台上传通道);
|
|
155
|
+
* - kind=file → sandbox 无不透明引用通道,丢弃。
|
|
156
|
+
* 无 canonical `data.media` 的媒体段一律 warn + 丢弃。
|
|
157
|
+
*/
|
|
158
|
+
function normalizeMediaSegment(segment) {
|
|
159
|
+
const data = segment.data ?? {};
|
|
160
|
+
const media = data.media;
|
|
161
|
+
if (!isMediaRef(media)) {
|
|
162
|
+
logger.warn(formatCompact({
|
|
163
|
+
op: 'sandbox_outbound_media_dropped',
|
|
164
|
+
type: segment.type,
|
|
165
|
+
reason: 'missing_media_ref',
|
|
166
|
+
}));
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (media.kind === 'url')
|
|
170
|
+
return { type: segment.type, data };
|
|
171
|
+
if (media.kind === 'base64') {
|
|
172
|
+
return {
|
|
173
|
+
type: segment.type,
|
|
174
|
+
data: {
|
|
175
|
+
...data,
|
|
176
|
+
media: { ...media, value: toInlineBase64Value(media.value, media.mime_type) },
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (media.kind === 'path') {
|
|
181
|
+
try {
|
|
182
|
+
const base64 = readFileSync(media.value).toString('base64');
|
|
183
|
+
return {
|
|
184
|
+
type: segment.type,
|
|
185
|
+
data: {
|
|
186
|
+
...data,
|
|
187
|
+
media: {
|
|
188
|
+
...media,
|
|
189
|
+
kind: 'base64',
|
|
190
|
+
value: toInlineBase64Value(base64, media.mime_type),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.warn(formatCompact({
|
|
197
|
+
op: 'sandbox_outbound_media_dropped',
|
|
198
|
+
type: segment.type,
|
|
199
|
+
reason: 'path_read_failed',
|
|
200
|
+
error: err instanceof Error ? err.message : String(err),
|
|
201
|
+
}));
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
logger.warn(formatCompact({
|
|
206
|
+
op: 'sandbox_outbound_media_dropped',
|
|
207
|
+
type: segment.type,
|
|
208
|
+
reason: 'unsupported_media_kind',
|
|
209
|
+
}));
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
/** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
|
|
213
|
+
export function normalizeSandboxOutboundSegments(segments) {
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const item of segments) {
|
|
216
|
+
if (item
|
|
217
|
+
&& typeof item === 'object'
|
|
218
|
+
&& !Array.isArray(item)
|
|
219
|
+
&& MEDIA_SEGMENT_TYPES.has(String(item.type))) {
|
|
220
|
+
const normalized = normalizeMediaSegment(item);
|
|
221
|
+
if (normalized)
|
|
222
|
+
out.push(normalized);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
out.push(item);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Wire-encode an already-rendered outbound payload.
|
|
231
|
+
* Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
|
|
232
|
+
* replies look like they disappeared).
|
|
233
|
+
*/
|
|
234
|
+
export function formatSandboxOutbound(payload, channel = {}) {
|
|
235
|
+
const stamp = {};
|
|
236
|
+
if (channel.type)
|
|
237
|
+
stamp.type = channel.type;
|
|
238
|
+
if (channel.id)
|
|
239
|
+
stamp.id = channel.id;
|
|
240
|
+
if (channel.bot)
|
|
241
|
+
stamp.bot = channel.bot;
|
|
242
|
+
if (channel.endpoint)
|
|
243
|
+
stamp.endpoint = channel.endpoint;
|
|
244
|
+
if (channel.messageId)
|
|
245
|
+
stamp.messageId = channel.messageId;
|
|
246
|
+
if (typeof payload === 'string') {
|
|
247
|
+
return JSON.stringify({
|
|
248
|
+
...stamp,
|
|
249
|
+
content: [{ type: 'text', data: { text: payload } }],
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(payload)) {
|
|
254
|
+
return JSON.stringify({
|
|
255
|
+
...stamp,
|
|
256
|
+
content: normalizeSandboxOutboundSegments(payload),
|
|
257
|
+
timestamp: Date.now(),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Already a wire envelope ({ content, type, … }) — pass through so the
|
|
261
|
+
// Console UI can read `content` / `type` without an extra nesting layer.
|
|
262
|
+
// Bare segment objects ({ type: 'text', data: … }) still need wrapping.
|
|
263
|
+
if (payload
|
|
264
|
+
&& typeof payload === 'object'
|
|
265
|
+
&& !Array.isArray(payload)
|
|
266
|
+
&& ('content' in payload
|
|
267
|
+
|| 'type' in payload && 'timestamp' in payload)) {
|
|
268
|
+
const envelope = payload;
|
|
269
|
+
return JSON.stringify({
|
|
270
|
+
...stamp,
|
|
271
|
+
...envelope,
|
|
272
|
+
...(Array.isArray(envelope.content)
|
|
273
|
+
? { content: normalizeSandboxOutboundSegments(envelope.content) }
|
|
274
|
+
: {}),
|
|
275
|
+
type: envelope.type ?? stamp.type,
|
|
276
|
+
id: envelope.id ?? stamp.id,
|
|
277
|
+
timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
|
|
281
|
+
}
|
|
282
|
+
/** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
|
|
283
|
+
const WS_OPEN = 1;
|
|
284
|
+
export function whenWsOpen(ws, fn) {
|
|
285
|
+
const std = ws;
|
|
286
|
+
if (typeof std.readyState === 'number') {
|
|
287
|
+
if (std.readyState === WS_OPEN) {
|
|
288
|
+
fn();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
std.addEventListener('open', fn, { once: true });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
fn();
|
|
295
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type SandboxSafetyMode = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
2
|
+
export type SandboxApprovalMode = 'ask' | 'auto' | 'bypass';
|
|
3
|
+
export interface SandboxAgentRunConfig {
|
|
4
|
+
readonly workingDirectory: string;
|
|
5
|
+
readonly safetyMode: SandboxSafetyMode;
|
|
6
|
+
readonly approvalMode: SandboxApprovalMode;
|
|
7
|
+
readonly networkAccess: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_SANDBOX_AGENT_RUN_CONFIG: SandboxAgentRunConfig;
|
|
10
|
+
export declare function normalizeSandboxAgentRunConfig(value: unknown): SandboxAgentRunConfig | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const DEFAULT_SANDBOX_AGENT_RUN_CONFIG = Object.freeze({
|
|
2
|
+
workingDirectory: '',
|
|
3
|
+
safetyMode: 'workspace-write',
|
|
4
|
+
approvalMode: 'ask',
|
|
5
|
+
networkAccess: false,
|
|
6
|
+
});
|
|
7
|
+
const SAFETY_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
8
|
+
const APPROVAL_MODES = new Set(['ask', 'auto', 'bypass']);
|
|
9
|
+
export function normalizeSandboxAgentRunConfig(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
return undefined;
|
|
12
|
+
const input = value;
|
|
13
|
+
const workingDirectory = typeof input.workingDirectory === 'string'
|
|
14
|
+
? input.workingDirectory.trim().slice(0, 4096)
|
|
15
|
+
: '';
|
|
16
|
+
const safetyMode = SAFETY_MODES.has(input.safetyMode)
|
|
17
|
+
? input.safetyMode
|
|
18
|
+
: DEFAULT_SANDBOX_AGENT_RUN_CONFIG.safetyMode;
|
|
19
|
+
const approvalMode = APPROVAL_MODES.has(input.approvalMode)
|
|
20
|
+
? input.approvalMode
|
|
21
|
+
: DEFAULT_SANDBOX_AGENT_RUN_CONFIG.approvalMode;
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
workingDirectory,
|
|
24
|
+
safetyMode,
|
|
25
|
+
approvalMode,
|
|
26
|
+
// Full host access cannot be combined with a portable network namespace.
|
|
27
|
+
// Keep the contract honest: danger mode includes network authority.
|
|
28
|
+
networkAccess: safetyMode === 'danger-full-access' || input.networkAccess === true,
|
|
29
|
+
});
|
|
30
|
+
}
|