@micha.bigler/ui-core-micha 4.5.0 → 4.7.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.0 - UCM-RCPT-1
4
+
5
+ Shared receipt capture and review, against the `django-core-micha` receipts module (DCM-RCPT-1; `consent/` and `read/`
6
+ under `apiBase`, default `/api/receipts/`). `ReceiptCaptureCard` offers the camera (`capture="environment"`) and a file
7
+ button for JPG, PNG, HEIC and PDF, scales large JPEG and PNG files on the device (HEIC and PDF pass through; at most
8
+ 10 MB), shows the progress while the receipt is read and the recognised boxes on the image, and the edge states (no
9
+ consent, nothing recognised, recognition unavailable, file not readable). Without consent it shows the consent switch
10
+ and, when the host gives `onUploadOnly`, an upload-only path. `ReceiptReviewDialog` (a sheet on phones) lists each
11
+ recognised value next to the form's current one with "Apply", "Only empty fields" and "Apply all"; the host passes its
12
+ form values, field labels and extra fields and receives only the chosen values (`onApply(values, { mode })`).
13
+ `ReceiptPicker` selects among several receipts in one photo (low confidence starts unselected). `ReceiptConsentSwitch`
14
+ reads and writes the user's own consent and works on a profile page too; the text names the `provider` the host passes.
15
+ Also `useReceiptConsent`, `createReceiptsApi` / `defaultReceiptsApi` (every function overridable), `receiptFileProblem`,
16
+ `prepareReceiptFile` and `receiptsTranslations` (de/en/fr/sw, part of `uiCoreTranslations`). The kit stores nothing; the
17
+ app keeps its form and its files.
18
+
19
+ ## 4.6.0 - UCM-CHAT-6
20
+
21
+ Plain model choice, summary and cost on the shared chat surface, against `django-core-micha` 3.5.0 (DCM-CHAT-13).
22
+ `ModelPicker` moves next to "Send" (new `ChatComposer` prop `actions`) and shows only the models the server offers,
23
+ grouped by quality level (`level`), with the known name, "by <provider>", an estimate "about x-y tokens per reply" (a
24
+ relative three-step scale when the server sends no tokens, "no tokens, Claude subscription" for subscription entries)
25
+ and the effort in words (Quick, Thorough, Very thorough, Maximum); the groups by driver, the "pay per use" chip and
26
+ the old effort labels are removed (keys `Chat.driver.*`, `Chat.payPerUse` gone). The catalogue is requested with the
27
+ chat (`getCatalogue({ chat })`, `?chat=<id>`) so the estimate follows the chat's length; a stored selection the
28
+ catalogue no longer offers is replaced once by the default. New optional `ChatWorkspace` props: `tokenBalance`
29
+ (footer "Balance"), `usageToTokens(usage)` (per-reply cost "<model> - <effort> - n tokens" under a reply) and
30
+ `longChatTokens` (default 40000, 0 turns it off; a notice above the composer and in the picker with "New
31
+ conversation"). `ChatThread` shows the server's summary collapsed as "Summary up to here" before the first newer turn
32
+ (plain text), from the turns page, the `history` frame and the `summarised` notice. Without the new props behaviour as
33
+ in 4.5.x apart from the picker's new look.
34
+
3
35
  ## 4.5.0 - UCM-BILL-3
4
36
 
5
37
  "Withdraw contract here" for EU/EEA consumers, against the `django-core-micha` 3.5.0 withdrawal API
@@ -6,18 +6,19 @@ import { useTranslation } from 'react-i18next';
6
6
  * Message input. Send is disabled while a turn runs, for an archived chat and while the connection is down;
7
7
  * Stop replaces it during a turn. Enter sends, Shift+Enter inserts a line break.
8
8
  */
9
- export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, inputRef, sx }) {
9
+ export const CHAT_COMPOSER_INPUT_SX = { flex: '1 1 12rem', minWidth: 0 };
10
+ export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, inputRef, sx, actions }) {
10
11
  const { t } = useTranslation();
11
12
  const blocked = archived || !connected;
12
13
  const canSend = !running && !blocked && value.trim().length > 0;
13
14
  const submit = () => { if (canSend)
14
15
  onSend === null || onSend === void 0 ? void 0 : onSend(); };
15
- return (_jsxs(Stack, { direction: "row", alignItems: "flex-end", gap: 1, sx: Object.assign({ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider' }, sx), children: [_jsx(TextField, { multiline: true, maxRows: 6, fullWidth: true, size: "small", value: value, inputRef: inputRef, disabled: blocked, placeholder: t('Chat.message'), slotProps: { htmlInput: { 'aria-label': t('Chat.message') } }, onChange: (event) => onChange === null || onChange === void 0 ? void 0 : onChange(event.target.value), onKeyDown: (event) => {
16
+ return (_jsxs(Stack, { direction: "row", alignItems: "flex-end", flexWrap: "wrap", gap: 1, sx: Object.assign({ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider' }, sx), children: [_jsx(TextField, { multiline: true, maxRows: 6, sx: CHAT_COMPOSER_INPUT_SX, size: "small", value: value, inputRef: inputRef, disabled: blocked, placeholder: t('Chat.message'), slotProps: { htmlInput: { 'aria-label': t('Chat.message') } }, onChange: (event) => onChange === null || onChange === void 0 ? void 0 : onChange(event.target.value), onKeyDown: (event) => {
16
17
  var _a;
17
18
  if (event.key === 'Enter' && !event.shiftKey && !((_a = event.nativeEvent) === null || _a === void 0 ? void 0 : _a.isComposing)) {
18
19
  event.preventDefault();
19
20
  submit();
20
21
  }
21
- } }), running ? _jsx(Button, { variant: "outlined", onClick: onStop, children: t('Chat.stop') }) : null, _jsx(Button, { variant: "contained", onClick: submit, disabled: !canSend, children: t('Chat.send') })] }));
22
+ } }), actions ? _jsx(Stack, { direction: "row", alignItems: "flex-end", children: actions }) : null, running ? _jsx(Button, { variant: "outlined", onClick: onStop, children: t('Chat.stop') }) : null, _jsx(Button, { variant: "contained", onClick: submit, disabled: !canSend, children: t('Chat.send') })] }));
22
23
  }
23
24
  export default ChatComposer;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useEffect, useLayoutEffect, useRef } from 'react';
2
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { Alert, Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import MarkdownReply from './MarkdownReply';
@@ -14,19 +14,27 @@ function SourceChips({ sources }) {
14
14
  return null;
15
15
  return (_jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.75, role: "list", "aria-label": t('Chat.sources'), children: sources.map((source, index) => (_jsx(Tooltip, { title: source.reference || '', describeChild: true, children: _jsx(Chip, { role: "listitem", size: "small", variant: "outlined", label: source.label }) }, `${source.label}-${index}`))) }));
16
16
  }
17
- function Answer({ turn, tool, onRetry, chat, renderAnswerExtra }) {
17
+ function Answer({ turn, tool, onRetry, chat, renderAnswerExtra, usageToTokens }) {
18
18
  var _a;
19
- const { t } = useTranslation();
19
+ const { t, i18n } = useTranslation();
20
20
  const generating = turn.state === 'generating';
21
21
  const failed = ['failed', 'cancelled'].includes(turn.state) && turn.error_code;
22
- return (_jsxs(Stack, { spacing: 1, "aria-busy": generating, children: [turn.assistant_text ? _jsx(MarkdownReply, { text: turn.assistant_text }) : null, generating && !turn.assistant_text && !tool ? _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Chat.typing') }) : null, generating && tool ? _jsx(Chip, { size: "small", color: "primary", variant: "outlined", label: tool.label, sx: CHAT_THREAD_TOOL_CHIP_SX }) : null, _jsx(SourceChips, { sources: turn.sources }), !generating && selectionLine(turn, t) ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: selectionLine(turn, t) }) : null, renderAnswerExtra ? _jsx(React.Fragment, { children: renderAnswerExtra({ turn, chat, appFrames: (_a = turn.appFrames) !== null && _a !== void 0 ? _a : [] }) }, chat === null || chat === void 0 ? void 0 : chat.id) : null, turn.state === 'interrupted' ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Chip, { size: "small", color: "warning", label: t('Chat.interrupted') }), onRetry ? _jsx(Button, { size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null] })) : null, failed ? (_jsx(Alert, { severity: "error", action: onRetry ? _jsx(Button, { color: "inherit", size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null, children: t('Chat.answerIncomplete') })) : null] }));
22
+ const replyCaption = (turn) => {
23
+ const tokens = turn.usage && (usageToTokens === null || usageToTokens === void 0 ? void 0 : usageToTokens(turn.usage));
24
+ if (Number.isInteger(tokens) && tokens >= 0) {
25
+ const count = new Intl.NumberFormat(typeof (i18n === null || i18n === void 0 ? void 0 : i18n.language) === 'string' ? i18n.language.split('_')[0] || undefined : undefined).format(tokens);
26
+ return [turn.model, turn.effort && t(`Chat.effort.${turn.effort}`), t('Chat.replyTokens', { n: count })].filter(Boolean).join(' · ');
27
+ }
28
+ return selectionLine(turn, t);
29
+ };
30
+ return (_jsxs(Stack, { spacing: 1, "aria-busy": generating, children: [turn.assistant_text ? _jsx(MarkdownReply, { text: turn.assistant_text }) : null, generating && !turn.assistant_text && !tool ? _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Chat.typing') }) : null, generating && tool ? _jsx(Chip, { size: "small", color: "primary", variant: "outlined", label: tool.label, sx: CHAT_THREAD_TOOL_CHIP_SX }) : null, _jsx(SourceChips, { sources: turn.sources }), !generating && replyCaption(turn) ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: replyCaption(turn) }) : null, renderAnswerExtra ? _jsx(React.Fragment, { children: renderAnswerExtra({ turn, chat, appFrames: (_a = turn.appFrames) !== null && _a !== void 0 ? _a : [] }) }, chat === null || chat === void 0 ? void 0 : chat.id) : null, turn.state === 'interrupted' ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Chip, { size: "small", color: "warning", label: t('Chat.interrupted') }), onRetry ? _jsx(Button, { size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null] })) : null, failed ? (_jsx(Alert, { severity: "error", action: onRetry ? _jsx(Button, { color: "inherit", size: "small", onClick: () => onRetry(turn), children: t('Chat.retry') }) : null, children: t('Chat.answerIncomplete') })) : null] }));
23
31
  }
24
32
  /**
25
33
  * The turns of one chat. The streaming answer is simply the last turn in state `generating`; the caller keeps its text
26
34
  * up to date. `notice` is a connection or limit message shown under the last turn. `readOnly` (an archived chat) hides Retry.
27
35
  */
28
- export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx, chat, renderAnswerExtra, trimmedCount }) {
29
- var _a;
36
+ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx, chat, renderAnswerExtra, trimmedCount, summary = null, usageToTokens }) {
37
+ var _a, _b;
30
38
  const { t } = useTranslation();
31
39
  const end = useRef(null);
32
40
  const scroller = useRef(null);
@@ -58,6 +66,11 @@ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder =
58
66
  loadingOlder.current = false;
59
67
  }
60
68
  };
69
+ const [summaryOpen, setSummaryOpen] = useState(false);
70
+ useEffect(() => { setSummaryOpen(false); }, [chat === null || chat === void 0 ? void 0 : chat.id, summary === null || summary === void 0 ? void 0 : summary.up_to_seq]);
71
+ const summaryVisible = Boolean((_b = summary === null || summary === void 0 ? void 0 : summary.text) === null || _b === void 0 ? void 0 : _b.trim());
72
+ const renderSummary = () => summaryVisible ? _jsxs(Box, { sx: { mb: 2 }, children: [_jsxs(Button, { size: "small", "aria-expanded": summaryOpen, onClick: () => setSummaryOpen((value) => !value), children: [t('Chat.summaryUntilHere'), " ", summaryOpen ? '▾' : '▸'] }), summaryOpen ? _jsx(Typography, { component: "div", variant: "body2", sx: { whiteSpace: 'pre-wrap' }, children: summary.text }) : null] }) : null;
73
+ let summaryRendered = false;
61
74
  return (_jsxs(Box, { ref: scroller, onScroll: (event) => {
62
75
  const el = event.currentTarget;
63
76
  pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
@@ -66,7 +79,7 @@ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder =
66
79
  }, role: "log", "aria-live": "polite", "aria-label": t('Chat.thread'), sx: Object.assign({ minHeight: 0, flex: 1, overflowY: 'auto', p: { xs: 1.5, md: 2.5 } }, sx), children: [hasOlder ? (_jsx(Box, { sx: { textAlign: 'center', mb: 2 }, children: _jsx(Button, { size: "small", onClick: loadOlder, disabled: olderBusy, children: t('Chat.loadOlder') }) })) : null, turns.length === 0 ? (_jsx(Typography, { variant: "h6", color: "text.secondary", align: "center", sx: { mt: 6 }, children: t('Chat.emptyTitle') })) : null, turns.map((turn, index) => {
67
80
  var _a;
68
81
  const previous = turns[index - 1];
69
- return (_jsxs(Box, { sx: { mb: 3 }, children: [previous && !sameSelection(previous, turn) ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { mb: 2, color: 'text.secondary' }, role: "separator", children: [_jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } }), _jsx(Typography, { variant: "caption", children: t('Chat.modelChanged', { model: selectionLine(turn, t) }) }), _jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } })] })) : null, _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end', mb: 1.5 }, children: _jsx(Typography, { sx: { maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }, children: turn.user_text }) }), _jsx(Answer, { turn: turn, tool: index === turns.length - 1 ? tool : null, onRetry: readOnly ? undefined : onRetry, chat: chat, renderAnswerExtra: renderAnswerExtra })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
70
- }), trimmedCount ? _jsx(Chip, { size: "small", variant: "outlined", label: t(trimmedCount === 1 ? 'Chat.historyTrimmed_one' : 'Chat.historyTrimmed_other', { count: trimmedCount }), sx: CHAT_THREAD_TRIMMED_SX }) : null, notice ? (_jsx(Alert, { severity: notice.severity || 'warning', sx: CHAT_THREAD_NOTICE_SX, action: notice.action ? _jsx(Button, { color: "inherit", size: "small", onClick: notice.action.onClick, children: notice.action.label }) : null, children: notice.text })) : null, _jsx("div", { ref: end })] }));
82
+ return (_jsxs(Box, { sx: { mb: 3 }, children: [!summaryRendered && summaryVisible && turn.seq > summary.up_to_seq ? (summaryRendered = true, renderSummary()) : null, previous && !sameSelection(previous, turn) ? (_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { mb: 2, color: 'text.secondary' }, role: "separator", children: [_jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } }), _jsx(Typography, { variant: "caption", children: t('Chat.modelChanged', { model: selectionLine(turn, t) }) }), _jsx(Box, { sx: { flex: 1, borderTop: 1, borderColor: 'divider' } })] })) : null, _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end', mb: 1.5 }, children: _jsx(Typography, { sx: { maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }, children: turn.user_text }) }), _jsx(Answer, { turn: turn, tool: index === turns.length - 1 ? tool : null, onRetry: readOnly ? undefined : onRetry, chat: chat, renderAnswerExtra: renderAnswerExtra, usageToTokens: usageToTokens })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
83
+ }), !summaryRendered ? renderSummary() : null, trimmedCount ? _jsx(Chip, { size: "small", variant: "outlined", label: t(trimmedCount === 1 ? 'Chat.historyTrimmed_one' : 'Chat.historyTrimmed_other', { count: trimmedCount }), sx: CHAT_THREAD_TRIMMED_SX }) : null, notice ? (_jsx(Alert, { severity: notice.severity || 'warning', sx: CHAT_THREAD_NOTICE_SX, action: notice.action ? _jsx(Button, { color: "inherit", size: "small", onClick: notice.action.onClick, children: notice.action.label }) : null, children: notice.text })) : null, _jsx("div", { ref: end })] }));
71
84
  }
72
85
  export default ChatThread;
@@ -62,7 +62,8 @@ const appendDelta = (turns, text) => {
62
62
  * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
63
63
  * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
64
64
  */
65
- export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, getSendContext, sendRequest, onSendRequestHandled, layout = 'viewport', compactBelow = 720 }) {
65
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, getSendContext, sendRequest, onSendRequestHandled, layout = 'viewport', compactBelow = 720, tokenBalance = null, usageToTokens, longChatTokens = 40000 }) {
66
+ var _a;
66
67
  const { t } = useTranslation();
67
68
  const theme = useTheme();
68
69
  const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
@@ -86,6 +87,9 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
86
87
  }, [layout, compactBelow, viewportNarrow, status]);
87
88
  const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
88
89
  const [catalogue, setCatalogue] = useState({ entries: [], default: null });
90
+ const [summary, setSummary] = useState(null);
91
+ // The chat the current catalogue was requested for: a general catalogue must never drive the stored selection of a chat.
92
+ const [catalogueChatId, setCatalogueChatId] = useState(null);
89
93
  const [chats, setChats] = useState([]);
90
94
  const [archived, setArchived] = useState([]);
91
95
  const [nextChats, setNextChats] = useState(null);
@@ -127,6 +131,36 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
127
131
  const isArchived = Boolean(chat === null || chat === void 0 ? void 0 : chat.archived_at);
128
132
  const selection = (chat === null || chat === void 0 ? void 0 : chat.selection) || catalogue.default;
129
133
  const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
134
+ const latestUsageTurn = [...turns].reverse().find((turn) => turn.usage);
135
+ const longChat = longChatTokens > 0 && Number.isFinite((_a = latestUsageTurn === null || latestUsageTurn === void 0 ? void 0 : latestUsageTurn.usage) === null || _a === void 0 ? void 0 : _a.context_tokens) && latestUsageTurn.usage.context_tokens >= longChatTokens;
136
+ // Only the newest catalogue request may update the state, so a slow answer for a chat that was left cannot overwrite the active one.
137
+ const catalogueRequest = useRef(0);
138
+ const catalogueFor = useRef(null);
139
+ const [catalogueLoading, setCatalogueLoading] = useState(false);
140
+ const refreshCatalogue = useCallback(async (chatId) => {
141
+ const request = ++catalogueRequest.current;
142
+ setCatalogueLoading(true);
143
+ try {
144
+ const next = await api.getCatalogue(chatId !== undefined ? { chat: chatId } : {});
145
+ if (request !== catalogueRequest.current)
146
+ return null;
147
+ catalogueFor.current = chatId !== null && chatId !== void 0 ? chatId : null;
148
+ setCatalogue(next);
149
+ setCatalogueChatId(chatId !== null && chatId !== void 0 ? chatId : null);
150
+ return next;
151
+ }
152
+ catch (_a) {
153
+ // A failed load for another chat must not leave the previous chat's models on offer.
154
+ if (request === catalogueRequest.current && chatId !== undefined && catalogueFor.current !== chatId)
155
+ setCatalogue({ entries: [], default: null });
156
+ return null;
157
+ }
158
+ finally {
159
+ if (request === catalogueRequest.current)
160
+ setCatalogueLoading(false);
161
+ }
162
+ }, [api]);
163
+ const shownChatId = useRef(null);
130
164
  // Bumped on every scope change so that a list response of the previous scope is dropped.
131
165
  const scopeVersion = useRef(0);
132
166
  useEffect(() => { scopeVersion.current += 1; setChat(null); setListsLoaded(false); appliedId.current = undefined; unloadableIds.current.clear(); }, [scope]);
@@ -166,6 +200,10 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
166
200
  setStatus('error'); });
167
201
  return () => { live = false; };
168
202
  }, [api, loadLists, scope]);
203
+ useEffect(() => { if ((chat === null || chat === void 0 ? void 0 : chat.id) !== undefined)
204
+ refreshCatalogue(chat.id); }, [chat === null || chat === void 0 ? void 0 : chat.id, refreshCatalogue]);
205
+ useEffect(() => { if (pickerOpen)
206
+ refreshCatalogue(chat === null || chat === void 0 ? void 0 : chat.id); }, [pickerOpen]);
169
207
  useEffect(() => {
170
208
  var _a;
171
209
  var _b;
@@ -213,7 +251,9 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
213
251
  }, [activeChatId, status, listsLoaded]);
214
252
  // Clear the previous chat's content before the browser paints, so a host-driven switch never flashes it under the new title.
215
253
  useLayoutEffect(() => {
254
+ var _a;
216
255
  connectedChatId.current = null; // the socket of the new chat has not opened yet
256
+ shownChatId.current = (_a = chat === null || chat === void 0 ? void 0 : chat.id) !== null && _a !== void 0 ? _a : null;
217
257
  setTurns([]);
218
258
  setOlderCursor(null);
219
259
  setTool(null);
@@ -222,6 +262,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
222
262
  setSidePanelOpen(false);
223
263
  setDraft('');
224
264
  setOpenedOnce(false);
265
+ setSummary(null);
225
266
  }, [chat === null || chat === void 0 ? void 0 : chat.id]);
226
267
  useEffect(() => {
227
268
  if (!(chat === null || chat === void 0 ? void 0 : chat.id))
@@ -231,6 +272,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
231
272
  .then((page) => { if (live) {
232
273
  setTurns(chronological((page.results || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
233
274
  setOlderCursor(page.next || null);
275
+ setSummary(page.summary || null);
234
276
  } })
235
277
  .catch(() => { if (live)
236
278
  setProblem('load_error'); });
@@ -240,8 +282,10 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
240
282
  var _a;
241
283
  if (chatId !== (chat === null || chat === void 0 ? void 0 : chat.id))
242
284
  return;
243
- if (frame.type === 'history')
285
+ if (frame.type === 'history') {
244
286
  setTurns(chronological((frame.turns || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
287
+ setSummary(frame.summary || null);
288
+ }
245
289
  else if (frame.type === 'delta')
246
290
  setTurns((current) => appendDelta(current, frame.text || ''));
247
291
  else if (frame.type === 'app') {
@@ -251,11 +295,16 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
251
295
  }
252
296
  else if (frame.type === 'notice' && frame.code === 'history_trimmed')
253
297
  setTrimmedCount(frame.left_out || 0);
298
+ else if (frame.type === 'notice' && frame.code === 'summarised') {
299
+ api.listTurns(chatId).then((page) => { if (shownChatId.current === chatId)
300
+ setSummary(page.summary || null); }).catch(() => { });
301
+ }
254
302
  else if (frame.type === 'tool')
255
303
  setTool(frame.phase === 'start' ? { label: frame.label, name: frame.name } : null);
256
304
  else if (frame.type === 'turn' && frame.turn) {
257
305
  setTool(null);
258
306
  setTurns((current) => { const pending = current.findLast(isGenerating); return settleTurn(current, Object.assign(Object.assign({}, frame.turn), { appFrames: (pending === null || pending === void 0 ? void 0 : pending.appFrames) || [] })); });
307
+ refreshCatalogue(chatId);
259
308
  if (frame.turn.error_code === 'quota_exceeded')
260
309
  setProblem('quota_exceeded');
261
310
  }
@@ -266,7 +315,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
266
315
  if (frame.code === 'unavailable')
267
316
  setUnavailableReason(frame.reason || unavailableReason);
268
317
  }
269
- }, [chat === null || chat === void 0 ? void 0 : chat.id]);
318
+ }, [chat === null || chat === void 0 ? void 0 : chat.id, api, refreshCatalogue]);
270
319
  const onOpen = useCallback((chatId) => { connectedChatId.current = String(chatId); setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
271
320
  const onClose = useCallback(({ reason, retrying }) => { connectedChatId.current = null; if (!retrying)
272
321
  setProblem(reason); }, []);
@@ -370,6 +419,21 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
370
419
  setChat(updated);
371
420
  await loadLists();
372
421
  });
422
+ const fallbackApplied = useRef(new Set());
423
+ useEffect(() => {
424
+ var _a;
425
+ if (!chat || catalogueChatId !== chat.id || !catalogue.entries.length || fallbackApplied.current.has(chat.id))
426
+ return;
427
+ const valid = catalogue.entries.some((entry) => { var _a, _b, _c; return entry.id === ((_a = chat.selection) === null || _a === void 0 ? void 0 : _a.entry) && ((_b = entry.efforts) === null || _b === void 0 ? void 0 : _b.includes((_c = chat.selection) === null || _c === void 0 ? void 0 : _c.effort)); });
428
+ if (valid)
429
+ return;
430
+ const served = catalogue.entries.find((item) => { var _a, _b, _c; return item.id === ((_a = catalogue.default) === null || _a === void 0 ? void 0 : _a.entry) && ((_b = item.efforts) === null || _b === void 0 ? void 0 : _b.includes((_c = catalogue.default) === null || _c === void 0 ? void 0 : _c.effort)); });
431
+ const entry = served || catalogue.entries.find((item) => item.default) || catalogue.entries[0];
432
+ if (!entry)
433
+ return;
434
+ fallbackApplied.current.add(chat.id);
435
+ updateChat(chat, { selection: { entry: entry.id, effort: served ? catalogue.default.effort : (_a = entry.efforts) === null || _a === void 0 ? void 0 : _a[0] } });
436
+ }, [chat, catalogue, catalogueChatId, updateChat]);
373
437
  const deleteChat = guarded(async (target) => {
374
438
  await api.deleteChat(target.id);
375
439
  setChats((current) => current.filter((item) => item.id !== target.id));
@@ -462,6 +526,6 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
462
526
  if (unavailable) {
463
527
  return (_jsx(Stack, { alignItems: "center", justifyContent: "center", spacing: 1, sx: Object.assign({ minHeight: 320, p: 3, textAlign: 'center' }, sx), children: _jsx(Typography, { color: "text.secondary", children: unavailableText || t('Chat.notAvailableHere') }) }));
464
528
  }
465
- return (_jsxs(Box, { ref: rootRef, sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : `minmax(240px, 320px) minmax(0, 1fr)${chat && sidePanel ? ' minmax(240px, 320px)' : ''}`, height: '100%', minHeight: 480, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden', bgcolor: 'background.paper' }, sx), children: [narrow ? null : _jsx(Box, { sx: { borderRight: 1, borderColor: 'divider', minHeight: 0 }, children: list }), _jsxs(Stack, { sx: { minWidth: 0, minHeight: 0 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }, children: [narrow ? _jsx(Button, { color: "inherit", onClick: () => setListOpen(true), children: t('Chat.chats') }) : null, _jsx(Typography, { noWrap: true, sx: { flex: 1, fontWeight: 600 }, children: chat ? (chat.title || t('Chat.untitled')) : '' }), socket.status === 'connecting' && openedOnce ? _jsx(Chip, { size: "small", color: "warning", variant: "outlined", label: t('Chat.reconnecting') }) : null, isArchived ? _jsx(Chip, { size: "small", label: t('Chat.archived') }) : null, isArchived ? _jsx(Button, { size: "small", onClick: () => updateChat(chat, { archived: false }), children: t('Chat.restore') }) : null, chat ? (_jsx(ModelPicker, { entries: catalogue.entries, selection: selection, disabled: isArchived || running, open: pickerOpen, onOpenChange: setPickerOpen, onChange: (next) => updateChat(chat, { selection: next }), sheet: narrow })) : null, chat && sidePanel && narrow ? _jsx(Button, { color: "inherit", onClick: () => setSidePanelOpen(true), children: sidePanelLabel || t('Chat.sidePanel') }) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, chat: chat, renderAnswerExtra: answerExtra, trimmedCount: trimmedCount, tool: tool, notice: noticeFor(), hasOlder: Boolean(olderCursor), olderBusy: olderBusy, onLoadOlder: loadOlder, onRetry: retry, readOnly: isArchived }), isArchived ? _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { px: 2, pb: 1 }, children: t('Chat.archivedReadOnly') }) : null, renderComposerExtra ? _jsx(Box, { children: renderComposerExtra({ chat }) }, chat.id) : null, _jsx(ChatComposer, { value: draft, onChange: setDraft, onSend: send, onStop: () => socket.send({ type: 'stop' }), running: running, archived: isArchived, connected: socket.connected, inputRef: composerInputRef })] })) : (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 2, sx: { flex: 1, p: 3 }, children: [_jsx(Typography, { color: "text.secondary", align: "center", children: t('Chat.selectChat') }), _jsx(Button, { variant: "contained", onClick: createChat, children: t('Chat.newChat') })] }))] }), chat && sidePanel && !narrow ? _jsx(Box, { sx: { borderLeft: 1, borderColor: 'divider', minHeight: 0, overflow: 'auto' }, children: sidePanel(chat) }, chat.id) : null, narrow && chat && sidePanel ? _jsx(Drawer, { anchor: "right", open: sidePanelOpen, onClose: () => setSidePanelOpen(false), slotProps: { paper: { 'aria-label': sidePanelLabel || t('Chat.sidePanel') } }, children: _jsx(Box, { children: sidePanel(chat) }, chat.id) }) : null, narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
529
+ return (_jsxs(Box, { ref: rootRef, sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : `minmax(240px, 320px) minmax(0, 1fr)${chat && sidePanel ? ' minmax(240px, 320px)' : ''}`, height: '100%', minHeight: 480, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden', bgcolor: 'background.paper' }, sx), children: [narrow ? null : _jsx(Box, { sx: { borderRight: 1, borderColor: 'divider', minHeight: 0 }, children: list }), _jsxs(Stack, { sx: { minWidth: 0, minHeight: 0 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, sx: { px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }, children: [narrow ? _jsx(Button, { color: "inherit", onClick: () => setListOpen(true), children: t('Chat.chats') }) : null, _jsx(Typography, { noWrap: true, sx: { flex: 1, fontWeight: 600 }, children: chat ? (chat.title || t('Chat.untitled')) : '' }), socket.status === 'connecting' && openedOnce ? _jsx(Chip, { size: "small", color: "warning", variant: "outlined", label: t('Chat.reconnecting') }) : null, isArchived ? _jsx(Chip, { size: "small", label: t('Chat.archived') }) : null, isArchived ? _jsx(Button, { size: "small", onClick: () => updateChat(chat, { archived: false }), children: t('Chat.restore') }) : null, chat && sidePanel && narrow ? _jsx(Button, { color: "inherit", onClick: () => setSidePanelOpen(true), children: sidePanelLabel || t('Chat.sidePanel') }) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, chat: chat, renderAnswerExtra: answerExtra, trimmedCount: trimmedCount, tool: tool, notice: noticeFor(), hasOlder: Boolean(olderCursor), olderBusy: olderBusy, onLoadOlder: loadOlder, onRetry: retry, readOnly: isArchived, summary: summary, usageToTokens: usageToTokens }), isArchived ? _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { px: 2, pb: 1 }, children: t('Chat.archivedReadOnly') }) : null, renderComposerExtra ? _jsx(Box, { children: renderComposerExtra({ chat }) }, chat.id) : null, longChat ? _jsx(Alert, { severity: "info", action: _jsx(Button, { color: "inherit", size: "small", onClick: createChat, children: t('Chat.newConversation') }), children: t('Chat.longChat') }) : null, _jsx(ChatComposer, { value: draft, onChange: setDraft, onSend: send, onStop: () => socket.send({ type: 'stop' }), running: running, archived: isArchived, connected: socket.connected, inputRef: composerInputRef, actions: _jsx(ModelPicker, { entries: catalogue.entries, selection: selection, disabled: isArchived || running || (catalogueLoading && catalogueChatId !== chat.id), open: pickerOpen, onOpenChange: setPickerOpen, onChange: (next) => updateChat(chat, { selection: next }), sheet: narrow, balance: tokenBalance, longChat: longChat, onNewChat: createChat }) })] })) : (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 2, sx: { flex: 1, p: 3 }, children: [_jsx(Typography, { color: "text.secondary", align: "center", children: t('Chat.selectChat') }), _jsx(Button, { variant: "contained", onClick: createChat, children: t('Chat.newChat') })] }))] }), chat && sidePanel && !narrow ? _jsx(Box, { sx: { borderLeft: 1, borderColor: 'divider', minHeight: 0, overflow: 'auto' }, children: sidePanel(chat) }, chat.id) : null, narrow && chat && sidePanel ? _jsx(Drawer, { anchor: "right", open: sidePanelOpen, onClose: () => setSidePanelOpen(false), slotProps: { paper: { 'aria-label': sidePanelLabel || t('Chat.sidePanel') } }, children: _jsx(Box, { children: sidePanel(chat) }, chat.id) }) : null, narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
466
530
  }
467
531
  export default ChatWorkspace;
@@ -1,19 +1,45 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useMemo, useRef, useState } from 'react';
3
- import { Box, Button, Chip, Drawer, List, ListItemButton, ListItemText, Popover, Stack, ToggleButton, ToggleButtonGroup, Typography, useMediaQuery, useTheme } from '@mui/material';
3
+ import { Alert, Box, Button, Drawer, IconButton, List, ListItemButton, ListItemText, Popover, Stack, ToggleButton, ToggleButtonGroup, Typography, useMediaQuery, useTheme } from '@mui/material';
4
+ import CloseIcon from '@mui/icons-material/Close';
4
5
  import { useTranslation } from 'react-i18next';
5
- export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
6
- export const driverName = (t, id) => t(`Chat.driver.${id}`, { defaultValue: id });
7
- const groupByDriver = (entries) => entries.reduce((groups, entry) => {
8
- (groups[entry.driver] = groups[entry.driver] || []).push(entry);
9
- return groups;
10
- }, {});
11
- /**
12
- * Model and effort of one chat. A popover at wide widths, a bottom sheet at narrow widths. `onChange` receives
13
- * `{ entry, effort }`; the caller persists it (the change applies from the next turn). Can be controlled with `open`.
14
- */
15
- export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange, sheet }) {
16
- const { t } = useTranslation();
6
+ const LEVELS = ['top', 'best', 'balanced', 'fast'];
7
+ const EFFORTS = ['low', 'medium', 'high', 'max'];
8
+ export const CHAT_MODEL_TRIGGER_SX = { minWidth: 0, px: 1.25, color: 'text.secondary', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: { xs: 140, sm: 'none' } };
9
+ export const CHAT_MODEL_ENTRY_SX = { borderRadius: 1, alignItems: 'flex-start' };
10
+ export const CHAT_MODEL_EFFORT_SX = { flex: 1, minWidth: 0 };
11
+ export const CHAT_MODEL_CLOSE_SX = { ml: 'auto' };
12
+ const supported = (entry, effort) => { var _a; return (_a = entry === null || entry === void 0 ? void 0 : entry.efforts) === null || _a === void 0 ? void 0 : _a.includes(effort); };
13
+ const validSelection = (entries, selection) => {
14
+ const entry = entries.find((item) => item.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
15
+ return entry && supported(entry, selection === null || selection === void 0 ? void 0 : selection.effort) ? selection : null;
16
+ };
17
+ const fallback = (entries) => {
18
+ var _a;
19
+ const entry = entries.find((item) => item.default) || entries[0];
20
+ return entry ? { entry: entry.id, effort: (_a = entry.efforts) === null || _a === void 0 ? void 0 : _a[0] } : null;
21
+ };
22
+ const cleanLabel = (entry) => ((entry === null || entry === void 0 ? void 0 : entry.label) || (entry === null || entry === void 0 ? void 0 : entry.model) || '').replace(/\s+\d+(?:\.\d+)*$/, '');
23
+ function estimateText(entry, effort, t, language) {
24
+ var _a;
25
+ if (entry.billing === 'subscription')
26
+ return t('Chat.noTokenSubscription');
27
+ const estimate = (_a = entry.estimate) === null || _a === void 0 ? void 0 : _a[effort];
28
+ if (!estimate)
29
+ return null;
30
+ if (estimate.unit === 'tokens') {
31
+ const format = new Intl.NumberFormat(language);
32
+ return t('Chat.estimateTokens', { lo: format.format(estimate.min), hi: format.format(estimate.max) });
33
+ }
34
+ if (estimate.unit === 'level') {
35
+ const level = Math.min(3, Math.max(1, Math.round(estimate.min)));
36
+ return _jsxs(Box, { component: "span", "aria-label": t(`Chat.costScale.${level}`), children: ['●'.repeat(level), '○'.repeat(3 - level)] });
37
+ }
38
+ return null;
39
+ }
40
+ export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange, sheet, balance = null, longChat = false, onNewChat }) {
41
+ const { t, i18n } = useTranslation();
42
+ const language = typeof (i18n === null || i18n === void 0 ? void 0 : i18n.language) === 'string' ? i18n.language.split('_')[0] || undefined : undefined;
17
43
  const theme = useTheme();
18
44
  const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
19
45
  const narrow = sheet !== null && sheet !== void 0 ? sheet : viewportNarrow;
@@ -21,16 +47,40 @@ export function ModelPicker({ entries = [], selection, onChange, disabled = fals
21
47
  const [innerOpen, setInnerOpen] = useState(false);
22
48
  const isOpen = open !== null && open !== void 0 ? open : innerOpen;
23
49
  const setOpen = (value) => { setInnerOpen(value); onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(value); };
24
- const groups = useMemo(() => groupByDriver(entries), [entries]);
25
- const active = entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
26
- const pickEntry = (entry) => {
27
- var _a, _b;
28
- const effort = ((_a = entry.efforts) === null || _a === void 0 ? void 0 : _a.includes(selection === null || selection === void 0 ? void 0 : selection.effort)) ? selection.effort : (_b = entry.efforts) === null || _b === void 0 ? void 0 : _b[0];
29
- onChange === null || onChange === void 0 ? void 0 : onChange({ entry: entry.id, effort });
50
+ const activeSelection = useMemo(() => validSelection(entries, selection) || fallback(entries), [entries, selection]);
51
+ const active = entries.find((entry) => entry.id === (activeSelection === null || activeSelection === void 0 ? void 0 : activeSelection.entry));
52
+ const effort = activeSelection === null || activeSelection === void 0 ? void 0 : activeSelection.effort;
53
+ const hasTokenEstimates = entries.some((entry) => Object.values(entry.estimate || {}).some((item) => item.unit === 'tokens'));
54
+ const grouped = LEVELS.map((level) => [level, entries.filter((entry) => entry.level === level)]).filter(([, items]) => items.length);
55
+ const unknown = entries.filter((entry) => !LEVELS.includes(entry.level));
56
+ const selectEntry = (entry) => { var _a; return onChange === null || onChange === void 0 ? void 0 : onChange({ entry: entry.id, effort: supported(entry, effort) ? effort : (_a = entry.efforts) === null || _a === void 0 ? void 0 : _a[0] }); };
57
+ const providerName = (entry) => ({ anthropic: 'Anthropic', claude_code: 'Anthropic', openai: 'OpenAI' })[entry.provider];
58
+ const entryRow = (entry) => {
59
+ var _a;
60
+ const shown = supported(entry, effort) ? effort : (_a = entry.efforts) === null || _a === void 0 ? void 0 : _a[0];
61
+ const estimate = estimateText(entry, shown, t, language);
62
+ const provider = providerName(entry);
63
+ const checked = entry.id === (active === null || active === void 0 ? void 0 : active.id);
64
+ return _jsx(ListItemButton, { role: "radio", "aria-checked": checked, tabIndex: checked ? 0 : -1, "data-model-entry": entry.id, selected: checked, sx: CHAT_MODEL_ENTRY_SX, onClick: () => selectEntry(entry), children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: { fontWeight: 600 }, children: entry.label || entry.model }), secondary: _jsxs(_Fragment, { children: [provider ? t('Chat.by', { provider }) : null, estimate ? _jsx(Box, { component: "span", sx: { display: 'block' }, children: estimate }) : null] }) }) }, entry.id);
65
+ };
66
+ // Radio-group keyboard behaviour: arrows move the focus and the choice, only the checked row is a tab stop.
67
+ const moveSelection = (event) => {
68
+ var _a;
69
+ const keys = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 };
70
+ if (!(event.key in keys))
71
+ return;
72
+ const order = [...grouped.flatMap(([, items]) => items), ...unknown];
73
+ const at = order.findIndex((entry) => entry.id === (active === null || active === void 0 ? void 0 : active.id));
74
+ const next = order[(at + keys[event.key] + order.length) % order.length];
75
+ if (!next)
76
+ return;
77
+ event.preventDefault();
78
+ selectEntry(next);
79
+ (_a = [...event.currentTarget.querySelectorAll('[data-model-entry]')].find((node) => node.dataset.modelEntry === next.id)) === null || _a === void 0 ? void 0 : _a.focus();
30
80
  };
31
- const content = (_jsx(Box, { sx: { p: 2, minWidth: narrow ? 0 : 320, maxWidth: '100%' }, children: _jsxs(Stack, { spacing: 1.5, children: [Object.entries(groups).map(([driver, items]) => (_jsxs(Box, { role: "group", "aria-label": driverName(t, driver), children: [_jsx(Typography, { variant: "overline", color: "text.secondary", children: driverName(t, driver) }), _jsx(List, { dense: true, disablePadding: true, children: items.map((entry) => (_jsxs(ListItemButton, { selected: entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry), onClick: () => pickEntry(entry), sx: { minHeight: 40, borderRadius: 1 }, children: [_jsx(ListItemText, { primary: entry.label || entry.model }), entry.billing === 'pay_per_use' ? _jsx(Chip, { size: "small", variant: "outlined", label: t('Chat.payPerUse') }) : null] }, entry.id))) })] }, driver))), _jsxs(Box, { children: [_jsx(Typography, { variant: "overline", color: "text.secondary", children: t('Chat.effortLabel') }), _jsx(ToggleButtonGroup, { exclusive: true, fullWidth: true, size: "small", "aria-label": t('Chat.effortLabel'), value: (selection === null || selection === void 0 ? void 0 : selection.effort) || null, onChange: (_event, effort) => { if (effort && active)
32
- onChange === null || onChange === void 0 ? void 0 : onChange({ entry: active.id, effort }); }, children: EFFORT_LEVELS.map((effort) => { var _a; return (_jsx(ToggleButton, { value: effort, disabled: !((_a = active === null || active === void 0 ? void 0 : active.efforts) === null || _a === void 0 ? void 0 : _a.includes(effort)), sx: { minHeight: 40 }, children: t(`Chat.effort.${effort}`) }, effort)); }) })] })] }) }));
33
- const label = active ? [active.label || active.model, (selection === null || selection === void 0 ? void 0 : selection.effort) && t(`Chat.effort.${selection.effort}`)].filter(Boolean).join(' · ') : t('Chat.chooseModel');
34
- return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: anchor, size: "small", color: "inherit", disabled: disabled || entries.length === 0, onClick: () => setOpen(true), "aria-haspopup": "dialog", children: label }), narrow ? (_jsx(Drawer, { anchor: "bottom", open: isOpen, onClose: () => setOpen(false), children: content })) : (_jsx(Popover, { open: isOpen, anchorEl: anchor.current, onClose: () => setOpen(false), anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, children: content }))] }));
81
+ const content = (_jsx(Box, { role: "dialog", "aria-labelledby": "chat-model-picker-title", sx: { p: 2, width: narrow ? 'auto' : 360, maxWidth: '100%', maxHeight: narrow ? '82vh' : 'min(640px, 80vh)', overflowY: 'auto' }, children: _jsxs(Stack, { spacing: 1.25, children: [_jsxs(Stack, { direction: "row", alignItems: "center", gap: 1, children: [_jsx(Typography, { id: "chat-model-picker-title", variant: "subtitle1", sx: { fontWeight: 600 }, children: t('Chat.pickerTitle') }), _jsx(IconButton, { "aria-label": t('Chat.close'), size: "small", sx: CHAT_MODEL_CLOSE_SX, onClick: () => setOpen(false), children: _jsx(CloseIcon, { fontSize: "small" }) })] }), _jsxs(Box, { role: "radiogroup", "aria-labelledby": "chat-model-picker-title", onKeyDown: moveSelection, children: [grouped.map(([level, items]) => _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", children: t(`Chat.level.${level}`) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t(`Chat.levelHint.${level}`) }), _jsx(List, { dense: true, disablePadding: true, children: items.map(entryRow) })] }, level)), unknown.length ? _jsx(List, { dense: true, disablePadding: true, children: unknown.map(entryRow) }) : null] }), active ? _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", sx: { mb: 0.75 }, children: t('Chat.effortQuestion') }), _jsx(ToggleButtonGroup, { exclusive: true, fullWidth: true, size: "small", value: effort || null, "aria-label": t('Chat.effortQuestion'), onChange: (_event, next) => { if (next)
82
+ onChange === null || onChange === void 0 ? void 0 : onChange({ entry: active.id, effort: next }); }, children: EFFORTS.filter((item) => { var _a; return (_a = active.efforts) === null || _a === void 0 ? void 0 : _a.includes(item); }).map((item) => _jsx(ToggleButton, { value: item, sx: CHAT_MODEL_EFFORT_SX, children: t(`Chat.effort.${item}`) }, item)) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t(hasTokenEstimates ? 'Chat.effortHintTokens' : 'Chat.effortHint') })] }) : null, longChat ? _jsx(Alert, { severity: "warning", action: onNewChat ? _jsx(Button, { color: "inherit", size: "small", onClick: onNewChat, children: t('Chat.newConversation') }) : null, children: t('Chat.longChat') }) : null, Number.isInteger(balance) && balance >= 0 ? _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('Chat.balanceLabel', { n: new Intl.NumberFormat(language).format(balance) }) }) : null] }) }));
83
+ const trigger = _jsx(Button, { ref: anchor, size: "small", color: "inherit", sx: CHAT_MODEL_TRIGGER_SX, disabled: disabled || !entries.length, onClick: () => setOpen(true), "aria-haspopup": "dialog", children: _jsx(Box, { component: "span", sx: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: active ? `${cleanLabel(active)} · ${t(`Chat.effort.${effort}`)} ▾` : t('Chat.chooseModel') }) });
84
+ return _jsxs(_Fragment, { children: [trigger, narrow ? _jsx(Drawer, { anchor: "bottom", open: isOpen, onClose: () => setOpen(false), children: content }) : _jsx(Popover, { open: isOpen, anchorEl: anchor.current, onClose: () => setOpen(false), anchorOrigin: { vertical: 'top', horizontal: 'left' }, transformOrigin: { vertical: 'bottom', horizontal: 'left' }, children: content })] });
35
85
  }
36
86
  export default ModelPicker;
package/dist/chat/api.js CHANGED
@@ -4,7 +4,7 @@ export function createChatApi(overrides = {}, { apiBase = '/api/assistant/' } =
4
4
  const urls = chatApiUrls(apiBase);
5
5
  const data = (request) => request.then((response) => response.data);
6
6
  const defaults = {
7
- getCatalogue: () => data(apiClient.get(urls.catalogue)),
7
+ getCatalogue: ({ chat } = {}) => data(chat !== undefined ? apiClient.get(urls.catalogue, { params: { chat } }) : apiClient.get(urls.catalogue)),
8
8
  listChats: ({ archived = false, cursor, scope } = {}) => data(apiClient.get(urls.chats, { params: Object.assign(Object.assign({ archived }, (cursor ? { cursor } : {})), (scope !== undefined ? { scope } : {})) })),
9
9
  createChat: (payload = {}) => data(apiClient.post(urls.chats, payload)),
10
10
  updateChat: (id, patch) => data(apiClient.patch(urls.chat(id), patch)),
@@ -1,7 +1,14 @@
1
1
  import { createKitSxEntry } from '../theme/kitSxEntry';
2
+ import { CHAT_COMPOSER_INPUT_SX } from './ChatComposer';
2
3
  import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from './ChatList';
3
4
  import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX, CHAT_THREAD_TRIMMED_SX } from './ChatThread';
5
+ import { CHAT_MODEL_TRIGGER_SX, CHAT_MODEL_ENTRY_SX, CHAT_MODEL_EFFORT_SX, CHAT_MODEL_CLOSE_SX } from './ModelPicker';
4
6
  export const CHAT_KIT_SX_REGISTRY = [
7
+ createKitSxEntry('ChatComposer.input', 'MuiTextField', CHAT_COMPOSER_INPUT_SX, 'CHAT_COMPOSER_INPUT_SX'),
8
+ createKitSxEntry('ModelPicker.trigger', 'MuiButton', CHAT_MODEL_TRIGGER_SX, 'CHAT_MODEL_TRIGGER_SX'),
9
+ createKitSxEntry('ModelPicker.entry', 'MuiListItemButton', CHAT_MODEL_ENTRY_SX, 'CHAT_MODEL_ENTRY_SX'),
10
+ createKitSxEntry('ModelPicker.effort', 'MuiToggleButton', CHAT_MODEL_EFFORT_SX, 'CHAT_MODEL_EFFORT_SX'),
11
+ createKitSxEntry('ModelPicker.close', 'MuiIconButton', CHAT_MODEL_CLOSE_SX, 'CHAT_MODEL_CLOSE_SX'),
5
12
  createKitSxEntry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX, 'CHAT_LIST_ROW_ACTION_SX'),
6
13
  createKitSxEntry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX, 'CHAT_LIST_ARCHIVED_ACTION_SX'),
7
14
  createKitSxEntry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX, 'CHAT_THREAD_TOOL_CHIP_SX'),
@@ -19,15 +19,35 @@ export const chatTranslations = {
19
19
  'Chat.retry': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer', sw: 'Jaribu tena' },
20
20
  'Chat.interrupted': { de: 'Unterbrochen', en: 'Interrupted', fr: 'Interrompue', sw: 'Imekatizwa' },
21
21
  'Chat.reconnecting': { de: 'Verbindung wird wiederhergestellt', en: 'Reconnecting', fr: 'Reconnexion', sw: 'Inaunganisha tena' },
22
- 'Chat.payPerUse': { de: 'Nutzungsabhängig', en: 'pay per use', fr: 'à l’usage', sw: 'lipa kwa matumizi' },
23
22
  'Chat.chooseModel': { de: 'Modell wählen', en: 'Choose model', fr: 'Choisir un modèle', sw: 'Chagua modeli' },
24
- 'Chat.effort.low': { de: 'Niedrig', en: 'Low', fr: 'Faible', sw: 'Chini' },
25
- 'Chat.effort.medium': { de: 'Mittel', en: 'Medium', fr: 'Moyen', sw: 'Wastani' },
26
- 'Chat.effort.high': { de: 'Hoch', en: 'High', fr: 'Élevé', sw: 'Juu' },
27
- 'Chat.effort.max': { de: 'Maximum', en: 'Max', fr: 'Maximal', sw: 'Juu zaidi' },
28
- 'Chat.driver.claude_cli': { de: 'Claude Code (Abo)', en: 'Claude Code subscription', fr: 'Claude Code (abonnement)', sw: 'Claude Code (usajili)' },
29
- 'Chat.driver.anthropic_api': { de: 'Anthropic API', en: 'Anthropic API', fr: 'API Anthropic', sw: 'API ya Anthropic' },
30
- 'Chat.driver.openai_api': { de: 'OpenAI API', en: 'OpenAI API', fr: 'API OpenAI', sw: 'API ya OpenAI' },
23
+ 'Chat.effort.low': { de: 'Schnell', en: 'Quick', fr: 'Rapide', sw: 'Haraka' },
24
+ 'Chat.effort.medium': { de: 'Gründlich', en: 'Thorough', fr: 'Approfondi', sw: 'Kwa kina' },
25
+ 'Chat.effort.high': { de: 'Sehr gründlich', en: 'Very thorough', fr: 'Très approfondi', sw: 'Kwa kina zaidi' },
26
+ 'Chat.effort.max': { de: 'Maximal', en: 'Maximum', fr: 'Maximum', sw: 'Kiwango cha juu' },
27
+ 'Chat.pickerTitle': { de: 'Modell für diese Unterhaltung', en: 'Model for this conversation', fr: 'Modèle pour cette conversation', sw: 'Modeli ya mazungumzo haya' },
28
+ 'Chat.close': { de: 'Schliessen', en: 'Close', fr: 'Fermer', sw: 'Funga' },
29
+ 'Chat.level.top': { de: 'Höchste Qualität', en: 'Highest quality', fr: 'Qualité maximale', sw: 'Ubora wa juu zaidi' },
30
+ 'Chat.levelHint.top': { de: 'für besonders anspruchsvolle Auswertungen', en: 'for especially demanding analysis', fr: 'pour les analyses particulièrement exigeantes', sw: 'kwa uchanganuzi wenye mahitaji makubwa' },
31
+ 'Chat.level.best': { de: 'Hohe Qualität', en: 'High quality', fr: 'Haute qualité', sw: 'Ubora wa juu' },
32
+ 'Chat.levelHint.best': { de: 'für schwierige Abgrenzungs- und Codierfragen', en: 'for difficult distinction and coding questions', fr: 'pour les questions difficiles de distinction et de codage', sw: 'kwa maswali magumu ya kutofautisha na kuweka misimbo' },
33
+ 'Chat.level.balanced': { de: 'Ausgewogen', en: 'Balanced', fr: 'Équilibré', sw: 'Wastani' },
34
+ 'Chat.levelHint.balanced': { de: 'für die meisten Fragen', en: 'for most questions', fr: 'pour la plupart des questions', sw: 'kwa maswali mengi' },
35
+ 'Chat.level.fast': { de: 'Schnell und günstig', en: 'Fast and cheap', fr: 'Rapide et économique', sw: 'Haraka na nafuu' },
36
+ 'Chat.levelHint.fast': { de: 'für kurze Nachfragen', en: 'for short follow-up questions', fr: 'pour les courtes questions de suivi', sw: 'kwa maswali mafupi ya ufuatiliaji' },
37
+ 'Chat.by': { de: 'von {{provider}}', en: 'by {{provider}}', fr: 'par {{provider}}', sw: 'na {{provider}}' },
38
+ 'Chat.estimateTokens': { de: '≈ {{lo}}–{{hi}} Token pro Antwort', en: '≈ {{lo}}–{{hi}} tokens per reply', fr: '≈ {{lo}}–{{hi}} jetons par réponse', sw: '≈ {{lo}}–{{hi}} tokeni kwa jibu' },
39
+ 'Chat.noTokenSubscription': { de: 'ohne Token · Claude-Abo', en: 'no tokens · Claude subscription', fr: 'sans jetons · abonnement Claude', sw: 'bila tokeni · usajili wa Claude' },
40
+ 'Chat.costScale.1': { de: 'Kosten niedrig', en: 'Low cost', fr: 'Coût faible', sw: 'Gharama ndogo' },
41
+ 'Chat.costScale.2': { de: 'Kosten mittel', en: 'Medium cost', fr: 'Coût moyen', sw: 'Gharama ya wastani' },
42
+ 'Chat.costScale.3': { de: 'Kosten hoch', en: 'High cost', fr: 'Coût élevé', sw: 'Gharama kubwa' },
43
+ 'Chat.effortQuestion': { de: 'Wie gründlich soll die Antwort sein?', en: 'How thorough should the answer be?', fr: 'Quel niveau d’approfondissement souhaitez-vous ?', sw: 'Jibu liwe na kina kiasi gani?' },
44
+ 'Chat.effortHintTokens': { de: 'Gründlicher heisst bessere Antworten, aber längere Wartezeit und mehr Token.', en: 'More thorough means better answers, but longer waits and more tokens.', fr: 'Une réponse plus approfondie est meilleure, mais prend plus de temps et consomme plus de jetons.', sw: 'Jibu lenye kina zaidi huwa bora, lakini husubiriwa muda mrefu na hutumia tokeni zaidi.' },
45
+ 'Chat.effortHint': { de: 'Gründlicher heisst bessere Antworten, aber längere Wartezeit.', en: 'More thorough means better answers, but longer waits.', fr: 'Une réponse plus approfondie est meilleure, mais prend plus de temps.', sw: 'Jibu lenye kina zaidi huwa bora, lakini husubiriwa muda mrefu.' },
46
+ 'Chat.longChat': { de: 'Diese Unterhaltung ist lang. Ältere Teile werden zusammengefasst; eine neue Unterhaltung ist günstiger.', en: 'This conversation is long. Older parts will be summarized; a new conversation costs less.', fr: 'Cette conversation est longue. Les anciens passages seront résumés ; une nouvelle conversation coûte moins cher.', sw: 'Mazungumzo haya ni marefu. Sehemu za awali zitafupishwa; mazungumzo mapya yatagharimu kidogo.' },
47
+ 'Chat.newConversation': { de: 'Neue Unterhaltung', en: 'New conversation', fr: 'Nouvelle conversation', sw: 'Mazungumzo mapya' },
48
+ 'Chat.balanceLabel': { de: 'Guthaben {{n}} Token', en: 'Balance: {{n}} tokens', fr: 'Solde : {{n}} jetons', sw: 'Salio: tokeni {{n}}' },
49
+ 'Chat.summaryUntilHere': { de: 'Zusammenfassung bis hier', en: 'Summary so far', fr: 'Résumé jusqu’ici', sw: 'Muhtasari hadi hapa' },
50
+ 'Chat.replyTokens': { de: '{{n}} Token', en: '{{n}} tokens', fr: '{{n}} jetons', sw: 'tokeni {{n}}' },
31
51
  'Chat.markdownTable': { de: 'Tabelleninhalt', en: 'Table content', fr: 'Contenu du tableau', sw: 'Yaliyomo kwenye jedwali' },
32
52
  'Chat.markdownCode': { de: 'Codeblock', en: 'Code block', fr: 'Bloc de code', sw: 'Kizuizi cha msimbo' },
33
53
  'Chat.changeModel': { de: 'Modell ändern', en: 'Change model', fr: 'Changer de modèle', sw: 'Badilisha modeli' },
@@ -0,0 +1,38 @@
1
+ export const receiptsTranslations = {
2
+ 'Receipts.CAMERA': { de: 'Beleg fotografieren', en: 'Take a receipt photo', fr: 'Photographier un reçu', sw: 'Piga picha ya risiti' },
3
+ 'Receipts.PICK_FILE': { de: 'Datei wählen', en: 'Choose a file', fr: 'Choisir un fichier', sw: 'Chagua faili' },
4
+ 'Receipts.ACCEPTED_TYPES': { de: 'JPG, PNG, HEIC oder PDF', en: 'JPG, PNG, HEIC or PDF', fr: 'JPG, PNG, HEIC ou PDF', sw: 'JPG, PNG, HEIC au PDF' },
5
+ 'Receipts.READING': { de: 'Beleg wird gelesen …', en: 'Reading receipt …', fr: 'Lecture du reçu …', sw: 'Risiti inasomwa …' },
6
+ 'Receipts.ANOTHER': { de: 'Anderer Beleg', en: 'Another receipt', fr: 'Un autre reçu', sw: 'Risiti nyingine' },
7
+ 'Receipts.REVIEW_TITLE': { de: 'Erkannte Werte', en: 'Recognised values', fr: 'Valeurs reconnues', sw: 'Thamani zilizotambuliwa' },
8
+ 'Receipts.SAME_AS_FORM': { de: 'wie im Formular', en: 'same as in the form', fr: 'identique au formulaire', sw: 'sawa na iliyo kwenye fomu' },
9
+ 'Receipts.APPLY': { de: 'Übernehmen', en: 'Apply', fr: 'Appliquer', sw: 'Tumia' },
10
+ 'Receipts.ONLY_EMPTY': { de: 'Nur leere Felder', en: 'Empty fields only', fr: 'Champs vides uniquement', sw: 'Sehemu tupu pekee' },
11
+ 'Receipts.APPLY_ALL': { de: 'Alle übernehmen', en: 'Apply all', fr: 'Tout appliquer', sw: 'Tumia zote' },
12
+ 'Receipts.CLOSE': { de: 'Schliessen', en: 'Close', fr: 'Fermer', sw: 'Funga' },
13
+ 'Receipts.CERTAIN': { de: 'sicher', en: 'confident', fr: 'sûr', sw: 'uhakika' },
14
+ 'Receipts.UNCERTAIN': { de: 'unsicher', en: 'uncertain', fr: 'incertain', sw: 'si ya uhakika' },
15
+ 'Receipts.RECOGNISED_COUNT': { de: '{{n}} Belege erkannt', en: '{{n}} receipts recognised', fr: '{{n}} reçus reconnus', sw: 'Risiti {{n}} zimetambuliwa' },
16
+ 'Receipts.APPLY_COUNT': { de: '{{n}} übernehmen', en: 'Apply {{n}}', fr: 'Appliquer {{n}}', sw: 'Tumia {{n}}' },
17
+ 'Receipts.UNREADABLE': { de: 'Unleserlich', en: 'Unreadable', fr: 'Illisible', sw: 'Haijasomeka' },
18
+ 'Receipts.AMOUNT_UNKNOWN': { de: 'Betrag nicht erkannt', en: 'Amount not recognised', fr: 'Montant non reconnu', sw: 'Kiasi hakikutambuliwa' },
19
+ 'Receipts.CONSENT': { de: 'Belege dürfen für die automatische Erkennung an {{provider}} übermittelt werden. Jederzeit widerrufbar.', en: 'Receipts may be sent to {{provider}} for automatic recognition. You can withdraw consent at any time.', fr: 'Les reçus peuvent être transmis à {{provider}} pour reconnaissance automatique. Vous pouvez retirer votre consentement à tout moment.', sw: 'Risiti zinaweza kutumwa kwa {{provider}} ili zitambuliwe kiotomatiki. Unaweza kuondoa ruhusa wakati wowote.' },
20
+ 'Receipts.UPLOAD_ONLY': { de: 'Nur hochladen', en: 'Upload only', fr: 'Téléverser uniquement', sw: 'Pakia pekee' },
21
+ 'Receipts.NOTHING_RECOGNISED': { de: 'Auf diesem Beleg wurden keine Angaben erkannt. Bitte von Hand erfassen.', en: 'No details were recognised on this receipt. Please enter them manually.', fr: 'Aucune information n’a été reconnue sur ce reçu. Veuillez les saisir manuellement.', sw: 'Hakuna maelezo yaliyotambuliwa kwenye risiti hii. Tafadhali yaweke mwenyewe.' },
22
+ 'Receipts.UNAVAILABLE': { de: 'Die Belegerkennung ist im Moment nicht verfügbar. Bitte von Hand erfassen.', en: 'Receipt recognition is unavailable right now. Please enter the details manually.', fr: 'La reconnaissance des reçus est momentanément indisponible. Veuillez saisir les informations manuellement.', sw: 'Utambuzi wa risiti haupatikani kwa sasa. Tafadhali weka maelezo mwenyewe.' },
23
+ 'Receipts.FILE_PROBLEM': { de: 'Diese Datei kann nicht gelesen werden. Erlaubt sind JPG, PNG, HEIC und PDF bis 10 MB.', en: 'This file cannot be read. JPG, PNG, HEIC and PDF files up to 10 MB are allowed.', fr: 'Impossible de lire ce fichier. Les fichiers JPG, PNG, HEIC et PDF jusqu’à 10 Mo sont acceptés.', sw: 'Faili hii haiwezi kusomwa. JPG, PNG, HEIC na PDF hadi MB 10 zinaruhusiwa.' },
24
+ 'Receipts.FIELD_merchant': { de: 'Händler', en: 'Merchant', fr: 'Commerçant', sw: 'Muuzaji' },
25
+ 'Receipts.FIELD_date': { de: 'Datum', en: 'Date', fr: 'Date', sw: 'Tarehe' },
26
+ 'Receipts.FIELD_total': { de: 'Betrag', en: 'Amount', fr: 'Montant', sw: 'Kiasi' },
27
+ 'Receipts.FIELD_currency': { de: 'Währung', en: 'Currency', fr: 'Devise', sw: 'Sarafu' },
28
+ 'Receipts.FIELD_payment_method': { de: 'Zahlungsart', en: 'Payment method', fr: 'Mode de paiement', sw: 'Njia ya malipo' },
29
+ 'Receipts.FIELD_account': { de: 'Konto', en: 'Account', fr: 'Compte', sw: 'Akaunti' },
30
+ 'Receipts.FIELD_items': { de: 'Positionen', en: 'Line items', fr: 'Articles', sw: 'Vipengee' },
31
+ 'Receipts.CARD_LABEL': { de: 'Beleg', en: 'Receipt', fr: 'Reçu', sw: 'Risiti' },
32
+ 'Receipts.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler', sw: 'Ghairi' },
33
+ 'Receipts.PAYMENT_cash': { de: 'Bar', en: 'Cash', fr: 'Espèces', sw: 'Taslimu' },
34
+ 'Receipts.PAYMENT_card': { de: 'Karte', en: 'Card', fr: 'Carte', sw: 'Kadi' },
35
+ 'Receipts.PAYMENT_other': { de: 'Andere', en: 'Other', fr: 'Autre', sw: 'Nyingine' },
36
+ 'Receipts.CONSENT_ERROR': { de: 'Die Einwilligung konnte nicht geladen werden.', en: 'Consent could not be loaded.', fr: 'Impossible de charger le consentement.', sw: 'Ruhusa haikuweza kupakiwa.' },
37
+ 'Receipts.RETRY': { de: 'Erneut versuchen', en: 'Try again', fr: 'Réessayer', sw: 'Jaribu tena' },
38
+ };
@@ -15,10 +15,11 @@ import { chartsTranslations } from './chartsTranslations';
15
15
  import { chatTranslations } from './chatTranslations';
16
16
  import { messagingTranslations } from './messagingTranslations';
17
17
  import { notificationsTranslations } from './notificationsTranslations';
18
+ import { receiptsTranslations } from './receiptsTranslations';
18
19
  import { onboardingTranslations } from './onboardingTranslations';
19
20
  import { sectionNavTranslations } from './sectionNavTranslations';
20
21
  import { userMenuTranslations } from './userMenuTranslations';
21
- export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), billingTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
22
+ export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), billingTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), receiptsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
22
23
  export function createUiCoreTranslations({ germanVariant = 'formal' } = {}) {
23
24
  return Object.fromEntries(Object.entries(uiCoreTranslations).map(([key, value]) => {
24
25
  const { de_informal } = value, rest = __rest(value, ["de_informal"]);
package/dist/index.js CHANGED
@@ -99,4 +99,13 @@ export { chartsTranslations } from './i18n/chartsTranslations';
99
99
  export { messagingTranslations } from './i18n/messagingTranslations';
100
100
  export { chatTranslations } from './i18n/chatTranslations';
101
101
  export { billingTranslations } from './i18n/billingTranslations';
102
+ export { receiptsTranslations } from './i18n/receiptsTranslations';
103
+ export { ReceiptCaptureCard } from './receipts/ReceiptCaptureCard';
104
+ export { ReceiptReviewDialog } from './receipts/ReceiptReviewDialog';
105
+ export { ReceiptPicker } from './receipts/ReceiptPicker';
106
+ export { ReceiptConsentSwitch } from './receipts/ReceiptConsentSwitch';
107
+ export { useReceiptConsent } from './receipts/useReceiptConsent';
108
+ export { createReceiptsApi, defaultReceiptsApi } from './receipts/api';
109
+ export { receiptFileProblem, prepareReceiptFile } from './receipts/files';
110
+ export { RECEIPT_ACCEPT, RECEIPT_MAX_BYTES } from './receipts/wire';
102
111
  export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
@@ -0,0 +1,119 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import { Alert, Box, Button, Card, CardContent, LinearProgress, Paper, Stack, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { defaultReceiptsApi } from './api';
6
+ import { RECEIPT_ACCEPT } from './wire';
7
+ import { prepareReceiptFile, receiptFileProblem } from './files';
8
+ import { useReceiptConsent } from './useReceiptConsent';
9
+ import ReceiptConsentSwitch from './ReceiptConsentSwitch';
10
+ import ReceiptPicker from './ReceiptPicker';
11
+ import ReceiptReviewDialog from './ReceiptReviewDialog';
12
+ export const RECEIPT_CAPTURE_CARD_SX = { width: '100%' };
13
+ export const RECEIPT_CAPTURE_ALERT_SX = { width: '100%' };
14
+ export const RECEIPT_CAPTURE_ACTION_SX = { flex: '1 1 auto' };
15
+ export const RECEIPT_CAPTURE_PROGRESS_SX = { width: '100%' };
16
+ const ERROR_TEXT = { unsupported_file_type: 'file', file_too_large: 'file', invalid_request: 'file', nothing_recognised: 'nothing', provider_unavailable: 'unavailable', spend_cap_reached: 'unavailable', count_limit_reached: 'unavailable', authentication_required: 'unavailable' };
17
+ const problemCode = (error) => { var _a, _b; return ((_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.code) || 'provider_unavailable'; };
18
+ export function ReceiptCaptureCard({ api = defaultReceiptsApi, provider = 'OpenAI', context, values, fieldLabels, extraFields, accountLabel, onApply, onSelectReceipts, confirmLabel, onUploadOnly, onFile, onReset, disabled, sx }) {
19
+ const { t } = useTranslation();
20
+ const consent = useReceiptConsent(api);
21
+ const [phase, setPhase] = useState('idle'), [error, setError] = useState(null), [receipt, setReceipt] = useState(null), [receipts, setReceipts] = useState([]), [progress, setProgress] = useState(null), [reviewOpen, setReviewOpen] = useState(false);
22
+ const [file, setFile] = useState(null), [preview, setPreview] = useState(null);
23
+ const cameraRef = useRef(null), fileRef = useRef(null), uploadRef = useRef(null), abortRef = useRef(null), urlRef = useRef(null);
24
+ const clearUrl = () => { if (urlRef.current)
25
+ URL.revokeObjectURL(urlRef.current); urlRef.current = null; setPreview(null); };
26
+ const reset = () => { var _a; (_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort(); abortRef.current = null; clearUrl(); setFile(null); setReceipt(null); setReceipts([]); setProgress(null); setError(null); setPhase('idle'); setReviewOpen(false); onReset === null || onReset === void 0 ? void 0 : onReset(); };
27
+ useEffect(() => () => { var _a; (_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort(); if (urlRef.current)
28
+ URL.revokeObjectURL(urlRef.current); }, []);
29
+ useEffect(() => { var _a; if (consent.status === 'ready' && !consent.granted && phase === 'reading') {
30
+ (_a = abortRef.current) === null || _a === void 0 ? void 0 : _a.abort();
31
+ setPhase('idle');
32
+ } }, [consent.status, consent.granted, phase]);
33
+ const choose = async (raw, uploadOnly = false) => {
34
+ var _a, _b, _c;
35
+ const incoming = ((_b = (_a = raw === null || raw === void 0 ? void 0 : raw.target) === null || _a === void 0 ? void 0 : _a.files) === null || _b === void 0 ? void 0 : _b[0]) || raw;
36
+ if (raw === null || raw === void 0 ? void 0 : raw.target)
37
+ raw.target.value = '';
38
+ if (!incoming)
39
+ return;
40
+ (_c = abortRef.current) === null || _c === void 0 ? void 0 : _c.abort();
41
+ clearUrl();
42
+ setError(null);
43
+ setReceipt(null);
44
+ setReceipts([]);
45
+ setReviewOpen(false);
46
+ let issue = receiptFileProblem(incoming, { checkSize: uploadOnly });
47
+ if (issue) {
48
+ setError(issue);
49
+ setPhase('error');
50
+ return;
51
+ }
52
+ if (uploadOnly) {
53
+ onUploadOnly === null || onUploadOnly === void 0 ? void 0 : onUploadOnly(incoming);
54
+ return;
55
+ }
56
+ const controller = new AbortController();
57
+ abortRef.current = controller;
58
+ setPhase('reading');
59
+ setProgress(null);
60
+ try {
61
+ const prepared = await prepareReceiptFile(incoming);
62
+ issue = receiptFileProblem(prepared);
63
+ if (controller.signal.aborted)
64
+ return;
65
+ if (issue) {
66
+ setError(issue);
67
+ setPhase('error');
68
+ return;
69
+ }
70
+ setFile(prepared);
71
+ onFile === null || onFile === void 0 ? void 0 : onFile(prepared);
72
+ if (['image/jpeg', 'image/png'].includes(prepared.type.toLowerCase()) && !/\.(heic|heif)$/i.test(prepared.name)) {
73
+ try {
74
+ urlRef.current = URL.createObjectURL(prepared);
75
+ setPreview(urlRef.current);
76
+ }
77
+ catch (_d) { /* preview is optional */ }
78
+ }
79
+ const result = await api.readReceipts(prepared, { context, signal: controller.signal, onUploadProgress: (e) => setProgress((e === null || e === void 0 ? void 0 : e.total) ? Math.round((e.loaded / e.total) * 100) : null) });
80
+ if (controller.signal.aborted)
81
+ return;
82
+ const list = Array.isArray(result) ? result : [];
83
+ if (!list.length) {
84
+ clearUrl();
85
+ setFile(null);
86
+ setError('nothing_recognised');
87
+ setPhase('error');
88
+ return;
89
+ }
90
+ setReceipts(list);
91
+ if (list.length === 1) {
92
+ setReceipt(list[0]);
93
+ setPhase('review');
94
+ setReviewOpen(true);
95
+ }
96
+ else
97
+ setPhase('picker');
98
+ }
99
+ catch (e) {
100
+ if (!controller.signal.aborted) {
101
+ const code = problemCode(e);
102
+ if (code === 'consent_required') {
103
+ consent.reload();
104
+ setPhase('idle');
105
+ }
106
+ else {
107
+ setError(code);
108
+ setPhase('error');
109
+ }
110
+ }
111
+ }
112
+ };
113
+ const errKey = error && ERROR_TEXT[error];
114
+ const overlayBoxes = receipt ? [receipt.box, ...(receipt.items || []).map((i) => i.box)].filter((b) => b && b.w > 0 && b.h > 0) : [];
115
+ const camera = _jsx("input", { ref: cameraRef, hidden: true, type: "file", accept: "image/*", capture: "environment", onChange: choose });
116
+ const chooser = _jsx("input", { ref: fileRef, hidden: true, type: "file", accept: RECEIPT_ACCEPT, onChange: choose });
117
+ return _jsx(Paper, { component: "section", "aria-label": t('Receipts.CARD_LABEL'), sx: [RECEIPT_CAPTURE_CARD_SX, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])], children: _jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 2, children: [_jsx(ReceiptConsentSwitch, { api: api, provider: provider, consent: consent }), !consent.granted && _jsx(_Fragment, { children: onUploadOnly && _jsxs(_Fragment, { children: [_jsx(Button, { disabled: disabled, onClick: () => { var _a; return (_a = uploadRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.UPLOAD_ONLY') }), _jsx("input", { ref: uploadRef, hidden: true, type: "file", accept: RECEIPT_ACCEPT, onChange: (e) => choose(e, true) })] }) }), consent.status === 'loading' && _jsx(LinearProgress, { sx: RECEIPT_CAPTURE_PROGRESS_SX }), consent.granted && _jsxs(_Fragment, { children: [_jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1, children: [camera, chooser, _jsx(Button, { sx: RECEIPT_CAPTURE_ACTION_SX, variant: "contained", disabled: disabled || phase === 'reading', onClick: () => { var _a; return (_a = cameraRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.CAMERA') }), _jsx(Button, { sx: RECEIPT_CAPTURE_ACTION_SX, disabled: disabled || phase === 'reading', onClick: () => { var _a; return (_a = fileRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: t('Receipts.PICK_FILE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Receipts.ACCEPTED_TYPES') })] }), phase === 'reading' && _jsxs(_Fragment, { children: [_jsx(Box, { sx: { position: 'relative' }, children: preview ? _jsxs(_Fragment, { children: [_jsx("img", { src: preview, alt: (file === null || file === void 0 ? void 0 : file.name) || '', style: { display: 'block', maxWidth: '100%', maxHeight: 360 } }), overlayBoxes.map((b, i) => _jsx(Box, { sx: { position: 'absolute', left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.w * 100}%`, height: `${b.h * 100}%`, border: 2, borderColor: 'primary.main', pointerEvents: 'none' } }, i))] }) : _jsx(Typography, { children: (file === null || file === void 0 ? void 0 : file.name) || ' ' }) }), _jsx(LinearProgress, { variant: progress == null ? 'indeterminate' : 'determinate', value: progress || 0, sx: RECEIPT_CAPTURE_PROGRESS_SX }), _jsx(Typography, { role: "status", children: t('Receipts.READING') })] }), phase === 'review' && _jsxs(_Fragment, { children: [_jsx(Box, { sx: { position: 'relative' }, children: preview && _jsxs(_Fragment, { children: [_jsx("img", { src: preview, alt: (file === null || file === void 0 ? void 0 : file.name) || '', style: { display: 'block', maxWidth: '100%', maxHeight: 360 } }), overlayBoxes.map((b, i) => _jsx(Box, { sx: { position: 'absolute', left: `${b.x * 100}%`, top: `${b.y * 100}%`, width: `${b.w * 100}%`, height: `${b.h * 100}%`, border: 2, borderColor: 'primary.main', pointerEvents: 'none' } }, i))] }) }), _jsx(Button, { onClick: () => setReviewOpen(true), children: t('Receipts.REVIEW_TITLE') }), _jsx(ReceiptReviewDialog, { open: reviewOpen, receipt: receipt, values: values, fieldLabels: fieldLabels, extraFields: extraFields, accountLabel: accountLabel, onApply: onApply, onClose: () => setReviewOpen(false) })] }), phase === 'picker' && _jsx(ReceiptPicker, { receipts: receipts, imageUrl: preview, fieldLabels: fieldLabels, confirmLabel: confirmLabel, onCancel: reset, onConfirm: (chosen) => { onSelectReceipts === null || onSelectReceipts === void 0 ? void 0 : onSelectReceipts(chosen); clearUrl(); setReceipts([]); setPhase('idle'); } }), errKey && _jsx(Alert, { severity: errKey === 'file' ? 'error' : errKey === 'nothing' ? 'info' : 'warning', sx: RECEIPT_CAPTURE_ALERT_SX, children: t(errKey === 'file' ? 'Receipts.FILE_PROBLEM' : errKey === 'nothing' ? 'Receipts.NOTHING_RECOGNISED' : 'Receipts.UNAVAILABLE') }), phase !== 'idle' && _jsx(Button, { onClick: reset, children: t('Receipts.ANOTHER') })] }) }) }) });
118
+ }
119
+ export default ReceiptCaptureCard;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useMemo } from 'react';
3
+ import { Alert, Button, FormControlLabel, Stack, Switch } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { defaultReceiptsApi } from './api';
6
+ import { useReceiptConsent } from './useReceiptConsent';
7
+ export const RECEIPT_CONSENT_CONTROL_SX = { alignItems: 'flex-start' };
8
+ export const RECEIPT_CONSENT_ALERT_SX = { width: '100%' };
9
+ export function ReceiptConsentSwitch({ api = defaultReceiptsApi, provider = 'OpenAI', consent, onChange, sx }) {
10
+ const { t } = useTranslation();
11
+ const fallbackApi = useMemo(() => consent ? { getConsent: () => Promise.resolve({ granted: false }), setConsent: (v) => consent.set(v).then((answer) => ({ granted: answer })) } : api, [api, Boolean(consent)]);
12
+ const own = useReceiptConsent(fallbackApi);
13
+ const state = consent || own;
14
+ const change = async (event) => { const answer = await state.set(event.target.checked); if (answer === event.target.checked)
15
+ onChange === null || onChange === void 0 ? void 0 : onChange(answer); };
16
+ return _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { sx: [RECEIPT_CONSENT_CONTROL_SX, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])], control: _jsx(Switch, { checked: Boolean(state.granted), onChange: change, disabled: state.status === 'loading' || state.busy, inputProps: { 'aria-label': t('Receipts.CONSENT', { provider }) } }), label: t('Receipts.CONSENT', { provider }) }), (state.status === 'error' || state.error) && _jsx(Alert, { severity: "error", sx: RECEIPT_CONSENT_ALERT_SX, action: _jsx(Button, { color: "inherit", size: "small", onClick: state.reload, children: t('Receipts.RETRY') }), children: t('Receipts.CONSENT_ERROR') })] });
17
+ }
18
+ export default ReceiptConsentSwitch;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useState } from 'react';
3
+ import { Box, Button, Card, CardContent, Checkbox, Chip, DialogActions, DialogContent, DialogTitle, Grid, Stack, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { formatReceiptAmount, formatReceiptDate } from './format';
6
+ export const RECEIPT_PICKER_CARD_SX = { height: '100%' };
7
+ export const RECEIPT_PICKER_CLICK_SX = { cursor: 'pointer', height: '100%' };
8
+ export const RECEIPT_PICKER_ACTION_SX = { justifyContent: 'space-between' };
9
+ function cropStyle(box) { if (!box || !box.w || !box.h)
10
+ return null; return { backgroundSize: `${100 / box.w}% ${100 / box.h}%`, backgroundPosition: `${(box.x / (1 - box.w)) * 100 || 0}% ${(box.y / (1 - box.h)) * 100 || 0}%` }; }
11
+ export function ReceiptPicker({ receipts = [], imageUrl, onConfirm, onCancel, confirmLabel, fieldLabels, sx }) {
12
+ const { t, i18n } = useTranslation();
13
+ const [selected, setSelected] = useState([]);
14
+ useEffect(() => setSelected(receipts.map((r, i) => r.confidence === 'low' ? -1 : i).filter((i) => i >= 0)), [receipts]);
15
+ const toggle = (i) => setSelected((old) => old.includes(i) ? old.filter((n) => n !== i) : [...old, i]);
16
+ const count = selected.length;
17
+ return _jsxs(Box, { sx: sx, children: [_jsx(DialogTitle, { children: t('Receipts.RECOGNISED_COUNT', { n: receipts.length }) }), _jsx(DialogContent, { children: _jsx(Grid, { container: true, spacing: 2, children: receipts.map((r, i) => { const crop = cropStyle(r.box); return _jsx(Grid, { size: { xs: 12, md: 4 }, children: _jsx(Card, { sx: RECEIPT_PICKER_CARD_SX, variant: "outlined", children: _jsx(Box, { onClick: () => toggle(i), sx: RECEIPT_PICKER_CLICK_SX, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsx(Box, { role: "img", "aria-label": r.merchant || t('Receipts.UNREADABLE'), sx: Object.assign({ height: 100, bgcolor: 'action.hover', backgroundImage: imageUrl && crop ? `url(${imageUrl})` : 'none', backgroundRepeat: 'no-repeat' }, (imageUrl && crop ? crop : {})) }), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(Checkbox, { checked: selected.includes(i), inputProps: { 'aria-label': r.merchant || t('Receipts.UNREADABLE') }, onChange: () => toggle(i), onClick: (e) => e.stopPropagation() }), _jsx(Typography, { children: r.merchant || t('Receipts.UNREADABLE') })] }), _jsxs(Typography, { variant: "body2", children: [r.date ? formatReceiptDate(r.date, i18n.language) : '', r.total ? ` · ${formatReceiptAmount(r.total, r.currency, i18n.language)}` : t('Receipts.AMOUNT_UNKNOWN')] }), _jsx(Chip, { size: "small", color: r.confidence === 'low' ? 'warning' : 'success', label: t(r.confidence === 'low' ? 'Receipts.UNCERTAIN' : 'Receipts.CERTAIN') })] }) }) }) }) }, i); }) }) }), _jsxs(DialogActions, { sx: RECEIPT_PICKER_ACTION_SX, children: [_jsx(Button, { onClick: onCancel, children: t('Receipts.CANCEL') }), _jsx(Button, { disabled: !count, variant: "contained", onClick: () => onConfirm === null || onConfirm === void 0 ? void 0 : onConfirm(receipts.filter((_r, i) => selected.includes(i))), children: confirmLabel ? confirmLabel(count) : t('Receipts.APPLY_COUNT', { n: count }) })] })] });
18
+ }
19
+ export default ReceiptPicker;
@@ -0,0 +1,39 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Drawer, Stack, Typography, useMediaQuery } from '@mui/material';
4
+ import { useTheme } from '@mui/material/styles';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { formatReceiptAmount, formatReceiptDate, isEmptyValue, receiptValueEqual } from './format';
7
+ export const RECEIPT_REVIEW_ACTIONS_SX = { gap: 1, flexWrap: 'wrap', flexDirection: { xs: 'column', sm: 'row' }, '& > :nth-of-type(1)': { order: { xs: 3, sm: 0 } }, '& > :nth-of-type(2)': { order: { xs: 2, sm: 0 } }, '& > :nth-of-type(3)': { order: { xs: 1, sm: 0 } } };
8
+ export const RECEIPT_REVIEW_ROW_SX = { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 1, alignItems: 'start' };
9
+ export const RECEIPT_REVIEW_SHEET_PAPER_SX = { height: '92%' };
10
+ export const RECEIPT_REVIEW_ACTION_BUTTON_SX = { flex: '1 1 auto' };
11
+ export function ReceiptReviewDialog({ open, receipt, values = {}, fieldLabels = {}, extraFields = [], accountLabel = (id) => id, onApply, onClose }) {
12
+ var _a;
13
+ const { t, i18n } = useTranslation();
14
+ const theme = useTheme();
15
+ const mobile = useMediaQuery(theme.breakpoints.down('sm'));
16
+ const rows = [];
17
+ const add = (key, value, recognized = value !== '' && value != null, format = (v) => String(v)) => { if (recognized)
18
+ rows.push({ key, value, text: format(value), confidence: receipt === null || receipt === void 0 ? void 0 : receipt.confidence }); };
19
+ add('merchant', receipt === null || receipt === void 0 ? void 0 : receipt.merchant);
20
+ add('date', receipt === null || receipt === void 0 ? void 0 : receipt.date, Boolean(receipt === null || receipt === void 0 ? void 0 : receipt.date), (v) => formatReceiptDate(v, i18n.language));
21
+ add('total', receipt === null || receipt === void 0 ? void 0 : receipt.total, Boolean(receipt === null || receipt === void 0 ? void 0 : receipt.total), (v) => formatReceiptAmount(v, receipt === null || receipt === void 0 ? void 0 : receipt.currency, i18n.language));
22
+ add('currency', receipt === null || receipt === void 0 ? void 0 : receipt.currency);
23
+ if ((receipt === null || receipt === void 0 ? void 0 : receipt.payment_method) && receipt.payment_method !== 'unknown')
24
+ add('payment_method', receipt.payment_method, true, (v) => t(`Receipts.PAYMENT_${v}`));
25
+ const account = (_a = receipt === null || receipt === void 0 ? void 0 : receipt.account_suggestions) === null || _a === void 0 ? void 0 : _a.find((s) => s === null || s === void 0 ? void 0 : s.account_id);
26
+ if (account)
27
+ rows.push({ key: 'account', value: account.account_id, text: accountLabel(account.account_id), confidence: account.confidence === 'low' ? 'low' : receipt === null || receipt === void 0 ? void 0 : receipt.confidence });
28
+ if (Array.isArray(receipt === null || receipt === void 0 ? void 0 : receipt.items) && receipt.items.length)
29
+ rows.push({ key: 'items', value: receipt.items, text: receipt.items.map((item) => `${item.description || ''}${item.amount ? ` · ${item.amount}` : ''}`).join('\n'), confidence: receipt === null || receipt === void 0 ? void 0 : receipt.confidence });
30
+ (extraFields || []).forEach(({ key, format }) => { if (!['merchant', 'date', 'total', 'currency', 'payment_method', 'account', 'items'].includes(key) && (receipt === null || receipt === void 0 ? void 0 : receipt[key]) != null && receipt[key] !== '')
31
+ add(key, receipt[key], true, format || ((v) => typeof v === 'string' ? v : JSON.stringify(v))); });
32
+ const label = (key) => { var _a; return fieldLabels[key] || ((_a = extraFields.find((field) => field.key === key)) === null || _a === void 0 ? void 0 : _a.label) || t(`Receipts.FIELD_${key}`); };
33
+ const apply = (list, mode, close) => { if (!list.length)
34
+ return; onApply === null || onApply === void 0 ? void 0 : onApply(Object.fromEntries(list.map((r) => [r.key, r.value])), { mode }); if (close)
35
+ onClose === null || onClose === void 0 ? void 0 : onClose(); };
36
+ const content = _jsxs(_Fragment, { children: [_jsx(DialogTitle, { id: "receipt-review-title", children: t('Receipts.REVIEW_TITLE') }), _jsx(DialogContent, { children: _jsx(Stack, { spacing: 2, children: rows.map((row) => { const current = values[row.key]; const equal = !isEmptyValue(current) && receiptValueEqual(row.key, current, row.value); return _jsxs(Box, { sx: RECEIPT_REVIEW_ROW_SX, children: [_jsxs(Box, { sx: { minWidth: 0 }, children: [_jsx(Typography, { variant: "subtitle2", children: label(row.key) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", children: [row.key === 'items' ? _jsx(Box, { component: "ul", sx: { m: 0, pl: 2 }, children: row.value.map((item, index) => _jsx("li", { children: _jsx(Typography, { component: "span", children: `${item.description || ''}${item.amount ? ` · ${item.amount}` : ''}` }) }, index)) }) : _jsx(Typography, { children: row.text }), row.confidence === 'low' && _jsx(Chip, { size: "small", color: "warning", label: t('Receipts.UNCERTAIN') }), equal ? _jsx(Typography, { variant: "caption", children: t('Receipts.SAME_AS_FORM') }) : !isEmptyValue(current) && _jsx(Typography, { variant: "body2", sx: { textDecoration: 'line-through' }, children: String(current) })] })] }), !equal && _jsx(Button, { size: "small", onClick: () => apply([row], 'field', false), children: t('Receipts.APPLY') })] }, row.key); }) }) }), _jsxs(DialogActions, { sx: RECEIPT_REVIEW_ACTIONS_SX, children: [_jsx(Button, { sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, onClick: onClose, children: t('Receipts.CLOSE') }), _jsx(Button, { variant: "outlined", sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, disabled: !rows.some((r) => isEmptyValue(values[r.key])), onClick: () => apply(rows.filter((r) => isEmptyValue(values[r.key])), 'empty', true), children: t('Receipts.ONLY_EMPTY') }), _jsx(Button, { variant: "contained", sx: RECEIPT_REVIEW_ACTION_BUTTON_SX, disabled: !rows.length, onClick: () => apply(rows, 'all', true), children: t('Receipts.APPLY_ALL') })] })] });
37
+ return mobile ? _jsx(Drawer, { anchor: "bottom", open: open, onClose: onClose, PaperProps: { sx: RECEIPT_REVIEW_SHEET_PAPER_SX, role: 'dialog', 'aria-labelledby': 'receipt-review-title' }, children: content }) : _jsx(Dialog, { open: open, onClose: onClose, maxWidth: "sm", fullWidth: true, "aria-labelledby": "receipt-review-title", children: content });
38
+ }
39
+ export default ReceiptReviewDialog;
@@ -0,0 +1,18 @@
1
+ import apiClient from '../auth/apiClient';
2
+ import { receiptsApiUrls } from './wire';
3
+ export function createReceiptsApi(overrides = {}, { apiBase = '/api/receipts/' } = {}) {
4
+ const urls = receiptsApiUrls(apiBase);
5
+ const defaults = {
6
+ getConsent: () => apiClient.get(urls.consent).then((r) => r.data),
7
+ setConsent: (granted) => (granted ? apiClient.post(urls.consent) : apiClient.delete(urls.consent)).then((r) => r.data),
8
+ readReceipts: (file, { context, onUploadProgress, signal } = {}) => {
9
+ const body = new FormData();
10
+ body.append('file', file);
11
+ if (context !== undefined)
12
+ body.append('context', JSON.stringify(context));
13
+ return apiClient.post(urls.read, body, { onUploadProgress, signal }).then((r) => { var _a; return Array.isArray((_a = r.data) === null || _a === void 0 ? void 0 : _a.receipts) ? r.data.receipts : []; });
14
+ },
15
+ };
16
+ return Object.assign(Object.assign({}, defaults), overrides);
17
+ }
18
+ export const defaultReceiptsApi = createReceiptsApi();
@@ -0,0 +1,39 @@
1
+ import { RECEIPT_MAX_BYTES } from './wire';
2
+ const extOf = (file) => String((file === null || file === void 0 ? void 0 : file.name) || '').split('.').pop().toLowerCase();
3
+ // `checkSize: false` is for files that are scaled down before upload; the bound is checked again on the prepared file.
4
+ export function receiptFileProblem(file, { checkSize = true } = {}) {
5
+ const ext = extOf(file);
6
+ const type = String((file === null || file === void 0 ? void 0 : file.type) || '').toLowerCase();
7
+ // A declared type decides; the extension only decides when the browser gave none (phones often send an empty type for HEIC).
8
+ const supported = type && type !== 'application/octet-stream'
9
+ ? ['image/jpeg', 'image/png', 'image/heic', 'image/heif', 'application/pdf'].includes(type)
10
+ : ['jpg', 'jpeg', 'png', 'heic', 'heif', 'pdf'].includes(ext);
11
+ if (!supported)
12
+ return 'unsupported_file_type';
13
+ if (!Number.isFinite(file === null || file === void 0 ? void 0 : file.size) || (checkSize && file.size > RECEIPT_MAX_BYTES))
14
+ return 'file_too_large';
15
+ return null;
16
+ }
17
+ function decode(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const image = new Image(); image.onload = () => { URL.revokeObjectURL(url); resolve(image); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode')); }; image.src = url; }); }
18
+ function blobOf(canvas) { return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85)); }
19
+ export async function prepareReceiptFile(file) {
20
+ var _a;
21
+ const ext = extOf(file), type = String((file === null || file === void 0 ? void 0 : file.type) || '').toLowerCase();
22
+ if (file.size <= 1.5 * 1024 * 1024 || !(['image/jpeg', 'image/png'].includes(type) || ['jpg', 'jpeg', 'png'].includes(ext)))
23
+ return file;
24
+ try {
25
+ const image = await decode(file), width = image.naturalWidth || image.width, height = image.naturalHeight || image.height;
26
+ const scale = Math.min(1, 2400 / Math.max(width, height));
27
+ if (!width || !height)
28
+ return file;
29
+ const canvas = document.createElement('canvas');
30
+ canvas.width = Math.round(width * scale);
31
+ canvas.height = Math.round(height * scale);
32
+ (_a = canvas.getContext('2d')) === null || _a === void 0 ? void 0 : _a.drawImage(image, 0, 0, canvas.width, canvas.height);
33
+ const blob = await blobOf(canvas);
34
+ return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
35
+ }
36
+ catch (_b) {
37
+ return file;
38
+ }
39
+ }
@@ -0,0 +1,42 @@
1
+ import { intlLocale } from '../billing/format';
2
+ export function formatReceiptAmount(total, currency, language) {
3
+ if (typeof total !== 'string' || !total.trim())
4
+ return total || '';
5
+ const n = Number(total);
6
+ if (!Number.isFinite(n))
7
+ return total;
8
+ const candidate = typeof currency === 'string' ? currency.toUpperCase() : '';
9
+ let code = 'USD';
10
+ try {
11
+ const supported = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('currency') : null;
12
+ if (/^[A-Z]{3}$/.test(candidate) && (!supported || supported.includes(candidate))) {
13
+ new Intl.NumberFormat(intlLocale(language), { style: 'currency', currency: candidate });
14
+ code = candidate;
15
+ }
16
+ }
17
+ catch (_a) {
18
+ code = 'USD';
19
+ }
20
+ try {
21
+ return new Intl.NumberFormat(intlLocale(language), { style: 'currency', currency: code }).format(n);
22
+ }
23
+ catch (_b) {
24
+ return total;
25
+ }
26
+ }
27
+ export function formatReceiptDate(iso, language) {
28
+ if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(iso))
29
+ return iso || '';
30
+ const date = new Date(`${iso}T00:00:00Z`);
31
+ if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== iso)
32
+ return iso;
33
+ return new Intl.DateTimeFormat(intlLocale(language), { dateStyle: 'short', timeZone: 'UTC' }).format(date);
34
+ }
35
+ export function isEmptyValue(value) { return value === '' || value === null || value === undefined || (Array.isArray(value) && value.length === 0); }
36
+ export function receiptValueEqual(key, a, b) {
37
+ if (key === 'items')
38
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => ['description', 'amount'].every((k) => { var _a; var _b, _c; return String((_b = item === null || item === void 0 ? void 0 : item[k]) !== null && _b !== void 0 ? _b : '').trim().toLowerCase() === String((_c = (_a = b[i]) === null || _a === void 0 ? void 0 : _a[k]) !== null && _c !== void 0 ? _c : '').trim().toLowerCase(); }));
39
+ if (key === 'total' && a !== '' && b !== '' && Number.isFinite(Number(a)) && Number.isFinite(Number(b)))
40
+ return Number(a) === Number(b);
41
+ return String(a !== null && a !== void 0 ? a : '').trim().toLowerCase() === String(b !== null && b !== void 0 ? b : '').trim().toLowerCase();
42
+ }
@@ -0,0 +1,52 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ export function useReceiptConsent(api) {
3
+ const [status, setStatus] = useState('loading');
4
+ const [granted, setGranted] = useState(false);
5
+ const [busy, setBusy] = useState(false);
6
+ const [error, setError] = useState(false);
7
+ const [reloadCount, setReloadCount] = useState(0);
8
+ const liveRef = useRef(true);
9
+ const generation = useRef(0); // bumped when the api changes, so an answer of the old api can never count
10
+ const reload = useCallback(() => { setGranted(false); setStatus('loading'); setError(false); setReloadCount((n) => n + 1); }, []);
11
+ useEffect(() => {
12
+ liveRef.current = true;
13
+ let requestLive = true;
14
+ generation.current += 1;
15
+ setGranted(false);
16
+ setStatus('loading'); // consent known for another api never carries over
17
+ Promise.resolve().then(() => api.getConsent()).then((answer) => { if (requestLive) {
18
+ setGranted(Boolean(answer === null || answer === void 0 ? void 0 : answer.granted));
19
+ setStatus('ready');
20
+ setError(false);
21
+ } }).catch(() => { if (requestLive) {
22
+ setStatus('error');
23
+ setError(true);
24
+ } });
25
+ return () => { requestLive = false; liveRef.current = false; };
26
+ }, [api, reloadCount]);
27
+ const set = useCallback(async (next) => {
28
+ const mine = generation.current;
29
+ setBusy(true);
30
+ setError(false);
31
+ if (!next)
32
+ setGranted(false); // fail closed: a revocation takes effect at once, whatever the server answers
33
+ try {
34
+ const answer = await api.setConsent(Boolean(next));
35
+ if (!liveRef.current || mine !== generation.current)
36
+ return false;
37
+ setGranted(Boolean(answer === null || answer === void 0 ? void 0 : answer.granted));
38
+ setStatus('ready');
39
+ return Boolean(answer === null || answer === void 0 ? void 0 : answer.granted);
40
+ }
41
+ catch (_a) {
42
+ if (liveRef.current && mine === generation.current)
43
+ setError(true);
44
+ return false;
45
+ }
46
+ finally {
47
+ if (liveRef.current && mine === generation.current)
48
+ setBusy(false);
49
+ }
50
+ }, [api]);
51
+ return { status, granted, busy, error, set, reload };
52
+ }
@@ -0,0 +1,5 @@
1
+ export const RECEIPT_ERROR_CODES = ['consent_required', 'unsupported_file_type', 'file_too_large', 'invalid_request', 'nothing_recognised', 'spend_cap_reached', 'count_limit_reached', 'provider_unavailable', 'authentication_required'];
2
+ export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
3
+ export const RECEIPT_ACCEPT = 'image/jpeg,image/png,image/heic,image/heif,application/pdf,.jpg,.jpeg,.png,.heic,.heif,.pdf';
4
+ export const RECEIPT_FIELD_KEYS = ['merchant', 'date', 'total', 'currency', 'payment_method', 'account', 'items'];
5
+ export const receiptsApiUrls = (base = '/api/receipts/') => { const root = base.replace(/\/$/, '') + '/'; return { consent: root + 'consent/', read: root + 'read/' }; };
@@ -39,6 +39,10 @@ import { PLAN_PICKER_ALERT_SX, PLAN_PICKER_CARD_SX, PLAN_PICKER_LABEL_SX, PLAN_P
39
39
  import { SUBSCRIPTION_SECTION_ALERT_SX } from '../billing/SubscriptionSection';
40
40
  import { WITHDRAWAL_DIALOG_ALERT_SX } from '../billing/WithdrawalDialog';
41
41
  import { WITHDRAWAL_SECTION_ALERT_SX } from '../billing/WithdrawalSection';
42
+ import { RECEIPT_CONSENT_ALERT_SX, RECEIPT_CONSENT_CONTROL_SX } from '../receipts/ReceiptConsentSwitch';
43
+ import { RECEIPT_CAPTURE_ACTION_SX, RECEIPT_CAPTURE_ALERT_SX, RECEIPT_CAPTURE_CARD_SX, RECEIPT_CAPTURE_PROGRESS_SX } from '../receipts/ReceiptCaptureCard';
44
+ import { RECEIPT_PICKER_ACTION_SX, RECEIPT_PICKER_CARD_SX } from '../receipts/ReceiptPicker';
45
+ import { RECEIPT_REVIEW_ACTIONS_SX, RECEIPT_REVIEW_ACTION_BUTTON_SX, RECEIPT_REVIEW_SHEET_PAPER_SX } from '../receipts/ReceiptReviewDialog';
42
46
  const SX_EXPORTS = {
43
47
  ACCESS_CODE_ACTION_SX,
44
48
  ACCESS_ENDED_BANNER_ALERT_SX,
@@ -116,6 +120,17 @@ const SX_EXPORTS = {
116
120
  SUBSCRIPTION_SECTION_ALERT_SX,
117
121
  WITHDRAWAL_DIALOG_ALERT_SX,
118
122
  WITHDRAWAL_SECTION_ALERT_SX,
123
+ RECEIPT_CONSENT_ALERT_SX,
124
+ RECEIPT_CONSENT_CONTROL_SX,
125
+ RECEIPT_CAPTURE_ACTION_SX,
126
+ RECEIPT_CAPTURE_ALERT_SX,
127
+ RECEIPT_CAPTURE_CARD_SX,
128
+ RECEIPT_CAPTURE_PROGRESS_SX,
129
+ RECEIPT_PICKER_ACTION_SX,
130
+ RECEIPT_PICKER_CARD_SX,
131
+ RECEIPT_REVIEW_ACTIONS_SX,
132
+ RECEIPT_REVIEW_ACTION_BUTTON_SX,
133
+ RECEIPT_REVIEW_SHEET_PAPER_SX,
119
134
  USER_INVITE_ACTION_SX,
120
135
  USER_INVITE_ALERT_SX,
121
136
  USER_LIST_ACTION_SX,
@@ -223,4 +238,15 @@ export const KIT_COMPONENT_SX_REGISTRY = [
223
238
  entry('SubscriptionSection.alert', 'MuiAlert', SUBSCRIPTION_SECTION_ALERT_SX),
224
239
  entry('WithdrawalDialog.alert', 'MuiAlert', WITHDRAWAL_DIALOG_ALERT_SX),
225
240
  entry('WithdrawalSection.alert', 'MuiAlert', WITHDRAWAL_SECTION_ALERT_SX),
241
+ entry('ReceiptConsentSwitch.alert', 'MuiAlert', RECEIPT_CONSENT_ALERT_SX),
242
+ entry('ReceiptConsentSwitch.control', 'MuiFormControlLabel', RECEIPT_CONSENT_CONTROL_SX),
243
+ entry('ReceiptCaptureCard.card', 'MuiPaper', RECEIPT_CAPTURE_CARD_SX),
244
+ entry('ReceiptCaptureCard.alert', 'MuiAlert', RECEIPT_CAPTURE_ALERT_SX),
245
+ entry('ReceiptCaptureCard.action', 'MuiButton', RECEIPT_CAPTURE_ACTION_SX),
246
+ entry('ReceiptCaptureCard.progress', 'MuiLinearProgress', RECEIPT_CAPTURE_PROGRESS_SX),
247
+ entry('ReceiptPicker.card', 'MuiCard', RECEIPT_PICKER_CARD_SX),
248
+ entry('ReceiptPicker.actions', 'MuiDialogActions', RECEIPT_PICKER_ACTION_SX),
249
+ entry('ReceiptReviewDialog.actions', 'MuiDialogActions', RECEIPT_REVIEW_ACTIONS_SX),
250
+ entry('ReceiptReviewDialog.sheetPaper', 'MuiPaper', RECEIPT_REVIEW_SHEET_PAPER_SX),
251
+ entry('ReceiptReviewDialog.action', 'MuiButton', RECEIPT_REVIEW_ACTION_BUTTON_SX),
226
252
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "4.5.0",
3
+ "version": "4.7.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {