@jupyter-ai/persona-manager 0.2.0-a2 → 0.2.0-a4
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 +11 -3
- package/lib/persona-controls.d.ts +6 -0
- package/lib/persona-controls.js +42 -7
- package/lib/persona-events.d.ts +11 -10
- package/lib/persona-events.js +20 -19
- package/lib/request.d.ts +1 -1
- package/lib/request.js +2 -2
- package/lib/slash-commands.js +3 -3
- package/lib/stop-button.js +2 -2
- package/package.json +2 -2
- package/src/__tests__/persona-events.spec.ts +6 -6
- package/src/index.ts +12 -3
- package/src/persona-controls.tsx +50 -8
- package/src/persona-events.ts +24 -22
- package/src/request.ts +2 -2
- package/src/slash-commands.ts +3 -3
- package/src/stop-button.tsx +2 -2
package/lib/index.js
CHANGED
|
@@ -76,9 +76,17 @@ const toolbarPlugin = {
|
|
|
76
76
|
provides: IInputToolbarRegistryFactory,
|
|
77
77
|
requires: [IPersonaControlRegistry, IPersonaSessionRegistry],
|
|
78
78
|
activate: (app, controlRegistry, sessionRegistry) => {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
const
|
|
79
|
+
// The event bus front-end; `PersonaControls` uses it to emit
|
|
80
|
+
// `persona_selected` on selection.
|
|
81
|
+
const events = app.serviceManager.events;
|
|
82
|
+
// Wrap the persona controls to inject the control + session registries and
|
|
83
|
+
// the event manager, which the generic toolbar-item props don't carry.
|
|
84
|
+
const PersonaControlsItem = (itemProps) => PersonaControls({
|
|
85
|
+
...itemProps,
|
|
86
|
+
controlRegistry,
|
|
87
|
+
sessionRegistry,
|
|
88
|
+
events
|
|
89
|
+
});
|
|
82
90
|
return {
|
|
83
91
|
create: () => {
|
|
84
92
|
// Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Event } from '@jupyterlab/services';
|
|
1
2
|
import { InputToolbarRegistry } from '@jupyter/chat';
|
|
2
3
|
import { PersonaOption, Usage } from './awareness';
|
|
3
4
|
import { PersonaSessionRegistry, PersonaSessionState } from './persona-events';
|
|
@@ -177,5 +178,10 @@ export declare function PersonaControls(props: InputToolbarRegistry.IToolbarItem
|
|
|
177
178
|
* Optional so the component still works without it (renders nothing).
|
|
178
179
|
*/
|
|
179
180
|
sessionRegistry?: PersonaSessionRegistry;
|
|
181
|
+
/**
|
|
182
|
+
* The event bus front-end, used to emit a `persona_selected` event when the
|
|
183
|
+
* user picks a persona so the server prepares it eagerly.
|
|
184
|
+
*/
|
|
185
|
+
events?: Event.IManager;
|
|
180
186
|
}): JSX.Element | null;
|
|
181
187
|
export {};
|
package/lib/persona-controls.js
CHANGED
|
@@ -6,6 +6,7 @@ import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
|
|
6
6
|
import { PageConfig } from '@jupyterlab/coreutils';
|
|
7
7
|
import { EMPTY_USAGE } from './awareness';
|
|
8
8
|
import { buildMessageMetadata, emptyPersonaSettings } from './metadata';
|
|
9
|
+
import { PERSONA_SELECTED_EVENT_SCHEMA_ID } from './persona-events';
|
|
9
10
|
const SELECTOR_CLASS = 'jp-jai-personaControls';
|
|
10
11
|
const MENU_CLASS = 'jp-jai-controlMenu';
|
|
11
12
|
const USAGE_CLASS = 'jp-jai-usage';
|
|
@@ -635,12 +636,31 @@ export function UsageChip(props) {
|
|
|
635
636
|
*/
|
|
636
637
|
export function PersonaControls(props) {
|
|
637
638
|
var _a, _b, _c, _d, _e, _f;
|
|
638
|
-
const { chatModel, model, controlRegistry, sessionRegistry } = props;
|
|
639
|
-
// The chat's
|
|
640
|
-
|
|
639
|
+
const { chatModel, model, controlRegistry, sessionRegistry, events } = props;
|
|
640
|
+
// The chat's stable id scopes persona events to this chat. It is assigned
|
|
641
|
+
// asynchronously (once the model is `ready`: the WS connection frame arrives,
|
|
642
|
+
// or the RTC document syncs), so track it in state and update it when the
|
|
643
|
+
// model becomes ready. Reading it synchronously would capture `undefined`
|
|
644
|
+
// forever, since `id` is a plain accessor with no change signal.
|
|
645
|
+
const [chatId, setChatId] = useState((_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.id) !== null && _a !== void 0 ? _a : null);
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
if (!chatModel) {
|
|
648
|
+
setChatId(null);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
let cancelled = false;
|
|
652
|
+
void chatModel.ready.then(id => {
|
|
653
|
+
if (!cancelled) {
|
|
654
|
+
setChatId(id !== null && id !== void 0 ? id : null);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
return () => {
|
|
658
|
+
cancelled = true;
|
|
659
|
+
};
|
|
660
|
+
}, [chatModel]);
|
|
641
661
|
// The per-chat persona session state, built from persona events and shared
|
|
642
662
|
// via the registry. Created on demand; discarded when the chat closes.
|
|
643
|
-
const managerState = useMemo(() => (sessionRegistry &&
|
|
663
|
+
const managerState = useMemo(() => (sessionRegistry && chatId ? sessionRegistry.get(chatId) : null), [sessionRegistry, chatId]);
|
|
644
664
|
const [personas, setPersonas] = useState([]);
|
|
645
665
|
// Whether a persona list has been received for this chat yet. Before that, an
|
|
646
666
|
// empty list means "still loading", not "this chat has no personas".
|
|
@@ -712,15 +732,15 @@ export function PersonaControls(props) {
|
|
|
712
732
|
// Discard this chat's session state when the chat model is disposed (chat
|
|
713
733
|
// closed), freeing its memory.
|
|
714
734
|
useEffect(() => {
|
|
715
|
-
if (!sessionRegistry || !
|
|
735
|
+
if (!sessionRegistry || !chatId || !chatModel) {
|
|
716
736
|
return;
|
|
717
737
|
}
|
|
718
|
-
const onDisposed = () => sessionRegistry.discard(
|
|
738
|
+
const onDisposed = () => sessionRegistry.discard(chatId);
|
|
719
739
|
chatModel.disposed.connect(onDisposed);
|
|
720
740
|
return () => {
|
|
721
741
|
chatModel.disposed.disconnect(onDisposed);
|
|
722
742
|
};
|
|
723
|
-
}, [sessionRegistry,
|
|
743
|
+
}, [sessionRegistry, chatId, chatModel]);
|
|
724
744
|
// Stamp the current persona + its settings onto the input model's metadata,
|
|
725
745
|
// so it rides out with the next message and the PersonaManager routes and
|
|
726
746
|
// applies it. Keyed on a signature so we only write when it changes.
|
|
@@ -729,6 +749,21 @@ export function PersonaControls(props) {
|
|
|
729
749
|
model.clearMetadata();
|
|
730
750
|
model.updateMetadata(buildMessageMetadata(selectedId, settings));
|
|
731
751
|
}, [model, metadataSignature]);
|
|
752
|
+
// On selection, tell the server to prepare the persona by
|
|
753
|
+
// emitting `persona_selected` over the event bus, so its controls appear
|
|
754
|
+
// before the first message. Skips "No one"; idempotent server-side.
|
|
755
|
+
useEffect(() => {
|
|
756
|
+
if (!events || !chatId || !selectedId) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
void events
|
|
760
|
+
.emit({
|
|
761
|
+
schema_id: PERSONA_SELECTED_EVENT_SCHEMA_ID,
|
|
762
|
+
version: '1',
|
|
763
|
+
data: { chat_id: chatId, persona_id: selectedId }
|
|
764
|
+
})
|
|
765
|
+
.catch(e => console.warn('Error emitting persona_selected event: ', e));
|
|
766
|
+
}, [events, chatId, selectedId]);
|
|
732
767
|
// No personas yet. While the manager's slot or its first list read is still
|
|
733
768
|
// pending, show a loading placeholder (on slow networks this takes seconds);
|
|
734
769
|
// once ready with no personas, show nothing.
|
package/lib/persona-events.d.ts
CHANGED
|
@@ -5,9 +5,10 @@ import { ISignal } from '@lumino/signaling';
|
|
|
5
5
|
import { CommandOption, ModelConfiguration, PersonaOption, SettingConfiguration, Usage } from './awareness';
|
|
6
6
|
export declare const PERSONAS_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1";
|
|
7
7
|
export declare const PERSONA_STATE_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1";
|
|
8
|
+
export declare const PERSONA_SELECTED_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1";
|
|
8
9
|
/** The wire shape of a `persona_state` event. */
|
|
9
10
|
type PersonaStatePayload = {
|
|
10
|
-
|
|
11
|
+
chat_id?: string;
|
|
11
12
|
persona_id?: string;
|
|
12
13
|
model?: ModelConfiguration;
|
|
13
14
|
settings?: SettingConfiguration[];
|
|
@@ -33,8 +34,8 @@ export declare class PersonaSessionState {
|
|
|
33
34
|
* or any persona's state updates, so React components re-render.
|
|
34
35
|
*/
|
|
35
36
|
export declare class PersonaManagerSessionState implements IDisposable {
|
|
36
|
-
readonly
|
|
37
|
-
constructor(
|
|
37
|
+
readonly chatId: string;
|
|
38
|
+
constructor(chatId: string);
|
|
38
39
|
/** Emits whenever the persona list or a persona's state changes. */
|
|
39
40
|
get changed(): ISignal<this, void>;
|
|
40
41
|
/** Whether a `personas` event has been received for this chat yet. */
|
|
@@ -65,20 +66,20 @@ export declare class PersonaManagerSessionState implements IDisposable {
|
|
|
65
66
|
export declare class PersonaSessionRegistry {
|
|
66
67
|
constructor(eventListener: IEventListener);
|
|
67
68
|
/**
|
|
68
|
-
* Get (or create) the manager session state for a chat
|
|
69
|
-
*
|
|
69
|
+
* Get (or create) the manager session state for a chat. Components call this
|
|
70
|
+
* with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
|
|
70
71
|
*/
|
|
71
|
-
get(
|
|
72
|
-
/** Whether a manager session state exists for `
|
|
73
|
-
has(
|
|
72
|
+
get(chatId: string): PersonaManagerSessionState;
|
|
73
|
+
/** Whether a manager session state exists for `chatId` (without creating one). */
|
|
74
|
+
has(chatId: string): boolean;
|
|
74
75
|
/**
|
|
75
76
|
* Discard a chat's session state and free its memory. Called when the client
|
|
76
77
|
* closes the chat (wired to the chat model's `disposed` signal).
|
|
77
78
|
*/
|
|
78
|
-
discard(
|
|
79
|
+
discard(chatId: string): void;
|
|
79
80
|
private _onPersonasEvent;
|
|
80
81
|
private _onPersonaStateEvent;
|
|
81
|
-
private
|
|
82
|
+
private _byChatId;
|
|
82
83
|
}
|
|
83
84
|
/**
|
|
84
85
|
* Plugin token for the shared `PersonaSessionRegistry`. Consumed by the toolbar
|
package/lib/persona-events.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Signal } from '@lumino/signaling';
|
|
|
3
3
|
import { EMPTY_MODEL_CONFIGURATION, EMPTY_USAGE } from './awareness';
|
|
4
4
|
export const PERSONAS_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1';
|
|
5
5
|
export const PERSONA_STATE_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1';
|
|
6
|
+
export const PERSONA_SELECTED_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1';
|
|
6
7
|
/**
|
|
7
8
|
* One persona's live session state, built from a `persona_state` event.
|
|
8
9
|
* Immutable: each update produces a new instance, so React consumers see a new
|
|
@@ -24,8 +25,8 @@ export class PersonaSessionState {
|
|
|
24
25
|
* or any persona's state updates, so React components re-render.
|
|
25
26
|
*/
|
|
26
27
|
export class PersonaManagerSessionState {
|
|
27
|
-
constructor(
|
|
28
|
-
this.
|
|
28
|
+
constructor(chatId) {
|
|
29
|
+
this.chatId = chatId;
|
|
29
30
|
this._personasReceived = false;
|
|
30
31
|
this._personas = [];
|
|
31
32
|
this._states = new Map();
|
|
@@ -82,47 +83,47 @@ export class PersonaSessionRegistry {
|
|
|
82
83
|
constructor(eventListener) {
|
|
83
84
|
this._onPersonasEvent = async (_manager, _schemaId, event) => {
|
|
84
85
|
const data = event;
|
|
85
|
-
if (!data.
|
|
86
|
+
if (!data.chat_id) {
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
88
|
-
this.get(data.
|
|
89
|
+
this.get(data.chat_id).updatePersonas(Array.isArray(data.personas) ? data.personas : []);
|
|
89
90
|
};
|
|
90
91
|
this._onPersonaStateEvent = async (_manager, _schemaId, event) => {
|
|
91
92
|
const data = event;
|
|
92
|
-
if (!data.
|
|
93
|
+
if (!data.chat_id || !data.persona_id) {
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
|
-
this.get(data.
|
|
96
|
+
this.get(data.chat_id).updatePersonaState(data.persona_id, data);
|
|
96
97
|
};
|
|
97
|
-
this.
|
|
98
|
+
this._byChatId = new Map();
|
|
98
99
|
eventListener.addListener(PERSONAS_EVENT_SCHEMA_ID, this._onPersonasEvent);
|
|
99
100
|
eventListener.addListener(PERSONA_STATE_EVENT_SCHEMA_ID, this._onPersonaStateEvent);
|
|
100
101
|
}
|
|
101
102
|
/**
|
|
102
|
-
* Get (or create) the manager session state for a chat
|
|
103
|
-
*
|
|
103
|
+
* Get (or create) the manager session state for a chat. Components call this
|
|
104
|
+
* with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
|
|
104
105
|
*/
|
|
105
|
-
get(
|
|
106
|
-
let state = this.
|
|
106
|
+
get(chatId) {
|
|
107
|
+
let state = this._byChatId.get(chatId);
|
|
107
108
|
if (!state) {
|
|
108
|
-
state = new PersonaManagerSessionState(
|
|
109
|
-
this.
|
|
109
|
+
state = new PersonaManagerSessionState(chatId);
|
|
110
|
+
this._byChatId.set(chatId, state);
|
|
110
111
|
}
|
|
111
112
|
return state;
|
|
112
113
|
}
|
|
113
|
-
/** Whether a manager session state exists for `
|
|
114
|
-
has(
|
|
115
|
-
return this.
|
|
114
|
+
/** Whether a manager session state exists for `chatId` (without creating one). */
|
|
115
|
+
has(chatId) {
|
|
116
|
+
return this._byChatId.has(chatId);
|
|
116
117
|
}
|
|
117
118
|
/**
|
|
118
119
|
* Discard a chat's session state and free its memory. Called when the client
|
|
119
120
|
* closes the chat (wired to the chat model's `disposed` signal).
|
|
120
121
|
*/
|
|
121
|
-
discard(
|
|
122
|
-
const state = this.
|
|
122
|
+
discard(chatId) {
|
|
123
|
+
const state = this._byChatId.get(chatId);
|
|
123
124
|
if (state) {
|
|
124
125
|
state.dispose();
|
|
125
|
-
this.
|
|
126
|
+
this._byChatId.delete(chatId);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
129
|
}
|
package/lib/request.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export declare function requestAPI<T>(namespace: string, endPoint?: string, init
|
|
|
12
12
|
* via the cancel endpoint. Backend-agnostic: each persona's
|
|
13
13
|
* `cancel_response()` decides what stopping means.
|
|
14
14
|
*/
|
|
15
|
-
export declare function cancelResponse(
|
|
15
|
+
export declare function cancelResponse(chatId: string): Promise<void>;
|
package/lib/request.js
CHANGED
|
@@ -38,9 +38,9 @@ export async function requestAPI(namespace, endPoint = '', init = {}) {
|
|
|
38
38
|
* via the cancel endpoint. Backend-agnostic: each persona's
|
|
39
39
|
* `cancel_response()` decides what stopping means.
|
|
40
40
|
*/
|
|
41
|
-
export async function cancelResponse(
|
|
41
|
+
export async function cancelResponse(chatId) {
|
|
42
42
|
try {
|
|
43
|
-
await requestAPI('api/ai', `personas/cancel?
|
|
43
|
+
await requestAPI('api/ai', `personas/cancel?chat_id=${encodeURIComponent(chatId)}`, { method: 'POST' });
|
|
44
44
|
}
|
|
45
45
|
catch (e) {
|
|
46
46
|
console.warn('Error cancelling response: ', e);
|
package/lib/slash-commands.js
CHANGED
|
@@ -34,15 +34,15 @@ export class SlashCommandProvider {
|
|
|
34
34
|
if (!currentWord.startsWith('/')) {
|
|
35
35
|
return [];
|
|
36
36
|
}
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
37
|
+
const chatId = (_b = (_a = inputModel.chatContext) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null;
|
|
38
|
+
if (!chatId) {
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
const personaId = (_d = (_c = inputModel.getMetadata) === null || _c === void 0 ? void 0 : _c.call(inputModel).to_persona) !== null && _d !== void 0 ? _d : null;
|
|
42
42
|
if (!personaId) {
|
|
43
43
|
return [];
|
|
44
44
|
}
|
|
45
|
-
const persona = this._registry.get(
|
|
45
|
+
const persona = this._registry.get(chatId).getPersona(personaId);
|
|
46
46
|
if (!persona) {
|
|
47
47
|
return [];
|
|
48
48
|
}
|
package/lib/stop-button.js
CHANGED
|
@@ -31,12 +31,12 @@ export function StopButton(props) {
|
|
|
31
31
|
};
|
|
32
32
|
}, [chatModel]);
|
|
33
33
|
async function handleStop() {
|
|
34
|
-
if (!chatModel) {
|
|
34
|
+
if (!(chatModel === null || chatModel === void 0 ? void 0 : chatModel.id)) {
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
setInFlight(true);
|
|
38
38
|
try {
|
|
39
|
-
await cancelResponse(chatModel.
|
|
39
|
+
await cancelResponse(chatModel.id);
|
|
40
40
|
}
|
|
41
41
|
finally {
|
|
42
42
|
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-a4",
|
|
4
4
|
"description": "The core manager & registry for AI personas in Jupyter AI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"watch:labextension": "jupyter labextension watch ."
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@jupyter/chat": "^0.
|
|
61
|
+
"@jupyter/chat": "^0.25.0-alpha.5",
|
|
62
62
|
"@jupyterlab/application": "^4.0.0",
|
|
63
63
|
"@jupyterlab/coreutils": "^6.0.0",
|
|
64
64
|
"@jupyterlab/services": "^7.0.0",
|
|
@@ -41,10 +41,10 @@ function makeRegistry(): {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe('PersonaSessionRegistry', () => {
|
|
44
|
-
it('routes a personas event to the matching chat by
|
|
44
|
+
it('routes a personas event to the matching chat by id', async () => {
|
|
45
45
|
const { registry, events } = makeRegistry();
|
|
46
46
|
await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
|
|
47
|
-
|
|
47
|
+
chat_id: 'a.chat',
|
|
48
48
|
personas: [{ id: 'p1', name: 'One', avatar_url: null }]
|
|
49
49
|
});
|
|
50
50
|
expect(registry.get('a.chat').personas.map(p => p.id)).toEqual(['p1']);
|
|
@@ -56,7 +56,7 @@ describe('PersonaSessionRegistry', () => {
|
|
|
56
56
|
it('routes persona_state events and builds PersonaSessionState', async () => {
|
|
57
57
|
const { registry, events } = makeRegistry();
|
|
58
58
|
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
59
|
-
|
|
59
|
+
chat_id: 'a.chat',
|
|
60
60
|
persona_id: 'p1',
|
|
61
61
|
model: { current: 'm1', options: [], settings: [] },
|
|
62
62
|
usage: { input_tokens: 5 }
|
|
@@ -74,11 +74,11 @@ describe('PersonaSessionRegistry', () => {
|
|
|
74
74
|
fired += 1;
|
|
75
75
|
});
|
|
76
76
|
await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
|
|
77
|
-
|
|
77
|
+
chat_id: 'a.chat',
|
|
78
78
|
personas: []
|
|
79
79
|
});
|
|
80
80
|
await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
|
|
81
|
-
|
|
81
|
+
chat_id: 'a.chat',
|
|
82
82
|
persona_id: 'p1'
|
|
83
83
|
});
|
|
84
84
|
expect(fired).toBe(2);
|
|
@@ -87,7 +87,7 @@ describe('PersonaSessionRegistry', () => {
|
|
|
87
87
|
it('discards a chat session state on close, freeing memory', async () => {
|
|
88
88
|
const { registry, events } = makeRegistry();
|
|
89
89
|
await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
|
|
90
|
-
|
|
90
|
+
chat_id: 'a.chat',
|
|
91
91
|
personas: [{ id: 'p1', name: 'One', avatar_url: null }]
|
|
92
92
|
});
|
|
93
93
|
const state = registry.get('a.chat');
|
package/src/index.ts
CHANGED
|
@@ -119,11 +119,20 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
|
|
|
119
119
|
controlRegistry: IPersonaControlRegistry,
|
|
120
120
|
sessionRegistry: PersonaSessionRegistry
|
|
121
121
|
): IInputToolbarRegistryFactory => {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
122
|
+
// The event bus front-end; `PersonaControls` uses it to emit
|
|
123
|
+
// `persona_selected` on selection.
|
|
124
|
+
const events = app.serviceManager.events;
|
|
125
|
+
// Wrap the persona controls to inject the control + session registries and
|
|
126
|
+
// the event manager, which the generic toolbar-item props don't carry.
|
|
124
127
|
const PersonaControlsItem = (
|
|
125
128
|
itemProps: InputToolbarRegistry.IToolbarItemProps
|
|
126
|
-
) =>
|
|
129
|
+
) =>
|
|
130
|
+
PersonaControls({
|
|
131
|
+
...itemProps,
|
|
132
|
+
controlRegistry,
|
|
133
|
+
sessionRegistry,
|
|
134
|
+
events
|
|
135
|
+
});
|
|
127
136
|
return {
|
|
128
137
|
create: () => {
|
|
129
138
|
// Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
|
package/src/persona-controls.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
|
|
21
21
|
import CheckIcon from '@mui/icons-material/Check';
|
|
22
22
|
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
|
23
23
|
import { PageConfig } from '@jupyterlab/coreutils';
|
|
24
|
+
import { Event } from '@jupyterlab/services';
|
|
24
25
|
import { InputToolbarRegistry } from '@jupyter/chat';
|
|
25
26
|
import {
|
|
26
27
|
EMPTY_USAGE,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
emptyPersonaSettings
|
|
36
37
|
} from './metadata';
|
|
37
38
|
import { IPersonaControlRegistry } from './persona-control-registry';
|
|
39
|
+
import { PERSONA_SELECTED_EVENT_SCHEMA_ID } from './persona-events';
|
|
38
40
|
|
|
39
41
|
const SELECTOR_CLASS = 'jp-jai-personaControls';
|
|
40
42
|
const MENU_CLASS = 'jp-jai-controlMenu';
|
|
@@ -1072,17 +1074,41 @@ export function PersonaControls(
|
|
|
1072
1074
|
* Optional so the component still works without it (renders nothing).
|
|
1073
1075
|
*/
|
|
1074
1076
|
sessionRegistry?: PersonaSessionRegistry;
|
|
1077
|
+
/**
|
|
1078
|
+
* The event bus front-end, used to emit a `persona_selected` event when the
|
|
1079
|
+
* user picks a persona so the server prepares it eagerly.
|
|
1080
|
+
*/
|
|
1081
|
+
events?: Event.IManager;
|
|
1075
1082
|
}
|
|
1076
1083
|
): JSX.Element | null {
|
|
1077
|
-
const { chatModel, model, controlRegistry, sessionRegistry } = props;
|
|
1078
|
-
// The chat's
|
|
1079
|
-
|
|
1084
|
+
const { chatModel, model, controlRegistry, sessionRegistry, events } = props;
|
|
1085
|
+
// The chat's stable id scopes persona events to this chat. It is assigned
|
|
1086
|
+
// asynchronously (once the model is `ready`: the WS connection frame arrives,
|
|
1087
|
+
// or the RTC document syncs), so track it in state and update it when the
|
|
1088
|
+
// model becomes ready. Reading it synchronously would capture `undefined`
|
|
1089
|
+
// forever, since `id` is a plain accessor with no change signal.
|
|
1090
|
+
const [chatId, setChatId] = useState<string | null>(chatModel?.id ?? null);
|
|
1091
|
+
useEffect(() => {
|
|
1092
|
+
if (!chatModel) {
|
|
1093
|
+
setChatId(null);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
let cancelled = false;
|
|
1097
|
+
void chatModel.ready.then(id => {
|
|
1098
|
+
if (!cancelled) {
|
|
1099
|
+
setChatId(id ?? null);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
return () => {
|
|
1103
|
+
cancelled = true;
|
|
1104
|
+
};
|
|
1105
|
+
}, [chatModel]);
|
|
1080
1106
|
|
|
1081
1107
|
// The per-chat persona session state, built from persona events and shared
|
|
1082
1108
|
// via the registry. Created on demand; discarded when the chat closes.
|
|
1083
1109
|
const managerState = useMemo(
|
|
1084
|
-
() => (sessionRegistry &&
|
|
1085
|
-
[sessionRegistry,
|
|
1110
|
+
() => (sessionRegistry && chatId ? sessionRegistry.get(chatId) : null),
|
|
1111
|
+
[sessionRegistry, chatId]
|
|
1086
1112
|
);
|
|
1087
1113
|
|
|
1088
1114
|
const [personas, setPersonas] = useState<PersonaOption[]>([]);
|
|
@@ -1169,15 +1195,15 @@ export function PersonaControls(
|
|
|
1169
1195
|
// Discard this chat's session state when the chat model is disposed (chat
|
|
1170
1196
|
// closed), freeing its memory.
|
|
1171
1197
|
useEffect(() => {
|
|
1172
|
-
if (!sessionRegistry || !
|
|
1198
|
+
if (!sessionRegistry || !chatId || !chatModel) {
|
|
1173
1199
|
return;
|
|
1174
1200
|
}
|
|
1175
|
-
const onDisposed = () => sessionRegistry.discard(
|
|
1201
|
+
const onDisposed = () => sessionRegistry.discard(chatId);
|
|
1176
1202
|
chatModel.disposed.connect(onDisposed);
|
|
1177
1203
|
return () => {
|
|
1178
1204
|
chatModel.disposed.disconnect(onDisposed);
|
|
1179
1205
|
};
|
|
1180
|
-
}, [sessionRegistry,
|
|
1206
|
+
}, [sessionRegistry, chatId, chatModel]);
|
|
1181
1207
|
|
|
1182
1208
|
// Stamp the current persona + its settings onto the input model's metadata,
|
|
1183
1209
|
// so it rides out with the next message and the PersonaManager routes and
|
|
@@ -1188,6 +1214,22 @@ export function PersonaControls(
|
|
|
1188
1214
|
model.updateMetadata(buildMessageMetadata(selectedId, settings));
|
|
1189
1215
|
}, [model, metadataSignature]);
|
|
1190
1216
|
|
|
1217
|
+
// On selection, tell the server to prepare the persona by
|
|
1218
|
+
// emitting `persona_selected` over the event bus, so its controls appear
|
|
1219
|
+
// before the first message. Skips "No one"; idempotent server-side.
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
if (!events || !chatId || !selectedId) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
void events
|
|
1225
|
+
.emit({
|
|
1226
|
+
schema_id: PERSONA_SELECTED_EVENT_SCHEMA_ID,
|
|
1227
|
+
version: '1',
|
|
1228
|
+
data: { chat_id: chatId, persona_id: selectedId }
|
|
1229
|
+
})
|
|
1230
|
+
.catch(e => console.warn('Error emitting persona_selected event: ', e));
|
|
1231
|
+
}, [events, chatId, selectedId]);
|
|
1232
|
+
|
|
1191
1233
|
// No personas yet. While the manager's slot or its first list read is still
|
|
1192
1234
|
// pending, show a loading placeholder (on slow networks this takes seconds);
|
|
1193
1235
|
// once ready with no personas, show nothing.
|
package/src/persona-events.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* `jupyterlab-eventlistener`), replacing the previous Yjs-awareness channel.
|
|
4
4
|
*
|
|
5
5
|
* Flow: the server emits `personas` / `persona_state` events (each carrying the
|
|
6
|
-
* chat's `
|
|
7
|
-
* `PersonaManagerSessionState`, which holds the persona list and a
|
|
6
|
+
* chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
|
|
7
|
+
* per-chat `PersonaManagerSessionState`, which holds the persona list and a
|
|
8
8
|
* `PersonaSessionState` per persona and fires a Lumino `changed` signal. React
|
|
9
9
|
* components listen to that signal. When a chat closes, its state is discarded.
|
|
10
10
|
*
|
|
@@ -31,10 +31,12 @@ export const PERSONAS_EVENT_SCHEMA_ID =
|
|
|
31
31
|
'https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1';
|
|
32
32
|
export const PERSONA_STATE_EVENT_SCHEMA_ID =
|
|
33
33
|
'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1';
|
|
34
|
+
export const PERSONA_SELECTED_EVENT_SCHEMA_ID =
|
|
35
|
+
'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1';
|
|
34
36
|
|
|
35
37
|
/** The wire shape of a `persona_state` event. */
|
|
36
38
|
type PersonaStatePayload = {
|
|
37
|
-
|
|
39
|
+
chat_id?: string;
|
|
38
40
|
persona_id?: string;
|
|
39
41
|
model?: ModelConfiguration;
|
|
40
42
|
settings?: SettingConfiguration[];
|
|
@@ -44,7 +46,7 @@ type PersonaStatePayload = {
|
|
|
44
46
|
|
|
45
47
|
/** The wire shape of a `personas` event. */
|
|
46
48
|
type PersonasPayload = {
|
|
47
|
-
|
|
49
|
+
chat_id?: string;
|
|
48
50
|
personas?: PersonaOption[];
|
|
49
51
|
};
|
|
50
52
|
|
|
@@ -76,7 +78,7 @@ export class PersonaSessionState {
|
|
|
76
78
|
* or any persona's state updates, so React components re-render.
|
|
77
79
|
*/
|
|
78
80
|
export class PersonaManagerSessionState implements IDisposable {
|
|
79
|
-
constructor(public readonly
|
|
81
|
+
constructor(public readonly chatId: string) {}
|
|
80
82
|
|
|
81
83
|
/** Emits whenever the persona list or a persona's state changes. */
|
|
82
84
|
get changed(): ISignal<this, void> {
|
|
@@ -151,32 +153,32 @@ export class PersonaSessionRegistry {
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
|
-
* Get (or create) the manager session state for a chat
|
|
155
|
-
*
|
|
156
|
+
* Get (or create) the manager session state for a chat. Components call this
|
|
157
|
+
* with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
|
|
156
158
|
*/
|
|
157
|
-
get(
|
|
158
|
-
let state = this.
|
|
159
|
+
get(chatId: string): PersonaManagerSessionState {
|
|
160
|
+
let state = this._byChatId.get(chatId);
|
|
159
161
|
if (!state) {
|
|
160
|
-
state = new PersonaManagerSessionState(
|
|
161
|
-
this.
|
|
162
|
+
state = new PersonaManagerSessionState(chatId);
|
|
163
|
+
this._byChatId.set(chatId, state);
|
|
162
164
|
}
|
|
163
165
|
return state;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
/** Whether a manager session state exists for `
|
|
167
|
-
has(
|
|
168
|
-
return this.
|
|
168
|
+
/** Whether a manager session state exists for `chatId` (without creating one). */
|
|
169
|
+
has(chatId: string): boolean {
|
|
170
|
+
return this._byChatId.has(chatId);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
/**
|
|
172
174
|
* Discard a chat's session state and free its memory. Called when the client
|
|
173
175
|
* closes the chat (wired to the chat model's `disposed` signal).
|
|
174
176
|
*/
|
|
175
|
-
discard(
|
|
176
|
-
const state = this.
|
|
177
|
+
discard(chatId: string): void {
|
|
178
|
+
const state = this._byChatId.get(chatId);
|
|
177
179
|
if (state) {
|
|
178
180
|
state.dispose();
|
|
179
|
-
this.
|
|
181
|
+
this._byChatId.delete(chatId);
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
184
|
|
|
@@ -186,10 +188,10 @@ export class PersonaSessionRegistry {
|
|
|
186
188
|
event: Event.Emission
|
|
187
189
|
): Promise<void> => {
|
|
188
190
|
const data = event as PersonasPayload;
|
|
189
|
-
if (!data.
|
|
191
|
+
if (!data.chat_id) {
|
|
190
192
|
return;
|
|
191
193
|
}
|
|
192
|
-
this.get(data.
|
|
194
|
+
this.get(data.chat_id).updatePersonas(
|
|
193
195
|
Array.isArray(data.personas) ? data.personas : []
|
|
194
196
|
);
|
|
195
197
|
};
|
|
@@ -200,13 +202,13 @@ export class PersonaSessionRegistry {
|
|
|
200
202
|
event: Event.Emission
|
|
201
203
|
): Promise<void> => {
|
|
202
204
|
const data = event as PersonaStatePayload;
|
|
203
|
-
if (!data.
|
|
205
|
+
if (!data.chat_id || !data.persona_id) {
|
|
204
206
|
return;
|
|
205
207
|
}
|
|
206
|
-
this.get(data.
|
|
208
|
+
this.get(data.chat_id).updatePersonaState(data.persona_id, data);
|
|
207
209
|
};
|
|
208
210
|
|
|
209
|
-
private
|
|
211
|
+
private _byChatId = new Map<string, PersonaManagerSessionState>();
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
/**
|
package/src/request.ts
CHANGED
|
@@ -48,11 +48,11 @@ export async function requestAPI<T>(
|
|
|
48
48
|
* via the cancel endpoint. Backend-agnostic: each persona's
|
|
49
49
|
* `cancel_response()` decides what stopping means.
|
|
50
50
|
*/
|
|
51
|
-
export async function cancelResponse(
|
|
51
|
+
export async function cancelResponse(chatId: string): Promise<void> {
|
|
52
52
|
try {
|
|
53
53
|
await requestAPI(
|
|
54
54
|
'api/ai',
|
|
55
|
-
`personas/cancel?
|
|
55
|
+
`personas/cancel?chat_id=${encodeURIComponent(chatId)}`,
|
|
56
56
|
{ method: 'POST' }
|
|
57
57
|
);
|
|
58
58
|
} catch (e) {
|
package/src/slash-commands.ts
CHANGED
|
@@ -43,8 +43,8 @@ export class SlashCommandProvider implements IChatCommandProvider {
|
|
|
43
43
|
return [];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
46
|
+
const chatId = inputModel.chatContext?.id ?? null;
|
|
47
|
+
if (!chatId) {
|
|
48
48
|
return [];
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -53,7 +53,7 @@ export class SlashCommandProvider implements IChatCommandProvider {
|
|
|
53
53
|
return [];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const persona = this._registry.get(
|
|
56
|
+
const persona = this._registry.get(chatId).getPersona(personaId);
|
|
57
57
|
if (!persona) {
|
|
58
58
|
return [];
|
|
59
59
|
}
|
package/src/stop-button.tsx
CHANGED
|
@@ -38,13 +38,13 @@ export function StopButton(
|
|
|
38
38
|
}, [chatModel]);
|
|
39
39
|
|
|
40
40
|
async function handleStop() {
|
|
41
|
-
if (!chatModel) {
|
|
41
|
+
if (!chatModel?.id) {
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
setInFlight(true);
|
|
46
46
|
try {
|
|
47
|
-
await cancelResponse(chatModel.
|
|
47
|
+
await cancelResponse(chatModel.id);
|
|
48
48
|
} finally {
|
|
49
49
|
setInFlight(false);
|
|
50
50
|
}
|