@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.
@@ -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>;
@@ -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 answered through the chat's controls.
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
- /** What the conversation publishes to the page, by path, now. */
98
- export declare function appPageData(app: Pick<AppSpec, 'kind' | 'name' | 'goal'>, turn: ChatTurnSnapshot): Record<string, unknown>;
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
- /** What the conversation publishes to the page, by path, now. */
201
- export function appPageData(app, turn) {
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
- const text = [message, inputs].filter(Boolean).join('\n');
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(textOf)
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: controlsRef.current?.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
- const heldForAdapter = useRef([]);
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
- // A new turn: whatever the panel showed is gone, this message is it.
401
- turnFeedRef.current?.begin(message, controlsRef.current?.thread());
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, { id: 'chat-turn', turn: feed.turn, ...feedWriters(feed) }, { id: 'chat-turn' });
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 { ChatTurnSnapshot, ChatTurnStatus } from '../../core';
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.36",
3
+ "version": "1.3.38",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",