broapp 0.4.0 → 0.4.1
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/package.json +1 -1
- package/src/ai/react/AiChat.tsx +8 -4
- package/src/ai/react/use-ai-chat.ts +55 -13
package/package.json
CHANGED
package/src/ai/react/AiChat.tsx
CHANGED
|
@@ -71,20 +71,24 @@ function ToolCall({
|
|
|
71
71
|
<div
|
|
72
72
|
className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
|
|
73
73
|
role="group"
|
|
74
|
-
aria-label={`Allow ${call.tool}?`}
|
|
74
|
+
aria-label={`Allow ${call.asks ?? call.tool}?`}
|
|
75
75
|
>
|
|
76
|
-
|
|
76
|
+
{/* A question about one of this call's own steps says which step. */}
|
|
77
|
+
<span>{call.asks === undefined ? 'Allow this?' : `Allow ${call.asks}?`}</span>
|
|
78
|
+
{call.asks === undefined || call.asksInput === undefined ? null : (
|
|
79
|
+
<pre className="ai-chat__json">{JSON.stringify(call.asksInput, null, 2)}</pre>
|
|
80
|
+
)}
|
|
77
81
|
{call.expiresAt === undefined ? null : (
|
|
78
82
|
<span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
|
|
79
83
|
)}
|
|
80
84
|
<button
|
|
81
85
|
className="button button--primary"
|
|
82
86
|
type="button"
|
|
83
|
-
onClick={() => onConfirm(call.callId, true)}
|
|
87
|
+
onClick={() => onConfirm(call.confirmId ?? call.callId, true)}
|
|
84
88
|
>
|
|
85
89
|
Allow
|
|
86
90
|
</button>
|
|
87
|
-
<button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
|
|
91
|
+
<button className="button" type="button" onClick={() => onConfirm(call.confirmId ?? call.callId, false)}>
|
|
88
92
|
Decline
|
|
89
93
|
</button>
|
|
90
94
|
</div>
|
|
@@ -24,6 +24,56 @@ export interface ToolCallState {
|
|
|
24
24
|
readonly output?: unknown;
|
|
25
25
|
/** While awaiting confirmation: when the question stops waiting. */
|
|
26
26
|
readonly expiresAt?: number;
|
|
27
|
+
/**
|
|
28
|
+
* While awaiting confirmation for one of this call's own steps: the id the
|
|
29
|
+
* answer goes to (`<callId>.<step>`), what that step runs and its input.
|
|
30
|
+
* Absent when the question is about the call itself.
|
|
31
|
+
*/
|
|
32
|
+
readonly confirmId?: string;
|
|
33
|
+
readonly asks?: string;
|
|
34
|
+
readonly asksInput?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Put a question on the card it belongs to.
|
|
39
|
+
*
|
|
40
|
+
* A question about the call itself names the call's own id. A tool that runs
|
|
41
|
+
* several gated steps — the launcher's `candidate.cycle` builds and previews
|
|
42
|
+
* after the patch it was called with — asks once per step, as `<callId>.<step>`,
|
|
43
|
+
* so each answer is to exactly one action. Those questions go on the parent's
|
|
44
|
+
* card, which then says what it is asking about.
|
|
45
|
+
*/
|
|
46
|
+
export function attachConfirm(
|
|
47
|
+
calls: readonly ToolCallState[],
|
|
48
|
+
event: { readonly callId?: string; readonly tool?: string; readonly input?: unknown; readonly expiresAt?: number },
|
|
49
|
+
): ToolCallState[] {
|
|
50
|
+
const id = event.callId ?? '';
|
|
51
|
+
const exact = calls.some((call) => call.callId === id);
|
|
52
|
+
return calls.map((call) => {
|
|
53
|
+
const own = call.callId === id;
|
|
54
|
+
const step = !exact && id.startsWith(`${call.callId}.`);
|
|
55
|
+
if (!own && !step) return call;
|
|
56
|
+
const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, ...rest } = call;
|
|
57
|
+
return {
|
|
58
|
+
...rest,
|
|
59
|
+
status: 'awaiting-confirmation',
|
|
60
|
+
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
61
|
+
...(step ? { confirmId: id, asks: event.tool ?? '', asksInput: event.input } : {}),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An answer was accepted: the card goes back to running until its result or
|
|
68
|
+
* its next question arrives. Without this a card whose tool asks again later
|
|
69
|
+
* would keep offering buttons for a question already answered.
|
|
70
|
+
*/
|
|
71
|
+
export function settleAnswer(calls: readonly ToolCallState[], answeredId: string): ToolCallState[] {
|
|
72
|
+
return calls.map((call) => {
|
|
73
|
+
if (call.status !== 'awaiting-confirmation' || (call.callId !== answeredId && call.confirmId !== answeredId)) return call;
|
|
74
|
+
const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, expiresAt: _expiresAt, ...rest } = call;
|
|
75
|
+
return { ...rest, status: 'running' };
|
|
76
|
+
});
|
|
27
77
|
}
|
|
28
78
|
|
|
29
79
|
/** One message in the transcript. */
|
|
@@ -152,18 +202,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
152
202
|
break;
|
|
153
203
|
case 'confirm':
|
|
154
204
|
setStatus('awaiting-confirmation');
|
|
155
|
-
patchPending((message) => ({
|
|
156
|
-
...message,
|
|
157
|
-
toolCalls: message.toolCalls.map((call) =>
|
|
158
|
-
call.callId === event.callId
|
|
159
|
-
? {
|
|
160
|
-
...call,
|
|
161
|
-
status: 'awaiting-confirmation',
|
|
162
|
-
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
163
|
-
}
|
|
164
|
-
: call,
|
|
165
|
-
),
|
|
166
|
-
}));
|
|
205
|
+
patchPending((message) => ({ ...message, toolCalls: attachConfirm(message.toolCalls, event) }));
|
|
167
206
|
break;
|
|
168
207
|
case 'tool-result': {
|
|
169
208
|
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
@@ -296,12 +335,15 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
296
335
|
// Nobody was waiting: the turn timed out or was cancelled while the
|
|
297
336
|
// question was on screen.
|
|
298
337
|
setError('That request has expired.');
|
|
338
|
+
return;
|
|
299
339
|
}
|
|
340
|
+
patchPending((message) => ({ ...message, toolCalls: settleAnswer(message.toolCalls, callId) }));
|
|
341
|
+
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
300
342
|
} catch (cause) {
|
|
301
343
|
setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
|
|
302
344
|
}
|
|
303
345
|
},
|
|
304
|
-
[shared],
|
|
346
|
+
[shared, patchPending],
|
|
305
347
|
);
|
|
306
348
|
|
|
307
349
|
const clear = React.useCallback((): void => {
|