@columenlabs/lumen-ui 0.1.214 → 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.
- package/dist/assistant/chat-image.js +7 -4
- package/dist/assistant/client.d.ts +7 -0
- package/dist/assistant/client.js +1 -0
- package/dist/assistant/lumen-assistant.d.ts +14 -4
- package/dist/assistant/lumen-assistant.js +40 -11
- package/dist/assistant/use-assistant-chat.d.ts +24 -2
- package/dist/assistant/use-assistant-chat.js +106 -16
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// ChatImage: an image in a chat message. A thumbnail until clicked; then it opens full width in place,
|
|
3
|
-
// where
|
|
3
|
+
// where a pinch (or Ctrl + wheel) zooms at the pointer, a drag or two fingers pan and a double-click
|
|
4
4
|
// toggles fit and 2× (react-zoom-pan-pinch). Its toolbar zooms, resets, opens the image in a 90%
|
|
5
5
|
// lightbox with the same controls (the kit's Dialog: Esc, × or a click outside closes it) and
|
|
6
6
|
// shrinks it back to the thumbnail.
|
|
@@ -20,8 +20,11 @@ function ZoomBar({ children }) {
|
|
|
20
20
|
return (_jsxs("div", { className: "absolute top-2 right-2 z-10 flex items-center gap-0.5 rounded-lg border bg-popover/90 p-0.5 text-popover-foreground shadow-sm backdrop-blur-sm", "data-slot": "image-zoom-bar", children: [_jsx(IconButton, { icon: ZoomOutIcon, label: "Zoom out", disabled: scale <= 1.001, onClick: () => zoomOut() }), _jsxs("span", { className: "num min-w-10 text-center text-muted-foreground text-xs", "aria-live": "polite", children: [Math.round(scale * 100), "%"] }), _jsx(IconButton, { icon: ZoomInIcon, label: "Zoom in", disabled: scale >= MAX_SCALE - 0.001, onClick: () => zoomIn() }), _jsx(IconButton, { icon: RotateCcwIcon, label: "Reset zoom", onClick: () => resetTransform() }), _jsx("span", { className: "mx-0.5 h-4 w-px bg-border", "aria-hidden": true }), children] }));
|
|
21
21
|
}
|
|
22
22
|
/** The image at fit (scale 1), with zoom and pan; the wrapper takes the box its parent gives it. */
|
|
23
|
-
function Zoomable({ src, alt, children, }) {
|
|
24
|
-
|
|
23
|
+
function Zoomable({ src, alt, panAtFit = false, children, }) {
|
|
24
|
+
const [zoomed, setZoomed] = useState(false);
|
|
25
|
+
return (_jsxs(TransformWrapper, { minScale: 1, maxScale: MAX_SCALE, centerZoomedOut: true,
|
|
26
|
+
// A pinch (a wheel with ctrlKey) zooms; two fingers or a plain wheel pan.
|
|
27
|
+
wheel: { step: 0.01, wheelDisabled: true }, trackPadPanning: { disabled: !panAtFit && !zoomed, velocityDisabled: true }, onTransform: (_, { scale }) => setZoomed(scale > 1.001), doubleClick: { mode: 'toggle', step: 1 }, children: [_jsx(ZoomBar, { children: children }), _jsx(TransformComponent, { wrapperClass: "!size-full cursor-grab active:cursor-grabbing", contentClass: "!size-full", children: _jsx("img", { src: src, alt: alt, draggable: false, className: "size-full object-contain" }) })] }));
|
|
25
28
|
}
|
|
26
29
|
export function ChatImage({ src, alt }) {
|
|
27
30
|
const [open, setOpen] = useState(false);
|
|
@@ -43,5 +46,5 @@ export function ChatImage({ src, alt }) {
|
|
|
43
46
|
onOpenAutoFocus: (e) => {
|
|
44
47
|
e.preventDefault();
|
|
45
48
|
e.currentTarget.focus();
|
|
46
|
-
}, className: cn('h-[90vh] w-[90vw] max-w-none overflow-hidden p-0 sm:max-w-none', 'max-md:h-full max-md:w-full'), children: [_jsx(DialogTitle, { className: "sr-only", children: alt }), _jsx("div", { className: "relative size-full", children: _jsx(Zoomable, { src: src, alt: alt, children: _jsx(DialogClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close" }) }) }) })] }) })] }));
|
|
49
|
+
}, className: cn('h-[90vh] w-[90vw] max-w-none overflow-hidden p-0 sm:max-w-none', 'max-md:h-full max-md:w-full'), children: [_jsx(DialogTitle, { className: "sr-only", children: alt }), _jsx("div", { className: "relative size-full", children: _jsx(Zoomable, { src: src, alt: alt, panAtFit: true, children: _jsx(DialogClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close" }) }) }) })] }) })] }));
|
|
47
50
|
}
|
|
@@ -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>;
|
package/dist/assistant/client.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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: [
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 [
|
|
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
|
|
260
|
+
if (busy)
|
|
261
|
+
return;
|
|
262
|
+
const next = queue[0];
|
|
263
|
+
if (!next || next.steering)
|
|
221
264
|
return;
|
|
222
|
-
|
|
223
|
-
void run(
|
|
224
|
-
}, [busy,
|
|
225
|
-
const
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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",
|