@xh/hoist-chat-kit 1.0.0 → 2.0.0-SNAPSHOT.1793828979839
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 +36 -0
- package/core/ChatKit.ts +0 -8
- package/core/ChatModel.ts +62 -12
- package/core/README.md +14 -3
- package/core/composer/ChatComposerModel.ts +1 -2
- package/core/composer/ChatComposerStatus.ts +27 -7
- package/core/impl/messageUtils.ts +29 -0
- package/core/replayChatEvents.ts +10 -8
- package/desktop/cmp/chat/ChatComposer.ts +1 -0
- package/desktop/cmp/chat/ChatMessageView.ts +53 -44
- package/desktop/cmp/chat/ChatPanel.ts +1 -5
- package/desktop/cmp/chat/ChatStatusRegion.scss +0 -8
- package/desktop/cmp/chat/ChatStatusRegion.ts +38 -80
- package/desktop/cmp/chat/ChatTimeline.scss +2 -1
- package/desktop/cmp/chat/ChatTimeline.ts +10 -0
- package/desktop/cmp/chat/ChatToolCall.scss +2 -1
- package/desktop/cmp/chat/ChatToolCall.ts +32 -13
- package/desktop/cmp/chat/README.md +15 -19
- package/desktop/cmp/chat/impl/ChatTimelineItems.ts +11 -2
- package/desktop/cmp/chat/impl/ChatTimelineRows.ts +75 -43
- package/desktop/cmp/chat/impl/ChatToolCallGroup.ts +41 -18
- package/desktop/cmp/chat/impl/ReplyDetached.ts +13 -0
- package/desktop/cmp/chat/impl/ToolCallStatus.ts +47 -12
- package/docs/README.md +4 -3
- package/docs/build-and-publish.md +15 -6
- package/docs/changelog-format.md +5 -3
- package/docs/doc-registry.json +34 -7
- package/docs/upgrade-notes/v2-upgrade-notes.md +81 -0
- package/package.json +1 -1
- package/threads/ChatThreadStore.ts +4 -1
- package/threads/ChatThreadsModel.ts +21 -5
- package/threads/README.md +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,42 @@
|
|
|
12
12
|
3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
|
|
13
13
|
-->
|
|
14
14
|
|
|
15
|
+
## 2.0.0-SNAPSHOT - unreleased
|
|
16
|
+
|
|
17
|
+
### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - remove the busy-line settings)
|
|
18
|
+
|
|
19
|
+
See [`docs/upgrade-notes/v2-upgrade-notes.md`](docs/upgrade-notes/v2-upgrade-notes.md) for
|
|
20
|
+
detailed, step-by-step upgrade instructions with before/after code examples.
|
|
21
|
+
|
|
22
|
+
* `ChatStatusRegion` now shows only what stops a send or needs the user's action. A reply in
|
|
23
|
+
progress, or one waiting for the user's answer, shows no line, as the reply shows its progress.
|
|
24
|
+
* Removed the busy line's settings: `showStopHint` and `assistantName` on `ChatStatusRegion`,
|
|
25
|
+
`ChatStatusRegion.defaults` with its `ChatStatusRegionDefaults` type, and
|
|
26
|
+
`ChatKit.defaults.busyIcon`. The Stop button names Esc in its tooltip.
|
|
27
|
+
* Removed the `xh-chat-status-region__lead`, `xh-chat-status-region__hint`,
|
|
28
|
+
`xh-chat-status-region--busy` and `xh-chat-status-region--attention` classes, which the region no
|
|
29
|
+
longer renders.
|
|
30
|
+
|
|
31
|
+
### 🎁 New Features
|
|
32
|
+
|
|
33
|
+
* `ChatThreadsModel` now blocks sends while the store lists a thread as running and its last reply
|
|
34
|
+
was left running, or it has no messages yet. `getChatComposerStatus()` reports the block with
|
|
35
|
+
Refresh Status, or as `detached` when the last reply is.
|
|
36
|
+
|
|
37
|
+
### 🐞 Bug Fixes
|
|
38
|
+
|
|
39
|
+
* Fixed Refresh Status in a `ChatThreadsModel` chat to reload the thread list too. The list could
|
|
40
|
+
show a thread as running after its run had finished.
|
|
41
|
+
* Fixed a reply left running on the server, live, loaded or replayed, to show its unfinished server
|
|
42
|
+
calls and sub-agents as "Not finished yet", not as failed, cancelled or "Stopped". Once the
|
|
43
|
+
conversation moves past the reply, it reads as complete and its open work closes.
|
|
44
|
+
|
|
45
|
+
### ✨ Styles
|
|
46
|
+
|
|
47
|
+
* Added the `xh-chat-timeline--detached`, `xh-chat-timeline__item--unfinished`,
|
|
48
|
+
`xh-chat-tool-call--unfinished` and `xh-chat-tool-call__glyph--unfinished` classes. A detached
|
|
49
|
+
reply's timeline no longer takes `xh-chat-timeline--stopped`.
|
|
50
|
+
|
|
15
51
|
## 1.0.0 - 2026-11-02
|
|
16
52
|
|
|
17
53
|
The first release of Hoist Chat Kit, a library of chat components for Hoist React applications.
|
package/core/ChatKit.ts
CHANGED
|
@@ -17,13 +17,6 @@ export interface ChatKitDefaults {
|
|
|
17
17
|
*/
|
|
18
18
|
assistantIcon: () => ReactElement;
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
* Returns the icon beside "Assistant is working" in the composer's status region, for an
|
|
22
|
-
* app with a busy glyph of its own. Called for each use. Return null from it for no icon.
|
|
23
|
-
* Null for the kit's own spinner. Default null.
|
|
24
|
-
*/
|
|
25
|
-
busyIcon: () => ReactElement;
|
|
26
|
-
|
|
27
20
|
/**
|
|
28
21
|
* What a chat spinner does under reduced motion - when the browser reports
|
|
29
22
|
* `prefers-reduced-motion: reduce`, or inside an element with class `xh-chat--reduced-motion`.
|
|
@@ -122,7 +115,6 @@ export class ChatKit {
|
|
|
122
115
|
/** Kit-wide settings - see {@link ChatKitDefaults}. */
|
|
123
116
|
static defaults: ChatKitDefaults = {
|
|
124
117
|
assistantIcon: () => Icon.ai(),
|
|
125
|
-
busyIcon: null,
|
|
126
118
|
reducedMotionSpinner: 'static',
|
|
127
119
|
stallAfterMs: 30_000,
|
|
128
120
|
drafts: 'local',
|
package/core/ChatModel.ts
CHANGED
|
@@ -84,7 +84,9 @@ import {genChatId} from '../impl/LangUtils';
|
|
|
84
84
|
import {
|
|
85
85
|
createTextPart,
|
|
86
86
|
getClientToolCalls,
|
|
87
|
+
isDetachedReply,
|
|
87
88
|
SETTLE_REASONS,
|
|
89
|
+
settleOpenCalls,
|
|
88
90
|
settleOpenParts,
|
|
89
91
|
settleStreamingParts,
|
|
90
92
|
updatePart
|
|
@@ -548,7 +550,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
548
550
|
@computed
|
|
549
551
|
get waitingToolCalls(): ChatToolCallPart[] {
|
|
550
552
|
const {lastMessage} = this;
|
|
551
|
-
|
|
553
|
+
// A detached reply's open calls wait on the server, not the user.
|
|
554
|
+
if (this.isBusy || !lastMessage?.isAssistant || isDetachedReply(lastMessage)) return [];
|
|
552
555
|
const isPausedOnInterrupts = !!this.pausedInterruptSet;
|
|
553
556
|
return lastMessage.toolCalls.filter(
|
|
554
557
|
it =>
|
|
@@ -717,7 +720,7 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
717
720
|
this.modelTools = tools.filter(
|
|
718
721
|
it => it.sendToModel ?? (it.execute != null || !!it.interactive)
|
|
719
722
|
);
|
|
720
|
-
this.messages =
|
|
723
|
+
this.messages = this.restore(initialMessages);
|
|
721
724
|
this.addArtifactsOf(this.messages);
|
|
722
725
|
tools.forEach(it => {
|
|
723
726
|
if (it.interactive && !it.execute && !it.renderer) {
|
|
@@ -798,7 +801,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
798
801
|
/**
|
|
799
802
|
* Stop following the current turn, without cancelling it. The message is kept as 'aborted',
|
|
800
803
|
* as with `stop`. In server mode, a turn with a step in flight is marked `metadata.detached`,
|
|
801
|
-
* as the server finishes it on its own, and a reload of the thread later shows its answer.
|
|
804
|
+
* as the server finishes it on its own, and a reload of the thread later shows its answer. Its
|
|
805
|
+
* unfinished tool calls and sub-agents stay open, where a stop closes them as cancelled.
|
|
802
806
|
*/
|
|
803
807
|
detach() {
|
|
804
808
|
this.abortTurn('detach');
|
|
@@ -1135,7 +1139,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
1135
1139
|
* that was pending or streaming becomes 'aborted', with its unfinished tool calls failed. A
|
|
1136
1140
|
* paused turn at the end of the history stays paused, so the user can still answer its tool
|
|
1137
1141
|
* calls - or send a new message, which closes them. A paused turn earlier in the history is
|
|
1138
|
-
* closed.
|
|
1142
|
+
* closed. So is a detached reply - see {@link detach} - except at the end of the history,
|
|
1143
|
+
* where its unfinished tool calls and sub-agents stay open.
|
|
1139
1144
|
*
|
|
1140
1145
|
* In server mode, a turn still running on the server is detached rather than cancelled - see
|
|
1141
1146
|
* {@link detach}.
|
|
@@ -1709,6 +1714,30 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
1709
1714
|
}
|
|
1710
1715
|
}
|
|
1711
1716
|
|
|
1717
|
+
/** Messages from stored specs, closed as `loadMessages` describes. */
|
|
1718
|
+
private restore(specs: ChatMessageSpec[]): ChatMessage[] {
|
|
1719
|
+
const messages = restoreMessages(specs),
|
|
1720
|
+
last = messages[messages.length - 1];
|
|
1721
|
+
if (isDetachedReply(last)) messages[messages.length - 1] = this.closeClientCalls(last);
|
|
1722
|
+
return messages;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* A detached reply with the open calls that the browser would run or answer closed - those to
|
|
1727
|
+
* a tool of the chat with an `execute`, or an interactive one. The turn that would run them
|
|
1728
|
+
* is over, and the server never does. Calls the server runs, display tools included, stay open.
|
|
1729
|
+
*/
|
|
1730
|
+
private closeClientCalls(message: ChatMessage): ChatMessage {
|
|
1731
|
+
return settleOpenCalls(
|
|
1732
|
+
message,
|
|
1733
|
+
it => {
|
|
1734
|
+
const spec = it.providerExecuted ? null : this.getOwnToolSpec(it.toolName);
|
|
1735
|
+
return !!(spec?.execute || spec?.interactive);
|
|
1736
|
+
},
|
|
1737
|
+
SETTLE_REASONS.interrupted
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1712
1741
|
/** The spec for a tool in `tools`, or null - the only kind that runs or answers a call. */
|
|
1713
1742
|
private getOwnToolSpec(name: string): ChatToolSpec {
|
|
1714
1743
|
return this.tools.find(it => it.name === name) ?? null;
|
|
@@ -1759,7 +1788,7 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
1759
1788
|
this.abortTurn(reason);
|
|
1760
1789
|
this._threadId = threadId ?? genChatId();
|
|
1761
1790
|
this.isThreadIdProvisional = threadId == null || !hasHistory(specs);
|
|
1762
|
-
this.messages =
|
|
1791
|
+
this.messages = this.restore(specs);
|
|
1763
1792
|
this.interruptSet = restoreInterruptSet(this.messages);
|
|
1764
1793
|
// A history rebuilt from events, rather than saved by the chat, has no marks on the
|
|
1765
1794
|
// server calls that a paused reply's interrupts wait on.
|
|
@@ -1788,7 +1817,8 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
1788
1817
|
if (this.interruptSet) this.setInterruptSet(declineRest(this.interruptSet));
|
|
1789
1818
|
this.closePausedTurn();
|
|
1790
1819
|
|
|
1791
|
-
|
|
1820
|
+
// A detached reply the conversation moves past gets no more results.
|
|
1821
|
+
const history = getHistory().map(it => (isDetachedReply(it) ? closeMovedPast(it) : it)),
|
|
1792
1822
|
userMessage = findLast(history, it => it.isUser),
|
|
1793
1823
|
message = new ChatMessage({role: 'assistant', status: 'pending'}),
|
|
1794
1824
|
turn = createTurn(message, 0, true, {
|
|
@@ -1871,10 +1901,11 @@ export class ChatModel extends HoistModel implements ChatComposerHost, ChatTrans
|
|
|
1871
1901
|
this.logError('Could not check the reply for catalog UI', e);
|
|
1872
1902
|
}
|
|
1873
1903
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1904
|
+
// A detached reply's work goes on on the server, so its open calls and sub-agents stay open.
|
|
1905
|
+
const settled = (
|
|
1906
|
+
detached
|
|
1907
|
+
? this.closeClientCalls(settleStreamingParts(message))
|
|
1908
|
+
: settleOpenParts(message, SETTLE_REASONS[status], status === 'aborted')
|
|
1878
1909
|
).withChanges({
|
|
1879
1910
|
status,
|
|
1880
1911
|
error: error ? errorText(error) : null,
|
|
@@ -2158,7 +2189,8 @@ function stopTurnClock(turn: ChatTurn): number {
|
|
|
2158
2189
|
|
|
2159
2190
|
/**
|
|
2160
2191
|
* Messages from stored specs, with anything left in progress closed - see `loadMessages`. A
|
|
2161
|
-
* paused turn at the end of the specs stays paused.
|
|
2192
|
+
* paused turn at the end of the specs stays paused. So does the open work of a detached reply at
|
|
2193
|
+
* the end, which the server may still finish.
|
|
2162
2194
|
*/
|
|
2163
2195
|
function restoreMessages(specs: ChatMessageSpec[]): ChatMessage[] {
|
|
2164
2196
|
const messages = specs.map(it => new ChatMessage(it)),
|
|
@@ -2178,11 +2210,29 @@ function restoreMessages(specs: ChatMessageSpec[]): ChatMessage[] {
|
|
|
2178
2210
|
status: 'aborted'
|
|
2179
2211
|
});
|
|
2180
2212
|
default:
|
|
2181
|
-
|
|
2213
|
+
if (!isDetachedReply(message)) {
|
|
2214
|
+
return settleOpenParts(message, SETTLE_REASONS.interrupted);
|
|
2215
|
+
}
|
|
2216
|
+
return idx === lastIdx ? settleStreamingParts(message) : closeMovedPast(message);
|
|
2182
2217
|
}
|
|
2183
2218
|
});
|
|
2184
2219
|
}
|
|
2185
2220
|
|
|
2221
|
+
/**
|
|
2222
|
+
* A detached reply that the conversation moved past, as complete, with its open work closed - as
|
|
2223
|
+
* for a paused reply the user moved past. The chat will not learn how its run ended.
|
|
2224
|
+
*/
|
|
2225
|
+
function closeMovedPast(message: ChatMessage): ChatMessage {
|
|
2226
|
+
// A call approved for a resume gets no result either, once nothing follows the reply.
|
|
2227
|
+
const parts = message.parts.map(it =>
|
|
2228
|
+
it.type === 'toolCall' && it.awaitsResume ? {...it, awaitsResume: false} : it
|
|
2229
|
+
);
|
|
2230
|
+
return settleOpenParts(message.withChanges({parts}), SETTLE_REASONS.complete).withChanges({
|
|
2231
|
+
status: 'complete',
|
|
2232
|
+
metadata: omit(message.metadata, 'detached')
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2186
2236
|
/** The tools a catalog adds to a chat - see {@link ChatModelConfig.catalog}. */
|
|
2187
2237
|
function getCatalogTools(catalog: ChatUiCatalog, historyMode: ChatHistoryMode): ChatToolSpec[] {
|
|
2188
2238
|
if (!catalog) return [];
|
package/core/README.md
CHANGED
|
@@ -418,6 +418,15 @@ chatModel.runOptions = {documentIds: selectedIds, profile: 'thorough'};
|
|
|
418
418
|
| `clear()`, `loadMessages()` | Discarded | No | As for `detach()` |
|
|
419
419
|
| `destroy()` | Discarded | No | The run goes on |
|
|
420
420
|
|
|
421
|
+
A detached reply keeps the server's unfinished tool calls and sub-agents open, as the server may
|
|
422
|
+
still finish them. Calls to a tool of the chat with an `execute`, or an interactive one, close as
|
|
423
|
+
interrupted, as the turn that would run them is over. The same holds for the last reply of a
|
|
424
|
+
history loaded with `loadMessages()`, if it is detached.
|
|
425
|
+
|
|
426
|
+
When the next turn starts, the reply becomes `complete`, without `metadata.detached`. Its open work
|
|
427
|
+
closes as "No result - the conversation moved on", so that the history a request sends has a
|
|
428
|
+
result for every call. A reply that a stop ended closes its open work as cancelled.
|
|
429
|
+
|
|
421
430
|
The turn's signal aborts with a `ChatAbortError`. Its `name` is `AbortError`, and its `reason` is
|
|
422
431
|
`stop`, `detach`, `clear`, `load` or `destroy`. A client tool can read the reason from
|
|
423
432
|
`context.signal.reason`. In client mode, a detach and a clear only abort, as a stop does, with no
|
|
@@ -930,7 +939,10 @@ The composer's status region holds the thread's next action. `ChatComposerModel.
|
|
|
930
939
|
reads it as a `ChatComposerStatus`: a `type`, the reply's display status, at most one `action`,
|
|
931
940
|
and the reason. `getChatComposerStatus()` is the same reading as a pure function. One status
|
|
932
941
|
shows at a time, in this order: blocked, offline, failed or detached, stalled, waiting to retry,
|
|
933
|
-
paused, busy, and a restored draft.
|
|
942
|
+
paused, busy, and a restored draft. A send that `ChatThreadsModel` blocks while the server runs the
|
|
943
|
+
turn offers Refresh Status, and reads as detached when the last reply is. The desktop
|
|
944
|
+
`ChatStatusRegion` shows a line only for what stops a send or needs the user's action - see
|
|
945
|
+
[Recover from a failed reply](../desktop/cmp/chat/README.md#recover-from-a-failed-reply).
|
|
934
946
|
|
|
935
947
|
The action for a failure depends on whether a resend is safe. `isChatRetrySafe(message,
|
|
936
948
|
historyMode)` decides it:
|
|
@@ -1177,7 +1189,7 @@ The kit records these keys in the `metadata` of an assistant message:
|
|
|
1177
1189
|
| `retryAfterSeconds` | Wait the backend asked for before a retry, on a failed run. |
|
|
1178
1190
|
| `runId` | Id of the latest run, from `RUN_STARTED`. |
|
|
1179
1191
|
| `serverId` | The server's id for the message, from `xh.chat.message` or `RUN_FINISHED`. Also set on a user message. |
|
|
1180
|
-
| `detached` | True on a reply left running on the server by `detach()`, `clear()` or `loadMessages()`, in server mode. |
|
|
1192
|
+
| `detached` | True on a reply left running on the server by `detach()`, `clear()` or `loadMessages()`, in server mode. Its unfinished tool calls and sub-agents stay open - see [Stop or detach a turn](#stop-or-detach-a-turn). |
|
|
1181
1193
|
| `feedback` | The user's rating, as `{rating, comment?}`. History usually restores the rating alone. |
|
|
1182
1194
|
| `artifacts` | `ChatArtifact[]` that the reply's tools produced, for generative UI. Restored to `artifacts` with the history. |
|
|
1183
1195
|
| `catalogVersion` | Version of the chat's catalog, on a reply that shows its UI. |
|
|
@@ -1393,7 +1405,6 @@ Fields of `ChatKit.defaults`. See `ChatKit.ts`.
|
|
|
1393
1405
|
| Field | Type | Description |
|
|
1394
1406
|
|-------|------|-------------|
|
|
1395
1407
|
| `assistantIcon` | `() => ReactElement` | Returns the icon that marks the assistant. A component's `assistantIcon` prop overrides it. Default `() => Icon.ai()`. |
|
|
1396
|
-
| `busyIcon` | `() => ReactElement` | Returns the icon beside "Assistant is working" in the status region. Return null from it for no icon. Null for the kit's spinner. Default null. |
|
|
1397
1408
|
| `reducedMotionSpinner` | `'static' \| 'spin'` | Under reduced motion, `static` swaps the chat's spinners for a static icon, and `spin` keeps Hoist's spinner. Default `static`. |
|
|
1398
1409
|
| `stallAfterMs` | `number` | Milliseconds with no data from the server before a reply shows as stalled. `ChatModelConfig.stallAfterMs` overrides it. Default 30,000. |
|
|
1399
1410
|
| `drafts` | `'local' \| 'memory'` | Where the composer keeps its unsent draft text: local storage, per thread, or memory only. Default `local`. See [Keep drafts](#keep-drafts). |
|
|
@@ -271,8 +271,7 @@ export class ChatComposerModel extends HoistModel {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
/**
|
|
274
|
-
* The
|
|
275
|
-
* {@link getChatComposerStatus}.
|
|
274
|
+
* The thread's status for the composer now, or null - see {@link getChatComposerStatus}.
|
|
276
275
|
*
|
|
277
276
|
* @param now - epoch millis to read the clock-based states at. Default now.
|
|
278
277
|
*/
|
|
@@ -3,8 +3,9 @@ import type {ChatHistoryMode} from '../ChatModel';
|
|
|
3
3
|
import {type ChatReplyStatus, getChatReplyStatus} from '../ChatReplyStatus';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* The thread's status for the composer - the one thing about the thread that the user should know
|
|
7
|
+
* before the next send, with at most one action. See {@link getChatComposerStatus}. The desktop
|
|
8
|
+
* `ChatStatusRegion` shows only those that stop a send or need the user's action.
|
|
8
9
|
*
|
|
9
10
|
* - `blocked` - the host gives a reason that sending must wait.
|
|
10
11
|
* - `offline` - the browser is offline. The draft is kept, and nothing sends by itself.
|
|
@@ -39,7 +40,7 @@ export type ChatComposerStatusType =
|
|
|
39
40
|
*/
|
|
40
41
|
export type ChatComposerAction = 'tryAgain' | 'refreshStatus' | 'cancelRetry' | 'discardDraft';
|
|
41
42
|
|
|
42
|
-
/** The
|
|
43
|
+
/** The thread's status for the composer - see {@link getChatComposerStatus}. */
|
|
43
44
|
export interface ChatComposerStatus {
|
|
44
45
|
type: ChatComposerStatusType;
|
|
45
46
|
|
|
@@ -105,12 +106,14 @@ export interface ChatComposerStatusSpec {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
* The
|
|
109
|
-
*
|
|
109
|
+
* The thread's status for the composer, or null for none. A pure function of its input, so a
|
|
110
|
+
* component re-reads it as the clock ticks. It reports every status, for a layout of an app's
|
|
111
|
+
* own - the desktop `ChatStatusRegion` shows a line for some of them only.
|
|
110
112
|
*
|
|
111
113
|
* One status shows at a time, the first that applies in this order: blocked, offline, failed or
|
|
112
114
|
* detached, stalled, waiting to retry, paused, busy, and a restored draft. A reply the server
|
|
113
|
-
* interrupted or cancelled counts as failed.
|
|
115
|
+
* interrupted or cancelled counts as failed. A send that a thread layer blocks while the server
|
|
116
|
+
* runs the thread's turn offers Refresh Status, and reads as detached if the last reply is.
|
|
114
117
|
*
|
|
115
118
|
* A failure that is retry-safe offers Try Again. One that is not offers Refresh Status if the host
|
|
116
119
|
* can refresh, else Try Again, and says the run may still be running. A retry-safe failure for
|
|
@@ -146,7 +149,17 @@ export function getChatComposerStatus(spec: ChatComposerStatusSpec): ChatCompose
|
|
|
146
149
|
...changes
|
|
147
150
|
});
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
// A send blocked while the server runs the turn can refresh to learn when it ends. A reply
|
|
153
|
+
// left running says so itself, so that block shows as the reply's status.
|
|
154
|
+
if (sendBlockedReason) {
|
|
155
|
+
const isRunOnServer = sendBlockedReason === RUN_ON_SERVER_REASON;
|
|
156
|
+
if (!isRunOnServer || replyStatus !== 'detached') {
|
|
157
|
+
return status('blocked', {
|
|
158
|
+
reason: sendBlockedReason,
|
|
159
|
+
action: isRunOnServer && canRefreshStatus ? 'refreshStatus' : null
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
150
163
|
if (isOffline) return status('offline');
|
|
151
164
|
|
|
152
165
|
switch (replyStatus) {
|
|
@@ -202,6 +215,13 @@ export function isChatRetrySafe(
|
|
|
202
215
|
return !(historyMode === 'server' && KIT_UNSAFE_CODES.includes(errorCode));
|
|
203
216
|
}
|
|
204
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The reason `ChatThreadsModel` gives for a send it blocks while the server runs a turn that the
|
|
220
|
+
* chat does not follow.
|
|
221
|
+
* @internal
|
|
222
|
+
*/
|
|
223
|
+
export const RUN_ON_SERVER_REASON = 'Wait for the reply to finish.';
|
|
224
|
+
|
|
205
225
|
//------------------
|
|
206
226
|
// Implementation
|
|
207
227
|
//------------------
|
|
@@ -61,6 +61,35 @@ export function settleOpenParts(
|
|
|
61
61
|
return settleParts(message, reason, isCancelled, endedAt);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A copy of a message with the open tool calls that `isClosed` picks closed with `reason`, as
|
|
66
|
+
* failed - or denied if they awaited approval. Other parts stay as they are. Returns the same
|
|
67
|
+
* message if no call was picked.
|
|
68
|
+
*
|
|
69
|
+
* @param endedAt - the time to record on the calls it ends. Default now.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function settleOpenCalls(
|
|
73
|
+
message: ChatMessage,
|
|
74
|
+
isClosed: (call: ChatToolCallPart) => boolean,
|
|
75
|
+
reason: string,
|
|
76
|
+
endedAt: number = null
|
|
77
|
+
): ChatMessage {
|
|
78
|
+
const calls = message.toolCalls.filter(it => !isToolCallFinal(it) && isClosed(it));
|
|
79
|
+
if (!calls.length) return message;
|
|
80
|
+
const settled = settleOpenParts(message.withChanges({parts: calls}), reason, false, endedAt),
|
|
81
|
+
byId = new Map(settled.parts.map(it => [it.id, it]));
|
|
82
|
+
return message.withChanges({parts: message.parts.map(it => byId.get(it.id) ?? it)});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True for a reply left running on the server - `aborted`, with `metadata.detached`.
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
89
|
+
export function isDetachedReply(message: ChatMessage): boolean {
|
|
90
|
+
return message?.status === 'aborted' && !!message.metadata.detached;
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
/**
|
|
65
94
|
* Why `settleOpenParts` closed a tool call, by how its turn ended.
|
|
66
95
|
* @internal
|
package/core/replayChatEvents.ts
CHANGED
|
@@ -62,7 +62,8 @@ export interface ChatReplayOptions {
|
|
|
62
62
|
* chat then offers to refresh its status, as after the user left a live reply running.
|
|
63
63
|
*
|
|
64
64
|
* Anything a turn leaves open, such as a tool call with no result, is closed as the live chat
|
|
65
|
-
* would close it, at the time of the turn's last event.
|
|
65
|
+
* would close it, at the time of the turn's last event. A detached turn's open tool calls and
|
|
66
|
+
* sub-agents stay open, as the server may still finish them. An interrupt that a later run answered
|
|
66
67
|
* counts as delivered. The user's answers to it are not among the events.
|
|
67
68
|
*
|
|
68
69
|
* A record that the decoder rejects fails the reply, as it would fail a live stream, with the
|
|
@@ -98,12 +99,15 @@ export function replayChatEvents(
|
|
|
98
99
|
isStopped = decodeError != null || opts.status === 'aborted' || !isEnded;
|
|
99
100
|
message = isStopped ? reducer.settleText(message) : reducer.finish(message);
|
|
100
101
|
|
|
102
|
+
// A turn with no end goes on on the server, so its open calls and sub-agents stay open - but
|
|
103
|
+
// for the calls of its earlier steps, which ended with their runs.
|
|
101
104
|
const status = decodeError ? 'error' : (opts.status ?? endStatus(message, reducer, isEnded)),
|
|
105
|
+
isDetached = !isEnded && opts.status == null && !decodeError,
|
|
102
106
|
times = runs.map(runTimes).filter(it => it != null),
|
|
103
107
|
endedAt = max(times.map(it => it[1])) ?? null,
|
|
104
108
|
settleAt = endedAt ?? Date.now(),
|
|
105
109
|
settled =
|
|
106
|
-
status === 'paused'
|
|
110
|
+
status === 'paused' || isDetached
|
|
107
111
|
? settleStreamingParts(settleEarlierCalls(message, lastIdx, settleAt), settleAt)
|
|
108
112
|
: settleOpenParts(message, SETTLE_REASONS[status], status === 'aborted', settleAt);
|
|
109
113
|
|
|
@@ -118,9 +122,7 @@ export function replayChatEvents(
|
|
|
118
122
|
const elapsedMs = sumBy(times, ([start, end]) => end - start);
|
|
119
123
|
replyMetadata = {...replyMetadata, elapsedMs, ...(status === 'paused' ? {} : {endedAt})};
|
|
120
124
|
}
|
|
121
|
-
if (
|
|
122
|
-
replyMetadata = {...replyMetadata, detached: true};
|
|
123
|
-
}
|
|
125
|
+
if (isDetached) replyMetadata = {...replyMetadata, detached: true};
|
|
124
126
|
// The server took each answer that a later run carried.
|
|
125
127
|
if (replyMetadata.interrupts) {
|
|
126
128
|
replyMetadata = {
|
|
@@ -205,9 +207,9 @@ function splitRuns(events: ChatEvent[]): ChatEvent[][] {
|
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
/**
|
|
208
|
-
* A paused reply with the tool calls of its earlier steps closed. A call the browser
|
|
209
|
-
* result in the next step's request, not among the events, so it would otherwise
|
|
210
|
-
*
|
|
210
|
+
* A paused or detached reply with the tool calls of its earlier steps closed. A call the browser
|
|
211
|
+
* ran has its result in the next step's request, not among the events, so it would otherwise stay
|
|
212
|
+
* open. Other parts stay as they are, such as a sub-agent that waits on the reply's interrupts.
|
|
211
213
|
*/
|
|
212
214
|
function settleEarlierCalls(message: ChatMessage, step: number, endedAt: number): ChatMessage {
|
|
213
215
|
const calls = message.toolCalls.filter(it => it.step < step),
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
} from './impl/groupParts';
|
|
85
85
|
import {ChatSourceLinks, findSidePanel} from './impl/ChatSourceLinks';
|
|
86
86
|
import {jsonView} from './impl/JsonView';
|
|
87
|
+
import {replyDetachedProvider} from './impl/ReplyDetached';
|
|
87
88
|
import {useChatMarkdownComponents} from './ChatMarkdownComponents';
|
|
88
89
|
import {formatFailureDetails, getFailureCause, isReplyNotSent} from './impl/ReplyFailure';
|
|
89
90
|
|
|
@@ -621,50 +622,58 @@ function assistantContent(
|
|
|
621
622
|
className: 'xh-chat-message__main',
|
|
622
623
|
items: [
|
|
623
624
|
props.showHeader === false ? null : messageHeader(props, context),
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
625
|
+
replyDetachedProvider({
|
|
626
|
+
value: getChatReplyStatus({message}) === 'detached',
|
|
627
|
+
item: div({
|
|
628
|
+
className: 'xh-chat-message__body',
|
|
629
|
+
items: [
|
|
630
|
+
...withInterrupts(
|
|
631
|
+
runs,
|
|
632
|
+
message,
|
|
633
|
+
chatModel,
|
|
634
|
+
runs.map((run, idx) =>
|
|
635
|
+
run.type === 'process'
|
|
636
|
+
? processRun(
|
|
637
|
+
run,
|
|
638
|
+
props,
|
|
639
|
+
impl,
|
|
640
|
+
context,
|
|
641
|
+
references,
|
|
642
|
+
markerSources
|
|
643
|
+
)
|
|
644
|
+
: run.type === 'timeline'
|
|
645
|
+
? timelineBlock(
|
|
646
|
+
run,
|
|
647
|
+
props,
|
|
648
|
+
context,
|
|
649
|
+
idx < runs.length - 1
|
|
650
|
+
)
|
|
651
|
+
: renderGroup(
|
|
652
|
+
run,
|
|
653
|
+
props,
|
|
654
|
+
impl,
|
|
655
|
+
context,
|
|
656
|
+
references,
|
|
657
|
+
markerSources
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
),
|
|
661
|
+
// With no header to show its status, a reply that waits for its first
|
|
662
|
+
// content shows a working cue in its body - after any answered questions.
|
|
663
|
+
props.showHeader === false &&
|
|
664
|
+
message.isStreaming &&
|
|
665
|
+
isEmpty(runs) &&
|
|
666
|
+
chatModel
|
|
667
|
+
? workingCue({
|
|
668
|
+
key: 'working',
|
|
669
|
+
message,
|
|
670
|
+
chatModel,
|
|
671
|
+
isLast: props.isLast
|
|
672
|
+
})
|
|
673
|
+
: null,
|
|
674
|
+
replyNotice(props, chatModel)
|
|
675
|
+
]
|
|
676
|
+
})
|
|
668
677
|
}),
|
|
669
678
|
!isNoAnswer && references.length
|
|
670
679
|
? chatSources({sources: toSourceList(references), links: impl.sourceLinks})
|
|
@@ -82,10 +82,7 @@ export interface ChatPanelProps
|
|
|
82
82
|
*/
|
|
83
83
|
messageListProps?: Omit<ChatMessageListProps, 'model' | 'modelConfig' | 'modelRef'>;
|
|
84
84
|
|
|
85
|
-
/**
|
|
86
|
-
* Props for the status region above the composer - for example `showStopHint`. Its
|
|
87
|
-
* `assistantName` defaults to the message list's.
|
|
88
|
-
*/
|
|
85
|
+
/** Props for the status region above the composer. */
|
|
89
86
|
statusRegionProps?: Omit<ChatStatusRegionProps, 'model' | 'modelConfig' | 'modelRef'>;
|
|
90
87
|
}
|
|
91
88
|
|
|
@@ -177,7 +174,6 @@ export const [ChatPanel, chatPanel] = hoistCmp.withFactory<ChatPanelProps>({
|
|
|
177
174
|
items: [
|
|
178
175
|
chatStatusRegion({
|
|
179
176
|
model: model.composerModel,
|
|
180
|
-
assistantName: messageListProps?.assistantName,
|
|
181
177
|
...statusRegionProps
|
|
182
178
|
}),
|
|
183
179
|
chatComposer({
|
|
@@ -11,14 +11,6 @@
|
|
|
11
11
|
min-width: 0;
|
|
12
12
|
padding: 0 2px 0 var(--xh-chat-composer-status-inset);
|
|
13
13
|
|
|
14
|
-
&--busy {
|
|
15
|
-
color: var(--xh-chat-busy-color);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
&--attention {
|
|
19
|
-
color: var(--xh-chat-attention-color);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
14
|
&--warning {
|
|
23
15
|
color: var(--xh-chat-warning-color);
|
|
24
16
|
}
|