@rool-dev/sdk 0.9.0-dev.bcd88e4 → 0.9.0-dev.c1da33d
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 +328 -159
- package/dist/channel.d.ts +95 -184
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +363 -349
- package/dist/channel.js.map +1 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +57 -57
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +35 -19
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +112 -128
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/locations.d.ts +34 -0
- package/dist/locations.d.ts.map +1 -0
- package/dist/locations.js +90 -0
- package/dist/locations.js.map +1 -0
- package/dist/rest.d.ts +18 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +46 -0
- package/dist/rest.js.map +1 -0
- package/dist/space.d.ts +11 -4
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +25 -45
- package/dist/space.js.map +1 -1
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +9 -12
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +62 -56
- package/dist/types.d.ts.map +1 -1
- package/dist/webdav.d.ts +159 -0
- package/dist/webdav.d.ts.map +1 -0
- package/dist/webdav.js +483 -0
- package/dist/webdav.js.map +1 -0
- package/package.json +1 -1
- package/dist/media.d.ts +0 -70
- package/dist/media.d.ts.map +0 -1
- package/dist/media.js +0 -228
- package/dist/media.js.map +0 -1
package/dist/channel.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from './event-emitter.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { generateBasename, loc, normalizeLocation, parseLocation } from './locations.js';
|
|
3
|
+
// 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
|
|
4
|
+
const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
4
5
|
export function generateEntityId() {
|
|
5
6
|
let result = '';
|
|
6
7
|
for (let i = 0; i < 6; i++) {
|
|
7
|
-
result +=
|
|
8
|
+
result += ENTITY_CHARS[Math.floor(Math.random() * ENTITY_CHARS.length)];
|
|
8
9
|
}
|
|
9
10
|
return result;
|
|
10
11
|
}
|
|
@@ -40,6 +41,80 @@ function findDefaultLeaf(interactions) {
|
|
|
40
41
|
}
|
|
41
42
|
return best?.id;
|
|
42
43
|
}
|
|
44
|
+
function attachmentBody(file, index) {
|
|
45
|
+
if (isFile(file)) {
|
|
46
|
+
return {
|
|
47
|
+
filename: safeAttachmentFilename(file.name, index, file.type),
|
|
48
|
+
contentType: file.type || 'application/octet-stream',
|
|
49
|
+
body: file,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (isBlob(file)) {
|
|
53
|
+
const contentType = file.type || 'application/octet-stream';
|
|
54
|
+
return {
|
|
55
|
+
filename: safeAttachmentFilename(`attachment-${index + 1}`, index, contentType),
|
|
56
|
+
contentType,
|
|
57
|
+
body: file,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
filename: safeAttachmentFilename(`attachment-${index + 1}`, index, file.contentType),
|
|
62
|
+
contentType: file.contentType,
|
|
63
|
+
body: base64Body(file.data),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isFile(value) {
|
|
67
|
+
return typeof File !== 'undefined' && value instanceof File;
|
|
68
|
+
}
|
|
69
|
+
function isBlob(value) {
|
|
70
|
+
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
71
|
+
}
|
|
72
|
+
function safeAttachmentFilename(name, index, contentType) {
|
|
73
|
+
const fallback = `attachment.${extensionForContentType(contentType)}`;
|
|
74
|
+
const leaf = name.split(/[/\\]/).pop() || fallback;
|
|
75
|
+
const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
|
|
76
|
+
const safe = cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
|
|
77
|
+
return `${index + 1}-${safe}`;
|
|
78
|
+
}
|
|
79
|
+
function extensionForContentType(contentType) {
|
|
80
|
+
if (contentType === 'image/png')
|
|
81
|
+
return 'png';
|
|
82
|
+
if (contentType === 'image/jpeg')
|
|
83
|
+
return 'jpg';
|
|
84
|
+
if (contentType === 'image/gif')
|
|
85
|
+
return 'gif';
|
|
86
|
+
if (contentType === 'image/webp')
|
|
87
|
+
return 'webp';
|
|
88
|
+
if (contentType === 'image/svg+xml')
|
|
89
|
+
return 'svg';
|
|
90
|
+
if (contentType === 'application/pdf')
|
|
91
|
+
return 'pdf';
|
|
92
|
+
if (contentType === 'text/markdown')
|
|
93
|
+
return 'md';
|
|
94
|
+
if (contentType === 'text/plain')
|
|
95
|
+
return 'txt';
|
|
96
|
+
if (contentType === 'text/csv')
|
|
97
|
+
return 'csv';
|
|
98
|
+
if (contentType === 'text/html')
|
|
99
|
+
return 'html';
|
|
100
|
+
if (contentType === 'application/json')
|
|
101
|
+
return 'json';
|
|
102
|
+
if (contentType === 'application/xml')
|
|
103
|
+
return 'xml';
|
|
104
|
+
return 'bin';
|
|
105
|
+
}
|
|
106
|
+
function base64Body(data) {
|
|
107
|
+
const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
|
|
108
|
+
if (typeof Buffer !== 'undefined') {
|
|
109
|
+
const buffer = Buffer.from(clean, 'base64');
|
|
110
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
111
|
+
}
|
|
112
|
+
const binary = atob(clean);
|
|
113
|
+
const bytes = new Uint8Array(binary.length);
|
|
114
|
+
for (let i = 0; i < binary.length; i++)
|
|
115
|
+
bytes[i] = binary.charCodeAt(i);
|
|
116
|
+
return bytes.buffer;
|
|
117
|
+
}
|
|
43
118
|
// Default timeout for waiting on SSE object events (30 seconds)
|
|
44
119
|
const OBJECT_COLLECT_TIMEOUT = 30000;
|
|
45
120
|
/**
|
|
@@ -49,16 +124,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
|
|
|
49
124
|
* at open time and cannot be changed. To use a different channel,
|
|
50
125
|
* open a second one.
|
|
51
126
|
*
|
|
52
|
-
* Objects are
|
|
53
|
-
*
|
|
127
|
+
* Objects are addressed by location (`/space/<collection>/<basename>.json`).
|
|
128
|
+
* Only schema, metadata, the live object location list, and the channel's own
|
|
129
|
+
* history are cached locally. Object bodies are fetched on demand. Changes
|
|
54
130
|
* arrive via SSE semantic events and are emitted as SDK events.
|
|
55
|
-
*
|
|
56
|
-
* Features:
|
|
57
|
-
* - High-level object operations
|
|
58
|
-
* - Built-in undo/redo with checkpoints
|
|
59
|
-
* - Metadata management
|
|
60
|
-
* - Event emission for state changes
|
|
61
|
-
* - Real-time updates via space-specific subscription
|
|
62
131
|
*/
|
|
63
132
|
export class RoolChannel extends EventEmitter {
|
|
64
133
|
_id;
|
|
@@ -70,23 +139,24 @@ export class RoolChannel extends EventEmitter {
|
|
|
70
139
|
_conversationId;
|
|
71
140
|
_closed = false;
|
|
72
141
|
graphqlClient;
|
|
73
|
-
|
|
142
|
+
restClient;
|
|
143
|
+
webdav;
|
|
74
144
|
onCloseCallback;
|
|
75
145
|
logger;
|
|
76
|
-
// Local cache for bounded data (schema, metadata, own channel, object
|
|
146
|
+
// Local cache for bounded data (schema, metadata, own channel, object locations, stats)
|
|
77
147
|
_meta;
|
|
78
148
|
_schema;
|
|
79
149
|
_channel;
|
|
80
|
-
|
|
150
|
+
_objectLocations;
|
|
81
151
|
_objectStats;
|
|
82
152
|
// Active leaf per conversation (client-side tree cursor)
|
|
83
153
|
_activeLeaves = new Map();
|
|
84
|
-
// Object collection: tracks pending local mutations for dedup
|
|
85
|
-
// Maps
|
|
154
|
+
// Object collection: tracks pending local mutations (by location) for dedup
|
|
155
|
+
// Maps location → optimistic object (for create/update) or null (for delete)
|
|
86
156
|
_pendingMutations = new Map();
|
|
87
|
-
// Resolvers waiting for object data from SSE events
|
|
157
|
+
// Resolvers waiting for object data from SSE events, keyed by location
|
|
88
158
|
_objectResolvers = new Map();
|
|
89
|
-
// Buffer for object data that arrived before a collector was registered
|
|
159
|
+
// Buffer for object data that arrived before a collector was registered, keyed by location
|
|
90
160
|
_objectBuffer = new Map();
|
|
91
161
|
constructor(config) {
|
|
92
162
|
super();
|
|
@@ -99,14 +169,15 @@ export class RoolChannel extends EventEmitter {
|
|
|
99
169
|
this._channelId = config.channelId;
|
|
100
170
|
this._conversationId = 'default';
|
|
101
171
|
this.graphqlClient = config.graphqlClient;
|
|
102
|
-
this.
|
|
172
|
+
this.restClient = config.restClient;
|
|
173
|
+
this.webdav = config.webdav;
|
|
103
174
|
this.logger = config.logger;
|
|
104
175
|
this.onCloseCallback = config.onClose;
|
|
105
176
|
// Initialize local cache from server data
|
|
106
177
|
this._meta = config.meta;
|
|
107
178
|
this._schema = config.schema;
|
|
108
179
|
this._channel = config.channel;
|
|
109
|
-
this.
|
|
180
|
+
this._objectLocations = config.objectLocations;
|
|
110
181
|
this._objectStats = new Map(Object.entries(config.objectStats));
|
|
111
182
|
}
|
|
112
183
|
/**
|
|
@@ -127,7 +198,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
127
198
|
return;
|
|
128
199
|
this._meta = data.meta;
|
|
129
200
|
this._schema = data.schema;
|
|
130
|
-
this.
|
|
201
|
+
this._objectLocations = data.objectLocations;
|
|
131
202
|
this._objectStats = new Map(Object.entries(data.objectStats));
|
|
132
203
|
if (data.channel)
|
|
133
204
|
this._channel = data.channel;
|
|
@@ -294,10 +365,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
294
365
|
}
|
|
295
366
|
/**
|
|
296
367
|
* Get a handle for a specific conversation within this channel.
|
|
297
|
-
* The handle scopes AI and mutation operations to that conversation's
|
|
298
|
-
* interaction history, while sharing the channel's single SSE connection.
|
|
299
|
-
*
|
|
300
|
-
* Conversations are auto-created on first interaction — no explicit create needed.
|
|
301
368
|
*/
|
|
302
369
|
conversation(conversationId) {
|
|
303
370
|
return new ConversationHandle(this, conversationId);
|
|
@@ -317,138 +384,113 @@ export class RoolChannel extends EventEmitter {
|
|
|
317
384
|
}
|
|
318
385
|
/**
|
|
319
386
|
* Create a checkpoint of the current space state.
|
|
320
|
-
* Checkpoints are space-wide and shared across channels and users.
|
|
321
|
-
* @returns The checkpoint ID
|
|
322
387
|
*/
|
|
323
388
|
async checkpoint(label = 'Change') {
|
|
324
389
|
const result = await this.graphqlClient.checkpoint(this._id, label, this._channelId);
|
|
325
390
|
return result.checkpointId;
|
|
326
391
|
}
|
|
327
|
-
/**
|
|
328
|
-
* Check if undo is available for this space.
|
|
329
|
-
*/
|
|
392
|
+
/** Check if undo is available for this space. */
|
|
330
393
|
async canUndo() {
|
|
331
394
|
const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
|
|
332
395
|
return status.canUndo;
|
|
333
396
|
}
|
|
334
|
-
/**
|
|
335
|
-
* Check if redo is available for this space.
|
|
336
|
-
*/
|
|
397
|
+
/** Check if redo is available for this space. */
|
|
337
398
|
async canRedo() {
|
|
338
399
|
const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
|
|
339
400
|
return status.canRedo;
|
|
340
401
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Restore the space to the most recent checkpoint.
|
|
343
|
-
* @returns true if undo was performed
|
|
344
|
-
*/
|
|
402
|
+
/** Restore the space to the most recent checkpoint. */
|
|
345
403
|
async undo() {
|
|
346
404
|
const result = await this.graphqlClient.undo(this._id, this._channelId);
|
|
347
405
|
return result.success;
|
|
348
406
|
}
|
|
349
|
-
/**
|
|
350
|
-
* Reapply the most recently undone checkpoint.
|
|
351
|
-
* Affects the entire space.
|
|
352
|
-
* @returns true if redo was performed
|
|
353
|
-
*/
|
|
407
|
+
/** Reapply the most recently undone checkpoint. */
|
|
354
408
|
async redo() {
|
|
355
409
|
const result = await this.graphqlClient.redo(this._id, this._channelId);
|
|
356
410
|
return result.success;
|
|
357
411
|
}
|
|
358
|
-
/**
|
|
359
|
-
* Clear the space's checkpoint history.
|
|
360
|
-
*/
|
|
412
|
+
/** Clear the space's checkpoint history. */
|
|
361
413
|
async clearHistory() {
|
|
362
414
|
await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
|
|
363
415
|
}
|
|
364
416
|
/**
|
|
365
|
-
* Get an object
|
|
366
|
-
*
|
|
417
|
+
* Get an object by location. Fetches from the server on each call.
|
|
418
|
+
*
|
|
419
|
+
* Accepts either the canonical form (`/space/<collection>/<basename>.json`)
|
|
420
|
+
* or the short form (`<collection>/<basename>`).
|
|
367
421
|
*/
|
|
368
|
-
async getObject(
|
|
369
|
-
return this.graphqlClient.getObject(this._id,
|
|
422
|
+
async getObject(location) {
|
|
423
|
+
return this.graphqlClient.getObject(this._id, normalizeLocation(location));
|
|
370
424
|
}
|
|
371
425
|
/**
|
|
372
426
|
* Get an object's stat (audit information).
|
|
373
|
-
* Returns
|
|
427
|
+
* Returns the cached stat or undefined if not known.
|
|
374
428
|
*/
|
|
375
|
-
stat(
|
|
376
|
-
return this._objectStats.get(
|
|
429
|
+
stat(location) {
|
|
430
|
+
return this._objectStats.get(normalizeLocation(location));
|
|
377
431
|
}
|
|
378
432
|
/**
|
|
379
433
|
* Find objects using structured filters and/or natural language.
|
|
380
|
-
*
|
|
381
|
-
* `where` provides exact-match filtering — values must match literally (no placeholders or operators).
|
|
382
|
-
* `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
|
|
383
|
-
* constrain the data set before the AI sees it.
|
|
384
|
-
*
|
|
385
|
-
* @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
|
|
386
|
-
* @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
|
|
387
|
-
* @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
|
|
388
|
-
* @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
|
|
389
|
-
* @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
|
|
390
|
-
* @param options.ephemeral - If true, the query won't be recorded in interaction history.
|
|
391
|
-
* @returns The matching objects and a descriptive message.
|
|
392
434
|
*/
|
|
393
435
|
async findObjects(options) {
|
|
394
436
|
return this._findObjectsImpl(options, this._conversationId);
|
|
395
437
|
}
|
|
396
438
|
/** @internal */
|
|
397
439
|
_findObjectsImpl(options, conversationId) {
|
|
398
|
-
|
|
440
|
+
const normalized = {
|
|
441
|
+
...options,
|
|
442
|
+
locations: options.locations?.map(normalizeLocation),
|
|
443
|
+
};
|
|
444
|
+
return this.graphqlClient.findObjects(this._id, normalized, this._channelId, conversationId);
|
|
399
445
|
}
|
|
400
446
|
/**
|
|
401
|
-
* Get all object
|
|
447
|
+
* Get all object locations (sync, from local cache).
|
|
402
448
|
* The list is loaded on open and kept current via SSE events.
|
|
403
|
-
* @param options.limit - Maximum number of IDs to return
|
|
404
|
-
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
405
449
|
*/
|
|
406
|
-
|
|
407
|
-
let
|
|
450
|
+
getObjectLocations(options) {
|
|
451
|
+
let locs = this._objectLocations;
|
|
408
452
|
if (options?.order === 'asc') {
|
|
409
|
-
|
|
453
|
+
locs = [...locs].reverse();
|
|
410
454
|
}
|
|
411
455
|
if (options?.limit !== undefined) {
|
|
412
|
-
|
|
456
|
+
locs = locs.slice(0, options.limit);
|
|
413
457
|
}
|
|
414
|
-
return
|
|
458
|
+
return locs;
|
|
415
459
|
}
|
|
416
460
|
/**
|
|
417
|
-
* Create a new object
|
|
418
|
-
*
|
|
461
|
+
* Create a new object in the given collection.
|
|
462
|
+
*
|
|
463
|
+
* @param collection - The collection (must exist in the schema)
|
|
464
|
+
* @param body - Object body fields. Use `{{placeholder}}` for AI-generated content.
|
|
465
|
+
* Fields prefixed with `_` are hidden from AI. Must not contain
|
|
466
|
+
* `id` or `type` (identity lives on the location).
|
|
467
|
+
* @param options.basename - Specific basename to use. If omitted, the SDK generates a random one.
|
|
419
468
|
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
420
|
-
* @returns The created object
|
|
469
|
+
* @returns The created object and a status message.
|
|
421
470
|
*/
|
|
422
|
-
async createObject(options) {
|
|
423
|
-
return this._createObjectImpl(options, this._conversationId);
|
|
471
|
+
async createObject(collection, body, options) {
|
|
472
|
+
return this._createObjectImpl(collection, body, options, this._conversationId);
|
|
424
473
|
}
|
|
425
474
|
/** @internal */
|
|
426
|
-
async _createObjectImpl(options, conversationId) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
|
|
430
|
-
// Validate ID format: alphanumeric, hyphens, underscores only
|
|
431
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
|
|
432
|
-
throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
|
|
475
|
+
async _createObjectImpl(collection, body, options, conversationId) {
|
|
476
|
+
if ('id' in body || 'type' in body) {
|
|
477
|
+
throw new Error('createObject: body must not contain `id` or `type` — identity is the location.');
|
|
433
478
|
}
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
this.
|
|
479
|
+
const basename = options?.basename ?? generateBasename();
|
|
480
|
+
const location = loc(collection, basename);
|
|
481
|
+
const optimistic = { location, collection, basename, body };
|
|
482
|
+
this._pendingMutations.set(location, optimistic);
|
|
483
|
+
this.emit('objectCreated', { location, object: optimistic, source: 'local_user' });
|
|
438
484
|
try {
|
|
439
|
-
// Await mutation — server processes AI placeholders before responding.
|
|
440
|
-
// SSE events arrive during the await and are buffered via _deliverObject.
|
|
441
485
|
const interactionId = generateEntityId();
|
|
442
|
-
const { message } = await this.graphqlClient.createObject(this.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return { object, message };
|
|
486
|
+
const { message, object } = await this.graphqlClient.createObject(this._id, location, body, this._channelId, conversationId, interactionId, { ephemeral: options?.ephemeral, parentInteractionId: options?.parentInteractionId });
|
|
487
|
+
const fresh = object ?? await this._collectObject(location);
|
|
488
|
+
return { object: fresh, message };
|
|
446
489
|
}
|
|
447
490
|
catch (error) {
|
|
448
491
|
this.logger.error('[RoolChannel] Failed to create object:', error);
|
|
449
|
-
this._pendingMutations.delete(
|
|
450
|
-
this._cancelCollector(
|
|
451
|
-
// Emit reset so UI can recover from the optimistic event
|
|
492
|
+
this._pendingMutations.delete(location);
|
|
493
|
+
this._cancelCollector(location);
|
|
452
494
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
453
495
|
this.emit('reset', { source: 'system' });
|
|
454
496
|
throw error;
|
|
@@ -456,98 +498,141 @@ export class RoolChannel extends EventEmitter {
|
|
|
456
498
|
}
|
|
457
499
|
/**
|
|
458
500
|
* Update an existing object.
|
|
459
|
-
*
|
|
460
|
-
* @param
|
|
461
|
-
* @param options.
|
|
501
|
+
*
|
|
502
|
+
* @param location - The object's location (canonical or short form)
|
|
503
|
+
* @param options.data - Fields to add or update. Pass `null` to delete a field. Use `{{placeholder}}` for AI-generated content.
|
|
504
|
+
* @param options.prompt - AI prompt to drive the update.
|
|
462
505
|
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
463
|
-
* @returns The updated object (with AI-filled content) and message
|
|
464
506
|
*/
|
|
465
|
-
async updateObject(
|
|
466
|
-
return this._updateObjectImpl(
|
|
507
|
+
async updateObject(location, options) {
|
|
508
|
+
return this._updateObjectImpl(location, options, this._conversationId);
|
|
467
509
|
}
|
|
468
510
|
/** @internal */
|
|
469
|
-
async _updateObjectImpl(
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
if (data
|
|
473
|
-
throw new Error('
|
|
511
|
+
async _updateObjectImpl(location, options, conversationId) {
|
|
512
|
+
const canonical = normalizeLocation(location);
|
|
513
|
+
const { data } = options;
|
|
514
|
+
if (data && ('id' in data || 'type' in data)) {
|
|
515
|
+
throw new Error('updateObject: data must not contain `id` or `type`. Use moveObject to change identity.');
|
|
474
516
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
// Normalize undefined to null (for JSON serialization) and build server data
|
|
479
|
-
let serverData;
|
|
517
|
+
// Normalize undefined to null (for JSON serialization) and build server patch
|
|
518
|
+
let serverPatch;
|
|
480
519
|
if (data) {
|
|
481
|
-
|
|
520
|
+
serverPatch = {};
|
|
482
521
|
for (const [key, value] of Object.entries(data)) {
|
|
483
|
-
|
|
484
|
-
serverData[key] = value === undefined ? null : value;
|
|
522
|
+
serverPatch[key] = value === undefined ? null : value;
|
|
485
523
|
}
|
|
486
524
|
}
|
|
487
525
|
// Emit optimistic event if we have data changes
|
|
488
526
|
if (data) {
|
|
489
|
-
|
|
490
|
-
const optimistic = {
|
|
491
|
-
this._pendingMutations.set(
|
|
492
|
-
this.emit('objectUpdated', {
|
|
527
|
+
const { collection, basename } = parseLocation(canonical);
|
|
528
|
+
const optimistic = { location: canonical, collection, basename, body: data };
|
|
529
|
+
this._pendingMutations.set(canonical, optimistic);
|
|
530
|
+
this.emit('objectUpdated', { location: canonical, object: optimistic, source: 'local_user' });
|
|
493
531
|
}
|
|
494
532
|
try {
|
|
495
533
|
const interactionId = generateEntityId();
|
|
496
|
-
const { message } = await this.graphqlClient.updateObject(this.
|
|
497
|
-
|
|
498
|
-
|
|
534
|
+
const { message, object } = await this.graphqlClient.updateObject(this._id, canonical, this._channelId, conversationId, interactionId, {
|
|
535
|
+
patch: serverPatch,
|
|
536
|
+
prompt: options.prompt,
|
|
537
|
+
ephemeral: options.ephemeral,
|
|
538
|
+
parentInteractionId: options.parentInteractionId,
|
|
539
|
+
});
|
|
540
|
+
const fresh = object ?? await this._collectObject(canonical);
|
|
541
|
+
return { object: fresh, message };
|
|
499
542
|
}
|
|
500
543
|
catch (error) {
|
|
501
544
|
this.logger.error('[RoolChannel] Failed to update object:', error);
|
|
502
|
-
this._pendingMutations.delete(
|
|
503
|
-
this._cancelCollector(
|
|
545
|
+
this._pendingMutations.delete(canonical);
|
|
546
|
+
this._cancelCollector(canonical);
|
|
547
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
548
|
+
this.emit('reset', { source: 'system' });
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Move (rename or relocate) an object to a new location.
|
|
554
|
+
* Use this to rename, change collection, or atomically rewrite the body.
|
|
555
|
+
*
|
|
556
|
+
* @param from - Current location
|
|
557
|
+
* @param to - New location
|
|
558
|
+
* @param options.body - Replace the body atomically as part of the move.
|
|
559
|
+
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
560
|
+
*/
|
|
561
|
+
async moveObject(from, to, options) {
|
|
562
|
+
return this._moveObjectImpl(from, to, options, this._conversationId);
|
|
563
|
+
}
|
|
564
|
+
/** @internal */
|
|
565
|
+
async _moveObjectImpl(from, to, options, conversationId) {
|
|
566
|
+
const fromLoc = normalizeLocation(from);
|
|
567
|
+
const toLoc = normalizeLocation(to);
|
|
568
|
+
if (options?.body && ('id' in options.body || 'type' in options.body)) {
|
|
569
|
+
throw new Error('moveObject: body must not contain `id` or `type` — identity is the location.');
|
|
570
|
+
}
|
|
571
|
+
// Optimistic event — emit move so listeners can update keys
|
|
572
|
+
const { collection, basename } = parseLocation(toLoc);
|
|
573
|
+
const optimistic = {
|
|
574
|
+
location: toLoc,
|
|
575
|
+
collection,
|
|
576
|
+
basename,
|
|
577
|
+
body: options?.body ?? {},
|
|
578
|
+
};
|
|
579
|
+
this._pendingMutations.set(toLoc, optimistic);
|
|
580
|
+
this.emit('objectMoved', { from: fromLoc, to: toLoc, object: optimistic, source: 'local_user' });
|
|
581
|
+
try {
|
|
582
|
+
const interactionId = generateEntityId();
|
|
583
|
+
const { message, object } = await this.graphqlClient.moveObject(this._id, fromLoc, toLoc, this._channelId, conversationId, interactionId, {
|
|
584
|
+
body: options?.body,
|
|
585
|
+
ephemeral: options?.ephemeral,
|
|
586
|
+
parentInteractionId: options?.parentInteractionId,
|
|
587
|
+
});
|
|
588
|
+
const fresh = object ?? await this._collectObject(toLoc);
|
|
589
|
+
return { object: fresh, message };
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
this.logger.error('[RoolChannel] Failed to move object:', error);
|
|
593
|
+
this._pendingMutations.delete(toLoc);
|
|
594
|
+
this._cancelCollector(toLoc);
|
|
504
595
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
505
596
|
this.emit('reset', { source: 'system' });
|
|
506
597
|
throw error;
|
|
507
598
|
}
|
|
508
599
|
}
|
|
509
600
|
/**
|
|
510
|
-
* Delete objects by
|
|
511
|
-
* Other objects that reference deleted objects
|
|
601
|
+
* Delete objects by location.
|
|
602
|
+
* Other objects that reference deleted objects will retain stale ref values.
|
|
512
603
|
*/
|
|
513
|
-
async deleteObjects(
|
|
514
|
-
return this._deleteObjectsImpl(
|
|
604
|
+
async deleteObjects(locations) {
|
|
605
|
+
return this._deleteObjectsImpl(locations, this._conversationId);
|
|
515
606
|
}
|
|
516
607
|
/** @internal */
|
|
517
|
-
async _deleteObjectsImpl(
|
|
518
|
-
if (
|
|
608
|
+
async _deleteObjectsImpl(locations, conversationId) {
|
|
609
|
+
if (locations.length === 0)
|
|
519
610
|
return;
|
|
611
|
+
const canonical = locations.map(normalizeLocation);
|
|
520
612
|
// Track for dedup and emit optimistic events
|
|
521
|
-
for (const
|
|
522
|
-
this._pendingMutations.set(
|
|
523
|
-
this.emit('objectDeleted', {
|
|
613
|
+
for (const location of canonical) {
|
|
614
|
+
this._pendingMutations.set(location, null);
|
|
615
|
+
this.emit('objectDeleted', { location, source: 'local_user' });
|
|
524
616
|
}
|
|
525
617
|
try {
|
|
526
|
-
|
|
618
|
+
const interactionId = generateEntityId();
|
|
619
|
+
await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
|
|
527
620
|
}
|
|
528
621
|
catch (error) {
|
|
529
622
|
this.logger.error('[RoolChannel] Failed to delete objects:', error);
|
|
530
|
-
for (const
|
|
531
|
-
this._pendingMutations.delete(
|
|
623
|
+
for (const location of canonical) {
|
|
624
|
+
this._pendingMutations.delete(location);
|
|
532
625
|
}
|
|
533
626
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
534
627
|
this.emit('reset', { source: 'system' });
|
|
535
628
|
throw error;
|
|
536
629
|
}
|
|
537
630
|
}
|
|
538
|
-
/**
|
|
539
|
-
* Get the current schema for this space.
|
|
540
|
-
* Returns a map of collection names to their definitions.
|
|
541
|
-
*/
|
|
631
|
+
/** Get the current schema for this space. */
|
|
542
632
|
getSchema() {
|
|
543
633
|
return this._schema;
|
|
544
634
|
}
|
|
545
|
-
/**
|
|
546
|
-
* Create a new collection schema.
|
|
547
|
-
* @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
|
|
548
|
-
* @param fields - Field definitions for the collection
|
|
549
|
-
* @returns The created CollectionDef
|
|
550
|
-
*/
|
|
635
|
+
/** Create a new collection schema. */
|
|
551
636
|
async createCollection(name, fields) {
|
|
552
637
|
return this._createCollectionImpl(name, fields, this._conversationId);
|
|
553
638
|
}
|
|
@@ -568,12 +653,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
568
653
|
throw error;
|
|
569
654
|
}
|
|
570
655
|
}
|
|
571
|
-
/**
|
|
572
|
-
* Alter an existing collection schema, replacing its field definitions.
|
|
573
|
-
* @param name - Name of the collection to alter
|
|
574
|
-
* @param fields - New field definitions (replaces all existing fields)
|
|
575
|
-
* @returns The updated CollectionDef
|
|
576
|
-
*/
|
|
656
|
+
/** Alter an existing collection schema, replacing its field definitions. */
|
|
577
657
|
async alterCollection(name, fields) {
|
|
578
658
|
return this._alterCollectionImpl(name, fields, this._conversationId);
|
|
579
659
|
}
|
|
@@ -583,7 +663,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
583
663
|
throw new Error(`Collection "${name}" not found`);
|
|
584
664
|
}
|
|
585
665
|
const previous = this._schema[name];
|
|
586
|
-
// Optimistic local update
|
|
587
666
|
this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
|
|
588
667
|
try {
|
|
589
668
|
return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
|
|
@@ -594,10 +673,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
594
673
|
throw error;
|
|
595
674
|
}
|
|
596
675
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Drop a collection schema.
|
|
599
|
-
* @param name - Name of the collection to drop
|
|
600
|
-
*/
|
|
676
|
+
/** Drop a collection schema. */
|
|
601
677
|
async dropCollection(name) {
|
|
602
678
|
return this._dropCollectionImpl(name, this._conversationId);
|
|
603
679
|
}
|
|
@@ -607,7 +683,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
607
683
|
throw new Error(`Collection "${name}" not found`);
|
|
608
684
|
}
|
|
609
685
|
const previous = this._schema[name];
|
|
610
|
-
// Optimistic local update
|
|
611
686
|
delete this._schema[name];
|
|
612
687
|
try {
|
|
613
688
|
await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
|
|
@@ -620,7 +695,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
620
695
|
}
|
|
621
696
|
/**
|
|
622
697
|
* Get the system instruction for the current conversation.
|
|
623
|
-
* Returns undefined if no system instruction is set.
|
|
624
698
|
*/
|
|
625
699
|
getSystemInstruction() {
|
|
626
700
|
return this._getSystemInstructionImpl(this._conversationId);
|
|
@@ -629,16 +703,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
629
703
|
_getSystemInstructionImpl(conversationId) {
|
|
630
704
|
return this._channel?.conversations[conversationId]?.systemInstruction;
|
|
631
705
|
}
|
|
632
|
-
/**
|
|
633
|
-
* Set the system instruction for the current conversation.
|
|
634
|
-
* Pass null to clear the instruction.
|
|
635
|
-
*/
|
|
706
|
+
/** Set the system instruction for the current conversation. */
|
|
636
707
|
async setSystemInstruction(instruction) {
|
|
637
708
|
return this._setSystemInstructionImpl(instruction, this._conversationId);
|
|
638
709
|
}
|
|
639
710
|
/** @internal */
|
|
640
711
|
async _setSystemInstructionImpl(instruction, conversationId) {
|
|
641
|
-
// Optimistic local update
|
|
642
712
|
this._ensureConversationImpl(conversationId);
|
|
643
713
|
const conv = this._channel.conversations[conversationId];
|
|
644
714
|
const previousInstruction = conv.systemInstruction;
|
|
@@ -648,7 +718,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
648
718
|
else {
|
|
649
719
|
conv.systemInstruction = instruction;
|
|
650
720
|
}
|
|
651
|
-
// Emit events for backward compat and new API
|
|
652
721
|
this.emit('conversationUpdated', {
|
|
653
722
|
conversationId,
|
|
654
723
|
channelId: this._channelId,
|
|
@@ -660,13 +729,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
660
729
|
source: 'local_user',
|
|
661
730
|
});
|
|
662
731
|
}
|
|
663
|
-
// Call server
|
|
664
732
|
try {
|
|
665
733
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
|
|
666
734
|
}
|
|
667
735
|
catch (error) {
|
|
668
736
|
this.logger.error('[RoolChannel] Failed to set system instruction:', error);
|
|
669
|
-
// Rollback
|
|
670
737
|
if (previousInstruction === undefined) {
|
|
671
738
|
delete conv.systemInstruction;
|
|
672
739
|
}
|
|
@@ -676,15 +743,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
676
743
|
throw error;
|
|
677
744
|
}
|
|
678
745
|
}
|
|
679
|
-
/**
|
|
680
|
-
* Rename the current conversation.
|
|
681
|
-
*/
|
|
746
|
+
/** Rename the current conversation. */
|
|
682
747
|
async renameConversation(name) {
|
|
683
748
|
return this._renameConversationImpl(name, this._conversationId);
|
|
684
749
|
}
|
|
685
750
|
/** @internal */
|
|
686
751
|
async _renameConversationImpl(name, conversationId) {
|
|
687
|
-
// Optimistic local update
|
|
688
752
|
this._ensureConversationImpl(conversationId);
|
|
689
753
|
const conv = this._channel.conversations[conversationId];
|
|
690
754
|
const previousName = conv.name;
|
|
@@ -700,21 +764,16 @@ export class RoolChannel extends EventEmitter {
|
|
|
700
764
|
source: 'local_user',
|
|
701
765
|
});
|
|
702
766
|
}
|
|
703
|
-
// Call server
|
|
704
767
|
try {
|
|
705
768
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
|
|
706
769
|
}
|
|
707
770
|
catch (error) {
|
|
708
771
|
this.logger.error('[RoolChannel] Failed to rename conversation:', error);
|
|
709
|
-
// Rollback
|
|
710
772
|
conv.name = previousName;
|
|
711
773
|
throw error;
|
|
712
774
|
}
|
|
713
775
|
}
|
|
714
|
-
/**
|
|
715
|
-
* Ensure a conversation exists in the local channel cache.
|
|
716
|
-
* @internal
|
|
717
|
-
*/
|
|
776
|
+
/** @internal */
|
|
718
777
|
_ensureConversationImpl(conversationId) {
|
|
719
778
|
if (!this._channel) {
|
|
720
779
|
this._channel = {
|
|
@@ -731,10 +790,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
731
790
|
};
|
|
732
791
|
}
|
|
733
792
|
}
|
|
734
|
-
/**
|
|
735
|
-
* Set a space-level metadata value.
|
|
736
|
-
* Metadata is stored in meta and hidden from AI operations.
|
|
737
|
-
*/
|
|
793
|
+
/** Set a space-level metadata value. */
|
|
738
794
|
setMetadata(key, value) {
|
|
739
795
|
this._setMetadataImpl(key, value, this._conversationId);
|
|
740
796
|
}
|
|
@@ -743,50 +799,43 @@ export class RoolChannel extends EventEmitter {
|
|
|
743
799
|
this._meta[key] = value;
|
|
744
800
|
this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
|
|
745
801
|
// Fire-and-forget server call
|
|
746
|
-
this.graphqlClient.setSpaceMeta(this.
|
|
802
|
+
this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
|
|
747
803
|
.catch((error) => {
|
|
748
804
|
this.logger.error('[RoolChannel] Failed to set meta:', error);
|
|
749
805
|
});
|
|
750
806
|
}
|
|
751
|
-
/**
|
|
752
|
-
* Get a space-level metadata value.
|
|
753
|
-
*/
|
|
807
|
+
/** Get a space-level metadata value. */
|
|
754
808
|
getMetadata(key) {
|
|
755
809
|
return this._meta[key];
|
|
756
810
|
}
|
|
757
|
-
/**
|
|
758
|
-
* Get all space-level metadata.
|
|
759
|
-
*/
|
|
811
|
+
/** Get all space-level metadata. */
|
|
760
812
|
getAllMetadata() {
|
|
761
813
|
return this._meta;
|
|
762
814
|
}
|
|
763
815
|
/**
|
|
764
816
|
* Send a prompt to the AI agent for space manipulation.
|
|
765
|
-
* @returns The message from the AI and the list of objects that were created or modified
|
|
817
|
+
* @returns The message from the AI and the list of objects that were created or modified.
|
|
766
818
|
*/
|
|
767
819
|
async prompt(prompt, options) {
|
|
768
820
|
return this._promptImpl(prompt, options, this._conversationId);
|
|
769
821
|
}
|
|
770
822
|
/** @internal */
|
|
771
823
|
async _promptImpl(prompt, options, conversationId) {
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
let
|
|
824
|
+
const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
|
|
825
|
+
const interactionId = generateEntityId();
|
|
826
|
+
let attachmentRefs;
|
|
775
827
|
if (attachments?.length) {
|
|
776
|
-
|
|
828
|
+
attachmentRefs = await Promise.all(attachments.map((file, index) => this.uploadAttachment(file, interactionId, index)));
|
|
777
829
|
}
|
|
778
830
|
// Auto-continue from active leaf if no explicit parent provided
|
|
779
831
|
const parentInteractionId = explicitParent !== undefined
|
|
780
832
|
? explicitParent
|
|
781
833
|
: (this._getActiveLeafImpl(conversationId) ?? null);
|
|
782
|
-
const interactionId = generateEntityId();
|
|
783
834
|
// Optimistically set active leaf before the server call.
|
|
784
835
|
this._activeLeaves.set(conversationId, interactionId);
|
|
785
836
|
let onAbort;
|
|
786
837
|
if (signal) {
|
|
787
838
|
if (signal.aborted) {
|
|
788
|
-
// Caller aborted before we even started; fire-and-forget the stop so
|
|
789
|
-
// the server-side prompt (about to start) is cancelled too.
|
|
790
839
|
this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
|
|
791
840
|
}
|
|
792
841
|
else {
|
|
@@ -798,29 +847,33 @@ export class RoolChannel extends EventEmitter {
|
|
|
798
847
|
}
|
|
799
848
|
let result;
|
|
800
849
|
try {
|
|
801
|
-
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
850
|
+
result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, conversationId, {
|
|
851
|
+
...rest,
|
|
852
|
+
locations: locations?.map(normalizeLocation),
|
|
853
|
+
attachmentRefs,
|
|
854
|
+
interactionId,
|
|
855
|
+
parentInteractionId,
|
|
856
|
+
});
|
|
802
857
|
}
|
|
803
858
|
finally {
|
|
804
859
|
if (onAbort)
|
|
805
860
|
signal.removeEventListener('abort', onAbort);
|
|
806
861
|
}
|
|
807
862
|
// Collect modified objects — they arrive via SSE events during/after the mutation.
|
|
808
|
-
// Try collecting from buffer first, then fetch any missing from server.
|
|
809
863
|
const objects = [];
|
|
810
864
|
const missing = [];
|
|
811
|
-
for (const
|
|
812
|
-
const buffered = this._objectBuffer.get(
|
|
865
|
+
for (const location of result.modifiedObjectLocations) {
|
|
866
|
+
const buffered = this._objectBuffer.get(location);
|
|
813
867
|
if (buffered) {
|
|
814
|
-
this._objectBuffer.delete(
|
|
868
|
+
this._objectBuffer.delete(location);
|
|
815
869
|
objects.push(buffered);
|
|
816
870
|
}
|
|
817
871
|
else {
|
|
818
|
-
missing.push(
|
|
872
|
+
missing.push(location);
|
|
819
873
|
}
|
|
820
874
|
}
|
|
821
|
-
// Fetch any objects not yet received via SSE
|
|
822
875
|
if (missing.length > 0) {
|
|
823
|
-
const fetched = await Promise.all(missing.map(
|
|
876
|
+
const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
|
|
824
877
|
for (const obj of fetched) {
|
|
825
878
|
if (obj)
|
|
826
879
|
objects.push(obj);
|
|
@@ -831,119 +884,89 @@ export class RoolChannel extends EventEmitter {
|
|
|
831
884
|
objects,
|
|
832
885
|
};
|
|
833
886
|
}
|
|
834
|
-
/**
|
|
835
|
-
* Rename this channel.
|
|
836
|
-
*/
|
|
887
|
+
/** Rename this channel. */
|
|
837
888
|
async rename(newName) {
|
|
838
|
-
// Optimistic local update
|
|
839
889
|
const previousName = this._channel?.name;
|
|
840
890
|
if (this._channel) {
|
|
841
891
|
this._channel.name = newName;
|
|
842
892
|
}
|
|
843
893
|
this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
|
|
844
|
-
// Call server
|
|
845
894
|
try {
|
|
846
895
|
await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
|
|
847
896
|
}
|
|
848
897
|
catch (error) {
|
|
849
898
|
this.logger.error('[RoolChannel] Failed to rename channel:', error);
|
|
850
|
-
// Rollback
|
|
851
899
|
if (this._channel) {
|
|
852
900
|
this._channel.name = previousName;
|
|
853
901
|
}
|
|
854
902
|
throw error;
|
|
855
903
|
}
|
|
856
904
|
}
|
|
857
|
-
/**
|
|
858
|
-
* List all media files for this space.
|
|
859
|
-
*/
|
|
860
|
-
async listMedia() {
|
|
861
|
-
return this.mediaClient.list(this._id);
|
|
862
|
-
}
|
|
863
|
-
/**
|
|
864
|
-
* Upload a file to this space. Returns the URL.
|
|
865
|
-
*/
|
|
866
|
-
async uploadMedia(file) {
|
|
867
|
-
return this.mediaClient.upload(this._id, file);
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
871
|
-
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
872
|
-
* Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
|
|
873
|
-
*/
|
|
874
|
-
async fetchMedia(url, options) {
|
|
875
|
-
return this.mediaClient.fetch(this._id, url, options);
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Delete a media file by URL.
|
|
879
|
-
*/
|
|
880
|
-
async deleteMedia(url) {
|
|
881
|
-
return this.mediaClient.delete(this._id, url);
|
|
882
|
-
}
|
|
883
905
|
/**
|
|
884
906
|
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
885
|
-
* Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
|
|
886
|
-
*
|
|
887
|
-
* @param url - The URL to fetch
|
|
888
|
-
* @param init - Optional method, headers, and body
|
|
889
|
-
* @returns The proxied Response
|
|
890
907
|
*/
|
|
891
908
|
async fetch(url, init) {
|
|
892
|
-
return this.
|
|
909
|
+
return this.restClient.proxyFetch(this._id, url, init);
|
|
910
|
+
}
|
|
911
|
+
async uploadAttachment(file, interactionId, index) {
|
|
912
|
+
await this.ensureCollection('attachments');
|
|
913
|
+
const directory = `attachments/${interactionId}`;
|
|
914
|
+
await this.ensureCollection(directory);
|
|
915
|
+
const attachment = attachmentBody(file, index);
|
|
916
|
+
const path = `${directory}/${attachment.filename}`;
|
|
917
|
+
await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
|
|
918
|
+
return this.webdav.ref(path);
|
|
919
|
+
}
|
|
920
|
+
async ensureCollection(path) {
|
|
921
|
+
const response = await this.webdav.request('MKCOL', path, { collection: true });
|
|
922
|
+
if (response.status === 201 || response.status === 405)
|
|
923
|
+
return;
|
|
924
|
+
throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
|
|
893
925
|
}
|
|
894
926
|
/**
|
|
895
927
|
* Register a collector that resolves when the object arrives via SSE.
|
|
896
|
-
* If the object is already in the buffer (arrived before collector), resolves immediately.
|
|
897
928
|
* @internal
|
|
898
929
|
*/
|
|
899
|
-
_collectObject(
|
|
930
|
+
_collectObject(location) {
|
|
900
931
|
return new Promise((resolve, reject) => {
|
|
901
|
-
|
|
902
|
-
const buffered = this._objectBuffer.get(objectId);
|
|
932
|
+
const buffered = this._objectBuffer.get(location);
|
|
903
933
|
if (buffered) {
|
|
904
|
-
this._objectBuffer.delete(
|
|
934
|
+
this._objectBuffer.delete(location);
|
|
905
935
|
resolve(buffered);
|
|
906
936
|
return;
|
|
907
937
|
}
|
|
908
938
|
const timer = setTimeout(() => {
|
|
909
|
-
this._objectResolvers.delete(
|
|
939
|
+
this._objectResolvers.delete(location);
|
|
910
940
|
// Fallback: try to fetch from server
|
|
911
|
-
this.graphqlClient.getObject(this._id,
|
|
941
|
+
this.graphqlClient.getObject(this._id, location).then(obj => {
|
|
912
942
|
if (obj) {
|
|
913
943
|
resolve(obj);
|
|
914
944
|
}
|
|
915
945
|
else {
|
|
916
|
-
reject(new Error(`Timeout waiting for object ${
|
|
946
|
+
reject(new Error(`Timeout waiting for object ${location} from SSE`));
|
|
917
947
|
}
|
|
918
948
|
}).catch(reject);
|
|
919
949
|
}, OBJECT_COLLECT_TIMEOUT);
|
|
920
|
-
this._objectResolvers.set(
|
|
950
|
+
this._objectResolvers.set(location, (obj) => {
|
|
921
951
|
clearTimeout(timer);
|
|
922
952
|
resolve(obj);
|
|
923
953
|
});
|
|
924
954
|
});
|
|
925
955
|
}
|
|
926
|
-
/**
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
_cancelCollector(objectId) {
|
|
931
|
-
this._objectResolvers.delete(objectId);
|
|
932
|
-
this._objectBuffer.delete(objectId);
|
|
956
|
+
/** @internal */
|
|
957
|
+
_cancelCollector(location) {
|
|
958
|
+
this._objectResolvers.delete(location);
|
|
959
|
+
this._objectBuffer.delete(location);
|
|
933
960
|
}
|
|
934
|
-
/**
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
*/
|
|
938
|
-
_deliverObject(objectId, object) {
|
|
939
|
-
const resolver = this._objectResolvers.get(objectId);
|
|
961
|
+
/** @internal */
|
|
962
|
+
_deliverObject(location, object) {
|
|
963
|
+
const resolver = this._objectResolvers.get(location);
|
|
940
964
|
if (resolver) {
|
|
941
965
|
resolver(object);
|
|
942
|
-
this._objectResolvers.delete(
|
|
966
|
+
this._objectResolvers.delete(location);
|
|
943
967
|
}
|
|
944
968
|
else {
|
|
945
|
-
|
|
946
|
-
this._objectBuffer.set(objectId, object);
|
|
969
|
+
this._objectBuffer.set(location, object);
|
|
947
970
|
}
|
|
948
971
|
}
|
|
949
972
|
/**
|
|
@@ -951,7 +974,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
951
974
|
* @internal
|
|
952
975
|
*/
|
|
953
976
|
handleChannelEvent(event) {
|
|
954
|
-
// Ignore events after close - the channel is being torn down
|
|
955
977
|
if (this._closed)
|
|
956
978
|
return;
|
|
957
979
|
const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
@@ -960,23 +982,31 @@ export class RoolChannel extends EventEmitter {
|
|
|
960
982
|
// Resync is handled by the client via _applyResyncData.
|
|
961
983
|
break;
|
|
962
984
|
case 'object_created':
|
|
963
|
-
if (event.
|
|
985
|
+
if (event.location && event.object) {
|
|
964
986
|
if (event.objectStat)
|
|
965
|
-
this._objectStats.set(event.
|
|
966
|
-
this._handleObjectCreated(event.
|
|
987
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
988
|
+
this._handleObjectCreated(event.location, event.object, changeSource);
|
|
967
989
|
}
|
|
968
990
|
break;
|
|
969
991
|
case 'object_updated':
|
|
970
|
-
if (event.
|
|
992
|
+
if (event.location && event.object) {
|
|
971
993
|
if (event.objectStat)
|
|
972
|
-
this._objectStats.set(event.
|
|
973
|
-
this._handleObjectUpdated(event.
|
|
994
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
995
|
+
this._handleObjectUpdated(event.location, event.object, changeSource);
|
|
974
996
|
}
|
|
975
997
|
break;
|
|
976
998
|
case 'object_deleted':
|
|
977
|
-
if (event.
|
|
978
|
-
this._objectStats.delete(event.
|
|
979
|
-
this._handleObjectDeleted(event.
|
|
999
|
+
if (event.location) {
|
|
1000
|
+
this._objectStats.delete(event.location);
|
|
1001
|
+
this._handleObjectDeleted(event.location, changeSource);
|
|
1002
|
+
}
|
|
1003
|
+
break;
|
|
1004
|
+
case 'object_moved':
|
|
1005
|
+
if (event.from && event.to && event.object) {
|
|
1006
|
+
this._objectStats.delete(event.from);
|
|
1007
|
+
if (event.objectStat)
|
|
1008
|
+
this._objectStats.set(event.to, event.objectStat);
|
|
1009
|
+
this._handleObjectMoved(event.from, event.to, event.object, changeSource);
|
|
980
1010
|
}
|
|
981
1011
|
break;
|
|
982
1012
|
case 'schema_updated':
|
|
@@ -992,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
992
1022
|
}
|
|
993
1023
|
break;
|
|
994
1024
|
case 'channel_updated':
|
|
995
|
-
// Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
|
|
996
1025
|
if (event.channelId === this._channelId && event.channel) {
|
|
997
1026
|
const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
|
|
998
1027
|
this._channel = event.channel;
|
|
@@ -1002,7 +1031,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
1002
1031
|
}
|
|
1003
1032
|
break;
|
|
1004
1033
|
case 'conversation_updated':
|
|
1005
|
-
// Only update if it's our channel
|
|
1006
1034
|
if (event.channelId === this._channelId && event.conversationId) {
|
|
1007
1035
|
if (!this._channel) {
|
|
1008
1036
|
this._channel = {
|
|
@@ -1013,17 +1041,13 @@ export class RoolChannel extends EventEmitter {
|
|
|
1013
1041
|
}
|
|
1014
1042
|
const prev = this._channel.conversations[event.conversationId];
|
|
1015
1043
|
if (event.conversation) {
|
|
1016
|
-
// Update or create conversation in local cache
|
|
1017
1044
|
this._channel.conversations[event.conversationId] = event.conversation;
|
|
1018
1045
|
}
|
|
1019
1046
|
else {
|
|
1020
|
-
// Conversation was deleted
|
|
1021
1047
|
delete this._channel.conversations[event.conversationId];
|
|
1022
1048
|
}
|
|
1023
|
-
// Skip emit if data is unchanged (e.g. echo of our own optimistic update)
|
|
1024
1049
|
if (JSON.stringify(prev) === JSON.stringify(event.conversation))
|
|
1025
1050
|
break;
|
|
1026
|
-
// Auto-advance active leaf if someone continued our current branch
|
|
1027
1051
|
if (event.conversation && !Array.isArray(event.conversation.interactions)) {
|
|
1028
1052
|
const currentLeaf = this._getActiveLeafImpl(event.conversationId);
|
|
1029
1053
|
if (currentLeaf) {
|
|
@@ -1035,13 +1059,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
1035
1059
|
}
|
|
1036
1060
|
}
|
|
1037
1061
|
}
|
|
1038
|
-
// Emit the new conversationUpdated event
|
|
1039
1062
|
this.emit('conversationUpdated', {
|
|
1040
1063
|
conversationId: event.conversationId,
|
|
1041
1064
|
channelId: event.channelId,
|
|
1042
1065
|
source: changeSource,
|
|
1043
1066
|
});
|
|
1044
|
-
// Backward compat: also emit channelUpdated when the active conversation updates
|
|
1045
1067
|
if (event.conversationId === this._conversationId) {
|
|
1046
1068
|
this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
|
|
1047
1069
|
}
|
|
@@ -1052,87 +1074,75 @@ export class RoolChannel extends EventEmitter {
|
|
|
1052
1074
|
break;
|
|
1053
1075
|
}
|
|
1054
1076
|
}
|
|
1055
|
-
/**
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
// Deliver to any pending collector (for mutation return values)
|
|
1062
|
-
this._deliverObject(objectId, object);
|
|
1063
|
-
// Maintain local ID list — prepend (most recently modified first)
|
|
1064
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1065
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1077
|
+
/** @internal */
|
|
1078
|
+
_handleObjectCreated(location, object, source) {
|
|
1079
|
+
this._deliverObject(location, object);
|
|
1080
|
+
// Maintain local location list — prepend (most recently modified first)
|
|
1081
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1082
|
+
const pending = this._pendingMutations.get(location);
|
|
1066
1083
|
if (pending !== undefined) {
|
|
1067
|
-
|
|
1068
|
-
this._pendingMutations.delete(objectId);
|
|
1084
|
+
this._pendingMutations.delete(location);
|
|
1069
1085
|
if (pending !== null) {
|
|
1070
|
-
//
|
|
1086
|
+
// Already emitted objectCreated optimistically.
|
|
1071
1087
|
// Emit objectUpdated only if AI resolved placeholders (data changed).
|
|
1072
1088
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1073
|
-
this.emit('objectUpdated', {
|
|
1089
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1074
1090
|
}
|
|
1075
1091
|
}
|
|
1076
1092
|
}
|
|
1077
1093
|
else {
|
|
1078
|
-
|
|
1079
|
-
this.emit('objectCreated', { objectId, object, source });
|
|
1094
|
+
this.emit('objectCreated', { location, object, source });
|
|
1080
1095
|
}
|
|
1081
1096
|
}
|
|
1082
|
-
/**
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
_handleObjectUpdated(objectId, object, source) {
|
|
1088
|
-
// Deliver to any pending collector
|
|
1089
|
-
this._deliverObject(objectId, object);
|
|
1090
|
-
// Maintain local ID list — move to front (most recently modified)
|
|
1091
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1092
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1097
|
+
/** @internal */
|
|
1098
|
+
_handleObjectUpdated(location, object, source) {
|
|
1099
|
+
this._deliverObject(location, object);
|
|
1100
|
+
this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
|
|
1101
|
+
const pending = this._pendingMutations.get(location);
|
|
1093
1102
|
if (pending !== undefined) {
|
|
1094
|
-
|
|
1095
|
-
this._pendingMutations.delete(objectId);
|
|
1103
|
+
this._pendingMutations.delete(location);
|
|
1096
1104
|
if (pending !== null) {
|
|
1097
|
-
// Already emitted objectUpdated optimistically.
|
|
1098
|
-
// Emit again only if data changed (AI resolved placeholders).
|
|
1099
1105
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1100
|
-
this.emit('objectUpdated', {
|
|
1106
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1101
1107
|
}
|
|
1102
1108
|
}
|
|
1103
1109
|
}
|
|
1104
1110
|
else {
|
|
1105
|
-
|
|
1106
|
-
this.emit('objectUpdated', { objectId, object, source });
|
|
1111
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1107
1112
|
}
|
|
1108
1113
|
}
|
|
1109
|
-
/**
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1114
|
+
/** @internal */
|
|
1115
|
+
_handleObjectDeleted(location, source) {
|
|
1116
|
+
this._objectLocations = this._objectLocations.filter(l => l !== location);
|
|
1117
|
+
const pending = this._pendingMutations.get(location);
|
|
1118
|
+
if (pending !== undefined) {
|
|
1119
|
+
this._pendingMutations.delete(location);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
this.emit('objectDeleted', { location, source });
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
/** @internal */
|
|
1126
|
+
_handleObjectMoved(from, to, object, source) {
|
|
1127
|
+
this._deliverObject(to, object);
|
|
1128
|
+
// Drop old location, insert new one at the front.
|
|
1129
|
+
this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
|
|
1130
|
+
const pending = this._pendingMutations.get(to);
|
|
1118
1131
|
if (pending !== undefined) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1132
|
+
this._pendingMutations.delete(to);
|
|
1133
|
+
if (pending !== null) {
|
|
1134
|
+
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1135
|
+
this.emit('objectUpdated', { location: to, object, source });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1121
1138
|
}
|
|
1122
1139
|
else {
|
|
1123
|
-
|
|
1124
|
-
this.emit('objectDeleted', { objectId, source });
|
|
1140
|
+
this.emit('objectMoved', { from, to, object, source });
|
|
1125
1141
|
}
|
|
1126
1142
|
}
|
|
1127
1143
|
}
|
|
1128
1144
|
/**
|
|
1129
1145
|
* A lightweight handle for a specific conversation within a channel.
|
|
1130
|
-
*
|
|
1131
|
-
* Scopes AI and mutation operations to a particular conversation's interaction
|
|
1132
|
-
* history, while sharing the channel's single SSE connection and object state.
|
|
1133
|
-
*
|
|
1134
|
-
* Obtain via `channel.conversation('thread-id')`.
|
|
1135
|
-
* Conversations are auto-created on first interaction.
|
|
1136
1146
|
*/
|
|
1137
1147
|
export class ConversationHandle {
|
|
1138
1148
|
/** @internal */
|
|
@@ -1178,16 +1188,20 @@ export class ConversationHandle {
|
|
|
1178
1188
|
return this._channel._findObjectsImpl(options, this._conversationId);
|
|
1179
1189
|
}
|
|
1180
1190
|
/** Create a new object. */
|
|
1181
|
-
async createObject(options) {
|
|
1182
|
-
return this._channel._createObjectImpl(options, this._conversationId);
|
|
1191
|
+
async createObject(collection, body, options) {
|
|
1192
|
+
return this._channel._createObjectImpl(collection, body, options, this._conversationId);
|
|
1183
1193
|
}
|
|
1184
1194
|
/** Update an existing object. */
|
|
1185
|
-
async updateObject(
|
|
1186
|
-
return this._channel._updateObjectImpl(
|
|
1195
|
+
async updateObject(location, options) {
|
|
1196
|
+
return this._channel._updateObjectImpl(location, options, this._conversationId);
|
|
1197
|
+
}
|
|
1198
|
+
/** Move (rename/relocate) an object. */
|
|
1199
|
+
async moveObject(from, to, options) {
|
|
1200
|
+
return this._channel._moveObjectImpl(from, to, options, this._conversationId);
|
|
1187
1201
|
}
|
|
1188
|
-
/** Delete objects by
|
|
1189
|
-
async deleteObjects(
|
|
1190
|
-
return this._channel._deleteObjectsImpl(
|
|
1202
|
+
/** Delete objects by location. */
|
|
1203
|
+
async deleteObjects(locations) {
|
|
1204
|
+
return this._channel._deleteObjectsImpl(locations, this._conversationId);
|
|
1191
1205
|
}
|
|
1192
1206
|
/** Send a prompt to the AI agent, scoped to this conversation's history. */
|
|
1193
1207
|
async prompt(text, options) {
|