@rimori/client 2.5.44 → 2.5.45
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/dist/fromRimori/EventBus.d.ts +7 -0
- package/dist/fromRimori/EventBus.js +9 -0
- package/dist/fromRimori/PluginTypes.d.ts +23 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin/CommunicationHandler.d.ts +1 -1
- package/dist/plugin/CommunicationHandler.js +5 -1
- package/dist/plugin/RimoriClient.d.ts +2 -2
- package/dist/plugin/RimoriClient.js +8 -2
- package/dist/plugin/VoiceDebugStore.d.ts +24 -0
- package/dist/plugin/VoiceDebugStore.js +50 -0
- package/dist/plugin/module/EventModule.d.ts +34 -0
- package/dist/plugin/module/EventModule.js +184 -0
- package/package.json +7 -3
|
@@ -39,6 +39,13 @@ export declare class EventBusHandler {
|
|
|
39
39
|
* Starts the interval to cleanup the generated ids.
|
|
40
40
|
*/
|
|
41
41
|
private startIdCleanup;
|
|
42
|
+
/**
|
|
43
|
+
* Generates a unique id for correlating a stream of chunk events.
|
|
44
|
+
* Exposed for the streaming primitive (`requestStream`/`respondStream`), which carries
|
|
45
|
+
* this id inside the payload (`__streamId`) rather than relying on a one-shot `eventId`.
|
|
46
|
+
* @returns A unique id.
|
|
47
|
+
*/
|
|
48
|
+
generateStreamId(): number;
|
|
42
49
|
/**
|
|
43
50
|
* Generates a unique id.
|
|
44
51
|
* @returns A unique id.
|
|
@@ -46,6 +46,15 @@ export class EventBusHandler {
|
|
|
46
46
|
}
|
|
47
47
|
}, 10000); // Run every 10 seconds
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Generates a unique id for correlating a stream of chunk events.
|
|
51
|
+
* Exposed for the streaming primitive (`requestStream`/`respondStream`), which carries
|
|
52
|
+
* this id inside the payload (`__streamId`) rather than relying on a one-shot `eventId`.
|
|
53
|
+
* @returns A unique id.
|
|
54
|
+
*/
|
|
55
|
+
generateStreamId() {
|
|
56
|
+
return this.generateUniqueId();
|
|
57
|
+
}
|
|
49
58
|
/**
|
|
50
59
|
* Generates a unique id.
|
|
51
60
|
* @returns A unique id.
|
|
@@ -32,6 +32,7 @@ export interface SidebarPage {
|
|
|
32
32
|
description: string;
|
|
33
33
|
url: string;
|
|
34
34
|
icon: string;
|
|
35
|
+
hideInExtension?: boolean;
|
|
35
36
|
}
|
|
36
37
|
export interface MenuEntry {
|
|
37
38
|
plugin_id: string;
|
|
@@ -39,6 +40,7 @@ export interface MenuEntry {
|
|
|
39
40
|
text: string;
|
|
40
41
|
iconUrl?: string;
|
|
41
42
|
args?: Record<string, unknown>;
|
|
43
|
+
hideInExtension?: boolean;
|
|
42
44
|
}
|
|
43
45
|
export type MainPanelAction = {
|
|
44
46
|
plugin_id: string;
|
|
@@ -156,7 +158,7 @@ export type ObjectTool = {
|
|
|
156
158
|
* Defines the structure, validation rules, and metadata for individual tool parameters.
|
|
157
159
|
* Used to create type-safe interfaces between LLMs, plugins, and the Rimori platform.
|
|
158
160
|
*/
|
|
159
|
-
interface ToolParameter {
|
|
161
|
+
export interface ToolParameter {
|
|
160
162
|
/** The data type of the parameter - can be primitive, nested object, or array */
|
|
161
163
|
type: ToolParameterType;
|
|
162
164
|
/** Human-readable description of the parameter's purpose and usage */
|
|
@@ -167,6 +169,26 @@ interface ToolParameter {
|
|
|
167
169
|
optional?: boolean;
|
|
168
170
|
/** Whether the parameter is an array */
|
|
169
171
|
isArray?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* For id-like parameters: a relative event topic (no pluginId prefix) that the owning plugin
|
|
174
|
+
* answers with the selectable options for this parameter. The responder must return
|
|
175
|
+
* `OptionItem[]` ({ title, value }). The host (rimori-main) requests this on the owning plugin —
|
|
176
|
+
* served headlessly by the plugin's web worker — and renders a dropdown of human-readable titles
|
|
177
|
+
* instead of a free-text id input. Topic must follow the event convention, e.g. `deck.requestOptions`.
|
|
178
|
+
*/
|
|
179
|
+
optionsEvent?: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A single selectable option returned by a parameter's `optionsEvent` responder.
|
|
183
|
+
* `title` is shown to the user; `value` is what gets stored in the action parameter.
|
|
184
|
+
*/
|
|
185
|
+
export interface OptionItem {
|
|
186
|
+
/** Human-readable label shown in the dropdown */
|
|
187
|
+
title: string;
|
|
188
|
+
/** The value stored in the exercise/action parameter when this option is picked */
|
|
189
|
+
value: string;
|
|
190
|
+
/** Optional secondary label / grouping hint shown next to the title */
|
|
191
|
+
group?: string;
|
|
170
192
|
}
|
|
171
193
|
/**
|
|
172
194
|
* Union type defining all possible parameter types for LLM tools.
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type { TOptions } from 'i18next';
|
|
|
11
11
|
export { setupWorker } from './worker/WorkerSetup';
|
|
12
12
|
export type { EventBusMessage } from './fromRimori/EventBus';
|
|
13
13
|
export { AudioController } from './controller/AudioController';
|
|
14
|
+
export { voiceDebug } from './plugin/VoiceDebugStore';
|
|
14
15
|
export type { Exercise } from './plugin/module/ExerciseModule';
|
|
15
16
|
export { Translator } from './controller/TranslationController';
|
|
16
17
|
export type { TriggerAction } from './plugin/module/ExerciseModule';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './utils/difficultyConverter';
|
|
|
10
10
|
export * from './plugin/CommunicationHandler';
|
|
11
11
|
export { setupWorker } from './worker/WorkerSetup';
|
|
12
12
|
export { AudioController } from './controller/AudioController';
|
|
13
|
+
export { voiceDebug } from './plugin/VoiceDebugStore';
|
|
13
14
|
export { Translator } from './controller/TranslationController';
|
|
14
15
|
export { TIER_ORDER, ROLE_ORDER } from './plugin/module/PluginModule';
|
|
15
16
|
export { StorageModule } from './plugin/module/StorageModule';
|
|
@@ -54,7 +54,7 @@ export declare class RimoriCommunicationHandler {
|
|
|
54
54
|
private isMessageChannelReady;
|
|
55
55
|
private pendingRequests;
|
|
56
56
|
private updateCallbacks;
|
|
57
|
-
constructor(pluginId: string, standalone: boolean);
|
|
57
|
+
constructor(pluginId: string, standalone: boolean, queryParams?: Record<string, string>);
|
|
58
58
|
private initMessageChannel;
|
|
59
59
|
private sendHello;
|
|
60
60
|
private sendFinishedInit;
|
|
@@ -9,9 +9,13 @@ export class RimoriCommunicationHandler {
|
|
|
9
9
|
isMessageChannelReady = false;
|
|
10
10
|
pendingRequests = [];
|
|
11
11
|
updateCallbacks = new Set();
|
|
12
|
-
constructor(pluginId, standalone
|
|
12
|
+
constructor(pluginId, standalone,
|
|
13
|
+
// Federation mode skips the MessageChannel handshake that would normally
|
|
14
|
+
// deliver query params, so callers (createWithInfo) inject them directly here.
|
|
15
|
+
queryParams = {}) {
|
|
13
16
|
this.pluginId = pluginId;
|
|
14
17
|
this.getClient = this.getClient.bind(this);
|
|
18
|
+
this.queryParams = queryParams;
|
|
15
19
|
//no need to forward messages to parent in standalone mode or worker context
|
|
16
20
|
if (standalone)
|
|
17
21
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SharedContentController } from './module/SharedContentController';
|
|
2
2
|
import { RimoriInfo } from './CommunicationHandler';
|
|
3
|
-
import { PluginModule } from './module/PluginModule';
|
|
3
|
+
import { ApplicationMode, PluginModule } from './module/PluginModule';
|
|
4
4
|
import { DbModule } from './module/DbModule';
|
|
5
5
|
import { EventModule } from './module/EventModule';
|
|
6
6
|
import { AIModule } from './module/AIModule';
|
|
@@ -29,7 +29,7 @@ export declare class RimoriClient {
|
|
|
29
29
|
* Uses a fresh per-plugin EventBus instance instead of the global singleton.
|
|
30
30
|
* Creates the Supabase PostgrestClient internally from the info.
|
|
31
31
|
*/
|
|
32
|
-
static createWithInfo(info: RimoriInfo): RimoriClient;
|
|
32
|
+
static createWithInfo(info: RimoriInfo, applicationMode?: ApplicationMode): RimoriClient;
|
|
33
33
|
static getInstance(pluginId?: string): Promise<RimoriClient>;
|
|
34
34
|
navigation: {
|
|
35
35
|
toDashboard: () => void;
|
|
@@ -10,6 +10,7 @@ import { StorageModule } from './module/StorageModule';
|
|
|
10
10
|
import { AssetsModule } from './module/AssetsModule';
|
|
11
11
|
import { PostgrestClient } from '@supabase/postgrest-js';
|
|
12
12
|
import { EventBus, EventBusHandler } from '../fromRimori/EventBus';
|
|
13
|
+
import { voiceDebug } from './VoiceDebugStore';
|
|
13
14
|
export class RimoriClient {
|
|
14
15
|
static instance;
|
|
15
16
|
controller;
|
|
@@ -43,15 +44,20 @@ export class RimoriClient {
|
|
|
43
44
|
if (this.plugin.applicationMode !== 'sidebar') {
|
|
44
45
|
Logger.getInstance(this);
|
|
45
46
|
}
|
|
47
|
+
// Expose the dev-only simulated-speech helper (no-op on prod).
|
|
48
|
+
voiceDebug.install();
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Creates a RimoriClient with pre-existing RimoriInfo (federation mode).
|
|
49
52
|
* Uses a fresh per-plugin EventBus instance instead of the global singleton.
|
|
50
53
|
* Creates the Supabase PostgrestClient internally from the info.
|
|
51
54
|
*/
|
|
52
|
-
static createWithInfo(info) {
|
|
55
|
+
static createWithInfo(info, applicationMode = 'main') {
|
|
53
56
|
const eventBus = EventBusHandler.create('Plugin EventBus ' + info.pluginId);
|
|
54
|
-
|
|
57
|
+
// Pass applicationMode as a query param so PluginModule resolves it the same way
|
|
58
|
+
// the iframe handshake would — keeps client.plugin.applicationMode correct in
|
|
59
|
+
// federation (which the context-menu / logger guards rely on).
|
|
60
|
+
const controller = new RimoriCommunicationHandler(info.pluginId, true, { applicationMode });
|
|
55
61
|
controller.handleRimoriInfoUpdate(info);
|
|
56
62
|
const supabase = new PostgrestClient(`${info.url}/rest/v1`, {
|
|
57
63
|
schema: info.dbSchema,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only mechanism to simulate submitted speech in voice chats (e.g. discussion
|
|
3
|
+
* roleplay) so an automated agent can "speak" without a real microphone.
|
|
4
|
+
*
|
|
5
|
+
* Plugins are federated into rimori-main's own window, so plugin code shares
|
|
6
|
+
* rimori-main's `window.location.hostname`. That makes a plain hostname check the
|
|
7
|
+
* authoritative environment signal — no handshake field needed. Prod
|
|
8
|
+
* (`app.rimori.se`) is the only host where this must stay completely off.
|
|
9
|
+
*
|
|
10
|
+
* Flow: the agent queues a transcription (via `window.__rimoriVoiceDebug.queue(text)`
|
|
11
|
+
* or `voiceDebug.queue(text)`), then drives the mic UI normally. `VoiceRecorder`
|
|
12
|
+
* reads the queued text at stop time and feeds it in as if STT had returned it,
|
|
13
|
+
* bypassing the real recording + `/voice/stt` call.
|
|
14
|
+
*/
|
|
15
|
+
export declare const voiceDebug: {
|
|
16
|
+
/** True everywhere except prod (`app.rimori.se`), including dev and all localhost ports. */
|
|
17
|
+
isEnabled(): boolean;
|
|
18
|
+
/** Queue the text that should be treated as the next spoken/transcribed message. */
|
|
19
|
+
queue(text: string): void;
|
|
20
|
+
/** Consume the queued transcription (returns null when nothing is queued). */
|
|
21
|
+
take(): string | null;
|
|
22
|
+
/** Expose `window.__rimoriVoiceDebug` for DevTools / Playwright. Self-guards on prod. */
|
|
23
|
+
install(): void;
|
|
24
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only mechanism to simulate submitted speech in voice chats (e.g. discussion
|
|
3
|
+
* roleplay) so an automated agent can "speak" without a real microphone.
|
|
4
|
+
*
|
|
5
|
+
* Plugins are federated into rimori-main's own window, so plugin code shares
|
|
6
|
+
* rimori-main's `window.location.hostname`. That makes a plain hostname check the
|
|
7
|
+
* authoritative environment signal — no handshake field needed. Prod
|
|
8
|
+
* (`app.rimori.se`) is the only host where this must stay completely off.
|
|
9
|
+
*
|
|
10
|
+
* Flow: the agent queues a transcription (via `window.__rimoriVoiceDebug.queue(text)`
|
|
11
|
+
* or `voiceDebug.queue(text)`), then drives the mic UI normally. `VoiceRecorder`
|
|
12
|
+
* reads the queued text at stop time and feeds it in as if STT had returned it,
|
|
13
|
+
* bypassing the real recording + `/voice/stt` call.
|
|
14
|
+
*/
|
|
15
|
+
// Federated plugins share rimori-main's window, so the host hostname is authoritative.
|
|
16
|
+
// Prod is the only place this must stay off.
|
|
17
|
+
function isProdHost() {
|
|
18
|
+
return typeof window !== 'undefined' && window.location.hostname === 'app.rimori.se';
|
|
19
|
+
}
|
|
20
|
+
let pending = null;
|
|
21
|
+
let installed = false;
|
|
22
|
+
export const voiceDebug = {
|
|
23
|
+
/** True everywhere except prod (`app.rimori.se`), including dev and all localhost ports. */
|
|
24
|
+
isEnabled() {
|
|
25
|
+
return !isProdHost();
|
|
26
|
+
},
|
|
27
|
+
/** Queue the text that should be treated as the next spoken/transcribed message. */
|
|
28
|
+
queue(text) {
|
|
29
|
+
pending = text;
|
|
30
|
+
console.log('[voice-debug] queued transcription:', text);
|
|
31
|
+
},
|
|
32
|
+
/** Consume the queued transcription (returns null when nothing is queued). */
|
|
33
|
+
take() {
|
|
34
|
+
const text = pending;
|
|
35
|
+
pending = null;
|
|
36
|
+
if (text !== null)
|
|
37
|
+
console.log('[voice-debug] injected transcription:', text);
|
|
38
|
+
return text;
|
|
39
|
+
},
|
|
40
|
+
/** Expose `window.__rimoriVoiceDebug` for DevTools / Playwright. Self-guards on prod. */
|
|
41
|
+
install() {
|
|
42
|
+
if (installed || isProdHost() || typeof window === 'undefined')
|
|
43
|
+
return;
|
|
44
|
+
installed = true;
|
|
45
|
+
window.__rimoriVoiceDebug = {
|
|
46
|
+
queue: (text) => voiceDebug.queue(text),
|
|
47
|
+
enabled: true,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -31,6 +31,40 @@ export declare class EventModule {
|
|
|
31
31
|
* @returns The response from the event.
|
|
32
32
|
*/
|
|
33
33
|
request<T>(topic: string, data?: any): Promise<EventBusMessage<T>>;
|
|
34
|
+
/**
|
|
35
|
+
* Request a stream of items from a responder, correlated by a generated `streamId`.
|
|
36
|
+
*
|
|
37
|
+
* Unlike `request` (one-shot), the responder may `push` many items over time; each is
|
|
38
|
+
* delivered to `onItem` in arrival order. The returned promise resolves once the stream
|
|
39
|
+
* terminates (responder finished) with the full collected array, and rejects on a
|
|
40
|
+
* responder error, inactivity timeout, or abort.
|
|
41
|
+
*
|
|
42
|
+
* @param topic The streaming request topic (must use a `request`-prefixed action).
|
|
43
|
+
* @param data The request payload (the caller's session token is forwarded automatically).
|
|
44
|
+
* @param onItem Called for each streamed item as it arrives.
|
|
45
|
+
* @param options.timeoutMs Inactivity timeout, reset on each chunk (default 60s).
|
|
46
|
+
* @param options.signal Abort signal — aborting stops delivery, signals the responder, and rejects.
|
|
47
|
+
* @returns Resolves with `{ items }` on completion.
|
|
48
|
+
*/
|
|
49
|
+
requestStream<TReq extends object = EventPayload, TItem = EventPayload>(topic: string, data: TReq, onItem: (item: TItem) => void, options?: {
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
items: TItem[];
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* Respond to a streaming request (see `requestStream`). The handler receives the request
|
|
57
|
+
* message and a `push` function it calls for each item to stream back; when the handler
|
|
58
|
+
* resolves a `done` is emitted, and if it throws an `error` is emitted instead.
|
|
59
|
+
*
|
|
60
|
+
* Mirrors `respond`'s session-token handling so the streamed AI calls inside the handler
|
|
61
|
+
* are attributed to the requester's session.
|
|
62
|
+
*
|
|
63
|
+
* @param topic The streaming request topic to handle.
|
|
64
|
+
* @param handler Receives the request message and a `push(item)` callback.
|
|
65
|
+
* @returns An EventListener with an `off()` method for cleanup.
|
|
66
|
+
*/
|
|
67
|
+
respondStream<TReq = EventPayload, TItem = EventPayload>(topic: string, handler: (msg: EventBusMessage<TReq>, push: (item: TItem) => void) => Promise<void>): EventListener;
|
|
34
68
|
/**
|
|
35
69
|
* Subscribe to an event.
|
|
36
70
|
* @param topic The topic to subscribe to.
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
import { AccomplishmentController } from '../../controller/AccomplishmentController';
|
|
2
2
|
import { EventBus } from '../../fromRimori/EventBus';
|
|
3
|
+
/** Default inactivity timeout for a stream — reset on every chunk received. */
|
|
4
|
+
const DEFAULT_STREAM_TIMEOUT_MS = 60_000;
|
|
5
|
+
/**
|
|
6
|
+
* Derives the sibling chunk/cancel topics for a streaming request.
|
|
7
|
+
*
|
|
8
|
+
* Routing matters here: the federation bridge (PluginEventBridge / CommunicationHandler)
|
|
9
|
+
* only delivers an inbound event to a plugin when the topic is in **that plugin's**
|
|
10
|
+
* namespace, is global, or is a response to one of its outstanding requests. So:
|
|
11
|
+
* - **Chunks** flow on the REQUESTER's namespace, so the bridge routes them back to the
|
|
12
|
+
* requester. (A responder-namespaced chunk would route to the responder — the original
|
|
13
|
+
* "stream timed out" bug.)
|
|
14
|
+
* - **Cancel** flows on the RESPONDER's namespace (derived from the request topic prefix),
|
|
15
|
+
* so it routes to the responder regardless of which requester sent it.
|
|
16
|
+
*
|
|
17
|
+
* e.g. request `pl772.lookup.requestBulkStream` from requester `pl454`:
|
|
18
|
+
* chunk `pl454.lookup.triggerBulkStreamChunk` (requester ns)
|
|
19
|
+
* cancel `pl772.lookup.triggerBulkStreamCancel` (responder ns)
|
|
20
|
+
* The `trigger` prefix keeps both valid per `validateTopic`.
|
|
21
|
+
*/
|
|
22
|
+
function deriveStreamTopics(requestTopic, requesterId) {
|
|
23
|
+
const [responderId, area, action] = requestTopic.split('.');
|
|
24
|
+
// Strip a leading `request` so the derived action reads cleanly; otherwise capitalise.
|
|
25
|
+
const base = action.startsWith('request')
|
|
26
|
+
? action.slice('request'.length)
|
|
27
|
+
: action.charAt(0).toUpperCase() + action.slice(1);
|
|
28
|
+
return {
|
|
29
|
+
chunkTopic: `${requesterId}.${area}.trigger${base}Chunk`,
|
|
30
|
+
cancelTopic: `${responderId}.${area}.trigger${base}Cancel`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
3
33
|
/**
|
|
4
34
|
* Event module for plugin event bus operations.
|
|
5
35
|
* Provides methods for emitting, listening to, and responding to events.
|
|
@@ -72,6 +102,160 @@ export class EventModule {
|
|
|
72
102
|
const sessionToken = this.aiModule.session.get() ?? undefined;
|
|
73
103
|
return this.eventBus.request(this.pluginId, globalTopic, data, sessionToken);
|
|
74
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Request a stream of items from a responder, correlated by a generated `streamId`.
|
|
107
|
+
*
|
|
108
|
+
* Unlike `request` (one-shot), the responder may `push` many items over time; each is
|
|
109
|
+
* delivered to `onItem` in arrival order. The returned promise resolves once the stream
|
|
110
|
+
* terminates (responder finished) with the full collected array, and rejects on a
|
|
111
|
+
* responder error, inactivity timeout, or abort.
|
|
112
|
+
*
|
|
113
|
+
* @param topic The streaming request topic (must use a `request`-prefixed action).
|
|
114
|
+
* @param data The request payload (the caller's session token is forwarded automatically).
|
|
115
|
+
* @param onItem Called for each streamed item as it arrives.
|
|
116
|
+
* @param options.timeoutMs Inactivity timeout, reset on each chunk (default 60s).
|
|
117
|
+
* @param options.signal Abort signal — aborting stops delivery, signals the responder, and rejects.
|
|
118
|
+
* @returns Resolves with `{ items }` on completion.
|
|
119
|
+
*/
|
|
120
|
+
requestStream(topic, data, onItem, options) {
|
|
121
|
+
const requestTopic = this.getGlobalEventTopic(topic);
|
|
122
|
+
// Chunks come back on our own namespace so the bridge routes them to us.
|
|
123
|
+
const { chunkTopic, cancelTopic } = deriveStreamTopics(requestTopic, this.pluginId);
|
|
124
|
+
const streamId = this.eventBus.generateStreamId();
|
|
125
|
+
const sessionToken = this.aiModule.session.get() ?? undefined;
|
|
126
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS;
|
|
127
|
+
const signal = options?.signal;
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const items = [];
|
|
130
|
+
let timer = null;
|
|
131
|
+
let settled = false;
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
settled = true;
|
|
134
|
+
listener.off();
|
|
135
|
+
if (timer)
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
signal?.removeEventListener('abort', onAbort);
|
|
138
|
+
};
|
|
139
|
+
const resetTimer = () => {
|
|
140
|
+
if (timer)
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
cleanup();
|
|
146
|
+
reject(new Error(`Stream timed out after ${timeoutMs}ms with no activity`));
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
};
|
|
149
|
+
const onAbort = () => {
|
|
150
|
+
if (settled)
|
|
151
|
+
return;
|
|
152
|
+
cleanup();
|
|
153
|
+
// Best-effort: tell the responder to stop producing for this stream.
|
|
154
|
+
this.eventBus.emit(this.pluginId, cancelTopic, { __streamId: streamId });
|
|
155
|
+
reject(new DOMException('Stream aborted', 'AbortError'));
|
|
156
|
+
};
|
|
157
|
+
// Subscribe BEFORE emitting the request so a synchronous responder (federated
|
|
158
|
+
// in-process bridge) cannot deliver chunks before we are listening.
|
|
159
|
+
const listener = this.eventBus.on(chunkTopic, ({ data }) => {
|
|
160
|
+
if (data.__streamId !== streamId || settled)
|
|
161
|
+
return; // correlate by streamId
|
|
162
|
+
resetTimer();
|
|
163
|
+
if (data.type === 'item') {
|
|
164
|
+
items.push(data.payload);
|
|
165
|
+
onItem(data.payload);
|
|
166
|
+
}
|
|
167
|
+
else if (data.type === 'done') {
|
|
168
|
+
cleanup();
|
|
169
|
+
resolve({ items });
|
|
170
|
+
}
|
|
171
|
+
else if (data.type === 'error') {
|
|
172
|
+
cleanup();
|
|
173
|
+
reject(new Error(data.error));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
if (signal) {
|
|
177
|
+
if (signal.aborted) {
|
|
178
|
+
onAbort();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
signal.addEventListener('abort', onAbort);
|
|
182
|
+
}
|
|
183
|
+
resetTimer();
|
|
184
|
+
// Forward the caller's session token so the responder's AI calls attribute to the
|
|
185
|
+
// active exercise session (parity with `request`).
|
|
186
|
+
this.eventBus.emit(this.pluginId, requestTopic, { __streamId: streamId, ...data }, undefined, sessionToken);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Respond to a streaming request (see `requestStream`). The handler receives the request
|
|
191
|
+
* message and a `push` function it calls for each item to stream back; when the handler
|
|
192
|
+
* resolves a `done` is emitted, and if it throws an `error` is emitted instead.
|
|
193
|
+
*
|
|
194
|
+
* Mirrors `respond`'s session-token handling so the streamed AI calls inside the handler
|
|
195
|
+
* are attributed to the requester's session.
|
|
196
|
+
*
|
|
197
|
+
* @param topic The streaming request topic to handle.
|
|
198
|
+
* @param handler Receives the request message and a `push(item)` callback.
|
|
199
|
+
* @returns An EventListener with an `off()` method for cleanup.
|
|
200
|
+
*/
|
|
201
|
+
respondStream(topic, handler) {
|
|
202
|
+
const requestTopic = this.getGlobalEventTopic(topic);
|
|
203
|
+
// Cancel arrives on our (responder) namespace; chunkTopic is per-request (requester's ns).
|
|
204
|
+
const { cancelTopic } = deriveStreamTopics(requestTopic, this.pluginId);
|
|
205
|
+
// Streams the requester aborted — push() becomes a no-op and we skip the final done.
|
|
206
|
+
const cancelled = new Set();
|
|
207
|
+
const cancelListener = this.eventBus.on(cancelTopic, ({ data }) => {
|
|
208
|
+
cancelled.add(data.__streamId);
|
|
209
|
+
});
|
|
210
|
+
const requestListener = this.eventBus.on(requestTopic, async (event) => {
|
|
211
|
+
const streamId = event.data?.__streamId;
|
|
212
|
+
if (streamId === undefined)
|
|
213
|
+
return; // not a stream request
|
|
214
|
+
// Reply on the REQUESTER's namespace (event.sender) so chunks route back to them.
|
|
215
|
+
const { chunkTopic } = deriveStreamTopics(requestTopic, event.sender);
|
|
216
|
+
const emitChunk = (chunk) => {
|
|
217
|
+
this.eventBus.emit(this.pluginId, chunkTopic, chunk);
|
|
218
|
+
};
|
|
219
|
+
const previousToken = this.aiModule.session.get();
|
|
220
|
+
if (event.ai_session_token) {
|
|
221
|
+
this.aiModule.session.set(event.ai_session_token);
|
|
222
|
+
}
|
|
223
|
+
const push = (item) => {
|
|
224
|
+
if (cancelled.has(streamId))
|
|
225
|
+
return;
|
|
226
|
+
emitChunk({ __streamId: streamId, type: 'item', payload: item });
|
|
227
|
+
};
|
|
228
|
+
try {
|
|
229
|
+
await handler(event, push);
|
|
230
|
+
if (!cancelled.has(streamId))
|
|
231
|
+
emitChunk({ __streamId: streamId, type: 'done' });
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (!cancelled.has(streamId)) {
|
|
235
|
+
emitChunk({ __streamId: streamId, type: 'error', error: err instanceof Error ? err.message : String(err) });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
cancelled.delete(streamId);
|
|
240
|
+
if (event.ai_session_token) {
|
|
241
|
+
if (previousToken) {
|
|
242
|
+
this.aiModule.session.set(previousToken);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
this.aiModule.session.clear();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
// Ignore our own sender so the responder never reacts to a request it emitted itself.
|
|
251
|
+
[this.pluginId]);
|
|
252
|
+
return {
|
|
253
|
+
off: () => {
|
|
254
|
+
requestListener.off();
|
|
255
|
+
cancelListener.off();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
75
259
|
/**
|
|
76
260
|
* Subscribe to an event.
|
|
77
261
|
* @param topic The topic to subscribe to.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimori/client",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.45",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"globals": "^16.4.0",
|
|
44
44
|
"prettier": "^3.6.2",
|
|
45
45
|
"typescript": "^5.7.2",
|
|
46
|
-
"typescript-eslint": "^8.46.0"
|
|
46
|
+
"typescript-eslint": "^8.46.0",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
50
|
"sync-db-types": "node ./scripts/sync-db-types.mjs",
|
|
@@ -52,6 +53,9 @@
|
|
|
52
53
|
"build": "tsc",
|
|
53
54
|
"dev": "tsc -w --preserveWatchOutput",
|
|
54
55
|
"lint": "pnpm exec eslint . --fix",
|
|
55
|
-
"format": "prettier --write ."
|
|
56
|
+
"format": "prettier --write .",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"test:unit": "vitest run"
|
|
56
60
|
}
|
|
57
61
|
}
|