@jini-ai/chat 0.6.17 → 0.6.21
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 +15 -0
- package/dist/core/accepted-input.d.ts +10 -2
- package/dist/core/accepted-input.js +87 -33
- package/dist/core/assistant-client/durable-run-subscription.js +6 -0
- package/dist/core/assistant-client/entry.d.ts +1 -1
- package/dist/core/assistant-client/entry.js +1 -1
- package/dist/core/assistant-client/messages.d.ts +21 -0
- package/dist/core/assistant-client/messages.js +31 -0
- package/dist/core/checkpoint-interval.d.ts +25 -0
- package/dist/core/checkpoint-interval.js +38 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
- package/dist/core/durable-runs/ports.d.ts +25 -1
- package/dist/core/events.d.ts +6 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -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 +11 -3
- package/dist/core/tool-events.js +75 -43
- 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.d.ts +3 -3
- package/dist/react/components/Composer.js +5 -4
- 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/MessageList.js +2 -1
- package/dist/react/components/MidRunUserMessage.js +1 -1
- 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/components/ChatPane.js +1 -1
- 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.d.ts +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
- 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 +41 -22
- 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/server/durable-runs/recover.js +3 -3
- package/dist/store/ports.d.ts +17 -3
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- package/dist/store/runtime/chat-history-store.js +6 -0
- package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
- package/dist/store/runtime/checkpoint-delta.js +75 -0
- package/dist/store/runtime/durable-run-store.js +92 -25
- package/dist/store/runtime/events-json.d.ts +17 -0
- package/dist/store/runtime/events-json.js +19 -0
- package/dist/store/runtime/run-guarded-chat-store.js +8 -3
- package/dist/store/runtime/run-ledger.d.ts +2 -2
- package/dist/store/runtime/run-ledger.js +120 -45
- package/dist/store/sql/read-version.d.ts +15 -0
- package/dist/store/sql/read-version.js +21 -0
- package/dist/store/sql/store.d.ts +9 -0
- package/dist/store/sql/store.js +89 -4
- package/dist/store/sqlite/schema.d.ts +2 -2
- package/dist/store/sqlite/schema.js +4 -0
- 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,39 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
|
|
|
36
37
|
...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
|
|
37
38
|
} }, options).message;
|
|
38
39
|
}
|
|
40
|
+
/** A checkpoint's run id proves acceptance even if the original start request is still pending.
|
|
41
|
+
* @returns A copy with pending state cleared only for this run's preceding user row; no I/O.
|
|
42
|
+
* @complexity O(1) time/space, called in the existing transcript reconciliation pass.
|
|
43
|
+
*/
|
|
44
|
+
function acceptPendingUserMessage({ message, reply, assistantId, runId }, _options = {}) {
|
|
45
|
+
if (!message.inputPending || reply?.id !== assistantId || runId === null)
|
|
46
|
+
return message;
|
|
47
|
+
return { ...message, inputPending: false };
|
|
48
|
+
}
|
|
49
|
+
/** The queued assistant bubble a send opens; the run's events fill it in. */
|
|
50
|
+
function pendingAssistantMessage({ id }, { agentId } = {}) {
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
role: 'assistant',
|
|
54
|
+
content: '',
|
|
55
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
56
|
+
runStatus: 'queued',
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
startedAt: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** What `run.start` receives for one send: only the options the caller actually set. */
|
|
62
|
+
function runStartInput({ assistantMessageId, history, conversationId, sendOptions }, { agentId } = {}) {
|
|
63
|
+
return {
|
|
64
|
+
assistantMessageId,
|
|
65
|
+
history,
|
|
66
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
67
|
+
conversationId,
|
|
68
|
+
...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
|
|
69
|
+
...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
|
|
70
|
+
...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
39
73
|
export function useConversation(options) {
|
|
40
74
|
const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
|
|
41
75
|
const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
|
|
@@ -66,7 +100,7 @@ export function useConversation(options) {
|
|
|
66
100
|
}
|
|
67
101
|
catch (error) {
|
|
68
102
|
if (!disposed)
|
|
69
|
-
console.error('[chat] conversation refresh failed', error);
|
|
103
|
+
console.error('[chat] conversation refresh failed', formatErrorForLog({ error }));
|
|
70
104
|
}
|
|
71
105
|
finally {
|
|
72
106
|
pending = false;
|
|
@@ -139,9 +173,11 @@ export function useConversation(options) {
|
|
|
139
173
|
const assistantId = activeAssistantIdRef.current;
|
|
140
174
|
if (!assistantId)
|
|
141
175
|
return;
|
|
142
|
-
setMessagesState((prev) => prev.map((m) => {
|
|
176
|
+
setMessagesState((prev) => prev.map((m, index) => {
|
|
143
177
|
if (m.id !== assistantId)
|
|
144
|
-
return
|
|
178
|
+
return acceptPendingUserMessage({
|
|
179
|
+
message: m, reply: prev[index + 1], assistantId, runId: run.runId,
|
|
180
|
+
});
|
|
145
181
|
// A plain if/else chain (not a nested ternary) — deliberately, so
|
|
146
182
|
// each of the four mapped statuses is its own independently
|
|
147
183
|
// trackable branch under coverage instrumentation. The fallback
|
|
@@ -184,20 +220,11 @@ export function useConversation(options) {
|
|
|
184
220
|
// permanently unset.
|
|
185
221
|
useEffect(() => {
|
|
186
222
|
applyRunToAssistantMessage();
|
|
187
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
223
|
}, [run.events, run.status, run.runId]);
|
|
189
224
|
const sendMessage = useCallback(async (content, sendOptions = {}) => {
|
|
190
225
|
const userMessage = pendingUserMessage({ content, sendOptions, createMessageId }, options);
|
|
191
226
|
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
|
-
};
|
|
227
|
+
const assistantMessage = pendingAssistantMessage({ id: createMessageId() }, { agentId: resolvedAgentId });
|
|
201
228
|
activeAssistantIdRef.current = assistantMessage.id;
|
|
202
229
|
setSendRefused(false);
|
|
203
230
|
const history = redactUserHistory({ history: [...messagesRef.current, userMessage] }, options);
|
|
@@ -209,15 +236,7 @@ export function useConversation(options) {
|
|
|
209
236
|
// (host stuck-chat investigation, 2026-09-27).
|
|
210
237
|
setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
|
|
211
238
|
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
|
-
});
|
|
239
|
+
const accepted = await run.start(runStartInput({ assistantMessageId: assistantMessage.id, history, conversationId, sendOptions }, { agentId: resolvedAgentId }));
|
|
221
240
|
if (!accepted) {
|
|
222
241
|
// The bubbles go, but the reason must not: it was rendered only on the assistant bubble.
|
|
223
242
|
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
|
|
|
@@ -165,7 +165,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
165
165
|
const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
|
|
166
166
|
if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
|
|
167
167
|
return "superseded";
|
|
168
|
-
const next = await ports.store.load({ messageId: run.messageId }, {});
|
|
168
|
+
const next = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
|
|
169
169
|
// Cancel/deletion can win while the old attempt is being fenced. Re-read after CAS and do
|
|
170
170
|
// not launch against a removed binding or a newer generation.
|
|
171
171
|
if (!next || next.runId !== nextRunId)
|
|
@@ -234,7 +234,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
234
234
|
}
|
|
235
235
|
/** The saved row after an await, or the outcome that ends this decision. O(1) store reads. */
|
|
236
236
|
async function reload(run) {
|
|
237
|
-
const current = await ports.store.load({ messageId: run.messageId }, {});
|
|
237
|
+
const current = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
|
|
238
238
|
if (!current)
|
|
239
239
|
return "gone";
|
|
240
240
|
// A probe only speaks about the generation it inspected. Never apply its dead result to
|
|
@@ -264,7 +264,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
264
264
|
}
|
|
265
265
|
return {
|
|
266
266
|
async recover({ messageId, trigger, expectedRunId }, options) {
|
|
267
|
-
const run = await ports.store.load({ messageId }, {});
|
|
267
|
+
const run = await ports.store.load({ messageId }, { includeTranscriptEvents: false });
|
|
268
268
|
if (!run)
|
|
269
269
|
return "gone";
|
|
270
270
|
if (run.message.runStatus !== "queued" && run.message.runStatus !== "running")
|
package/dist/store/ports.d.ts
CHANGED
|
@@ -88,8 +88,22 @@ export interface ChatStore {
|
|
|
88
88
|
delete(required: {
|
|
89
89
|
readonly id: string;
|
|
90
90
|
}, optional?: Record<string, never>): Promise<void>;
|
|
91
|
-
/** Compatibility read
|
|
92
|
-
|
|
91
|
+
/** Compatibility read in stored order; unauthorized returns []. `false` omits event columns;
|
|
92
|
+
* `user` keeps input receipts/folders without reading prior assistant payloads. */
|
|
93
|
+
messages(required: ChatMessagePageInput, optional?: {
|
|
94
|
+
readonly includeEvents?: boolean | "user";
|
|
95
|
+
}): Promise<ChatMessage[]>;
|
|
96
|
+
/** Optional indexed compatibility read; unknown and other-owned rows both return null. */
|
|
97
|
+
message?(required: ChatMessagePageInput & {
|
|
98
|
+
readonly messageId: string;
|
|
99
|
+
}, optional?: Record<string, never>): Promise<ChatMessage | null>;
|
|
100
|
+
/** Cheap owner-scoped history validator; reads metadata, never message/event payloads. */
|
|
101
|
+
messagesVersion?(required: ChatMessagePageInput, optional?: Record<string, never>): Promise<string | null>;
|
|
102
|
+
/** JSON response chunks, preserving stored event JSON instead of serializing event object trees.
|
|
103
|
+
* The optional ordering policy sees message metadata and receipt-derived folders, without events. */
|
|
104
|
+
messagesJson?(required: ChatMessagePageInput, optional?: {
|
|
105
|
+
readonly orderMessages?: (messages: ChatMessage[]) => ChatMessage[];
|
|
106
|
+
}): Promise<AsyncIterable<string>>;
|
|
93
107
|
/** Idempotent update by message ID within its original conversation; unauthorized: null. */
|
|
94
108
|
appendMessage(required: {
|
|
95
109
|
readonly conversationId: string;
|
|
@@ -100,7 +114,7 @@ export interface ChatStore {
|
|
|
100
114
|
readonly search?: ChatSearchCapability;
|
|
101
115
|
}
|
|
102
116
|
/** Eight-method compatibility subset plus optional bounded history; old hosts/fakes still fit. */
|
|
103
|
-
export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'appendMessage' | 'history'>;
|
|
117
|
+
export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'message' | 'messagesJson' | 'messagesVersion' | 'appendMessage' | 'history'>;
|
|
104
118
|
/** Internal persistence projections. Model-facing tools redact and cap these before returning. */
|
|
105
119
|
export interface ChatHistoryReader {
|
|
106
120
|
list(required: Record<string, never>, optional?: {
|
|
@@ -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 = () => {
|
|
@@ -50,6 +50,12 @@ export function createChatHistoryStore({ kernel: hostKernel, scope, isDaemonRunI
|
|
|
50
50
|
async messages(required, optional = {}) {
|
|
51
51
|
return repairLegacyTurnOrder({ messages: await store.messages(required, optional), isDaemonRunId }, {});
|
|
52
52
|
},
|
|
53
|
+
// Raw history must apply the same repair before emitting chunks; ordering never needs events.
|
|
54
|
+
async messagesJson(required, optional = {}) {
|
|
55
|
+
return store.messagesJson(required, { ...optional,
|
|
56
|
+
orderMessages: messages => repairLegacyTurnOrder({ messages, isDaemonRunId }, {}),
|
|
57
|
+
});
|
|
58
|
+
},
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
function storeFactory(transport) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AgentEvent } from "../../core/index.js";
|
|
2
|
+
import type { CheckpointState, RunProgress } from "../../core/durable-runs/ledger-contracts.js";
|
|
3
|
+
/** A delta may append events or replace the final coalesced text event. Every earlier event
|
|
4
|
+
* must match; replay reordering, thinking replacement and tool identity deduplication fall back.
|
|
5
|
+
* @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
|
|
6
|
+
export declare function checkpointDelta({ previous, progress }: {
|
|
7
|
+
previous: CheckpointState;
|
|
8
|
+
progress: RunProgress;
|
|
9
|
+
}, _options?: {}): {
|
|
10
|
+
removeCharacters: number;
|
|
11
|
+
suffix: string;
|
|
12
|
+
grown: boolean;
|
|
13
|
+
} | undefined;
|
|
14
|
+
/** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
|
|
15
|
+
export declare function hasNewToolProgress({ saved, incoming }: {
|
|
16
|
+
saved: readonly AgentEvent[];
|
|
17
|
+
incoming: readonly AgentEvent[];
|
|
18
|
+
}, _options?: {}): boolean;
|
|
19
|
+
/** Durable tool identity shared by the full merge and delta progress check. */
|
|
20
|
+
export declare function toolKey({ event }: {
|
|
21
|
+
event: AgentEvent;
|
|
22
|
+
}, _options?: {}): string;
|
|
23
|
+
/** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
|
|
24
|
+
* SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
|
|
25
|
+
* a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
|
|
26
|
+
* @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
|
|
27
|
+
* with at most 256 UTF-16 units retained beyond the existing projection references.
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkpointState({ progress, length, contentLength, version }: {
|
|
30
|
+
progress: RunProgress;
|
|
31
|
+
length: number;
|
|
32
|
+
contentLength: number;
|
|
33
|
+
version: string;
|
|
34
|
+
}, _options?: {}): CheckpointState;
|
|
35
|
+
//# sourceMappingURL=checkpoint-delta.d.ts.map
|