@columenlabs/lumen-ui 0.1.215 → 0.1.216

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.
@@ -38,6 +38,13 @@ export interface AssistantAdapter {
38
38
  usage?(month?: string): Promise<UsageHistory>;
39
39
  /** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
40
40
  send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
41
+ /**
42
+ * A message for the answer running in `chatId`: it joins at the answer's next step. `steered:
43
+ * false` (or no method) and the panel queues it for after the answer instead.
44
+ */
45
+ steer?(chatId: string, message: string): Promise<{
46
+ steered: boolean;
47
+ }>;
41
48
  settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
42
49
  /** Takes back a change the assistant made at once. */
43
50
  undoWrite(chatId: string, stepId: string): Promise<StepItem>;
@@ -71,6 +71,7 @@ export function assistantClient(base) {
71
71
  action,
72
72
  ...(amount !== undefined ? { amount } : {}),
73
73
  }),
74
+ steer: (chatId, message) => send('POST', `/chats/${encodeURIComponent(chatId)}/steer`, { message }),
74
75
  async send(msg, onEvent, signal) {
75
76
  const url = `${root}/chat`;
76
77
  const res = await fetch(url, {
@@ -3,7 +3,7 @@ import { type LumenOption } from '../lumen-selector.js';
3
3
  import type { AssistantAdapter } from './client.js';
4
4
  import { type AssistantSource } from './sources.js';
5
5
  import { usd } from './usage-history.js';
6
- import { type AssistantChatState } from './use-assistant-chat.js';
6
+ import { type AssistantChatState, type QueuedMessage } from './use-assistant-chat.js';
7
7
  export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
8
8
  /** The person's assistant status, shared by the panel and Settings. */
9
9
  export declare function useAssistantStatus(client: AssistantAdapter): import("@tanstack/react-query").UseQueryResult<AssistantStatus, Error>;
@@ -68,18 +68,28 @@ export interface ComposerExtra {
68
68
  images?: AssistantImage[];
69
69
  }
70
70
  /**
71
- * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
71
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. While it
72
+ * runs, words in the box go into it with Enter (Steer, where `canSteer`) or wait for it to end
73
+ * with Alt+Enter (Queue); the `queue` lists above the box, each with Steer now, Edit and Remove.
74
+ * Three
72
75
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
73
76
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
74
77
  */
75
- export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
78
+ export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
76
79
  name: string;
77
80
  /** Images can be pasted or dropped on the box (`LumenAssistant` `images`). */
78
81
  images?: boolean;
79
82
  /** The page's name for `@` This page; the document's title by default. */
80
83
  page?: string;
81
84
  busy: boolean;
85
+ /** The first queued message; `queue` lists them all, with their actions. */
82
86
  queued: string | null;
87
+ queue?: QueuedMessage[];
88
+ /** Enter steers a message into the running answer; Alt+Enter queues it. */
89
+ canSteer?: boolean;
90
+ onSteerQueued?: (id: string) => void;
91
+ /** Takes a queued message out; Edit puts its words back in the box. */
92
+ onUnqueue?: (id: string) => QueuedMessage | undefined;
83
93
  disabled?: boolean;
84
94
  commands?: AssistantCommand[];
85
95
  /** What `#` lists. */
@@ -90,7 +100,7 @@ export declare function LumenComposer({ name, busy, queued, disabled, commands,
90
100
  }[];
91
101
  /** What `@` lists, a group each. */
92
102
  sources?: readonly AssistantSource[];
93
- onSend: (text: string, extra: ComposerExtra) => void;
103
+ onSend: (text: string, extra: ComposerExtra, how?: 'steer' | 'queue') => void;
94
104
  onStop: () => void;
95
105
  }): import("react").JSX.Element;
96
106
  /**
@@ -6,7 +6,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  // with Stop, a queued message and the chat's cost and context under it.
7
7
  import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, } from '@columenlabs/lumen-ui-types/assistant';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
- import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, LightbulbIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
9
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
10
10
  import { Fragment, createContext, useContext, useEffect, useRef, useState, } from 'react';
11
11
  import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
12
12
  import { screenMapFor, useAssistantScreen } from '../assistant-screen.js';
@@ -30,7 +30,7 @@ import { ChatImage } from './chat-image.js';
30
30
  import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
31
31
  import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
32
32
  import { UsageHistoryView, tokens, usd } from './usage-history.js';
33
- import { useAssistantChat } from './use-assistant-chat.js';
33
+ import { useAssistantChat, } from './use-assistant-chat.js';
34
34
  // The Markdown renderer (tables included) loads with the first answer (lazy-part.tsx).
35
35
  // A tab whose build a deploy replaced shows the answer as plain text instead of reloading mid chat.
36
36
  const ChatMarkdown = lazyPart(() => import('./chat-markdown.js').then((m) => m.default), null, ({ children }) => _jsx("p", { className: "whitespace-pre-wrap", children: children }));
@@ -190,7 +190,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
190
190
  title: c.title,
191
191
  updated_at: new Date(c.at).toISOString(),
192
192
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
193
- })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: CopyIcon, label: "Copy chat", disabled: !chat.items.length, onClick: () => copyMarkdown(chatMarkdown(chat.title, chat.items, name), 'Chat') }), _jsx(IconButton, { icon: PlusIcon, label: "New chat", disabled: !chat.items.length, onClick: chat.reset }), onClose && (_jsx(IconButton, { icon: XIcon, label: `Close ${s?.name ?? 'assistant'}`, onClick: onClose }))] }), _jsx(AssistantName.Provider, { value: name, children: _jsx(Screen.Provider, { value: controls, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more) => chat.send(text, { ...extra(), ...more }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
193
+ })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: CopyIcon, label: "Copy chat", disabled: !chat.items.length, onClick: () => copyMarkdown(chatMarkdown(chat.title, chat.items, name), 'Chat') }), _jsx(IconButton, { icon: PlusIcon, label: "New chat", disabled: !chat.items.length, onClick: chat.reset }), onClose && (_jsx(IconButton, { icon: XIcon, label: `Close ${s?.name ?? 'assistant'}`, onClick: onClose }))] }), _jsx(AssistantName.Provider, { value: name, children: _jsx(Screen.Provider, { value: controls, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, queue: chat.queue, canSteer: chat.canSteer, onSteerQueued: chat.steerQueued, onUnqueue: chat.unqueue, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
194
194
  }
195
195
  function Messages({ chat, status, onSettings, }) {
196
196
  const end = useRef(null);
@@ -264,6 +264,8 @@ function Item({ item, onSettle, busy, }) {
264
264
  const tierName = useContext(TierNames);
265
265
  switch (item.kind) {
266
266
  case 'user':
267
+ if (item.steered)
268
+ return (_jsxs("div", { className: "ml-8 flex flex-col items-end gap-0.5 self-end", children: [_jsxs("span", { className: "flex items-center gap-1 text-primary text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3" }), "Steered"] }), _jsx("div", { className: "rounded-lg border border-primary/40 bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words", children: item.text })] }));
267
269
  return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap has-[[data-slot=chat-image]]:ml-0 has-[[data-slot=chat-image]]:w-full", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: TIER, label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.images?.length ? (_jsx("span", { className: "mb-1 flex flex-wrap gap-1.5", children: item.images.map((src, i) => (_jsx(ChatImage, { src: src, alt: `Pasted ${i + 1}` }, `${i}:${src.length}`))) })) : null, item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
268
270
  case 'text':
269
271
  return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(ChatMarkdown, { children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
@@ -466,11 +468,14 @@ async function readImage(file) {
466
468
  }
467
469
  const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
468
470
  /**
469
- * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
471
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. While it
472
+ * runs, words in the box go into it with Enter (Steer, where `canSteer`) or wait for it to end
473
+ * with Alt+Enter (Queue); the `queue` lists above the box, each with Steer now, Edit and Remove.
474
+ * Three
470
475
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
471
476
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
472
477
  */
473
- export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
478
+ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
474
479
  const [text, setText] = useState('');
475
480
  const [caret, setCaret] = useState(0);
476
481
  const [closed, setClosed] = useState(null);
@@ -557,7 +562,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
557
562
  lumenToast.error(errorText(e));
558
563
  }
559
564
  };
560
- const send = () => {
565
+ const send = (how) => {
561
566
  if (!text.trim() || disabled)
562
567
  return;
563
568
  const c = m ? commands.find((x) => x.name === m[1]) : undefined;
@@ -565,9 +570,25 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
565
570
  void run(c, m?.[2] ?? '');
566
571
  return;
567
572
  }
568
- onSend(text, extra());
573
+ onSend(text, extra(), busy ? (how ?? (canSteer ? 'steer' : 'queue')) : undefined);
569
574
  clear();
570
575
  };
576
+ // Edit: a queued message back in the box, its chips with it.
577
+ const edit = (id) => {
578
+ const q = onUnqueue?.(id);
579
+ if (!q)
580
+ return;
581
+ setText(q.message);
582
+ setCaret(q.message.length);
583
+ if (q.extra?.tier)
584
+ setTier(q.extra.tier);
585
+ if (q.extra?.refs?.length)
586
+ setRefs(q.extra.refs);
587
+ if (q.extra?.images?.length)
588
+ setImages(q.extra.images);
589
+ requestAnimationFrame(() => box.current?.focus());
590
+ };
591
+ const waiting = queue ?? (queued ? [{ id: 'queued', message: queued }] : []);
571
592
  const pickCommand = (o) => {
572
593
  const c = commands.find((x) => x.name === o.value);
573
594
  if (!c)
@@ -707,7 +728,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
707
728
  };
708
729
  const tierLabel = tiers.find((t) => t.id === tier)?.label;
709
730
  const hints = [commands.length && '/', POINT, tiers.length > 1 && TIER].filter(Boolean);
710
- return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [queued && (_jsxs("p", { className: "mb-2 truncate text-muted-foreground text-xs", children: ["Queued: ", _jsx("span", { className: "text-foreground", children: queued })] })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
731
+ return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [waiting.length > 0 && (_jsx(ComposerQueue, { queue: waiting, canSteer: busy && canSteer && !!onSteerQueued, onSteer: (id) => onSteerQueued?.(id), onEdit: onUnqueue ? edit : undefined, onRemove: onUnqueue ? (id) => void onUnqueue(id) : undefined })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
711
732
  value: c.name,
712
733
  label: `/${c.name}`,
713
734
  ...(c.args ? { detail: c.args } : {}),
@@ -728,7 +749,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
728
749
  const r = sourceItems.get(o.value)?.();
729
750
  if (r)
730
751
  setRefs((p) => [...p, r]);
731
- }, onClose: () => setClosed(`${text}|${caret}`) })), images.length > 0 && (_jsx("div", { className: "mb-2 flex flex-wrap gap-1.5", "aria-label": "Images going with your message", children: images.map((img, i) => (_jsxs("span", { className: "relative", children: [_jsx("img", { src: imageUrl(img), alt: `Pasted ${i + 1}`, className: "size-12 rounded-md border object-cover" }), _jsx("button", { type: "button", "aria-label": `Remove image ${i + 1}`, className: "absolute -top-1.5 -right-1.5 grid size-4 place-items-center rounded-full border bg-background text-muted-foreground hover:text-foreground", onClick: () => setImages((p) => p.filter((_, j) => j !== i)), children: _jsx(XIcon, { className: "size-3" }) })] }, `${i}:${img.data.length}`))) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && (_jsx(ComposerChip, { sign: TIER, label: tierLabel, onRemove: () => setTier(null) })), refs.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => {
752
+ }, onClose: () => setClosed(`${text}|${caret}`) })), images.length > 0 && (_jsx("div", { className: "mb-2 flex flex-wrap gap-1.5", "aria-label": "Images going with your message", children: images.map((img, i) => (_jsxs("span", { className: "relative", children: [_jsx("img", { src: imageUrl(img), alt: `Pasted ${i + 1}`, className: "size-12 rounded-md border object-cover" }), _jsx("button", { type: "button", "aria-label": `Remove image ${i + 1}`, className: "absolute -top-1.5 -right-1.5 grid size-4 place-items-center rounded-full border bg-background text-muted-foreground hover:text-foreground", onClick: () => setImages((p) => p.filter((_, j) => j !== i)), children: _jsx(XIcon, { className: "size-3" }) })] }, `${i}:${img.data.length}`))) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && (_jsx(ComposerChip, { sign: TIER, label: tierLabel, onRemove: () => setTier(null) })), refs.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: cn('max-h-48 min-h-11 resize-none', busy && text.trim() && canSteer ? 'pr-18' : 'pr-11'), value: text, disabled: disabled, onChange: (e) => {
732
753
  setText(e.target.value);
733
754
  setCaret(e.target.selectionStart);
734
755
  }, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onPaste: (e) => {
@@ -746,9 +767,17 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
746
767
  return;
747
768
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
748
769
  e.preventDefault();
749
- send();
770
+ send(e.altKey ? 'queue' : undefined);
750
771
  }
751
- } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: busy ? 'Queue' : 'Send', variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: send })) })] })] }));
772
+ } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : busy ? (_jsxs("span", { className: "flex gap-1", children: [_jsx(IconButton, { icon: ListPlusIcon, label: canSteer ? 'Queue for after (⌥↩)' : 'Queue for after (↩)', variant: canSteer ? 'outline' : 'default', size: "icon-xs", disabled: disabled, onClick: () => send('queue') }), canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now (\u21A9)", variant: "default", size: "icon-xs", disabled: disabled, onClick: () => send('steer') }))] })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: "Send", variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: () => send() })) })] })] }));
773
+ }
774
+ /**
775
+ * What waits above the box while an answer runs: steering messages until the answer reads them,
776
+ * then queued ones in order, each with Steer now, Edit (back in the box) and Remove.
777
+ */
778
+ function ComposerQueue({ queue, canSteer, onSteer, onEdit, onRemove, }) {
779
+ const waiting = queue.filter((q) => !q.steering).length;
780
+ return (_jsxs("div", { className: "mb-2 flex flex-col gap-1", children: [waiting > 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-muted-foreground text-xs", children: [_jsx(ListPlusIcon, { className: "size-3.5" }), "Queued \u00B7 ", waiting] })), queue.map((q) => (_jsxs("div", { className: "flex h-8 min-w-0 items-center gap-1 rounded-md border bg-background pr-1 pl-2 text-sm", children: [q.steering && (_jsx(CornerDownRightIcon, { "aria-label": "Steering: Sage reads it at its next step", className: "size-3.5 shrink-0 animate-pulse text-primary" })), _jsx("span", { className: cn('min-w-0 flex-1 truncate', q.steering && 'text-muted-foreground'), title: q.message, children: q.message }), !q.steering && canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now", size: "icon-xs", onClick: () => onSteer(q.id) })), !q.steering && onEdit && (_jsx(IconButton, { icon: PencilIcon, label: "Edit", size: "icon-xs", onClick: () => onEdit(q.id) })), !q.steering && onRemove && (_jsx(IconButton, { icon: XIcon, label: "Remove", size: "icon-xs", onClick: () => onRemove(q.id) }))] }, q.id)))] }));
752
781
  }
753
782
  // The same thing pointed at twice: its kind and what finds it, whatever its words read then.
754
783
  const refKey = (r) => r.kind === 'page'
@@ -4,6 +4,15 @@ import type { AssistantAdapter } from './client.js';
4
4
  export type LiveItem = ChatItem & {
5
5
  live?: boolean;
6
6
  };
7
+ type Extra = Omit<AssistantMessage, 'message' | 'chat_id'>;
8
+ /** A message sent while an answer runs: waiting for it to end, or steered into it. */
9
+ export interface QueuedMessage {
10
+ id: string;
11
+ message: string;
12
+ extra?: Extra;
13
+ /** Sent into the running answer, not read by it yet. */
14
+ steering?: boolean;
15
+ }
7
16
  export interface AssistantChatState {
8
17
  chatId: string | null;
9
18
  title: string;
@@ -11,11 +20,23 @@ export interface AssistantChatState {
11
20
  usage: ChatUsage | null;
12
21
  /** An answer is streaming. */
13
22
  busy: boolean;
14
- /** A message waiting for the answer to end. */
23
+ /** The first message waiting for the answer to end (`queue` has them all). */
15
24
  queued: string | null;
25
+ /** Messages sent while the answer runs, in order: steering ones first wait to be read. */
26
+ queue: QueuedMessage[];
27
+ /** The adapter takes a message into a running answer (Steer). */
28
+ canSteer: boolean;
16
29
  /** The chat is loading. */
17
30
  loading: boolean;
18
- send: (message: string, extra?: Omit<AssistantMessage, 'message' | 'chat_id'>) => void;
31
+ /**
32
+ * Sends now, or while an answer runs: `steer` (the default where the adapter can) into it at its
33
+ * next step, else queued for after it.
34
+ */
35
+ send: (message: string, extra?: Extra, how?: 'steer' | 'queue') => void;
36
+ /** A queued message steered into the running answer now. */
37
+ steerQueued: (id: string) => void;
38
+ /** Takes a queued message out; its words and extras come back (Edit puts them in the box). */
39
+ unqueue: (id: string) => QueuedMessage | undefined;
19
40
  stop: () => void;
20
41
  open: (id: string) => Promise<void>;
21
42
  reset: () => void;
@@ -28,3 +49,4 @@ export interface AssistantChatState {
28
49
  /** A copy of the items with one event applied (hand-off sections copied too). */
29
50
  export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
30
51
  export declare function useAssistantChat(client: AssistantAdapter): AssistantChatState;
52
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { ApiError } from '../api-fetch.js';
2
3
  import { errorText } from '../integrations.js';
3
4
  /** The browser's IANA zone, sent with each message for the assistant's bearings. */
4
5
  function browserZone() {
@@ -30,6 +31,8 @@ function keepOutcomes(m) {
30
31
  }
31
32
  catch { }
32
33
  }
34
+ let queuedSeq = 0;
35
+ const queuedId = () => `q${++queuedSeq}`;
33
36
  /** Where an event lands: the chat, or the last hand-off section when it came from inside one. */
34
37
  function sinkOf(items, handoff) {
35
38
  if (!handoff)
@@ -148,12 +151,34 @@ export function useAssistantChat(client) {
148
151
  const [usage, setUsage] = useState(null);
149
152
  const [busy, setBusy] = useState(false);
150
153
  const [loading, setLoading] = useState(false);
151
- const [queued, setQueued] = useState(null);
154
+ const [queue, setQueue] = useState([]);
155
+ // An adapter whose app has no steer route: Steer hides and messages queue.
156
+ const [steerOff, setSteerOff] = useState(false);
152
157
  const abort = useRef(null);
153
158
  const outcomes = useRef(null);
154
159
  outcomes.current ??= readOutcomes();
155
160
  const idRef = useRef(null);
156
161
  idRef.current = chatId;
162
+ const queueRef = useRef([]);
163
+ queueRef.current = queue;
164
+ /** Steering messages not read (all, or those `texts`) go back to waiting, first in line. */
165
+ const unsteer = useCallback((texts) => {
166
+ setQueue((q) => {
167
+ const left = [...(texts ?? [])];
168
+ const back = q.map((x) => {
169
+ if (!x.steering)
170
+ return x;
171
+ if (texts) {
172
+ const at = left.indexOf(x.message);
173
+ if (at < 0)
174
+ return x;
175
+ left.splice(at, 1);
176
+ }
177
+ return { ...x, steering: false };
178
+ });
179
+ return back.some((x, i) => x !== q[i]) ? back : q;
180
+ });
181
+ }, []);
157
182
  const run = useCallback(async (message, extra) => {
158
183
  const ctl = new AbortController();
159
184
  abort.current = ctl;
@@ -194,6 +219,19 @@ export function useAssistantChat(client) {
194
219
  setUsage(e.data);
195
220
  else if (e.event === 'done')
196
221
  setUsage(e.data.usage);
222
+ else if (e.event === 'steered') {
223
+ // Read by the answer here: the line moves from the queue into the chat.
224
+ setQueue((q) => {
225
+ const at = q.findIndex((m) => m.steering && m.message === e.data.text);
226
+ return at < 0 ? q : [...q.slice(0, at), ...q.slice(at + 1)];
227
+ });
228
+ setItems((p) => [
229
+ ...settled(p),
230
+ { kind: 'user', text: e.data.text, at: e.data.at, steered: true },
231
+ ]);
232
+ }
233
+ else if (e.event === 'unsteered')
234
+ unsteer(e.data.texts);
197
235
  else
198
236
  setItems((p) => applyEvent(p, e));
199
237
  }, ctl.signal);
@@ -211,30 +249,78 @@ export function useAssistantChat(client) {
211
249
  }
212
250
  finally {
213
251
  setItems(settled);
252
+ // Steers the answer never read (Stop, an error) wait for the next answer instead.
253
+ unsteer();
214
254
  abort.current = null;
215
255
  setBusy(false);
216
256
  }
217
- }, [client]);
218
- // A queued message goes once the answer ends.
257
+ }, [client, unsteer]);
258
+ // A queued message goes once the answer ends, one an answer.
219
259
  useEffect(() => {
220
- if (busy || !queued)
260
+ if (busy)
261
+ return;
262
+ const next = queue[0];
263
+ if (!next || next.steering)
221
264
  return;
222
- setQueued(null);
223
- void run(queued.message, queued.extra);
224
- }, [busy, queued, run]);
225
- const send = useCallback((message, extra) => {
265
+ setQueue((q) => q.slice(1));
266
+ void run(next.message, next.extra);
267
+ }, [busy, queue, run]);
268
+ const canSteer = !!client.steer && !steerOff;
269
+ // Into the running answer; refused (none runs, the model reads none) it waits in the queue.
270
+ const steerIn = useCallback(async (m) => {
271
+ const id = idRef.current;
272
+ let steered = false;
273
+ if (id && client.steer && abort.current)
274
+ try {
275
+ steered = (await client.steer(id, m.message)).steered;
276
+ }
277
+ catch (err) {
278
+ if (err instanceof ApiError && [404, 405, 410].includes(err.status))
279
+ setSteerOff(true);
280
+ }
281
+ if (!steered)
282
+ setQueue((q) => q.map((x) => (x.id === m.id ? { ...x, steering: false } : x)));
283
+ }, [client]);
284
+ const send = useCallback((message, extra, how) => {
226
285
  const text = message.trim();
227
286
  if (!text)
228
287
  return;
229
- if (abort.current)
230
- setQueued({ message: text, ...(extra ? { extra } : {}) });
231
- else
232
- void run(text, extra);
233
- }, [run]);
288
+ if (!abort.current)
289
+ return void run(text, extra);
290
+ const steer = (how ?? 'steer') === 'steer' && canSteer && !!idRef.current;
291
+ const m = {
292
+ id: queuedId(),
293
+ message: text,
294
+ ...(extra ? { extra } : {}),
295
+ ...(steer ? { steering: true } : {}),
296
+ };
297
+ // Steering ones sit first: they are read before anything queued after the answer.
298
+ setQueue((q) => steer ? [...q.filter((x) => x.steering), m, ...q.filter((x) => !x.steering)] : [...q, m]);
299
+ if (steer)
300
+ void steerIn(m);
301
+ }, [run, canSteer, steerIn]);
302
+ const steerQueued = useCallback((id) => {
303
+ const m = queueRef.current.find((x) => x.id === id && !x.steering);
304
+ if (!m || !abort.current)
305
+ return;
306
+ const on = { ...m, steering: true };
307
+ setQueue((q) => [
308
+ ...q.filter((x) => x.steering),
309
+ on,
310
+ ...q.filter((x) => !x.steering && x.id !== id),
311
+ ]);
312
+ void steerIn(on);
313
+ }, [steerIn]);
314
+ const unqueue = useCallback((id) => {
315
+ const m = queueRef.current.find((x) => x.id === id && !x.steering);
316
+ if (m)
317
+ setQueue((q) => q.filter((x) => x.id !== id));
318
+ return m;
319
+ }, []);
234
320
  const stop = useCallback(() => abort.current?.abort(), []);
235
321
  const reset = useCallback(() => {
236
322
  abort.current?.abort();
237
- setQueued(null);
323
+ setQueue([]);
238
324
  setChatId(null);
239
325
  idRef.current = null;
240
326
  setTitle('');
@@ -243,7 +329,7 @@ export function useAssistantChat(client) {
243
329
  }, []);
244
330
  const open = useCallback(async (id) => {
245
331
  abort.current?.abort();
246
- setQueued(null);
332
+ setQueue([]);
247
333
  setLoading(true);
248
334
  try {
249
335
  const c = await client.chat(id);
@@ -306,9 +392,13 @@ export function useAssistantChat(client) {
306
392
  items,
307
393
  usage,
308
394
  busy,
309
- queued: queued?.message ?? null,
395
+ queued: queue[0]?.message ?? null,
396
+ queue,
397
+ canSteer,
310
398
  loading,
311
399
  send,
400
+ steerQueued,
401
+ unqueue,
312
402
  stop,
313
403
  open,
314
404
  reset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.215",
3
+ "version": "0.1.216",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.215",
52
+ "@columenlabs/lumen-ui-types": "0.1.216",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",