@columenlabs/lumen-ui 0.1.281 → 0.1.283
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/index.d.ts +1 -1
- package/dist/assistant/index.js +1 -1
- package/dist/assistant/lumen-assistant.d.ts +5 -3
- package/dist/assistant/lumen-assistant.js +53 -16
- package/dist/assistant/settings.d.ts +8 -0
- package/dist/assistant/settings.js +21 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/voice-input.d.ts +34 -0
- package/dist/voice-input.js +127 -0
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { type AssistantAdapter, assistantClient } from './client.js';
|
|
2
2
|
export { ChatImage } from './chat-image.js';
|
|
3
3
|
export { type AssistantCommand, type AssistantMode, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
4
|
-
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
4
|
+
export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
5
5
|
export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
|
|
6
6
|
export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
7
7
|
export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
|
package/dist/assistant/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export { assistantClient } from './client.js';
|
|
5
5
|
export { ChatImage } from './chat-image.js';
|
|
6
6
|
export { LumenAssistant, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
|
|
7
|
-
export { assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
7
|
+
export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
|
|
8
8
|
export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
|
|
9
9
|
export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
10
10
|
export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
|
|
@@ -59,8 +59,8 @@ export interface LumenAssistantProps {
|
|
|
59
59
|
mode?: string;
|
|
60
60
|
};
|
|
61
61
|
/**
|
|
62
|
-
* Where the server compacts the chat, in tokens (the agent's `compactAt()`)
|
|
63
|
-
*
|
|
62
|
+
* Where the server compacts the chat, in tokens (the agent's `compactAt()`): the context donut turns
|
|
63
|
+
* yellow at 75% of it and red at it. The person's Auto-compact (status `compact_at`) wins; 150k by default.
|
|
64
64
|
*/
|
|
65
65
|
compactAt?: number;
|
|
66
66
|
}
|
|
@@ -108,7 +108,9 @@ export declare function readAssistantImage(file: File): Promise<AssistantImage |
|
|
|
108
108
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
|
|
109
109
|
* point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
|
|
110
110
|
*/
|
|
111
|
-
export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, }: {
|
|
111
|
+
export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, voice, }: {
|
|
112
|
+
/** A mic beside Send that types what the person says (`VoiceButton`); hidden where the browser can't. */
|
|
113
|
+
voice?: boolean;
|
|
112
114
|
/** Drawn in the icon row after the paperclip and the modes: `LumenAssistant` puts its context donut here. */
|
|
113
115
|
meter?: ReactNode;
|
|
114
116
|
/** Toggles beside the paperclip (`LumenAssistant` `modes`). */
|
|
@@ -4,7 +4,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
// (the person's messages, the answer as it streams, each tool call as a line, drafted changes as
|
|
5
5
|
// cards to Save or Dismiss, work handed to a higher tier in its own section); and the composer,
|
|
6
6
|
// with Stop, a queued message and the chat's cost and context under it.
|
|
7
|
-
import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, } from '@columenlabs/lumen-ui-types/assistant';
|
|
7
|
+
import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, COMPACT_AT, } from '@columenlabs/lumen-ui-types/assistant';
|
|
8
8
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
9
|
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PaperclipIcon, PencilIcon, PlusIcon, RefreshCwIcon, ReplyIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
10
10
|
import { Fragment, createContext, useContext, useEffect, useRef, useState, } from 'react';
|
|
@@ -24,6 +24,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/colla
|
|
|
24
24
|
import { InputGroup, InputGroupAddon, InputGroupTextarea } from '../ui/input-group.js';
|
|
25
25
|
import { Spinner } from '../ui/spinner.js';
|
|
26
26
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
|
|
27
|
+
import { VoiceButton } from '../voice-input.js';
|
|
27
28
|
import { ChatImage } from './chat-image.js';
|
|
28
29
|
import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
|
|
29
30
|
import { ImageCard, ImagePending, toolIs } from './image-card.js';
|
|
@@ -36,7 +37,6 @@ import { useAssistantChat, } from './use-assistant-chat.js';
|
|
|
36
37
|
const ChatMarkdown = lazyPart(() => import('./chat-markdown.js').then((m) => m.default), null, ({ children }) => _jsx("p", { className: "whitespace-pre-wrap", children: children }));
|
|
37
38
|
/** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
|
|
38
39
|
const CONTEXT_MAX = 200_000;
|
|
39
|
-
const COMPACT_AT = 150_000;
|
|
40
40
|
export const assistantStatusKey = (client) => [
|
|
41
41
|
'lumen-assistant',
|
|
42
42
|
client.key,
|
|
@@ -191,7 +191,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
|
|
|
191
191
|
title: c.title,
|
|
192
192
|
updated_at: new Date(c.at).toISOString(),
|
|
193
193
|
...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
|
|
194
|
-
})) ?? (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()), onFresh: (choice) => chat.fresh(choice, 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, meter: _jsx(ContextDonut, { usage: chat.usage, compactAt: compactAt }) })] }));
|
|
194
|
+
})) ?? (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()), onFresh: (choice) => chat.fresh(choice, 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, meter: _jsx(ContextDonut, { usage: chat.usage, compactAt: s?.compact_at ?? compactAt, items: chat.items }) })] }));
|
|
195
195
|
}
|
|
196
196
|
function Messages({ chat, status, onSettings, onRetry, onFresh, }) {
|
|
197
197
|
const end = useRef(null);
|
|
@@ -581,7 +581,7 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
|
|
|
581
581
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
|
|
582
582
|
* point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
|
|
583
583
|
*/
|
|
584
|
-
export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, }) {
|
|
584
|
+
export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, voice = true, }) {
|
|
585
585
|
const [text, setText] = useState('');
|
|
586
586
|
const [caret, setCaret] = useState(0);
|
|
587
587
|
const [closed, setClosed] = useState(null);
|
|
@@ -904,7 +904,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
|
|
|
904
904
|
if (e.target.files)
|
|
905
905
|
attach(e.target.files);
|
|
906
906
|
e.target.value = '';
|
|
907
|
-
} })] })), 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))), meter,
|
|
907
|
+
} })] })), 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))), meter, _jsxs("div", { className: "ml-auto flex items-center gap-0.5", children: [voice && (_jsx(VoiceButton, { value: text, onChange: setText, size: "icon-xs", disabled: disabled })), 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() }))] })] })] })] }));
|
|
908
908
|
}
|
|
909
909
|
/**
|
|
910
910
|
* What waits above the box while an answer runs: steering messages until the answer reads them,
|
|
@@ -929,30 +929,67 @@ const pointedRef = (p) => p.kind === 'card'
|
|
|
929
929
|
text: textOf(p.el),
|
|
930
930
|
}
|
|
931
931
|
: { kind: p.section.kind, label: p.section.label, id: p.section.id, text: textOf(p.el) };
|
|
932
|
-
/**
|
|
933
|
-
const CONTEXT_WATCH =
|
|
934
|
-
|
|
932
|
+
/** Share of the compact point from which the donut turns yellow; red at the compact point itself. */
|
|
933
|
+
const CONTEXT_WATCH = 0.75;
|
|
934
|
+
/** What the context pop-up counts from the chat: its turns, and the images and pointed-at things sent. */
|
|
935
|
+
function chatCounts(items) {
|
|
936
|
+
let turns = 0;
|
|
937
|
+
let attached = 0;
|
|
938
|
+
for (const i of items) {
|
|
939
|
+
if (i.kind !== 'user' || i.steered)
|
|
940
|
+
continue;
|
|
941
|
+
turns += 1;
|
|
942
|
+
attached += (i.images?.length ?? 0) + (i.refs?.length ?? 0);
|
|
943
|
+
}
|
|
944
|
+
return { turns, attached };
|
|
945
|
+
}
|
|
935
946
|
/**
|
|
936
|
-
* How full the chat's context is, as a small donut in the composer's icon row (after the paperclip and
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
947
|
+
* How full the chat's context is, as a small donut in the composer's icon row (after the paperclip and
|
|
948
|
+
* the modes). It fills against `CONTEXT_MAX`: neutral, yellow from 75% of the compact point, red at it
|
|
949
|
+
* (75k and 100k when chats compact at 100k). Hover, focus or a tap (touch has no hover) opens its pop-up:
|
|
950
|
+
* context used of the most with a bar, that the chat compacts on its own, its turns, the images and
|
|
951
|
+
* attachments sent, and how much of the last turn came from the prompt cache. No price: a person reads
|
|
952
|
+
* their use in Settings.
|
|
940
953
|
*/
|
|
941
|
-
function ContextDonut({ usage, compactAt }) {
|
|
954
|
+
function ContextDonut({ usage, compactAt, items, }) {
|
|
955
|
+
const [open, setOpen] = useState(false);
|
|
956
|
+
const touch = useRef(false);
|
|
957
|
+
const wasOpen = useRef(false);
|
|
942
958
|
const ctx = usage?.context ?? 0;
|
|
943
959
|
if (ctx <= 0)
|
|
944
960
|
return null;
|
|
945
961
|
const share = Math.min(1, ctx / CONTEXT_MAX);
|
|
946
962
|
const r = 6;
|
|
947
963
|
const round = 2 * Math.PI * r;
|
|
948
|
-
const
|
|
964
|
+
const level = ctx >= compactAt ? 'alert' : ctx >= compactAt * CONTEXT_WATCH ? 'watch' : 'ok';
|
|
965
|
+
const tone = level === 'alert'
|
|
949
966
|
? 'text-destructive'
|
|
950
|
-
:
|
|
967
|
+
: level === 'watch'
|
|
951
968
|
? 'text-warning'
|
|
952
969
|
: 'text-muted-foreground';
|
|
953
970
|
const cached = usage && usage.in > 0 ? Math.round((usage.read / usage.in) * 100) : null;
|
|
971
|
+
const { turns, attached } = chatCounts(items);
|
|
954
972
|
const said = `Context ${tokens(ctx)} of ${tokens(CONTEXT_MAX)}`;
|
|
955
|
-
|
|
973
|
+
const rows = [
|
|
974
|
+
['This chat', `${turns} ${turns === 1 ? 'turn' : 'turns'}`],
|
|
975
|
+
['Images and attachments', String(attached)],
|
|
976
|
+
...(cached !== null ? [['Cache read', `${cached}%`]] : []),
|
|
977
|
+
];
|
|
978
|
+
return (_jsxs(Tooltip, { open: open, onOpenChange: setOpen, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": said, "aria-expanded": open, "data-testid": "context-donut", "data-level": level,
|
|
979
|
+
// A tap opens or closes the pop-up (touch has no hover); a mouse click leaves it open.
|
|
980
|
+
// The press itself counts as outside the pop-up and closes it, so the tap reads the state before.
|
|
981
|
+
onPointerDown: (e) => {
|
|
982
|
+
touch.current = e.pointerType !== 'mouse';
|
|
983
|
+
wasOpen.current = open;
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
}, onClick: (e) => {
|
|
986
|
+
e.preventDefault();
|
|
987
|
+
setOpen(touch.current ? !wasOpen.current : true);
|
|
988
|
+
}, className: cn('grid size-6 place-items-center rounded-md hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', tone), children: _jsxs("svg", { viewBox: "0 0 16 16", className: "size-4 -rotate-90", "aria-hidden": "true", children: [_jsx("title", { children: said }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", strokeWidth: "2.5", className: "stroke-muted-foreground/25" }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", strokeWidth: "2.5", stroke: "currentColor", strokeDasharray: `${share * round} ${round}` })] }) }) }), _jsxs(TooltipContent, { "data-testid": "context-popup", className: "flex w-56 flex-col items-stretch gap-1.5 px-3 py-2.5 text-left font-sans tabular-nums", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "font-medium", children: "Context" }), _jsxs("span", { className: "whitespace-nowrap", children: [tokens(ctx), " ", _jsxs("span", { className: "text-muted-foreground", children: ["/ ", tokens(CONTEXT_MAX)] })] })] }), _jsx("div", { className: "h-1 overflow-hidden rounded-full bg-muted-foreground/20", children: _jsx("div", { className: cn('h-full rounded-full', level === 'alert'
|
|
989
|
+
? 'bg-destructive'
|
|
990
|
+
: level === 'watch'
|
|
991
|
+
? 'bg-warning'
|
|
992
|
+
: 'bg-foreground/60'), style: { width: `${Math.max(2, share * 100)}%` } }) }), _jsx("span", { className: "text-muted-foreground", children: "Auto-compacts as needed" }), _jsx("div", { className: "my-0.5 border-t" }), rows.map(([k, v]) => (_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "truncate text-muted-foreground", children: k }), _jsx("span", { className: "whitespace-nowrap", children: v })] }, k)))] })] }));
|
|
956
993
|
}
|
|
957
994
|
/**
|
|
958
995
|
* The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { SettingsSectionDef } from '../settings.js';
|
|
2
2
|
import type { AssistantAdapter } from './client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Settings: the context size past which a chat is summarized out of sight (Auto-compact), the
|
|
5
|
+
* person's own; the app's default until they pick. An app that shows only some of the kit's
|
|
6
|
+
* sections puts this row in its own.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AutoCompactField({ client }: {
|
|
9
|
+
client: AssistantAdapter;
|
|
10
|
+
}): import("react").JSX.Element | null;
|
|
3
11
|
/**
|
|
4
12
|
* The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
|
|
5
13
|
* where the app keeps its admin settings, shown only to admins.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { COMPACT_AT, COMPACT_CHOICES } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
3
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
4
|
import { ChartColumnIcon, CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, PlugIcon, ReceiptIcon, SendIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
|
|
4
5
|
import { useId, useState } from 'react';
|
|
@@ -18,7 +19,7 @@ import { Label } from '../ui/label.js';
|
|
|
18
19
|
import { Switch } from '../ui/switch.js';
|
|
19
20
|
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
20
21
|
import { allowanceAmount, assistantStatusKey, usd, useAssistantStatus } from './lumen-assistant.js';
|
|
21
|
-
import { AssistantUsageTable, OwnKeyUsage, UsageHistoryView } from './usage-history.js';
|
|
22
|
+
import { AssistantUsageTable, OwnKeyUsage, UsageHistoryView, tokens } from './usage-history.js';
|
|
22
23
|
const EFFORTS = [
|
|
23
24
|
{ value: 'default', label: 'Model default' },
|
|
24
25
|
{ value: 'low', label: 'Low' },
|
|
@@ -84,12 +85,30 @@ function General({ client }) {
|
|
|
84
85
|
value: t.id,
|
|
85
86
|
label: t.label,
|
|
86
87
|
})),
|
|
87
|
-
], onChange: (advice) => void set({ advice }) }) })), _jsx(SettingField, { label: "Screen", info: "Takes you to a page, a Settings row, a chart or a card when you ask. Each move has an Undo; nothing is saved.", children: _jsx(LumenSelector, { label: "Screen", size: "sm", className: "w-44", value: s.screen ?? 'move', options: [
|
|
88
|
+
], onChange: (advice) => void set({ advice }) }) })), _jsx(AutoCompactField, { client: client }), _jsx(SettingField, { label: "Screen", info: "Takes you to a page, a Settings row, a chart or a card when you ask. Each move has an Undo; nothing is saved.", children: _jsx(LumenSelector, { label: "Screen", size: "sm", className: "w-44", value: s.screen ?? 'move', options: [
|
|
88
89
|
{ value: 'move', label: 'Move' },
|
|
89
90
|
{ value: 'off', label: 'Off' },
|
|
90
91
|
], onChange: (screen) => void set({ screen: screen }) }) })] }));
|
|
91
92
|
} }));
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Settings: the context size past which a chat is summarized out of sight (Auto-compact), the
|
|
96
|
+
* person's own; the app's default until they pick. An app that shows only some of the kit's
|
|
97
|
+
* sections puts this row in its own.
|
|
98
|
+
*/
|
|
99
|
+
export function AutoCompactField({ client }) {
|
|
100
|
+
const { apply } = useApply(client);
|
|
101
|
+
const s = useAssistantStatus(client).data;
|
|
102
|
+
if (!s)
|
|
103
|
+
return null;
|
|
104
|
+
const now = s.compact_at ?? COMPACT_AT;
|
|
105
|
+
const choices = [...COMPACT_CHOICES];
|
|
106
|
+
if (!choices.includes(now))
|
|
107
|
+
choices.push(now);
|
|
108
|
+
return (_jsx(SettingField, { label: "Auto-compact", info: "Past this much context, the chat's older part is summarized out of sight, so long chats stay quick.", children: _jsx(LumenSelector, { label: "Auto-compact", size: "sm", className: "w-44", value: String(now), options: choices
|
|
109
|
+
.sort((a, b) => a - b)
|
|
110
|
+
.map((n) => ({ value: String(n), label: `At ${tokens(n)}` })), onChange: (v) => void apply('settings', () => client.settings({ compact_at: Number(v) })) }) }));
|
|
111
|
+
}
|
|
93
112
|
// ---------------------------------------------------------------- model providers
|
|
94
113
|
/** A dollar cap a month, saved on Enter or leaving the field. */
|
|
95
114
|
function CapInput({ label, value, onSave, }) {
|
package/dist/index.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { NotificationsSettings } from './notifications.js';
|
|
|
79
79
|
export { type TasksConfig, TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
|
|
80
80
|
export { type McpServer, McpSettings } from './mcp-settings.js';
|
|
81
81
|
export { lumenToast } from './lumen-toast.js';
|
|
82
|
+
export { useVoiceInput, VoiceButton, type VoiceButtonProps, type VoiceInputOptions, voiceSupported, } from './voice-input.js';
|
|
82
83
|
export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
|
|
83
84
|
export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
|
|
84
85
|
export { FETCH_MS, fetchWithin } from './fetch-within.js';
|
package/dist/index.js
CHANGED
|
@@ -86,6 +86,7 @@ export { NotificationsSettings } from './notifications.js';
|
|
|
86
86
|
export { TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
|
|
87
87
|
export { McpSettings } from './mcp-settings.js';
|
|
88
88
|
export { lumenToast } from './lumen-toast.js';
|
|
89
|
+
export { useVoiceInput, VoiceButton, voiceSupported, } from './voice-input.js';
|
|
89
90
|
export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
|
|
90
91
|
export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
|
|
91
92
|
export { FETCH_MS, fetchWithin } from './fetch-within.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** True where the browser can turn speech into text. */
|
|
2
|
+
export declare const voiceSupported: () => boolean;
|
|
3
|
+
export type VoiceInputOptions = {
|
|
4
|
+
/** The box's text now. Speech joins the end of it. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** Gets the box's whole new text: what was there plus what was said, the words still being heard included. */
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
/** Recognition language as a BCP 47 tag; the browser's language by default. */
|
|
9
|
+
lang?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Speech to text into a text box: `listening`, `supported` and `toggle()`. While listening, each word
|
|
13
|
+
* heard goes into the box as `onChange(text)` (live, then settled). If the person types while it
|
|
14
|
+
* listens, what they typed is kept and later speech joins after it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useVoiceInput({ value, onChange, lang }: VoiceInputOptions): {
|
|
17
|
+
supported: boolean;
|
|
18
|
+
listening: boolean;
|
|
19
|
+
start: () => void;
|
|
20
|
+
stop: () => void | undefined;
|
|
21
|
+
toggle: () => void;
|
|
22
|
+
};
|
|
23
|
+
export type VoiceButtonProps = VoiceInputOptions & {
|
|
24
|
+
/** `icon-xs` beside a composer's other tools; `icon-sm` by default. */
|
|
25
|
+
size?: 'icon-xs' | 'icon-sm' | 'icon';
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The mic beside a text box: tap to talk, tap again to stop. While it listens the icon turns into a
|
|
31
|
+
* red dot and the words appear in the box as they are heard. Draws nothing where the browser has no
|
|
32
|
+
* speech to text.
|
|
33
|
+
*/
|
|
34
|
+
export declare function VoiceButton({ size, disabled, className, ...options }: VoiceButtonProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// VoiceButton: talk instead of type. A mic beside any text box that writes what the person says into
|
|
3
|
+
// it as they speak, through the browser's own speech recognition (Web Speech API): no server of ours,
|
|
4
|
+
// no cost. Tap to start, tap to stop; the words join what is already in the box, never replace it.
|
|
5
|
+
// Where the browser has no speech recognition (Firefox) the button is not drawn.
|
|
6
|
+
import { MicIcon } from 'lucide-react';
|
|
7
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
8
|
+
import { IconButton } from './icon-button.js';
|
|
9
|
+
import { cn } from './lib/utils.js';
|
|
10
|
+
import { lumenToast } from './lumen-toast.js';
|
|
11
|
+
const recognitionClass = () => {
|
|
12
|
+
if (typeof window === 'undefined')
|
|
13
|
+
return undefined;
|
|
14
|
+
const w = window;
|
|
15
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
|
|
16
|
+
};
|
|
17
|
+
/** True where the browser can turn speech into text. */
|
|
18
|
+
export const voiceSupported = () => !!recognitionClass();
|
|
19
|
+
/** What each recognition error means, in plain words. Errors left out (`aborted`, `no-speech`) say nothing. */
|
|
20
|
+
const ERRORS = {
|
|
21
|
+
'not-allowed': "The microphone is blocked. Allow it for this site in your browser's settings, then tap the mic again.",
|
|
22
|
+
'service-not-allowed': 'This browser does not allow speech to text here. Try Chrome, Edge or Safari, or type instead.',
|
|
23
|
+
'audio-capture': 'No microphone was found. Connect one, then tap the mic again.',
|
|
24
|
+
network: 'Speech to text needs an internet connection. Check it, then tap the mic again.',
|
|
25
|
+
'language-not-supported': "Speech to text doesn't support your browser's language yet.",
|
|
26
|
+
};
|
|
27
|
+
/** `base` and `spoken` joined by one space, unless either side already has it. */
|
|
28
|
+
const join = (base, spoken) => {
|
|
29
|
+
const s = spoken.trim();
|
|
30
|
+
if (!s)
|
|
31
|
+
return base;
|
|
32
|
+
if (!base || /\s$/.test(base))
|
|
33
|
+
return base + s;
|
|
34
|
+
return `${base} ${s}`;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Speech to text into a text box: `listening`, `supported` and `toggle()`. While listening, each word
|
|
38
|
+
* heard goes into the box as `onChange(text)` (live, then settled). If the person types while it
|
|
39
|
+
* listens, what they typed is kept and later speech joins after it.
|
|
40
|
+
*/
|
|
41
|
+
export function useVoiceInput({ value, onChange, lang }) {
|
|
42
|
+
const supported = useSyncExternalStore(noop, voiceSupported, () => false);
|
|
43
|
+
const [listening, setListening] = useState(false);
|
|
44
|
+
const rec = useRef(null);
|
|
45
|
+
// Latest props, read from the recognition's callbacks.
|
|
46
|
+
const latest = useRef({ value, onChange });
|
|
47
|
+
latest.current = { value, onChange };
|
|
48
|
+
// The text before this stretch of speech, the first result that belongs to it, and what was last written.
|
|
49
|
+
const base = useRef('');
|
|
50
|
+
const from = useRef(0);
|
|
51
|
+
// Every text written this stretch: the box may not have re-rendered with the last one yet.
|
|
52
|
+
const wrote = useRef(new Set());
|
|
53
|
+
const stop = useCallback(() => rec.current?.stop(), []);
|
|
54
|
+
// Leaving the page (or the box) ends listening, so the mic is never left on.
|
|
55
|
+
useEffect(() => () => rec.current?.abort(), []);
|
|
56
|
+
const start = useCallback(() => {
|
|
57
|
+
const Class = recognitionClass();
|
|
58
|
+
if (!Class || rec.current)
|
|
59
|
+
return;
|
|
60
|
+
const r = new Class();
|
|
61
|
+
r.lang = lang ?? (typeof navigator !== 'undefined' ? navigator.language : '') ?? '';
|
|
62
|
+
r.continuous = true;
|
|
63
|
+
r.interimResults = true;
|
|
64
|
+
base.current = latest.current.value;
|
|
65
|
+
from.current = 0;
|
|
66
|
+
wrote.current.clear();
|
|
67
|
+
r.onresult = (e) => {
|
|
68
|
+
const now = latest.current.value;
|
|
69
|
+
// Typed into the box since the last words: keep that text and start the next words after it.
|
|
70
|
+
if (wrote.current.size && !wrote.current.has(now)) {
|
|
71
|
+
base.current = now;
|
|
72
|
+
from.current = e.resultIndex;
|
|
73
|
+
wrote.current.clear();
|
|
74
|
+
}
|
|
75
|
+
let spoken = '';
|
|
76
|
+
for (let i = from.current; i < e.results.length; i++) {
|
|
77
|
+
spoken += ` ${e.results[i]?.[0]?.transcript ?? ''}`;
|
|
78
|
+
}
|
|
79
|
+
const next = join(base.current, spoken.replace(/\s+/g, ' '));
|
|
80
|
+
wrote.current.add(next);
|
|
81
|
+
if (next !== now)
|
|
82
|
+
latest.current.onChange(next);
|
|
83
|
+
};
|
|
84
|
+
r.onerror = (e) => {
|
|
85
|
+
const message = ERRORS[e.error];
|
|
86
|
+
if (message)
|
|
87
|
+
lumenToast.error(message, { id: 'lumen-voice' });
|
|
88
|
+
};
|
|
89
|
+
r.onend = () => {
|
|
90
|
+
rec.current = null;
|
|
91
|
+
setListening(false);
|
|
92
|
+
};
|
|
93
|
+
rec.current = r;
|
|
94
|
+
try {
|
|
95
|
+
r.start();
|
|
96
|
+
setListening(true);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
rec.current = null;
|
|
100
|
+
lumenToast.error('Speech to text could not start. Try again.', { id: 'lumen-voice' });
|
|
101
|
+
}
|
|
102
|
+
}, [lang]);
|
|
103
|
+
const toggle = useCallback(() => (rec.current ? stop() : start()), [start, stop]);
|
|
104
|
+
return { supported, listening, start, stop, toggle };
|
|
105
|
+
}
|
|
106
|
+
const noop = () => () => { };
|
|
107
|
+
/**
|
|
108
|
+
* The mic beside a text box: tap to talk, tap again to stop. While it listens the icon turns into a
|
|
109
|
+
* red dot and the words appear in the box as they are heard. Draws nothing where the browser has no
|
|
110
|
+
* speech to text.
|
|
111
|
+
*/
|
|
112
|
+
export function VoiceButton({ size = 'icon-sm', disabled, className, ...options }) {
|
|
113
|
+
const { supported, listening, toggle, stop } = useVoiceInput(options);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (disabled && listening)
|
|
116
|
+
stop();
|
|
117
|
+
}, [disabled, listening, stop]);
|
|
118
|
+
if (!supported)
|
|
119
|
+
return null;
|
|
120
|
+
return (_jsx(IconButton, { icon: listening ? ListeningDot : MicIcon, label: listening ? 'Stop listening' : 'Talk to type', size: size, variant: "ghost",
|
|
121
|
+
// Inside a form it must not send it.
|
|
122
|
+
type: "button", "aria-pressed": listening, "data-voice": listening ? 'listening' : 'idle', disabled: disabled, className: cn(listening && 'text-destructive', className), onClick: toggle }));
|
|
123
|
+
}
|
|
124
|
+
/** A red dot that pulses while the mic listens. */
|
|
125
|
+
function ListeningDot({ className }) {
|
|
126
|
+
return (_jsx("span", { className: cn('grid size-4 place-items-center', className), "aria-hidden": true, children: _jsx("span", { className: "size-2.5 animate-pulse rounded-full bg-destructive" }) }));
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.283",
|
|
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.283",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|