@micha.bigler/ui-core-micha 4.2.0 → 4.3.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 +16 -0
- package/README.md +9 -0
- package/dist/chat/ChatWorkspace.js +86 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.3.0 - UCM-CHAT-5
|
|
4
|
+
|
|
5
|
+
`ChatWorkspace` gets three optional props; without them it behaves as in 4.2.x. `getSendContext()` (synchronous, no
|
|
6
|
+
arguments) is called once per `send` frame, right before it is sent, for every route: the composer, `send(text)` from
|
|
7
|
+
the answer slot, `sendRequest` and the retry of a failed turn. A plain object is sent as `context` on the frame
|
|
8
|
+
(`{ type: 'send', message, context }`, read by `django-core-micha` 3.3.0, DCM-CHAT-11); `undefined` or `null` sends no
|
|
9
|
+
`context`; anything else, or a thrown error, sends nothing, keeps the draft and shows the generic action-error notice
|
|
10
|
+
(a failed retry also keeps the failed turn on screen). The surface does not interpret or size-check the context; the core
|
|
11
|
+
answers `invalid_frame` for a bad or oversized one. The context never appears in the displayed turn text.
|
|
12
|
+
`sendRequest` (`{ id, text }`) sends `text` as the next message of the shown chat, like a typed send including
|
|
13
|
+
`getSendContext`. Each new `id` is handled once; with no chat shown, an archived chat, no connection, a running turn,
|
|
14
|
+
blank text or `activeChatId` `null` it is not sent. A request that arrives together with an `activeChatId` switch waits
|
|
15
|
+
for that chat. `onSendRequestHandled({ id, sent })` is called once per request so a host can fall back to
|
|
16
|
+
`composerPrefill` when `sent` is `false`. Give every request a fresh `id` (a counter); an `id` that was answered earlier
|
|
17
|
+
is treated as the same request and ignored.
|
|
18
|
+
|
|
3
19
|
## 4.2.0 - UCM-CHAT-4
|
|
4
20
|
|
|
5
21
|
`ChatWorkspace` takes two optional props so a host can control which chat is shown. `activeChatId` shows that chat
|
package/README.md
CHANGED
|
@@ -44,3 +44,12 @@ The main entry needs only the required peers. Charts, the chat surface and `Mark
|
|
|
44
44
|
extra peers are optional: `@micha.bigler/ui-core-micha/charts` (`@mui/x-charts`), `/chat` and `/markdown`
|
|
45
45
|
(`react-markdown`, `remark-gfm`). Install those peers only in apps that import the sub-path. The 4.0.0 entry in
|
|
46
46
|
`CHANGELOG.md` has the migration table.
|
|
47
|
+
|
|
48
|
+
## Shared chat: host controls
|
|
49
|
+
|
|
50
|
+
`ChatWorkspace` (from `@micha.bigler/ui-core-micha/chat`) takes optional props for apps that put UI around the surface;
|
|
51
|
+
without them it behaves as before. Each is described in `CHANGELOG.md` under the release that added it.
|
|
52
|
+
Since 4.3.0: `getSendContext()` (a plain object attached as `context` to every `send` frame, read by
|
|
53
|
+
`django-core-micha` 3.3.0), `sendRequest` (`{ id, text }`, sends a message on the host's behalf) and
|
|
54
|
+
`onSendRequestHandled({ id, sent })`. Since 4.2.0: `activeChatId`, `onActiveChatChange`, `composerPrefill` and
|
|
55
|
+
`send(text)` / `canSend` in `renderAnswerExtra`.
|
|
@@ -9,6 +9,26 @@ import ChatThread from './ChatThread';
|
|
|
9
9
|
import ChatComposer from './ChatComposer';
|
|
10
10
|
import ModelPicker from './ModelPicker';
|
|
11
11
|
const PENDING_ID = 'pending';
|
|
12
|
+
const buildSendFrame = (message, getContext) => {
|
|
13
|
+
if (typeof getContext !== 'function')
|
|
14
|
+
return { frame: { type: 'send', message } };
|
|
15
|
+
try {
|
|
16
|
+
const context = getContext();
|
|
17
|
+
if (context == null)
|
|
18
|
+
return { frame: { type: 'send', message } };
|
|
19
|
+
if (typeof context !== 'object' || Array.isArray(context))
|
|
20
|
+
return { error: true };
|
|
21
|
+
const prototype = Object.getPrototypeOf(context);
|
|
22
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
23
|
+
return { error: true };
|
|
24
|
+
if (typeof JSON.stringify(context) !== 'string')
|
|
25
|
+
return { error: true };
|
|
26
|
+
return { frame: { type: 'send', message, context } };
|
|
27
|
+
}
|
|
28
|
+
catch (_a) {
|
|
29
|
+
return { error: true };
|
|
30
|
+
}
|
|
31
|
+
};
|
|
12
32
|
const bySeq = (a, b) => { var _a, _b; return ((_a = a.seq) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER) - ((_b = b.seq) !== null && _b !== void 0 ? _b : Number.MAX_SAFE_INTEGER); };
|
|
13
33
|
const chronological = (turns) => [...(turns || [])].sort(bySeq);
|
|
14
34
|
const isGenerating = (turn) => turn.state === 'generating';
|
|
@@ -42,7 +62,7 @@ const appendDelta = (turns, text) => {
|
|
|
42
62
|
* Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
|
|
43
63
|
* calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
|
|
44
64
|
*/
|
|
45
|
-
export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, layout = 'viewport', compactBelow = 720 }) {
|
|
65
|
+
export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, getSendContext, sendRequest, onSendRequestHandled, layout = 'viewport', compactBelow = 720 }) {
|
|
46
66
|
const { t } = useTranslation();
|
|
47
67
|
const theme = useTheme();
|
|
48
68
|
const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
|
|
@@ -78,6 +98,13 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
78
98
|
const [draft, setDraft] = useState('');
|
|
79
99
|
const composerInputRef = useRef(null);
|
|
80
100
|
const lastPrefillId = useRef(undefined);
|
|
101
|
+
const lastSendRequestId = useRef(undefined);
|
|
102
|
+
const deferredSendRequest = useRef(null); // `{ id, wanted }`: a request that had to wait for the chat named by `activeChatId`
|
|
103
|
+
const connectedChatId = useRef(null);
|
|
104
|
+
const getSendContextRef = useRef(getSendContext);
|
|
105
|
+
getSendContextRef.current = getSendContext;
|
|
106
|
+
const sendRequestHandledRef = useRef(onSendRequestHandled);
|
|
107
|
+
sendRequestHandledRef.current = onSendRequestHandled;
|
|
81
108
|
const [focusRequest, setFocusRequest] = useState(0);
|
|
82
109
|
const focusPending = useRef(false);
|
|
83
110
|
const [problem, setProblem] = useState(null);
|
|
@@ -186,6 +213,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
186
213
|
}, [activeChatId, status, listsLoaded]);
|
|
187
214
|
// Clear the previous chat's content before the browser paints, so a host-driven switch never flashes it under the new title.
|
|
188
215
|
useLayoutEffect(() => {
|
|
216
|
+
connectedChatId.current = null; // the socket of the new chat has not opened yet
|
|
189
217
|
setTurns([]);
|
|
190
218
|
setOlderCursor(null);
|
|
191
219
|
setTool(null);
|
|
@@ -239,8 +267,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
239
267
|
setUnavailableReason(frame.reason || unavailableReason);
|
|
240
268
|
}
|
|
241
269
|
}, [chat === null || chat === void 0 ? void 0 : chat.id]);
|
|
242
|
-
const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
|
|
243
|
-
const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
|
|
270
|
+
const onOpen = useCallback((chatId) => { connectedChatId.current = String(chatId); setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
|
|
271
|
+
const onClose = useCallback(({ reason, retrying }) => { connectedChatId.current = null; if (!retrying)
|
|
244
272
|
setProblem(reason); }, []);
|
|
245
273
|
const socket = useChatSocket(chat === null || chat === void 0 ? void 0 : chat.id, { wsUrl, onFrame, onOpen, onClose });
|
|
246
274
|
useEffect(() => {
|
|
@@ -262,6 +290,53 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
262
290
|
focusPending.current = true;
|
|
263
291
|
setFocusRequest((count) => count + 1);
|
|
264
292
|
}, [composerPrefill, chat, isArchived, activeChatId]);
|
|
293
|
+
// Host send request, handled once per id. A request that arrives before the chat named by `activeChatId` is shown waits
|
|
294
|
+
// for that chat AND for its socket to open (sending needs a connected socket of that chat); a chat that cannot be found
|
|
295
|
+
// ends the wait. Every other request is decided at once. Decided = sent if the surface can send right now, otherwise
|
|
296
|
+
// reported as not sent.
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
var _a;
|
|
299
|
+
const answerFor = (id, sent) => {
|
|
300
|
+
var _a, _b;
|
|
301
|
+
lastSendRequestId.current = id;
|
|
302
|
+
if (((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === id)
|
|
303
|
+
deferredSendRequest.current = null;
|
|
304
|
+
(_b = sendRequestHandledRef.current) === null || _b === void 0 ? void 0 : _b.call(sendRequestHandledRef, { id, sent });
|
|
305
|
+
};
|
|
306
|
+
// A waiting request that the host replaced or withdrew is answered as not sent, exactly once.
|
|
307
|
+
const waiting = deferredSendRequest.current;
|
|
308
|
+
if (waiting && waiting.id !== (sendRequest === null || sendRequest === void 0 ? void 0 : sendRequest.id))
|
|
309
|
+
answerFor(waiting.id, false);
|
|
310
|
+
if (sendRequest == null || sendRequest.id === lastSendRequestId.current)
|
|
311
|
+
return;
|
|
312
|
+
const answer = (sent) => answerFor(sendRequest.id, sent);
|
|
313
|
+
const blank = typeof sendRequest.text !== 'string' || !sendRequest.text.trim();
|
|
314
|
+
if (activeChatId === null || blank) {
|
|
315
|
+
answer(false);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const wanted = activeChatId === undefined ? null : String(activeChatId);
|
|
319
|
+
const deferred = ((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === sendRequest.id ? deferredSendRequest.current : null;
|
|
320
|
+
// The host named another chat while the request was waiting: it belonged to the earlier one, so it is not sent.
|
|
321
|
+
if (deferred && deferred.wanted !== wanted) {
|
|
322
|
+
answer(false);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (wanted !== null && !unloadableIds.current.has(wanted)) {
|
|
326
|
+
if (String(chat === null || chat === void 0 ? void 0 : chat.id) !== wanted) {
|
|
327
|
+
deferredSendRequest.current = { id: sendRequest.id, wanted };
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Only a request that could be sent once connected is worth waiting for.
|
|
331
|
+
if (deferred && connectedChatId.current !== wanted && !isArchived && !running)
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const text = sendRequest.text;
|
|
335
|
+
const sent = Boolean(canSendRef.current && typeof text === 'string' && text.trim() && startTurn(text));
|
|
336
|
+
if (sent)
|
|
337
|
+
canSendRef.current = false;
|
|
338
|
+
answer(sent);
|
|
339
|
+
}, [sendRequest, chat, activeChatId, socket.connected, running]);
|
|
265
340
|
// Runs after the render that carries the prefilled draft; a counter, so the same text requested twice focuses twice.
|
|
266
341
|
// A disabled input (connection down) cannot take focus, so the request stays pending until it is enabled again.
|
|
267
342
|
useEffect(() => {
|
|
@@ -319,7 +394,12 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
319
394
|
}
|
|
320
395
|
});
|
|
321
396
|
const startTurn = (message) => {
|
|
322
|
-
|
|
397
|
+
const built = buildSendFrame(message, getSendContextRef.current);
|
|
398
|
+
if (built.error) {
|
|
399
|
+
setProblem('action_error');
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (!socket.send(built.frame))
|
|
323
403
|
return false;
|
|
324
404
|
setProblem(null);
|
|
325
405
|
setTurns((current) => [...current, {
|
|
@@ -349,8 +429,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
|
|
|
349
429
|
const retry = (turn) => {
|
|
350
430
|
if (!socket.connected)
|
|
351
431
|
return;
|
|
352
|
-
|
|
353
|
-
|
|
432
|
+
if (startTurn(turn.user_text))
|
|
433
|
+
setTurns((current) => current.filter((item) => item.id !== turn.id));
|
|
354
434
|
};
|
|
355
435
|
const noticeFor = () => {
|
|
356
436
|
const texts = {
|