@micha.bigler/ui-core-micha 3.13.2 → 3.14.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/WORK_ORDERS.md +5 -1
  3. package/dist/chat/ChatComposer.js +23 -0
  4. package/dist/chat/ChatList.js +30 -0
  5. package/dist/chat/ChatThread.js +69 -0
  6. package/dist/chat/ChatWorkspace.js +218 -0
  7. package/dist/chat/MarkdownReply.js +21 -0
  8. package/dist/chat/ModelPicker.js +38 -0
  9. package/dist/chat/api.js +17 -0
  10. package/dist/chat/useChatSocket.js +68 -0
  11. package/dist/chat/wire.js +7 -0
  12. package/dist/i18n/chatTranslations.js +55 -0
  13. package/dist/i18n/uiCoreTranslations.js +2 -1
  14. package/dist/index.js +10 -0
  15. package/dist/theme/kitSxRegistry.js +10 -0
  16. package/package.json +4 -2
  17. package/src/chat/ChatComposer.jsx +36 -0
  18. package/src/chat/ChatList.jsx +87 -0
  19. package/src/chat/ChatThread.jsx +128 -0
  20. package/src/chat/ChatWorkspace.jsx +244 -0
  21. package/src/chat/MarkdownReply.jsx +22 -0
  22. package/src/chat/ModelPicker.jsx +85 -0
  23. package/src/chat/api.js +18 -0
  24. package/src/chat/useChatSocket.js +32 -0
  25. package/src/chat/wire.js +7 -0
  26. package/src/i18n/chatTranslations.ts +55 -0
  27. package/src/i18n/uiCoreTranslations.js +2 -0
  28. package/src/index.js +11 -0
  29. package/src/theme/kitSxRegistry.js +10 -0
  30. package/tests/chatApi.test.js +48 -0
  31. package/tests/chatDriverNames.test.jsx +71 -0
  32. package/tests/chatExports.test.js +3 -0
  33. package/tests/chatList.test.jsx +84 -0
  34. package/tests/chatPicker.test.jsx +82 -0
  35. package/tests/chatSocket.test.jsx +26 -0
  36. package/tests/chatThread.test.jsx +116 -0
  37. package/tests/chatTranslations.test.js +47 -0
  38. package/tests/chatWorkspace.test.jsx +234 -0
  39. package/tests/i18nAggregate.test.js +2 -0
  40. package/work-orders/UCM-AUTH-13.md +2 -0
  41. package/work-orders/UCM-CHAT-1.md +190 -0
  42. package/work-orders/UCM-CHAT-2.md +75 -0
  43. package/work-orders/UCM-DEP-1.md +63 -0
  44. package/work-orders/assets/UCM-CHAT-1-ai-chat.html +447 -0
@@ -0,0 +1,244 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Alert, Box, Button, Chip, Drawer, Stack, Typography, useMediaQuery, useTheme } from '@mui/material';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { createChatApi } from './api';
5
+ import { useChatSocket } from './useChatSocket';
6
+ import ChatList from './ChatList';
7
+ import ChatThread from './ChatThread';
8
+ import ChatComposer from './ChatComposer';
9
+ import ModelPicker from './ModelPicker';
10
+
11
+ const PENDING_ID = 'pending';
12
+ const bySeq = (a, b) => (a.seq ?? Number.MAX_SAFE_INTEGER) - (b.seq ?? Number.MAX_SAFE_INTEGER);
13
+ const chronological = (turns) => [...(turns || [])].sort(bySeq);
14
+ const isGenerating = (turn) => turn.state === 'generating';
15
+
16
+ /** Replaces the turn that was generating (or has the same id) with the final one; appends it when neither exists. */
17
+ const settleTurn = (turns, final) => {
18
+ const index = turns.findIndex((turn) => turn.id === final.id || isGenerating(turn));
19
+ if (index < 0) return chronological([...turns, final]);
20
+ return [...turns.slice(0, index), final, ...turns.slice(index + 1)];
21
+ };
22
+ const failGenerating = (turns, code) => turns.map((turn) => (isGenerating(turn)
23
+ ? { ...turn, state: 'failed', error_code: code === 'quota_exceeded' ? null : code }
24
+ : turn));
25
+ const appendDelta = (turns, text) => {
26
+ const index = turns.findLastIndex(isGenerating);
27
+ if (index < 0) return turns;
28
+ return turns.map((turn, i) => (i === index ? { ...turn, assistant_text: `${turn.assistant_text || ''}${text}` } : turn));
29
+ };
30
+
31
+ /**
32
+ * The complete chat surface: chat list, thread, composer and model picker for one user.
33
+ *
34
+ * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
35
+ * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
36
+ */
37
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx }) {
38
+ const { t } = useTranslation();
39
+ const theme = useTheme();
40
+ const narrow = useMediaQuery(theme.breakpoints.down('md'));
41
+ const api = useMemo(() => createChatApi(apiOverrides, { apiBase }), [apiOverrides, apiBase]);
42
+
43
+ const [status, setStatus] = useState('loading'); // loading | available | not_configured | error
44
+ const [catalogue, setCatalogue] = useState({ entries: [], default: null });
45
+ const [chats, setChats] = useState([]);
46
+ const [archived, setArchived] = useState([]);
47
+ const [nextChats, setNextChats] = useState(null);
48
+ const [chat, setChat] = useState(null);
49
+ const [turns, setTurns] = useState([]);
50
+ const [olderCursor, setOlderCursor] = useState(null);
51
+ const [olderBusy, setOlderBusy] = useState(false);
52
+ const [tool, setTool] = useState(null);
53
+ const [draft, setDraft] = useState('');
54
+ const [problem, setProblem] = useState(null); // an error code or close reason, shown as a notice
55
+ const [listOpen, setListOpen] = useState(false);
56
+ const [pickerOpen, setPickerOpen] = useState(false);
57
+ const [openedOnce, setOpenedOnce] = useState(false); // distinguishes a reconnect from the first connect
58
+
59
+ const running = turns.some(isGenerating);
60
+ const isArchived = Boolean(chat?.archived_at);
61
+ const selection = chat?.selection || catalogue.default;
62
+ const activeEntry = catalogue.entries.find((entry) => entry.id === selection?.entry);
63
+
64
+ const loadLists = useCallback(async () => {
65
+ const [active, old] = await Promise.all([api.listChats(), api.listChats({ archived: true })]);
66
+ setChats(active.results || []);
67
+ setNextChats(active.next || null);
68
+ setArchived(old.results || []);
69
+ }, [api]);
70
+
71
+ useEffect(() => {
72
+ let live = true;
73
+ api.getStatus()
74
+ .then(async (state) => {
75
+ if (state.status !== 'available') { if (live) setStatus(state.status); return; }
76
+ const [entries] = await Promise.all([api.getCatalogue(), loadLists()]);
77
+ if (live) { setStatus(state.status); setCatalogue(entries); }
78
+ })
79
+ .catch(() => { if (live) setStatus('error'); });
80
+ return () => { live = false; };
81
+ }, [api, loadLists]);
82
+
83
+ useEffect(() => {
84
+ setTurns([]); setOlderCursor(null); setTool(null); setProblem(null); setDraft(''); setOpenedOnce(false);
85
+ if (!chat?.id) return undefined;
86
+ let live = true;
87
+ api.listTurns(chat.id)
88
+ .then((page) => { if (live) { setTurns(chronological(page.results)); setOlderCursor(page.next || null); } })
89
+ .catch(() => { if (live) setProblem('load_error'); });
90
+ return () => { live = false; };
91
+ }, [api, chat?.id]);
92
+
93
+ const onFrame = useCallback((frame, chatId) => {
94
+ if (chatId !== chat?.id) return;
95
+ if (frame.type === 'history') setTurns(chronological(frame.turns));
96
+ else if (frame.type === 'delta') setTurns((current) => appendDelta(current, frame.text || ''));
97
+ else if (frame.type === 'tool') setTool(frame.phase === 'start' ? { label: frame.label, name: frame.name } : null);
98
+ else if (frame.type === 'turn' && frame.turn) {
99
+ setTool(null);
100
+ setTurns((current) => settleTurn(current, frame.turn));
101
+ if (frame.turn.error_code === 'quota_exceeded') setProblem('quota_exceeded');
102
+ } else if (frame.type === 'error') {
103
+ setTool(null);
104
+ setTurns((current) => failGenerating(current, frame.code));
105
+ setProblem(frame.code);
106
+ }
107
+ }, [chat?.id]);
108
+ const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
109
+ const onClose = useCallback(({ reason, retrying }) => { if (!retrying) setProblem(reason); }, []);
110
+ const socket = useChatSocket(chat?.id, { wsUrl, onFrame, onOpen, onClose });
111
+
112
+ const guarded = (action) => async (...args) => {
113
+ try { return await action(...args); } catch { setProblem('action_error'); return null; }
114
+ };
115
+
116
+ const selectChat = (next) => { setChat(next); setListOpen(false); };
117
+ const createChat = guarded(async () => {
118
+ const created = await api.createChat(scope ? { scope } : {});
119
+ setChats((current) => [created, ...current]);
120
+ selectChat(created);
121
+ });
122
+ const updateChat = guarded(async (target, patch) => {
123
+ const updated = await api.updateChat(target.id, patch);
124
+ if (chat?.id === target.id) setChat(updated);
125
+ await loadLists();
126
+ });
127
+ const deleteChat = guarded(async (target) => {
128
+ await api.deleteChat(target.id);
129
+ setChats((current) => current.filter((item) => item.id !== target.id));
130
+ setArchived((current) => current.filter((item) => item.id !== target.id));
131
+ if (chat?.id === target.id) setChat(null);
132
+ });
133
+ const loadMoreChats = guarded(async () => {
134
+ const page = await api.listChats({ cursor: nextChats });
135
+ setChats((current) => [...current, ...(page.results || [])]);
136
+ setNextChats(page.next || null);
137
+ });
138
+ const loadOlder = guarded(async () => {
139
+ setOlderBusy(true);
140
+ try {
141
+ const page = await api.listTurns(chat.id, { cursor: olderCursor });
142
+ setTurns((current) => chronological([...(page.results || []), ...current]));
143
+ setOlderCursor(page.next || null);
144
+ } finally { setOlderBusy(false); }
145
+ });
146
+
147
+ const startTurn = (message) => {
148
+ if (!socket.send({ type: 'send', message })) return false;
149
+ setProblem(null);
150
+ setTurns((current) => [...current, {
151
+ id: PENDING_ID, user_text: message, assistant_text: '', state: 'generating', sources: [],
152
+ driver: activeEntry?.driver, model: activeEntry?.label || activeEntry?.model, effort: selection?.effort,
153
+ }]);
154
+ return true;
155
+ };
156
+ const send = () => { if (startTurn(draft)) setDraft(''); };
157
+ const retry = (turn) => {
158
+ if (!socket.connected) return;
159
+ setTurns((current) => current.filter((item) => item.id !== turn.id));
160
+ startTurn(turn.user_text);
161
+ };
162
+
163
+ const noticeFor = () => {
164
+ const texts = {
165
+ session_ended: 'Chat.sessionEnded', access_lost: 'Chat.accessLost', rate_limited: 'Chat.rateLimited',
166
+ load_error: 'Chat.loadError', action_error: 'Chat.actionError',
167
+ };
168
+ if (problem === 'quota_exceeded') {
169
+ return { text: t('Chat.usageLimit', { model: activeEntry?.label || activeEntry?.model || '' }), action: { label: t('Chat.changeModel'), onClick: () => setPickerOpen(true) } };
170
+ }
171
+ if (problem === 'connection_lost') return { text: t('Chat.connectionLost'), action: { label: t('Chat.reconnect'), onClick: socket.reconnect } };
172
+ if (problem === 'not_configured') return null;
173
+ if (texts[problem]) return { text: t(texts[problem]), severity: 'warning' };
174
+ return null;
175
+ };
176
+
177
+ const list = (
178
+ <ChatList
179
+ chats={chats}
180
+ archivedChats={archived}
181
+ activeId={chat?.id}
182
+ hasMore={Boolean(nextChats)}
183
+ onSelect={selectChat}
184
+ onNew={createChat}
185
+ onRename={(target, title) => updateChat(target, { title })}
186
+ onArchive={(target) => updateChat(target, { archived: true })}
187
+ onRestore={(target) => updateChat(target, { archived: false })}
188
+ onDelete={deleteChat}
189
+ onLoadMore={loadMoreChats}
190
+ />
191
+ );
192
+
193
+ const unavailable = status === 'not_configured' || problem === 'not_configured';
194
+ if (status === 'loading') return <Box sx={sx} aria-busy="true" />;
195
+ if (status === 'error') return <Box sx={sx}><Alert severity="error">{t('Chat.loadError')}</Alert></Box>;
196
+ if (unavailable) {
197
+ return (
198
+ <Stack alignItems="center" justifyContent="center" spacing={1} sx={{ minHeight: 320, p: 3, textAlign: 'center', ...sx }}>
199
+ <Typography variant="h6">{t('Chat.unavailable')}</Typography>
200
+ <Typography color="text.secondary">{t('Chat.noModel')}</Typography>
201
+ </Stack>
202
+ );
203
+ }
204
+
205
+ return (
206
+ <Box sx={{ 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 }}>
207
+ {narrow ? null : <Box sx={{ borderRight: 1, borderColor: 'divider', minHeight: 0 }}>{list}</Box>}
208
+ <Stack sx={{ minWidth: 0, minHeight: 0 }}>
209
+ <Stack direction="row" alignItems="center" gap={1} sx={{ px: 1.5, py: 0.5, borderBottom: 1, borderColor: 'divider', minHeight: 56 }}>
210
+ {narrow ? <Button color="inherit" onClick={() => setListOpen(true)}>{t('Chat.chats')}</Button> : null}
211
+ <Typography noWrap sx={{ flex: 1, fontWeight: 600 }}>{chat ? (chat.title || t('Chat.untitled')) : ''}</Typography>
212
+ {socket.status === 'connecting' && openedOnce ? <Chip size="small" color="warning" variant="outlined" label={t('Chat.reconnecting')} /> : null}
213
+ {isArchived ? <Chip size="small" label={t('Chat.archived')} /> : null}
214
+ {isArchived ? <Button size="small" onClick={() => updateChat(chat, { archived: false })}>{t('Chat.restore')}</Button> : null}
215
+ {chat ? (
216
+ <ModelPicker
217
+ entries={catalogue.entries}
218
+ selection={selection}
219
+ disabled={isArchived || running}
220
+ open={pickerOpen}
221
+ onOpenChange={setPickerOpen}
222
+ onChange={(next) => updateChat(chat, { selection: next })}
223
+ />
224
+ ) : null}
225
+ </Stack>
226
+ {chat ? (
227
+ <>
228
+ <ChatThread turns={turns} tool={tool} notice={noticeFor()} hasOlder={Boolean(olderCursor)} olderBusy={olderBusy} onLoadOlder={loadOlder} onRetry={retry} readOnly={isArchived} />
229
+ {isArchived ? <Typography variant="caption" color="text.secondary" sx={{ px: 2, pb: 1 }}>{t('Chat.archivedReadOnly')}</Typography> : null}
230
+ <ChatComposer value={draft} onChange={setDraft} onSend={send} onStop={() => socket.send({ type: 'stop' })} running={running} archived={isArchived} connected={socket.connected} />
231
+ </>
232
+ ) : (
233
+ <Stack alignItems="center" justifyContent="center" spacing={2} sx={{ flex: 1, p: 3 }}>
234
+ <Typography color="text.secondary" align="center">{t('Chat.selectChat')}</Typography>
235
+ <Button variant="contained" onClick={createChat}>{t('Chat.newChat')}</Button>
236
+ </Stack>
237
+ )}
238
+ </Stack>
239
+ {narrow ? <Drawer open={listOpen} onClose={() => setListOpen(false)} slotProps={{ paper: { sx: { width: 'min(320px, 86vw)' } } }}>{list}</Drawer> : null}
240
+ </Box>
241
+ );
242
+ }
243
+
244
+ export default ChatWorkspace;
@@ -0,0 +1,22 @@
1
+ import React, { memo } from 'react';
2
+ import { Box } from '@mui/material';
3
+ import { useTranslation } from 'react-i18next';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+
7
+ export const MARKDOWN_LIMIT = 200000;
8
+ const allowed = ['p','br','hr','h1','h2','h3','h4','h5','h6','strong','em','del','ul','ol','li','blockquote','code','pre','table','thead','tbody','tr','th','td','a','img'];
9
+ const plainText = (children) => (Array.isArray(children) ? children : [children]).map((child) => typeof child === 'string' ? child : '').join('');
10
+ function MarkdownReply({ text = '' }) {
11
+ const { t } = useTranslation();
12
+ if (text.length > MARKDOWN_LIMIT) return <Box sx={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{text}</Box>;
13
+ return <Box sx={{ minWidth: 0, overflowWrap: 'anywhere', '& p': { my: 1 }, '& table': { borderCollapse: 'collapse' }, '& th,& td': { border: 1, borderColor: 'divider', p: 1 }, '& pre': { overflowX: 'auto', bgcolor: 'action.hover', p: 1, borderRadius: 1 }, '& blockquote': { borderLeft: 2, borderColor: 'divider', pl: 1, color: 'text.secondary' } }}>
14
+ <ReactMarkdown remarkPlugins={[remarkGfm]} allowedElements={allowed} unwrapDisallowed components={{
15
+ a: ({ href, children }) => { const label = plainText(children); return <span>{children}{href && href !== label ? ' (' + href + ')' : ''}</span>; },
16
+ img: ({ alt, src }) => <span>{alt || src || ''}</span>,
17
+ table: ({ children }) => <Box role="region" tabIndex={0} aria-label={t('Chat.markdownTable')} sx={{ overflowX: 'auto', maxWidth: '100%' }}><table>{children}</table></Box>,
18
+ pre: ({ children }) => <pre tabIndex={0} aria-label={t('Chat.markdownCode')}>{children}</pre>,
19
+ }}>{text}</ReactMarkdown>
20
+ </Box>;
21
+ }
22
+ export default memo(MarkdownReply);
@@ -0,0 +1,85 @@
1
+ import React, { useMemo, useRef, useState } from 'react';
2
+ import { Box, Button, Chip, Drawer, List, ListItemButton, ListItemText, Popover, Stack, ToggleButton, ToggleButtonGroup, Typography, useMediaQuery, useTheme } from '@mui/material';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
6
+ export const driverName = (t, id) => t(`Chat.driver.${id}`, { defaultValue: id });
7
+
8
+ const groupByDriver = (entries) => entries.reduce((groups, entry) => {
9
+ (groups[entry.driver] = groups[entry.driver] || []).push(entry);
10
+ return groups;
11
+ }, {});
12
+
13
+ /**
14
+ * Model and effort of one chat. A popover at wide widths, a bottom sheet at narrow widths. `onChange` receives
15
+ * `{ entry, effort }`; the caller persists it (the change applies from the next turn). Can be controlled with `open`.
16
+ */
17
+ export function ModelPicker({ entries = [], selection, onChange, disabled = false, open, onOpenChange }) {
18
+ const { t } = useTranslation();
19
+ const theme = useTheme();
20
+ const narrow = useMediaQuery(theme.breakpoints.down('md'));
21
+ const anchor = useRef(null);
22
+ const [innerOpen, setInnerOpen] = useState(false);
23
+ const isOpen = open ?? innerOpen;
24
+ const setOpen = (value) => { setInnerOpen(value); onOpenChange?.(value); };
25
+ const groups = useMemo(() => groupByDriver(entries), [entries]);
26
+ const active = entries.find((entry) => entry.id === selection?.entry);
27
+
28
+ const pickEntry = (entry) => {
29
+ const effort = entry.efforts?.includes(selection?.effort) ? selection.effort : entry.efforts?.[0];
30
+ onChange?.({ entry: entry.id, effort });
31
+ };
32
+
33
+ const content = (
34
+ <Box sx={{ p: 2, minWidth: narrow ? 0 : 320, maxWidth: '100%' }}>
35
+ <Stack spacing={1.5}>
36
+ {Object.entries(groups).map(([driver, items]) => (
37
+ <Box key={driver} role="group" aria-label={driverName(t, driver)}>
38
+ <Typography variant="overline" color="text.secondary">{driverName(t, driver)}</Typography>
39
+ <List dense disablePadding>
40
+ {items.map((entry) => (
41
+ <ListItemButton key={entry.id} selected={entry.id === selection?.entry} onClick={() => pickEntry(entry)} sx={{ minHeight: 40, borderRadius: 1 }}>
42
+ <ListItemText primary={entry.label || entry.model} />
43
+ {entry.billing === 'pay_per_use' ? <Chip size="small" variant="outlined" label={t('Chat.payPerUse')} /> : null}
44
+ </ListItemButton>
45
+ ))}
46
+ </List>
47
+ </Box>
48
+ ))}
49
+ <Box>
50
+ <Typography variant="overline" color="text.secondary">{t('Chat.effortLabel')}</Typography>
51
+ <ToggleButtonGroup
52
+ exclusive
53
+ fullWidth
54
+ size="small"
55
+ aria-label={t('Chat.effortLabel')}
56
+ value={selection?.effort || null}
57
+ onChange={(_event, effort) => { if (effort && active) onChange?.({ entry: active.id, effort }); }}
58
+ >
59
+ {EFFORT_LEVELS.map((effort) => (
60
+ <ToggleButton key={effort} value={effort} disabled={!active?.efforts?.includes(effort)} sx={{ minHeight: 40 }}>
61
+ {t(`Chat.effort.${effort}`)}
62
+ </ToggleButton>
63
+ ))}
64
+ </ToggleButtonGroup>
65
+ </Box>
66
+ </Stack>
67
+ </Box>
68
+ );
69
+
70
+ const label = active ? [active.label || active.model, selection?.effort && t(`Chat.effort.${selection.effort}`)].filter(Boolean).join(' · ') : t('Chat.chooseModel');
71
+ return (
72
+ <>
73
+ <Button ref={anchor} size="small" color="inherit" disabled={disabled || entries.length === 0} onClick={() => setOpen(true)} aria-haspopup="dialog">
74
+ {label}
75
+ </Button>
76
+ {narrow ? (
77
+ <Drawer anchor="bottom" open={isOpen} onClose={() => setOpen(false)}>{content}</Drawer>
78
+ ) : (
79
+ <Popover open={isOpen} anchorEl={anchor.current} onClose={() => setOpen(false)} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}>{content}</Popover>
80
+ )}
81
+ </>
82
+ );
83
+ }
84
+
85
+ export default ModelPicker;
@@ -0,0 +1,18 @@
1
+ import apiClient from '../auth/apiClient';
2
+ import { chatApiUrls } from './wire';
3
+
4
+ export function createChatApi(overrides = {}, { apiBase = '/api/assistant/' } = {}) {
5
+ const urls = chatApiUrls(apiBase);
6
+ const data = (request) => request.then((response) => response.data);
7
+ const defaults = {
8
+ getCatalogue: () => data(apiClient.get(urls.catalogue)),
9
+ listChats: ({ archived = false, cursor } = {}) => data(apiClient.get(urls.chats, { params: { archived, ...(cursor ? { cursor } : {}) } })),
10
+ createChat: (payload = {}) => data(apiClient.post(urls.chats, payload)),
11
+ updateChat: (id, patch) => data(apiClient.patch(urls.chat(id), patch)),
12
+ deleteChat: (id) => data(apiClient.delete(urls.chat(id))),
13
+ listTurns: (id, { cursor } = {}) => data(apiClient.get(urls.turns(id), { params: cursor ? { cursor } : {} })),
14
+ getStatus: () => data(apiClient.get(urls.status)),
15
+ };
16
+ return { ...defaults, ...overrides };
17
+ }
18
+ export const defaultChatApi = createChatApi();
@@ -0,0 +1,32 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { CHAT_CLOSE_CODES, defaultChatWsUrl } from './wire';
3
+
4
+ const delays = [1000, 2000, 4000];
5
+ export function useChatSocket(chatId, { wsUrl = defaultChatWsUrl, onFrame, onOpen, onClose } = {}) {
6
+ const socket = useRef(null); const callbacks = useRef({ onFrame, onOpen, onClose }); callbacks.current = { onFrame, onOpen, onClose };
7
+ // The URL builder is read at connect time only: an inline arrow from the caller must not reopen the socket on every render.
8
+ const urlBuilder = useRef(wsUrl); urlBuilder.current = wsUrl;
9
+ const [status, setStatus] = useState('idle'); const [generation, setGeneration] = useState(0);
10
+ const reconnect = useCallback(() => setGeneration((n) => n + 1), []);
11
+ useEffect(() => {
12
+ if (!chatId || typeof WebSocket === 'undefined') { setStatus('idle'); return undefined; }
13
+ let alive = true; let tries = 0; let timer; let wasOpened = false;
14
+ const connect = () => {
15
+ setStatus('connecting'); const ws = new WebSocket(typeof urlBuilder.current === 'function' ? urlBuilder.current(chatId) : urlBuilder.current); socket.current = ws;
16
+ ws.onopen = () => { if (!alive) return; tries = 0; setStatus('open'); callbacks.current.onOpen?.(chatId, wasOpened); wasOpened = true; };
17
+ ws.onmessage = (event) => { if (!alive) return; try { const frame = JSON.parse(event.data); if (frame && typeof frame === 'object') callbacks.current.onFrame?.(frame, chatId); } catch { /* Ignore malformed frames. */ } };
18
+ ws.onclose = (event) => {
19
+ if (!alive || socket.current !== ws) return; socket.current = null;
20
+ const reason = CHAT_CLOSE_CODES[event.code];
21
+ if (reason) { setStatus('closed'); callbacks.current.onClose?.({ reason, chatId, retrying: false }); return; }
22
+ if (tries < delays.length) { setStatus('connecting'); callbacks.current.onClose?.({ reason: 'connection_lost', chatId, retrying: true }); timer = setTimeout(connect, delays[tries++]); }
23
+ else { setStatus('closed'); callbacks.current.onClose?.({ reason: 'connection_lost', chatId, retrying: false }); }
24
+ };
25
+ };
26
+ connect();
27
+ return () => { alive = false; clearTimeout(timer); const ws = socket.current; socket.current = null; if (ws) { ws.onopen = ws.onmessage = ws.onclose = null; ws.close(); } };
28
+ }, [chatId, generation]);
29
+ const send = useCallback((frame) => { const ws = socket.current; if (!ws || ws.readyState !== WebSocket.OPEN) return false; ws.send(JSON.stringify(frame)); return true; }, []);
30
+ return { status, connected: status === 'open', send, reconnect };
31
+ }
32
+ export default useChatSocket;
@@ -0,0 +1,7 @@
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'];
3
+ export const chatApiUrls = (base = '/api/assistant/') => {
4
+ const root = base.replace(/\/$/, '') + '/';
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
+ };
7
+ export const defaultChatWsUrl = (chatId) => (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws/assistant/chats/' + encodeURIComponent(chatId) + '/';
@@ -0,0 +1,55 @@
1
+ export const chatTranslations = {
2
+ 'Chat.newChat': { de: 'Neuer Chat', en: 'New chat', fr: 'Nouveau chat', sw: 'Mazungumzo mapya' },
3
+ 'Chat.chats': { de: 'Chats', en: 'Chats', fr: 'Discussions', sw: 'Mazungumzo' },
4
+ 'Chat.archived': { de: 'Archiviert', en: 'Archived', fr: 'Archivé', sw: 'Imewekwa kwenye kumbukumbu' },
5
+ 'Chat.restore': { de: 'Wiederherstellen', en: 'Restore', fr: 'Restaurer', sw: 'Rejesha' },
6
+ 'Chat.rename': { de: 'Umbenennen', en: 'Rename', fr: 'Renommer', sw: 'Badilisha jina' },
7
+ 'Chat.archive': { de: 'Archivieren', en: 'Archive', fr: 'Archiver', sw: 'Weka kwenye kumbukumbu' },
8
+ 'Chat.delete': { de: 'Löschen', en: 'Delete', fr: 'Supprimer', sw: 'Futa' },
9
+ 'Chat.deleteConfirm': { de: 'Möchten Sie diesen Chat wirklich löschen?', en: 'Delete this chat?', fr: 'Supprimer cette discussion ?', sw: 'Futa mazungumzo haya?' },
10
+ 'Chat.cancel': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler', sw: 'Ghairi' },
11
+ 'Chat.save': { de: 'Speichern', en: 'Save', fr: 'Enregistrer', sw: 'Hifadhi' },
12
+ 'Chat.actions': { de: 'Chat-Aktionen', en: 'Chat actions', fr: 'Actions de discussion', sw: 'Vitendo vya mazungumzo' },
13
+ 'Chat.loadMore': { de: 'Mehr laden', en: 'Load more', fr: 'Charger plus', sw: 'Pakia zaidi' },
14
+ 'Chat.loadOlder': { de: 'Ältere laden', en: 'Load older', fr: 'Charger les plus anciens', sw: 'Pakia za zamani' },
15
+ 'Chat.send': { de: 'Senden', en: 'Send', fr: 'Envoyer', sw: 'Tuma' },
16
+ 'Chat.stop': { de: 'Stoppen', en: 'Stop', fr: 'Arrêter', sw: 'Simamisha' },
17
+ 'Chat.message': { de: 'Nachricht', en: 'Message', fr: 'Message', sw: 'Ujumbe' },
18
+ 'Chat.typing': { de: 'Antwort wird erstellt …', en: 'Thinking…', fr: 'Réflexion…', sw: 'Inafikiria…' },
19
+ 'Chat.retry': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer', sw: 'Jaribu tena' },
20
+ 'Chat.interrupted': { de: 'Unterbrochen', en: 'Interrupted', fr: 'Interrompue', sw: 'Imekatizwa' },
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
+ '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' },
31
+ 'Chat.markdownTable': { de: 'Tabelleninhalt', en: 'Table content', fr: 'Contenu du tableau', sw: 'Yaliyomo kwenye jedwali' },
32
+ 'Chat.markdownCode': { de: 'Codeblock', en: 'Code block', fr: 'Bloc de code', sw: 'Kizuizi cha msimbo' },
33
+ 'Chat.changeModel': { de: 'Modell ändern', en: 'Change model', fr: 'Changer de modèle', sw: 'Badilisha modeli' },
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
+ '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.unavailable': { de: 'Chat ist nicht verfügbar', en: 'Chat is not available', fr: 'Le chat n’est pas disponible', sw: 'Mazungumzo hayapatikani' },
37
+ '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
+ 'Chat.modelChanged': { de: 'Modell gewechselt: {{model}}', en: 'Model changed: {{model}}', fr: 'Modèle changé : {{model}}', sw: 'Modeli imebadilishwa: {{model}}' },
39
+ 'Chat.connectionLost': { de: 'Verbindung verloren', en: 'Connection lost', fr: 'Connexion perdue', sw: 'Muunganisho umepotea' },
40
+ 'Chat.reconnect': { de: 'Erneut verbinden', en: 'Reconnect', fr: 'Se reconnecter', sw: 'Unganisha tena' },
41
+ 'Chat.sessionEnded': { de: 'Ihre Sitzung ist beendet. Bitte melden Sie sich erneut an.', en: 'Your session has ended. Please sign in again.', fr: 'Votre session a pris fin. Veuillez vous reconnecter.', sw: 'Kipindi chako kimeisha. Tafadhali ingia tena.' },
42
+ 'Chat.accessLost': { de: 'Sie haben keinen Zugriff mehr auf diesen Chat.', en: 'You no longer have access to this chat.', fr: 'Vous n’avez plus accès à cette discussion.', sw: 'Huna tena ufikiaji wa mazungumzo haya.' },
43
+ 'Chat.rateLimited': { de: 'Zu viele Anfragen. Bitte einen Moment warten.', en: 'Too many requests. Please wait a moment.', fr: 'Trop de requêtes. Veuillez patienter un instant.', sw: 'Maombi mengi mno. Tafadhali subiri kidogo.' },
44
+ 'Chat.untitled': { de: 'Neuer Chat', en: 'New chat', fr: 'Nouvelle discussion', sw: 'Mazungumzo mapya' },
45
+ 'Chat.emptyTitle': { de: 'Wie kann ich helfen?', en: 'How can I help?', fr: 'Comment puis-je aider ?', sw: 'Naweza kusaidiaje?' },
46
+ 'Chat.selectChat': { de: 'Wählen Sie einen Chat oder starten Sie einen neuen.', en: 'Select a chat or start a new one.', fr: 'Sélectionnez une discussion ou commencez-en une nouvelle.', sw: 'Chagua mazungumzo au anza mapya.' },
47
+ 'Chat.archivedReadOnly': { de: 'Archivierter Chat. Stellen Sie ihn wieder her, um weiterzuschreiben.', en: 'Archived chat. Restore it to continue.', fr: 'Discussion archivée. Restaurez-la pour continuer.', sw: 'Mazungumzo yaliyowekwa kwenye kumbukumbu. Yarejeshe ili kuendelea.' },
48
+ 'Chat.loadError': { de: 'Chats konnten nicht geladen werden.', en: 'Chats could not be loaded.', fr: 'Impossible de charger les discussions.', sw: 'Mazungumzo hayakuweza kupakiwa.' },
49
+ 'Chat.actionError': { de: 'Aktion fehlgeschlagen.', en: 'Action failed.', fr: 'Action impossible.', sw: 'Kitendo kimeshindwa.' },
50
+ 'Chat.sources': { de: 'Quellen', en: 'Sources', fr: 'Sources', sw: 'Vyanzo' },
51
+ 'Chat.model': { de: 'Modell', en: 'Model', fr: 'Modèle', sw: 'Modeli' },
52
+ 'Chat.effortLabel': { de: 'Aufwand', en: 'Effort', fr: 'Effort', sw: 'Juhudi' },
53
+ 'Chat.renameLabel': { de: 'Chat-Titel', en: 'Chat title', fr: 'Titre de la discussion', sw: 'Kichwa cha mazungumzo' },
54
+ 'Chat.thread': { de: 'Unterhaltung', en: 'Conversation', fr: 'Conversation', sw: 'Mazungumzo' },
55
+ };
@@ -1,5 +1,6 @@
1
1
  import { authTranslations } from './authTranslations';
2
2
  import { chartsTranslations } from './chartsTranslations';
3
+ import { chatTranslations } from './chatTranslations';
3
4
  import { messagingTranslations } from './messagingTranslations';
4
5
  import { notificationsTranslations } from './notificationsTranslations';
5
6
  import { onboardingTranslations } from './onboardingTranslations';
@@ -9,6 +10,7 @@ import { userMenuTranslations } from './userMenuTranslations';
9
10
  export const uiCoreTranslations = {
10
11
  ...authTranslations,
11
12
  ...chartsTranslations,
13
+ ...chatTranslations,
12
14
  ...messagingTranslations,
13
15
  ...notificationsTranslations,
14
16
  ...onboardingTranslations,
package/src/index.js CHANGED
@@ -124,6 +124,16 @@ export { ReactionBar } from './messaging/ReactionBar';
124
124
  export { PollCard } from './messaging/PollCard';
125
125
  export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
126
126
 
127
+ // --- Shared AI chat ---
128
+ export { ChatWorkspace } from './chat/ChatWorkspace';
129
+ export { ChatList } from './chat/ChatList';
130
+ export { ChatThread } from './chat/ChatThread';
131
+ export { ChatComposer } from './chat/ChatComposer';
132
+ export { ModelPicker } from './chat/ModelPicker';
133
+ export { default as MarkdownReply } from './chat/MarkdownReply';
134
+ export { useChatSocket } from './chat/useChatSocket';
135
+ export { createChatApi, defaultChatApi } from './chat/api';
136
+
127
137
  // --- 9. Onboarding ---
128
138
  export * from './onboarding/api';
129
139
  export { selectActiveSteps } from './onboarding/stepSelection';
@@ -144,4 +154,5 @@ export { notificationsTranslations } from './i18n/notificationsTranslations';
144
154
  export { onboardingTranslations } from './i18n/onboardingTranslations';
145
155
  export { chartsTranslations } from './i18n/chartsTranslations';
146
156
  export { messagingTranslations } from './i18n/messagingTranslations';
157
+ export { chatTranslations } from './i18n/chatTranslations';
147
158
  export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
@@ -97,6 +97,8 @@ import { LOGIN_PAGE_ALERT_SX } from '../pages/LoginPage';
97
97
  import { SIGN_UP_PAGE_ALERT_SX } from '../pages/SignUpPage';
98
98
  import { SIGNUP_CONFIRM_PAGE_ALERT_SX } from '../pages/SignupConfirmPage';
99
99
  import { REAUTH_MODAL_ALERT_SX, REAUTH_MODAL_TEXT_FIELD_SX } from '../auth/ReauthModal';
100
+ import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from '../chat/ChatList';
101
+ import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX } from '../chat/ChatThread';
100
102
 
101
103
  const SX_EXPORTS = {
102
104
  ACCESS_CODE_ACTION_SX,
@@ -168,6 +170,10 @@ const SX_EXPORTS = {
168
170
  SUPPORT_RECOVERY_AGENT_PAPER_SX,
169
171
  SUPPORT_RECOVERY_ALERT_SX,
170
172
  THREAD_DAY_SEPARATOR_SX,
173
+ CHAT_LIST_ARCHIVED_ACTION_SX,
174
+ CHAT_LIST_ROW_ACTION_SX,
175
+ CHAT_THREAD_NOTICE_SX,
176
+ CHAT_THREAD_TOOL_CHIP_SX,
171
177
  USER_INVITE_ACTION_SX,
172
178
  USER_INVITE_ALERT_SX,
173
179
  USER_LIST_ACTION_SX,
@@ -272,4 +278,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
272
278
  entry('SignupConfirmPage.alert', 'MuiAlert', SIGNUP_CONFIRM_PAGE_ALERT_SX),
273
279
  entry('ReauthModal.alert', 'MuiAlert', REAUTH_MODAL_ALERT_SX),
274
280
  entry('ReauthModal.field', 'MuiTextField', REAUTH_MODAL_TEXT_FIELD_SX),
281
+ entry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX),
282
+ entry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX),
283
+ entry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX),
284
+ entry('ChatThread.notice', 'MuiAlert', CHAT_THREAD_NOTICE_SX),
275
285
  ];
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../src/auth/apiClient', () => ({
4
+ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
5
+ }));
6
+
7
+ import apiClient from '../src/auth/apiClient';
8
+ import { createChatApi } from '../src/chat/api';
9
+
10
+ beforeEach(() => {
11
+ for (const method of ['get', 'post', 'patch', 'delete']) apiClient[method].mockReset().mockResolvedValue({ data: { ok: method } });
12
+ });
13
+
14
+ describe('chat api client', () => {
15
+ it('calls the contract endpoints and unwraps the response data', async () => {
16
+ const api = createChatApi();
17
+ expect(await api.getCatalogue()).toEqual({ ok: 'get' });
18
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/catalogue/');
19
+ await api.listChats({ archived: true, cursor: 'c1' });
20
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/chats/', { params: { archived: true, cursor: 'c1' } });
21
+ await api.createChat({ scope: 'x' });
22
+ expect(apiClient.post).toHaveBeenLastCalledWith('/api/assistant/chats/', { scope: 'x' });
23
+ await api.updateChat(4, { selection: { entry: 'a', effort: 'low' } });
24
+ expect(apiClient.patch).toHaveBeenLastCalledWith('/api/assistant/chats/4/', { selection: { entry: 'a', effort: 'low' } });
25
+ await api.deleteChat(4);
26
+ expect(apiClient.delete).toHaveBeenLastCalledWith('/api/assistant/chats/4/');
27
+ await api.listTurns(4, { cursor: 'older' });
28
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/chats/4/turns/', { params: { cursor: 'older' } });
29
+ await api.getStatus();
30
+ expect(apiClient.get).toHaveBeenLastCalledWith('/api/assistant/status/');
31
+ });
32
+
33
+ it('keeps the defaults of every other function when one is overridden', async () => {
34
+ const names = ['getCatalogue', 'listChats', 'createChat', 'updateChat', 'deleteChat', 'listTurns', 'getStatus'];
35
+ const defaults = createChatApi({}, { apiBase: '/v2/chat' });
36
+ for (const name of names) {
37
+ const override = vi.fn();
38
+ const api = createChatApi({ [name]: override }, { apiBase: '/v2/chat' });
39
+ expect(api[name]).toBe(override);
40
+ for (const other of names.filter((n) => n !== name)) expect(String(api[other])).toBe(String(defaults[other]));
41
+ }
42
+ const api = createChatApi({ getStatus: vi.fn() }, { apiBase: '/v2/chat' });
43
+ await api.getCatalogue();
44
+ expect(apiClient.get).toHaveBeenLastCalledWith('/v2/chat/catalogue/');
45
+ await api.deleteChat(9);
46
+ expect(apiClient.delete).toHaveBeenLastCalledWith('/v2/chat/chats/9/');
47
+ });
48
+ });