@datalayer/agent-runtimes 1.3.36 → 1.3.38
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/loop/core/index.d.ts +19 -0
- package/lib/loop/index.d.ts +1 -1
- package/lib/loop/plugins/app-page/AppPage.d.ts +2 -1
- package/lib/loop/plugins/app-page/AppPage.js +3 -1
- package/lib/loop/plugins/app-page/appPageModel.d.ts +25 -5
- package/lib/loop/plugins/app-page/appPageModel.js +124 -7
- package/lib/loop/plugins/chat/ChatView.js +26 -25
- package/lib/loop/plugins/chat/index.js +6 -1
- package/lib/loop/plugins/chat/turnState.d.ts +20 -1
- package/lib/loop/plugins/chat/turnState.js +56 -0
- package/package.json +1 -1
package/lib/loop/core/index.d.ts
CHANGED
|
@@ -955,10 +955,29 @@ export type ChatTurnSnapshot = {
|
|
|
955
955
|
*/
|
|
956
956
|
activity?: string;
|
|
957
957
|
};
|
|
958
|
+
/**
|
|
959
|
+
* One message of the conversation, as a page shows it (LOOP R-01, C-18): a
|
|
960
|
+
* person's or the assistant's words, or a tool the assistant called — what
|
|
961
|
+
* the Chat component draws, bound to a chat application's `/messages`.
|
|
962
|
+
*/
|
|
963
|
+
export type ConversationEntry = {
|
|
964
|
+
role: 'user' | 'assistant';
|
|
965
|
+
text: string;
|
|
966
|
+
} | {
|
|
967
|
+
role: 'tool';
|
|
968
|
+
name: string;
|
|
969
|
+
args: Record<string, unknown>;
|
|
970
|
+
result?: unknown;
|
|
971
|
+
};
|
|
958
972
|
export type ChatTurnContribution = {
|
|
959
973
|
/** Stable id, for the registry and the graph. */
|
|
960
974
|
id: string;
|
|
961
975
|
/** The turn, live. */
|
|
962
976
|
turn: ReadonlySignal<ChatTurnSnapshot>;
|
|
977
|
+
/**
|
|
978
|
+
* The whole conversation, live: every message and tool call in order, the
|
|
979
|
+
* reply as it streams included; empty again when the chat starts over.
|
|
980
|
+
*/
|
|
981
|
+
conversation: ReadonlySignal<ConversationEntry[]>;
|
|
963
982
|
};
|
|
964
983
|
export declare const LoopChatTurn: import("@datalayer/reactor").ContributionPoint<ChatTurnContribution>;
|
package/lib/loop/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { ChatPlugin, CHAT_PLUGIN_NAME, type ChatPluginConfig, } from './plugins/
|
|
|
15
15
|
export { ChatHeaderPlugin, CHAT_HEADER_PLUGIN_NAME, } from './plugins/chat-header';
|
|
16
16
|
export { InputPromptPlugin, INPUT_PROMPT_PLUGIN_NAME, type InputPromptPluginConfig, } from './plugins/input-prompt';
|
|
17
17
|
export { createChatExtrasPlugin, CHAT_EXTRAS_PLUGIN_NAME, type ChatExtrasHandle, } from './plugins/chat-extras';
|
|
18
|
-
export { LoopChatExtras, LoopChatLayout, LoopChatTurn, LoopOpeningDocument, LoopOpeningNotebook, LoopPromptPanel, type ChatLayoutContribution, type ChatLayoutParts, type ChatTurnContribution, type ChatTurnSnapshot, type ChatTurnStatus, type LoopChatExtrasValue, type OpeningDocumentContribution, type OpeningNotebookContribution, type PromptPanelContribution, } from './core';
|
|
18
|
+
export { LoopChatExtras, LoopChatLayout, LoopChatTurn, LoopOpeningDocument, LoopOpeningNotebook, LoopPromptPanel, type ChatLayoutContribution, type ChatLayoutParts, type ChatTurnContribution, type ConversationEntry, type ChatTurnSnapshot, type ChatTurnStatus, type LoopChatExtrasValue, type OpeningDocumentContribution, type OpeningNotebookContribution, type PromptPanelContribution, } from './core';
|
|
19
19
|
export { LoopPageLayoutPlugin, LOOP_PAGE_LAYOUT_PLUGIN_NAME, openConversationPanel, pageLayoutSheet, pageLayoutPanelOpen, setPageLayoutChatMode, SplitLayout, type LoopPageLayoutConfig, } from './plugins/page-layout';
|
|
20
20
|
export { useOptionalTeamSelection } from './plugins/agents/useTeamSelection';
|
|
21
21
|
export type { TeamMember, TeamSelection } from './plugins/agents/team';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An application's page, drawn beside its conversation: its A2UI surface on
|
|
3
3
|
* the workspace's own renderer (`InlineSurface`), fed by the chat's current
|
|
4
|
-
* turn, its buttons
|
|
4
|
+
* turn and its conversation, its buttons — and a Chat block's message, a File
|
|
5
|
+
* upload's files — answered through the chat's controls.
|
|
5
6
|
*
|
|
6
7
|
* @module loop/plugins/app-page/AppPage
|
|
7
8
|
*/
|
|
@@ -8,11 +8,13 @@ import { InlineSurface, SURFACE_CATALOG_ID, } from '../a2ui-surface/InlineSurfac
|
|
|
8
8
|
import { appPageAction, appPageData, appPageMessages } from './appPageModel';
|
|
9
9
|
/** No chat in the workspace: the page reads a turn that never starts. */
|
|
10
10
|
const NO_TURN = signal({ id: 0, status: 'idle' });
|
|
11
|
+
const NO_CONVERSATION = signal([]);
|
|
11
12
|
export function AppPage({ app, workspace }) {
|
|
12
13
|
const entries = useContributions(LoopChatTurn);
|
|
13
14
|
const turn = useSignalValue(entries[0]?.value.turn ?? NO_TURN);
|
|
15
|
+
const conversation = useSignalValue(entries[0]?.value.conversation ?? NO_CONVERSATION);
|
|
14
16
|
const messages = useMemo(() => appPageMessages(app, SURFACE_CATALOG_ID), [app]);
|
|
15
|
-
const data = useMemo(() => appPageData(app, turn), [app, turn]);
|
|
17
|
+
const data = useMemo(() => appPageData(app, turn, conversation), [app, turn, conversation]);
|
|
16
18
|
const [refusal, setRefusal] = useState(null);
|
|
17
19
|
// The workspace changes as the chat reports itself; the handler reads the latest.
|
|
18
20
|
const workspaceRef = useRef(workspace);
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* A chat, a widget or a worker runs as its conversation. Its page is its
|
|
7
7
|
* A2UI surface (`interface.surface`) drawn beside that conversation, fed from
|
|
8
8
|
* it: the question last asked, the answer as it streams, where the turn
|
|
9
|
-
* stands. What a block writes — an input's `value` — stays in the surface's
|
|
9
|
+
* stands, a chat's whole conversation. What a block writes — an input's `value` — stays in the surface's
|
|
10
10
|
* data model at a path the application takes, and what a button does reaches
|
|
11
11
|
* the application through the chat's own channels: a message sent, the
|
|
12
12
|
* answer stopped, the conversation started over. Nothing here is a server
|
|
13
13
|
* API; a setting reaches the application in the words of the message it is
|
|
14
|
-
* sent with, until the session API carries a settings change (R-04)
|
|
14
|
+
* sent with, until the session API carries a settings change (R-04), and so
|
|
15
|
+
* does a text file given to a widget, its contents in the message (E-01).
|
|
15
16
|
*
|
|
16
17
|
* A decision is not here: its page is drawn by the Studio's run page, with
|
|
17
18
|
* its own paths (`INTERFACE_PATHS`), until it moves onto the workspace (R-02).
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
* @module loop/plugins/app-page/appPageModel
|
|
22
23
|
*/
|
|
23
24
|
import type { AppSettingSpec, AppSpec } from '../../../types/agentspecs';
|
|
24
|
-
import type { ChatTurnSnapshot } from '../../core';
|
|
25
|
+
import type { ChatTurnSnapshot, ConversationEntry } from '../../core';
|
|
25
26
|
/** The id of the editor surface an application's page is drawn in. */
|
|
26
27
|
export declare const APP_PAGE_SURFACE = "app-page";
|
|
27
28
|
/** The id of the A2UI surface itself, inside it. */
|
|
@@ -94,8 +95,11 @@ export declare function hasAppPage(app: Pick<AppSpec, 'kind' | 'interface' | 'ag
|
|
|
94
95
|
export declare function surfaceUnshown(app: Pick<AppSpec, 'kind' | 'interface' | 'agent'>): string | null;
|
|
95
96
|
/** A setting's first value, as its input holds it. */
|
|
96
97
|
export declare function initialInput(setting: AppSettingSpec): unknown;
|
|
97
|
-
/**
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* What the conversation publishes to the page, by path, now: from its
|
|
100
|
+
* current turn, and for a chat its whole conversation.
|
|
101
|
+
*/
|
|
102
|
+
export declare function appPageData(app: Pick<AppSpec, 'kind' | 'name' | 'goal'>, turn: ChatTurnSnapshot, conversation: ConversationEntry[]): Record<string, unknown>;
|
|
99
103
|
/** The page's data model when it is drawn: what is published, the inputs, the draft. */
|
|
100
104
|
export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' | 'goal' | 'interface'>): Record<string, unknown>;
|
|
101
105
|
/**
|
|
@@ -103,6 +107,22 @@ export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' |
|
|
|
103
107
|
* its label, then any other value a block wrote under `/inputs` by its id.
|
|
104
108
|
*/
|
|
105
109
|
export declare function inputsInWords(app: Pick<AppSpec, 'interface'>, inputs: unknown): string;
|
|
110
|
+
/**
|
|
111
|
+
* The most of a file's text a message carries: about thirty thousand
|
|
112
|
+
* tokens. A larger file is refused in a sentence rather than sent for the
|
|
113
|
+
* model to refuse.
|
|
114
|
+
*/
|
|
115
|
+
export declare const MAX_FILE_CHARACTERS = 120000;
|
|
116
|
+
/**
|
|
117
|
+
* The files given on the page with their contents, for the message a widget
|
|
118
|
+
* is run with — or why one cannot go: it is not a file as File upload gives
|
|
119
|
+
* it, not text, or too long for a message.
|
|
120
|
+
*/
|
|
121
|
+
export declare function filesInWords(files: unknown): {
|
|
122
|
+
words: string;
|
|
123
|
+
} | {
|
|
124
|
+
refused: string;
|
|
125
|
+
};
|
|
106
126
|
/** What a button asked, as the surface hands it over. */
|
|
107
127
|
export type AppPageEvent = {
|
|
108
128
|
name: string;
|
|
@@ -28,6 +28,18 @@ const SEND = {
|
|
|
28
28
|
context: ['message'],
|
|
29
29
|
asks: { message: 'The message it sends' },
|
|
30
30
|
};
|
|
31
|
+
const MESSAGES = {
|
|
32
|
+
path: '/messages',
|
|
33
|
+
meaning: 'The conversation so far, in order: each message {role: user or assistant, text}, each tool called {role: tool, name, args, result}.',
|
|
34
|
+
words: 'The conversation',
|
|
35
|
+
list: true,
|
|
36
|
+
};
|
|
37
|
+
const FILES = {
|
|
38
|
+
path: '/files',
|
|
39
|
+
meaning: 'The files given on the page, each {name, type, size, data_url}: a text file’s contents go with the next run.',
|
|
40
|
+
words: 'The files given',
|
|
41
|
+
list: true,
|
|
42
|
+
};
|
|
31
43
|
const STOP = {
|
|
32
44
|
name: 'stop',
|
|
33
45
|
meaning: 'Stop the answer being written.',
|
|
@@ -54,6 +66,7 @@ export const APP_KIND_PATHS = {
|
|
|
54
66
|
words: 'Its answer',
|
|
55
67
|
},
|
|
56
68
|
STATUS,
|
|
69
|
+
MESSAGES,
|
|
57
70
|
],
|
|
58
71
|
accepts: [DRAFT],
|
|
59
72
|
actions: [
|
|
@@ -79,15 +92,22 @@ export const APP_KIND_PATHS = {
|
|
|
79
92
|
},
|
|
80
93
|
STATUS,
|
|
81
94
|
],
|
|
82
|
-
accepts: [],
|
|
95
|
+
accepts: [FILES],
|
|
83
96
|
actions: [
|
|
84
97
|
{
|
|
85
98
|
name: 'run',
|
|
86
|
-
meaning: 'Run it on its inputs: every value written under /inputs is sent, with the context’s message when there is one.',
|
|
99
|
+
meaning: 'Run it on its inputs: every value written under /inputs is sent, and each text file at /files, with the context’s message when there is one.',
|
|
87
100
|
words: 'Run it on its inputs',
|
|
88
101
|
context: ['message'],
|
|
89
102
|
asks: { message: 'What it says before the inputs' },
|
|
90
103
|
},
|
|
104
|
+
{
|
|
105
|
+
name: 'upload',
|
|
106
|
+
meaning: 'Run it on the files just given — the context’s files, else those at /files — with its inputs.',
|
|
107
|
+
words: 'Run it on the files given',
|
|
108
|
+
context: ['files'],
|
|
109
|
+
asks: { files: 'The files it runs on' },
|
|
110
|
+
},
|
|
91
111
|
STOP,
|
|
92
112
|
],
|
|
93
113
|
inputs: true,
|
|
@@ -197,8 +217,11 @@ export function initialInput(setting) {
|
|
|
197
217
|
return String(setting.default ?? '');
|
|
198
218
|
}
|
|
199
219
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
220
|
+
/**
|
|
221
|
+
* What the conversation publishes to the page, by path, now: from its
|
|
222
|
+
* current turn, and for a chat its whole conversation.
|
|
223
|
+
*/
|
|
224
|
+
export function appPageData(app, turn, conversation) {
|
|
202
225
|
const data = {
|
|
203
226
|
'/app': app.name,
|
|
204
227
|
'/status': STATUS_WORDS[turn.status] ?? '',
|
|
@@ -207,6 +230,7 @@ export function appPageData(app, turn) {
|
|
|
207
230
|
case 'chat':
|
|
208
231
|
data['/question'] = turn.user ?? '';
|
|
209
232
|
data['/answer'] = turn.assistant ?? '';
|
|
233
|
+
data['/messages'] = conversation;
|
|
210
234
|
break;
|
|
211
235
|
case 'widget':
|
|
212
236
|
data['/output'] = turn.assistant ?? '';
|
|
@@ -224,7 +248,7 @@ export function appPageData(app, turn) {
|
|
|
224
248
|
/** The page's data model when it is drawn: what is published, the inputs, the draft. */
|
|
225
249
|
export function appPageInitialData(app) {
|
|
226
250
|
const tree = {};
|
|
227
|
-
for (const [path, value] of Object.entries(appPageData(app, { id: 0, status: 'idle' }))) {
|
|
251
|
+
for (const [path, value] of Object.entries(appPageData(app, { id: 0, status: 'idle' }, []))) {
|
|
228
252
|
tree[path.slice(1)] = value;
|
|
229
253
|
}
|
|
230
254
|
const paths = appKindPaths(app);
|
|
@@ -237,6 +261,9 @@ export function appPageInitialData(app) {
|
|
|
237
261
|
if (paths.accepts.some(entry => entry.path === DRAFT.path)) {
|
|
238
262
|
tree.draft = '';
|
|
239
263
|
}
|
|
264
|
+
if (paths.accepts.some(entry => entry.path === FILES.path)) {
|
|
265
|
+
tree.files = [];
|
|
266
|
+
}
|
|
240
267
|
return tree;
|
|
241
268
|
}
|
|
242
269
|
/** One input in words: `Plan: Team`; nothing for an empty one. */
|
|
@@ -273,6 +300,83 @@ export function inputsInWords(app, inputs) {
|
|
|
273
300
|
}
|
|
274
301
|
return lines.filter(Boolean).join('\n');
|
|
275
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* The most of a file's text a message carries: about thirty thousand
|
|
305
|
+
* tokens. A larger file is refused in a sentence rather than sent for the
|
|
306
|
+
* model to refuse.
|
|
307
|
+
*/
|
|
308
|
+
export const MAX_FILE_CHARACTERS = 120_000;
|
|
309
|
+
/** The kinds of file read as text, besides `text/*`. */
|
|
310
|
+
const TEXT_TYPES = new Set([
|
|
311
|
+
'application/json',
|
|
312
|
+
'application/xml',
|
|
313
|
+
'application/yaml',
|
|
314
|
+
'application/x-yaml',
|
|
315
|
+
'application/csv',
|
|
316
|
+
]);
|
|
317
|
+
/** The extensions read as text whatever type the browser gives. */
|
|
318
|
+
const TEXT_EXTENSIONS = /\.(csv|tsv|txt|md|json|jsonl|xml|ya?ml|log)$/i;
|
|
319
|
+
/** The words of a data URL, decoded as UTF-8. */
|
|
320
|
+
function dataUrlText(dataUrl) {
|
|
321
|
+
const comma = dataUrl.indexOf(',');
|
|
322
|
+
if (!dataUrl.startsWith('data:') || comma < 0) {
|
|
323
|
+
throw new Error('it is not a data URL');
|
|
324
|
+
}
|
|
325
|
+
const body = dataUrl.slice(comma + 1);
|
|
326
|
+
if (!dataUrl.slice(0, comma).endsWith(';base64')) {
|
|
327
|
+
return decodeURIComponent(body);
|
|
328
|
+
}
|
|
329
|
+
const bytes = Uint8Array.from(atob(body), char => char.charCodeAt(0));
|
|
330
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The files given on the page with their contents, for the message a widget
|
|
334
|
+
* is run with — or why one cannot go: it is not a file as File upload gives
|
|
335
|
+
* it, not text, or too long for a message.
|
|
336
|
+
*/
|
|
337
|
+
export function filesInWords(files) {
|
|
338
|
+
if (files === undefined || files === null) {
|
|
339
|
+
return { words: '' };
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(files)) {
|
|
342
|
+
return { refused: 'What was given as files is not a list of files.' };
|
|
343
|
+
}
|
|
344
|
+
const parts = [];
|
|
345
|
+
for (const [index, file] of files.entries()) {
|
|
346
|
+
const given = (file ?? {});
|
|
347
|
+
if (typeof given.name !== 'string' || typeof given.data_url !== 'string') {
|
|
348
|
+
return {
|
|
349
|
+
refused: `File ${index + 1} is not a file as File upload gives it ({name, type, size, data_url}).`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const name = given.name;
|
|
353
|
+
const type = typeof given.type === 'string' ? given.type : '';
|
|
354
|
+
if (!type.startsWith('text/') &&
|
|
355
|
+
!TEXT_TYPES.has(type) &&
|
|
356
|
+
!TEXT_EXTENSIONS.test(name)) {
|
|
357
|
+
return {
|
|
358
|
+
refused: `${name} is not a text file: the page hands its application a file's text, in its message.`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
let text;
|
|
362
|
+
try {
|
|
363
|
+
text = dataUrlText(given.data_url);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
return {
|
|
367
|
+
refused: `${name} could not be read: ${error.message}.`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (text.length > MAX_FILE_CHARACTERS) {
|
|
371
|
+
return {
|
|
372
|
+
refused: `${name} is too long to send in a message: ${text.length.toLocaleString('en')} characters, at most ${MAX_FILE_CHARACTERS.toLocaleString('en')}.`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const fence = text.includes('```') ? '~~~~' : '```';
|
|
376
|
+
parts.push(`The file ${name} (${type || 'text'}):\n${fence}\n${text.replace(/\n$/, '')}\n${fence}`);
|
|
377
|
+
}
|
|
378
|
+
return { words: parts.join('\n\n') };
|
|
379
|
+
}
|
|
276
380
|
/**
|
|
277
381
|
* What a button pressed on the page does.
|
|
278
382
|
*
|
|
@@ -298,8 +402,21 @@ export function appPageAction(app, event, read, lastInputs = '') {
|
|
|
298
402
|
return { stop: true };
|
|
299
403
|
case 'new':
|
|
300
404
|
return { newChat: true };
|
|
301
|
-
case 'run':
|
|
302
|
-
|
|
405
|
+
case 'run':
|
|
406
|
+
case 'upload': {
|
|
407
|
+
const given = event.context?.files;
|
|
408
|
+
const files = filesInWords(event.name === 'upload' && given !== undefined
|
|
409
|
+
? given
|
|
410
|
+
: read(FILES.path));
|
|
411
|
+
if ('refused' in files) {
|
|
412
|
+
return files;
|
|
413
|
+
}
|
|
414
|
+
if (event.name === 'upload' && !files.words) {
|
|
415
|
+
return { refused: 'No file was given: choose one first.' };
|
|
416
|
+
}
|
|
417
|
+
const text = [[message, inputs].filter(Boolean).join('\n'), files.words]
|
|
418
|
+
.filter(Boolean)
|
|
419
|
+
.join('\n\n');
|
|
303
420
|
return text
|
|
304
421
|
? { send: text, inputs, clear: [] }
|
|
305
422
|
: { refused: `${app.name} has no inputs to run on: set one first.` };
|
|
@@ -39,7 +39,7 @@ import { useConfig } from '../../../hooks/useConfig';
|
|
|
39
39
|
import { useSkills, useSkillActions } from '../../../hooks/useSkills';
|
|
40
40
|
import { AI_MODEL_CATALOGUE, isChatModel } from '../../../specs/models';
|
|
41
41
|
import { LoopAgentBlueprint, LoopAgentGate, LoopChatComposer, LoopChatExtras, LoopChatHeader, LoopChatLayout, LoopChatSuggestion, LoopChatSurface, LoopChatTurn, LoopFrontendTool, LoopSlots, canOpenView, onPromptFocusRequest, onSurfaceRequest, useLoopPromptStore, } from '../../core';
|
|
42
|
-
import { turnWritersOf } from './turnState';
|
|
42
|
+
import { messageText, turnWritersOf } from './turnState';
|
|
43
43
|
import { isInactiveSurfaceContribution, orderToolContributions, toolsForChatView, } from './chatViewTools';
|
|
44
44
|
import { useAgentCommandTools } from '../../../tools/adapters/commands';
|
|
45
45
|
import { NONE_EDITOR, seedEditorChoice, setEditorOptions, } from '../shell/editorChoice';
|
|
@@ -225,6 +225,8 @@ export default function ChatView({ workspace }) {
|
|
|
225
225
|
if (!feed) {
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
228
|
+
// The whole conversation, for a page that shows it (a Chat block).
|
|
229
|
+
feed.items(items);
|
|
228
230
|
const SETTLED = new Set([
|
|
229
231
|
'complete',
|
|
230
232
|
'completed',
|
|
@@ -315,24 +317,31 @@ export default function ChatView({ workspace }) {
|
|
|
315
317
|
break;
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
|
-
const textOf = (message) => {
|
|
319
|
-
const content = message.content;
|
|
320
|
-
return typeof content === 'string'
|
|
321
|
-
? content
|
|
322
|
-
: content
|
|
323
|
-
.map(part => typeof part === 'string'
|
|
324
|
-
? part
|
|
325
|
-
: (part.text ?? ''))
|
|
326
|
-
.join('');
|
|
327
|
-
};
|
|
328
320
|
const reply = messages
|
|
329
321
|
.slice(lastUser + 1)
|
|
330
322
|
.filter(message => message.role === 'assistant')
|
|
331
|
-
.map(
|
|
323
|
+
.map(messageText)
|
|
332
324
|
.filter(text => text.trim().length > 0)
|
|
333
325
|
.join('\n\n');
|
|
334
326
|
feed.assistant(reply);
|
|
335
327
|
}, []);
|
|
328
|
+
const heldForAdapter = useRef([]);
|
|
329
|
+
/*
|
|
330
|
+
* Every message that goes to the agent goes through here: the composer's
|
|
331
|
+
* (through the workspace's dispatch and the prompt channel), a host's, and
|
|
332
|
+
* one sent from the application's page (`viewControls.send`) — so each
|
|
333
|
+
* begins its turn, and a page reading the turn sees it asked and answered.
|
|
334
|
+
*/
|
|
335
|
+
const sendNow = useCallback((message) => {
|
|
336
|
+
// A new turn: whatever the panel showed is gone, this message is it.
|
|
337
|
+
turnFeedRef.current?.begin(message, controlsRef.current?.thread());
|
|
338
|
+
const send = controlsRef.current?.send;
|
|
339
|
+
if (!send) {
|
|
340
|
+
heldForAdapter.current.push(message);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
send(message);
|
|
344
|
+
}, []);
|
|
336
345
|
// One writer, so neither fact can erase the other.
|
|
337
346
|
useEffect(() => {
|
|
338
347
|
setViewControls(sendReady
|
|
@@ -340,10 +349,10 @@ export default function ChatView({ workspace }) {
|
|
|
340
349
|
busy,
|
|
341
350
|
stop: controlsRef.current?.stop,
|
|
342
351
|
newChat: controlsRef.current?.newChat,
|
|
343
|
-
send:
|
|
352
|
+
send: sendNow,
|
|
344
353
|
}
|
|
345
354
|
: null);
|
|
346
|
-
}, [busy, sendReady, setViewControls]);
|
|
355
|
+
}, [busy, sendReady, setViewControls, sendNow]);
|
|
347
356
|
// Stop reporting when the view goes away, or the shell would keep a stop
|
|
348
357
|
// button for something that is no longer there.
|
|
349
358
|
useEffect(() => () => setViewControls(null), [setViewControls]);
|
|
@@ -358,15 +367,7 @@ export default function ChatView({ workspace }) {
|
|
|
358
367
|
* again. Held instead, and sent the moment the adapter is ready, which is
|
|
359
368
|
* the next second or two rather than minutes.
|
|
360
369
|
*/
|
|
361
|
-
|
|
362
|
-
useEffect(() => workspace.prompts.subscribe(message => {
|
|
363
|
-
const send = controlsRef.current?.send;
|
|
364
|
-
if (!send) {
|
|
365
|
-
heldForAdapter.current.push(message);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
send(message);
|
|
369
|
-
}), [workspace.prompts]);
|
|
370
|
+
useEffect(() => workspace.prompts.subscribe(sendNow), [workspace.prompts, sendNow]);
|
|
370
371
|
// The adapter arrived: whatever was said while it was starting goes now.
|
|
371
372
|
useEffect(() => {
|
|
372
373
|
if (!sendReady || heldForAdapter.current.length === 0) {
|
|
@@ -397,8 +398,8 @@ export default function ChatView({ workspace }) {
|
|
|
397
398
|
inputPromptConfig?.firstPromptHook?.();
|
|
398
399
|
}
|
|
399
400
|
setTransient(null);
|
|
400
|
-
//
|
|
401
|
-
|
|
401
|
+
// The turn begins where the message goes out (`sendNow`): a command
|
|
402
|
+
// the workspace runs is not one.
|
|
402
403
|
const outcome = await workspace.submit(message);
|
|
403
404
|
if (!outcome.handled) {
|
|
404
405
|
/*
|
|
@@ -82,7 +82,12 @@ export const ChatPlugin = definePlugin({
|
|
|
82
82
|
*/
|
|
83
83
|
build: ctx => {
|
|
84
84
|
const feed = createTurnFeed();
|
|
85
|
-
ctx.contribute(LoopChatTurn, {
|
|
85
|
+
ctx.contribute(LoopChatTurn, {
|
|
86
|
+
id: 'chat-turn',
|
|
87
|
+
turn: feed.turn,
|
|
88
|
+
conversation: feed.conversation,
|
|
89
|
+
...feedWriters(feed),
|
|
90
|
+
}, { id: 'chat-turn' });
|
|
86
91
|
return {};
|
|
87
92
|
},
|
|
88
93
|
contributes: [
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* previous turn), `assistant` as the reply arrives, `end` when the agent
|
|
8
8
|
* stops. Readers hold the signal.
|
|
9
9
|
*
|
|
10
|
+
* Beside the turn, the whole conversation (`conversation`), for a page that
|
|
11
|
+
* shows it as the Chat component does: the chat view hands it every change
|
|
12
|
+
* of its items, streaming included.
|
|
13
|
+
*
|
|
10
14
|
* @module loop/plugins/chat/turnState
|
|
11
15
|
*/
|
|
12
16
|
import { type ReadonlySignal } from '@datalayer/reactor';
|
|
13
17
|
import type { ContextSnapshotData } from '../../../types';
|
|
14
|
-
import type {
|
|
18
|
+
import type { DisplayItem } from '../../../types/chat';
|
|
19
|
+
import type { ChatMessage } from '../../../types/messages';
|
|
20
|
+
import type { ChatTurnSnapshot, ChatTurnStatus, ConversationEntry } from '../../core';
|
|
15
21
|
export type TurnFeed = {
|
|
16
22
|
/** The turn, for readers. */
|
|
17
23
|
turn: ReadonlySignal<ChatTurnSnapshot>;
|
|
@@ -28,7 +34,20 @@ export type TurnFeed = {
|
|
|
28
34
|
usage: (snapshot: ContextSnapshotData | undefined) => void;
|
|
29
35
|
/** What the agent is doing now, or nothing. */
|
|
30
36
|
activity: (label: string | undefined) => void;
|
|
37
|
+
/** The conversation, for readers. */
|
|
38
|
+
conversation: ReadonlySignal<ConversationEntry[]>;
|
|
39
|
+
/** The chat's items as they now stand, streaming included. */
|
|
40
|
+
items: (items: DisplayItem[]) => void;
|
|
31
41
|
};
|
|
42
|
+
/** A message's words, whether its content is a string or parts. */
|
|
43
|
+
export declare function messageText(message: ChatMessage): string;
|
|
44
|
+
/**
|
|
45
|
+
* The conversation as a page shows it: each person's and assistant's
|
|
46
|
+
* message with words in it, and each tool call by its name, its arguments
|
|
47
|
+
* and its result once it has one. A system message is the chat's own and is
|
|
48
|
+
* left out.
|
|
49
|
+
*/
|
|
50
|
+
export declare function conversationOf(items: DisplayItem[]): ConversationEntry[];
|
|
32
51
|
export declare function createTurnFeed(): TurnFeed;
|
|
33
52
|
/**
|
|
34
53
|
* The writers, as extra fields on the contribution.
|
|
@@ -11,14 +11,70 @@
|
|
|
11
11
|
* previous turn), `assistant` as the reply arrives, `end` when the agent
|
|
12
12
|
* stops. Readers hold the signal.
|
|
13
13
|
*
|
|
14
|
+
* Beside the turn, the whole conversation (`conversation`), for a page that
|
|
15
|
+
* shows it as the Chat component does: the chat view hands it every change
|
|
16
|
+
* of its items, streaming included.
|
|
17
|
+
*
|
|
14
18
|
* @module loop/plugins/chat/turnState
|
|
15
19
|
*/
|
|
16
20
|
import { signal } from '@datalayer/reactor';
|
|
21
|
+
/** A message's words, whether its content is a string or parts. */
|
|
22
|
+
export function messageText(message) {
|
|
23
|
+
const content = message.content;
|
|
24
|
+
return typeof content === 'string'
|
|
25
|
+
? content
|
|
26
|
+
: content
|
|
27
|
+
.map(part => typeof part === 'string'
|
|
28
|
+
? part
|
|
29
|
+
: (part.text ?? ''))
|
|
30
|
+
.join('');
|
|
31
|
+
}
|
|
32
|
+
const isToolCall = (item) => item.type === 'tool-call';
|
|
33
|
+
/**
|
|
34
|
+
* The conversation as a page shows it: each person's and assistant's
|
|
35
|
+
* message with words in it, and each tool call by its name, its arguments
|
|
36
|
+
* and its result once it has one. A system message is the chat's own and is
|
|
37
|
+
* left out.
|
|
38
|
+
*/
|
|
39
|
+
export function conversationOf(items) {
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (isToolCall(item)) {
|
|
43
|
+
entries.push({
|
|
44
|
+
role: 'tool',
|
|
45
|
+
name: item.toolName,
|
|
46
|
+
args: item.args ?? {},
|
|
47
|
+
...(item.result === undefined ? {} : { result: item.result }),
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (item.role !== 'user' && item.role !== 'assistant') {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const text = messageText(item);
|
|
55
|
+
if (text.trim()) {
|
|
56
|
+
entries.push({ role: item.role, text });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
61
|
+
/** Whether two conversations say the same, entry by entry. */
|
|
62
|
+
const sameConversation = (one, other) => one.length === other.length &&
|
|
63
|
+
one.every((entry, index) => JSON.stringify(entry) === JSON.stringify(other[index]));
|
|
17
64
|
const IDLE = { id: 0, status: 'idle' };
|
|
18
65
|
export function createTurnFeed() {
|
|
19
66
|
const turn = signal(IDLE);
|
|
67
|
+
const conversation = signal([]);
|
|
20
68
|
return {
|
|
21
69
|
turn,
|
|
70
|
+
conversation,
|
|
71
|
+
items: items => {
|
|
72
|
+
const next = conversationOf(items);
|
|
73
|
+
// Nothing new: readers do not re-render for an identical conversation.
|
|
74
|
+
if (!sameConversation(conversation.value, next)) {
|
|
75
|
+
conversation.value = next;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
22
78
|
begin: (user, thread) => {
|
|
23
79
|
// The window's fill carries over: it is the conversation's, not the
|
|
24
80
|
// turn's, and the footer under a fresh turn should not read empty
|