@columenlabs/lumen-ui 0.1.78 → 0.1.80
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/client.d.ts +3 -1
- package/dist/assistant/client.js +1 -0
- package/dist/assistant/index.d.ts +3 -2
- package/dist/assistant/index.js +2 -1
- package/dist/assistant/lumen-assistant.d.ts +45 -5
- package/dist/assistant/lumen-assistant.js +331 -22
- package/dist/assistant/settings.d.ts +7 -0
- package/dist/assistant/settings.js +38 -19
- package/dist/assistant/sources.d.ts +54 -0
- package/dist/assistant/sources.js +114 -0
- package/dist/assistant/use-assistant-chat.d.ts +3 -1
- package/dist/assistant/use-assistant-chat.js +17 -7
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
type Test = NonNullable<KeyState['test']>;
|
|
3
3
|
export interface AssistantAdapter {
|
|
4
4
|
/** Names the cache its reads keep (two assistants on one page need two keys). */
|
|
@@ -34,6 +34,8 @@ export interface AssistantAdapter {
|
|
|
34
34
|
/** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
|
|
35
35
|
send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
|
|
36
36
|
settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
|
|
37
|
+
/** Takes back a change the assistant made at once. */
|
|
38
|
+
undoWrite(chatId: string, stepId: string): Promise<StepItem>;
|
|
37
39
|
}
|
|
38
40
|
/** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
|
|
39
41
|
export declare function assistantClient(base: string): AssistantAdapter;
|
package/dist/assistant/client.js
CHANGED
|
@@ -62,6 +62,7 @@ export function assistantClient(base) {
|
|
|
62
62
|
settleDraft: (chatId, draftId, action) => send('POST', `/chats/${encodeURIComponent(chatId)}/drafts/${encodeURIComponent(draftId)}`, {
|
|
63
63
|
action,
|
|
64
64
|
}),
|
|
65
|
+
undoWrite: (chatId, stepId) => send('POST', `/chats/${encodeURIComponent(chatId)}/writes/${encodeURIComponent(stepId)}/undo`),
|
|
65
66
|
async send(msg, onEvent, signal) {
|
|
66
67
|
const url = `${root}/chat`;
|
|
67
68
|
const res = await fetch(url, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type AssistantAdapter, assistantClient } from './client.js';
|
|
2
|
-
export { LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
3
|
-
export { assistantSettingsSections } from './settings.js';
|
|
2
|
+
export { type AssistantCommand, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
3
|
+
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
4
|
+
export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
4
5
|
export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
|
package/dist/assistant/index.js
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
// `@columenlabs/lumen-ui-types/assistant`.
|
|
4
4
|
export { assistantClient } from './client.js';
|
|
5
5
|
export { LumenAssistant, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
6
|
-
export { assistantSettingsSections } from './settings.js';
|
|
6
|
+
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
7
|
+
export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
7
8
|
export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { AssistantMessage, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AssistantMessage, AssistantRefNote, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
import { type LumenOption } from '../lumen-selector.js';
|
|
2
3
|
import type { AssistantAdapter } from './client.js';
|
|
4
|
+
import { type AssistantSource } from './sources.js';
|
|
3
5
|
import { type AssistantChatState } from './use-assistant-chat.js';
|
|
4
6
|
export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
|
|
5
7
|
/** The person's assistant status, shared by the panel and Settings. */
|
|
@@ -17,16 +19,54 @@ export interface LumenAssistantProps {
|
|
|
17
19
|
onClose?: () => void;
|
|
18
20
|
/** An outside chat state (the dock shares one between the button and the panel). */
|
|
19
21
|
chat?: AssistantChatState;
|
|
22
|
+
/**
|
|
23
|
+
* The app's own slash commands, listed before the server's (`createAssistant` `commands`) and the
|
|
24
|
+
* kit's: /compact, /clear, /model and /escalate.
|
|
25
|
+
*/
|
|
26
|
+
commands?: AssistantCommand[];
|
|
27
|
+
/** What `#` lists everywhere; a page adds its own with `useAssistantSource`. */
|
|
28
|
+
sources?: readonly AssistantSource[];
|
|
29
|
+
}
|
|
30
|
+
/** A slash command: typing `/` in the composer lists them. */
|
|
31
|
+
export interface AssistantCommand {
|
|
32
|
+
/** Typed after the slash: `story`. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** One line in the list. */
|
|
35
|
+
about: string;
|
|
36
|
+
/** What follows the name, shown in the list (`<words>`). Without it, picking it runs it. */
|
|
37
|
+
args?: string;
|
|
38
|
+
/** Choices for the words after it, listed as they are typed (a tier, a saved search). */
|
|
39
|
+
options?: () => LumenOption[];
|
|
40
|
+
/** Runs it: a string is sent as the message; nothing when the command acted itself. */
|
|
41
|
+
run: (args: string) => string | undefined | Promise<string | undefined>;
|
|
20
42
|
}
|
|
21
43
|
/** The assistant's chat: header, messages and composer. */
|
|
22
|
-
export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, }: LumenAssistantProps): import("react").JSX.Element;
|
|
23
|
-
/**
|
|
24
|
-
export
|
|
44
|
+
export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, commands, sources, }: LumenAssistantProps): import("react").JSX.Element;
|
|
45
|
+
/** What the composer sends with the text: the tier picked with `@` and the things pointed at with `#`. */
|
|
46
|
+
export interface ComposerExtra {
|
|
47
|
+
tier?: string;
|
|
48
|
+
refs?: AssistantRefNote[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
|
|
52
|
+
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
53
|
+
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
54
|
+
*/
|
|
55
|
+
export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, onSend, onStop, }: {
|
|
25
56
|
name: string;
|
|
26
57
|
busy: boolean;
|
|
27
58
|
queued: string | null;
|
|
28
59
|
disabled?: boolean;
|
|
29
|
-
|
|
60
|
+
commands?: AssistantCommand[];
|
|
61
|
+
/** What `@` lists. */
|
|
62
|
+
tiers?: {
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
model_label?: string;
|
|
66
|
+
}[];
|
|
67
|
+
/** What `#` lists, a group each. */
|
|
68
|
+
sources?: readonly AssistantSource[];
|
|
69
|
+
onSend: (text: string, extra: ComposerExtra) => void;
|
|
30
70
|
onStop: () => void;
|
|
31
71
|
}): import("react").JSX.Element;
|
|
32
72
|
/**
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { ArrowUpIcon, CheckIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PlusIcon, SparklesIcon, SquareIcon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
4
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
4
|
+
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
5
5
|
import Markdown from 'react-markdown';
|
|
6
6
|
import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
|
|
7
7
|
import { IconButton } from '../icon-button.js';
|
|
8
8
|
import { errorText } from '../integrations.js';
|
|
9
9
|
import { cn } from '../lib/utils.js';
|
|
10
|
-
import { LumenSelector } from '../lumen-selector.js';
|
|
10
|
+
import { LumenSelector, LumenSelectorList, } from '../lumen-selector.js';
|
|
11
11
|
import { lumenToast } from '../lumen-toast.js';
|
|
12
12
|
import { Alert, AlertDescription } from '../ui/alert.js';
|
|
13
13
|
import { Button } from '../ui/button.js';
|
|
14
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
|
|
14
15
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
|
|
15
16
|
import { Spinner } from '../ui/spinner.js';
|
|
16
17
|
import { Textarea } from '../ui/textarea.js';
|
|
18
|
+
import { selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
17
19
|
import { useAssistantChat } from './use-assistant-chat.js';
|
|
18
20
|
/** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
|
|
19
21
|
const CONTEXT_MAX = 200_000;
|
|
@@ -35,7 +37,7 @@ const tokens = (n) => n < 1000
|
|
|
35
37
|
: `${(n / 1e6).toFixed(1)}M`;
|
|
36
38
|
const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
|
|
37
39
|
/** The assistant's chat: header, messages and composer. */
|
|
38
|
-
export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, }) {
|
|
40
|
+
export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, commands = [], sources = [], }) {
|
|
39
41
|
const own = useAssistantChat(client);
|
|
40
42
|
const chat = outside ?? own;
|
|
41
43
|
const qc = useQueryClient();
|
|
@@ -60,11 +62,71 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
|
|
|
60
62
|
lumenToast.error(errorText(e));
|
|
61
63
|
}
|
|
62
64
|
};
|
|
63
|
-
|
|
65
|
+
const tiers = s?.tiers ?? [];
|
|
66
|
+
const above = tiers.slice(tiers.findIndex((t) => t.id === s?.tier) + 1);
|
|
67
|
+
// The kit's commands; the app's own (`commands`, then the server's) come first and win a name.
|
|
68
|
+
const builtIns = [
|
|
69
|
+
{
|
|
70
|
+
name: 'compact',
|
|
71
|
+
about: 'Summarise the chat so far to free up context',
|
|
72
|
+
run: () => '/compact',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'clear',
|
|
76
|
+
about: 'Start a new chat',
|
|
77
|
+
run: () => {
|
|
78
|
+
chat.reset();
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
...(tiers.length > 1
|
|
83
|
+
? [
|
|
84
|
+
{
|
|
85
|
+
name: 'model',
|
|
86
|
+
about: 'Answer on another tier from now on',
|
|
87
|
+
args: '<tier>',
|
|
88
|
+
options: () => tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })),
|
|
89
|
+
run: (arg) => {
|
|
90
|
+
const t = tiers.find((x) => x.id === arg || x.label.toLowerCase() === arg.toLowerCase());
|
|
91
|
+
if (t)
|
|
92
|
+
void setTier(t.id);
|
|
93
|
+
else
|
|
94
|
+
lumenToast.error(`No tier is called ${arg || 'that'}.`);
|
|
95
|
+
return undefined;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
: []),
|
|
100
|
+
...(above[0]
|
|
101
|
+
? [
|
|
102
|
+
{
|
|
103
|
+
name: 'escalate',
|
|
104
|
+
about: `Hand a task to ${above[0].label}`,
|
|
105
|
+
args: '<task>',
|
|
106
|
+
run: (task) => {
|
|
107
|
+
if (task.trim())
|
|
108
|
+
chat.send(task, { ...(context ? { context } : {}), tier: above[0]?.id });
|
|
109
|
+
return undefined;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
: []),
|
|
114
|
+
];
|
|
115
|
+
const served = (s?.commands ?? []).map((c) => ({
|
|
116
|
+
...c,
|
|
117
|
+
run: (args) => `/${c.name}${args ? ` ${args}` : ''}`,
|
|
118
|
+
}));
|
|
119
|
+
const allCommands = [];
|
|
120
|
+
for (const c of [...commands, ...served, ...builtIns])
|
|
121
|
+
if (!allCommands.some((x) => x.name === c.name))
|
|
122
|
+
allCommands.push(c);
|
|
123
|
+
const shown = useShownAssistantSources();
|
|
124
|
+
const allSources = [...shown, ...sources.filter((x) => !shown.some((y) => y.id === x.id))];
|
|
125
|
+
return (_jsxs("div", { "data-assistant": "", className: "flex h-full min-h-0 flex-col", children: [_jsxs("div", { className: "flex h-12 shrink-0 items-center gap-1 border-b px-3", children: [_jsx(SparklesIcon, { className: "size-4 text-edge" }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: chat.title || s?.name || 'Assistant' }), s && s.tiers.length > 1 && (_jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Tier", value: s.tier, options: s.tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })), onChange: (t) => void setTier(t) })), _jsx(LumenAssistantChats, { chats: chats.data?.map((c) => ({
|
|
64
126
|
id: c.id,
|
|
65
127
|
title: c.title,
|
|
66
128
|
updated_at: new Date(c.at).toISOString(),
|
|
67
|
-
})) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _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(Messages, { chat: chat, status: s, onSettings: onSettings }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, disabled: !s || !!s.wait, onSend: (text) => chat.send(text, context ? { context } :
|
|
129
|
+
})) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _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(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, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
|
|
68
130
|
}
|
|
69
131
|
function Messages({ chat, status, onSettings, }) {
|
|
70
132
|
const end = useRef(null);
|
|
@@ -73,7 +135,7 @@ function Messages({ chat, status, onSettings, }) {
|
|
|
73
135
|
useEffect(() => {
|
|
74
136
|
end.current?.scrollIntoView({ block: 'end' });
|
|
75
137
|
}, [count]);
|
|
76
|
-
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: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "Open Settings" }))] })] })), chat.loading ? (_jsxs("p", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Loading the chat\u2026"] })) : chat.items.length === 0 && !status?.wait ? (_jsxs("p", { className: "pt-8 text-center text-muted-foreground text-sm", children: ["Ask ", status?.name ?? 'the assistant', " anything."] })) : (_jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy })), chat.busy && !chat.items.some(isLive) && (_jsxs("p", { className: "flex items-center gap-2 py-1 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Thinking\u2026"] })), _jsx("div", { ref: end })] }));
|
|
138
|
+
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: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "Open Settings" }))] })] })), chat.loading ? (_jsxs("p", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Loading the chat\u2026"] })) : chat.items.length === 0 && !status?.wait ? (_jsxs("p", { className: "pt-8 text-center text-muted-foreground text-sm", children: ["Ask ", status?.name ?? 'the assistant', " anything."] })) : (_jsx(TierNames.Provider, { value: (id) => status?.tiers.find((t) => t.id === id)?.label ?? id, children: _jsx(UndoWrite.Provider, { value: chat.undo, children: _jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy }) }) })), chat.busy && !chat.items.some(isLive) && (_jsxs("p", { className: "flex items-center gap-2 py-1 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Thinking\u2026"] })), _jsx("div", { ref: end })] }));
|
|
77
139
|
}
|
|
78
140
|
const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
|
|
79
141
|
const WAIT = {
|
|
@@ -83,13 +145,32 @@ const WAIT = {
|
|
|
83
145
|
app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
|
|
84
146
|
own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
|
|
85
147
|
};
|
|
148
|
+
/** A tier's name for a message's `@` chip. */
|
|
149
|
+
const TierNames = createContext((id) => id);
|
|
150
|
+
/** Takes back a change made at once (Undo on its line). */
|
|
151
|
+
const UndoWrite = createContext(null);
|
|
86
152
|
function Items({ items, onSettle, busy, }) {
|
|
87
|
-
|
|
153
|
+
// Cards drafted one after another sit in one stack, so many of them skim as lines.
|
|
154
|
+
const rows = [];
|
|
155
|
+
items.forEach((item, at) => {
|
|
156
|
+
const last = rows[rows.length - 1];
|
|
157
|
+
const prev = items[at - 1];
|
|
158
|
+
if (item.kind === 'draft' && prev?.kind === 'draft' && last) {
|
|
159
|
+
if ('drafts' in last)
|
|
160
|
+
last.drafts.push(item);
|
|
161
|
+
else
|
|
162
|
+
rows[rows.length - 1] = { at: last.at, drafts: [prev, item] };
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
rows.push({ at, item: item });
|
|
166
|
+
});
|
|
167
|
+
return (_jsx("div", { className: "flex flex-col gap-3", children: rows.map((r) => 'drafts' in r ? (_jsx(DraftStack, { drafts: r.drafts, onSettle: onSettle, busy: busy }, r.at)) : (_jsx(Item, { item: r.item, onSettle: onSettle, busy: busy }, r.at))) }));
|
|
88
168
|
}
|
|
89
169
|
function Item({ item, onSettle, busy, }) {
|
|
170
|
+
const tierName = useContext(TierNames);
|
|
90
171
|
switch (item.kind) {
|
|
91
172
|
case 'user':
|
|
92
|
-
return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
|
|
173
|
+
return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: "@", label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
|
|
93
174
|
case 'text':
|
|
94
175
|
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(Markdown, { allowedElements: ['p', 'strong', 'em', 'code', 'ul', 'ol', 'li', 'a', 'pre', 'br'], unwrapDisallowed: true, 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" }))] }));
|
|
95
176
|
case 'step':
|
|
@@ -114,7 +195,25 @@ function Item({ item, onSettle, busy, }) {
|
|
|
114
195
|
}
|
|
115
196
|
const Rule = ({ children }) => (_jsx("p", { className: "flex items-center gap-2 text-muted-foreground text-xs before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border", children: children }));
|
|
116
197
|
function Step({ step, live }) {
|
|
117
|
-
|
|
198
|
+
const undo = useContext(UndoWrite);
|
|
199
|
+
const [undoing, setUndoing] = useState(false);
|
|
200
|
+
const w = step.write;
|
|
201
|
+
const take = async () => {
|
|
202
|
+
if (!undo)
|
|
203
|
+
return;
|
|
204
|
+
setUndoing(true);
|
|
205
|
+
try {
|
|
206
|
+
await undo(step);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
lumenToast.error(errorText(e));
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
setUndoing(false);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
return (_jsxs("p", { className: cn('flex min-w-0 items-center gap-1.5 text-xs', step.ok ? 'text-muted-foreground' : 'text-loss'), children: [live ? (_jsx(Spinner, { className: "size-3" })) : w ? (_jsx(PencilIcon, { className: "size-3.5 shrink-0" })) : step.ok ? (_jsx(WrenchIcon, { className: "size-3.5 shrink-0" })) : (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })), _jsx("span", { className: "truncate", children: step.title }), !live && (_jsxs("span", { className: "shrink-0 tabular-nums", children: [step.rows !== undefined && `${step.rows} row${step.rows === 1 ? '' : 's'} · `, seconds(step.ms)] })), step.error && _jsxs("span", { className: "truncate", children: ["\u00B7 ", step.error] }), w?.status === 'undone' ? (_jsx("span", { className: "shrink-0", children: "\u00B7 Undone" })) : (w?.undo &&
|
|
216
|
+
undo && (_jsxs(Button, { size: "sm", variant: "ghost", className: "ml-auto h-6 shrink-0 px-2 text-xs", disabled: undoing, onClick: () => void take(), children: [undoing ? _jsx(Spinner, {}) : _jsx(Undo2Icon, {}), "Undo"] })))] }));
|
|
118
217
|
}
|
|
119
218
|
/** The arguments as short lines: `name: value`. */
|
|
120
219
|
function argLines(args) {
|
|
@@ -125,9 +224,18 @@ function argLines(args) {
|
|
|
125
224
|
typeof v === 'string' ? v : JSON.stringify(v),
|
|
126
225
|
]);
|
|
127
226
|
}
|
|
128
|
-
|
|
227
|
+
/** One line that says what a card would change: its first few values. */
|
|
228
|
+
const argSummary = (args) => argLines(args)
|
|
229
|
+
.slice(0, 3)
|
|
230
|
+
.map(([, v]) => v)
|
|
231
|
+
.join(' · ');
|
|
232
|
+
function DraftStatus({ draft }) {
|
|
233
|
+
return draft.status === 'saved' ? (_jsxs("span", { className: "flex shrink-0 items-center gap-1 text-gain text-xs", children: [_jsx(CheckIcon, { className: "size-3.5" }), " Saved"] })) : draft.status === 'dismissed' ? (_jsx("span", { className: "shrink-0 text-muted-foreground text-xs", children: "Dismissed" })) : draft.status === 'failed' ? (_jsx("span", { className: "shrink-0 text-loss text-xs", children: "Failed" })) : null;
|
|
234
|
+
}
|
|
235
|
+
/** Saves or dismisses a card, a toast on failure. */
|
|
236
|
+
function useSettle(onSettle) {
|
|
129
237
|
const [pending, setPending] = useState(null);
|
|
130
|
-
const go = async (action) => {
|
|
238
|
+
const go = async (draft, action) => {
|
|
131
239
|
setPending(action);
|
|
132
240
|
try {
|
|
133
241
|
await onSettle(draft, action);
|
|
@@ -139,9 +247,46 @@ function Draft({ draft, onSettle, busy, }) {
|
|
|
139
247
|
setPending(null);
|
|
140
248
|
}
|
|
141
249
|
};
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
|
|
250
|
+
return { pending, go };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A drafted change. Open, it shows what it would run with and Save or Dismiss; settled, or as a
|
|
254
|
+
* row in a stack of cards, it is one line that opens in place.
|
|
255
|
+
*/
|
|
256
|
+
function Draft({ draft, onSettle, busy, row, }) {
|
|
257
|
+
const { pending, go } = useSettle(onSettle);
|
|
258
|
+
const [open, setOpen] = useState(!row && draft.status === 'open');
|
|
259
|
+
const lines = argLines(draft.args);
|
|
260
|
+
const actions = draft.status === 'open' && (_jsxs("div", { className: "flex shrink-0 justify-end gap-1", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss'), children: "Dismiss" }), _jsxs(Button, { size: "sm", disabled: !!pending || busy, onClick: () => void go(draft, 'save'), children: [pending === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save"] })] }));
|
|
261
|
+
return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "assistant-draft", className: cn('flex flex-col text-sm', row ? 'border-b px-1 py-1.5 last:border-b-0' : 'gap-2 rounded-lg border bg-card p-3'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": `${open ? 'Hide' : 'Show'} ${draft.title}`, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronRightIcon, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx("span", { className: "shrink-0 font-medium", children: draft.title }), !open && (_jsx("span", { className: "min-w-0 truncate text-muted-foreground text-xs", children: argSummary(draft.args) }))] }) }), _jsx(DraftStatus, { draft: draft }), row && draft.status === 'open' && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: XIcon, label: `Dismiss ${draft.title}`, size: "icon-xs", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss') }), _jsx(IconButton, { icon: pending === 'save' ? Spinner : CheckIcon, label: `Save ${draft.title}`, size: "icon-xs", variant: "outline", disabled: !!pending || busy, onClick: () => void go(draft, 'save') })] }))] }), _jsxs(CollapsibleContent, { className: cn('flex flex-col gap-2', row && 'pt-1.5 pl-5'), children: [_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs", children: lines.slice(0, 12).map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "font-code break-words", children: v })] }, k))) }), !row && actions] }), draft.error && _jsx("p", { className: "text-loss text-xs", children: draft.error })] }));
|
|
262
|
+
}
|
|
263
|
+
/** Two or more cards in a row: one stack with a count, Save all and Dismiss all, a line each. */
|
|
264
|
+
function DraftStack({ drafts, onSettle, busy, }) {
|
|
265
|
+
const [all, setAll] = useState(null);
|
|
266
|
+
const open = drafts.filter((d) => d.status === 'open');
|
|
267
|
+
const saved = drafts.filter((d) => d.status === 'saved').length;
|
|
268
|
+
const failed = drafts.filter((d) => d.status === 'failed').length;
|
|
269
|
+
// One at a time, in order: a later change may build on an earlier one.
|
|
270
|
+
const settleAll = async (action) => {
|
|
271
|
+
setAll(action);
|
|
272
|
+
try {
|
|
273
|
+
for (const d of open)
|
|
274
|
+
await onSettle(d, action);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
lumenToast.error(errorText(e));
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
setAll(null);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
return (_jsxs("div", { "data-slot": "assistant-draft-stack", className: "flex flex-col rounded-lg border bg-card p-2 text-sm", children: [_jsxs("div", { className: "flex items-center gap-2 px-1 pb-1.5", children: [_jsxs("span", { className: "min-w-0 flex-1 text-xs", children: [_jsxs("span", { className: "font-medium", children: [drafts.length, " changes"] }), _jsxs("span", { className: "text-muted-foreground", children: [' · ', [
|
|
284
|
+
open.length && `${open.length} to review`,
|
|
285
|
+
saved && `${saved} saved`,
|
|
286
|
+
failed && `${failed} failed`,
|
|
287
|
+
]
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.join(' · ') || 'all settled'] })] }), open.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!all || busy, onClick: () => void settleAll('dismiss'), children: "Dismiss all" }), _jsxs(Button, { size: "sm", disabled: !!all || busy, onClick: () => void settleAll('save'), children: [all === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save all"] })] }))] }), _jsx("div", { className: "flex flex-col border-t", children: drafts.map((d) => (_jsx(Draft, { draft: d, onSettle: onSettle, busy: busy || !!all, row: true }, d.id))) })] }));
|
|
145
290
|
}
|
|
146
291
|
const WHY = {
|
|
147
292
|
asked: 'you asked',
|
|
@@ -152,23 +297,182 @@ const WHY = {
|
|
|
152
297
|
function HandOff({ item, onSettle, busy, }) {
|
|
153
298
|
return (_jsxs("section", { className: "flex flex-col gap-2 border-l-2 border-primary/40 pl-3", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3.5 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: item.label }), _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 ", WHY[item.why]] }), item.usage && (_jsx("span", { className: "ml-auto text-muted-foreground tabular-nums", children: usd(item.usage.usd) }))] }), _jsx("p", { className: "text-muted-foreground text-xs", children: item.task }), _jsx(Items, { items: item.items, onSettle: onSettle, busy: busy })] }));
|
|
154
299
|
}
|
|
155
|
-
/**
|
|
156
|
-
|
|
300
|
+
/** A chip over the message box: a tier picked with `@` or a thing pointed at with `#`. */
|
|
301
|
+
function ComposerChip({ sign, label, onRemove, }) {
|
|
302
|
+
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" }) }))] }));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
|
|
306
|
+
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
307
|
+
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
308
|
+
*/
|
|
309
|
+
export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], onSend, onStop, }) {
|
|
157
310
|
const [text, setText] = useState('');
|
|
311
|
+
const [caret, setCaret] = useState(0);
|
|
312
|
+
const [closed, setClosed] = useState(null);
|
|
313
|
+
const [tier, setTier] = useState(null);
|
|
314
|
+
const [refs, setRefs] = useState([]);
|
|
315
|
+
const list = useRef(null);
|
|
316
|
+
const box = useRef(null);
|
|
317
|
+
const selection = usePageSelection();
|
|
318
|
+
// An app's compose request (`composeAssistant`): its chips and text, ready to finish.
|
|
319
|
+
const [request, take] = useComposeRequest();
|
|
320
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only a new request fills the box.
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!request)
|
|
323
|
+
return;
|
|
324
|
+
take();
|
|
325
|
+
if (request.refs?.length)
|
|
326
|
+
setRefs((p) => [...p, ...(request.refs ?? [])]);
|
|
327
|
+
if (request.tier)
|
|
328
|
+
setTier(request.tier);
|
|
329
|
+
if (request.text !== undefined) {
|
|
330
|
+
setText(request.text);
|
|
331
|
+
setCaret(request.text.length);
|
|
332
|
+
}
|
|
333
|
+
requestAnimationFrame(() => box.current?.focus());
|
|
334
|
+
}, [request]);
|
|
335
|
+
const m = /^\/(\S*)(?:\s(.*))?$/s.exec(text);
|
|
336
|
+
const cmd = m && m[2] !== undefined ? commands.find((c) => c.name === m[1]) : undefined;
|
|
337
|
+
// `@` or `#` as the word the caret is in: `pick #sto|` lists the sources' items matching "sto".
|
|
338
|
+
const word = /(^|\s)([@#])([^\s@#]*)$/.exec(text.slice(0, caret));
|
|
339
|
+
const trigger = word ? word[2] : null;
|
|
340
|
+
const asked = word?.[3] ?? '';
|
|
341
|
+
const open = closed !== `${text}|${caret}`;
|
|
342
|
+
const slash = m && commands.length ? (m[2] === undefined ? 'command' : cmd?.options ? 'arg' : null) : null;
|
|
343
|
+
const at = trigger === '@' && tiers.length > 1
|
|
344
|
+
? '@'
|
|
345
|
+
: trigger === '#' && (sources.length || selection)
|
|
346
|
+
? '#'
|
|
347
|
+
: null;
|
|
348
|
+
const picking = open ? (slash ?? at) : null;
|
|
349
|
+
const extra = () => ({
|
|
350
|
+
...(tier ? { tier } : {}),
|
|
351
|
+
...(refs.length ? { refs } : {}),
|
|
352
|
+
});
|
|
353
|
+
const clear = () => {
|
|
354
|
+
setText('');
|
|
355
|
+
setCaret(0);
|
|
356
|
+
setTier(null);
|
|
357
|
+
setRefs([]);
|
|
358
|
+
};
|
|
359
|
+
const run = async (c, args) => {
|
|
360
|
+
const keep = extra();
|
|
361
|
+
clear();
|
|
362
|
+
try {
|
|
363
|
+
const out = await c.run(args.trim());
|
|
364
|
+
if (out)
|
|
365
|
+
onSend(out, keep);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
lumenToast.error(errorText(e));
|
|
369
|
+
}
|
|
370
|
+
};
|
|
158
371
|
const send = () => {
|
|
159
372
|
if (!text.trim() || disabled)
|
|
160
373
|
return;
|
|
161
|
-
|
|
162
|
-
|
|
374
|
+
const c = m ? commands.find((x) => x.name === m[1]) : undefined;
|
|
375
|
+
if (c) {
|
|
376
|
+
void run(c, m?.[2] ?? '');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
onSend(text, extra());
|
|
380
|
+
clear();
|
|
163
381
|
};
|
|
164
|
-
const
|
|
165
|
-
|
|
382
|
+
const pickCommand = (o) => {
|
|
383
|
+
const c = commands.find((x) => x.name === o.value);
|
|
384
|
+
if (!c)
|
|
385
|
+
return;
|
|
386
|
+
if (c.args) {
|
|
387
|
+
setText(`/${c.name} `);
|
|
388
|
+
setCaret(c.name.length + 2);
|
|
389
|
+
box.current?.focus();
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
void run(c, '');
|
|
393
|
+
};
|
|
394
|
+
// Takes the `@word` or `#word` out of the text once its pick is a chip.
|
|
395
|
+
const dropWord = () => {
|
|
396
|
+
if (!word)
|
|
397
|
+
return;
|
|
398
|
+
const start = caret - 1 - asked.length;
|
|
399
|
+
const next = text.slice(0, start) + text.slice(caret).replace(/^ /, '');
|
|
400
|
+
setText(next);
|
|
401
|
+
setCaret(start);
|
|
402
|
+
requestAnimationFrame(() => {
|
|
403
|
+
box.current?.focus();
|
|
404
|
+
box.current?.setSelectionRange(start, start);
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
const sourceItems = new Map();
|
|
408
|
+
const picked = selection ? selectionRef(selection) : null;
|
|
409
|
+
if (picked)
|
|
410
|
+
sourceItems.set('selection', picked);
|
|
411
|
+
const sourceOptions = picking === '#'
|
|
412
|
+
? [
|
|
413
|
+
...(picked
|
|
414
|
+
? [
|
|
415
|
+
{
|
|
416
|
+
value: 'selection',
|
|
417
|
+
label: picked.label,
|
|
418
|
+
group: 'Selected text',
|
|
419
|
+
...(selection?.where ? { detail: selection.where } : {}),
|
|
420
|
+
...(refs.some((r) => sameRef(r, picked)) ? { disabled: 'Added' } : {}),
|
|
421
|
+
},
|
|
422
|
+
]
|
|
423
|
+
: []),
|
|
424
|
+
...sources.flatMap((s) => s.items(asked).map((i) => {
|
|
425
|
+
const value = `${s.id}:${i.id}`;
|
|
426
|
+
sourceItems.set(value, i.ref);
|
|
427
|
+
return {
|
|
428
|
+
value,
|
|
429
|
+
label: i.label,
|
|
430
|
+
group: s.label,
|
|
431
|
+
...(i.detail ? { detail: i.detail } : {}),
|
|
432
|
+
...(s.icon ? { icon: s.icon } : {}),
|
|
433
|
+
...(i.keywords ? { keywords: i.keywords } : {}),
|
|
434
|
+
...(i.target ? { target: i.target } : {}),
|
|
435
|
+
...(refs.some((r) => sameRef(r, i.ref)) ? { disabled: 'Added' } : {}),
|
|
436
|
+
};
|
|
437
|
+
})),
|
|
438
|
+
]
|
|
439
|
+
: [];
|
|
440
|
+
const tierLabel = tiers.find((t) => t.id === tier)?.label;
|
|
441
|
+
const hints = [
|
|
442
|
+
commands.length && '/',
|
|
443
|
+
tiers.length > 1 && '@',
|
|
444
|
+
(sources.length || selection) && '#',
|
|
445
|
+
].filter(Boolean);
|
|
446
|
+
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) => ({
|
|
447
|
+
value: c.name,
|
|
448
|
+
label: `/${c.name}`,
|
|
449
|
+
...(c.args ? { detail: c.args } : {}),
|
|
450
|
+
description: c.about,
|
|
451
|
+
})), empty: "No command matches", onPick: pickCommand, onClose: () => setClosed(`${text}|${caret}`) })), picking === 'arg' && cmd?.options && (_jsx(LumenSelectorList, { handle: list, label: `/${cmd.name}`, className: "mb-2", fuzzy: true, query: m?.[2] ?? '', options: cmd.options(), onPick: (o) => void run(cmd, o.value), onClose: () => setClosed(`${text}|${caret}`) })), picking === '@' && (_jsx(LumenSelectorList, { handle: list, label: "Answer this message on", className: "mb-2", fuzzy: true, query: asked, options: tiers.map((t) => ({
|
|
452
|
+
value: t.id,
|
|
453
|
+
label: t.label,
|
|
454
|
+
...(t.model_label ? { detail: t.model_label } : {}),
|
|
455
|
+
})), empty: "No tier matches", onPick: (o) => {
|
|
456
|
+
setTier(o.value);
|
|
457
|
+
dropWord();
|
|
458
|
+
}, onClose: () => setClosed(`${text}|${caret}`) })), picking === '#' && (_jsx(LumenSelectorList, { handle: list, label: "Point at", className: "mb-2", query: asked, options: sourceOptions, empty: "Nothing matches", onPick: (o) => {
|
|
459
|
+
const r = sourceItems.get(o.value);
|
|
460
|
+
if (r)
|
|
461
|
+
setRefs((p) => [...p, r]);
|
|
462
|
+
dropWord();
|
|
463
|
+
}, onClose: () => setClosed(`${text}|${caret}`) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && _jsx(ComposerChip, { sign: "@", label: tierLabel, onRemove: () => setTier(null) }), refs.map((r, i) => (_jsx(ComposerChip, { sign: "#", 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) => {
|
|
464
|
+
setText(e.target.value);
|
|
465
|
+
setCaret(e.target.selectionStart);
|
|
466
|
+
}, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onKeyDown: (e) => {
|
|
467
|
+
if (picking && list.current?.key(e))
|
|
468
|
+
return;
|
|
166
469
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
167
470
|
e.preventDefault();
|
|
168
471
|
send();
|
|
169
472
|
}
|
|
170
473
|
} }), _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 })) })] })] }));
|
|
171
474
|
}
|
|
475
|
+
const sameRef = (a, b) => a.kind === b.kind && JSON.stringify(a) === JSON.stringify(b);
|
|
172
476
|
/** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
|
|
173
477
|
function UsageLine({ usage }) {
|
|
174
478
|
if (!usage || !usage.calls)
|
|
@@ -185,6 +489,11 @@ export function useLumenAssistantDock(client, o = {
|
|
|
185
489
|
}) {
|
|
186
490
|
const open = assistantOpen.use();
|
|
187
491
|
const chat = useAssistantChat(client);
|
|
492
|
+
const [request] = useComposeRequest();
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
if (request && !assistantOpen.get())
|
|
495
|
+
assistantOpen.write(true);
|
|
496
|
+
}, [request]);
|
|
188
497
|
const [unread, setUnread] = useState(false);
|
|
189
498
|
const wasBusy = useRef(false);
|
|
190
499
|
useEffect(() => {
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { SettingsSectionDef } from '../settings.js';
|
|
2
2
|
import type { AssistantAdapter } from './client.js';
|
|
3
|
+
/**
|
|
4
|
+
* The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
|
|
5
|
+
* where the app keeps its admin settings, shown only to admins.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assistantAdminSections({ client, }: {
|
|
8
|
+
client: AssistantAdapter;
|
|
9
|
+
}): SettingsSectionDef[];
|
|
3
10
|
/**
|
|
4
11
|
* The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
|
|
5
12
|
* Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
|
|
@@ -31,6 +31,7 @@ const ACCESS = [
|
|
|
31
31
|
{ value: 'off', label: 'Off' },
|
|
32
32
|
{ value: 'read', label: 'Read' },
|
|
33
33
|
{ value: 'draft', label: 'Read + draft' },
|
|
34
|
+
{ value: 'write', label: 'Read + write' },
|
|
34
35
|
];
|
|
35
36
|
/** Runs a change and keeps the status it returns, or says why it failed. */
|
|
36
37
|
function useApply(client) {
|
|
@@ -172,28 +173,31 @@ function ChatgptSignin({ client, p }) {
|
|
|
172
173
|
function SigninPaste({ id, redirect, pasted, setPasted, issues, busy, finish, }) {
|
|
173
174
|
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: id, children: _jsx(InfoLabel, { info: `The sign-in ends on ${redirect}, a page that does not load. That is expected: copy the whole address from that tab's address bar.`, children: "Paste the address the sign-in ended on" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, autoComplete: "off", placeholder: `${redirect}?code=…`, className: "font-code text-xs", value: pasted, onChange: (e) => setPasted(e.target.value) }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !pasted.trim(), label: "Finish", onSave: finish })] })] }));
|
|
174
175
|
}
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
const ready = (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] }));
|
|
177
|
+
const notSet = _jsx(Badge, { variant: "outline", children: "Not set" });
|
|
178
|
+
/** One provider, collapsed to a row: the person's own key (or their ChatGPT plan sign-in). */
|
|
179
|
+
function OwnProviderKey({ client, p }) {
|
|
177
180
|
const { apply, refresh } = useApply(client);
|
|
178
|
-
const appOn = p.app.set || !!p.app.from_env;
|
|
179
181
|
const plan = p.own.via === 'chatgpt';
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
plan ? 'your ChatGPT plan' : p.own.set ? 'your own key' : null,
|
|
183
|
-
]
|
|
184
|
-
.filter(Boolean)
|
|
185
|
-
.join(' · ');
|
|
186
|
-
const failed = [p.app, p.own].some((k) => k.set && k.test && !k.test.ok);
|
|
187
|
-
const badge = failed ? (_jsx(TestBadge, { test: { ok: false } })) : appOn || p.own.set ? (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] })) : (_jsx(Badge, { variant: "outline", children: "Not set" }));
|
|
188
|
-
const ownKey = (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your own API key' : 'Your own key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }));
|
|
189
|
-
return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: meta, badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [_jsx(KeyBlock, { label: "Shared key", info: "An admin sets it once; everyone uses it, within each person's monthly cap.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: admin, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }), p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && ownKey] }) }));
|
|
182
|
+
const badge = p.own.set && p.own.test && !p.own.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : p.own.set ? (ready) : (notSet);
|
|
183
|
+
return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: plan ? 'Your ChatGPT plan' : p.own.set ? 'Your key' : 'No key', badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your API key' : 'Your key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }))] }) }));
|
|
190
184
|
}
|
|
185
|
+
/** A person's own keys, one row a provider, and what they may spend on them. */
|
|
191
186
|
function Providers({ client }) {
|
|
192
187
|
const { apply } = useApply(client);
|
|
193
|
-
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
188
|
+
return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
|
|
189
|
+
}
|
|
190
|
+
/** One provider's shared key, which an admin sets for everyone. */
|
|
191
|
+
function SharedProviderKey({ client, p }) {
|
|
192
|
+
const { apply, refresh } = useApply(client);
|
|
193
|
+
const on = p.app.set || !!p.app.from_env;
|
|
194
|
+
const badge = p.app.set && p.app.test && !p.app.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : on ? (ready) : (notSet);
|
|
195
|
+
return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: p.app.from_env ? 'From the server' : on ? 'Shared key' : 'No key', badge: badge, children: _jsx(KeyBlock, { label: "Shared key", info: "Everyone's answers run on it, within the monthly allowance below.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }) }));
|
|
196
|
+
}
|
|
197
|
+
/** Admins only: the app's shared keys and what each person may spend on them. */
|
|
198
|
+
function SharedKeys({ client }) {
|
|
199
|
+
const { apply } = useApply(client);
|
|
200
|
+
return (_jsx(WithStatus, { client: client, children: (s) => s.admin ? (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(SharedProviderKey, { client: client, p: p }, p.id))) }), _jsx(Row, { label: "Each person may spend", info: "A month on the shared keys, every provider together. Past it, a person's own key pays.", children: _jsx(CapInput, { label: "Allowance per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })] })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Only an admin can change these." })) }));
|
|
197
201
|
}
|
|
198
202
|
// ---------------------------------------------------------------- models
|
|
199
203
|
function Models({ client }) {
|
|
@@ -214,7 +218,7 @@ function Models({ client }) {
|
|
|
214
218
|
function Tools({ client }) {
|
|
215
219
|
const { apply } = useApply(client);
|
|
216
220
|
return (_jsx(WithStatus, { client: client, children: (s) => s.groups.length ? (_jsx("div", { className: "flex flex-col", children: s.groups.map((g) => (_jsx(Row, { label: g.label, info: g.about, children: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: g.access, "aria-label": g.label, onValueChange: (v) => v &&
|
|
217
|
-
void apply('access', () => client.settings({ access: { [g.id]: v } })), children: ACCESS.filter((a) => a.value !== 'draft' || g.drafts).map((a) => (_jsx(ToggleGroupItem, { value: a.value, children: a.label }, a.value))) }) }, g.id))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "This app gives the assistant no tools yet." })) }));
|
|
221
|
+
void apply('access', () => client.settings({ access: { [g.id]: v } })), children: ACCESS.filter((a) => (a.value !== 'draft' || g.drafts) && (a.value !== 'write' || g.writes)).map((a) => (_jsx(ToggleGroupItem, { value: a.value, children: a.label }, a.value))) }) }, g.id))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "This app gives the assistant no tools yet." })) }));
|
|
218
222
|
}
|
|
219
223
|
// ---------------------------------------------------------------- web
|
|
220
224
|
function ScrapeqlBlock({ client, s }) {
|
|
@@ -260,6 +264,21 @@ function Spend({ client }) {
|
|
|
260
264
|
` · ${usd(sp.cap)} cap on ${sp.payer === 'app' ? 'the app key' : 'your key'}`] })] }), sp.rows.length ? (_jsx("div", { className: "flex flex-col text-sm", children: sp.rows.map((r) => (_jsxs("div", { className: "flex items-center gap-2 border-b py-2 last:border-b-0", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: r.label }), _jsx("span", { className: "text-muted-foreground text-xs", children: r.payer === 'app' ? 'Shared key' : 'Your key' }), _jsxs("span", { className: "w-16 text-right text-muted-foreground text-xs tabular-nums", children: [r.calls, " calls"] }), _jsx("span", { className: "w-16 text-right tabular-nums", children: usd(r.usd) })] }, `${r.label}:${r.payer}`))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing spent this month." }))] }));
|
|
261
265
|
} }));
|
|
262
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
|
|
269
|
+
* where the app keeps its admin settings, shown only to admins.
|
|
270
|
+
*/
|
|
271
|
+
export function assistantAdminSections({ client, }) {
|
|
272
|
+
return [
|
|
273
|
+
{
|
|
274
|
+
id: 'assistant-shared-keys',
|
|
275
|
+
title: 'Shared model keys',
|
|
276
|
+
icon: KeyRoundIcon,
|
|
277
|
+
description: "Keys everyone's answers run on, and the allowance",
|
|
278
|
+
content: _jsx(SharedKeys, { client: client }),
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
}
|
|
263
282
|
/**
|
|
264
283
|
* The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
|
|
265
284
|
* Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
|
|
@@ -278,7 +297,7 @@ export function assistantSettingsSections({ client, name, web = true, }) {
|
|
|
278
297
|
title: 'Model providers',
|
|
279
298
|
icon: KeyRoundIcon,
|
|
280
299
|
sub: true,
|
|
281
|
-
description: '
|
|
300
|
+
description: 'Your own key for each provider',
|
|
282
301
|
content: _jsx(Providers, { client: client }),
|
|
283
302
|
},
|
|
284
303
|
{
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AssistantRefNote } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
import { type ComponentType } from 'react';
|
|
3
|
+
/** One thing a source holds: a row in the `#` list, sent with the message as `ref`. */
|
|
4
|
+
export interface AssistantSourceItem {
|
|
5
|
+
/** Unique within its source. */
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** The list's right column: a date, a count, where it lives. */
|
|
9
|
+
detail?: string;
|
|
10
|
+
/** Extra words search matches. */
|
|
11
|
+
keywords?: string[];
|
|
12
|
+
/** What it stands for on screen, lit while hovered in the list (`card:<id>` or an app path). */
|
|
13
|
+
target?: string;
|
|
14
|
+
/** What goes to the model: its kind, its label and whatever finds it (an id, a path). */
|
|
15
|
+
ref: AssistantRefNote;
|
|
16
|
+
}
|
|
17
|
+
/** A group of things in the `#` list. */
|
|
18
|
+
export interface AssistantSource {
|
|
19
|
+
/** Unique: a page's source replaces an app source of the same id. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** The group heading: "On this page", "Stories". */
|
|
22
|
+
label: string;
|
|
23
|
+
icon?: ComponentType<{
|
|
24
|
+
className?: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** What it holds now; `query` is what follows the `#`, for a source that searches. */
|
|
27
|
+
items: (query: string) => readonly AssistantSourceItem[];
|
|
28
|
+
}
|
|
29
|
+
/** Adds a source to the `#` list while the calling page is shown. */
|
|
30
|
+
export declare function useAssistantSource(source: AssistantSource | null | undefined): void;
|
|
31
|
+
/** The sources pages have added, newest page last. */
|
|
32
|
+
export declare function useShownAssistantSources(): readonly AssistantSource[];
|
|
33
|
+
/** What an app puts in the composer: text, chips to point at, a tier for the message. */
|
|
34
|
+
export interface ComposeRequest {
|
|
35
|
+
text?: string;
|
|
36
|
+
refs?: AssistantRefNote[];
|
|
37
|
+
tier?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Opens the assistant with `refs` as chips in its composer and `text` typed, focused for the person
|
|
41
|
+
* to finish: an Ask button beside a highlighted part of the page calls it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function composeAssistant(request: ComposeRequest): void;
|
|
44
|
+
/** The newest compose request and a way to mark it taken. */
|
|
45
|
+
export declare function useComposeRequest(): readonly [ComposeRequest | null, () => void];
|
|
46
|
+
/** Text the person highlighted on the page, and where it sits (`data-assistant-where`). */
|
|
47
|
+
export interface PageSelection {
|
|
48
|
+
text: string;
|
|
49
|
+
where?: string;
|
|
50
|
+
}
|
|
51
|
+
/** What the person last highlighted on the page outside the assistant, or null. */
|
|
52
|
+
export declare function usePageSelection(): PageSelection | null;
|
|
53
|
+
/** A highlight as a `#` chip: its first words, the whole text and where it sits go to the model. */
|
|
54
|
+
export declare const selectionRef: (s: PageSelection) => AssistantRefNote;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
const shown = new Map();
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
let snapshot = [];
|
|
5
|
+
const changed = () => {
|
|
6
|
+
snapshot = [...shown.values()];
|
|
7
|
+
for (const l of listeners)
|
|
8
|
+
l();
|
|
9
|
+
};
|
|
10
|
+
const subscribe = (l) => {
|
|
11
|
+
listeners.add(l);
|
|
12
|
+
return () => listeners.delete(l);
|
|
13
|
+
};
|
|
14
|
+
/** Adds a source to the `#` list while the calling page is shown. */
|
|
15
|
+
export function useAssistantSource(source) {
|
|
16
|
+
const latest = useRef(source);
|
|
17
|
+
latest.current = source;
|
|
18
|
+
const id = source?.id;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!id)
|
|
21
|
+
return;
|
|
22
|
+
// The list reads the newest items, so a source registered once follows the page's data.
|
|
23
|
+
shown.set(id, {
|
|
24
|
+
get id() {
|
|
25
|
+
return id;
|
|
26
|
+
},
|
|
27
|
+
get label() {
|
|
28
|
+
return latest.current?.label ?? id;
|
|
29
|
+
},
|
|
30
|
+
get icon() {
|
|
31
|
+
return latest.current?.icon;
|
|
32
|
+
},
|
|
33
|
+
items: (q) => latest.current?.items(q) ?? [],
|
|
34
|
+
});
|
|
35
|
+
changed();
|
|
36
|
+
return () => {
|
|
37
|
+
shown.delete(id);
|
|
38
|
+
changed();
|
|
39
|
+
};
|
|
40
|
+
}, [id]);
|
|
41
|
+
}
|
|
42
|
+
/** The sources pages have added, newest page last. */
|
|
43
|
+
export function useShownAssistantSources() {
|
|
44
|
+
return useSyncExternalStore(subscribe, () => snapshot, () => snapshot);
|
|
45
|
+
}
|
|
46
|
+
let composing = null;
|
|
47
|
+
const composeListeners = new Set();
|
|
48
|
+
/**
|
|
49
|
+
* Opens the assistant with `refs` as chips in its composer and `text` typed, focused for the person
|
|
50
|
+
* to finish: an Ask button beside a highlighted part of the page calls it.
|
|
51
|
+
*/
|
|
52
|
+
export function composeAssistant(request) {
|
|
53
|
+
composing = request;
|
|
54
|
+
for (const l of composeListeners)
|
|
55
|
+
l();
|
|
56
|
+
}
|
|
57
|
+
/** The newest compose request and a way to mark it taken. */
|
|
58
|
+
export function useComposeRequest() {
|
|
59
|
+
const request = useSyncExternalStore((l) => {
|
|
60
|
+
composeListeners.add(l);
|
|
61
|
+
return () => composeListeners.delete(l);
|
|
62
|
+
}, () => composing, () => composing);
|
|
63
|
+
const take = () => {
|
|
64
|
+
composing = null;
|
|
65
|
+
for (const l of composeListeners)
|
|
66
|
+
l();
|
|
67
|
+
};
|
|
68
|
+
return [request, take];
|
|
69
|
+
}
|
|
70
|
+
let selected = null;
|
|
71
|
+
const selectionListeners = new Set();
|
|
72
|
+
let watching = 0;
|
|
73
|
+
// The last highlight outside the assistant, kept while the person moves into the composer.
|
|
74
|
+
const onSelection = () => {
|
|
75
|
+
const sel = document.getSelection();
|
|
76
|
+
const node = sel?.anchorNode;
|
|
77
|
+
const el = node instanceof Element ? node : node?.parentElement;
|
|
78
|
+
if (!el || el.closest('[data-assistant], input, textarea, [contenteditable="true"]'))
|
|
79
|
+
return;
|
|
80
|
+
const text = sel?.toString().trim() ?? '';
|
|
81
|
+
const next = text
|
|
82
|
+
? {
|
|
83
|
+
text: text.slice(0, 4000),
|
|
84
|
+
...(el.closest('[data-assistant-where]')?.dataset.assistantWhere
|
|
85
|
+
? { where: el.closest('[data-assistant-where]')?.dataset.assistantWhere }
|
|
86
|
+
: {}),
|
|
87
|
+
}
|
|
88
|
+
: null;
|
|
89
|
+
if (next?.text === selected?.text && next?.where === selected?.where)
|
|
90
|
+
return;
|
|
91
|
+
selected = next;
|
|
92
|
+
for (const l of selectionListeners)
|
|
93
|
+
l();
|
|
94
|
+
};
|
|
95
|
+
/** What the person last highlighted on the page outside the assistant, or null. */
|
|
96
|
+
export function usePageSelection() {
|
|
97
|
+
return useSyncExternalStore((l) => {
|
|
98
|
+
selectionListeners.add(l);
|
|
99
|
+
if (watching++ === 0)
|
|
100
|
+
document.addEventListener('selectionchange', onSelection);
|
|
101
|
+
return () => {
|
|
102
|
+
selectionListeners.delete(l);
|
|
103
|
+
if (--watching === 0)
|
|
104
|
+
document.removeEventListener('selectionchange', onSelection);
|
|
105
|
+
};
|
|
106
|
+
}, () => selected, () => null);
|
|
107
|
+
}
|
|
108
|
+
/** A highlight as a `#` chip: its first words, the whole text and where it sits go to the model. */
|
|
109
|
+
export const selectionRef = (s) => ({
|
|
110
|
+
kind: 'selection',
|
|
111
|
+
label: `“${s.text.length > 40 ? `${s.text.slice(0, 40)}…` : s.text}”`,
|
|
112
|
+
text: s.text,
|
|
113
|
+
...(s.where ? { where: s.where } : {}),
|
|
114
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
import type { AssistantAdapter } from './client.js';
|
|
3
3
|
/** An item as the panel draws it: a text still streaming, a step still running. */
|
|
4
4
|
export type LiveItem = ChatItem & {
|
|
@@ -20,6 +20,8 @@ export interface AssistantChatState {
|
|
|
20
20
|
open: (id: string) => Promise<void>;
|
|
21
21
|
reset: () => void;
|
|
22
22
|
settle: (draft: DraftItem, action: 'save' | 'dismiss') => Promise<void>;
|
|
23
|
+
/** Takes back a change the assistant made at once (Undo on its line). */
|
|
24
|
+
undo: (step: StepItem) => Promise<void>;
|
|
23
25
|
}
|
|
24
26
|
/** A copy of the items with one event applied (hand-off sections copied too). */
|
|
25
27
|
export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
|
|
@@ -201,18 +201,27 @@ export function useAssistantChat(client) {
|
|
|
201
201
|
setLoading(false);
|
|
202
202
|
}
|
|
203
203
|
}, [client]);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return;
|
|
208
|
-
const next = await client.settleDraft(id, draft.id, action);
|
|
209
|
-
const swap = (list) => list.map((i) => i.kind === 'draft' && i.id === next.id
|
|
204
|
+
// Puts a settled card or an undone line back in place, in a hand-off section too.
|
|
205
|
+
const replace = useCallback((next) => {
|
|
206
|
+
const swap = (list) => list.map((i) => i.kind === next.kind && i.id === next.id
|
|
210
207
|
? next
|
|
211
208
|
: i.kind === 'handoff'
|
|
212
209
|
? { ...i, items: swap(i.items) }
|
|
213
210
|
: i);
|
|
214
211
|
setItems(swap);
|
|
215
|
-
}, [
|
|
212
|
+
}, []);
|
|
213
|
+
const settle = useCallback(async (draft, action) => {
|
|
214
|
+
const id = idRef.current;
|
|
215
|
+
if (!id)
|
|
216
|
+
return;
|
|
217
|
+
replace(await client.settleDraft(id, draft.id, action));
|
|
218
|
+
}, [client, replace]);
|
|
219
|
+
const undo = useCallback(async (step) => {
|
|
220
|
+
const id = idRef.current;
|
|
221
|
+
if (!id)
|
|
222
|
+
return;
|
|
223
|
+
replace(await client.undoWrite(id, step.id));
|
|
224
|
+
}, [client, replace]);
|
|
216
225
|
return {
|
|
217
226
|
chatId,
|
|
218
227
|
title,
|
|
@@ -226,5 +235,6 @@ export function useAssistantChat(client) {
|
|
|
226
235
|
open,
|
|
227
236
|
reset,
|
|
228
237
|
settle,
|
|
238
|
+
undo,
|
|
229
239
|
};
|
|
230
240
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
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.80",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|