@micha.bigler/ui-core-micha 4.5.0 → 4.6.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.0 - UCM-CHAT-6
4
+
5
+ Plain model choice, summary and cost on the shared chat surface, against `django-core-micha` 3.5.0 (DCM-CHAT-13).
6
+ `ModelPicker` moves next to "Send" (new `ChatComposer` prop `actions`) and shows only the models the server offers,
7
+ grouped by quality level (`level`), with the known name, "by <provider>", an estimate "about x-y tokens per reply" (a
8
+ relative three-step scale when the server sends no tokens, "no tokens, Claude subscription" for subscription entries)
9
+ and the effort in words (Quick, Thorough, Very thorough, Maximum); the groups by driver, the "pay per use" chip and
10
+ the old effort labels are removed (keys `Chat.driver.*`, `Chat.payPerUse` gone). The catalogue is requested with the
11
+ chat (`getCatalogue({ chat })`, `?chat=<id>`) so the estimate follows the chat's length; a stored selection the
12
+ catalogue no longer offers is replaced once by the default. New optional `ChatWorkspace` props: `tokenBalance`
13
+ (footer "Balance"), `usageToTokens(usage)` (per-reply cost "<model> - <effort> - n tokens" under a reply) and
14
+ `longChatTokens` (default 40000, 0 turns it off; a notice above the composer and in the picker with "New
15
+ conversation"). `ChatThread` shows the server's summary collapsed as "Summary up to here" before the first newer turn
16
+ (plain text), from the turns page, the `history` frame and the `summarised` notice. Without the new props behaviour as
17
+ in 4.5.x apart from the picker's new look.
18
+
3
19
  ## 4.5.0 - UCM-BILL-3
4
20
 
5
21
  "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' },
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.6.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {