@columenlabs/lumen-ui 0.1.77 → 0.1.79
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 +42 -0
- package/dist/assistant/client.js +81 -0
- package/dist/assistant/index.d.ts +5 -0
- package/dist/assistant/index.js +8 -0
- package/dist/assistant/lumen-assistant.d.ts +84 -0
- package/dist/assistant/lumen-assistant.js +518 -0
- package/dist/assistant/settings.d.ts +13 -0
- package/dist/assistant/settings.js +322 -0
- package/dist/assistant/sources.d.ts +54 -0
- package/dist/assistant/sources.js +114 -0
- package/dist/assistant/use-assistant-chat.d.ts +28 -0
- package/dist/assistant/use-assistant-chat.js +240 -0
- package/dist/expand-list.d.ts +21 -0
- package/dist/expand-list.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integrations.d.ts +3 -9
- package/dist/integrations.js +3 -4
- package/package.json +6 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CheckIcon, ExternalLinkIcon, GlobeIcon, KeyRoundIcon, LayersIcon, ReceiptIcon, SparklesIcon, Trash2Icon, WrenchIcon, } from 'lucide-react';
|
|
4
|
+
import { useId, useState } from 'react';
|
|
5
|
+
import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
|
|
6
|
+
import { SaveButton } from '../form-issues.js';
|
|
7
|
+
import { IconButton } from '../icon-button.js';
|
|
8
|
+
import { InfoLabel } from '../info-tip.js';
|
|
9
|
+
import { KeyTestLine, TestBadge, errorText } from '../integrations.js';
|
|
10
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
11
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
12
|
+
import { Badge } from '../ui/badge.js';
|
|
13
|
+
import { Input } from '../ui/input.js';
|
|
14
|
+
import { Label } from '../ui/label.js';
|
|
15
|
+
import { Switch } from '../ui/switch.js';
|
|
16
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
17
|
+
import { assistantStatusKey, usd, useAssistantStatus } from './lumen-assistant.js';
|
|
18
|
+
const EFFORTS = [
|
|
19
|
+
{ value: 'default', label: 'Model default' },
|
|
20
|
+
{ value: 'low', label: 'Low' },
|
|
21
|
+
{ value: 'medium', label: 'Medium' },
|
|
22
|
+
{ value: 'high', label: 'High' },
|
|
23
|
+
{ value: 'max', label: 'Max' },
|
|
24
|
+
];
|
|
25
|
+
const HAND_OFF = [
|
|
26
|
+
{ value: 'next', label: 'Next tier up' },
|
|
27
|
+
{ value: 'any', label: 'Any higher tier' },
|
|
28
|
+
{ value: 'ask', label: 'Only when I ask' },
|
|
29
|
+
];
|
|
30
|
+
const ACCESS = [
|
|
31
|
+
{ value: 'off', label: 'Off' },
|
|
32
|
+
{ value: 'read', label: 'Read' },
|
|
33
|
+
{ value: 'draft', label: 'Read + draft' },
|
|
34
|
+
{ value: 'write', label: 'Read + write' },
|
|
35
|
+
];
|
|
36
|
+
/** Runs a change and keeps the status it returns, or says why it failed. */
|
|
37
|
+
function useApply(client) {
|
|
38
|
+
const qc = useQueryClient();
|
|
39
|
+
const [busy, setBusy] = useState(null);
|
|
40
|
+
const apply = async (what, run, done) => {
|
|
41
|
+
setBusy(what);
|
|
42
|
+
try {
|
|
43
|
+
qc.setQueryData(assistantStatusKey(client), await run());
|
|
44
|
+
if (done)
|
|
45
|
+
lumenToast.success(done);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
lumenToast.error(errorText(e));
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
setBusy(null);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const refresh = () => qc.invalidateQueries({ queryKey: assistantStatusKey(client) });
|
|
57
|
+
return { apply, busy, refresh };
|
|
58
|
+
}
|
|
59
|
+
/** The section's body once the status loads. */
|
|
60
|
+
function WithStatus({ client, children, }) {
|
|
61
|
+
const q = useAssistantStatus(client);
|
|
62
|
+
if (!q.data)
|
|
63
|
+
return (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isError ? errorText(q.error) : 'Loading…' }));
|
|
64
|
+
return _jsx(_Fragment, { children: children(q.data) });
|
|
65
|
+
}
|
|
66
|
+
/** One setting: its label (with an (i) when `info` says more) and its control. */
|
|
67
|
+
function Row({ label, info, children }) {
|
|
68
|
+
return (_jsxs("div", { "data-slot": "setting-row", className: "flex flex-wrap items-center gap-x-4 gap-y-2 border-b py-3 last:border-b-0", children: [_jsx(Label, { className: "min-w-0 flex-1", children: _jsx(InfoLabel, { info: info, children: label }) }), _jsx("div", { className: "shrink-0", children: children })] }));
|
|
69
|
+
}
|
|
70
|
+
const testOf = (k) => k.test
|
|
71
|
+
? {
|
|
72
|
+
at: new Date(k.test.at).toISOString(),
|
|
73
|
+
ok: k.test.ok,
|
|
74
|
+
detail: k.test.detail,
|
|
75
|
+
error: k.test.ok ? null : k.test.detail,
|
|
76
|
+
}
|
|
77
|
+
: null;
|
|
78
|
+
// ---------------------------------------------------------------- the assistant
|
|
79
|
+
function General({ client }) {
|
|
80
|
+
const { apply } = useApply(client);
|
|
81
|
+
const set = (patch) => apply('settings', () => client.settings(patch));
|
|
82
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
83
|
+
const above = s.tiers.slice(s.tiers.findIndex((t) => t.id === s.tier) + 1);
|
|
84
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsx(Row, { label: "Tier", children: _jsx(LumenSelector, { label: "Tier", size: "sm", className: "w-44", value: s.tier, options: s.tiers.map((t) => ({
|
|
85
|
+
value: t.id,
|
|
86
|
+
label: t.label,
|
|
87
|
+
detail: t.model_label,
|
|
88
|
+
})), onChange: (tier) => void set({ tier }) }) }), s.tiers.length > 1 && (_jsx(Row, { label: "Hand off the work", info: "On its own, a higher tier takes one task in its own section of the chat and sends back a memo. It costs more.", children: _jsx(LumenSelector, { label: "Hand off the work", size: "sm", className: "w-44", value: s.hand_off, options: HAND_OFF, onChange: (hand_off) => void set({ hand_off }) }) })), s.tiers.length > 1 && (_jsx(Row, { label: "Ask for advice", info: "A bigger tier reads the chat and sends a plan before hard questions. It runs no tools, at most twice an answer.", children: _jsx(LumenSelector, { label: "Ask for advice", size: "sm", className: "w-44", value: s.advice, options: [
|
|
89
|
+
{ value: 'off', label: 'Off' },
|
|
90
|
+
...(above.length ? above : s.tiers).map((t) => ({
|
|
91
|
+
value: t.id,
|
|
92
|
+
label: t.label,
|
|
93
|
+
})),
|
|
94
|
+
], onChange: (advice) => void set({ advice }) }) }))] }));
|
|
95
|
+
} }));
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------- model providers
|
|
98
|
+
/** Paste a key, Save tests it first; then its hint, Test and Remove. */
|
|
99
|
+
function KeyBlock({ label, info, prefix, provider, state, canEdit, onSave, onRemove, onTest, keysUrl, }) {
|
|
100
|
+
const id = useId();
|
|
101
|
+
const [key, setKey] = useState('');
|
|
102
|
+
const [replacing, setReplacing] = useState(false);
|
|
103
|
+
const [busy, setBusy] = useState(false);
|
|
104
|
+
const editing = canEdit && (!state.set || replacing);
|
|
105
|
+
const issues = [];
|
|
106
|
+
if (editing && key.trim() && !key.trim().startsWith(prefix))
|
|
107
|
+
issues.push({ where: label, message: `An ${provider} key starts with ${prefix}`, target: id });
|
|
108
|
+
const save = async () => {
|
|
109
|
+
setBusy(true);
|
|
110
|
+
if (await onSave(key.trim())) {
|
|
111
|
+
setKey('');
|
|
112
|
+
setReplacing(false);
|
|
113
|
+
}
|
|
114
|
+
setBusy(false);
|
|
115
|
+
};
|
|
116
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoLabel, { className: "flex-1 font-medium text-sm", info: info, children: label }), state.set ? (state.hint ? (_jsx("span", { className: "font-code text-muted-foreground text-xs", children: state.hint })) : (_jsx(Badge, { variant: "secondary", children: "Set" }))) : state.from_env ? (_jsx(Badge, { variant: "secondary", children: "From the server" })) : (_jsx(Badge, { variant: "outline", children: "Not set" })), state.set && state.test && _jsx(TestBadge, { test: state.test })] }), editing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, type: "password", autoComplete: "off", placeholder: `${prefix}…`, className: "font-code text-xs", value: key, onChange: (e) => setKey(e.target.value) }), keysUrl && (_jsx(IconButton, { icon: ExternalLinkIcon, label: `Get an ${provider} key`, href: keysUrl, variant: "outline" })), replacing && (_jsx(IconButton, { icon: Trash2Icon, label: "Keep the saved key", onClick: () => setReplacing(false) })), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !key.trim(), label: "Test and save", onSave: save })] })) : (state.set &&
|
|
117
|
+
canEdit && (_jsxs(_Fragment, { children: [_jsx(KeyTestLine, { test: testOf(state), run: onTest }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: `Remove ${label.toLowerCase()}`, variant: "outline", onClick: () => void onRemove() }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Replace", onSave: () => setReplacing(true) })] })] })))] }));
|
|
118
|
+
}
|
|
119
|
+
/** A dollar cap a month, saved on Enter or leaving the field. */
|
|
120
|
+
function CapInput({ label, value, onSave, }) {
|
|
121
|
+
const [text, setText] = useState(String(value));
|
|
122
|
+
const commit = () => {
|
|
123
|
+
const n = Number(text);
|
|
124
|
+
if (Number.isFinite(n) && n >= 0 && n !== value)
|
|
125
|
+
onSave(Math.round(n * 100) / 100);
|
|
126
|
+
else
|
|
127
|
+
setText(String(value));
|
|
128
|
+
};
|
|
129
|
+
return (_jsxs("div", { className: "flex items-center gap-1 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "$" }), _jsx(Input, { "aria-label": label, inputMode: "decimal", className: "h-8 w-20 text-right tabular-nums", value: text, onChange: (e) => setText(e.target.value), onBlur: commit, onKeyDown: (e) => e.key === 'Enter' && commit() }), _jsx("span", { className: "text-muted-foreground", children: "/ month" })] }));
|
|
130
|
+
}
|
|
131
|
+
/** A person's ChatGPT plan sign-in: opens in a new tab, finished by pasting back where it ended. */
|
|
132
|
+
function ChatgptSignin({ client, p }) {
|
|
133
|
+
const { apply, refresh } = useApply(client);
|
|
134
|
+
const id = useId();
|
|
135
|
+
const [started, setStarted] = useState(null);
|
|
136
|
+
const [pasted, setPasted] = useState('');
|
|
137
|
+
const [busy, setBusy] = useState(false);
|
|
138
|
+
const signedIn = p.own.via === 'chatgpt';
|
|
139
|
+
const start = async () => {
|
|
140
|
+
// Open the tab inside the click, or the browser blocks it; point it once the address is known.
|
|
141
|
+
const tab = window.open('about:blank', '_blank');
|
|
142
|
+
if (tab)
|
|
143
|
+
tab.opener = null;
|
|
144
|
+
try {
|
|
145
|
+
const { url, redirect } = await client.startSignin(p.id);
|
|
146
|
+
if (tab)
|
|
147
|
+
tab.location.href = url;
|
|
148
|
+
else
|
|
149
|
+
window.open(url, '_blank', 'noopener');
|
|
150
|
+
setStarted(redirect);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
tab?.close();
|
|
154
|
+
lumenToast.error(errorText(e));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const finish = async () => {
|
|
158
|
+
setBusy(true);
|
|
159
|
+
if (await apply(`signin:${p.id}`, () => client.finishSignin(p.id, pasted.trim()), 'Signed in')) {
|
|
160
|
+
setPasted('');
|
|
161
|
+
setStarted(null);
|
|
162
|
+
}
|
|
163
|
+
setBusy(false);
|
|
164
|
+
};
|
|
165
|
+
const issues = [];
|
|
166
|
+
if (pasted.trim() && started && !pasted.trim().startsWith(started.split('/auth')[0] ?? ''))
|
|
167
|
+
issues.push({ where: 'Address', message: `It starts with ${started}`, target: id });
|
|
168
|
+
if (signedIn)
|
|
169
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex-1 font-medium text-sm", children: "Your ChatGPT plan" }), _jsx("span", { className: "text-muted-foreground text-xs", children: p.own.hint }), p.own.test && _jsx(TestBadge, { test: p.own.test })] }), _jsx(KeyTestLine, { test: testOf(p.own), run: () => client.testOwnKey(p.id).finally(refresh) }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: "Sign out of ChatGPT", variant: "outline", onClick: () => void apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Signed out') }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Sign in again", onSave: start })] }), started && (_jsx(SigninPaste, { id: id, redirect: started, pasted: pasted, setPasted: setPasted, issues: issues, busy: busy, finish: finish }))] }));
|
|
170
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoLabel, { className: "flex-1 font-medium text-sm", info: "Run OpenAI models on your own ChatGPT plan instead of an API key.", children: "Your ChatGPT plan" }), _jsx(Badge, { variant: "outline", children: "Not signed in" })] }), _jsx("div", { className: "flex justify-end", children: _jsx(SaveButton, { issues: [], icon: ExternalLinkIcon, label: started ? 'Open the sign-in again' : 'Sign in with ChatGPT', onSave: start }) }), started && (_jsx(SigninPaste, { id: id, redirect: started, pasted: pasted, setPasted: setPasted, issues: issues, busy: busy, finish: finish }))] }));
|
|
171
|
+
}
|
|
172
|
+
/** Where the sign-in tab ends, and the box its address is pasted into. */
|
|
173
|
+
function SigninPaste({ id, redirect, pasted, setPasted, issues, busy, finish, }) {
|
|
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 })] })] }));
|
|
175
|
+
}
|
|
176
|
+
/** One provider, collapsed to a row: where to make a key (a new tab), the app's key and the person's. */
|
|
177
|
+
function ProviderKeys({ client, p, admin, }) {
|
|
178
|
+
const { apply, refresh } = useApply(client);
|
|
179
|
+
const appOn = p.app.set || !!p.app.from_env;
|
|
180
|
+
const plan = p.own.via === 'chatgpt';
|
|
181
|
+
const meta = [
|
|
182
|
+
appOn ? 'Shared key set' : 'No shared key',
|
|
183
|
+
plan ? 'your ChatGPT plan' : p.own.set ? 'your own key' : null,
|
|
184
|
+
]
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.join(' · ');
|
|
187
|
+
const failed = [p.app, p.own].some((k) => k.set && k.test && !k.test.ok);
|
|
188
|
+
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" }));
|
|
189
|
+
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) }));
|
|
190
|
+
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] }) }));
|
|
191
|
+
}
|
|
192
|
+
function Providers({ client }) {
|
|
193
|
+
const { apply } = useApply(client);
|
|
194
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
195
|
+
const ownSet = s.providers.some((p) => p.own.set);
|
|
196
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(ProviderKeys, { client: client, p: p, admin: s.admin }, p.id))) }), s.admin && (_jsx(Row, { label: "Each person may spend", info: "On the shared keys, every provider together.", children: _jsx(CapInput, { label: "Cap per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })), ownSet && (_jsx(Row, { label: "Your keys may spend", info: "On your own keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) })), _jsx(Row, { label: "Pay with first", info: "When it reaches its cap this month, or has no key for the tier's provider, the other pays.", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: s.pay, "aria-label": "Pay with", onValueChange: (v) => v && void apply('pay', () => client.settings({ pay: v })), children: [_jsx(ToggleGroupItem, { value: "app", children: "Shared keys" }), _jsx(ToggleGroupItem, { value: "own", disabled: !ownSet, children: "Your keys" })] }) })] }));
|
|
197
|
+
} }));
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------- models
|
|
200
|
+
function Models({ client }) {
|
|
201
|
+
const { apply } = useApply(client);
|
|
202
|
+
const set = (patch) => void apply('settings', () => client.settings(patch));
|
|
203
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
204
|
+
const models = s.models.map((m) => ({ value: m.id, label: m.label, group: m.group }));
|
|
205
|
+
const tiers = s.tiers.map((t) => ({ value: t.id, label: t.label }));
|
|
206
|
+
return (_jsxs("div", { className: "flex flex-col", children: [s.tiers.map((t) => {
|
|
207
|
+
const p = s.providers.find((x) => x.id === t.provider);
|
|
208
|
+
const keyless = p && !p.app.set && !p.app.from_env && !p.own.set;
|
|
209
|
+
return (_jsx(Row, { label: t.label, info: t.about, children: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [keyless && _jsxs(Badge, { variant: "outline", children: ["No ", p.label, " key"] }), _jsx(LumenSelector, { label: `${t.label} model`, size: "sm", className: "w-36 sm:w-44", value: t.model, options: models, onChange: (model) => set({ tiers: { [t.id]: { model } } }) }), _jsx(LumenSelector, { label: `${t.label} effort`, size: "sm", className: "w-24 sm:w-32", value: t.effort, options: EFFORTS, onChange: (effort) => set({ tiers: { [t.id]: { effort } } }) })] }) }, t.id));
|
|
210
|
+
}), s.tasks.length > 1 &&
|
|
211
|
+
s.tasks.map((task) => (_jsx(Row, { label: `${task.label} starts on`, children: _jsx(LumenSelector, { label: `${task.label} starts on`, size: "sm", className: "w-44", value: task.tier, options: tiers, onChange: (tier) => set({ tasks: { [task.id]: tier } }) }) }, task.id)))] }));
|
|
212
|
+
} }));
|
|
213
|
+
}
|
|
214
|
+
// ---------------------------------------------------------------- tools
|
|
215
|
+
function Tools({ client }) {
|
|
216
|
+
const { apply } = useApply(client);
|
|
217
|
+
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 &&
|
|
218
|
+
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." })) }));
|
|
219
|
+
}
|
|
220
|
+
// ---------------------------------------------------------------- web
|
|
221
|
+
function ScrapeqlBlock({ client, s }) {
|
|
222
|
+
const { apply, refresh } = useApply(client);
|
|
223
|
+
const ids = useId();
|
|
224
|
+
const [id, setId] = useState('');
|
|
225
|
+
const [secret, setSecret] = useState('');
|
|
226
|
+
const [replacing, setReplacing] = useState(false);
|
|
227
|
+
const [busy, setBusy] = useState(false);
|
|
228
|
+
const state = s.web.scrapeql;
|
|
229
|
+
if (!state)
|
|
230
|
+
return null;
|
|
231
|
+
const editing = s.admin && (!state.set || replacing);
|
|
232
|
+
const issues = [];
|
|
233
|
+
if (editing && id && !secret)
|
|
234
|
+
issues.push({
|
|
235
|
+
where: 'Client Secret',
|
|
236
|
+
message: 'Paste the secret too',
|
|
237
|
+
target: `${ids}-secret`,
|
|
238
|
+
});
|
|
239
|
+
const save = async () => {
|
|
240
|
+
setBusy(true);
|
|
241
|
+
if (await apply('scrapeql', () => client.scrapeql({ client_id: id.trim(), client_secret: secret.trim() }), 'Token saved')) {
|
|
242
|
+
setId('');
|
|
243
|
+
setSecret('');
|
|
244
|
+
setReplacing(false);
|
|
245
|
+
}
|
|
246
|
+
setBusy(false);
|
|
247
|
+
};
|
|
248
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex-1 font-medium text-sm", children: _jsx(InfoLabel, { info: "Search and pages that need a real browser. Paste a Cloudflare Access service token that ScrapeQL lets in.", children: "ScrapeQL" }) }), state.set ? (state.hint ? (_jsx("span", { className: "font-code text-muted-foreground text-xs", children: state.hint })) : (_jsx(Badge, { variant: "secondary", children: "Set" }))) : (_jsx(Badge, { variant: "outline", children: "Not set" })), state.set && state.test && _jsx(TestBadge, { test: state.test })] }), editing ? (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx(Input, { id: `${ids}-id`, "aria-label": "Client ID", placeholder: "Client ID", autoComplete: "off", className: "font-code text-xs", value: id, onChange: (e) => setId(e.target.value) }), _jsx(Input, { id: `${ids}-secret`, "aria-label": "Client Secret", placeholder: "Client Secret", type: "password", autoComplete: "off", className: "font-code text-xs", value: secret, onChange: (e) => setSecret(e.target.value) }), _jsxs("div", { className: "flex justify-end gap-2", children: [replacing && (_jsx(IconButton, { icon: Trash2Icon, label: "Keep the saved token", onClick: () => setReplacing(false) })), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !id.trim() || !secret.trim(), label: "Test and save", onSave: save })] })] })) : (state.set &&
|
|
249
|
+
s.admin && (_jsxs(_Fragment, { children: [_jsx(KeyTestLine, { test: testOf(state), run: () => client.testScrapeql().finally(refresh) }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: "Remove the token", variant: "outline", onClick: () => void apply('scrapeql', () => client.scrapeql(null), 'Token removed') }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Replace", onSave: () => setReplacing(true) })] })] })))] }));
|
|
250
|
+
}
|
|
251
|
+
function Web({ client }) {
|
|
252
|
+
const { apply } = useApply(client);
|
|
253
|
+
return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(Row, { label: "Read the web", info: "A plain fetch of public pages and APIs from the server, never the app's own pages.", children: _jsx(Switch, { "aria-label": "Read the web", checked: s.web.fetch, onCheckedChange: (fetch) => void apply('web', () => client.settings({ web: { fetch } })) }) }), _jsx(ScrapeqlBlock, { client: client, s: s })] })) }));
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------- spend
|
|
256
|
+
function Spend({ client }) {
|
|
257
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
258
|
+
const sp = s.spend;
|
|
259
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "font-semibold text-2xl tabular-nums", children: usd(sp.usd) }), _jsxs("span", { className: "text-muted-foreground text-sm", children: [sp.month, sp.cap != null &&
|
|
260
|
+
sp.payer &&
|
|
261
|
+
` · ${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." }))] }));
|
|
262
|
+
} }));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
|
|
266
|
+
* Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
|
|
267
|
+
*/
|
|
268
|
+
export function assistantSettingsSections({ client, name, web = true, }) {
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
id: 'assistant',
|
|
272
|
+
title: name,
|
|
273
|
+
icon: SparklesIcon,
|
|
274
|
+
description: 'Which tier answers, and when it asks a bigger one',
|
|
275
|
+
content: _jsx(General, { client: client }),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'assistant-providers',
|
|
279
|
+
title: 'Model providers',
|
|
280
|
+
icon: KeyRoundIcon,
|
|
281
|
+
sub: true,
|
|
282
|
+
description: 'Keys for each provider, and who pays',
|
|
283
|
+
content: _jsx(Providers, { client: client }),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'assistant-models',
|
|
287
|
+
title: 'Models',
|
|
288
|
+
icon: LayersIcon,
|
|
289
|
+
sub: true,
|
|
290
|
+
description: "Each tier's model and effort",
|
|
291
|
+
content: _jsx(Models, { client: client }),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'assistant-tools',
|
|
295
|
+
title: 'Tools',
|
|
296
|
+
icon: WrenchIcon,
|
|
297
|
+
sub: true,
|
|
298
|
+
description: 'What the assistant may read and draft',
|
|
299
|
+
content: _jsx(Tools, { client: client }),
|
|
300
|
+
},
|
|
301
|
+
...(web
|
|
302
|
+
? [
|
|
303
|
+
{
|
|
304
|
+
id: 'assistant-web',
|
|
305
|
+
title: 'Web',
|
|
306
|
+
icon: GlobeIcon,
|
|
307
|
+
sub: true,
|
|
308
|
+
description: 'Reading public pages and search',
|
|
309
|
+
content: _jsx(Web, { client: client }),
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
: []),
|
|
313
|
+
{
|
|
314
|
+
id: 'assistant-spend',
|
|
315
|
+
title: 'Spend',
|
|
316
|
+
icon: ReceiptIcon,
|
|
317
|
+
sub: true,
|
|
318
|
+
description: 'What answers cost this month',
|
|
319
|
+
content: _jsx(Spend, { client: client }),
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
import type { AssistantAdapter } from './client.js';
|
|
3
|
+
/** An item as the panel draws it: a text still streaming, a step still running. */
|
|
4
|
+
export type LiveItem = ChatItem & {
|
|
5
|
+
live?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export interface AssistantChatState {
|
|
8
|
+
chatId: string | null;
|
|
9
|
+
title: string;
|
|
10
|
+
items: LiveItem[];
|
|
11
|
+
usage: ChatUsage | null;
|
|
12
|
+
/** An answer is streaming. */
|
|
13
|
+
busy: boolean;
|
|
14
|
+
/** A message waiting for the answer to end. */
|
|
15
|
+
queued: string | null;
|
|
16
|
+
/** The chat is loading. */
|
|
17
|
+
loading: boolean;
|
|
18
|
+
send: (message: string, extra?: Omit<AssistantMessage, 'message' | 'chat_id'>) => void;
|
|
19
|
+
stop: () => void;
|
|
20
|
+
open: (id: string) => Promise<void>;
|
|
21
|
+
reset: () => void;
|
|
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>;
|
|
25
|
+
}
|
|
26
|
+
/** A copy of the items with one event applied (hand-off sections copied too). */
|
|
27
|
+
export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
|
|
28
|
+
export declare function useAssistantChat(client: AssistantAdapter): AssistantChatState;
|