@columenlabs/lumen-ui 0.1.225 → 0.1.227
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.d.ts +7 -0
- package/dist/assistant/chat-image.js +11 -7
- package/dist/assistant/copy.js +7 -1
- package/dist/assistant/image-card.d.ts +11 -0
- package/dist/assistant/image-card.js +61 -0
- package/dist/assistant/index.d.ts +2 -1
- package/dist/assistant/index.js +2 -1
- package/dist/assistant/lumen-assistant.d.ts +32 -3
- package/dist/assistant/lumen-assistant.js +58 -12
- package/dist/assistant/reply-selection.d.ts +8 -0
- package/dist/assistant/reply-selection.js +79 -0
- package/dist/assistant/sources.d.ts +9 -1
- package/dist/assistant/use-assistant-chat.js +2 -0
- package/dist/whats-new.d.ts +6 -1
- package/dist/whats-new.js +7 -5
- package/package.json +2 -2
|
@@ -2,3 +2,10 @@ export declare function ChatImage({ src, alt }: {
|
|
|
2
2
|
src: string;
|
|
3
3
|
alt: string;
|
|
4
4
|
}): import("react").JSX.Element;
|
|
5
|
+
/** An image over 90% of the screen with zoom and pan (the kit's Dialog: Esc, × or a click outside closes it). */
|
|
6
|
+
export declare function ImageLightbox({ src, alt, open, onOpenChange, }: {
|
|
7
|
+
src: string;
|
|
8
|
+
alt: string;
|
|
9
|
+
open: boolean;
|
|
10
|
+
onOpenChange: (open: boolean) => void;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -40,11 +40,15 @@ export function ChatImage({ src, alt }) {
|
|
|
40
40
|
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "relative w-full overflow-hidden rounded-md border bg-background", style: { aspectRatio: ratio ?? 4 / 3, maxHeight: '60vh' }, "data-slot": "chat-image", children: _jsxs(Zoomable, { src: src, alt: alt, children: [_jsx(IconButton, { icon: Maximize2Icon, label: "Full screen", onClick: () => setFull(true) }), _jsx(IconButton, { icon: Minimize2Icon, label: "Shrink", onClick: () => {
|
|
41
41
|
setOpen(false);
|
|
42
42
|
setFull(false);
|
|
43
|
-
} })] }) }), _jsx(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
} })] }) }), _jsx(ImageLightbox, { src: src, alt: alt, open: full, onOpenChange: setFull })] }));
|
|
44
|
+
}
|
|
45
|
+
/** An image over 90% of the screen with zoom and pan (the kit's Dialog: Esc, × or a click outside closes it). */
|
|
46
|
+
export function ImageLightbox({ src, alt, open, onOpenChange, }) {
|
|
47
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { showCloseButton: false, "aria-describedby": undefined,
|
|
48
|
+
// Focus the box, not its first button: a focused button shows its tooltip, which would take
|
|
49
|
+
// the first Esc.
|
|
50
|
+
onOpenAutoFocus: (e) => {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
e.currentTarget.focus();
|
|
53
|
+
}, 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" }) }) }) })] }) }));
|
|
50
54
|
}
|
package/dist/assistant/copy.js
CHANGED
|
@@ -36,7 +36,13 @@ function itemMarkdown(i, depth, name) {
|
|
|
36
36
|
const refs = i.refs?.length
|
|
37
37
|
? `\n\nPointing at: ${i.refs.map((r) => r.label).join(', ')}`
|
|
38
38
|
: '';
|
|
39
|
-
|
|
39
|
+
const quote = i.quote
|
|
40
|
+
? `${i.quote.text
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((l) => `> ${l}`)
|
|
43
|
+
.join('\n')}\n\n`
|
|
44
|
+
: '';
|
|
45
|
+
return `${h} You\n\n${quote}${i.text}${refs}`;
|
|
40
46
|
}
|
|
41
47
|
case 'text':
|
|
42
48
|
return `${h} ${name}\n\n${i.text}`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StepImage, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
/** Whether a tool's name as the model called it (`group__tool`) is `tool`. */
|
|
3
|
+
export declare const toolIs: (called: string, tool: string) => boolean;
|
|
4
|
+
export declare function ImageCard({ step, image, mode, }: {
|
|
5
|
+
step: StepItem;
|
|
6
|
+
image: StepImage;
|
|
7
|
+
/** The composer mode Make another turns on. */
|
|
8
|
+
mode?: string;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
/** The image tool's line while it runs. */
|
|
11
|
+
export declare function ImagePending(): import("react").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DownloadIcon, ImageIcon, ImagePlusIcon, Maximize2Icon, RefreshCwIcon } from 'lucide-react';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { IconButton } from '../icon-button.js';
|
|
5
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
6
|
+
import { ImageLightbox } from './chat-image.js';
|
|
7
|
+
import { composeAssistant } from './sources.js';
|
|
8
|
+
/** Whether a tool's name as the model called it (`group__tool`) is `tool`. */
|
|
9
|
+
export const toolIs = (called, tool) => called === tool || called.endsWith(`__${tool}`);
|
|
10
|
+
/** The image as the composer sends it, or null when it cannot be read. */
|
|
11
|
+
async function asReference(url) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(url, {
|
|
14
|
+
credentials: 'same-origin',
|
|
15
|
+
signal: AbortSignal.timeout(15_000),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return null;
|
|
19
|
+
const blob = await res.blob();
|
|
20
|
+
const data = await new Promise((ok, fail) => {
|
|
21
|
+
const f = new FileReader();
|
|
22
|
+
f.onload = () => ok(String(f.result));
|
|
23
|
+
f.onerror = () => fail(f.error);
|
|
24
|
+
f.readAsDataURL(blob);
|
|
25
|
+
});
|
|
26
|
+
const m = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/.exec(data);
|
|
27
|
+
return m ? { media_type: m[1], data: m[2] } : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function ImageCard({ step, image, mode, }) {
|
|
34
|
+
const [zoom, setZoom] = useState(false);
|
|
35
|
+
const [busy, setBusy] = useState(false);
|
|
36
|
+
const alt = image.alt || step.title;
|
|
37
|
+
const withImage = async (then) => {
|
|
38
|
+
setBusy(true);
|
|
39
|
+
const img = await asReference(image.url);
|
|
40
|
+
setBusy(false);
|
|
41
|
+
if (img)
|
|
42
|
+
then(img);
|
|
43
|
+
else
|
|
44
|
+
lumenToast.error("That image couldn't be read. Try again.");
|
|
45
|
+
};
|
|
46
|
+
return (_jsxs("div", { className: "flex w-full min-w-0 flex-col gap-1.5", "data-slot": "chat-made-image", children: [_jsxs("div", { className: "relative w-full max-w-sm overflow-hidden rounded-lg border bg-muted", children: [_jsx("img", { src: image.url, alt: alt, className: "block w-full" }), _jsxs("div", { className: "absolute top-2 right-2 flex gap-1 rounded-lg border bg-popover/90 p-0.5 text-popover-foreground shadow-sm backdrop-blur-sm", children: [_jsx(IconButton, { icon: Maximize2Icon, label: "Zoom", onClick: () => setZoom(true) }), _jsx(IconButton, { icon: ImagePlusIcon, label: "Use as reference", disabled: busy, onClick: () => void withImage((img) => composeAssistant({ images: [img] })) }), _jsx(IconButton, { icon: DownloadIcon, label: "Download", onClick: () => {
|
|
47
|
+
const a = document.createElement('a');
|
|
48
|
+
a.href = image.url;
|
|
49
|
+
a.download = 'image.png';
|
|
50
|
+
a.click();
|
|
51
|
+
} }), _jsx(IconButton, { icon: RefreshCwIcon, label: "Make another", disabled: busy, onClick: () => void withImage((img) => composeAssistant({
|
|
52
|
+
text: 'Make another version of this image.',
|
|
53
|
+
images: [img],
|
|
54
|
+
...(mode ? { use: mode } : {}),
|
|
55
|
+
send: true,
|
|
56
|
+
})) })] })] }), image.cost && _jsxs("span", { className: "text-muted-foreground text-xs", children: ["Image \u00B7 ", image.cost] }), _jsx(ImageLightbox, { src: image.url, alt: alt, open: zoom, onOpenChange: setZoom })] }));
|
|
57
|
+
}
|
|
58
|
+
/** The image tool's line while it runs. */
|
|
59
|
+
export function ImagePending() {
|
|
60
|
+
return (_jsx("div", { className: "flex w-full min-w-0 flex-col gap-1.5", children: _jsxs("output", { className: "flex aspect-[400/260] w-full max-w-sm animate-pulse flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/50 text-muted-foreground", children: [_jsx(ImageIcon, { className: "size-7", "aria-hidden": true }), _jsx("span", { className: "text-foreground text-sm", children: "Making the image" }), _jsx("span", { className: "text-xs", children: "Usually 10 to 20 s" })] }) }));
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type AssistantAdapter, assistantClient } from './client.js';
|
|
2
|
-
export {
|
|
2
|
+
export { ChatImage } from './chat-image.js';
|
|
3
|
+
export { type AssistantCommand, type AssistantMode, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
3
4
|
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
4
5
|
export { UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
|
|
5
6
|
export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
package/dist/assistant/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// routes of `@columenlabs/lumen-ai/assistant`; the shapes both share are
|
|
3
3
|
// `@columenlabs/lumen-ui-types/assistant`.
|
|
4
4
|
export { assistantClient } from './client.js';
|
|
5
|
-
export {
|
|
5
|
+
export { ChatImage } from './chat-image.js';
|
|
6
|
+
export { LumenAssistant, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
6
7
|
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
7
8
|
export { UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
|
|
8
9
|
export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantRefNote, type AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantQuote, type AssistantRefNote, type AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
import { type LucideIcon } from 'lucide-react';
|
|
2
3
|
import { type LumenOption } from '../lumen-selector.js';
|
|
3
4
|
import type { AssistantAdapter } from './client.js';
|
|
4
5
|
import { type AssistantSource } from './sources.js';
|
|
@@ -43,6 +44,25 @@ export interface LumenAssistantProps {
|
|
|
43
44
|
* until it is sent. Turn it on when the app's chat route passes `images` on to the agent.
|
|
44
45
|
*/
|
|
45
46
|
images?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Toggles beside the paperclip, one per mode ("Make an image"): one turned on goes with the next
|
|
49
|
+
* message as its `use`, and the app's plan reads it (call the tool that makes an image).
|
|
50
|
+
*/
|
|
51
|
+
modes?: AssistantMode[];
|
|
52
|
+
/**
|
|
53
|
+
* The app's tool that makes images: while it runs its line is a "Making the image" box, and its
|
|
54
|
+
* card's Make another sends again with `mode` on.
|
|
55
|
+
*/
|
|
56
|
+
imageTool?: {
|
|
57
|
+
tool: string;
|
|
58
|
+
mode?: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** A composer mode: its toggle's icon and label (also its tooltip and aria-label). */
|
|
62
|
+
export interface AssistantMode {
|
|
63
|
+
id: string;
|
|
64
|
+
icon: LucideIcon;
|
|
65
|
+
label: string;
|
|
46
66
|
}
|
|
47
67
|
/** A slash command: typing `/` in the composer lists them. */
|
|
48
68
|
export interface AssistantCommand {
|
|
@@ -58,7 +78,7 @@ export interface AssistantCommand {
|
|
|
58
78
|
run: (args: string) => string | undefined | Promise<string | undefined>;
|
|
59
79
|
}
|
|
60
80
|
/** The assistant's chat: header, messages and composer. */
|
|
61
|
-
export declare function LumenAssistant({ client, context, onSettings, keepDays, chatScope, onClose, chat: outside, commands, sources, images, }: LumenAssistantProps): import("react").JSX.Element;
|
|
81
|
+
export declare function LumenAssistant({ client, context, onSettings, keepDays, chatScope, onClose, chat: outside, commands, sources, images, modes, imageTool, }: LumenAssistantProps): import("react").JSX.Element;
|
|
62
82
|
/** An allowance in its unit: `$4.20`, or `350 credits`. */
|
|
63
83
|
export declare const allowanceAmount: (u: AllowanceUnit, n: number) => string;
|
|
64
84
|
/** What the composer sends with the text: the tier picked with `#` and the things pointed at with `@`. */
|
|
@@ -66,7 +86,14 @@ export interface ComposerExtra {
|
|
|
66
86
|
tier?: string;
|
|
67
87
|
refs?: AssistantRefNote[];
|
|
68
88
|
images?: AssistantImage[];
|
|
89
|
+
quote?: AssistantQuote;
|
|
90
|
+
use?: string;
|
|
69
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* A pasted, dropped or picked image, ready to send: scaled to `IMAGE_EDGE` on a canvas and saved as JPEG
|
|
94
|
+
* (PNG when it has few pixels to lose), as base64. `null` when the browser cannot read it.
|
|
95
|
+
*/
|
|
96
|
+
export declare function readAssistantImage(file: File): Promise<AssistantImage | null>;
|
|
70
97
|
/**
|
|
71
98
|
* The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. While it
|
|
72
99
|
* runs, words in the box go into it with Enter (Steer, where `canSteer`) or wait for it to end
|
|
@@ -75,7 +102,9 @@ export interface ComposerExtra {
|
|
|
75
102
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
|
|
76
103
|
* point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
|
|
77
104
|
*/
|
|
78
|
-
export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
|
|
105
|
+
export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, }: {
|
|
106
|
+
/** Toggles beside the paperclip (`LumenAssistant` `modes`). */
|
|
107
|
+
modes?: AssistantMode[];
|
|
79
108
|
name: string;
|
|
80
109
|
/** Images can be pasted or dropped on the box (`LumenAssistant` `images`). */
|
|
81
110
|
images?: boolean;
|
|
@@ -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, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, RefreshCwIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
|
|
9
|
+
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PaperclipIcon, PencilIcon, PlusIcon, ReceiptIcon, RefreshCwIcon, ReplyIcon, SearchIcon, 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';
|
|
@@ -28,6 +28,8 @@ import { Spinner } from '../ui/spinner.js';
|
|
|
28
28
|
import { Textarea } from '../ui/textarea.js';
|
|
29
29
|
import { ChatImage } from './chat-image.js';
|
|
30
30
|
import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
|
|
31
|
+
import { ImageCard, ImagePending, toolIs } from './image-card.js';
|
|
32
|
+
import { ReplyToSelection } from './reply-selection.js';
|
|
31
33
|
import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
32
34
|
import { UsageHistoryView, tokens, usd } from './usage-history.js';
|
|
33
35
|
import { useAssistantChat, } from './use-assistant-chat.js';
|
|
@@ -49,7 +51,7 @@ export function useAssistantStatus(client) {
|
|
|
49
51
|
export { usd };
|
|
50
52
|
const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
|
|
51
53
|
/** The assistant's chat: header, messages and composer. */
|
|
52
|
-
export function LumenAssistant({ client, context, onSettings, keepDays = 30, chatScope, onClose, chat: outside, commands = [], sources = [], images = false, }) {
|
|
54
|
+
export function LumenAssistant({ client, context, onSettings, keepDays = 30, chatScope, onClose, chat: outside, commands = [], sources = [], images = false, modes = [], imageTool, }) {
|
|
53
55
|
const own = useAssistantChat(client);
|
|
54
56
|
const chat = outside ?? own;
|
|
55
57
|
const qc = useQueryClient();
|
|
@@ -190,10 +192,11 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
|
|
|
190
192
|
title: c.title,
|
|
191
193
|
updated_at: new Date(c.at).toISOString(),
|
|
192
194
|
...(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, onRetry: (at, user) => chat.retry(at, user, extra()) }) }) }), _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 })] }));
|
|
195
|
+
})) ?? (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(ImageTool.Provider, { value: imageTool ?? null, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()) }) }) }) }), _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, modes: modes, 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
196
|
}
|
|
195
197
|
function Messages({ chat, status, onSettings, onRetry, }) {
|
|
196
198
|
const end = useRef(null);
|
|
199
|
+
const box = useRef(null);
|
|
197
200
|
const count = JSON.stringify(chat.items).length;
|
|
198
201
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll to the end as the chat grows
|
|
199
202
|
useEffect(() => {
|
|
@@ -202,7 +205,7 @@ function Messages({ chat, status, onSettings, onRetry, }) {
|
|
|
202
205
|
// The app's own link to Settings, else the shell's, at the section that answers the wait.
|
|
203
206
|
const shellSettings = useOpenSettings();
|
|
204
207
|
const settings = onSettings ? (_section) => onSettings() : shellSettings;
|
|
205
|
-
return (_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [status.wait === 'app_cap' && status.allowance
|
|
208
|
+
return (_jsxs("div", { ref: box, className: "relative min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [_jsx(ReplyToSelection, { box: box }), status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [status.wait === 'app_cap' && status.allowance
|
|
206
209
|
? allowanceWait(status.allowance)
|
|
207
210
|
: WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), settings && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => settings(status.wait === 'app_cap' && status.allowance
|
|
208
211
|
? 'assistant-allowance'
|
|
@@ -236,6 +239,8 @@ const AssistantName = createContext('Assistant');
|
|
|
236
239
|
/** A tier's name for a message's `#` chip. */
|
|
237
240
|
const TierNames = createContext((id) => id);
|
|
238
241
|
/** Takes back a change made at once (Undo on its line). */
|
|
242
|
+
/** The app's image tool (`LumenAssistant` `imageTool`). */
|
|
243
|
+
const ImageTool = createContext(null);
|
|
239
244
|
const UndoWrite = createContext(null);
|
|
240
245
|
/** Sends a failed turn again; null while it can't (an answer runs, nobody can pay). */
|
|
241
246
|
const RetryTurn = createContext(null);
|
|
@@ -281,13 +286,13 @@ function Item({ item, onSettle, busy, }) {
|
|
|
281
286
|
case 'user': {
|
|
282
287
|
if (item.steered)
|
|
283
288
|
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 })] }));
|
|
284
|
-
const bubble = (_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] }))] }));
|
|
289
|
+
const bubble = (_jsxs("div", { "data-chat-from": "user", 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.quote && _jsx(QuoteLine, { quote: item.quote }), (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] }))] }));
|
|
285
290
|
if (!item.retried)
|
|
286
291
|
return bubble;
|
|
287
292
|
return (_jsxs("div", { className: "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(RefreshCwIcon, { className: "size-3" }), "Retry"] }), bubble] }));
|
|
288
293
|
}
|
|
289
294
|
case 'text':
|
|
290
|
-
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" }))] }));
|
|
295
|
+
return (_jsxs("div", { "data-chat-from": "assistant", 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" }))] }));
|
|
291
296
|
case 'step':
|
|
292
297
|
return _jsx(Step, { step: item, live: !!item.live });
|
|
293
298
|
case 'draft':
|
|
@@ -398,6 +403,11 @@ function WebSources({ items }) {
|
|
|
398
403
|
return (_jsx("ol", { "aria-label": "Sources", className: "flex min-w-0 flex-wrap gap-1.5", children: pages.map((p, n) => (_jsx("li", { className: "min-w-0 max-w-full", children: _jsxs("a", { href: p.url, target: "_blank", rel: "noopener noreferrer", title: p.title ? `${p.title} · ${p.url}` : p.url, className: "flex h-6 min-w-0 items-center gap-1.5 rounded-full border pr-2.5 pl-1 text-xs hover:bg-muted", children: [_jsx("span", { className: "grid size-4 shrink-0 place-items-center rounded-full bg-primary/15 font-medium text-primary text-xs tabular-nums", children: n + 1 }), _jsx("span", { className: "truncate", children: hostOf(p.url) })] }) }, p.url))) }));
|
|
399
404
|
}
|
|
400
405
|
function Step({ step, live }) {
|
|
406
|
+
const images = useContext(ImageTool);
|
|
407
|
+
if (step.image)
|
|
408
|
+
return _jsx(ImageCard, { step: step, image: step.image, mode: images?.mode });
|
|
409
|
+
if (live && images && toolIs(step.tool, images.tool))
|
|
410
|
+
return _jsx(ImagePending, {});
|
|
401
411
|
if (step.web)
|
|
402
412
|
return _jsx(WebStep, { step: step, live: live });
|
|
403
413
|
return _jsx(ToolStep, { step: step, live: live });
|
|
@@ -512,13 +522,20 @@ const TIER = '#';
|
|
|
512
522
|
function ComposerChip({ sign, label, onRemove, }) {
|
|
513
523
|
return (_jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: sign }), _jsx("span", { className: "truncate", children: label }), onRemove && (_jsx("button", { type: "button", "aria-label": `Remove ${label}`, className: "text-muted-foreground hover:text-foreground", onClick: onRemove, children: _jsx(XIcon, { className: "size-3" }) }))] }));
|
|
514
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* A quote a message replies to: the reply icon and the quoted words. Over the box it is one line
|
|
527
|
+
* with an × (`onRemove`); in a sent message up to two lines. The whole text is in its hover.
|
|
528
|
+
*/
|
|
529
|
+
function QuoteLine({ quote, onRemove }) {
|
|
530
|
+
return (_jsxs("span", { "data-slot": "chat-quote", className: cn('flex min-w-0 items-start gap-1.5 rounded-md bg-background/60 px-2 py-1 text-muted-foreground text-xs whitespace-normal', onRemove ? 'mb-2 items-center bg-muted py-0.5 pr-0.5' : 'mb-1.5'), title: quote.text, children: [_jsx(ReplyIcon, { className: cn('size-3.5 shrink-0', !onRemove && 'mt-0.5'), "aria-hidden": true }), _jsx("span", { className: cn('min-w-0 flex-1', onRemove ? 'truncate' : 'line-clamp-2 break-words'), children: quote.text }), onRemove && (_jsx(IconButton, { icon: XIcon, label: "Remove quote", size: "icon-xs", onClick: onRemove }))] }));
|
|
531
|
+
}
|
|
515
532
|
/** The longest edge an image is sent at (what the models read best; larger only costs more). */
|
|
516
533
|
const IMAGE_EDGE = 1568;
|
|
517
534
|
/**
|
|
518
|
-
* A pasted or
|
|
535
|
+
* A pasted, dropped or picked image, ready to send: scaled to `IMAGE_EDGE` on a canvas and saved as JPEG
|
|
519
536
|
* (PNG when it has few pixels to lose), as base64. `null` when the browser cannot read it.
|
|
520
537
|
*/
|
|
521
|
-
async function
|
|
538
|
+
export async function readAssistantImage(file) {
|
|
522
539
|
try {
|
|
523
540
|
const bmp = await createImageBitmap(file);
|
|
524
541
|
const k = Math.min(1, IMAGE_EDGE / Math.max(bmp.width, bmp.height));
|
|
@@ -551,15 +568,18 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
|
|
|
551
568
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
|
|
552
569
|
* point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
|
|
553
570
|
*/
|
|
554
|
-
export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
|
|
571
|
+
export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, }) {
|
|
555
572
|
const [text, setText] = useState('');
|
|
556
573
|
const [caret, setCaret] = useState(0);
|
|
557
574
|
const [closed, setClosed] = useState(null);
|
|
558
575
|
const [tier, setTier] = useState(null);
|
|
559
576
|
const [refs, setRefs] = useState([]);
|
|
560
577
|
const [images, setImages] = useState([]);
|
|
578
|
+
const [quote, setQuote] = useState(null);
|
|
579
|
+
const [use, setUse] = useState(null);
|
|
561
580
|
const list = useRef(null);
|
|
562
581
|
const box = useRef(null);
|
|
582
|
+
const filePick = useRef(null);
|
|
563
583
|
const selection = usePageSelection();
|
|
564
584
|
// An app's compose request (`composeAssistant`): its chips and text, ready to finish.
|
|
565
585
|
const [request, take] = useComposeRequest();
|
|
@@ -568,10 +588,24 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
568
588
|
if (!request)
|
|
569
589
|
return;
|
|
570
590
|
take();
|
|
591
|
+
// Make another: sent at once, the box left as it is.
|
|
592
|
+
if (request.send && request.text) {
|
|
593
|
+
onSend(request.text, {
|
|
594
|
+
...(request.images?.length ? { images: request.images } : {}),
|
|
595
|
+
...(request.use ? { use: request.use } : {}),
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (request.images?.length)
|
|
600
|
+
setImages((p) => [...p, ...(request.images ?? [])].slice(0, ASSISTANT_IMAGES_MAX));
|
|
601
|
+
if (request.use)
|
|
602
|
+
setUse(request.use);
|
|
571
603
|
if (request.refs?.length)
|
|
572
604
|
setRefs((p) => [...p, ...(request.refs ?? [])]);
|
|
573
605
|
if (request.tier)
|
|
574
606
|
setTier(request.tier);
|
|
607
|
+
if (request.quote)
|
|
608
|
+
setQuote(request.quote);
|
|
575
609
|
if (request.text !== undefined) {
|
|
576
610
|
setText(request.text);
|
|
577
611
|
setCaret(request.text.length);
|
|
@@ -592,6 +626,8 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
592
626
|
...(tier ? { tier } : {}),
|
|
593
627
|
...(refs.length ? { refs } : {}),
|
|
594
628
|
...(images.length ? { images } : {}),
|
|
629
|
+
...(quote ? { quote } : {}),
|
|
630
|
+
...(use ? { use } : {}),
|
|
595
631
|
});
|
|
596
632
|
const clear = () => {
|
|
597
633
|
setText('');
|
|
@@ -599,6 +635,8 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
599
635
|
setTier(null);
|
|
600
636
|
setRefs([]);
|
|
601
637
|
setImages([]);
|
|
638
|
+
setQuote(null);
|
|
639
|
+
setUse(null);
|
|
602
640
|
};
|
|
603
641
|
/** Adds the image files among `files`; says so when one is left out. Returns whether any was an image. */
|
|
604
642
|
const attach = (files) => {
|
|
@@ -606,7 +644,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
606
644
|
if (!canAttach || !picked.length)
|
|
607
645
|
return false;
|
|
608
646
|
void (async () => {
|
|
609
|
-
const read = (await Promise.all(picked.map(
|
|
647
|
+
const read = (await Promise.all(picked.map(readAssistantImage))).filter((x) => !!x);
|
|
610
648
|
if (read.length < picked.length)
|
|
611
649
|
lumenToast.error("That image couldn't be read. Try a PNG or JPEG.");
|
|
612
650
|
setImages((p) => {
|
|
@@ -662,6 +700,10 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
662
700
|
setRefs(q.extra.refs);
|
|
663
701
|
if (q.extra?.images?.length)
|
|
664
702
|
setImages(q.extra.images);
|
|
703
|
+
if (q.extra?.quote)
|
|
704
|
+
setQuote(q.extra.quote);
|
|
705
|
+
if (q.extra?.use)
|
|
706
|
+
setUse(q.extra.use);
|
|
665
707
|
requestAnimationFrame(() => box.current?.focus());
|
|
666
708
|
};
|
|
667
709
|
const waiting = queue ?? (queued ? [{ id: 'queued', message: queued }] : []);
|
|
@@ -825,7 +867,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
825
867
|
const r = sourceItems.get(o.value)?.();
|
|
826
868
|
if (r)
|
|
827
869
|
setRefs((p) => [...p, r]);
|
|
828
|
-
}, 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) => {
|
|
870
|
+
}, onClose: () => setClosed(`${text}|${caret}`) })), quote && _jsx(QuoteLine, { quote: quote, onRemove: () => setQuote(null) }), 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', (canAttach || modes.length > 0) && 'pb-9'), value: text, disabled: disabled, onChange: (e) => {
|
|
829
871
|
setText(e.target.value);
|
|
830
872
|
setCaret(e.target.selectionStart);
|
|
831
873
|
}, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onPaste: (e) => {
|
|
@@ -845,7 +887,11 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
845
887
|
e.preventDefault();
|
|
846
888
|
send(e.altKey ? 'queue' : undefined);
|
|
847
889
|
}
|
|
848
|
-
} }),
|
|
890
|
+
} }), (canAttach || modes.length > 0) && (_jsxs("div", { className: "absolute bottom-1.5 left-1.5 flex items-center gap-0.5", children: [canAttach && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: PaperclipIcon, label: "Attach images", size: "icon-xs", disabled: disabled, onClick: () => filePick.current?.click() }), _jsx("input", { ref: filePick, type: "file", accept: "image/*", multiple: true, hidden: true, onChange: (e) => {
|
|
891
|
+
if (e.target.files)
|
|
892
|
+
attach(e.target.files);
|
|
893
|
+
e.target.value = '';
|
|
894
|
+
} })] })), modes.map((md) => (_jsx(IconButton, { icon: md.icon, label: md.label, size: "icon-xs", variant: use === md.id ? 'secondary' : 'ghost', "aria-pressed": use === md.id, className: cn(use === md.id && 'text-primary'), disabled: disabled, onClick: () => setUse((u) => (u === md.id ? null : md.id)) }, md.id)))] })), _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() })) })] })] }));
|
|
849
895
|
}
|
|
850
896
|
/**
|
|
851
897
|
* What waits above the box while an answer runs: steering messages until the answer reads them,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The Reply bar over selected chat text. `box` is the chat's scrolling box, positioned; the bar
|
|
4
|
+
* sits in it, above the selection (below it when the top is out of view).
|
|
5
|
+
*/
|
|
6
|
+
export declare function ReplyToSelection({ box }: {
|
|
7
|
+
box: RefObject<HTMLElement | null>;
|
|
8
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Reply to a piece of the chat: text selected inside one message (the person's or the assistant's)
|
|
3
|
+
// gets a small bar above it, Reply and Copy. Reply puts the selection over the composer as a quote
|
|
4
|
+
// (`composeAssistant`), which goes with the next message.
|
|
5
|
+
import { ASSISTANT_QUOTE_MAX } from '@columenlabs/lumen-ui-types/assistant';
|
|
6
|
+
import { CopyIcon, ReplyIcon } from 'lucide-react';
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { IconButton } from '../icon-button.js';
|
|
9
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
10
|
+
import { composeAssistant } from './sources.js';
|
|
11
|
+
/** The bar's size, to keep it inside the chat's box. */
|
|
12
|
+
const BAR_W = 72;
|
|
13
|
+
const BAR_H = 40;
|
|
14
|
+
/** The message (`data-chat-from`) a node sits in, when it is inside `box`. */
|
|
15
|
+
function messageOf(node, box) {
|
|
16
|
+
const el = node instanceof Element ? node : node?.parentElement;
|
|
17
|
+
const m = el?.closest('[data-chat-from]') ?? null;
|
|
18
|
+
return m && box.contains(m) ? m : null;
|
|
19
|
+
}
|
|
20
|
+
/** The selection as a quote and where its bar goes in `box`, or null when it is not one message's text. */
|
|
21
|
+
function pickedIn(box) {
|
|
22
|
+
const sel = document.getSelection();
|
|
23
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount)
|
|
24
|
+
return null;
|
|
25
|
+
const from = messageOf(sel.anchorNode, box);
|
|
26
|
+
if (!from || from !== messageOf(sel.focusNode, box))
|
|
27
|
+
return null;
|
|
28
|
+
const text = sel.toString().trim().slice(0, ASSISTANT_QUOTE_MAX);
|
|
29
|
+
if (!text)
|
|
30
|
+
return null;
|
|
31
|
+
const r = sel.getRangeAt(0).getBoundingClientRect();
|
|
32
|
+
const b = box.getBoundingClientRect();
|
|
33
|
+
const above = r.top - b.top - BAR_H - 4;
|
|
34
|
+
const top = (above >= 0 ? above : r.bottom - b.top + 4) + box.scrollTop;
|
|
35
|
+
const mid = r.left - b.left + r.width / 2 - BAR_W / 2;
|
|
36
|
+
const left = Math.max(4, Math.min(mid, b.width - BAR_W - 4));
|
|
37
|
+
return {
|
|
38
|
+
quote: { text, from: from.dataset.chatFrom === 'user' ? 'user' : 'assistant' },
|
|
39
|
+
top,
|
|
40
|
+
left,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The Reply bar over selected chat text. `box` is the chat's scrolling box, positioned; the bar
|
|
45
|
+
* sits in it, above the selection (below it when the top is out of view).
|
|
46
|
+
*/
|
|
47
|
+
export function ReplyToSelection({ box }) {
|
|
48
|
+
const [picked, setPicked] = useState(null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let frame = 0;
|
|
51
|
+
const read = () => {
|
|
52
|
+
cancelAnimationFrame(frame);
|
|
53
|
+
frame = requestAnimationFrame(() => {
|
|
54
|
+
const el = box.current;
|
|
55
|
+
setPicked(el ? pickedIn(el) : null);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('selectionchange', read);
|
|
59
|
+
return () => {
|
|
60
|
+
cancelAnimationFrame(frame);
|
|
61
|
+
document.removeEventListener('selectionchange', read);
|
|
62
|
+
};
|
|
63
|
+
}, [box]);
|
|
64
|
+
if (!picked)
|
|
65
|
+
return null;
|
|
66
|
+
const done = () => {
|
|
67
|
+
document.getSelection()?.removeAllRanges();
|
|
68
|
+
setPicked(null);
|
|
69
|
+
};
|
|
70
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": "Selected text", className: "absolute z-10 flex gap-0.5 rounded-lg border bg-popover p-0.5 shadow-md", style: { top: picked.top, left: picked.left },
|
|
71
|
+
// A press on the bar keeps the selection.
|
|
72
|
+
onMouseDown: (e) => e.preventDefault(), children: [_jsx(IconButton, { icon: ReplyIcon, label: "Reply", onClick: () => {
|
|
73
|
+
composeAssistant({ quote: picked.quote });
|
|
74
|
+
done();
|
|
75
|
+
} }), _jsx(IconButton, { icon: CopyIcon, label: "Copy", onClick: () => {
|
|
76
|
+
void navigator.clipboard.writeText(picked.quote.text).then(() => lumenToast.success('Copied'), () => lumenToast.error("Couldn't copy. Copy it with your keyboard instead."));
|
|
77
|
+
done();
|
|
78
|
+
} })] }));
|
|
79
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssistantRefNote } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AssistantImage, AssistantQuote, AssistantRefNote } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
import { type ComponentType } from 'react';
|
|
3
3
|
/** One thing a source holds: a row in the `@` list, sent with the message as `ref`. */
|
|
4
4
|
export interface AssistantSourceItem {
|
|
@@ -35,6 +35,14 @@ export interface ComposeRequest {
|
|
|
35
35
|
text?: string;
|
|
36
36
|
refs?: AssistantRefNote[];
|
|
37
37
|
tier?: string;
|
|
38
|
+
/** A piece of the chat to reply to: it shows above the box with an ×. */
|
|
39
|
+
quote?: AssistantQuote;
|
|
40
|
+
/** Images to send with the message (an image card's Use as reference). */
|
|
41
|
+
images?: AssistantImage[];
|
|
42
|
+
/** A composer mode to turn on (`LumenAssistant` `modes`), by id. */
|
|
43
|
+
use?: string;
|
|
44
|
+
/** Send at once with `text`, leaving the box as it is (an image card's Make another). */
|
|
45
|
+
send?: boolean;
|
|
38
46
|
}
|
|
39
47
|
/**
|
|
40
48
|
* Opens the assistant with `refs` as chips in its composer and `text` typed, focused for the person
|
|
@@ -192,6 +192,7 @@ export function useAssistantChat(client) {
|
|
|
192
192
|
at: Date.now(),
|
|
193
193
|
...(extra?.context ? { context: extra.context.label } : {}),
|
|
194
194
|
...(extra?.refs?.length ? { refs: extra.refs } : {}),
|
|
195
|
+
...(extra?.quote ? { quote: extra.quote } : {}),
|
|
195
196
|
...(extra?.tier ? { tier: extra.tier } : {}),
|
|
196
197
|
...(retried ? { retried: true } : {}),
|
|
197
198
|
...(extra?.images?.length
|
|
@@ -311,6 +312,7 @@ export function useAssistantChat(client) {
|
|
|
311
312
|
void run(user.text, {
|
|
312
313
|
...extra,
|
|
313
314
|
...(user.refs?.length ? { refs: user.refs } : {}),
|
|
315
|
+
...(user.quote ? { quote: user.quote } : {}),
|
|
314
316
|
...(user.tier ? { tier: user.tier } : {}),
|
|
315
317
|
...(images.length ? { images } : {}),
|
|
316
318
|
}, true);
|
package/dist/whats-new.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
export declare function WhatsNew({ api, apps, everyone, }: {
|
|
2
|
+
export declare function WhatsNew({ api, apps, everyone, label, }: {
|
|
3
3
|
api: string;
|
|
4
4
|
/** Each app's chip label by id, in the order groups list them; left out: no app chips. */
|
|
5
5
|
apps?: Record<string, ReactNode>;
|
|
6
6
|
/** The chip of a takeaway about no one app. */
|
|
7
7
|
everyone?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Status-bar text that opens the notes (the app's version): no spark and no "What's new" words;
|
|
10
|
+
* the tooltip and aria-label still say What's new. Left out: the spark and those words.
|
|
11
|
+
*/
|
|
12
|
+
label?: ReactNode;
|
|
8
13
|
}): import("react").JSX.Element | null;
|
package/dist/whats-new.js
CHANGED
|
@@ -42,7 +42,7 @@ async function page(api, before) {
|
|
|
42
42
|
throw new Error(`releases ${r.status}`);
|
|
43
43
|
return (await r.json()).releases;
|
|
44
44
|
}
|
|
45
|
-
export function WhatsNew({ api, apps, everyone = 'Global', }) {
|
|
45
|
+
export function WhatsNew({ api, apps, everyone = 'Global', label = null, }) {
|
|
46
46
|
const [open, setOpen] = useState(false);
|
|
47
47
|
const { timeZone } = usePrefs();
|
|
48
48
|
const q = useInfiniteQuery({
|
|
@@ -53,13 +53,15 @@ export function WhatsNew({ api, apps, everyone = 'Global', }) {
|
|
|
53
53
|
staleTime: 30 * 60_000,
|
|
54
54
|
retry: false,
|
|
55
55
|
});
|
|
56
|
-
// Hidden while loading, on an error, and when the host has no releases API (404).
|
|
57
|
-
|
|
56
|
+
// Hidden while loading, on an error, and when the host has no releases API (404). A label (the
|
|
57
|
+
// app's version) stays on the bar through that and opens the notes once they load.
|
|
58
|
+
const ready = !!q.data && q.data.pages[0] !== null;
|
|
59
|
+
if (!ready && label == null)
|
|
58
60
|
return null;
|
|
59
|
-
const releases = q.data
|
|
61
|
+
const releases = ready ? (q.data?.pages.flatMap((p) => p ?? []) ?? []) : [];
|
|
60
62
|
const latest = releases[0];
|
|
61
63
|
const day = (at) => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone });
|
|
62
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: latest ? `What's new · ${latest.tag}` : "What's new", "aria-label": "What's new", children: [_jsx(SparklesIcon, { className: "size-3.5" }), _jsx("span", { className: "max-md:hidden", children: "What's new" })] }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
|
|
64
|
+
return (_jsxs(_Fragment, { children: [label != null ? (_jsx("button", { type: "button", className: "-mx-1 flex-none rounded px-1 tabular-nums hover:bg-muted hover:text-foreground", onClick: () => ready && setOpen(true), title: "What's new", "aria-label": "What's new", children: label })) : (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: latest ? `What's new · ${latest.tag}` : "What's new", "aria-label": "What's new", children: [_jsx(SparklesIcon, { className: "size-3.5" }), _jsx("span", { className: "max-md:hidden", children: "What's new" })] })), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
|
|
63
65
|
}
|
|
64
66
|
/** One release's takeaways: a flat list, or one sub-section per app once one app has many. */
|
|
65
67
|
function Notes({ takeaways, apps, everyone, }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.227",
|
|
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.227",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|