@jini-ai/chat 0.6.17 → 0.6.19
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 +9 -0
- package/dist/core/events.d.ts +6 -0
- package/dist/core/run-events/entry.js +15 -2
- package/dist/core/surface-recovery.d.ts +19 -0
- package/dist/core/surface-recovery.js +26 -0
- package/dist/core/tool-events.d.ts +6 -1
- package/dist/core/tool-events.js +74 -27
- package/dist/core/turn-usage.d.ts +1 -1
- package/dist/core/turn-usage.js +5 -3
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
- package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
- package/dist/react/components/Composer.js +2 -1
- package/dist/react/components/ConversationList.js +2 -1
- package/dist/react/components/ConversationTitleEditor.js +2 -1
- package/dist/react/components/ExtEventErrorBoundary.js +2 -1
- package/dist/react/components/McpUiSurfaceCard.js +12 -5
- package/dist/react/components/ToolCard.js +3 -2
- package/dist/react/components/ToolCard.rules.js +2 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +14 -0
- package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +22 -3
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +3 -1
- package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
- package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
- package/dist/react/hooks/composer-draft-cache.js +18 -17
- package/dist/react/hooks/useComposer.js +52 -4
- package/dist/react/hooks/useConversation.js +28 -20
- package/dist/react/hooks/useRunStream.js +2 -1
- package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
- package/dist/react/hooks/useSurfaceRecovery.js +84 -0
- package/dist/react/styles/reference.css +4 -5
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- package/package.json +12 -12
|
@@ -216,31 +216,33 @@ function storedTimestamp(store, { key }) {
|
|
|
216
216
|
function writeStoredEnvelope(prefix, { conversationId, envelope }) {
|
|
217
217
|
const store = storage();
|
|
218
218
|
if (!store)
|
|
219
|
-
return;
|
|
219
|
+
return false;
|
|
220
220
|
const key = `${prefix}${conversationId}`;
|
|
221
221
|
try {
|
|
222
222
|
if (envelope === null) {
|
|
223
223
|
store.removeItem(key);
|
|
224
|
-
return;
|
|
224
|
+
return true;
|
|
225
225
|
}
|
|
226
226
|
if (store.getItem(key) === null)
|
|
227
227
|
pruneStored(prefix, MAX_CACHED_CONVERSATION_DRAFTS - 1);
|
|
228
228
|
store.setItem(key, JSON.stringify(envelope));
|
|
229
|
+
return true;
|
|
229
230
|
}
|
|
230
231
|
catch {
|
|
231
232
|
/* blocked, full, or unusable storage — memory-only is the documented degraded mode */
|
|
232
233
|
/* (on a removal: blocked storage; the in-memory tier is already correct) */
|
|
234
|
+
return false;
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
/**
|
|
236
|
-
* Writes or removes the stored copy for `conversationId
|
|
238
|
+
* Writes or removes the stored copy for `conversationId`; returns false on storage failure.
|
|
237
239
|
*
|
|
238
240
|
* @param draft A blank draft removes the entry; see {@link writeStoredEnvelope}.
|
|
239
241
|
* @complexity Time O(n) in the draft's length; see {@link writeStoredEnvelope}.
|
|
240
242
|
*/
|
|
241
243
|
function writeStoredDraft(conversationId, draft) {
|
|
242
244
|
const envelope = draft === '' ? null : { v: 1, t: Date.now(), d: draft };
|
|
243
|
-
writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
|
|
245
|
+
return writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
|
|
244
246
|
}
|
|
245
247
|
/**
|
|
246
248
|
* Records `draft` in the in-memory tier, evicting the oldest-inserted conversation first if this
|
|
@@ -274,7 +276,7 @@ export function readCachedDraft({ conversationId }) {
|
|
|
274
276
|
return stored;
|
|
275
277
|
}
|
|
276
278
|
/**
|
|
277
|
-
* Stores `draft` for `conversationId` in both tiers.
|
|
279
|
+
* Stores `draft` for `conversationId` in both tiers. Returns whether durable storage accepted it.
|
|
278
280
|
*
|
|
279
281
|
* @param conversationId No-ops when absent (`null`/`undefined`) — there is nothing to key on yet.
|
|
280
282
|
* `useComposer` covers that window by adopting the typed text once an id arrives; see its own doc.
|
|
@@ -283,11 +285,11 @@ export function readCachedDraft({ conversationId }) {
|
|
|
283
285
|
* writes `''` here.
|
|
284
286
|
* @complexity Time/space: O(n) in the draft's length; see {@link writeStoredDraft}.
|
|
285
287
|
*/
|
|
286
|
-
export function writeCachedDraft({ conversationId, draft }) {
|
|
288
|
+
export function writeCachedDraft({ conversationId, draft }, _options = {}) {
|
|
287
289
|
if (!conversationId)
|
|
288
|
-
return;
|
|
290
|
+
return false;
|
|
289
291
|
rememberCachedDraft({ conversationId: conversationId, draft: draft });
|
|
290
|
-
writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
|
|
292
|
+
return writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
|
|
291
293
|
}
|
|
292
294
|
/**
|
|
293
295
|
* Stores `draft` for `conversationId` in the in-memory tier only, leaving `localStorage` alone.
|
|
@@ -436,26 +438,25 @@ export function writeCachedAttachmentBatchId({ conversationId, batchId }, _optio
|
|
|
436
438
|
setBounded(stagedBatchIds, { key: conversationId, value: batchId });
|
|
437
439
|
}
|
|
438
440
|
/**
|
|
439
|
-
* Stores `attachments` for `conversationId` in both tiers.
|
|
441
|
+
* Stores `attachments` for `conversationId` in both tiers. Returns whether storage accepted it.
|
|
440
442
|
*
|
|
441
443
|
* @param attachments An empty list deletes the entry, so a sent or cleared turn leaves nothing to
|
|
442
444
|
* restore — the same rule the draft text follows.
|
|
443
445
|
* @complexity Time/space: O(n) in the number of references.
|
|
444
446
|
*/
|
|
445
|
-
export function writeCachedAttachments({ conversationId, attachments }) {
|
|
447
|
+
export function writeCachedAttachments({ conversationId, attachments }, _options = {}) {
|
|
446
448
|
if (!conversationId)
|
|
447
|
-
return;
|
|
449
|
+
return false;
|
|
448
450
|
if (attachments.length === 0) {
|
|
449
451
|
stagedAttachments.delete(conversationId);
|
|
450
452
|
// A sent or cleared turn's batch is spent; the next upload starts a fresh one.
|
|
451
453
|
stagedBatchIds.delete(conversationId);
|
|
452
|
-
writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
|
|
453
|
-
return;
|
|
454
|
+
return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
|
|
454
455
|
}
|
|
455
456
|
setBounded(stagedAttachments, { key: conversationId, value: attachments });
|
|
456
457
|
const batchId = stagedBatchIds.get(conversationId);
|
|
457
458
|
const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
|
|
458
|
-
writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
|
|
459
|
+
return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
|
|
459
460
|
}
|
|
460
461
|
/**
|
|
461
462
|
* Test-only: empties the cache so test cases cannot leak drafts into each other.
|
|
@@ -489,11 +490,11 @@ export function readCachedFolders({ conversationId }, _options = {}) {
|
|
|
489
490
|
} });
|
|
490
491
|
return stored ?? [];
|
|
491
492
|
}
|
|
492
|
-
/** Persist exact strings independently of text and attachment validation. */
|
|
493
|
+
/** Persist exact strings independently of text and attachment validation; report storage failure. */
|
|
493
494
|
export function writeCachedFolders({ conversationId, folders }, _options = {}) {
|
|
494
495
|
if (!conversationId)
|
|
495
|
-
return;
|
|
496
|
+
return false;
|
|
496
497
|
setBounded(folderDrafts, { key: conversationId, value: [...folders] });
|
|
497
|
-
writeStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, envelope: folders.length ? { v: 1, t: Date.now(), folders } : null });
|
|
498
|
+
return writeStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, envelope: folders.length ? { v: 1, t: Date.now(), folders } : null });
|
|
498
499
|
}
|
|
499
500
|
//# sourceMappingURL=composer-draft-cache.js.map
|
|
@@ -49,16 +49,19 @@ const EMPTY_MENTION = { open: false, query: '', results: [] };
|
|
|
49
49
|
*/
|
|
50
50
|
export const DRAFT_PERSIST_DEBOUNCE_MS = 800;
|
|
51
51
|
/**
|
|
52
|
-
* One pending durable draft write at a time, trailing-edge debounced.
|
|
52
|
+
* One pending durable draft write at a time, trailing-edge debounced. Release checks flush and
|
|
53
|
+
* consult the draft owner without adding a browser reload prompt. Flushes on unmount and when
|
|
53
54
|
* the page is hidden (`pagehide`, or `visibilitychange` to hidden — the last event a mobile browser
|
|
54
55
|
* reliably delivers before discarding the tab).
|
|
55
56
|
* @param pageLifecycle The page-exit event sources, captured at mount; `undefined` resolves the
|
|
56
57
|
* browser's own, `null` subscribes to nothing (unmount still flushes).
|
|
57
58
|
* @complexity Time/space: O(1) per call.
|
|
58
59
|
*/
|
|
59
|
-
function useDeferredDraftWrite(pageLifecycle) {
|
|
60
|
+
function useDeferredDraftWrite(pageLifecycle, canRelease) {
|
|
60
61
|
const [lifecycle] = useState(() => (pageLifecycle === undefined ? browserPageLifecycle() : pageLifecycle));
|
|
61
62
|
const pendingRef = useRef(null);
|
|
63
|
+
const canReleaseRef = useRef(canRelease);
|
|
64
|
+
canReleaseRef.current = canRelease;
|
|
62
65
|
const cancel = useCallback(() => {
|
|
63
66
|
const pending = pendingRef.current;
|
|
64
67
|
if (!pending)
|
|
@@ -91,10 +94,23 @@ function useDeferredDraftWrite(pageLifecycle) {
|
|
|
91
94
|
if (doc.visibilityState === 'hidden')
|
|
92
95
|
flush();
|
|
93
96
|
};
|
|
97
|
+
const onReleaseCheck = (event) => {
|
|
98
|
+
try {
|
|
99
|
+
flush();
|
|
100
|
+
if (!canReleaseRef.current())
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Host persistence may throw; a release must never discard the only surviving copy.
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
94
108
|
page.addEventListener('pagehide', flush);
|
|
109
|
+
page.addEventListener('jini:release-check', onReleaseCheck);
|
|
95
110
|
doc.addEventListener('visibilitychange', onVisibilityChange);
|
|
96
111
|
return () => {
|
|
97
112
|
page.removeEventListener('pagehide', flush);
|
|
113
|
+
page.removeEventListener('jini:release-check', onReleaseCheck);
|
|
98
114
|
doc.removeEventListener('visibilitychange', onVisibilityChange);
|
|
99
115
|
flush();
|
|
100
116
|
};
|
|
@@ -124,7 +140,20 @@ export function useComposer(options = {}) {
|
|
|
124
140
|
const [attachments, setAttachments] = useState([]);
|
|
125
141
|
const [agent, setAgent] = useState(options.initialAgent);
|
|
126
142
|
const [mention, setMention] = useState(EMPTY_MENTION);
|
|
127
|
-
const
|
|
143
|
+
const pendingUploadsRef = useRef(0);
|
|
144
|
+
const deferredWrite = useDeferredDraftWrite(options.pageLifecycle, () => {
|
|
145
|
+
if (pendingUploadsRef.current > 0)
|
|
146
|
+
return false;
|
|
147
|
+
// Retry even after the debounce: the cache's memory tier cannot survive guest destruction.
|
|
148
|
+
// Empty values are durable deletions too. A failed remove must not resurrect a cleared draft
|
|
149
|
+
// after this guest is destroyed and storage becomes readable again.
|
|
150
|
+
const textSaved = conversationId ? writeCachedDraft({ conversationId, draft }) : draft.trim() === '';
|
|
151
|
+
const foldersSaved = conversationId ? writeCachedFolders({ conversationId, folders }) : folders.length === 0;
|
|
152
|
+
// Without the host's liveness validator, a remount deliberately does not restore files.
|
|
153
|
+
const filesSaved = canReleaseAttachments({ conversationId, attachments, validateAttachments,
|
|
154
|
+
hydratedFor: attachmentsHydratedForRef.current });
|
|
155
|
+
return textSaved && foldersSaved && filesSaved;
|
|
156
|
+
});
|
|
128
157
|
const commitDraft = useCallback((next) => {
|
|
129
158
|
setDraftState(next);
|
|
130
159
|
rememberCachedDraft({ conversationId: conversationId, draft: next });
|
|
@@ -263,7 +292,14 @@ export function useComposer(options = {}) {
|
|
|
263
292
|
if (!project?.uploadFiles) {
|
|
264
293
|
throw new Error('addAttachments requires ProjectContextValue.uploadFiles to be wired by the host');
|
|
265
294
|
}
|
|
266
|
-
|
|
295
|
+
pendingUploadsRef.current++;
|
|
296
|
+
let uploaded;
|
|
297
|
+
try {
|
|
298
|
+
uploaded = await project.uploadFiles(files);
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
pendingUploadsRef.current--;
|
|
302
|
+
}
|
|
267
303
|
setAttachments((prev) => [...prev, ...uploaded]);
|
|
268
304
|
// `uploadFiles` resolves 1:1 with `files`, in order, on success (see
|
|
269
305
|
// `create-daemon-attachment-uploader.ts`'s "preserved order" doc) - any failure rejects the
|
|
@@ -335,4 +371,16 @@ export function useComposer(options = {}) {
|
|
|
335
371
|
flushDraft: deferredWrite.flush,
|
|
336
372
|
}), [draft, history, setDraft, folders, addFolders, removeFolder, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset, deferredWrite.flush]);
|
|
337
373
|
}
|
|
374
|
+
/** Never erase a pending restore; once settled, confirm persistence of empty lists as well.
|
|
375
|
+
* Unkeyed empty composers have no stored entry to delete. O(staged files), bounded by the cache.
|
|
376
|
+
*/
|
|
377
|
+
function canReleaseAttachments({ conversationId, attachments, validateAttachments, hydratedFor }, _options = {}) {
|
|
378
|
+
if (!conversationId)
|
|
379
|
+
return attachments.length === 0;
|
|
380
|
+
if (hydratedFor !== conversationId)
|
|
381
|
+
return false;
|
|
382
|
+
if (attachments.length > 0 && !validateAttachments)
|
|
383
|
+
return false;
|
|
384
|
+
return writeCachedAttachments({ conversationId, attachments });
|
|
385
|
+
}
|
|
338
386
|
//# sourceMappingURL=useComposer.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
1
2
|
import { acceptedInputUserEvent, projectAcceptedInputEvents } from '../../core/accepted-input.js';
|
|
2
3
|
import { redactUserHistory, redactUserMessage } from '../../core/user-text-redaction.js';
|
|
3
4
|
import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
|
|
@@ -36,6 +37,30 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
|
|
|
36
37
|
...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
|
|
37
38
|
} }, options).message;
|
|
38
39
|
}
|
|
40
|
+
/** The queued assistant bubble a send opens; the run's events fill it in. */
|
|
41
|
+
function pendingAssistantMessage({ id }, { agentId } = {}) {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
role: 'assistant',
|
|
45
|
+
content: '',
|
|
46
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
47
|
+
runStatus: 'queued',
|
|
48
|
+
createdAt: Date.now(),
|
|
49
|
+
startedAt: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** What `run.start` receives for one send: only the options the caller actually set. */
|
|
53
|
+
function runStartInput({ assistantMessageId, history, conversationId, sendOptions }, { agentId } = {}) {
|
|
54
|
+
return {
|
|
55
|
+
assistantMessageId,
|
|
56
|
+
history,
|
|
57
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
58
|
+
conversationId,
|
|
59
|
+
...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
|
|
60
|
+
...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
|
|
61
|
+
...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
39
64
|
export function useConversation(options) {
|
|
40
65
|
const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
|
|
41
66
|
const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
|
|
@@ -66,7 +91,7 @@ export function useConversation(options) {
|
|
|
66
91
|
}
|
|
67
92
|
catch (error) {
|
|
68
93
|
if (!disposed)
|
|
69
|
-
console.error('[chat] conversation refresh failed', error);
|
|
94
|
+
console.error('[chat] conversation refresh failed', formatErrorForLog({ error }));
|
|
70
95
|
}
|
|
71
96
|
finally {
|
|
72
97
|
pending = false;
|
|
@@ -184,20 +209,11 @@ export function useConversation(options) {
|
|
|
184
209
|
// permanently unset.
|
|
185
210
|
useEffect(() => {
|
|
186
211
|
applyRunToAssistantMessage();
|
|
187
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
212
|
}, [run.events, run.status, run.runId]);
|
|
189
213
|
const sendMessage = useCallback(async (content, sendOptions = {}) => {
|
|
190
214
|
const userMessage = pendingUserMessage({ content, sendOptions, createMessageId }, options);
|
|
191
215
|
const resolvedAgentId = sendOptions.agentId ?? agentId;
|
|
192
|
-
const assistantMessage = {
|
|
193
|
-
id: createMessageId(),
|
|
194
|
-
role: 'assistant',
|
|
195
|
-
content: '',
|
|
196
|
-
...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
|
|
197
|
-
runStatus: 'queued',
|
|
198
|
-
createdAt: Date.now(),
|
|
199
|
-
startedAt: Date.now(),
|
|
200
|
-
};
|
|
216
|
+
const assistantMessage = pendingAssistantMessage({ id: createMessageId() }, { agentId: resolvedAgentId });
|
|
201
217
|
activeAssistantIdRef.current = assistantMessage.id;
|
|
202
218
|
setSendRefused(false);
|
|
203
219
|
const history = redactUserHistory({ history: [...messagesRef.current, userMessage] }, options);
|
|
@@ -209,15 +225,7 @@ export function useConversation(options) {
|
|
|
209
225
|
// (host stuck-chat investigation, 2026-09-27).
|
|
210
226
|
setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
|
|
211
227
|
setScrollIntent(true);
|
|
212
|
-
const accepted = await run.start({
|
|
213
|
-
assistantMessageId: assistantMessage.id,
|
|
214
|
-
history,
|
|
215
|
-
...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
|
|
216
|
-
conversationId,
|
|
217
|
-
...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
|
|
218
|
-
...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
|
|
219
|
-
...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
|
|
220
|
-
});
|
|
228
|
+
const accepted = await run.start(runStartInput({ assistantMessageId: assistantMessage.id, history, conversationId, sendOptions }, { agentId: resolvedAgentId }));
|
|
221
229
|
if (!accepted) {
|
|
222
230
|
// The bubbles go, but the reason must not: it was rendered only on the assistant bubble.
|
|
223
231
|
setMessagesState(prev => prev.filter(message => message.id !== userMessage.id && message.id !== assistantMessage.id));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
1
2
|
/**
|
|
2
3
|
* @module useRunStream
|
|
3
4
|
*
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
import { mergeRunEvents } from '../../core/durable-projection.js';
|
|
21
22
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
22
23
|
function reportRunStreamHostEffectFailure(effectName, error) {
|
|
23
|
-
console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, error);
|
|
24
|
+
console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, formatErrorForLog({ error }));
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* Fire-and-forget teardown call to `transport.stopRun`. Invokes it synchronously, then observes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type McpUiToolCallHandler, type UIResource } from '@jini-ai/ui/mcp-ui';
|
|
2
|
+
import type { ExtEventCall } from '../ext-event-renderer-registry.js';
|
|
3
|
+
/** Restore only an unanswered call: completed decisions must never execute twice. */
|
|
4
|
+
export declare function surfaceNeedsRecovery({ call, runStreaming }: {
|
|
5
|
+
call: ExtEventCall | undefined;
|
|
6
|
+
runStreaming: boolean;
|
|
7
|
+
}, _options?: {}): boolean;
|
|
8
|
+
/** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
|
|
9
|
+
* use the same stream. A component owns its subscription and aborts it on teardown.
|
|
10
|
+
* @complexity O(card document size) per emission; O(1) subscriptions per visible card.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }: {
|
|
13
|
+
resource: UIResource | undefined;
|
|
14
|
+
call: ExtEventCall | undefined;
|
|
15
|
+
runStreaming: boolean;
|
|
16
|
+
onToolCall: McpUiToolCallHandler | undefined;
|
|
17
|
+
}, _options?: {}): {
|
|
18
|
+
resource: UIResource | undefined;
|
|
19
|
+
restored: boolean;
|
|
20
|
+
live: boolean;
|
|
21
|
+
onToolCall: McpUiToolCallHandler | undefined;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=useSurfaceRecovery.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { parseUIResource } from '@jini-ai/ui/mcp-ui';
|
|
3
|
+
import { recoverableSurfaceResource, surfaceRecoveryInfo, SURFACE_EXCHANGE_META } from '../../core/surface-recovery.js';
|
|
4
|
+
/** Restore only an unanswered call: completed decisions must never execute twice. */
|
|
5
|
+
export function surfaceNeedsRecovery({ call, runStreaming }, _options = {}) {
|
|
6
|
+
if (!call)
|
|
7
|
+
return false;
|
|
8
|
+
if (!call.result)
|
|
9
|
+
return !runStreaming;
|
|
10
|
+
return call.result.isError || /\b(expired|abandoned|did not respond|run ended)\b/i.test(call.result.content);
|
|
11
|
+
}
|
|
12
|
+
function recoveryCaller(call) {
|
|
13
|
+
if (!call || !('restore' in call) || !('answer' in call))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (typeof call.restore !== 'function' || typeof call.answer !== 'function')
|
|
16
|
+
return undefined;
|
|
17
|
+
return call;
|
|
18
|
+
}
|
|
19
|
+
/** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
|
|
20
|
+
* use the same stream. A component owns its subscription and aborts it on teardown.
|
|
21
|
+
* @complexity O(card document size) per emission; O(1) subscriptions per visible card.
|
|
22
|
+
*/
|
|
23
|
+
export function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }, _options = {}) {
|
|
24
|
+
const [restored, setRestored] = useState();
|
|
25
|
+
const [live, setLive] = useState(false);
|
|
26
|
+
const pending = useRef(undefined);
|
|
27
|
+
const answerController = useRef(undefined);
|
|
28
|
+
const completed = useRef(false);
|
|
29
|
+
const info = surfaceRecoveryInfo({ value: resource?.resource._meta?.[SURFACE_EXCHANGE_META] });
|
|
30
|
+
const caller = info ? recoveryCaller(onToolCall) : undefined;
|
|
31
|
+
const needsRecovery = surfaceNeedsRecovery({ call, runStreaming });
|
|
32
|
+
const onSurface = (emission) => {
|
|
33
|
+
if (emission.channel !== 'mcp-ui')
|
|
34
|
+
return;
|
|
35
|
+
const next = parseUIResource(recoverableSurfaceResource({ resource: emission.payload['resource'], exchange: emission.payload['surfaceExchange'] }));
|
|
36
|
+
if (next) {
|
|
37
|
+
setRestored(next);
|
|
38
|
+
setLive(true);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!caller || !info || !needsRecovery)
|
|
43
|
+
return;
|
|
44
|
+
let disposed = false;
|
|
45
|
+
const restore = () => {
|
|
46
|
+
if (pending.current || disposed || completed.current)
|
|
47
|
+
return;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
pending.current = controller;
|
|
50
|
+
void caller.restore({ ...info, signal: controller.signal, onSurface: emission => { if (!disposed)
|
|
51
|
+
onSurface(emission); } })
|
|
52
|
+
.then(() => { if (!disposed)
|
|
53
|
+
completed.current = true; })
|
|
54
|
+
.catch(() => { })
|
|
55
|
+
.finally(() => { if (!disposed) {
|
|
56
|
+
pending.current = undefined;
|
|
57
|
+
setLive(false);
|
|
58
|
+
} });
|
|
59
|
+
};
|
|
60
|
+
restore();
|
|
61
|
+
window.addEventListener('focus', restore);
|
|
62
|
+
return () => {
|
|
63
|
+
disposed = true;
|
|
64
|
+
window.removeEventListener('focus', restore);
|
|
65
|
+
pending.current?.abort();
|
|
66
|
+
pending.current = undefined;
|
|
67
|
+
answerController.current?.abort();
|
|
68
|
+
answerController.current = undefined;
|
|
69
|
+
};
|
|
70
|
+
}, [caller, info?.exchangeId, needsRecovery]);
|
|
71
|
+
useEffect(() => () => answerController.current?.abort(), []);
|
|
72
|
+
const toolCall = caller ? async (submitted) => {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
answerController.current = controller;
|
|
75
|
+
try {
|
|
76
|
+
return await caller.answer({ call: submitted, signal: controller.signal, onSurface });
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
answerController.current = undefined;
|
|
80
|
+
}
|
|
81
|
+
} : onToolCall;
|
|
82
|
+
return { resource: restored ?? resource, restored: restored !== undefined, live, onToolCall: toolCall };
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=useSurfaceRecovery.js.map
|
|
@@ -36,11 +36,10 @@
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/* Lets a long agent name inside (e.g. an AgentRuntimePicker trigger) shrink and ellipsis instead
|
|
39
|
-
of forcing the row wider
|
|
40
|
-
|
|
41
|
-
is exactly where this stops being cosmetic. */
|
|
39
|
+
of forcing the row wider, while retaining the fixed controls' intrinsic minimum. A zero floor
|
|
40
|
+
let the effort selector overflow this wrapper across the Send/Stop hit targets in a dock. */
|
|
42
41
|
.jini-composer-footer-accessories {
|
|
43
|
-
min-width:
|
|
42
|
+
min-width: min-content;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
/* The picker, plus menu and send share this <div>; flex keeps them on one line, and it wraps
|
|
@@ -48,7 +47,7 @@
|
|
|
48
47
|
.jini-composer-footer-trailing {
|
|
49
48
|
display: flex;
|
|
50
49
|
align-items: center;
|
|
51
|
-
min-width:
|
|
50
|
+
min-width: min-content;
|
|
52
51
|
margin-left: auto;
|
|
53
52
|
}
|
|
54
53
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import { redactSecrets } from '@jini-ai/core';
|
|
1
3
|
import { ChatStoreError } from "../errors.js";
|
|
2
4
|
/**
|
|
3
5
|
* @file Retention for chat history: deletes every conversation whose `expires_at` has passed.
|
|
@@ -47,7 +49,7 @@ export function startChatExpirySweep({ maintenance }, options = {}) {
|
|
|
47
49
|
const onError = options.onError ??
|
|
48
50
|
((error) => {
|
|
49
51
|
// eslint-disable-next-line no-console
|
|
50
|
-
console.warn(
|
|
52
|
+
console.warn('[chat-expiry-sweep] sweep failed:', redactSecrets({ input: inspect(error) }, { redactPii: false }));
|
|
51
53
|
});
|
|
52
54
|
let active;
|
|
53
55
|
const pass = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/chat",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.19",
|
|
4
4
|
"description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -214,10 +214,10 @@
|
|
|
214
214
|
},
|
|
215
215
|
"dependencies": {
|
|
216
216
|
"kysely": "^0.29.6",
|
|
217
|
-
"@jini-ai/core": "^0.6.
|
|
218
|
-
"@jini-ai/
|
|
219
|
-
"@jini-ai/
|
|
220
|
-
"@jini-ai/diagnostics": "^0.6.
|
|
217
|
+
"@jini-ai/core": "^0.6.19",
|
|
218
|
+
"@jini-ai/db": "^0.6.19",
|
|
219
|
+
"@jini-ai/agentic": "^0.6.19",
|
|
220
|
+
"@jini-ai/diagnostics": "^0.6.19"
|
|
221
221
|
},
|
|
222
222
|
"peerDependencies": {
|
|
223
223
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -261,13 +261,13 @@
|
|
|
261
261
|
"pg": "8.23.0",
|
|
262
262
|
"@ag-ui/core": "0.0.58",
|
|
263
263
|
"fast-check": "4.3.0",
|
|
264
|
-
"@jini-ai/
|
|
265
|
-
"@jini-ai/
|
|
266
|
-
"@jini-ai/
|
|
267
|
-
"@jini-ai/protocol": "^0.6.
|
|
268
|
-
"@jini-ai/
|
|
269
|
-
"@jini-ai/
|
|
270
|
-
"@jini-ai/
|
|
264
|
+
"@jini-ai/db": "0.6.19",
|
|
265
|
+
"@jini-ai/agentic": "^0.6.19",
|
|
266
|
+
"@jini-ai/agent-runtime": "0.6.19",
|
|
267
|
+
"@jini-ai/protocol": "^0.6.19",
|
|
268
|
+
"@jini-ai/diagnostics": "^0.6.19",
|
|
269
|
+
"@jini-ai/ui": "^0.6.19",
|
|
270
|
+
"@jini-ai/core": "^0.6.19"
|
|
271
271
|
},
|
|
272
272
|
"typesVersions": {
|
|
273
273
|
"*": {
|