@jupyter-ai/persona-manager 0.2.0-a6 → 0.2.0
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/lib/index.js +7 -6
- package/lib/persona-events.d.ts +35 -3
- package/lib/persona-events.js +55 -8
- package/lib/stop-button.d.ts +16 -4
- package/lib/stop-button.js +39 -16
- package/package.json +2 -3
- package/src/__tests__/persona-events.spec.ts +86 -17
- package/src/index.ts +9 -10
- package/src/persona-events.ts +58 -21
- package/src/stop-button.tsx +46 -17
package/lib/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { IChatCommandRegistry, IInputToolbarRegistryFactory, InputToolbarRegistry } from '@jupyter/chat';
|
|
2
|
-
import { IEventListener } from 'jupyterlab-eventlistener';
|
|
3
2
|
import { PersonaControls } from './persona-controls';
|
|
4
3
|
import { IPersonaControlRegistry, PersonaControlRegistry } from './persona-control-registry';
|
|
5
4
|
import { IPersonaSessionRegistry, PersonaSessionRegistry } from './persona-events';
|
|
@@ -24,16 +23,15 @@ const plugin = {
|
|
|
24
23
|
};
|
|
25
24
|
/**
|
|
26
25
|
* Plugin that provides the shared per-chat persona session-state registry,
|
|
27
|
-
* fed by Jupyter Events via `
|
|
26
|
+
* fed by Jupyter Events via the `ServiceManager` event bus.
|
|
28
27
|
*/
|
|
29
28
|
const sessionRegistryPlugin = {
|
|
30
29
|
id: '@jupyter-ai/persona-manager:session-registry',
|
|
31
30
|
description: 'Provides the per-chat persona session-state registry (fed by Jupyter Events).',
|
|
32
31
|
autoStart: true,
|
|
33
32
|
provides: IPersonaSessionRegistry,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return new PersonaSessionRegistry(eventListener);
|
|
33
|
+
activate: (app) => {
|
|
34
|
+
return new PersonaSessionRegistry(app.serviceManager.events);
|
|
37
35
|
}
|
|
38
36
|
};
|
|
39
37
|
/**
|
|
@@ -87,6 +85,9 @@ const toolbarPlugin = {
|
|
|
87
85
|
sessionRegistry,
|
|
88
86
|
events
|
|
89
87
|
});
|
|
88
|
+
// Wrap the stop button to inject the session registry, so it can enable
|
|
89
|
+
// itself while a persona in the chat is processing a message.
|
|
90
|
+
const StopButtonItem = (itemProps) => StopButton({ ...itemProps, sessionRegistry });
|
|
90
91
|
return {
|
|
91
92
|
create: () => {
|
|
92
93
|
// Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
|
|
@@ -100,7 +101,7 @@ const toolbarPlugin = {
|
|
|
100
101
|
// toolbar's cancel button (10); a tie would leave the order to
|
|
101
102
|
// insertion rather than position.
|
|
102
103
|
registry.addItem('stop', {
|
|
103
|
-
element:
|
|
104
|
+
element: StopButtonItem,
|
|
104
105
|
position: 7
|
|
105
106
|
});
|
|
106
107
|
return registry;
|
package/lib/persona-events.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Frontend persona session state, delivered over Jupyter Events (via the
|
|
3
|
+
* `ServiceManager` event bus), replacing the previous Yjs-awareness channel.
|
|
4
|
+
*
|
|
5
|
+
* Flow: the server emits `personas` / `persona_state` events (each carrying the
|
|
6
|
+
* chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
|
|
7
|
+
* per-chat `PersonaManagerSessionState`, which holds the persona list and a
|
|
8
|
+
* `PersonaSessionState` per persona and fires a Lumino `changed` signal. React
|
|
9
|
+
* components listen to that signal. When a chat closes, its state is discarded.
|
|
10
|
+
*
|
|
11
|
+
* Names mirror the Python package (`PersonaManagerSessionState`,
|
|
12
|
+
* `PersonaSessionState`).
|
|
13
|
+
*/
|
|
14
|
+
import { Event } from '@jupyterlab/services';
|
|
2
15
|
import { Token } from '@lumino/coreutils';
|
|
3
16
|
import { IDisposable } from '@lumino/disposable';
|
|
4
17
|
import { ISignal } from '@lumino/signaling';
|
|
@@ -14,6 +27,7 @@ type PersonaStatePayload = {
|
|
|
14
27
|
settings?: SettingConfiguration[];
|
|
15
28
|
usage?: Usage;
|
|
16
29
|
slash_commands?: CommandOption[];
|
|
30
|
+
processing?: boolean;
|
|
17
31
|
};
|
|
18
32
|
/**
|
|
19
33
|
* One persona's live session state, built from a `persona_state` event.
|
|
@@ -27,6 +41,15 @@ export declare class PersonaSessionState {
|
|
|
27
41
|
readonly settings: SettingConfiguration[];
|
|
28
42
|
readonly usage: Usage;
|
|
29
43
|
readonly slash_commands: CommandOption[];
|
|
44
|
+
/** Whether the persona is currently processing a message. */
|
|
45
|
+
readonly processing: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Merge a `persona_state` event payload onto this state, returning a new
|
|
48
|
+
* instance. Attributes the event omits are carried forward unchanged, so a
|
|
49
|
+
* partial event (e.g. usage-only) replaces only what it carries. A new
|
|
50
|
+
* reference is returned so React consumers re-render.
|
|
51
|
+
*/
|
|
52
|
+
withUpdate(payload: PersonaStatePayload): PersonaSessionState;
|
|
30
53
|
}
|
|
31
54
|
/**
|
|
32
55
|
* The per-chat manager session state: the persona list plus a
|
|
@@ -44,9 +67,18 @@ export declare class PersonaManagerSessionState implements IDisposable {
|
|
|
44
67
|
get personas(): PersonaOption[];
|
|
45
68
|
/** A persona's session state, or undefined if it has not published yet. */
|
|
46
69
|
getPersona(id: string): PersonaSessionState | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Whether any persona in this chat is currently processing a message. The
|
|
72
|
+
* stop button uses this to enable itself, since a persona can be processing
|
|
73
|
+
* (thinking, running tools, awaiting an agent turn) without actively writing.
|
|
74
|
+
*/
|
|
75
|
+
get processing(): boolean;
|
|
47
76
|
/** Apply a `personas` event payload. */
|
|
48
77
|
updatePersonas(personas: PersonaOption[]): void;
|
|
49
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Apply a `persona_state` event payload for one persona, merging it onto the
|
|
80
|
+
* persona's existing state (absent attributes are left unchanged).
|
|
81
|
+
*/
|
|
50
82
|
updatePersonaState(personaId: string, payload: PersonaStatePayload): void;
|
|
51
83
|
get isDisposed(): boolean;
|
|
52
84
|
dispose(): void;
|
|
@@ -64,7 +96,7 @@ export declare class PersonaManagerSessionState implements IDisposable {
|
|
|
64
96
|
* controls and the slash-command provider.
|
|
65
97
|
*/
|
|
66
98
|
export declare class PersonaSessionRegistry {
|
|
67
|
-
constructor(
|
|
99
|
+
constructor(events: Event.IManager);
|
|
68
100
|
/**
|
|
69
101
|
* Get (or create) the manager session state for a chat. Components call this
|
|
70
102
|
* with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
|
package/lib/persona-events.js
CHANGED
|
@@ -11,12 +11,29 @@ export const PERSONA_SELECTED_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupy
|
|
|
11
11
|
*/
|
|
12
12
|
export class PersonaSessionState {
|
|
13
13
|
constructor(id, payload = {}) {
|
|
14
|
-
var _a, _b, _c, _d;
|
|
14
|
+
var _a, _b, _c, _d, _e;
|
|
15
15
|
this.id = id;
|
|
16
16
|
this.model = (_a = payload.model) !== null && _a !== void 0 ? _a : EMPTY_MODEL_CONFIGURATION;
|
|
17
17
|
this.settings = (_b = payload.settings) !== null && _b !== void 0 ? _b : [];
|
|
18
18
|
this.usage = { ...EMPTY_USAGE, ...((_c = payload.usage) !== null && _c !== void 0 ? _c : {}) };
|
|
19
19
|
this.slash_commands = (_d = payload.slash_commands) !== null && _d !== void 0 ? _d : [];
|
|
20
|
+
this.processing = (_e = payload.processing) !== null && _e !== void 0 ? _e : false;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Merge a `persona_state` event payload onto this state, returning a new
|
|
24
|
+
* instance. Attributes the event omits are carried forward unchanged, so a
|
|
25
|
+
* partial event (e.g. usage-only) replaces only what it carries. A new
|
|
26
|
+
* reference is returned so React consumers re-render.
|
|
27
|
+
*/
|
|
28
|
+
withUpdate(payload) {
|
|
29
|
+
var _a, _b, _c, _d, _e;
|
|
30
|
+
return new PersonaSessionState(this.id, {
|
|
31
|
+
model: (_a = payload.model) !== null && _a !== void 0 ? _a : this.model,
|
|
32
|
+
settings: (_b = payload.settings) !== null && _b !== void 0 ? _b : this.settings,
|
|
33
|
+
usage: (_c = payload.usage) !== null && _c !== void 0 ? _c : this.usage,
|
|
34
|
+
slash_commands: (_d = payload.slash_commands) !== null && _d !== void 0 ? _d : this.slash_commands,
|
|
35
|
+
processing: (_e = payload.processing) !== null && _e !== void 0 ? _e : this.processing
|
|
36
|
+
});
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
/**
|
|
@@ -49,15 +66,35 @@ export class PersonaManagerSessionState {
|
|
|
49
66
|
getPersona(id) {
|
|
50
67
|
return this._states.get(id);
|
|
51
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether any persona in this chat is currently processing a message. The
|
|
71
|
+
* stop button uses this to enable itself, since a persona can be processing
|
|
72
|
+
* (thinking, running tools, awaiting an agent turn) without actively writing.
|
|
73
|
+
*/
|
|
74
|
+
get processing() {
|
|
75
|
+
for (const state of this._states.values()) {
|
|
76
|
+
if (state.processing) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
52
82
|
/** Apply a `personas` event payload. */
|
|
53
83
|
updatePersonas(personas) {
|
|
54
84
|
this._personasReceived = true;
|
|
55
85
|
this._personas = personas;
|
|
56
86
|
this._changed.emit();
|
|
57
87
|
}
|
|
58
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Apply a `persona_state` event payload for one persona, merging it onto the
|
|
90
|
+
* persona's existing state (absent attributes are left unchanged).
|
|
91
|
+
*/
|
|
59
92
|
updatePersonaState(personaId, payload) {
|
|
60
|
-
this._states.
|
|
93
|
+
const prev = this._states.get(personaId);
|
|
94
|
+
const next = prev
|
|
95
|
+
? prev.withUpdate(payload)
|
|
96
|
+
: new PersonaSessionState(personaId, payload);
|
|
97
|
+
this._states.set(personaId, next);
|
|
61
98
|
this._changed.emit();
|
|
62
99
|
}
|
|
63
100
|
get isDisposed() {
|
|
@@ -80,15 +117,15 @@ export class PersonaManagerSessionState {
|
|
|
80
117
|
* controls and the slash-command provider.
|
|
81
118
|
*/
|
|
82
119
|
export class PersonaSessionRegistry {
|
|
83
|
-
constructor(
|
|
84
|
-
this._onPersonasEvent = async (
|
|
120
|
+
constructor(events) {
|
|
121
|
+
this._onPersonasEvent = async (event) => {
|
|
85
122
|
const data = event;
|
|
86
123
|
if (!data.chat_id) {
|
|
87
124
|
return;
|
|
88
125
|
}
|
|
89
126
|
this.get(data.chat_id).updatePersonas(Array.isArray(data.personas) ? data.personas : []);
|
|
90
127
|
};
|
|
91
|
-
this._onPersonaStateEvent = async (
|
|
128
|
+
this._onPersonaStateEvent = async (event) => {
|
|
92
129
|
const data = event;
|
|
93
130
|
if (!data.chat_id || !data.persona_id) {
|
|
94
131
|
return;
|
|
@@ -96,8 +133,18 @@ export class PersonaSessionRegistry {
|
|
|
96
133
|
this.get(data.chat_id).updatePersonaState(data.persona_id, data);
|
|
97
134
|
};
|
|
98
135
|
this._byChatId = new Map();
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
// The ServiceManager event bus (JupyterLab >= 4.0) exposes a single shared
|
|
137
|
+
// stream of all Jupyter Events; filter it by schema id to route the two
|
|
138
|
+
// persona event types. This supersedes the former `jupyterlab-eventlistener`
|
|
139
|
+
// dependency.
|
|
140
|
+
events.stream.connect((_, emission) => {
|
|
141
|
+
if (emission.schema_id === PERSONAS_EVENT_SCHEMA_ID) {
|
|
142
|
+
void this._onPersonasEvent(emission);
|
|
143
|
+
}
|
|
144
|
+
else if (emission.schema_id === PERSONA_STATE_EVENT_SCHEMA_ID) {
|
|
145
|
+
void this._onPersonaStateEvent(emission);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
101
148
|
}
|
|
102
149
|
/**
|
|
103
150
|
* Get (or create) the manager session state for a chat. Components call this
|
package/lib/stop-button.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { InputToolbarRegistry } from '@jupyter/chat';
|
|
2
|
+
import { PersonaSessionRegistry } from './persona-events';
|
|
2
3
|
/**
|
|
3
|
-
* A stop button for the chat input toolbar.
|
|
4
|
-
*
|
|
5
|
-
* and calls the persona cancel endpoint on
|
|
4
|
+
* A stop button for the chat input toolbar. Enables itself while a persona in
|
|
5
|
+
* the chat is processing a message — tracked via the persona-state events fed
|
|
6
|
+
* into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
|
|
7
|
+
* click.
|
|
8
|
+
*
|
|
9
|
+
* This tracks *processing* rather than the chat's writers list: a persona can
|
|
10
|
+
* be processing (thinking, running tools, awaiting an agent turn) without
|
|
11
|
+
* actively writing, and should be interruptible the whole time.
|
|
6
12
|
*/
|
|
7
|
-
export declare function StopButton(props: InputToolbarRegistry.IToolbarItemProps
|
|
13
|
+
export declare function StopButton(props: InputToolbarRegistry.IToolbarItemProps & {
|
|
14
|
+
/**
|
|
15
|
+
* The per-chat persona session-state registry (fed by Jupyter Events).
|
|
16
|
+
* Optional so the button still renders (permanently disabled) without it.
|
|
17
|
+
*/
|
|
18
|
+
sessionRegistry?: PersonaSessionRegistry;
|
|
19
|
+
}): JSX.Element;
|
package/lib/stop-button.js
CHANGED
|
@@ -4,39 +4,62 @@ import { TooltippedIconButton } from '@jupyter/chat';
|
|
|
4
4
|
import { cancelResponse } from './request';
|
|
5
5
|
const STOP_BUTTON_CLASS = 'jp-jai-stopButton';
|
|
6
6
|
/**
|
|
7
|
-
* A stop button for the chat input toolbar.
|
|
8
|
-
*
|
|
9
|
-
* and calls the persona cancel endpoint on
|
|
7
|
+
* A stop button for the chat input toolbar. Enables itself while a persona in
|
|
8
|
+
* the chat is processing a message — tracked via the persona-state events fed
|
|
9
|
+
* into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
|
|
10
|
+
* click.
|
|
11
|
+
*
|
|
12
|
+
* This tracks *processing* rather than the chat's writers list: a persona can
|
|
13
|
+
* be processing (thinking, running tools, awaiting an agent turn) without
|
|
14
|
+
* actively writing, and should be interruptible the whole time.
|
|
10
15
|
*/
|
|
11
16
|
export function StopButton(props) {
|
|
12
|
-
|
|
17
|
+
var _a;
|
|
18
|
+
const { chatModel, sessionRegistry } = props;
|
|
13
19
|
const [disabled, setDisabled] = useState(true);
|
|
14
20
|
const [inFlight, setInFlight] = useState(false);
|
|
15
21
|
const tooltip = 'Stop generating';
|
|
22
|
+
// The chat's stable id is assigned asynchronously (once the model is
|
|
23
|
+
// `ready`), so track it in state and update it when the model becomes ready.
|
|
24
|
+
const [chatId, setChatId] = useState((_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.id) !== null && _a !== void 0 ? _a : null);
|
|
16
25
|
useEffect(() => {
|
|
17
|
-
var _a;
|
|
18
26
|
if (!chatModel) {
|
|
19
|
-
|
|
27
|
+
setChatId(null);
|
|
20
28
|
return;
|
|
21
29
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
void chatModel.ready.then(id => {
|
|
32
|
+
if (!cancelled) {
|
|
33
|
+
setChatId(id !== null && id !== void 0 ? id : null);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
28
36
|
return () => {
|
|
29
|
-
|
|
30
|
-
(_a = chatModel.writersChanged) === null || _a === void 0 ? void 0 : _a.disconnect(checkWriters);
|
|
37
|
+
cancelled = true;
|
|
31
38
|
};
|
|
32
39
|
}, [chatModel]);
|
|
40
|
+
// Enable the button while any persona in this chat is processing. The
|
|
41
|
+
// manager session state fires `changed` on every persona-state update, which
|
|
42
|
+
// includes the processing transitions emitted by `track_processing`.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!sessionRegistry || !chatId) {
|
|
45
|
+
setDisabled(true);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const managerState = sessionRegistry.get(chatId);
|
|
49
|
+
const sync = () => setDisabled(!managerState.processing);
|
|
50
|
+
sync();
|
|
51
|
+
managerState.changed.connect(sync);
|
|
52
|
+
return () => {
|
|
53
|
+
managerState.changed.disconnect(sync);
|
|
54
|
+
};
|
|
55
|
+
}, [sessionRegistry, chatId]);
|
|
33
56
|
async function handleStop() {
|
|
34
|
-
if (!
|
|
57
|
+
if (!chatId) {
|
|
35
58
|
return;
|
|
36
59
|
}
|
|
37
60
|
setInFlight(true);
|
|
38
61
|
try {
|
|
39
|
-
await cancelResponse(
|
|
62
|
+
await cancelResponse(chatId);
|
|
40
63
|
}
|
|
41
64
|
finally {
|
|
42
65
|
setInFlight(false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jupyter-ai/persona-manager",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The core manager & registry for AI personas in Jupyter AI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -61,8 +61,7 @@
|
|
|
61
61
|
"@jupyter/chat": "^0.25.0-alpha.5",
|
|
62
62
|
"@jupyterlab/application": "^4.0.0",
|
|
63
63
|
"@jupyterlab/coreutils": "^6.0.0",
|
|
64
|
-
"@jupyterlab/services": "^7.0.0"
|
|
65
|
-
"jupyterlab-eventlistener": "^0.4.0"
|
|
64
|
+
"@jupyterlab/services": "^7.0.0"
|
|
66
65
|
},
|
|
67
66
|
"devDependencies": {
|
|
68
67
|
"@jupyterlab/builder": "^4.0.0",
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* per-chat routing, the `changed` signal, and discarding a chat's state when it
|
|
4
4
|
* closes.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { Event } from '@jupyterlab/services';
|
|
7
|
+
import { Signal } from '@lumino/signaling';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
PERSONAS_EVENT_SCHEMA_ID,
|
|
@@ -11,31 +12,26 @@ import {
|
|
|
11
12
|
PersonaSessionRegistry
|
|
12
13
|
} from '../persona-events';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A stand-in for the ServiceManager event bus (`Event.IManager`) that lets
|
|
17
|
+
* tests push events onto the shared stream the registry subscribes to.
|
|
18
|
+
*/
|
|
19
|
+
class FakeEventManager {
|
|
20
|
+
readonly stream = new Signal<this, Event.Emission>(this);
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
const list = this._bySchema.get(schemaId) ?? [];
|
|
22
|
-
list.push(listener);
|
|
23
|
-
this._bySchema.set(schemaId, list);
|
|
24
|
-
}
|
|
22
|
+
/** Push an event onto the stream, mirroring the jupyter_server event bus. */
|
|
25
23
|
async emit(schemaId: string, data: any): Promise<void> {
|
|
26
|
-
|
|
27
|
-
await l(null, schemaId, data);
|
|
28
|
-
}
|
|
24
|
+
this.stream.emit({ schema_id: schemaId, ...data });
|
|
29
25
|
}
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
function makeRegistry(): {
|
|
33
29
|
registry: PersonaSessionRegistry;
|
|
34
|
-
events:
|
|
30
|
+
events: FakeEventManager;
|
|
35
31
|
} {
|
|
36
|
-
const events = new
|
|
32
|
+
const events = new FakeEventManager();
|
|
37
33
|
const registry = new PersonaSessionRegistry(
|
|
38
|
-
events as unknown as
|
|
34
|
+
events as unknown as Event.IManager
|
|
39
35
|
);
|
|
40
36
|
return { registry, events };
|
|
41
37
|
}
|
|
@@ -66,6 +62,38 @@ describe('PersonaSessionRegistry', () => {
|
|
|
66
62
|
expect(state?.usage.input_tokens).toBe(5);
|
|
67
63
|
});
|
|
68
64
|
|
|
65
|
+
it('merges partial events, leaving unset attributes unchanged', async () => {
|
|
66
|
+
const { registry, events } = makeRegistry();
|
|
67
|
+
// First event sets the model.
|
|
68
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
69
|
+
chat_id: 'a.chat',
|
|
70
|
+
persona_id: 'p1',
|
|
71
|
+
model: { current: 'm1', options: [], settings: [] }
|
|
72
|
+
});
|
|
73
|
+
// A usage-only event must not drop the previously-set model.
|
|
74
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
75
|
+
chat_id: 'a.chat',
|
|
76
|
+
persona_id: 'p1',
|
|
77
|
+
usage: { input_tokens: 5 }
|
|
78
|
+
});
|
|
79
|
+
const state = registry.get('a.chat').getPersona('p1');
|
|
80
|
+
expect(state?.model.current).toBe('m1');
|
|
81
|
+
expect(state?.usage.input_tokens).toBe(5);
|
|
82
|
+
|
|
83
|
+
// A processing-only event likewise preserves model and usage.
|
|
84
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
85
|
+
chat_id: 'a.chat',
|
|
86
|
+
persona_id: 'p1',
|
|
87
|
+
processing: true
|
|
88
|
+
});
|
|
89
|
+
const after = registry.get('a.chat').getPersona('p1');
|
|
90
|
+
expect(after?.model.current).toBe('m1');
|
|
91
|
+
expect(after?.usage.input_tokens).toBe(5);
|
|
92
|
+
expect(after?.processing).toBe(true);
|
|
93
|
+
// A new instance is produced on each merge, so React consumers re-render.
|
|
94
|
+
expect(after).not.toBe(state);
|
|
95
|
+
});
|
|
96
|
+
|
|
69
97
|
it('fires the changed signal on updates', async () => {
|
|
70
98
|
const { registry, events } = makeRegistry();
|
|
71
99
|
const managerState = registry.get('a.chat');
|
|
@@ -84,6 +112,47 @@ describe('PersonaSessionRegistry', () => {
|
|
|
84
112
|
expect(fired).toBe(2);
|
|
85
113
|
});
|
|
86
114
|
|
|
115
|
+
it('tracks per-persona processing and exposes chat-level processing', async () => {
|
|
116
|
+
const { registry, events } = makeRegistry();
|
|
117
|
+
const managerState = registry.get('a.chat');
|
|
118
|
+
expect(managerState.processing).toBe(false);
|
|
119
|
+
|
|
120
|
+
// A persona reports it started processing.
|
|
121
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
122
|
+
chat_id: 'a.chat',
|
|
123
|
+
persona_id: 'p1',
|
|
124
|
+
processing: true
|
|
125
|
+
});
|
|
126
|
+
expect(managerState.getPersona('p1')?.processing).toBe(true);
|
|
127
|
+
expect(managerState.processing).toBe(true);
|
|
128
|
+
|
|
129
|
+
// A second, idle persona doesn't flip the chat back to idle.
|
|
130
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
131
|
+
chat_id: 'a.chat',
|
|
132
|
+
persona_id: 'p2',
|
|
133
|
+
processing: false
|
|
134
|
+
});
|
|
135
|
+
expect(managerState.processing).toBe(true);
|
|
136
|
+
|
|
137
|
+
// The processing persona reports it finished; chat is now idle.
|
|
138
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
139
|
+
chat_id: 'a.chat',
|
|
140
|
+
persona_id: 'p1',
|
|
141
|
+
processing: false
|
|
142
|
+
});
|
|
143
|
+
expect(managerState.processing).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('defaults persona processing to false when the field is absent', async () => {
|
|
147
|
+
const { registry, events } = makeRegistry();
|
|
148
|
+
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
149
|
+
chat_id: 'a.chat',
|
|
150
|
+
persona_id: 'p1'
|
|
151
|
+
});
|
|
152
|
+
expect(registry.get('a.chat').getPersona('p1')?.processing).toBe(false);
|
|
153
|
+
expect(registry.get('a.chat').processing).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
87
156
|
it('discards a chat session state on close, freeing memory', async () => {
|
|
88
157
|
const { registry, events } = makeRegistry();
|
|
89
158
|
await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,6 @@ import {
|
|
|
9
9
|
InputToolbarRegistry
|
|
10
10
|
} from '@jupyter/chat';
|
|
11
11
|
|
|
12
|
-
import { IEventListener } from 'jupyterlab-eventlistener';
|
|
13
|
-
|
|
14
12
|
import { PersonaControls } from './persona-controls';
|
|
15
13
|
import {
|
|
16
14
|
IPersonaControlRegistry,
|
|
@@ -51,7 +49,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
51
49
|
|
|
52
50
|
/**
|
|
53
51
|
* Plugin that provides the shared per-chat persona session-state registry,
|
|
54
|
-
* fed by Jupyter Events via `
|
|
52
|
+
* fed by Jupyter Events via the `ServiceManager` event bus.
|
|
55
53
|
*/
|
|
56
54
|
const sessionRegistryPlugin: JupyterFrontEndPlugin<PersonaSessionRegistry> = {
|
|
57
55
|
id: '@jupyter-ai/persona-manager:session-registry',
|
|
@@ -59,12 +57,8 @@ const sessionRegistryPlugin: JupyterFrontEndPlugin<PersonaSessionRegistry> = {
|
|
|
59
57
|
'Provides the per-chat persona session-state registry (fed by Jupyter Events).',
|
|
60
58
|
autoStart: true,
|
|
61
59
|
provides: IPersonaSessionRegistry,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
app: JupyterFrontEnd,
|
|
65
|
-
eventListener: IEventListener
|
|
66
|
-
): PersonaSessionRegistry => {
|
|
67
|
-
return new PersonaSessionRegistry(eventListener);
|
|
60
|
+
activate: (app: JupyterFrontEnd): PersonaSessionRegistry => {
|
|
61
|
+
return new PersonaSessionRegistry(app.serviceManager.events);
|
|
68
62
|
}
|
|
69
63
|
};
|
|
70
64
|
|
|
@@ -133,6 +127,11 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
|
|
|
133
127
|
sessionRegistry,
|
|
134
128
|
events
|
|
135
129
|
});
|
|
130
|
+
// Wrap the stop button to inject the session registry, so it can enable
|
|
131
|
+
// itself while a persona in the chat is processing a message.
|
|
132
|
+
const StopButtonItem = (
|
|
133
|
+
itemProps: InputToolbarRegistry.IToolbarItemProps
|
|
134
|
+
) => StopButton({ ...itemProps, sessionRegistry });
|
|
136
135
|
return {
|
|
137
136
|
create: () => {
|
|
138
137
|
// Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
|
|
@@ -146,7 +145,7 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
|
|
|
146
145
|
// toolbar's cancel button (10); a tie would leave the order to
|
|
147
146
|
// insertion rather than position.
|
|
148
147
|
registry.addItem('stop', {
|
|
149
|
-
element:
|
|
148
|
+
element: StopButtonItem,
|
|
150
149
|
position: 7
|
|
151
150
|
});
|
|
152
151
|
return registry;
|
package/src/persona-events.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Frontend persona session state, delivered over Jupyter Events (via
|
|
3
|
-
* `
|
|
2
|
+
* Frontend persona session state, delivered over Jupyter Events (via the
|
|
3
|
+
* `ServiceManager` event bus), replacing the previous Yjs-awareness channel.
|
|
4
4
|
*
|
|
5
5
|
* Flow: the server emits `personas` / `persona_state` events (each carrying the
|
|
6
6
|
* chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* `PersonaSessionState`).
|
|
13
13
|
*/
|
|
14
14
|
import { Event } from '@jupyterlab/services';
|
|
15
|
-
import { IEventListener } from 'jupyterlab-eventlistener';
|
|
16
15
|
import { Token } from '@lumino/coreutils';
|
|
17
16
|
import { IDisposable } from '@lumino/disposable';
|
|
18
17
|
import { ISignal, Signal } from '@lumino/signaling';
|
|
@@ -42,6 +41,7 @@ type PersonaStatePayload = {
|
|
|
42
41
|
settings?: SettingConfiguration[];
|
|
43
42
|
usage?: Usage;
|
|
44
43
|
slash_commands?: CommandOption[];
|
|
44
|
+
processing?: boolean;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/** The wire shape of a `personas` event. */
|
|
@@ -64,12 +64,31 @@ export class PersonaSessionState {
|
|
|
64
64
|
this.settings = payload.settings ?? [];
|
|
65
65
|
this.usage = { ...EMPTY_USAGE, ...(payload.usage ?? {}) };
|
|
66
66
|
this.slash_commands = payload.slash_commands ?? [];
|
|
67
|
+
this.processing = payload.processing ?? false;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
readonly model: ModelConfiguration;
|
|
70
71
|
readonly settings: SettingConfiguration[];
|
|
71
72
|
readonly usage: Usage;
|
|
72
73
|
readonly slash_commands: CommandOption[];
|
|
74
|
+
/** Whether the persona is currently processing a message. */
|
|
75
|
+
readonly processing: boolean;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Merge a `persona_state` event payload onto this state, returning a new
|
|
79
|
+
* instance. Attributes the event omits are carried forward unchanged, so a
|
|
80
|
+
* partial event (e.g. usage-only) replaces only what it carries. A new
|
|
81
|
+
* reference is returned so React consumers re-render.
|
|
82
|
+
*/
|
|
83
|
+
withUpdate(payload: PersonaStatePayload): PersonaSessionState {
|
|
84
|
+
return new PersonaSessionState(this.id, {
|
|
85
|
+
model: payload.model ?? this.model,
|
|
86
|
+
settings: payload.settings ?? this.settings,
|
|
87
|
+
usage: payload.usage ?? this.usage,
|
|
88
|
+
slash_commands: payload.slash_commands ?? this.slash_commands,
|
|
89
|
+
processing: payload.processing ?? this.processing
|
|
90
|
+
});
|
|
91
|
+
}
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
/**
|
|
@@ -100,6 +119,20 @@ export class PersonaManagerSessionState implements IDisposable {
|
|
|
100
119
|
return this._states.get(id);
|
|
101
120
|
}
|
|
102
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Whether any persona in this chat is currently processing a message. The
|
|
124
|
+
* stop button uses this to enable itself, since a persona can be processing
|
|
125
|
+
* (thinking, running tools, awaiting an agent turn) without actively writing.
|
|
126
|
+
*/
|
|
127
|
+
get processing(): boolean {
|
|
128
|
+
for (const state of this._states.values()) {
|
|
129
|
+
if (state.processing) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
/** Apply a `personas` event payload. */
|
|
104
137
|
updatePersonas(personas: PersonaOption[]): void {
|
|
105
138
|
this._personasReceived = true;
|
|
@@ -107,9 +140,16 @@ export class PersonaManagerSessionState implements IDisposable {
|
|
|
107
140
|
this._changed.emit();
|
|
108
141
|
}
|
|
109
142
|
|
|
110
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Apply a `persona_state` event payload for one persona, merging it onto the
|
|
145
|
+
* persona's existing state (absent attributes are left unchanged).
|
|
146
|
+
*/
|
|
111
147
|
updatePersonaState(personaId: string, payload: PersonaStatePayload): void {
|
|
112
|
-
this._states.
|
|
148
|
+
const prev = this._states.get(personaId);
|
|
149
|
+
const next = prev
|
|
150
|
+
? prev.withUpdate(payload)
|
|
151
|
+
: new PersonaSessionState(personaId, payload);
|
|
152
|
+
this._states.set(personaId, next);
|
|
113
153
|
this._changed.emit();
|
|
114
154
|
}
|
|
115
155
|
|
|
@@ -141,15 +181,18 @@ export class PersonaManagerSessionState implements IDisposable {
|
|
|
141
181
|
* controls and the slash-command provider.
|
|
142
182
|
*/
|
|
143
183
|
export class PersonaSessionRegistry {
|
|
144
|
-
constructor(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
constructor(events: Event.IManager) {
|
|
185
|
+
// The ServiceManager event bus (JupyterLab >= 4.0) exposes a single shared
|
|
186
|
+
// stream of all Jupyter Events; filter it by schema id to route the two
|
|
187
|
+
// persona event types. This supersedes the former `jupyterlab-eventlistener`
|
|
188
|
+
// dependency.
|
|
189
|
+
events.stream.connect((_, emission) => {
|
|
190
|
+
if (emission.schema_id === PERSONAS_EVENT_SCHEMA_ID) {
|
|
191
|
+
void this._onPersonasEvent(emission);
|
|
192
|
+
} else if (emission.schema_id === PERSONA_STATE_EVENT_SCHEMA_ID) {
|
|
193
|
+
void this._onPersonaStateEvent(emission);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
/**
|
|
@@ -182,11 +225,7 @@ export class PersonaSessionRegistry {
|
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
227
|
|
|
185
|
-
private _onPersonasEvent = async (
|
|
186
|
-
_manager: unknown,
|
|
187
|
-
_schemaId: string,
|
|
188
|
-
event: Event.Emission
|
|
189
|
-
): Promise<void> => {
|
|
228
|
+
private _onPersonasEvent = async (event: Event.Emission): Promise<void> => {
|
|
190
229
|
const data = event as PersonasPayload;
|
|
191
230
|
if (!data.chat_id) {
|
|
192
231
|
return;
|
|
@@ -197,8 +236,6 @@ export class PersonaSessionRegistry {
|
|
|
197
236
|
};
|
|
198
237
|
|
|
199
238
|
private _onPersonaStateEvent = async (
|
|
200
|
-
_manager: unknown,
|
|
201
|
-
_schemaId: string,
|
|
202
239
|
event: Event.Emission
|
|
203
240
|
): Promise<void> => {
|
|
204
241
|
const data = event as PersonaStatePayload;
|
package/src/stop-button.tsx
CHANGED
|
@@ -2,49 +2,78 @@ import React, { useEffect, useState } from 'react';
|
|
|
2
2
|
import StopIcon from '@mui/icons-material/Stop';
|
|
3
3
|
import { InputToolbarRegistry, TooltippedIconButton } from '@jupyter/chat';
|
|
4
4
|
import { cancelResponse } from './request';
|
|
5
|
+
import { PersonaSessionRegistry } from './persona-events';
|
|
5
6
|
|
|
6
7
|
const STOP_BUTTON_CLASS = 'jp-jai-stopButton';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* A stop button for the chat input toolbar.
|
|
10
|
-
*
|
|
11
|
-
* and calls the persona cancel endpoint on
|
|
10
|
+
* A stop button for the chat input toolbar. Enables itself while a persona in
|
|
11
|
+
* the chat is processing a message — tracked via the persona-state events fed
|
|
12
|
+
* into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
|
|
13
|
+
* click.
|
|
14
|
+
*
|
|
15
|
+
* This tracks *processing* rather than the chat's writers list: a persona can
|
|
16
|
+
* be processing (thinking, running tools, awaiting an agent turn) without
|
|
17
|
+
* actively writing, and should be interruptible the whole time.
|
|
12
18
|
*/
|
|
13
19
|
export function StopButton(
|
|
14
|
-
props: InputToolbarRegistry.IToolbarItemProps
|
|
20
|
+
props: InputToolbarRegistry.IToolbarItemProps & {
|
|
21
|
+
/**
|
|
22
|
+
* The per-chat persona session-state registry (fed by Jupyter Events).
|
|
23
|
+
* Optional so the button still renders (permanently disabled) without it.
|
|
24
|
+
*/
|
|
25
|
+
sessionRegistry?: PersonaSessionRegistry;
|
|
26
|
+
}
|
|
15
27
|
): JSX.Element {
|
|
16
|
-
const { chatModel } = props;
|
|
28
|
+
const { chatModel, sessionRegistry } = props;
|
|
17
29
|
const [disabled, setDisabled] = useState(true);
|
|
18
30
|
const [inFlight, setInFlight] = useState(false);
|
|
19
31
|
const tooltip = 'Stop generating';
|
|
20
32
|
|
|
33
|
+
// The chat's stable id is assigned asynchronously (once the model is
|
|
34
|
+
// `ready`), so track it in state and update it when the model becomes ready.
|
|
35
|
+
const [chatId, setChatId] = useState<string | null>(chatModel?.id ?? null);
|
|
21
36
|
useEffect(() => {
|
|
22
37
|
if (!chatModel) {
|
|
23
|
-
|
|
38
|
+
setChatId(null);
|
|
24
39
|
return;
|
|
25
40
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
void chatModel.ready.then(id => {
|
|
43
|
+
if (!cancelled) {
|
|
44
|
+
setChatId(id ?? null);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
30
49
|
};
|
|
50
|
+
}, [chatModel]);
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
// Enable the button while any persona in this chat is processing. The
|
|
53
|
+
// manager session state fires `changed` on every persona-state update, which
|
|
54
|
+
// includes the processing transitions emitted by `track_processing`.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!sessionRegistry || !chatId) {
|
|
57
|
+
setDisabled(true);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const managerState = sessionRegistry.get(chatId);
|
|
61
|
+
const sync = () => setDisabled(!managerState.processing);
|
|
62
|
+
sync();
|
|
63
|
+
managerState.changed.connect(sync);
|
|
35
64
|
return () => {
|
|
36
|
-
|
|
65
|
+
managerState.changed.disconnect(sync);
|
|
37
66
|
};
|
|
38
|
-
}, [
|
|
67
|
+
}, [sessionRegistry, chatId]);
|
|
39
68
|
|
|
40
69
|
async function handleStop() {
|
|
41
|
-
if (!
|
|
70
|
+
if (!chatId) {
|
|
42
71
|
return;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
setInFlight(true);
|
|
46
75
|
try {
|
|
47
|
-
await cancelResponse(
|
|
76
|
+
await cancelResponse(chatId);
|
|
48
77
|
} finally {
|
|
49
78
|
setInFlight(false);
|
|
50
79
|
}
|