@columenlabs/lumen-ui 0.1.282 → 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.
@@ -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`). */
@@ -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';
@@ -580,7 +581,7 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
580
581
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
581
582
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
582
583
  */
583
- 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, }) {
584
585
  const [text, setText] = useState('');
585
586
  const [caret, setCaret] = useState(0);
586
587
  const [closed, setClosed] = useState(null);
@@ -903,7 +904,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
903
904
  if (e.target.files)
904
905
  attach(e.target.files);
905
906
  e.target.value = '';
906
- } })] })), 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, _jsx("div", { className: "ml-auto", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : busy ? (_jsxs("span", { className: "flex gap-1", children: [_jsx(IconButton, { icon: ListPlusIcon, label: canSteer ? 'Queue for after (⌥↩)' : 'Queue for after (↩)', variant: canSteer ? 'outline' : 'default', size: "icon-xs", disabled: disabled, onClick: () => send('queue') }), canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now (\u21A9)", variant: "default", size: "icon-xs", disabled: disabled, onClick: () => send('steer') }))] })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: "Send", variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: () => send() })) })] })] })] }));
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() }))] })] })] })] }));
907
908
  }
908
909
  /**
909
910
  * What waits above the box while an answer runs: steering messages until the answer reads them,
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.282",
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.282",
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",