@micha.bigler/ui-core-micha 4.0.1 → 4.1.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.0 - UCM-CHAT-3
4
+
5
+ Extension points on the `/chat` surface; with none of the new props `ChatWorkspace` renders and behaves as in 4.0.x.
6
+ New `ChatWorkspace` props: `scope` (now also sent on the chat lists and the status request), `unavailableReasonText`
7
+ (object or function mapping the server's `unavailable` reason to the app's text; generic fallback otherwise),
8
+ `sidePanel(chat)` with `sidePanelLabel` (a column next to the thread, a sheet in the narrow and compact layouts),
9
+ `renderAnswerExtra({ turn, chat, appFrames })` (under each answer, with the `app` frames that arrived during that turn),
10
+ `renderComposerExtra({ chat })`, `onAppFrame({ name, payload, chatId, turnId })`, and `layout` (`'viewport'` default,
11
+ `'container'` follows the container width below `compactBelow` px, `'compact'` always narrow). `ModelPicker` takes an
12
+ optional `sheet` boolean. A `history_trimmed` notice shows a quiet chip with the number of left-out messages. New texts
13
+ `Chat.notAvailableHere`, `Chat.historyTrimmed_one/_other` and the fallback `Chat.sidePanel` (de/en/fr/sw). Built
14
+ against the `django-core-micha` DCM-CHAT-10 contract.
15
+
3
16
  ## 4.0.1 - UCM-DEP-3
4
17
 
5
18
  The `exports` map no longer lists `./package.json`; the public entries are exactly `.`, `./charts`, `./chat` and `./markdown`.
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import MarkdownReply from './MarkdownReply';
6
6
  export const CHAT_THREAD_TOOL_CHIP_SX = { alignSelf: 'flex-start' };
7
7
  export const CHAT_THREAD_NOTICE_SX = { mb: 2 };
8
+ export const CHAT_THREAD_TRIMMED_SX = { mb: 1.5 };
8
9
  const selectionLine = (turn, t) => [turn.model, turn.effort && t(`Chat.effort.${turn.effort}`)].filter(Boolean).join(' · ');
9
10
  const sameSelection = (a, b) => a.driver === b.driver && a.model === b.model && a.effort === b.effort;
10
11
  function SourceChips({ sources }) {
@@ -13,17 +14,18 @@ function SourceChips({ sources }) {
13
14
  return null;
14
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}`))) }));
15
16
  }
16
- function Answer({ turn, tool, onRetry }) {
17
+ function Answer({ turn, tool, onRetry, chat, renderAnswerExtra }) {
18
+ var _a;
17
19
  const { t } = useTranslation();
18
20
  const generating = turn.state === 'generating';
19
21
  const failed = ['failed', 'cancelled'].includes(turn.state) && turn.error_code;
20
- 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, 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
+ 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] }));
21
23
  }
22
24
  /**
23
25
  * The turns of one chat. The streaming answer is simply the last turn in state `generating`; the caller keeps its text
24
26
  * up to date. `notice` is a connection or limit message shown under the last turn. `readOnly` (an archived chat) hides Retry.
25
27
  */
26
- export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx }) {
28
+ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx, chat, renderAnswerExtra, trimmedCount }) {
27
29
  var _a;
28
30
  const { t } = useTranslation();
29
31
  const end = useRef(null);
@@ -64,7 +66,7 @@ export function ChatThread({ turns = [], tool = null, notice = null, hasOlder =
64
66
  }, 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) => {
65
67
  var _a;
66
68
  const previous = turns[index - 1];
67
- 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 })] }, (_a = turn.id) !== null && _a !== void 0 ? _a : index));
68
- }), 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 })] }));
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 })] }));
69
71
  }
70
72
  export default ChatThread;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Alert, Box, Button, Chip, Drawer, Stack, Typography, useMediaQuery, useTheme } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { createChatApi } from './api';
@@ -21,6 +21,15 @@ const settleTurn = (turns, final) => {
21
21
  };
22
22
  const failGenerating = (turns, code) => turns.map((turn) => (isGenerating(turn)
23
23
  ? Object.assign(Object.assign({}, turn), { state: 'failed', error_code: code === 'quota_exceeded' ? null : code }) : turn));
24
+ /** Stores an app frame on the turn that is generating; frames outside a running turn are dropped. */
25
+ const attachAppFrame = (turns, frame) => {
26
+ const index = turns.findLastIndex(isGenerating);
27
+ if (index < 0)
28
+ return turns;
29
+ const turn = turns[index];
30
+ const appFrames = [...(turn.appFrames || []), { name: frame.name, payload: frame.payload }];
31
+ return turns.map((item, i) => (i === index ? Object.assign(Object.assign({}, turn), { appFrames }) : item));
32
+ };
24
33
  const appendDelta = (turns, text) => {
25
34
  const index = turns.findLastIndex(isGenerating);
26
35
  if (index < 0)
@@ -33,12 +42,29 @@ const appendDelta = (turns, text) => {
33
42
  * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
34
43
  * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
35
44
  */
36
- export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx }) {
45
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, layout = 'viewport', compactBelow = 720 }) {
37
46
  const { t } = useTranslation();
38
47
  const theme = useTheme();
39
- const narrow = useMediaQuery(theme.breakpoints.down('md'));
40
- const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
48
+ const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
49
+ const rootRef = useRef(null);
50
+ const [containerNarrow, setContainerNarrow] = useState(false);
41
51
  const [status, setStatus] = useState('loading'); // loading | available | not_configured | error
52
+ const narrow = layout === 'compact' || (layout === 'container' ? containerNarrow : viewportNarrow);
53
+ const appFrameCallback = useRef(onAppFrame);
54
+ useEffect(() => { appFrameCallback.current = onAppFrame; }, [onAppFrame]);
55
+ useEffect(() => {
56
+ if (layout !== 'container')
57
+ return undefined;
58
+ if (typeof ResizeObserver === 'undefined' || !rootRef.current) {
59
+ setContainerNarrow(viewportNarrow);
60
+ return undefined;
61
+ }
62
+ const observer = new ResizeObserver((entries) => { var _a, _b; const width = (_b = (_a = entries[0]) === null || _a === void 0 ? void 0 : _a.contentRect) === null || _b === void 0 ? void 0 : _b.width; if (width !== undefined)
63
+ setContainerNarrow(width < compactBelow); });
64
+ observer.observe(rootRef.current);
65
+ return () => observer.disconnect();
66
+ }, [layout, compactBelow, viewportNarrow, status]);
67
+ const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
42
68
  const [catalogue, setCatalogue] = useState({ entries: [], default: null });
43
69
  const [chats, setChats] = useState([]);
44
70
  const [archived, setArchived] = useState([]);
@@ -49,7 +75,12 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
49
75
  const [olderBusy, setOlderBusy] = useState(false);
50
76
  const [tool, setTool] = useState(null);
51
77
  const [draft, setDraft] = useState('');
52
- const [problem, setProblem] = useState(null); // an error code or close reason, shown as a notice
78
+ const [problem, setProblem] = useState(null);
79
+ const [unavailableReason, setUnavailableReason] = useState(null);
80
+ const [trimmedCount, setTrimmedCount] = useState(null);
81
+ const [sidePanelOpen, setSidePanelOpen] = useState(false);
82
+ const turnsRef = useRef(turns);
83
+ useEffect(() => { turnsRef.current = turns; }, [turns]);
53
84
  const [listOpen, setListOpen] = useState(false);
54
85
  const [pickerOpen, setPickerOpen] = useState(false);
55
86
  const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
@@ -57,19 +88,31 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
57
88
  const isArchived = Boolean(chat === null || chat === void 0 ? void 0 : chat.archived_at);
58
89
  const selection = (chat === null || chat === void 0 ? void 0 : chat.selection) || catalogue.default;
59
90
  const activeEntry = catalogue.entries.find((entry) => entry.id === (selection === null || selection === void 0 ? void 0 : selection.entry));
91
+ // Bumped on every scope change so that a list response of the previous scope is dropped.
92
+ const scopeVersion = useRef(0);
93
+ useEffect(() => { scopeVersion.current += 1; setChat(null); }, [scope]);
60
94
  const loadLists = useCallback(async () => {
61
- const [active, old] = await Promise.all([api.listChats(), api.listChats({ archived: true })]);
95
+ const version = scopeVersion.current;
96
+ // Without a scope the api is called exactly as before the scope existed.
97
+ const [active, old] = scope === undefined
98
+ ? await Promise.all([api.listChats(), api.listChats({ archived: true })])
99
+ : await Promise.all([api.listChats({ scope }), api.listChats({ scope, archived: true })]);
100
+ if (version !== scopeVersion.current)
101
+ return;
62
102
  setChats(active.results || []);
63
103
  setNextChats(active.next || null);
64
104
  setArchived(old.results || []);
65
- }, [api]);
105
+ }, [api, scope]);
66
106
  useEffect(() => {
67
107
  let live = true;
68
- api.getStatus()
108
+ const request = scope === undefined ? api.getStatus() : api.getStatus({ scope });
109
+ request
69
110
  .then(async (state) => {
70
111
  if (state.status !== 'available') {
71
- if (live)
112
+ if (live) {
72
113
  setStatus(state.status);
114
+ setUnavailableReason(state.reason || null);
115
+ }
73
116
  return;
74
117
  }
75
118
  const [entries] = await Promise.all([api.getCatalogue(), loadLists()]);
@@ -81,12 +124,14 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
81
124
  .catch(() => { if (live)
82
125
  setStatus('error'); });
83
126
  return () => { live = false; };
84
- }, [api, loadLists]);
127
+ }, [api, loadLists, scope]);
85
128
  useEffect(() => {
86
129
  setTurns([]);
87
130
  setOlderCursor(null);
88
131
  setTool(null);
89
132
  setProblem(null);
133
+ setTrimmedCount(null);
134
+ setSidePanelOpen(false);
90
135
  setDraft('');
91
136
  setOpenedOnce(false);
92
137
  if (!(chat === null || chat === void 0 ? void 0 : chat.id))
@@ -94,7 +139,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
94
139
  let live = true;
95
140
  api.listTurns(chat.id)
96
141
  .then((page) => { if (live) {
97
- setTurns(chronological(page.results));
142
+ setTurns(chronological((page.results || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
98
143
  setOlderCursor(page.next || null);
99
144
  } })
100
145
  .catch(() => { if (live)
@@ -102,17 +147,25 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
102
147
  return () => { live = false; };
103
148
  }, [api, chat === null || chat === void 0 ? void 0 : chat.id]);
104
149
  const onFrame = useCallback((frame, chatId) => {
150
+ var _a;
105
151
  if (chatId !== (chat === null || chat === void 0 ? void 0 : chat.id))
106
152
  return;
107
153
  if (frame.type === 'history')
108
- setTurns(chronological(frame.turns));
154
+ setTurns(chronological((frame.turns || []).map((turn) => (Object.assign(Object.assign({}, turn), { appFrames: [] })))));
109
155
  else if (frame.type === 'delta')
110
156
  setTurns((current) => appendDelta(current, frame.text || ''));
157
+ else if (frame.type === 'app') {
158
+ setTurns((current) => attachAppFrame(current, frame));
159
+ const running = turnsRef.current.findLast(isGenerating);
160
+ (_a = appFrameCallback.current) === null || _a === void 0 ? void 0 : _a.call(appFrameCallback, { name: frame.name, payload: frame.payload, chatId, turnId: running === null || running === void 0 ? void 0 : running.id });
161
+ }
162
+ else if (frame.type === 'notice' && frame.code === 'history_trimmed')
163
+ setTrimmedCount(frame.left_out || 0);
111
164
  else if (frame.type === 'tool')
112
165
  setTool(frame.phase === 'start' ? { label: frame.label, name: frame.name } : null);
113
166
  else if (frame.type === 'turn' && frame.turn) {
114
167
  setTool(null);
115
- setTurns((current) => settleTurn(current, frame.turn));
168
+ 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) || [] })); });
116
169
  if (frame.turn.error_code === 'quota_exceeded')
117
170
  setProblem('quota_exceeded');
118
171
  }
@@ -120,6 +173,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
120
173
  setTool(null);
121
174
  setTurns((current) => failGenerating(current, frame.code));
122
175
  setProblem(frame.code);
176
+ if (frame.code === 'unavailable')
177
+ setUnavailableReason(frame.reason || unavailableReason);
123
178
  }
124
179
  }, [chat === null || chat === void 0 ? void 0 : chat.id]);
125
180
  const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
@@ -137,7 +192,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
137
192
  };
138
193
  const selectChat = (next) => { setChat(next); setListOpen(false); };
139
194
  const createChat = guarded(async () => {
140
- const created = await api.createChat(scope ? { scope } : {});
195
+ const created = await api.createChat(scope !== undefined ? { scope } : {});
141
196
  setChats((current) => [created, ...current]);
142
197
  selectChat(created);
143
198
  });
@@ -155,7 +210,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
155
210
  setChat(null);
156
211
  });
157
212
  const loadMoreChats = guarded(async () => {
158
- const page = await api.listChats({ cursor: nextChats });
213
+ const page = await api.listChats(Object.assign(Object.assign({}, (scope !== undefined ? { scope } : {})), { cursor: nextChats }));
159
214
  setChats((current) => [...current, ...(page.results || [])]);
160
215
  setNextChats(page.next || null);
161
216
  });
@@ -205,14 +260,19 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx })
205
260
  return null;
206
261
  };
207
262
  const list = (_jsx(ChatList, { chats: chats, archivedChats: archived, activeId: chat === null || chat === void 0 ? void 0 : chat.id, hasMore: Boolean(nextChats), onSelect: selectChat, onNew: createChat, onRename: (target, title) => updateChat(target, { title }), onArchive: (target) => updateChat(target, { archived: true }), onRestore: (target) => updateChat(target, { archived: false }), onDelete: deleteChat, onLoadMore: loadMoreChats }));
208
- const unavailable = status === 'not_configured' || problem === 'not_configured';
263
+ const notConfigured = status === 'not_configured' || problem === 'not_configured';
264
+ const unavailable = status === 'unavailable' || problem === 'unavailable';
265
+ const unavailableText = typeof unavailableReasonText === 'function' ? unavailableReasonText(unavailableReason) : unavailableReasonText === null || unavailableReasonText === void 0 ? void 0 : unavailableReasonText[unavailableReason];
209
266
  if (status === 'loading')
210
267
  return _jsx(Box, { sx: sx, "aria-busy": "true" });
211
268
  if (status === 'error')
212
269
  return _jsx(Box, { sx: sx, children: _jsx(Alert, { severity: "error", children: t('Chat.loadError') }) });
213
- if (unavailable) {
270
+ if (notConfigured) {
214
271
  return (_jsxs(Stack, { alignItems: "center", justifyContent: "center", spacing: 1, sx: Object.assign({ minHeight: 320, p: 3, textAlign: 'center' }, sx), children: [_jsx(Typography, { variant: "h6", children: t('Chat.unavailable') }), _jsx(Typography, { color: "text.secondary", children: t('Chat.noModel') })] }));
215
272
  }
216
- return (_jsxs(Box, { sx: Object.assign({ display: 'grid', gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : 'minmax(240px, 320px) minmax(0, 1fr)', 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 }) })) : null] }), chat ? (_jsxs(_Fragment, { children: [_jsx(ChatThread, { turns: turns, 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, _jsx(ChatComposer, { value: draft, onChange: setDraft, onSend: send, onStop: () => socket.send({ type: 'stop' }), running: running, archived: isArchived, connected: socket.connected })] })) : (_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') })] }))] }), narrow ? _jsx(Drawer, { open: listOpen, onClose: () => setListOpen(false), slotProps: { paper: { sx: { width: 'min(320px, 86vw)' } } }, children: list }) : null] }));
273
+ if (unavailable) {
274
+ 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') }) }));
275
+ }
276
+ 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: renderAnswerExtra, 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 })] })) : (_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] }));
217
277
  }
218
278
  export default ChatWorkspace;
@@ -12,10 +12,11 @@ const groupByDriver = (entries) => entries.reduce((groups, entry) => {
12
12
  * Model and effort of one chat. A popover at wide widths, a bottom sheet at narrow widths. `onChange` receives
13
13
  * `{ entry, effort }`; the caller persists it (the change applies from the next turn). Can be controlled with `open`.
14
14
  */
15
- export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange }) {
15
+ export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange, sheet }) {
16
16
  const { t } = useTranslation();
17
17
  const theme = useTheme();
18
- const narrow = useMediaQuery(theme.breakpoints.down('md'));
18
+ const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
19
+ const narrow = sheet !== null && sheet !== void 0 ? sheet : viewportNarrow;
19
20
  const anchor = useRef(null);
20
21
  const [innerOpen, setInnerOpen] = useState(false);
21
22
  const isOpen = open !== null && open !== void 0 ? open : innerOpen;
package/dist/chat/api.js CHANGED
@@ -5,12 +5,12 @@ export function createChatApi(overrides = {}, { apiBase = '/api/assistant/' } =
5
5
  const data = (request) => request.then((response) => response.data);
6
6
  const defaults = {
7
7
  getCatalogue: () => data(apiClient.get(urls.catalogue)),
8
- listChats: ({ archived = false, cursor } = {}) => data(apiClient.get(urls.chats, { params: Object.assign({ archived }, (cursor ? { cursor } : {})) })),
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)),
11
11
  deleteChat: (id) => data(apiClient.delete(urls.chat(id))),
12
12
  listTurns: (id, { cursor } = {}) => data(apiClient.get(urls.turns(id), { params: cursor ? { cursor } : {} })),
13
- getStatus: () => data(apiClient.get(urls.status)),
13
+ getStatus: ({ scope } = {}) => data(scope !== undefined ? apiClient.get(urls.status, { params: { scope } }) : apiClient.get(urls.status)),
14
14
  };
15
15
  return Object.assign(Object.assign({}, defaults), overrides);
16
16
  }
@@ -1,9 +1,10 @@
1
1
  import { createKitSxEntry } from '../theme/kitSxEntry';
2
2
  import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from './ChatList';
3
- import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX } from './ChatThread';
3
+ import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX, CHAT_THREAD_TRIMMED_SX } from './ChatThread';
4
4
  export const CHAT_KIT_SX_REGISTRY = [
5
5
  createKitSxEntry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX, 'CHAT_LIST_ROW_ACTION_SX'),
6
6
  createKitSxEntry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX, 'CHAT_LIST_ARCHIVED_ACTION_SX'),
7
7
  createKitSxEntry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX, 'CHAT_THREAD_TOOL_CHIP_SX'),
8
+ createKitSxEntry('ChatThread.trimmed', 'MuiChip', CHAT_THREAD_TRIMMED_SX, 'CHAT_THREAD_TRIMMED_SX'),
8
9
  createKitSxEntry('ChatThread.notice', 'MuiAlert', CHAT_THREAD_NOTICE_SX, 'CHAT_THREAD_NOTICE_SX'),
9
10
  ];
package/dist/chat/wire.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export const CHAT_CLOSE_CODES = { 4401: 'session_ended', 4403: 'access_lost', 4429: 'rate_limited' };
2
- export const CHAT_ERROR_CODES = ['quota_exceeded', 'provider_error', 'timeout', 'prompt_too_long', 'invalid_output', 'security_violation', 'not_configured', 'rate_limited', 'invalid_selection', 'archived', 'turn_active', 'invalid_frame'];
2
+ export const CHAT_ERROR_CODES = ['quota_exceeded', 'provider_error', 'timeout', 'prompt_too_long', 'invalid_output', 'security_violation', 'not_configured', 'rate_limited', 'invalid_selection', 'archived', 'turn_active', 'invalid_frame', 'unavailable'];
3
3
  export const chatApiUrls = (base = '/api/assistant/') => {
4
4
  const root = base.replace(/\/$/, '') + '/';
5
5
  return { catalogue: root + 'catalogue/', chats: root + 'chats/', chat: (id) => root + 'chats/' + encodeURIComponent(id) + '/', turns: (id) => root + 'chats/' + encodeURIComponent(id) + '/turns/', status: root + 'status/' };
6
6
  };
7
7
  export const defaultChatWsUrl = (chatId) => (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws/assistant/chats/' + encodeURIComponent(chatId) + '/';
8
+ export const CHAT_FRAME_TYPES = { APP: 'app', NOTICE: 'notice' };
9
+ export const CHAT_NOTICE_CODES = { HISTORY_TRIMMED: 'history_trimmed' };
@@ -33,6 +33,10 @@ export const chatTranslations = {
33
33
  'Chat.changeModel': { de: 'Modell ändern', en: 'Change model', fr: 'Changer de modèle', sw: 'Badilisha modeli' },
34
34
  'Chat.usageLimit': { de: 'Nutzungslimit für {{model}} erreicht. Wählen Sie ein anderes Modell oder versuchen Sie es später erneut.', en: 'Usage limit reached for {{model}}. Choose another model or try again later.', fr: 'Limite d’utilisation atteinte pour {{model}}. Choisissez un autre modèle ou réessayez plus tard.', sw: 'Kikomo cha matumizi kimefikiwa kwa {{model}}. Chagua modeli nyingine au jaribu tena baadaye.' },
35
35
  'Chat.answerIncomplete': { de: 'Die Antwort konnte nicht abgeschlossen werden.', en: 'The answer could not be completed.', fr: 'La réponse n’a pas pu être terminée.', sw: 'Jibu halikuweza kukamilishwa.' },
36
+ 'Chat.notAvailableHere': { de: 'Der Assistent ist hier nicht verfügbar.', en: 'The assistant is not available here.', fr: 'L’assistant n’est pas disponible ici.', sw: 'Msaidizi hapatikani hapa.' },
37
+ 'Chat.historyTrimmed_one': { de: '{{count}} frühere Nachricht wurde ausgelassen', en: '{{count}} earlier message left out', fr: '{{count}} message précédent omis', sw: 'Ujumbe {{count}} wa awali umeachwa' },
38
+ 'Chat.historyTrimmed_other': { de: '{{count}} frühere Nachrichten wurden ausgelassen', en: '{{count}} earlier messages left out', fr: '{{count}} messages précédents omis', sw: 'Ujumbe {{count}} wa awali umeachwa' },
39
+ 'Chat.sidePanel': { de: 'Details', en: 'Details', fr: 'Détails', sw: 'Maelezo' },
36
40
  'Chat.unavailable': { de: 'Chat ist nicht verfügbar', en: 'Chat is not available', fr: 'Le chat n’est pas disponible', sw: 'Mazungumzo hayapatikani' },
37
41
  'Chat.noModel': { de: 'Auf diesem Server ist kein Sprachmodell konfiguriert.', en: 'No language model is configured on this server.', fr: 'Aucun modèle de langage n’est configuré sur ce serveur.', sw: 'Hakuna modeli ya lugha iliyosanidiwa kwenye seva hii.' },
38
42
  'Chat.modelChanged': { de: 'Modell gewechselt: {{model}}', en: 'Model changed: {{model}}', fr: 'Modèle changé : {{model}}', sw: 'Modeli imebadilishwa: {{model}}' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {