@artflo-ai/artflo-openclaw-plugin 0.0.1
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 +102 -0
- package/dist/index.js +73 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-00-216Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-00-217Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-05-727Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-30-218Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-30-218Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-30-35-728Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-00-218Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-00-219Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-05-729Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-30-220Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-30-220Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-31-35-729Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-00-221Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-00-221Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-05-730Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-30-222Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-30-222Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-32-35-731Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-00-223Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-00-223Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-05-732Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-30-223Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-30-223Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-33-35-734Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-34-00-228Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
- package/dist/logs/ws-traffic-traces/2026-03-27T20-34-00-229Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
- package/dist/src/config.js +57 -0
- package/dist/src/constants.js +35 -0
- package/dist/src/core/api/api-base.js +12 -0
- package/dist/src/core/api/upload-file.js +59 -0
- package/dist/src/core/canvas/canvas-session-manager.js +189 -0
- package/dist/src/core/canvas/canvas-websocket-client.js +453 -0
- package/dist/src/core/canvas/create-canvas.js +37 -0
- package/dist/src/core/canvas/types.js +23 -0
- package/dist/src/core/canvas/ws-trace.js +42 -0
- package/dist/src/core/config/fetch-client-params.js +20 -0
- package/dist/src/core/config/fetch-vip-info.js +30 -0
- package/dist/src/core/config/model-config-transformer.js +104 -0
- package/dist/src/core/executor/element-builders.js +216 -0
- package/dist/src/core/executor/execute-plan.js +1221 -0
- package/dist/src/core/executor/execution-trace.js +34 -0
- package/dist/src/core/layout/layout-service.js +366 -0
- package/dist/src/core/plan/analyze-plan-groups.js +71 -0
- package/dist/src/core/plan/types.js +1 -0
- package/dist/src/core/plan/validate-plan.js +159 -0
- package/dist/src/paths.js +16 -0
- package/dist/src/services/canvas-session-registry.js +57 -0
- package/dist/src/tools/register-tools.js +669 -0
- package/dist/src/tools/tool-trace.js +19 -0
- package/openclaw.plugin.json +33 -0
- package/package.json +42 -0
- package/skills/artflo-canvas/SKILL.md +118 -0
- package/skills/artflo-canvas/references/graph-rules.md +53 -0
- package/skills/artflo-canvas/references/layout-notes.md +31 -0
- package/skills/artflo-canvas/references/node-schema.json +948 -0
- package/skills/artflo-canvas/references/node-schema.md +188 -0
- package/skills/artflo-canvas/references/planning-guide.md +321 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { CanvasWebSocketClient } from './canvas-websocket-client.js';
|
|
3
|
+
export class CanvasSessionManager extends EventEmitter {
|
|
4
|
+
sessionKey;
|
|
5
|
+
config;
|
|
6
|
+
client = null;
|
|
7
|
+
elements = new Map();
|
|
8
|
+
connectionParams = null;
|
|
9
|
+
isConnected = false;
|
|
10
|
+
isConnecting = false;
|
|
11
|
+
intentionalClose = false;
|
|
12
|
+
reconnectAttempts = 0;
|
|
13
|
+
constructor(sessionKey, config) {
|
|
14
|
+
super();
|
|
15
|
+
this.sessionKey = sessionKey;
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
async connect(params) {
|
|
19
|
+
if (this.isConnected || this.isConnecting) {
|
|
20
|
+
return this.getElements();
|
|
21
|
+
}
|
|
22
|
+
this.isConnecting = true;
|
|
23
|
+
this.intentionalClose = false;
|
|
24
|
+
this.connectionParams = params;
|
|
25
|
+
try {
|
|
26
|
+
this.client = new CanvasWebSocketClient(this.config);
|
|
27
|
+
this.setupEventListeners();
|
|
28
|
+
const canvasDoc = await this.client.connect(params);
|
|
29
|
+
this.elements.clear();
|
|
30
|
+
Object.values(canvasDoc.elements).forEach((element) => {
|
|
31
|
+
this.elements.set(element.id, element);
|
|
32
|
+
});
|
|
33
|
+
this.isConnected = true;
|
|
34
|
+
this.isConnecting = false;
|
|
35
|
+
this.reconnectAttempts = 0;
|
|
36
|
+
return this.getElements();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
this.isConnecting = false;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
getConnectionState() {
|
|
44
|
+
return {
|
|
45
|
+
sessionKey: this.sessionKey,
|
|
46
|
+
canvasId: this.connectionParams?.canvasId ?? null,
|
|
47
|
+
isConnected: this.isConnected,
|
|
48
|
+
isConnecting: this.isConnecting,
|
|
49
|
+
elementCount: this.elements.size,
|
|
50
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
51
|
+
clientState: this.client?.getConnectionState() ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
getElements() {
|
|
55
|
+
return Array.from(this.elements.values());
|
|
56
|
+
}
|
|
57
|
+
getElement(id) {
|
|
58
|
+
return this.elements.get(id);
|
|
59
|
+
}
|
|
60
|
+
getCanvasConfig() {
|
|
61
|
+
return this.client?.getCanvasConfig() ?? null;
|
|
62
|
+
}
|
|
63
|
+
async addElements(elements) {
|
|
64
|
+
this.ensureConnected();
|
|
65
|
+
await this.client.addElements(elements);
|
|
66
|
+
elements.forEach((element) => this.elements.set(element.id, element));
|
|
67
|
+
}
|
|
68
|
+
async changeElements(changes) {
|
|
69
|
+
this.ensureConnected();
|
|
70
|
+
await this.client.changeElements(changes);
|
|
71
|
+
changes.forEach((change) => {
|
|
72
|
+
if (!change.id)
|
|
73
|
+
return;
|
|
74
|
+
const existing = this.elements.get(change.id);
|
|
75
|
+
if (!existing)
|
|
76
|
+
return;
|
|
77
|
+
const updated = { ...existing, ...change };
|
|
78
|
+
if (existing.data && change.data) {
|
|
79
|
+
updated.data = { ...existing.data, ...change.data };
|
|
80
|
+
}
|
|
81
|
+
this.elements.set(change.id, updated);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async deleteElements(ids) {
|
|
85
|
+
this.ensureConnected();
|
|
86
|
+
await this.client.removeElements(ids);
|
|
87
|
+
ids.forEach((id) => this.elements.delete(id));
|
|
88
|
+
}
|
|
89
|
+
async executeNodes(nodeIds) {
|
|
90
|
+
this.ensureConnected();
|
|
91
|
+
await this.client.executeNodes(nodeIds);
|
|
92
|
+
}
|
|
93
|
+
async waitForCompletion(nodeIds, timeout = 300_000) {
|
|
94
|
+
this.ensureConnected();
|
|
95
|
+
try {
|
|
96
|
+
await Promise.all(nodeIds.map((id) => this.client.waitForStatus(id, 3, timeout)));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async waitForStatus(nodeId, status, timeout = 300_000) {
|
|
104
|
+
this.ensureConnected();
|
|
105
|
+
try {
|
|
106
|
+
await this.client.waitForStatus(nodeId, status, timeout);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
disconnect() {
|
|
114
|
+
this.intentionalClose = true;
|
|
115
|
+
this.isConnected = false;
|
|
116
|
+
this.isConnecting = false;
|
|
117
|
+
if (this.client) {
|
|
118
|
+
this.client.disconnect();
|
|
119
|
+
this.client.removeAllListeners();
|
|
120
|
+
this.client = null;
|
|
121
|
+
}
|
|
122
|
+
this.elements.clear();
|
|
123
|
+
}
|
|
124
|
+
ensureConnected() {
|
|
125
|
+
if (!this.client || !this.isConnected) {
|
|
126
|
+
throw new Error('Not connected');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
setupEventListeners() {
|
|
130
|
+
if (!this.client)
|
|
131
|
+
return;
|
|
132
|
+
this.client.on('element:add', (element) => {
|
|
133
|
+
this.elements.set(element.id, element);
|
|
134
|
+
});
|
|
135
|
+
this.client.on('element:change', (element) => {
|
|
136
|
+
this.elements.set(element.id, element);
|
|
137
|
+
});
|
|
138
|
+
this.client.on('element:delete', (id) => {
|
|
139
|
+
this.elements.delete(id);
|
|
140
|
+
});
|
|
141
|
+
this.client.on('close', () => {
|
|
142
|
+
this.isConnected = false;
|
|
143
|
+
if (!this.intentionalClose) {
|
|
144
|
+
void this.attemptReconnect();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
this.client.on('error', () => {
|
|
148
|
+
// error is usually followed by close, but guard against edge cases
|
|
149
|
+
// where close doesn't fire.
|
|
150
|
+
this.isConnected = false;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async attemptReconnect() {
|
|
154
|
+
if (this.intentionalClose || !this.connectionParams) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
158
|
+
this.emit('reconnect:failed');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.reconnectAttempts += 1;
|
|
162
|
+
const delay = Math.min(1_000 * Math.pow(2, this.reconnectAttempts - 1), 30_000);
|
|
163
|
+
void this.trace(`reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${delay}ms`);
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
165
|
+
if (this.intentionalClose)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
// Tear down the old client so connect() doesn't bail on the isConnecting guard.
|
|
169
|
+
if (this.client) {
|
|
170
|
+
this.client.removeAllListeners();
|
|
171
|
+
this.client = null;
|
|
172
|
+
}
|
|
173
|
+
this.isConnected = false;
|
|
174
|
+
this.isConnecting = false;
|
|
175
|
+
await this.connect(this.connectionParams);
|
|
176
|
+
void this.trace('reconnect:success');
|
|
177
|
+
this.emit('reconnect:success');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
void this.trace('reconnect:attempt_failed');
|
|
181
|
+
await this.attemptReconnect();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
trace(event) {
|
|
185
|
+
// Lightweight internal trace — goes to stderr so it doesn't pollute tool output.
|
|
186
|
+
const ts = new Date().toISOString();
|
|
187
|
+
process.stderr.write(`[CanvasSession:${this.sessionKey}] ${ts} ${event}\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { getCanvasWsUrl } from '../api/api-base.js';
|
|
4
|
+
import { writeWsTrace } from './ws-trace.js';
|
|
5
|
+
import { WS_ERROR, isJoinErrorMessage, isJoinSuccessMessage, isPushErrorMessage, isPushSuccessMessage, isSyncPushMessage, } from './types.js';
|
|
6
|
+
export class CanvasWebSocketClient extends EventEmitter {
|
|
7
|
+
config;
|
|
8
|
+
ws = null;
|
|
9
|
+
clientId = 0;
|
|
10
|
+
clock = 0;
|
|
11
|
+
connected = false;
|
|
12
|
+
connecting = false;
|
|
13
|
+
currentApiKey = '';
|
|
14
|
+
currentCanvasId = '';
|
|
15
|
+
elements = new Map();
|
|
16
|
+
canvasConfig = null;
|
|
17
|
+
/** Heartbeat interval handle — sends ping every 30s to keep the connection alive. */
|
|
18
|
+
heartbeatTimer = null;
|
|
19
|
+
/** Fires if we don't receive a pong within 10s of a ping. */
|
|
20
|
+
pongTimeout = null;
|
|
21
|
+
static HEARTBEAT_INTERVAL_MS = 30_000;
|
|
22
|
+
static PONG_TIMEOUT_MS = 10_000;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
super();
|
|
25
|
+
this.config = config;
|
|
26
|
+
}
|
|
27
|
+
buildWebSocketUrl(params) {
|
|
28
|
+
const queryParams = new URLSearchParams();
|
|
29
|
+
queryParams.set('app_key', params.appKey || this.config.appKey);
|
|
30
|
+
queryParams.set('device_id', params.deviceId || this.config.deviceId);
|
|
31
|
+
queryParams.set('time_zone', params.timeZone || this.config.timeZone);
|
|
32
|
+
queryParams.set('oper_system', params.operSystem || this.config.operSystem);
|
|
33
|
+
queryParams.set('country_code', params.countryCode || this.config.countryCode);
|
|
34
|
+
queryParams.set('api_key', params.apiKey || this.config.apiKey);
|
|
35
|
+
queryParams.set('canvas_id', params.canvasId);
|
|
36
|
+
return `${getCanvasWsUrl(this.config)}?${queryParams.toString()}`;
|
|
37
|
+
}
|
|
38
|
+
async connect(params) {
|
|
39
|
+
if (this.connected) {
|
|
40
|
+
this.disconnect();
|
|
41
|
+
}
|
|
42
|
+
this.connecting = true;
|
|
43
|
+
this.currentCanvasId = params.canvasId;
|
|
44
|
+
this.currentApiKey = params.apiKey || this.config.apiKey;
|
|
45
|
+
const wsUrl = this.buildWebSocketUrl(params);
|
|
46
|
+
void this.trace('lifecycle', 'connect:start', {
|
|
47
|
+
canvasId: params.canvasId,
|
|
48
|
+
wsUrl,
|
|
49
|
+
});
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
try {
|
|
52
|
+
this.ws = new WebSocket(wsUrl);
|
|
53
|
+
this.ws.on('open', () => {
|
|
54
|
+
void this.trace('lifecycle', 'socket:open', {
|
|
55
|
+
canvasId: params.canvasId,
|
|
56
|
+
});
|
|
57
|
+
this.send({
|
|
58
|
+
type: 'join',
|
|
59
|
+
join_canvas_id: params.canvasId,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
this.ws.on('message', (data) => {
|
|
63
|
+
try {
|
|
64
|
+
void this.trace('inbound', 'socket:message_raw', {
|
|
65
|
+
raw: data.toString(),
|
|
66
|
+
});
|
|
67
|
+
const message = JSON.parse(data.toString());
|
|
68
|
+
this.handleMessage(message, resolve, reject);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
void this.trace('lifecycle', 'socket:message_parse_error', {
|
|
72
|
+
error: error instanceof Error ? error.message : String(error),
|
|
73
|
+
});
|
|
74
|
+
reject(error instanceof Error
|
|
75
|
+
? error
|
|
76
|
+
: new Error(String(error)));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// Reset pong timeout on every pong frame — connection is alive.
|
|
80
|
+
this.ws.on('pong', () => {
|
|
81
|
+
this.clearPongTimeout();
|
|
82
|
+
});
|
|
83
|
+
this.ws.on('close', () => {
|
|
84
|
+
this.stopHeartbeat();
|
|
85
|
+
this.connected = false;
|
|
86
|
+
this.connecting = false;
|
|
87
|
+
void this.trace('lifecycle', 'socket:close', {
|
|
88
|
+
canvasId: this.currentCanvasId,
|
|
89
|
+
});
|
|
90
|
+
this.emit('close');
|
|
91
|
+
});
|
|
92
|
+
this.ws.on('error', (error) => {
|
|
93
|
+
this.stopHeartbeat();
|
|
94
|
+
this.connected = false;
|
|
95
|
+
this.connecting = false;
|
|
96
|
+
void this.trace('lifecycle', 'socket:error', {
|
|
97
|
+
canvasId: this.currentCanvasId,
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
});
|
|
100
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.connected = false;
|
|
105
|
+
this.connecting = false;
|
|
106
|
+
void this.trace('lifecycle', 'connect:exception', {
|
|
107
|
+
canvasId: params.canvasId,
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
});
|
|
110
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
disconnect() {
|
|
115
|
+
this.stopHeartbeat();
|
|
116
|
+
if (this.ws) {
|
|
117
|
+
this.send({ type: 'leave' });
|
|
118
|
+
this.ws.close();
|
|
119
|
+
this.ws = null;
|
|
120
|
+
}
|
|
121
|
+
void this.trace('lifecycle', 'disconnect', {
|
|
122
|
+
canvasId: this.currentCanvasId,
|
|
123
|
+
});
|
|
124
|
+
this.connected = false;
|
|
125
|
+
this.connecting = false;
|
|
126
|
+
this.clientId = 0;
|
|
127
|
+
this.clock = 0;
|
|
128
|
+
this.currentCanvasId = '';
|
|
129
|
+
this.elements.clear();
|
|
130
|
+
}
|
|
131
|
+
async addElements(elements) {
|
|
132
|
+
await this.push([{ type: 'element:add', elements }]);
|
|
133
|
+
elements.forEach((element) => this.elements.set(element.id, element));
|
|
134
|
+
}
|
|
135
|
+
async changeElements(changes) {
|
|
136
|
+
await this.push([{ type: 'element:change', elements: changes }]);
|
|
137
|
+
changes.forEach((change) => {
|
|
138
|
+
if (!change.id)
|
|
139
|
+
return;
|
|
140
|
+
const existing = this.elements.get(change.id);
|
|
141
|
+
if (!existing)
|
|
142
|
+
return;
|
|
143
|
+
const updated = { ...existing, ...change };
|
|
144
|
+
if (existing.data && change.data) {
|
|
145
|
+
updated.data = { ...existing.data, ...change.data };
|
|
146
|
+
}
|
|
147
|
+
this.elements.set(change.id, updated);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async removeElements(elementIds) {
|
|
151
|
+
await this.push([{ type: 'element:delete', delete_elements: elementIds }]);
|
|
152
|
+
elementIds.forEach((id) => this.elements.delete(id));
|
|
153
|
+
}
|
|
154
|
+
async executeNodes(targetIds, position) {
|
|
155
|
+
if (!this.connected || !this.ws) {
|
|
156
|
+
throw new Error('Not connected to canvas');
|
|
157
|
+
}
|
|
158
|
+
const message = {
|
|
159
|
+
type: 'push',
|
|
160
|
+
order_op_data: {
|
|
161
|
+
batch_targets: targetIds,
|
|
162
|
+
position,
|
|
163
|
+
},
|
|
164
|
+
api_key: this.currentApiKey || this.config.apiKey,
|
|
165
|
+
};
|
|
166
|
+
this.send(message);
|
|
167
|
+
}
|
|
168
|
+
getElement(id) {
|
|
169
|
+
return this.elements.get(id);
|
|
170
|
+
}
|
|
171
|
+
getAllElements() {
|
|
172
|
+
return this.elements;
|
|
173
|
+
}
|
|
174
|
+
getCanvasConfig() {
|
|
175
|
+
return this.canvasConfig;
|
|
176
|
+
}
|
|
177
|
+
getConnectionState() {
|
|
178
|
+
return {
|
|
179
|
+
connected: this.connected,
|
|
180
|
+
connecting: this.connecting,
|
|
181
|
+
clock: this.clock,
|
|
182
|
+
clientId: this.clientId,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
waitForStatus(elementId, targetStatus, timeout = 300_000) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
let settled = false;
|
|
188
|
+
const cleanup = () => {
|
|
189
|
+
this.removeListener('element:change', onElementChange);
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
};
|
|
192
|
+
const onElementChange = (element) => {
|
|
193
|
+
if (settled || element.id !== elementId)
|
|
194
|
+
return;
|
|
195
|
+
checkStatus();
|
|
196
|
+
};
|
|
197
|
+
const checkStatus = () => {
|
|
198
|
+
const element = this.elements.get(elementId);
|
|
199
|
+
if (!element)
|
|
200
|
+
return;
|
|
201
|
+
const currentStatus = element.data.status;
|
|
202
|
+
if (currentStatus === targetStatus) {
|
|
203
|
+
settled = true;
|
|
204
|
+
cleanup();
|
|
205
|
+
resolve(element);
|
|
206
|
+
}
|
|
207
|
+
else if (currentStatus === 400) {
|
|
208
|
+
settled = true;
|
|
209
|
+
cleanup();
|
|
210
|
+
reject(new Error(`Element ${elementId} execution failed (status 400)`));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
// Check synchronously first — element may already be in the target state.
|
|
214
|
+
checkStatus();
|
|
215
|
+
if (settled)
|
|
216
|
+
return;
|
|
217
|
+
this.on('element:change', onElementChange);
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
if (settled)
|
|
220
|
+
return;
|
|
221
|
+
settled = true;
|
|
222
|
+
cleanup();
|
|
223
|
+
reject(new Error(`Timeout waiting for element ${elementId} status ${targetStatus}`));
|
|
224
|
+
}, timeout);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
send(message) {
|
|
228
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
229
|
+
void this.trace('lifecycle', 'send:skipped_not_open', {
|
|
230
|
+
canvasId: this.currentCanvasId,
|
|
231
|
+
message,
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
void this.trace('outbound', `send:${message.type}`, {
|
|
236
|
+
canvasId: this.currentCanvasId,
|
|
237
|
+
message,
|
|
238
|
+
});
|
|
239
|
+
this.ws.send(JSON.stringify(message));
|
|
240
|
+
}
|
|
241
|
+
async push(operations, retries = 3) {
|
|
242
|
+
if (!this.connected || !this.ws) {
|
|
243
|
+
throw new Error('Not connected to canvas');
|
|
244
|
+
}
|
|
245
|
+
this.clock += 1;
|
|
246
|
+
const message = {
|
|
247
|
+
type: 'push',
|
|
248
|
+
push_op_data: {
|
|
249
|
+
clock: this.clock,
|
|
250
|
+
op_infos: operations,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
void this.trace('outbound', 'push:operations', {
|
|
254
|
+
canvasId: this.currentCanvasId,
|
|
255
|
+
operations,
|
|
256
|
+
clock: this.clock,
|
|
257
|
+
retries,
|
|
258
|
+
});
|
|
259
|
+
const attempt = (attemptsLeft) => new Promise((resolve, reject) => {
|
|
260
|
+
const onMessage = (msg) => {
|
|
261
|
+
if (isPushSuccessMessage(msg)) {
|
|
262
|
+
void this.trace('inbound', 'push:success', {
|
|
263
|
+
canvasId: this.currentCanvasId,
|
|
264
|
+
message: msg,
|
|
265
|
+
});
|
|
266
|
+
this.clock = msg.clock;
|
|
267
|
+
this.removeListener('message', onMessage);
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
resolve();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (isPushErrorMessage(msg)) {
|
|
273
|
+
void this.trace('inbound', 'push:error', {
|
|
274
|
+
canvasId: this.currentCanvasId,
|
|
275
|
+
message: msg,
|
|
276
|
+
attemptsLeft,
|
|
277
|
+
});
|
|
278
|
+
this.removeListener('message', onMessage);
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
const error = new Error(msg.err_message || 'Push error');
|
|
281
|
+
if (attemptsLeft > 1) {
|
|
282
|
+
setTimeout(() => attempt(attemptsLeft - 1).then(resolve, reject), (4 - attemptsLeft) * 2_000);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
reject(error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
this.on('message', onMessage);
|
|
290
|
+
this.send(message);
|
|
291
|
+
const timer = setTimeout(() => {
|
|
292
|
+
this.removeListener('message', onMessage);
|
|
293
|
+
void this.trace('lifecycle', 'push:timeout', {
|
|
294
|
+
canvasId: this.currentCanvasId,
|
|
295
|
+
attemptsLeft,
|
|
296
|
+
clock: this.clock,
|
|
297
|
+
});
|
|
298
|
+
if (attemptsLeft > 1) {
|
|
299
|
+
setTimeout(() => attempt(attemptsLeft - 1).then(resolve, reject), (4 - attemptsLeft) * 2_000);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
reject(new Error('Push timeout'));
|
|
303
|
+
}
|
|
304
|
+
}, this.config.requestTimeoutMs);
|
|
305
|
+
});
|
|
306
|
+
return attempt(retries);
|
|
307
|
+
}
|
|
308
|
+
handleMessage(message, joinResolve, joinReject) {
|
|
309
|
+
void this.trace('inbound', `message:${message.type}`, {
|
|
310
|
+
canvasId: this.currentCanvasId,
|
|
311
|
+
message,
|
|
312
|
+
});
|
|
313
|
+
this.emit('message', message);
|
|
314
|
+
if (isJoinSuccessMessage(message)) {
|
|
315
|
+
this.connected = true;
|
|
316
|
+
this.connecting = false;
|
|
317
|
+
this.clientId = message.client_id;
|
|
318
|
+
this.clock = message.canvas_doc.clock;
|
|
319
|
+
this.elements.clear();
|
|
320
|
+
Object.entries(message.canvas_doc.elements).forEach(([id, element]) => {
|
|
321
|
+
this.elements.set(id, element);
|
|
322
|
+
});
|
|
323
|
+
if (message.canvas_config) {
|
|
324
|
+
this.canvasConfig = message.canvas_config;
|
|
325
|
+
}
|
|
326
|
+
else if (message.canvas_config) {
|
|
327
|
+
this.canvasConfig = message.canvas_config;
|
|
328
|
+
}
|
|
329
|
+
void this.trace('lifecycle', 'join:success', {
|
|
330
|
+
canvasId: this.currentCanvasId,
|
|
331
|
+
clientId: this.clientId,
|
|
332
|
+
clock: this.clock,
|
|
333
|
+
elementCount: this.elements.size,
|
|
334
|
+
hasCanvasConfig: this.canvasConfig !== null,
|
|
335
|
+
});
|
|
336
|
+
this.startHeartbeat();
|
|
337
|
+
joinResolve?.(message.canvas_doc);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (isJoinErrorMessage(message)) {
|
|
341
|
+
this.connecting = false;
|
|
342
|
+
void this.trace('lifecycle', 'join:error', {
|
|
343
|
+
canvasId: this.currentCanvasId,
|
|
344
|
+
message,
|
|
345
|
+
});
|
|
346
|
+
joinReject?.(new Error(this.getErrorMessage(message.err_code)));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (isSyncPushMessage(message)) {
|
|
350
|
+
this.handleSyncPush(message.ops_data);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
handleSyncPush(opsData) {
|
|
354
|
+
void this.trace('inbound', 'sync_push:ops', {
|
|
355
|
+
canvasId: this.currentCanvasId,
|
|
356
|
+
opsData,
|
|
357
|
+
});
|
|
358
|
+
for (const ops of opsData) {
|
|
359
|
+
if (ops.client_id === this.clientId)
|
|
360
|
+
continue;
|
|
361
|
+
for (const op of ops.op_infos) {
|
|
362
|
+
if (op.type === 'element:add') {
|
|
363
|
+
op.elements.forEach((element) => {
|
|
364
|
+
this.elements.set(element.id, element);
|
|
365
|
+
this.emit('element:add', element);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (op.type === 'element:change') {
|
|
369
|
+
op.elements.forEach((change) => {
|
|
370
|
+
if (!change.id)
|
|
371
|
+
return;
|
|
372
|
+
const existing = this.elements.get(change.id);
|
|
373
|
+
if (!existing)
|
|
374
|
+
return;
|
|
375
|
+
const updated = { ...existing, ...change };
|
|
376
|
+
if (existing.data && change.data) {
|
|
377
|
+
updated.data = { ...existing.data, ...change.data };
|
|
378
|
+
}
|
|
379
|
+
this.elements.set(change.id, updated);
|
|
380
|
+
this.emit('element:change', updated);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (op.type === 'element:delete') {
|
|
384
|
+
op.delete_elements.forEach((id) => {
|
|
385
|
+
this.elements.delete(id);
|
|
386
|
+
this.emit('element:delete', id);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (ops.clock > this.clock) {
|
|
391
|
+
this.clock = ops.clock;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Heartbeat — keeps the WebSocket alive by sending periodic pings.
|
|
397
|
+
// If the server doesn't respond with a pong within PONG_TIMEOUT_MS we
|
|
398
|
+
// consider the connection dead and force-close so the reconnect logic kicks in.
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
startHeartbeat() {
|
|
401
|
+
this.stopHeartbeat();
|
|
402
|
+
this.heartbeatTimer = setInterval(() => {
|
|
403
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
404
|
+
return;
|
|
405
|
+
this.ws.ping();
|
|
406
|
+
void this.trace('lifecycle', 'heartbeat:ping', {
|
|
407
|
+
canvasId: this.currentCanvasId,
|
|
408
|
+
});
|
|
409
|
+
// If no pong comes back in time, terminate the socket so 'close' fires.
|
|
410
|
+
this.pongTimeout = setTimeout(() => {
|
|
411
|
+
void this.trace('lifecycle', 'heartbeat:pong_timeout', {
|
|
412
|
+
canvasId: this.currentCanvasId,
|
|
413
|
+
});
|
|
414
|
+
this.ws?.terminate();
|
|
415
|
+
}, CanvasWebSocketClient.PONG_TIMEOUT_MS);
|
|
416
|
+
}, CanvasWebSocketClient.HEARTBEAT_INTERVAL_MS);
|
|
417
|
+
}
|
|
418
|
+
stopHeartbeat() {
|
|
419
|
+
if (this.heartbeatTimer) {
|
|
420
|
+
clearInterval(this.heartbeatTimer);
|
|
421
|
+
this.heartbeatTimer = null;
|
|
422
|
+
}
|
|
423
|
+
this.clearPongTimeout();
|
|
424
|
+
}
|
|
425
|
+
clearPongTimeout() {
|
|
426
|
+
if (this.pongTimeout) {
|
|
427
|
+
clearTimeout(this.pongTimeout);
|
|
428
|
+
this.pongTimeout = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async trace(direction, event, payload) {
|
|
432
|
+
await writeWsTrace({
|
|
433
|
+
canvasId: this.currentCanvasId || undefined,
|
|
434
|
+
direction,
|
|
435
|
+
event,
|
|
436
|
+
payload,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
getErrorMessage(code) {
|
|
440
|
+
switch (code) {
|
|
441
|
+
case WS_ERROR.DOCUMENT_NOT_EXISTS:
|
|
442
|
+
return 'Canvas not found';
|
|
443
|
+
case WS_ERROR.DOCUMENT_NOT_AUTHORIZED:
|
|
444
|
+
return 'Not authorized to access canvas';
|
|
445
|
+
case WS_ERROR.DOCUMENT_IS_DELETED:
|
|
446
|
+
return 'Canvas has been deleted';
|
|
447
|
+
case WS_ERROR.AUTHENTICATION_FAILED:
|
|
448
|
+
return 'Authentication failed';
|
|
449
|
+
default:
|
|
450
|
+
return `Unknown error: ${code}`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a new Artflo canvas via the REST API.
|
|
3
|
+
*
|
|
4
|
+
* Derives the HTTP API base URL from the configured WebSocket URL:
|
|
5
|
+
* wss://prewebapi.artflo.ai/canvas/ws → https://prewebapi.artflo.ai
|
|
6
|
+
* wss://webapi.artflo.ai/canvas/ws → https://webapi.artflo.ai
|
|
7
|
+
*/
|
|
8
|
+
import { getApiBaseUrl } from '../api/api-base.js';
|
|
9
|
+
export async function createCanvas(config, name = 'Untitled') {
|
|
10
|
+
const baseUrl = getApiBaseUrl(config);
|
|
11
|
+
const response = await fetch(`${baseUrl}/canvas`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
'X-API-Key': config.apiKey,
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ name, belong_type: 0 }),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`Create canvas failed: HTTP ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
const body = (await response.json());
|
|
23
|
+
if (body.code !== 0 || !body.data?.id) {
|
|
24
|
+
throw new Error(`Create canvas failed: code=${body.code}`);
|
|
25
|
+
}
|
|
26
|
+
const canvasId = body.data.id;
|
|
27
|
+
// Derive project URL from API host: prewebapi → pre.artflo.ai, webapi → artflo.ai
|
|
28
|
+
const hostname = new URL(baseUrl).hostname;
|
|
29
|
+
const projectHost = hostname.startsWith('prewebapi')
|
|
30
|
+
? 'pre.artflo.ai'
|
|
31
|
+
: 'artflo.ai';
|
|
32
|
+
return {
|
|
33
|
+
id: canvasId,
|
|
34
|
+
name: body.data.name ?? name,
|
|
35
|
+
url: `https://${projectHost}/project/${canvasId}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function isJoinSuccessMessage(msg) {
|
|
2
|
+
return msg.type === 'join:success';
|
|
3
|
+
}
|
|
4
|
+
export function isJoinErrorMessage(msg) {
|
|
5
|
+
return msg.type === 'join:error';
|
|
6
|
+
}
|
|
7
|
+
export function isPushSuccessMessage(msg) {
|
|
8
|
+
return msg.type === 'push:success';
|
|
9
|
+
}
|
|
10
|
+
export function isPushErrorMessage(msg) {
|
|
11
|
+
return msg.type === 'push:error';
|
|
12
|
+
}
|
|
13
|
+
export function isSyncPushMessage(msg) {
|
|
14
|
+
return msg.type === 'sync_push:success';
|
|
15
|
+
}
|
|
16
|
+
export const WS_ERROR = {
|
|
17
|
+
DOCUMENT_NOT_EXISTS: 12001,
|
|
18
|
+
CLIENT_NOT_EXISTS: 12002,
|
|
19
|
+
DOCUMENT_NOT_AUTHORIZED: 12004,
|
|
20
|
+
DOCUMENT_IS_DELETED: 12005,
|
|
21
|
+
SYSTEM_ERROR: 11002,
|
|
22
|
+
AUTHENTICATION_FAILED: 20000,
|
|
23
|
+
};
|