@rool-dev/sdk 0.9.0-dev.a397f4d → 0.9.0-dev.bab1e95
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 -350
- 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 +41 -19
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +120 -117
- 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.js +4 -2
- 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,139 +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 type = data.type;
|
|
430
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(basename)) {
|
|
431
|
-
throw new Error(`Invalid object ID "${basename}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
|
|
432
|
-
}
|
|
433
|
-
if (typeof type !== 'string' || !type) {
|
|
434
|
-
throw new Error('createObject: data.type is required');
|
|
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.');
|
|
435
478
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
this._pendingMutations.set(objectId, optimisticObject);
|
|
442
|
-
this.emit('objectCreated', { objectId, object: optimisticObject, source: 'local_user' });
|
|
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' });
|
|
443
484
|
try {
|
|
444
485
|
const interactionId = generateEntityId();
|
|
445
|
-
const { message } = await this.graphqlClient.createObject(this.
|
|
446
|
-
const
|
|
447
|
-
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 };
|
|
448
489
|
}
|
|
449
490
|
catch (error) {
|
|
450
491
|
this.logger.error('[RoolChannel] Failed to create object:', error);
|
|
451
|
-
this._pendingMutations.delete(
|
|
452
|
-
this._cancelCollector(
|
|
492
|
+
this._pendingMutations.delete(location);
|
|
493
|
+
this._cancelCollector(location);
|
|
453
494
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
454
495
|
this.emit('reset', { source: 'system' });
|
|
455
496
|
throw error;
|
|
@@ -457,98 +498,141 @@ export class RoolChannel extends EventEmitter {
|
|
|
457
498
|
}
|
|
458
499
|
/**
|
|
459
500
|
* Update an existing object.
|
|
460
|
-
*
|
|
461
|
-
* @param
|
|
462
|
-
* @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.
|
|
463
505
|
* @param options.ephemeral - If true, the operation won't be recorded in interaction history.
|
|
464
|
-
* @returns The updated object (with AI-filled content) and message
|
|
465
506
|
*/
|
|
466
|
-
async updateObject(
|
|
467
|
-
return this._updateObjectImpl(
|
|
507
|
+
async updateObject(location, options) {
|
|
508
|
+
return this._updateObjectImpl(location, options, this._conversationId);
|
|
468
509
|
}
|
|
469
510
|
/** @internal */
|
|
470
|
-
async _updateObjectImpl(
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
if (data
|
|
474
|
-
throw new Error('
|
|
475
|
-
}
|
|
476
|
-
if (data && ('id' in data)) {
|
|
477
|
-
throw new Error('Cannot delete id field. The id field is immutable after creation.');
|
|
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.');
|
|
478
516
|
}
|
|
479
|
-
// Normalize undefined to null (for JSON serialization) and build server
|
|
480
|
-
let
|
|
517
|
+
// Normalize undefined to null (for JSON serialization) and build server patch
|
|
518
|
+
let serverPatch;
|
|
481
519
|
if (data) {
|
|
482
|
-
|
|
520
|
+
serverPatch = {};
|
|
483
521
|
for (const [key, value] of Object.entries(data)) {
|
|
484
|
-
|
|
485
|
-
serverData[key] = value === undefined ? null : value;
|
|
522
|
+
serverPatch[key] = value === undefined ? null : value;
|
|
486
523
|
}
|
|
487
524
|
}
|
|
488
525
|
// Emit optimistic event if we have data changes
|
|
489
526
|
if (data) {
|
|
490
|
-
|
|
491
|
-
const optimistic = {
|
|
492
|
-
this._pendingMutations.set(
|
|
493
|
-
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' });
|
|
494
531
|
}
|
|
495
532
|
try {
|
|
496
533
|
const interactionId = generateEntityId();
|
|
497
|
-
const { message } = await this.graphqlClient.updateObject(this.
|
|
498
|
-
|
|
499
|
-
|
|
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 };
|
|
500
542
|
}
|
|
501
543
|
catch (error) {
|
|
502
544
|
this.logger.error('[RoolChannel] Failed to update object:', error);
|
|
503
|
-
this._pendingMutations.delete(
|
|
504
|
-
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);
|
|
505
595
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
506
596
|
this.emit('reset', { source: 'system' });
|
|
507
597
|
throw error;
|
|
508
598
|
}
|
|
509
599
|
}
|
|
510
600
|
/**
|
|
511
|
-
* Delete objects by
|
|
512
|
-
* Other objects that reference deleted objects
|
|
601
|
+
* Delete objects by location.
|
|
602
|
+
* Other objects that reference deleted objects will retain stale ref values.
|
|
513
603
|
*/
|
|
514
|
-
async deleteObjects(
|
|
515
|
-
return this._deleteObjectsImpl(
|
|
604
|
+
async deleteObjects(locations) {
|
|
605
|
+
return this._deleteObjectsImpl(locations, this._conversationId);
|
|
516
606
|
}
|
|
517
607
|
/** @internal */
|
|
518
|
-
async _deleteObjectsImpl(
|
|
519
|
-
if (
|
|
608
|
+
async _deleteObjectsImpl(locations, conversationId) {
|
|
609
|
+
if (locations.length === 0)
|
|
520
610
|
return;
|
|
611
|
+
const canonical = locations.map(normalizeLocation);
|
|
521
612
|
// Track for dedup and emit optimistic events
|
|
522
|
-
for (const
|
|
523
|
-
this._pendingMutations.set(
|
|
524
|
-
this.emit('objectDeleted', {
|
|
613
|
+
for (const location of canonical) {
|
|
614
|
+
this._pendingMutations.set(location, null);
|
|
615
|
+
this.emit('objectDeleted', { location, source: 'local_user' });
|
|
525
616
|
}
|
|
526
617
|
try {
|
|
527
|
-
|
|
618
|
+
const interactionId = generateEntityId();
|
|
619
|
+
await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
|
|
528
620
|
}
|
|
529
621
|
catch (error) {
|
|
530
622
|
this.logger.error('[RoolChannel] Failed to delete objects:', error);
|
|
531
|
-
for (const
|
|
532
|
-
this._pendingMutations.delete(
|
|
623
|
+
for (const location of canonical) {
|
|
624
|
+
this._pendingMutations.delete(location);
|
|
533
625
|
}
|
|
534
626
|
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
535
627
|
this.emit('reset', { source: 'system' });
|
|
536
628
|
throw error;
|
|
537
629
|
}
|
|
538
630
|
}
|
|
539
|
-
/**
|
|
540
|
-
* Get the current schema for this space.
|
|
541
|
-
* Returns a map of collection names to their definitions.
|
|
542
|
-
*/
|
|
631
|
+
/** Get the current schema for this space. */
|
|
543
632
|
getSchema() {
|
|
544
633
|
return this._schema;
|
|
545
634
|
}
|
|
546
|
-
/**
|
|
547
|
-
* Create a new collection schema.
|
|
548
|
-
* @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
|
|
549
|
-
* @param fields - Field definitions for the collection
|
|
550
|
-
* @returns The created CollectionDef
|
|
551
|
-
*/
|
|
635
|
+
/** Create a new collection schema. */
|
|
552
636
|
async createCollection(name, fields) {
|
|
553
637
|
return this._createCollectionImpl(name, fields, this._conversationId);
|
|
554
638
|
}
|
|
@@ -569,12 +653,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
569
653
|
throw error;
|
|
570
654
|
}
|
|
571
655
|
}
|
|
572
|
-
/**
|
|
573
|
-
* Alter an existing collection schema, replacing its field definitions.
|
|
574
|
-
* @param name - Name of the collection to alter
|
|
575
|
-
* @param fields - New field definitions (replaces all existing fields)
|
|
576
|
-
* @returns The updated CollectionDef
|
|
577
|
-
*/
|
|
656
|
+
/** Alter an existing collection schema, replacing its field definitions. */
|
|
578
657
|
async alterCollection(name, fields) {
|
|
579
658
|
return this._alterCollectionImpl(name, fields, this._conversationId);
|
|
580
659
|
}
|
|
@@ -584,7 +663,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
584
663
|
throw new Error(`Collection "${name}" not found`);
|
|
585
664
|
}
|
|
586
665
|
const previous = this._schema[name];
|
|
587
|
-
// Optimistic local update
|
|
588
666
|
this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
|
|
589
667
|
try {
|
|
590
668
|
return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
|
|
@@ -595,10 +673,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
595
673
|
throw error;
|
|
596
674
|
}
|
|
597
675
|
}
|
|
598
|
-
/**
|
|
599
|
-
* Drop a collection schema.
|
|
600
|
-
* @param name - Name of the collection to drop
|
|
601
|
-
*/
|
|
676
|
+
/** Drop a collection schema. */
|
|
602
677
|
async dropCollection(name) {
|
|
603
678
|
return this._dropCollectionImpl(name, this._conversationId);
|
|
604
679
|
}
|
|
@@ -608,7 +683,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
608
683
|
throw new Error(`Collection "${name}" not found`);
|
|
609
684
|
}
|
|
610
685
|
const previous = this._schema[name];
|
|
611
|
-
// Optimistic local update
|
|
612
686
|
delete this._schema[name];
|
|
613
687
|
try {
|
|
614
688
|
await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
|
|
@@ -621,7 +695,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
621
695
|
}
|
|
622
696
|
/**
|
|
623
697
|
* Get the system instruction for the current conversation.
|
|
624
|
-
* Returns undefined if no system instruction is set.
|
|
625
698
|
*/
|
|
626
699
|
getSystemInstruction() {
|
|
627
700
|
return this._getSystemInstructionImpl(this._conversationId);
|
|
@@ -630,16 +703,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
630
703
|
_getSystemInstructionImpl(conversationId) {
|
|
631
704
|
return this._channel?.conversations[conversationId]?.systemInstruction;
|
|
632
705
|
}
|
|
633
|
-
/**
|
|
634
|
-
* Set the system instruction for the current conversation.
|
|
635
|
-
* Pass null to clear the instruction.
|
|
636
|
-
*/
|
|
706
|
+
/** Set the system instruction for the current conversation. */
|
|
637
707
|
async setSystemInstruction(instruction) {
|
|
638
708
|
return this._setSystemInstructionImpl(instruction, this._conversationId);
|
|
639
709
|
}
|
|
640
710
|
/** @internal */
|
|
641
711
|
async _setSystemInstructionImpl(instruction, conversationId) {
|
|
642
|
-
// Optimistic local update
|
|
643
712
|
this._ensureConversationImpl(conversationId);
|
|
644
713
|
const conv = this._channel.conversations[conversationId];
|
|
645
714
|
const previousInstruction = conv.systemInstruction;
|
|
@@ -649,7 +718,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
649
718
|
else {
|
|
650
719
|
conv.systemInstruction = instruction;
|
|
651
720
|
}
|
|
652
|
-
// Emit events for backward compat and new API
|
|
653
721
|
this.emit('conversationUpdated', {
|
|
654
722
|
conversationId,
|
|
655
723
|
channelId: this._channelId,
|
|
@@ -661,13 +729,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
661
729
|
source: 'local_user',
|
|
662
730
|
});
|
|
663
731
|
}
|
|
664
|
-
// Call server
|
|
665
732
|
try {
|
|
666
733
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { systemInstruction: instruction });
|
|
667
734
|
}
|
|
668
735
|
catch (error) {
|
|
669
736
|
this.logger.error('[RoolChannel] Failed to set system instruction:', error);
|
|
670
|
-
// Rollback
|
|
671
737
|
if (previousInstruction === undefined) {
|
|
672
738
|
delete conv.systemInstruction;
|
|
673
739
|
}
|
|
@@ -677,15 +743,12 @@ export class RoolChannel extends EventEmitter {
|
|
|
677
743
|
throw error;
|
|
678
744
|
}
|
|
679
745
|
}
|
|
680
|
-
/**
|
|
681
|
-
* Rename the current conversation.
|
|
682
|
-
*/
|
|
746
|
+
/** Rename the current conversation. */
|
|
683
747
|
async renameConversation(name) {
|
|
684
748
|
return this._renameConversationImpl(name, this._conversationId);
|
|
685
749
|
}
|
|
686
750
|
/** @internal */
|
|
687
751
|
async _renameConversationImpl(name, conversationId) {
|
|
688
|
-
// Optimistic local update
|
|
689
752
|
this._ensureConversationImpl(conversationId);
|
|
690
753
|
const conv = this._channel.conversations[conversationId];
|
|
691
754
|
const previousName = conv.name;
|
|
@@ -701,21 +764,16 @@ export class RoolChannel extends EventEmitter {
|
|
|
701
764
|
source: 'local_user',
|
|
702
765
|
});
|
|
703
766
|
}
|
|
704
|
-
// Call server
|
|
705
767
|
try {
|
|
706
768
|
await this.graphqlClient.updateConversation(this._id, this._channelId, conversationId, { name });
|
|
707
769
|
}
|
|
708
770
|
catch (error) {
|
|
709
771
|
this.logger.error('[RoolChannel] Failed to rename conversation:', error);
|
|
710
|
-
// Rollback
|
|
711
772
|
conv.name = previousName;
|
|
712
773
|
throw error;
|
|
713
774
|
}
|
|
714
775
|
}
|
|
715
|
-
/**
|
|
716
|
-
* Ensure a conversation exists in the local channel cache.
|
|
717
|
-
* @internal
|
|
718
|
-
*/
|
|
776
|
+
/** @internal */
|
|
719
777
|
_ensureConversationImpl(conversationId) {
|
|
720
778
|
if (!this._channel) {
|
|
721
779
|
this._channel = {
|
|
@@ -732,10 +790,7 @@ export class RoolChannel extends EventEmitter {
|
|
|
732
790
|
};
|
|
733
791
|
}
|
|
734
792
|
}
|
|
735
|
-
/**
|
|
736
|
-
* Set a space-level metadata value.
|
|
737
|
-
* Metadata is stored in meta and hidden from AI operations.
|
|
738
|
-
*/
|
|
793
|
+
/** Set a space-level metadata value. */
|
|
739
794
|
setMetadata(key, value) {
|
|
740
795
|
this._setMetadataImpl(key, value, this._conversationId);
|
|
741
796
|
}
|
|
@@ -744,50 +799,43 @@ export class RoolChannel extends EventEmitter {
|
|
|
744
799
|
this._meta[key] = value;
|
|
745
800
|
this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
|
|
746
801
|
// Fire-and-forget server call
|
|
747
|
-
this.graphqlClient.setSpaceMeta(this.
|
|
802
|
+
this.graphqlClient.setSpaceMeta(this._id, this._meta, this._channelId, conversationId)
|
|
748
803
|
.catch((error) => {
|
|
749
804
|
this.logger.error('[RoolChannel] Failed to set meta:', error);
|
|
750
805
|
});
|
|
751
806
|
}
|
|
752
|
-
/**
|
|
753
|
-
* Get a space-level metadata value.
|
|
754
|
-
*/
|
|
807
|
+
/** Get a space-level metadata value. */
|
|
755
808
|
getMetadata(key) {
|
|
756
809
|
return this._meta[key];
|
|
757
810
|
}
|
|
758
|
-
/**
|
|
759
|
-
* Get all space-level metadata.
|
|
760
|
-
*/
|
|
811
|
+
/** Get all space-level metadata. */
|
|
761
812
|
getAllMetadata() {
|
|
762
813
|
return this._meta;
|
|
763
814
|
}
|
|
764
815
|
/**
|
|
765
816
|
* Send a prompt to the AI agent for space manipulation.
|
|
766
|
-
* @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.
|
|
767
818
|
*/
|
|
768
819
|
async prompt(prompt, options) {
|
|
769
820
|
return this._promptImpl(prompt, options, this._conversationId);
|
|
770
821
|
}
|
|
771
822
|
/** @internal */
|
|
772
823
|
async _promptImpl(prompt, options, conversationId) {
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
let
|
|
824
|
+
const { attachments, parentInteractionId: explicitParent, signal, locations, ...rest } = options ?? {};
|
|
825
|
+
const interactionId = generateEntityId();
|
|
826
|
+
let attachmentRefs;
|
|
776
827
|
if (attachments?.length) {
|
|
777
|
-
|
|
828
|
+
attachmentRefs = await Promise.all(attachments.map((file, index) => this.uploadAttachment(file, interactionId, index)));
|
|
778
829
|
}
|
|
779
830
|
// Auto-continue from active leaf if no explicit parent provided
|
|
780
831
|
const parentInteractionId = explicitParent !== undefined
|
|
781
832
|
? explicitParent
|
|
782
833
|
: (this._getActiveLeafImpl(conversationId) ?? null);
|
|
783
|
-
const interactionId = generateEntityId();
|
|
784
834
|
// Optimistically set active leaf before the server call.
|
|
785
835
|
this._activeLeaves.set(conversationId, interactionId);
|
|
786
836
|
let onAbort;
|
|
787
837
|
if (signal) {
|
|
788
838
|
if (signal.aborted) {
|
|
789
|
-
// Caller aborted before we even started; fire-and-forget the stop so
|
|
790
|
-
// the server-side prompt (about to start) is cancelled too.
|
|
791
839
|
this.graphqlClient.stopInteraction(this._id, interactionId).catch(() => { });
|
|
792
840
|
}
|
|
793
841
|
else {
|
|
@@ -799,29 +847,33 @@ export class RoolChannel extends EventEmitter {
|
|
|
799
847
|
}
|
|
800
848
|
let result;
|
|
801
849
|
try {
|
|
802
|
-
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
|
+
});
|
|
803
857
|
}
|
|
804
858
|
finally {
|
|
805
859
|
if (onAbort)
|
|
806
860
|
signal.removeEventListener('abort', onAbort);
|
|
807
861
|
}
|
|
808
862
|
// Collect modified objects — they arrive via SSE events during/after the mutation.
|
|
809
|
-
// Try collecting from buffer first, then fetch any missing from server.
|
|
810
863
|
const objects = [];
|
|
811
864
|
const missing = [];
|
|
812
|
-
for (const
|
|
813
|
-
const buffered = this._objectBuffer.get(
|
|
865
|
+
for (const location of result.modifiedObjectLocations) {
|
|
866
|
+
const buffered = this._objectBuffer.get(location);
|
|
814
867
|
if (buffered) {
|
|
815
|
-
this._objectBuffer.delete(
|
|
868
|
+
this._objectBuffer.delete(location);
|
|
816
869
|
objects.push(buffered);
|
|
817
870
|
}
|
|
818
871
|
else {
|
|
819
|
-
missing.push(
|
|
872
|
+
missing.push(location);
|
|
820
873
|
}
|
|
821
874
|
}
|
|
822
|
-
// Fetch any objects not yet received via SSE
|
|
823
875
|
if (missing.length > 0) {
|
|
824
|
-
const fetched = await Promise.all(missing.map(
|
|
876
|
+
const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
|
|
825
877
|
for (const obj of fetched) {
|
|
826
878
|
if (obj)
|
|
827
879
|
objects.push(obj);
|
|
@@ -832,119 +884,89 @@ export class RoolChannel extends EventEmitter {
|
|
|
832
884
|
objects,
|
|
833
885
|
};
|
|
834
886
|
}
|
|
835
|
-
/**
|
|
836
|
-
* Rename this channel.
|
|
837
|
-
*/
|
|
887
|
+
/** Rename this channel. */
|
|
838
888
|
async rename(newName) {
|
|
839
|
-
// Optimistic local update
|
|
840
889
|
const previousName = this._channel?.name;
|
|
841
890
|
if (this._channel) {
|
|
842
891
|
this._channel.name = newName;
|
|
843
892
|
}
|
|
844
893
|
this.emit('channelUpdated', { channelId: this._channelId, source: 'local_user' });
|
|
845
|
-
// Call server
|
|
846
894
|
try {
|
|
847
895
|
await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
|
|
848
896
|
}
|
|
849
897
|
catch (error) {
|
|
850
898
|
this.logger.error('[RoolChannel] Failed to rename channel:', error);
|
|
851
|
-
// Rollback
|
|
852
899
|
if (this._channel) {
|
|
853
900
|
this._channel.name = previousName;
|
|
854
901
|
}
|
|
855
902
|
throw error;
|
|
856
903
|
}
|
|
857
904
|
}
|
|
858
|
-
/**
|
|
859
|
-
* List all media files for this space.
|
|
860
|
-
*/
|
|
861
|
-
async listMedia() {
|
|
862
|
-
return this.mediaClient.list(this._id);
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Upload a file to this space. Returns the URL.
|
|
866
|
-
*/
|
|
867
|
-
async uploadMedia(file) {
|
|
868
|
-
return this.mediaClient.upload(this._id, file);
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
872
|
-
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
873
|
-
* Pass `{ forceProxy: true }` to skip the direct fetch and go straight through the server proxy.
|
|
874
|
-
*/
|
|
875
|
-
async fetchMedia(url, options) {
|
|
876
|
-
return this.mediaClient.fetch(this._id, url, options);
|
|
877
|
-
}
|
|
878
|
-
/**
|
|
879
|
-
* Delete a media file by URL.
|
|
880
|
-
*/
|
|
881
|
-
async deleteMedia(url) {
|
|
882
|
-
return this.mediaClient.delete(this._id, url);
|
|
883
|
-
}
|
|
884
905
|
/**
|
|
885
906
|
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
886
|
-
* Requires editor role or above. Blocked for private/internal IP ranges (SSRF protection).
|
|
887
|
-
*
|
|
888
|
-
* @param url - The URL to fetch
|
|
889
|
-
* @param init - Optional method, headers, and body
|
|
890
|
-
* @returns The proxied Response
|
|
891
907
|
*/
|
|
892
908
|
async fetch(url, init) {
|
|
893
|
-
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()}`);
|
|
894
925
|
}
|
|
895
926
|
/**
|
|
896
927
|
* Register a collector that resolves when the object arrives via SSE.
|
|
897
|
-
* If the object is already in the buffer (arrived before collector), resolves immediately.
|
|
898
928
|
* @internal
|
|
899
929
|
*/
|
|
900
|
-
_collectObject(
|
|
930
|
+
_collectObject(location) {
|
|
901
931
|
return new Promise((resolve, reject) => {
|
|
902
|
-
|
|
903
|
-
const buffered = this._objectBuffer.get(objectId);
|
|
932
|
+
const buffered = this._objectBuffer.get(location);
|
|
904
933
|
if (buffered) {
|
|
905
|
-
this._objectBuffer.delete(
|
|
934
|
+
this._objectBuffer.delete(location);
|
|
906
935
|
resolve(buffered);
|
|
907
936
|
return;
|
|
908
937
|
}
|
|
909
938
|
const timer = setTimeout(() => {
|
|
910
|
-
this._objectResolvers.delete(
|
|
939
|
+
this._objectResolvers.delete(location);
|
|
911
940
|
// Fallback: try to fetch from server
|
|
912
|
-
this.graphqlClient.getObject(this._id,
|
|
941
|
+
this.graphqlClient.getObject(this._id, location).then(obj => {
|
|
913
942
|
if (obj) {
|
|
914
943
|
resolve(obj);
|
|
915
944
|
}
|
|
916
945
|
else {
|
|
917
|
-
reject(new Error(`Timeout waiting for object ${
|
|
946
|
+
reject(new Error(`Timeout waiting for object ${location} from SSE`));
|
|
918
947
|
}
|
|
919
948
|
}).catch(reject);
|
|
920
949
|
}, OBJECT_COLLECT_TIMEOUT);
|
|
921
|
-
this._objectResolvers.set(
|
|
950
|
+
this._objectResolvers.set(location, (obj) => {
|
|
922
951
|
clearTimeout(timer);
|
|
923
952
|
resolve(obj);
|
|
924
953
|
});
|
|
925
954
|
});
|
|
926
955
|
}
|
|
927
|
-
/**
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
_cancelCollector(objectId) {
|
|
932
|
-
this._objectResolvers.delete(objectId);
|
|
933
|
-
this._objectBuffer.delete(objectId);
|
|
956
|
+
/** @internal */
|
|
957
|
+
_cancelCollector(location) {
|
|
958
|
+
this._objectResolvers.delete(location);
|
|
959
|
+
this._objectBuffer.delete(location);
|
|
934
960
|
}
|
|
935
|
-
/**
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
*/
|
|
939
|
-
_deliverObject(objectId, object) {
|
|
940
|
-
const resolver = this._objectResolvers.get(objectId);
|
|
961
|
+
/** @internal */
|
|
962
|
+
_deliverObject(location, object) {
|
|
963
|
+
const resolver = this._objectResolvers.get(location);
|
|
941
964
|
if (resolver) {
|
|
942
965
|
resolver(object);
|
|
943
|
-
this._objectResolvers.delete(
|
|
966
|
+
this._objectResolvers.delete(location);
|
|
944
967
|
}
|
|
945
968
|
else {
|
|
946
|
-
|
|
947
|
-
this._objectBuffer.set(objectId, object);
|
|
969
|
+
this._objectBuffer.set(location, object);
|
|
948
970
|
}
|
|
949
971
|
}
|
|
950
972
|
/**
|
|
@@ -952,7 +974,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
952
974
|
* @internal
|
|
953
975
|
*/
|
|
954
976
|
handleChannelEvent(event) {
|
|
955
|
-
// Ignore events after close - the channel is being torn down
|
|
956
977
|
if (this._closed)
|
|
957
978
|
return;
|
|
958
979
|
const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
@@ -961,23 +982,31 @@ export class RoolChannel extends EventEmitter {
|
|
|
961
982
|
// Resync is handled by the client via _applyResyncData.
|
|
962
983
|
break;
|
|
963
984
|
case 'object_created':
|
|
964
|
-
if (event.
|
|
985
|
+
if (event.location && event.object) {
|
|
965
986
|
if (event.objectStat)
|
|
966
|
-
this._objectStats.set(event.
|
|
967
|
-
this._handleObjectCreated(event.
|
|
987
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
988
|
+
this._handleObjectCreated(event.location, event.object, changeSource);
|
|
968
989
|
}
|
|
969
990
|
break;
|
|
970
991
|
case 'object_updated':
|
|
971
|
-
if (event.
|
|
992
|
+
if (event.location && event.object) {
|
|
972
993
|
if (event.objectStat)
|
|
973
|
-
this._objectStats.set(event.
|
|
974
|
-
this._handleObjectUpdated(event.
|
|
994
|
+
this._objectStats.set(event.location, event.objectStat);
|
|
995
|
+
this._handleObjectUpdated(event.location, event.object, changeSource);
|
|
975
996
|
}
|
|
976
997
|
break;
|
|
977
998
|
case 'object_deleted':
|
|
978
|
-
if (event.
|
|
979
|
-
this._objectStats.delete(event.
|
|
980
|
-
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);
|
|
981
1010
|
}
|
|
982
1011
|
break;
|
|
983
1012
|
case 'schema_updated':
|
|
@@ -993,7 +1022,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
993
1022
|
}
|
|
994
1023
|
break;
|
|
995
1024
|
case 'channel_updated':
|
|
996
|
-
// Only update if it's our channel — channel_updated is now metadata-only (name, extensionUrl)
|
|
997
1025
|
if (event.channelId === this._channelId && event.channel) {
|
|
998
1026
|
const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
|
|
999
1027
|
this._channel = event.channel;
|
|
@@ -1003,7 +1031,6 @@ export class RoolChannel extends EventEmitter {
|
|
|
1003
1031
|
}
|
|
1004
1032
|
break;
|
|
1005
1033
|
case 'conversation_updated':
|
|
1006
|
-
// Only update if it's our channel
|
|
1007
1034
|
if (event.channelId === this._channelId && event.conversationId) {
|
|
1008
1035
|
if (!this._channel) {
|
|
1009
1036
|
this._channel = {
|
|
@@ -1014,17 +1041,13 @@ export class RoolChannel extends EventEmitter {
|
|
|
1014
1041
|
}
|
|
1015
1042
|
const prev = this._channel.conversations[event.conversationId];
|
|
1016
1043
|
if (event.conversation) {
|
|
1017
|
-
// Update or create conversation in local cache
|
|
1018
1044
|
this._channel.conversations[event.conversationId] = event.conversation;
|
|
1019
1045
|
}
|
|
1020
1046
|
else {
|
|
1021
|
-
// Conversation was deleted
|
|
1022
1047
|
delete this._channel.conversations[event.conversationId];
|
|
1023
1048
|
}
|
|
1024
|
-
// Skip emit if data is unchanged (e.g. echo of our own optimistic update)
|
|
1025
1049
|
if (JSON.stringify(prev) === JSON.stringify(event.conversation))
|
|
1026
1050
|
break;
|
|
1027
|
-
// Auto-advance active leaf if someone continued our current branch
|
|
1028
1051
|
if (event.conversation && !Array.isArray(event.conversation.interactions)) {
|
|
1029
1052
|
const currentLeaf = this._getActiveLeafImpl(event.conversationId);
|
|
1030
1053
|
if (currentLeaf) {
|
|
@@ -1036,13 +1059,11 @@ export class RoolChannel extends EventEmitter {
|
|
|
1036
1059
|
}
|
|
1037
1060
|
}
|
|
1038
1061
|
}
|
|
1039
|
-
// Emit the new conversationUpdated event
|
|
1040
1062
|
this.emit('conversationUpdated', {
|
|
1041
1063
|
conversationId: event.conversationId,
|
|
1042
1064
|
channelId: event.channelId,
|
|
1043
1065
|
source: changeSource,
|
|
1044
1066
|
});
|
|
1045
|
-
// Backward compat: also emit channelUpdated when the active conversation updates
|
|
1046
1067
|
if (event.conversationId === this._conversationId) {
|
|
1047
1068
|
this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
|
|
1048
1069
|
}
|
|
@@ -1053,87 +1074,75 @@ export class RoolChannel extends EventEmitter {
|
|
|
1053
1074
|
break;
|
|
1054
1075
|
}
|
|
1055
1076
|
}
|
|
1056
|
-
/**
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
// Deliver to any pending collector (for mutation return values)
|
|
1063
|
-
this._deliverObject(objectId, object);
|
|
1064
|
-
// Maintain local ID list — prepend (most recently modified first)
|
|
1065
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1066
|
-
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);
|
|
1067
1083
|
if (pending !== undefined) {
|
|
1068
|
-
|
|
1069
|
-
this._pendingMutations.delete(objectId);
|
|
1084
|
+
this._pendingMutations.delete(location);
|
|
1070
1085
|
if (pending !== null) {
|
|
1071
|
-
//
|
|
1086
|
+
// Already emitted objectCreated optimistically.
|
|
1072
1087
|
// Emit objectUpdated only if AI resolved placeholders (data changed).
|
|
1073
1088
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1074
|
-
this.emit('objectUpdated', {
|
|
1089
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1075
1090
|
}
|
|
1076
1091
|
}
|
|
1077
1092
|
}
|
|
1078
1093
|
else {
|
|
1079
|
-
|
|
1080
|
-
this.emit('objectCreated', { objectId, object, source });
|
|
1094
|
+
this.emit('objectCreated', { location, object, source });
|
|
1081
1095
|
}
|
|
1082
1096
|
}
|
|
1083
|
-
/**
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
_handleObjectUpdated(objectId, object, source) {
|
|
1089
|
-
// Deliver to any pending collector
|
|
1090
|
-
this._deliverObject(objectId, object);
|
|
1091
|
-
// Maintain local ID list — move to front (most recently modified)
|
|
1092
|
-
this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
|
|
1093
|
-
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);
|
|
1094
1102
|
if (pending !== undefined) {
|
|
1095
|
-
|
|
1096
|
-
this._pendingMutations.delete(objectId);
|
|
1103
|
+
this._pendingMutations.delete(location);
|
|
1097
1104
|
if (pending !== null) {
|
|
1098
|
-
// Already emitted objectUpdated optimistically.
|
|
1099
|
-
// Emit again only if data changed (AI resolved placeholders).
|
|
1100
1105
|
if (JSON.stringify(pending) !== JSON.stringify(object)) {
|
|
1101
|
-
this.emit('objectUpdated', {
|
|
1106
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1102
1107
|
}
|
|
1103
1108
|
}
|
|
1104
1109
|
}
|
|
1105
1110
|
else {
|
|
1106
|
-
|
|
1107
|
-
this.emit('objectUpdated', { objectId, object, source });
|
|
1111
|
+
this.emit('objectUpdated', { location, object, source });
|
|
1108
1112
|
}
|
|
1109
1113
|
}
|
|
1110
|
-
/**
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
*/
|
|
1115
|
-
_handleObjectDeleted(objectId, source) {
|
|
1116
|
-
// Remove from local ID list
|
|
1117
|
-
this._objectIds = this._objectIds.filter(id => id !== objectId);
|
|
1118
|
-
const pending = this._pendingMutations.get(objectId);
|
|
1114
|
+
/** @internal */
|
|
1115
|
+
_handleObjectDeleted(location, source) {
|
|
1116
|
+
this._objectLocations = this._objectLocations.filter(l => l !== location);
|
|
1117
|
+
const pending = this._pendingMutations.get(location);
|
|
1119
1118
|
if (pending !== undefined) {
|
|
1120
|
-
|
|
1121
|
-
this._pendingMutations.delete(objectId);
|
|
1119
|
+
this._pendingMutations.delete(location);
|
|
1122
1120
|
}
|
|
1123
1121
|
else {
|
|
1124
|
-
|
|
1125
|
-
|
|
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);
|
|
1131
|
+
if (pending !== undefined) {
|
|
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
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
this.emit('objectMoved', { from, to, object, source });
|
|
1126
1141
|
}
|
|
1127
1142
|
}
|
|
1128
1143
|
}
|
|
1129
1144
|
/**
|
|
1130
1145
|
* A lightweight handle for a specific conversation within a channel.
|
|
1131
|
-
*
|
|
1132
|
-
* Scopes AI and mutation operations to a particular conversation's interaction
|
|
1133
|
-
* history, while sharing the channel's single SSE connection and object state.
|
|
1134
|
-
*
|
|
1135
|
-
* Obtain via `channel.conversation('thread-id')`.
|
|
1136
|
-
* Conversations are auto-created on first interaction.
|
|
1137
1146
|
*/
|
|
1138
1147
|
export class ConversationHandle {
|
|
1139
1148
|
/** @internal */
|
|
@@ -1179,16 +1188,20 @@ export class ConversationHandle {
|
|
|
1179
1188
|
return this._channel._findObjectsImpl(options, this._conversationId);
|
|
1180
1189
|
}
|
|
1181
1190
|
/** Create a new object. */
|
|
1182
|
-
async createObject(options) {
|
|
1183
|
-
return this._channel._createObjectImpl(options, this._conversationId);
|
|
1191
|
+
async createObject(collection, body, options) {
|
|
1192
|
+
return this._channel._createObjectImpl(collection, body, options, this._conversationId);
|
|
1184
1193
|
}
|
|
1185
1194
|
/** Update an existing object. */
|
|
1186
|
-
async updateObject(
|
|
1187
|
-
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);
|
|
1188
1201
|
}
|
|
1189
|
-
/** Delete objects by
|
|
1190
|
-
async deleteObjects(
|
|
1191
|
-
return this._channel._deleteObjectsImpl(
|
|
1202
|
+
/** Delete objects by location. */
|
|
1203
|
+
async deleteObjects(locations) {
|
|
1204
|
+
return this._channel._deleteObjectsImpl(locations, this._conversationId);
|
|
1192
1205
|
}
|
|
1193
1206
|
/** Send a prompt to the AI agent, scoped to this conversation's history. */
|
|
1194
1207
|
async prompt(text, options) {
|