@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.
- package/CHANGELOG.md +29 -0
- package/WORK_ORDERS.md +5 -1
- package/dist/chat/ChatComposer.js +23 -0
- package/dist/chat/ChatList.js +30 -0
- package/dist/chat/ChatThread.js +69 -0
- package/dist/chat/ChatWorkspace.js +218 -0
- package/dist/chat/MarkdownReply.js +21 -0
- package/dist/chat/ModelPicker.js +38 -0
- package/dist/chat/api.js +17 -0
- package/dist/chat/useChatSocket.js +68 -0
- package/dist/chat/wire.js +7 -0
- package/dist/i18n/chatTranslations.js +55 -0
- package/dist/i18n/uiCoreTranslations.js +2 -1
- package/dist/index.js +10 -0
- package/dist/theme/kitSxRegistry.js +10 -0
- package/package.json +4 -2
- package/src/chat/ChatComposer.jsx +36 -0
- package/src/chat/ChatList.jsx +87 -0
- package/src/chat/ChatThread.jsx +128 -0
- package/src/chat/ChatWorkspace.jsx +244 -0
- package/src/chat/MarkdownReply.jsx +22 -0
- package/src/chat/ModelPicker.jsx +85 -0
- package/src/chat/api.js +18 -0
- package/src/chat/useChatSocket.js +32 -0
- package/src/chat/wire.js +7 -0
- package/src/i18n/chatTranslations.ts +55 -0
- package/src/i18n/uiCoreTranslations.js +2 -0
- package/src/index.js +11 -0
- package/src/theme/kitSxRegistry.js +10 -0
- package/tests/chatApi.test.js +48 -0
- package/tests/chatDriverNames.test.jsx +71 -0
- package/tests/chatExports.test.js +3 -0
- package/tests/chatList.test.jsx +84 -0
- package/tests/chatPicker.test.jsx +82 -0
- package/tests/chatSocket.test.jsx +26 -0
- package/tests/chatThread.test.jsx +116 -0
- package/tests/chatTranslations.test.js +47 -0
- package/tests/chatWorkspace.test.jsx +234 -0
- package/tests/i18nAggregate.test.js +2 -0
- package/work-orders/UCM-AUTH-13.md +2 -0
- package/work-orders/UCM-CHAT-1.md +190 -0
- package/work-orders/UCM-CHAT-2.md +75 -0
- package/work-orders/UCM-DEP-1.md +63 -0
- package/work-orders/assets/UCM-CHAT-1-ai-chat.html +447 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { CHAT_CLOSE_CODES, defaultChatWsUrl } from './wire';
|
|
3
|
+
const delays = [1000, 2000, 4000];
|
|
4
|
+
export function useChatSocket(chatId, { wsUrl = defaultChatWsUrl, onFrame, onOpen, onClose } = {}) {
|
|
5
|
+
const socket = useRef(null);
|
|
6
|
+
const callbacks = useRef({ onFrame, onOpen, onClose });
|
|
7
|
+
callbacks.current = { onFrame, onOpen, onClose };
|
|
8
|
+
// The URL builder is read at connect time only: an inline arrow from the caller must not reopen the socket on every render.
|
|
9
|
+
const urlBuilder = useRef(wsUrl);
|
|
10
|
+
urlBuilder.current = wsUrl;
|
|
11
|
+
const [status, setStatus] = useState('idle');
|
|
12
|
+
const [generation, setGeneration] = useState(0);
|
|
13
|
+
const reconnect = useCallback(() => setGeneration((n) => n + 1), []);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!chatId || typeof WebSocket === 'undefined') {
|
|
16
|
+
setStatus('idle');
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
let alive = true;
|
|
20
|
+
let tries = 0;
|
|
21
|
+
let timer;
|
|
22
|
+
let wasOpened = false;
|
|
23
|
+
const connect = () => {
|
|
24
|
+
setStatus('connecting');
|
|
25
|
+
const ws = new WebSocket(typeof urlBuilder.current === 'function' ? urlBuilder.current(chatId) : urlBuilder.current);
|
|
26
|
+
socket.current = ws;
|
|
27
|
+
ws.onopen = () => { var _a, _b; if (!alive)
|
|
28
|
+
return; tries = 0; setStatus('open'); (_b = (_a = callbacks.current).onOpen) === null || _b === void 0 ? void 0 : _b.call(_a, chatId, wasOpened); wasOpened = true; };
|
|
29
|
+
ws.onmessage = (event) => { var _a, _b; if (!alive)
|
|
30
|
+
return; try {
|
|
31
|
+
const frame = JSON.parse(event.data);
|
|
32
|
+
if (frame && typeof frame === 'object')
|
|
33
|
+
(_b = (_a = callbacks.current).onFrame) === null || _b === void 0 ? void 0 : _b.call(_a, frame, chatId);
|
|
34
|
+
}
|
|
35
|
+
catch ( /* Ignore malformed frames. */_c) { /* Ignore malformed frames. */ } };
|
|
36
|
+
ws.onclose = (event) => {
|
|
37
|
+
var _a, _b, _c, _d, _e, _f;
|
|
38
|
+
if (!alive || socket.current !== ws)
|
|
39
|
+
return;
|
|
40
|
+
socket.current = null;
|
|
41
|
+
const reason = CHAT_CLOSE_CODES[event.code];
|
|
42
|
+
if (reason) {
|
|
43
|
+
setStatus('closed');
|
|
44
|
+
(_b = (_a = callbacks.current).onClose) === null || _b === void 0 ? void 0 : _b.call(_a, { reason, chatId, retrying: false });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (tries < delays.length) {
|
|
48
|
+
setStatus('connecting');
|
|
49
|
+
(_d = (_c = callbacks.current).onClose) === null || _d === void 0 ? void 0 : _d.call(_c, { reason: 'connection_lost', chatId, retrying: true });
|
|
50
|
+
timer = setTimeout(connect, delays[tries++]);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
setStatus('closed');
|
|
54
|
+
(_f = (_e = callbacks.current).onClose) === null || _f === void 0 ? void 0 : _f.call(_e, { reason: 'connection_lost', chatId, retrying: false });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
connect();
|
|
59
|
+
return () => { alive = false; clearTimeout(timer); const ws = socket.current; socket.current = null; if (ws) {
|
|
60
|
+
ws.onopen = ws.onmessage = ws.onclose = null;
|
|
61
|
+
ws.close();
|
|
62
|
+
} };
|
|
63
|
+
}, [chatId, generation]);
|
|
64
|
+
const send = useCallback((frame) => { const ws = socket.current; if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
65
|
+
return false; ws.send(JSON.stringify(frame)); return true; }, []);
|
|
66
|
+
return { status, connected: status === 'open', send, reconnect };
|
|
67
|
+
}
|
|
68
|
+
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
|
+
};
|
|
@@ -11,12 +11,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
11
11
|
};
|
|
12
12
|
import { authTranslations } from './authTranslations';
|
|
13
13
|
import { chartsTranslations } from './chartsTranslations';
|
|
14
|
+
import { chatTranslations } from './chatTranslations';
|
|
14
15
|
import { messagingTranslations } from './messagingTranslations';
|
|
15
16
|
import { notificationsTranslations } from './notificationsTranslations';
|
|
16
17
|
import { onboardingTranslations } from './onboardingTranslations';
|
|
17
18
|
import { sectionNavTranslations } from './sectionNavTranslations';
|
|
18
19
|
import { userMenuTranslations } from './userMenuTranslations';
|
|
19
|
-
export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), chartsTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
|
|
20
|
+
export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
|
|
20
21
|
export function createUiCoreTranslations({ germanVariant = 'formal' } = {}) {
|
|
21
22
|
return Object.fromEntries(Object.entries(uiCoreTranslations).map(([key, value]) => {
|
|
22
23
|
const { de_informal } = value, rest = __rest(value, ["de_informal"]);
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,15 @@ export { AttachmentList } from './messaging/AttachmentList';
|
|
|
78
78
|
export { ReactionBar } from './messaging/ReactionBar';
|
|
79
79
|
export { PollCard } from './messaging/PollCard';
|
|
80
80
|
export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
|
|
81
|
+
// --- Shared AI chat ---
|
|
82
|
+
export { ChatWorkspace } from './chat/ChatWorkspace';
|
|
83
|
+
export { ChatList } from './chat/ChatList';
|
|
84
|
+
export { ChatThread } from './chat/ChatThread';
|
|
85
|
+
export { ChatComposer } from './chat/ChatComposer';
|
|
86
|
+
export { ModelPicker } from './chat/ModelPicker';
|
|
87
|
+
export { default as MarkdownReply } from './chat/MarkdownReply';
|
|
88
|
+
export { useChatSocket } from './chat/useChatSocket';
|
|
89
|
+
export { createChatApi, defaultChatApi } from './chat/api';
|
|
81
90
|
// --- 9. Onboarding ---
|
|
82
91
|
export * from './onboarding/api';
|
|
83
92
|
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
@@ -92,4 +101,5 @@ export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
|
92
101
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
93
102
|
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
94
103
|
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
104
|
+
export { chatTranslations } from './i18n/chatTranslations';
|
|
95
105
|
export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
|
|
@@ -33,6 +33,8 @@ import { LOGIN_PAGE_ALERT_SX } from '../pages/LoginPage';
|
|
|
33
33
|
import { SIGN_UP_PAGE_ALERT_SX } from '../pages/SignUpPage';
|
|
34
34
|
import { SIGNUP_CONFIRM_PAGE_ALERT_SX } from '../pages/SignupConfirmPage';
|
|
35
35
|
import { REAUTH_MODAL_ALERT_SX, REAUTH_MODAL_TEXT_FIELD_SX } from '../auth/ReauthModal';
|
|
36
|
+
import { CHAT_LIST_ARCHIVED_ACTION_SX, CHAT_LIST_ROW_ACTION_SX } from '../chat/ChatList';
|
|
37
|
+
import { CHAT_THREAD_NOTICE_SX, CHAT_THREAD_TOOL_CHIP_SX } from '../chat/ChatThread';
|
|
36
38
|
const SX_EXPORTS = {
|
|
37
39
|
ACCESS_CODE_ACTION_SX,
|
|
38
40
|
ACCESS_CODE_ALERT_SX,
|
|
@@ -103,6 +105,10 @@ const SX_EXPORTS = {
|
|
|
103
105
|
SUPPORT_RECOVERY_AGENT_PAPER_SX,
|
|
104
106
|
SUPPORT_RECOVERY_ALERT_SX,
|
|
105
107
|
THREAD_DAY_SEPARATOR_SX,
|
|
108
|
+
CHAT_LIST_ARCHIVED_ACTION_SX,
|
|
109
|
+
CHAT_LIST_ROW_ACTION_SX,
|
|
110
|
+
CHAT_THREAD_NOTICE_SX,
|
|
111
|
+
CHAT_THREAD_TOOL_CHIP_SX,
|
|
106
112
|
USER_INVITE_ACTION_SX,
|
|
107
113
|
USER_INVITE_ALERT_SX,
|
|
108
114
|
USER_LIST_ACTION_SX,
|
|
@@ -202,4 +208,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
|
|
|
202
208
|
entry('SignupConfirmPage.alert', 'MuiAlert', SIGNUP_CONFIRM_PAGE_ALERT_SX),
|
|
203
209
|
entry('ReauthModal.alert', 'MuiAlert', REAUTH_MODAL_ALERT_SX),
|
|
204
210
|
entry('ReauthModal.field', 'MuiTextField', REAUTH_MODAL_TEXT_FIELD_SX),
|
|
211
|
+
entry('ChatList.rowAction', 'MuiIconButton', CHAT_LIST_ROW_ACTION_SX),
|
|
212
|
+
entry('ChatList.archivedAction', 'MuiButton', CHAT_LIST_ARCHIVED_ACTION_SX),
|
|
213
|
+
entry('ChatThread.toolChip', 'MuiChip', CHAT_THREAD_TOOL_CHIP_SX),
|
|
214
|
+
entry('ChatThread.notice', 'MuiAlert', CHAT_THREAD_NOTICE_SX),
|
|
205
215
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.3",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"sideEffects": [
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"private": false,
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@fontsource/dm-sans": "^5.2.8"
|
|
24
|
+
"@fontsource/dm-sans": "^5.2.8",
|
|
25
|
+
"react-markdown": "^10.1.0",
|
|
26
|
+
"remark-gfm": "^4.0.1"
|
|
25
27
|
},
|
|
26
28
|
"peerDependencies": {
|
|
27
29
|
"@emotion/react": "^11.14.0",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Button, Stack, TextField } from '@mui/material';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Message input. Send is disabled while a turn runs, for an archived chat and while the connection is down;
|
|
7
|
+
* Stop replaces it during a turn. Enter sends, Shift+Enter inserts a line break.
|
|
8
|
+
*/
|
|
9
|
+
export function ChatComposer({ value = '', onChange, onSend, onStop, running = false, archived = false, connected = true, sx }) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
const blocked = archived || !connected;
|
|
12
|
+
const canSend = !running && !blocked && value.trim().length > 0;
|
|
13
|
+
const submit = () => { if (canSend) onSend?.(); };
|
|
14
|
+
return (
|
|
15
|
+
<Stack direction="row" alignItems="flex-end" gap={1} sx={{ p: { xs: 1, md: 2 }, borderTop: 1, borderColor: 'divider', ...sx }}>
|
|
16
|
+
<TextField
|
|
17
|
+
multiline
|
|
18
|
+
maxRows={6}
|
|
19
|
+
fullWidth
|
|
20
|
+
size="small"
|
|
21
|
+
value={value}
|
|
22
|
+
disabled={blocked}
|
|
23
|
+
placeholder={t('Chat.message')}
|
|
24
|
+
slotProps={{ htmlInput: { 'aria-label': t('Chat.message') } }}
|
|
25
|
+
onChange={(event) => onChange?.(event.target.value)}
|
|
26
|
+
onKeyDown={(event) => {
|
|
27
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent?.isComposing) { event.preventDefault(); submit(); }
|
|
28
|
+
}}
|
|
29
|
+
/>
|
|
30
|
+
{running ? <Button variant="outlined" onClick={onStop}>{t('Chat.stop')}</Button> : null}
|
|
31
|
+
<Button variant="contained" onClick={submit} disabled={!canSend}>{t('Chat.send')}</Button>
|
|
32
|
+
</Stack>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default ChatComposer;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Badge, Box, Button, Collapse, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItem, ListItemButton, ListItemText, Menu, MenuItem, Stack, TextField } from '@mui/material';
|
|
3
|
+
import { MoreVert } from '@mui/icons-material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
|
|
6
|
+
export const CHAT_LIST_ROW_ACTION_SX = { width: 40, height: 40 };
|
|
7
|
+
export const CHAT_LIST_ARCHIVED_ACTION_SX = { justifyContent: 'space-between', px: 2 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The chat list: active chats, then an "Archived" entry that expands the archived ones (read-only, restorable).
|
|
11
|
+
* Rename, Archive/Restore and Delete sit in each row's overflow menu; Delete asks for confirmation first.
|
|
12
|
+
*/
|
|
13
|
+
export function ChatList({ chats = [], archivedChats = [], activeId, hasMore = false, onSelect, onNew, onRename, onArchive, onRestore, onDelete, onLoadMore, sx }) {
|
|
14
|
+
const { t } = useTranslation();
|
|
15
|
+
const [menu, setMenu] = useState(null); // { anchor, chat, archived }
|
|
16
|
+
const [dialog, setDialog] = useState(null); // { kind: 'rename' | 'delete', chat }
|
|
17
|
+
const [title, setTitle] = useState('');
|
|
18
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
19
|
+
|
|
20
|
+
const openMenu = (event, chat, archived) => { event.stopPropagation(); setMenu({ anchor: event.currentTarget, chat, archived }); };
|
|
21
|
+
const closeMenu = () => setMenu(null);
|
|
22
|
+
const startRename = () => { setTitle(menu.chat.title || ''); setDialog({ kind: 'rename', chat: menu.chat }); closeMenu(); };
|
|
23
|
+
const startDelete = () => { setDialog({ kind: 'delete', chat: menu.chat }); closeMenu(); };
|
|
24
|
+
const toggleArchive = () => { (menu.archived ? onRestore : onArchive)?.(menu.chat); closeMenu(); };
|
|
25
|
+
const closeDialog = () => setDialog(null);
|
|
26
|
+
const confirmRename = () => { onRename?.(dialog.chat, title.trim()); closeDialog(); };
|
|
27
|
+
const confirmDelete = () => { onDelete?.(dialog.chat); closeDialog(); };
|
|
28
|
+
|
|
29
|
+
const row = (chat, archived) => (
|
|
30
|
+
<ListItem
|
|
31
|
+
key={chat.id}
|
|
32
|
+
disablePadding
|
|
33
|
+
secondaryAction={<IconButton edge="end" aria-label={t('Chat.actions')} onClick={(event) => openMenu(event, chat, archived)} sx={CHAT_LIST_ROW_ACTION_SX}><MoreVert fontSize="small" /></IconButton>}
|
|
34
|
+
>
|
|
35
|
+
<ListItemButton selected={chat.id === activeId} onClick={() => onSelect?.(chat)} sx={{ minHeight: 48, pr: 6 }}>
|
|
36
|
+
<ListItemText primary={chat.title || t('Chat.untitled')} slotProps={{ primary: { noWrap: true } }} />
|
|
37
|
+
</ListItemButton>
|
|
38
|
+
</ListItem>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Stack sx={{ minWidth: 0, height: '100%', overflowY: 'auto', ...sx }} component="nav" aria-label={t('Chat.chats')}>
|
|
43
|
+
<Box sx={{ p: 1.5 }}>
|
|
44
|
+
<Button fullWidth variant="contained" onClick={onNew}>{t('Chat.newChat')}</Button>
|
|
45
|
+
</Box>
|
|
46
|
+
<List disablePadding>{chats.map((chat) => row(chat, false))}</List>
|
|
47
|
+
{hasMore ? <Button onClick={onLoadMore}>{t('Chat.loadMore')}</Button> : null}
|
|
48
|
+
{archivedChats.length > 0 ? (
|
|
49
|
+
<>
|
|
50
|
+
<Button color="inherit" onClick={() => setShowArchived((value) => !value)} aria-expanded={showArchived} sx={CHAT_LIST_ARCHIVED_ACTION_SX}>
|
|
51
|
+
{t('Chat.archived')}
|
|
52
|
+
<Badge color="default" badgeContent={archivedChats.length} sx={{ mr: 1.5 }} />
|
|
53
|
+
</Button>
|
|
54
|
+
<Collapse in={showArchived} unmountOnExit><List disablePadding>{archivedChats.map((chat) => row(chat, true))}</List></Collapse>
|
|
55
|
+
</>
|
|
56
|
+
) : null}
|
|
57
|
+
|
|
58
|
+
<Menu anchorEl={menu?.anchor} open={Boolean(menu)} onClose={closeMenu}>
|
|
59
|
+
<MenuItem onClick={startRename}>{t('Chat.rename')}</MenuItem>
|
|
60
|
+
<MenuItem onClick={toggleArchive}>{menu?.archived ? t('Chat.restore') : t('Chat.archive')}</MenuItem>
|
|
61
|
+
<MenuItem onClick={startDelete}>{t('Chat.delete')}</MenuItem>
|
|
62
|
+
</Menu>
|
|
63
|
+
|
|
64
|
+
<Dialog open={dialog?.kind === 'rename'} onClose={closeDialog} fullWidth maxWidth="xs">
|
|
65
|
+
<DialogTitle>{t('Chat.rename')}</DialogTitle>
|
|
66
|
+
<DialogContent>
|
|
67
|
+
<TextField autoFocus fullWidth margin="dense" value={title} onChange={(event) => setTitle(event.target.value)} slotProps={{ htmlInput: { 'aria-label': t('Chat.renameLabel'), maxLength: 120 } }} onKeyDown={(event) => { if (event.key === 'Enter' && title.trim()) confirmRename(); }} />
|
|
68
|
+
</DialogContent>
|
|
69
|
+
<DialogActions>
|
|
70
|
+
<Button onClick={closeDialog}>{t('Chat.cancel')}</Button>
|
|
71
|
+
<Button variant="contained" disabled={!title.trim()} onClick={confirmRename}>{t('Chat.save')}</Button>
|
|
72
|
+
</DialogActions>
|
|
73
|
+
</Dialog>
|
|
74
|
+
|
|
75
|
+
<Dialog open={dialog?.kind === 'delete'} onClose={closeDialog} fullWidth maxWidth="xs">
|
|
76
|
+
<DialogTitle>{t('Chat.delete')}</DialogTitle>
|
|
77
|
+
<DialogContent><DialogContentText>{t('Chat.deleteConfirm')}</DialogContentText></DialogContent>
|
|
78
|
+
<DialogActions>
|
|
79
|
+
<Button onClick={closeDialog}>{t('Chat.cancel')}</Button>
|
|
80
|
+
<Button color="error" variant="contained" onClick={confirmDelete}>{t('Chat.delete')}</Button>
|
|
81
|
+
</DialogActions>
|
|
82
|
+
</Dialog>
|
|
83
|
+
</Stack>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default ChatList;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { Alert, Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import MarkdownReply from './MarkdownReply';
|
|
5
|
+
|
|
6
|
+
export const CHAT_THREAD_TOOL_CHIP_SX = { alignSelf: 'flex-start' };
|
|
7
|
+
export const CHAT_THREAD_NOTICE_SX = { mb: 2 };
|
|
8
|
+
|
|
9
|
+
const selectionLine = (turn, t) => [turn.model, turn.effort && t(`Chat.effort.${turn.effort}`)].filter(Boolean).join(' · ');
|
|
10
|
+
const sameSelection = (a, b) => a.driver === b.driver && a.model === b.model && a.effort === b.effort;
|
|
11
|
+
|
|
12
|
+
function SourceChips({ sources }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
if (!sources?.length) return null;
|
|
15
|
+
return (
|
|
16
|
+
<Stack direction="row" flexWrap="wrap" gap={0.75} role="list" aria-label={t('Chat.sources')}>
|
|
17
|
+
{sources.map((source, index) => (
|
|
18
|
+
<Tooltip key={`${source.label}-${index}`} title={source.reference || ''} describeChild>
|
|
19
|
+
<Chip role="listitem" size="small" variant="outlined" label={source.label} />
|
|
20
|
+
</Tooltip>
|
|
21
|
+
))}
|
|
22
|
+
</Stack>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Answer({ turn, tool, onRetry }) {
|
|
27
|
+
const { t } = useTranslation();
|
|
28
|
+
const generating = turn.state === 'generating';
|
|
29
|
+
const failed = ['failed', 'cancelled'].includes(turn.state) && turn.error_code;
|
|
30
|
+
return (
|
|
31
|
+
<Stack spacing={1} aria-busy={generating}>
|
|
32
|
+
{turn.assistant_text ? <MarkdownReply text={turn.assistant_text} /> : null}
|
|
33
|
+
{generating && !turn.assistant_text && !tool ? <Typography variant="body2" color="text.secondary">{t('Chat.typing')}</Typography> : null}
|
|
34
|
+
{generating && tool ? <Chip size="small" color="primary" variant="outlined" label={tool.label} sx={CHAT_THREAD_TOOL_CHIP_SX} /> : null}
|
|
35
|
+
<SourceChips sources={turn.sources} />
|
|
36
|
+
{!generating && selectionLine(turn, t) ? <Typography variant="caption" color="text.secondary">{selectionLine(turn, t)}</Typography> : null}
|
|
37
|
+
{turn.state === 'interrupted' ? (
|
|
38
|
+
<Stack direction="row" alignItems="center" gap={1}>
|
|
39
|
+
<Chip size="small" color="warning" label={t('Chat.interrupted')} />
|
|
40
|
+
{onRetry ? <Button size="small" onClick={() => onRetry(turn)}>{t('Chat.retry')}</Button> : null}
|
|
41
|
+
</Stack>
|
|
42
|
+
) : null}
|
|
43
|
+
{failed ? (
|
|
44
|
+
<Alert severity="error" action={onRetry ? <Button color="inherit" size="small" onClick={() => onRetry(turn)}>{t('Chat.retry')}</Button> : null}>
|
|
45
|
+
{t('Chat.answerIncomplete')}
|
|
46
|
+
</Alert>
|
|
47
|
+
) : null}
|
|
48
|
+
</Stack>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The turns of one chat. The streaming answer is simply the last turn in state `generating`; the caller keeps its text
|
|
54
|
+
* up to date. `notice` is a connection or limit message shown under the last turn. `readOnly` (an archived chat) hides Retry.
|
|
55
|
+
*/
|
|
56
|
+
export function ChatThread({ turns = [], tool = null, notice = null, hasOlder = false, olderBusy = false, onLoadOlder, onRetry, readOnly = false, sx }) {
|
|
57
|
+
const { t } = useTranslation();
|
|
58
|
+
const end = useRef(null);
|
|
59
|
+
const scroller = useRef(null);
|
|
60
|
+
const heightBeforeOlder = useRef(null);
|
|
61
|
+
const pinned = useRef(true); // follow the stream only while the reader is at the bottom
|
|
62
|
+
const last = turns[turns.length - 1];
|
|
63
|
+
// Follow the stream, but only when new content arrives at the end (not when older turns are prepended).
|
|
64
|
+
useEffect(() => { if (pinned.current) end.current?.scrollIntoView?.({ block: 'end' }); }, [last?.id, last?.assistant_text, last?.state]);
|
|
65
|
+
|
|
66
|
+
// Keep the reader's position when older turns are prepended.
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
const el = scroller.current;
|
|
69
|
+
if (el && heightBeforeOlder.current !== null) el.scrollTop += el.scrollHeight - heightBeforeOlder.current;
|
|
70
|
+
heightBeforeOlder.current = null;
|
|
71
|
+
}, [turns[0]?.id]);
|
|
72
|
+
const loadingOlder = useRef(false); // one request at a time, also across rapid scroll events
|
|
73
|
+
const loadOlder = async () => {
|
|
74
|
+
if (loadingOlder.current) return;
|
|
75
|
+
loadingOlder.current = true;
|
|
76
|
+
heightBeforeOlder.current = scroller.current?.scrollHeight ?? null;
|
|
77
|
+
try { await onLoadOlder?.(); } finally { loadingOlder.current = false; }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Box ref={scroller} onScroll={(event) => {
|
|
82
|
+
const el = event.currentTarget;
|
|
83
|
+
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
84
|
+
if (hasOlder && !olderBusy && el.scrollTop <= 40) loadOlder();
|
|
85
|
+
}} role="log" aria-live="polite" aria-label={t('Chat.thread')} sx={{ minHeight: 0, flex: 1, overflowY: 'auto', p: { xs: 1.5, md: 2.5 }, ...sx }}>
|
|
86
|
+
{hasOlder ? (
|
|
87
|
+
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
88
|
+
<Button size="small" onClick={loadOlder} disabled={olderBusy}>{t('Chat.loadOlder')}</Button>
|
|
89
|
+
</Box>
|
|
90
|
+
) : null}
|
|
91
|
+
{turns.length === 0 ? (
|
|
92
|
+
<Typography variant="h6" color="text.secondary" align="center" sx={{ mt: 6 }}>{t('Chat.emptyTitle')}</Typography>
|
|
93
|
+
) : null}
|
|
94
|
+
{turns.map((turn, index) => {
|
|
95
|
+
const previous = turns[index - 1];
|
|
96
|
+
return (
|
|
97
|
+
<Box key={turn.id ?? index} sx={{ mb: 3 }}>
|
|
98
|
+
{previous && !sameSelection(previous, turn) ? (
|
|
99
|
+
<Stack direction="row" alignItems="center" gap={1} sx={{ mb: 2, color: 'text.secondary' }} role="separator">
|
|
100
|
+
<Box sx={{ flex: 1, borderTop: 1, borderColor: 'divider' }} />
|
|
101
|
+
<Typography variant="caption">{t('Chat.modelChanged', { model: selectionLine(turn, t) })}</Typography>
|
|
102
|
+
<Box sx={{ flex: 1, borderTop: 1, borderColor: 'divider' }} />
|
|
103
|
+
</Stack>
|
|
104
|
+
) : null}
|
|
105
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1.5 }}>
|
|
106
|
+
<Typography sx={{ maxWidth: '85%', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', bgcolor: 'action.hover', borderRadius: 2, px: 1.5, py: 1 }}>
|
|
107
|
+
{turn.user_text}
|
|
108
|
+
</Typography>
|
|
109
|
+
</Box>
|
|
110
|
+
<Answer turn={turn} tool={index === turns.length - 1 ? tool : null} onRetry={readOnly ? undefined : onRetry} />
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
{notice ? (
|
|
115
|
+
<Alert
|
|
116
|
+
severity={notice.severity || 'warning'}
|
|
117
|
+
sx={CHAT_THREAD_NOTICE_SX}
|
|
118
|
+
action={notice.action ? <Button color="inherit" size="small" onClick={notice.action.onClick}>{notice.action.label}</Button> : null}
|
|
119
|
+
>
|
|
120
|
+
{notice.text}
|
|
121
|
+
</Alert>
|
|
122
|
+
) : null}
|
|
123
|
+
<div ref={end} />
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default ChatThread;
|