@columenlabs/lumen-ui 0.1.258 → 0.1.260

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.
@@ -1,4 +1,4 @@
1
- import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem, UsageHistory } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantConnectorInput, AssistantEvent, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem, UsageHistory } 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). */
@@ -31,6 +31,13 @@ export interface AssistantAdapter {
31
31
  client_secret: string;
32
32
  } | null): Promise<AssistantStatus>;
33
33
  testScrapeql(): Promise<Test>;
34
+ /** Admin: adds a connector (an MCP server), kept only when its test lists its tools. */
35
+ addConnector?(input: AssistantConnectorInput): Promise<AssistantStatus>;
36
+ /** Admin: changes one; left out, `headers` keeps the ones saved. */
37
+ saveConnector?(id: string, input: AssistantConnectorInput): Promise<AssistantStatus>;
38
+ removeConnector?(id: string): Promise<AssistantStatus>;
39
+ /** Admin: lists its tools again. */
40
+ testConnector?(id: string): Promise<Test>;
34
41
  chats(): Promise<AssistantChatRow[]>;
35
42
  chat(id: string): Promise<AssistantChat>;
36
43
  removeChat(id: string): Promise<void>;
@@ -55,6 +55,10 @@ export function assistantClient(base) {
55
55
  signOut: (p) => send('DELETE', `/my-signin?provider=${p}`),
56
56
  scrapeql: (token) => token ? send('PUT', '/web/scrapeql', token) : send('DELETE', '/web/scrapeql'),
57
57
  testScrapeql: () => send('POST', '/web/test'),
58
+ addConnector: (input) => send('POST', '/connectors', input),
59
+ saveConnector: (id, input) => send('PUT', `/connectors/${encodeURIComponent(id)}`, input),
60
+ removeConnector: (id) => send('DELETE', `/connectors/${encodeURIComponent(id)}`),
61
+ testConnector: (id) => send('POST', `/connectors/${encodeURIComponent(id)}/test`),
58
62
  chats: () => get('/chats'),
59
63
  chat: (id) => get(`/chats/${encodeURIComponent(id)}`),
60
64
  removeChat: async (id) => {
@@ -2,6 +2,6 @@ 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
4
  export { assistantAdminSections, assistantSettingsSections } from './settings.js';
5
- export { AssistantChatCost, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
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';
@@ -5,6 +5,6 @@ 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
7
  export { assistantAdminSections, assistantSettingsSections } from './settings.js';
8
- export { AssistantChatCost, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
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';
@@ -4,10 +4,12 @@ import type { AssistantAdapter } from './client.js';
4
4
  * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
5
5
  * where the app keeps its admin settings, shown only to admins.
6
6
  */
7
- export declare function assistantAdminSections({ client, allowance, providers, }: {
7
+ export declare function assistantAdminSections({ client, allowance, providers, connectors, }: {
8
8
  client: AssistantAdapter;
9
9
  /** Add Allowance requests (the app set `allowance` on the server). */
10
10
  allowance?: boolean;
11
+ /** Add Connectors: outside MCP servers the assistant may use (the app set `connectors` on the server). */
12
+ connectors?: boolean;
11
13
  /** Give each provider's shared key a section of its own (listed under Allowance) instead of one
12
14
  * Shared model keys section: the app's providers, in order. */
13
15
  providers?: {
@@ -19,7 +21,7 @@ export declare function assistantAdminSections({ client, allowance, providers, }
19
21
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
20
22
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
21
23
  */
22
- export declare function assistantSettingsSections({ client, name, web, allowance, }: {
24
+ export declare function assistantSettingsSections({ client, name, web, allowance, usage, }: {
23
25
  client: AssistantAdapter;
24
26
  /** The assistant's name, the parent section's title. */
25
27
  name: string;
@@ -27,4 +29,7 @@ export declare function assistantSettingsSections({ client, name, web, allowance
27
29
  web?: boolean;
28
30
  /** Show Allowance: what is left on the shared keys, and Request more (`allowance` on the server). */
29
31
  allowance?: boolean;
32
+ /** Show Usage: this month's chats and runs by who paid (key, sign-in or credits); Model
33
+ * providers then leaves out its own spend table. */
34
+ usage?: boolean;
30
35
  }): SettingsSectionDef[];
@@ -1,6 +1,6 @@
1
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 { CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, ReceiptIcon, SendIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
3
+ import { ChartColumnIcon, CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, PlugIcon, ReceiptIcon, SendIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
4
4
  import { useId, useState } from 'react';
5
5
  import { ConnectionLogo, LumenConnection, } from '../connection.js';
6
6
  import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
@@ -18,7 +18,7 @@ import { Label } from '../ui/label.js';
18
18
  import { Switch } from '../ui/switch.js';
19
19
  import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
20
20
  import { allowanceAmount, assistantStatusKey, usd, useAssistantStatus } from './lumen-assistant.js';
21
- import { OwnKeyUsage, UsageHistoryView } from './usage-history.js';
21
+ import { AssistantUsageTable, OwnKeyUsage, UsageHistoryView } from './usage-history.js';
22
22
  const EFFORTS = [
23
23
  { value: 'default', label: 'Model default' },
24
24
  { value: 'low', label: 'Low' },
@@ -163,14 +163,14 @@ function OwnProviderKey({ client, p }) {
163
163
  return (_jsx(LumenConnection, { id: p.id, name: p.label, ways: ways, use: p.use, onUse: (use) => apply(`use:${p.id}`, () => client.settings({ own_use: { [p.id]: use } })) }));
164
164
  }
165
165
  /** A person's own keys, one row a provider, and what they may spend on them. */
166
- function Providers({ client }) {
166
+ function Providers({ client, usage }) {
167
167
  const { apply } = useApply(client);
168
168
  return (_jsx(WithStatus, { client: client, children: (s) => {
169
169
  // A provider the app gives this person no way of their own on shows no row: what shows here
170
170
  // is exactly what the server may pay with.
171
171
  const rows = s.providers.filter((p) => !p.own_ways || p.own_ways.length > 0);
172
172
  const ownSet = rows.some(ownUsable);
173
- return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: rows.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), ownSet && (_jsx(SettingField, { label: "Answers run on", info: "Your own key or plan first, with the shared credits behind it; or the shared credits first.", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: s.pay, "aria-label": "Answers run on", onValueChange: (pay) => pay && void apply('pay', () => client.settings({ pay: pay })), children: [_jsx(ToggleGroupItem, { value: "own", children: "Your own first" }), _jsx(ToggleGroupItem, { value: "app", children: "Credits first" })] }) })), ownSet && (_jsx(SettingField, { 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)) }) })), ownSet && _jsx(OwnCapUse, { s: s }), _jsx(OwnKeyUsage, { client: client, always: ownSet, usable: rows.filter(ownUsable).map((p) => p.id) })] }));
173
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: rows.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), ownSet && (_jsx(SettingField, { label: "Answers run on", info: "Your own key or plan first, with the shared credits behind it; or the shared credits first.", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: s.pay, "aria-label": "Answers run on", onValueChange: (pay) => pay && void apply('pay', () => client.settings({ pay: pay })), children: [_jsx(ToggleGroupItem, { value: "own", children: "Your own first" }), _jsx(ToggleGroupItem, { value: "app", children: "Credits first" })] }) })), ownSet && (_jsx(SettingField, { 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)) }) })), ownSet && _jsx(OwnCapUse, { s: s }), !usage && (_jsx(OwnKeyUsage, { client: client, always: ownSet, usable: rows.filter(ownUsable).map((p) => p.id) }))] }));
174
174
  } }));
175
175
  }
176
176
  /**
@@ -312,6 +312,104 @@ function Web({ client }) {
312
312
  const { apply } = useApply(client);
313
313
  return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(SettingField, { 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 })] })) }));
314
314
  }
315
+ // ---------------------------------------------------------------- connectors
316
+ const CONNECTOR_ACCESS_LABELS = [
317
+ { value: 'off', label: 'Off' },
318
+ { value: 'read', label: 'Read' },
319
+ { value: 'draft', label: 'Read + draft' },
320
+ ];
321
+ /** Name, address and up to two sign-in headers; a pair left empty is not sent. */
322
+ const headerFields = (prefix = '') => [
323
+ { id: `${prefix}h1`, label: 'Header', placeholder: 'CF-Access-Client-Id' },
324
+ { id: `${prefix}v1`, label: 'Value', secret: true },
325
+ { id: `${prefix}h2`, label: 'Header', placeholder: 'CF-Access-Client-Secret' },
326
+ { id: `${prefix}v2`, label: 'Value', secret: true },
327
+ ];
328
+ const headersOf = (v) => {
329
+ const out = {};
330
+ for (const i of [1, 2]) {
331
+ const k = v[`h${i}`]?.trim();
332
+ const val = v[`v${i}`]?.trim();
333
+ if (k && val)
334
+ out[k] = val;
335
+ }
336
+ return out;
337
+ };
338
+ function ConnectorRow({ client, c }) {
339
+ const { apply, refresh } = useApply(client);
340
+ const reads = c.tools.filter((t) => t.read).length;
341
+ const access = (v) => apply('connector', () => client.saveConnector(c.id, {
342
+ name: c.name,
343
+ url: c.url,
344
+ access: v,
345
+ }));
346
+ return (_jsx(LumenConnection, { id: c.id, name: c.name, logo: _jsx(ConnectionLogo, { icon: PlugIcon }), meta: `${c.tools.length} tool${c.tools.length === 1 ? '' : 's'}${c.access === 'off' ? ' · off' : ''}`, ways: [
347
+ {
348
+ id: 'headers',
349
+ label: 'Sign-in headers',
350
+ icon: KeyRoundIcon,
351
+ info: 'Sent with every call to this server, such as a Cloudflare Access service token. Kept sealed on the server and never shown again.',
352
+ set: true,
353
+ ident: c.headers.length ? c.headers.join(', ') : 'None',
354
+ test: c.test
355
+ ? {
356
+ at: c.test.at,
357
+ ok: c.test.ok,
358
+ detail: c.test.detail,
359
+ error: c.test.ok ? null : c.test.detail,
360
+ }
361
+ : null,
362
+ fields: [{ id: 'url', label: 'Address', placeholder: c.url }, ...headerFields()],
363
+ addLabel: 'Replace',
364
+ onSave: (v) => apply('connector', () => client.saveConnector(c.id, {
365
+ name: c.name,
366
+ url: v.url?.trim() || c.url,
367
+ headers: headersOf(v),
368
+ }), 'Connector saved'),
369
+ ...(client.testConnector
370
+ ? {
371
+ onTest: () => client.testConnector(c.id).finally(refresh),
372
+ }
373
+ : {}),
374
+ onRemove: () => apply('connector', () => client.removeConnector(c.id), 'Connector removed'),
375
+ removeTitle: `Remove ${c.name}?`,
376
+ removeText: 'The assistant stops using its tools.',
377
+ },
378
+ ], lines: [
379
+ { label: 'Address', value: c.url, mono: true },
380
+ {
381
+ label: 'Tools',
382
+ info: 'The assistant finds these only when it needs them, so they cost nothing until used.',
383
+ value: c.tools.length
384
+ ? `${c.tools.map((t) => t.title ?? t.name).join(', ')} (${reads} read-only)`
385
+ : 'None listed',
386
+ },
387
+ ], children: _jsx(SettingField, { label: "Use", info: "Read uses the tools the server marks read-only. Read + draft also lets the others make a card the person saves or dismisses.", children: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: c.access, "aria-label": `Use ${c.name}`, onValueChange: (v) => v && void access(v), children: CONNECTOR_ACCESS_LABELS.map((a) => (_jsx(ToggleGroupItem, { value: a.value, children: a.label }, a.value))) }) }) }));
388
+ }
389
+ /** Admin: the app's connectors, each an MCP server, and a row to add one. */
390
+ function Connectors({ client }) {
391
+ const { apply } = useApply(client);
392
+ return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs(LumenExpandList, { children: [(s.connectors ?? []).map((c) => (_jsx(ConnectorRow, { client: client, c: c }, c.id))), _jsx(LumenConnection, { id: "connector-new", name: "Add connector", logo: _jsx(ConnectionLogo, { icon: PlugIcon }), meta: "Any MCP server over HTTP", ways: [
393
+ {
394
+ id: 'new',
395
+ label: 'MCP server',
396
+ icon: PlugIcon,
397
+ info: 'Its address (Streamable HTTP) and the headers it signs in with. It is tested before it is kept.',
398
+ set: false,
399
+ fields: [
400
+ { id: 'name', label: 'Name', placeholder: 'Web search' },
401
+ { id: 'url', label: 'Address', placeholder: 'https://example.com/mcp' },
402
+ ...headerFields(),
403
+ ],
404
+ addLabel: 'Add connector',
405
+ onSave: (v) => apply('connector', () => client.addConnector({
406
+ name: v.name?.trim() ?? '',
407
+ url: v.url?.trim() ?? '',
408
+ headers: headersOf(v),
409
+ }), 'Connector added'),
410
+ },
411
+ ] })] })) }));
412
+ }
315
413
  // ---------------------------------------------------------------- spend
316
414
  function Spend({ client }) {
317
415
  return (_jsx(WithStatus, { client: client, children: (s) => {
@@ -414,7 +512,7 @@ function AllowanceRequests({ client }) {
414
512
  * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
415
513
  * where the app keeps its admin settings, shown only to admins.
416
514
  */
417
- export function assistantAdminSections({ client, allowance = false, providers, }) {
515
+ export function assistantAdminSections({ client, allowance = false, providers, connectors = false, }) {
418
516
  const keys = providers
419
517
  ? [
420
518
  {
@@ -455,13 +553,24 @@ export function assistantAdminSections({ client, allowance = false, providers, }
455
553
  },
456
554
  ]
457
555
  : []),
556
+ ...(connectors
557
+ ? [
558
+ {
559
+ id: 'assistant-connectors',
560
+ title: 'Connectors',
561
+ icon: PlugIcon,
562
+ description: 'Outside services the assistant may use',
563
+ content: _jsx(Connectors, { client: client }),
564
+ },
565
+ ]
566
+ : []),
458
567
  ];
459
568
  }
460
569
  /**
461
570
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
462
571
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
463
572
  */
464
- export function assistantSettingsSections({ client, name, web = true, allowance = false, }) {
573
+ export function assistantSettingsSections({ client, name, web = true, allowance = false, usage = false, }) {
465
574
  return [
466
575
  {
467
576
  id: 'assistant',
@@ -476,7 +585,7 @@ export function assistantSettingsSections({ client, name, web = true, allowance
476
585
  icon: KeyRoundIcon,
477
586
  sub: true,
478
587
  description: 'Your own key for each provider',
479
- content: _jsx(Providers, { client: client }),
588
+ content: _jsx(Providers, { client: client, usage: usage }),
480
589
  },
481
590
  {
482
591
  id: 'assistant-models',
@@ -514,6 +623,18 @@ export function assistantSettingsSections({ client, name, web = true, allowance
514
623
  description: 'What each model, chat and run cost',
515
624
  content: _jsx(Spend, { client: client }),
516
625
  },
626
+ ...(usage
627
+ ? [
628
+ {
629
+ id: 'assistant-usage',
630
+ title: 'Usage',
631
+ icon: ChartColumnIcon,
632
+ sub: true,
633
+ description: 'Every chat and run this month, and who paid',
634
+ content: _jsx(AssistantUsageTable, { client: client }),
635
+ },
636
+ ]
637
+ : []),
517
638
  ...(allowance
518
639
  ? [
519
640
  {
@@ -26,7 +26,7 @@ export declare function OwnKeyUsage({ client, always, usable, }: {
26
26
  }): import("react").JSX.Element | null;
27
27
  /**
28
28
  * What one chat has cost so far, as a chip for the assistant's header: what was charged (credits or
29
- * the person's key) plus a plan sign-in's calls at API rates, shown ≈ when the plan's part is in it.
29
+ * the person's key) plus a plan sign-in's calls at API rates (an estimate, said in its hover).
30
30
  * Nothing while the chat has no history line. The hover splits it.
31
31
  */
32
32
  export declare function AssistantChatCost({ client, chatId, busy, }: {
@@ -35,3 +35,12 @@ export declare function AssistantChatCost({ client, chatId, busy, }: {
35
35
  /** The chat is answering: the chip reads its history again when the answer ends. */
36
36
  busy?: boolean;
37
37
  }): import("react").JSX.Element | null;
38
+ /**
39
+ * This month's use, one row a chat or run: its Type (the person's API key, their plan sign-in, or
40
+ * the app's credits), Date, Calls and Amount, grouped by Type, Date or not at all, filtered by
41
+ * type or name. A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the
42
+ * same tokens at the model's API rates (the plan charges $0).
43
+ */
44
+ export declare function AssistantUsageTable({ client }: {
45
+ client: AssistantAdapter;
46
+ }): import("react").JSX.Element | null;
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useQuery } from '@tanstack/react-query';
3
- import { ChevronLeftIcon, ChevronRightIcon, CpuIcon, MessageSquareIcon } from 'lucide-react';
4
- import { Fragment, useEffect, useState } from 'react';
3
+ import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, CpuIcon, HashIcon, KeyRoundIcon, MessageSquareIcon, ReceiptIcon, } from 'lucide-react';
4
+ import { Fragment, useEffect, useMemo, useState } from 'react';
5
5
  import { DetailsTable } from '../details-table.js';
6
6
  import { IconButton } from '../icon-button.js';
7
7
  import { InfoLabel } from '../info-tip.js';
8
8
  import { cn } from '../lib/utils.js';
9
+ import { LumenTokenizedFilter, useLumenFilter } from '../lumen-filter.js';
10
+ import { LumenTable } from '../lumen-view.js';
9
11
  import { SettingField } from '../setting-parts.js';
10
12
  import { Badge } from '../ui/badge.js';
11
13
  import { Label } from '../ui/label.js';
12
14
  import { Spinner } from '../ui/spinner.js';
13
15
  import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
14
16
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
17
+ import { CHIP_TONE } from '../view-model.js';
15
18
  export const usd = (n) => n === 0 ? '$0' : n < 0.01 ? '<$0.01' : `$${n.toFixed(n < 10 ? 2 : 0)}`;
16
19
  export const tokens = (n) => n < 1000
17
20
  ? String(n)
@@ -155,10 +158,10 @@ export function OwnKeyUsage({ client, always, usable, }) {
155
158
  const named = (e) => e.own_plan_calls ? `${e.title} · ${PLAN_NAME}, ${calls(e.own_plan_calls)}` : e.title;
156
159
  const total = keyTotal + planTotal + (planEarlier ? planListEarlier : 0);
157
160
  const anyPlan = planTotal > 0 || (planEarlier > 0 && planListEarlier > 0);
158
- // An amount with plan calls in it reads ≈: the plan's part is what API rates would charge.
159
- const amount = (key, plan, title) => plan > 0 ? (_jsxs("span", { title: title, children: ["\u2248", usd(key + plan)] }, "a")) : (usd(key));
161
+ // An amount with plan calls in it is an estimate: the plan's part is what API rates would charge.
162
+ const amount = (key, plan, title) => plan > 0 ? (_jsx("span", { title: title, children: usd(key + plan) }, "a")) : (usd(key));
160
163
  const planTitle = (key, plan) => `${key > 0 ? `${usd(key)} on your key + ` : ''}about ${usd(plan)} at API rates on your ${PLAN_NAME}, which charged $0`;
161
- return (_jsxs("div", { className: "flex flex-col gap-2 border-t border-border/60 pb-3 first:border-t-0", children: [_jsx(SettingField, { label: "Spent on your keys", info: "Each chat and run your own key or plan paid for, by its last use. A \u2248 amount prices plan calls at API rates; the plan charges $0.", className: "border-t-0", end: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: range, "aria-label": "Range", onValueChange: (v) => v && setRange(v), children: OWN_RANGES.map((r) => (_jsx(ToggleGroupItem, { value: r.id, "aria-label": `Last ${r.id}`, children: r.id }, r.id))) }) }), sessions.length || untracked || gone || planEarlier ? (_jsx(DetailsTable, { head: [
164
+ return (_jsxs("div", { className: "flex flex-col gap-2 border-t border-border/60 pb-3 first:border-t-0", children: [_jsx(SettingField, { label: "Spent on your keys", info: "Each chat and run your own key or plan paid for, by its last use. Plan amounts are estimates at API rates; the plan charges $0.", className: "border-t-0", end: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: range, "aria-label": "Range", onValueChange: (v) => v && setRange(v), children: OWN_RANGES.map((r) => (_jsx(ToggleGroupItem, { value: r.id, "aria-label": `Last ${r.id}`, children: r.id }, r.id))) }) }), sessions.length || untracked || gone || planEarlier ? (_jsx(DetailsTable, { head: [
162
165
  { label: 'Session', grow: true },
163
166
  { label: 'Date', hideOnPhone: true },
164
167
  { label: 'Amount', right: true },
@@ -217,7 +220,7 @@ export function OwnKeyUsage({ client, always, usable, }) {
217
220
  }
218
221
  /**
219
222
  * What one chat has cost so far, as a chip for the assistant's header: what was charged (credits or
220
- * the person's key) plus a plan sign-in's calls at API rates, shown ≈ when the plan's part is in it.
223
+ * the person's key) plus a plan sign-in's calls at API rates (an estimate, said in its hover).
221
224
  * Nothing while the chat has no history line. The hover splits it.
222
225
  */
223
226
  export function AssistantChatCost({ client, chatId, busy, }) {
@@ -241,9 +244,173 @@ export function AssistantChatCost({ client, chatId, busy, }) {
241
244
  own > 0 ? `${usd(own)} on your key` : null,
242
245
  charged - own >= 0.005 ? `${usd(charged - own)} on credits` : null,
243
246
  e.own_plan_calls
244
- ? `${e.own_plan_calls === 1 ? '1 call' : `${e.own_plan_calls} calls`} on your ${PLAN_NAME}: ≈${usd(plan)} at API rates (charged $0)`
247
+ ? `${e.own_plan_calls === 1 ? '1 call' : `${e.own_plan_calls} calls`} on your ${PLAN_NAME}: ${usd(plan)} estimate at API rates (charged $0)`
245
248
  : null,
246
249
  ].filter(Boolean);
247
- const text = `${plan > 0 ? '≈' : ''}${usd(total)}`;
250
+ const text = usd(total);
248
251
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": `This chat cost ${text}`, className: "shrink-0 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50", children: _jsx(Badge, { variant: "outline", className: "num whitespace-nowrap", children: text }) }) }), _jsx(TooltipContent, { children: _jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-medium", children: "This chat" }), lines.map((l) => (_jsx("span", { children: l }, l)))] }) })] }));
249
252
  }
253
+ const TYPE_LABEL = {
254
+ key: 'API key',
255
+ plan: 'Sign-in',
256
+ credits: 'Credits',
257
+ };
258
+ const TYPE_TONE = { key: 'sky', plan: 'amber', credits: 'muted' };
259
+ /** Dollars with no `<` or `≈` (the table's note says which amounts are estimates). */
260
+ const money = (n) => (n === 0 ? '$0' : `$${n.toFixed(n < 10 ? 2 : 0)}`);
261
+ const amountOf = (r) => r.key + r.credits + r.plan;
262
+ const EARLIER = 'Earlier';
263
+ /** This month's rows: every chat and run, then the spend no chat or run carries, by who paid. */
264
+ function usageRows(h) {
265
+ const out = h.entries.map((e) => {
266
+ const key = e.own_usd ?? 0;
267
+ const plan = e.own_plan_usd ?? 0;
268
+ const type = plan > 0 || e.own_plan_calls ? 'plan' : key > 0 ? 'key' : 'credits';
269
+ return {
270
+ id: e.id,
271
+ title: e.title,
272
+ type,
273
+ at: e.at,
274
+ calls: e.calls,
275
+ tokens: { in: e.in, read: e.read, out: e.out },
276
+ key,
277
+ credits: Math.max(0, e.usd - key),
278
+ plan,
279
+ };
280
+ });
281
+ const sum = (xs) => xs.reduce((a, n) => a + n, 0);
282
+ const earlier = (type, title, calls, amount) => amount >= 0.01 &&
283
+ out.push({
284
+ id: `earlier:${type}:${title}`,
285
+ title,
286
+ type,
287
+ at: 0,
288
+ calls,
289
+ key: type === 'key' ? amount : 0,
290
+ credits: type === 'credits' ? amount : 0,
291
+ plan: type === 'plan' ? amount : 0,
292
+ });
293
+ const own = h.rows.filter((r) => r.payer === 'own' && !r.free);
294
+ const plans = own.filter((r) => r.way === 'plan' || (!r.way && r.usd < 0.0005));
295
+ const keys = own.filter((r) => !plans.includes(r));
296
+ const app = h.rows.filter((r) => r.payer !== 'own' && !r.free);
297
+ earlier('key', 'Earlier this month, not per chat', Math.max(0, sum(keys.map((r) => r.calls)) - sum(h.entries.filter((e) => e.own_usd).map((e) => e.calls))), sum(keys.map((r) => r.usd)) - sum(h.entries.map((e) => e.own_usd ?? 0)));
298
+ earlier('plan', 'Earlier this month, not per chat', Math.max(0, sum(plans.map((r) => r.calls)) - sum(h.entries.map((e) => e.own_plan_calls ?? 0))), sum(plans.map((r) => r.list_usd ?? 0)) - sum(h.entries.map((e) => e.own_plan_usd ?? 0)));
299
+ earlier('credits', 'Earlier this month, not per chat', Math.max(0, sum(app.map((r) => r.calls)) -
300
+ sum(h.entries.filter((e) => e.usd > (e.own_usd ?? 0)).map((e) => e.calls))), sum(app.map((r) => r.usd)) - sum(h.entries.map((e) => Math.max(0, e.usd - (e.own_usd ?? 0)))));
301
+ return out;
302
+ }
303
+ /** A row's hover: its tokens (cache reads as a share of input) and what it cost, by who paid. */
304
+ function UsageTip({ r, children }) {
305
+ const t = r.tokens;
306
+ const lines = [
307
+ ...(t
308
+ ? [
309
+ ['Input', tokens(t.in)],
310
+ ['Cache read', `${tokens(t.read)} · ${t.in ? Math.round((t.read / t.in) * 100) : 0}%`],
311
+ ['Output', tokens(t.out)],
312
+ ]
313
+ : []),
314
+ ...(r.key > 0 ? [['Cost (your key)', money(r.key)]] : []),
315
+ ...(r.credits > 0 || (r.type === 'credits' && !r.plan)
316
+ ? [['Credits', money(r.credits)]]
317
+ : []),
318
+ ...(r.plan > 0 || r.type === 'plan'
319
+ ? [['Estimate (API rates)', money(r.plan)]]
320
+ : []),
321
+ ];
322
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: children }), _jsx(TooltipContent, { className: "max-w-72", children: _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "truncate font-medium", children: r.title }), _jsx("dl", { className: "grid grid-cols-[auto_auto] gap-x-4 gap-y-0.5", children: lines.map(([k, v]) => (_jsxs(Fragment, { children: [_jsx("dt", { className: "opacity-75", children: k }), _jsx("dd", { className: "num text-right", children: v })] }, k))) }), !t && _jsx("span", { className: "opacity-75", children: "Spent before each chat kept its own count." })] }) })] }));
323
+ }
324
+ const dayOf = (at) => at ? new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : EARLIER;
325
+ const USAGE_FIELDS = [
326
+ {
327
+ key: 'title',
328
+ label: 'Session',
329
+ icon: MessageSquareIcon,
330
+ format: 'text',
331
+ value: (r) => r.title,
332
+ render: (r) => (_jsx(UsageTip, { r: r, children: _jsx("span", {
333
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a keyboard reaches the row's hover
334
+ tabIndex: 0, className: cn('rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50', !r.at && 'text-muted-foreground'), children: r.title }) })),
335
+ },
336
+ {
337
+ key: 'type',
338
+ label: 'Type',
339
+ icon: KeyRoundIcon,
340
+ format: 'text',
341
+ groupable: true,
342
+ filterable: true,
343
+ value: (r) => TYPE_LABEL[r.type],
344
+ render: (r) => (_jsx(UsageTip, { r: r, children: _jsx(Badge, { variant: "outline", className: cn('whitespace-nowrap', CHIP_TONE[TYPE_TONE[r.type]]), children: TYPE_LABEL[r.type] }) })),
345
+ },
346
+ {
347
+ key: 'at',
348
+ label: 'Date',
349
+ icon: CalendarIcon,
350
+ format: 'integer',
351
+ hideOnPhone: true,
352
+ value: (r) => r.at,
353
+ render: (r) => (_jsx(UsageTip, { r: r, children: r.at ? (_jsx("time", { dateTime: new Date(r.at).toISOString(), className: "text-muted-foreground", children: when(r.at) })) : (_jsx("span", { className: "text-muted-foreground", children: EARLIER })) })),
354
+ },
355
+ {
356
+ key: 'calls',
357
+ label: 'Calls',
358
+ icon: HashIcon,
359
+ format: 'integer',
360
+ hideOnPhone: true,
361
+ aggregate: 'sum',
362
+ value: (r) => r.calls,
363
+ render: (r) => (_jsx(UsageTip, { r: r, children: _jsx("span", { children: r.calls }) })),
364
+ },
365
+ {
366
+ key: 'amount',
367
+ label: 'Amount',
368
+ icon: ReceiptIcon,
369
+ format: 'price',
370
+ aggregate: 'sum',
371
+ value: (r) => amountOf(r),
372
+ render: (r) => (_jsx(UsageTip, { r: r, children: _jsx("span", { children: money(amountOf(r)) }) })),
373
+ rollRender: (rows) => money(rows.reduce((a, r) => a + amountOf(r), 0)),
374
+ },
375
+ ];
376
+ /** Grouping by date: each day, then the spend from before chats kept their own count. */
377
+ const DAY_FIELD = {
378
+ key: 'day',
379
+ label: 'Date',
380
+ icon: CalendarIcon,
381
+ format: 'text',
382
+ groupable: true,
383
+ value: (r) => dayOf(r.at),
384
+ };
385
+ const USAGE_DOMAIN = {
386
+ id: 'assistant-usage',
387
+ fields: USAGE_FIELDS,
388
+ rowKey: (r) => r.id,
389
+ text: (r) => [r.title],
390
+ formulas: false,
391
+ };
392
+ const USAGE_FILTER = [
393
+ { key: 'type', label: 'Type', icon: KeyRoundIcon, values: (r) => [TYPE_LABEL[r.type]] },
394
+ ];
395
+ const GROUPS = [
396
+ { id: 'type', label: 'Type', field: USAGE_FIELDS[1] },
397
+ { id: 'none', label: 'None', field: null },
398
+ { id: 'date', label: 'Date', field: DAY_FIELD },
399
+ ];
400
+ /**
401
+ * This month's use, one row a chat or run: its Type (the person's API key, their plan sign-in, or
402
+ * the app's credits), Date, Calls and Amount, grouped by Type, Date or not at all, filtered by
403
+ * type or name. A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the
404
+ * same tokens at the model's API rates (the plan charges $0).
405
+ */
406
+ export function AssistantUsageTable({ client }) {
407
+ const [group, setGroup] = useState('type');
408
+ const q = useAssistantUsage(client);
409
+ const rows = useMemo(() => (q.data ? usageRows(q.data) : []), [q.data]);
410
+ const filter = useLumenFilter(rows, USAGE_FILTER, { text: (r) => [r.title] });
411
+ if (!client.usage)
412
+ return null;
413
+ if (!q.data)
414
+ return (_jsx("div", { className: "flex justify-center p-4", children: q.isError ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Usage didn't load. Try again later." })) : (_jsx(Spinner, {})) }));
415
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(LumenTokenizedFilter, { filter: filter, label: "Filter usage", placeholder: "Filter: type, or a session name", className: "min-w-48 flex-1", link: null }), _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: group, "aria-label": "Group by", onValueChange: (v) => v && setGroup(v), children: GROUPS.map((g) => (_jsx(ToggleGroupItem, { value: g.id, "aria-label": `Group by ${g.label}`, children: g.label }, g.id))) })] }), _jsx(LumenTable, { domain: USAGE_DOMAIN, rows: filter.results, groupBy: GROUPS.find((g) => g.id === group)?.field ?? null, sort: { key: 'at', desc: true }, emptyState: rows.length ? undefined : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing used this month." })), onClearFilter: filter.clear }), _jsxs("p", { className: "text-muted-foreground text-xs", children: ["Sign-in amounts are estimates: your ", PLAN_NAME, " charges $0, so the amount is what the same tokens would cost at the model's API rates. Key and credit amounts are what was charged."] })] }));
416
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.258",
3
+ "version": "0.1.260",
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.258",
52
+ "@columenlabs/lumen-ui-types": "0.1.260",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",