@parallelworks/ui 0.75.1 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/ai/adapter/openai/createOpenAIStream.js +1 -0
- package/dist/ai/adapter/openai/sse.d.ts +2 -1
- package/dist/ai/adapter/openai/sse.js +20 -0
- package/dist/ai/adapter/openai/wire.d.ts +5 -0
- package/dist/ai/adapter/types.d.ts +11 -0
- package/dist/ai/components/ChatMessage.js +30 -5
- package/dist/ai/components/ChatThread.js +4 -3
- package/dist/ai/components/ComposerAddMenu.js +17 -6
- package/dist/ai/components/ShareDialog.d.ts +3 -0
- package/dist/ai/components/ShareDialog.js +49 -13
- package/dist/ai/components/agent/AgentMessageParts.js +36 -3
- package/dist/ai/core/ChatProvider.d.ts +3 -1
- package/dist/ai/core/ChatProvider.js +215 -131
- package/dist/ai/core/parts.js +5 -1
- package/dist/ai/strings.d.ts +14 -0
- package/dist/ai/strings.js +11 -4
- package/dist/ai/types.d.ts +3 -1
- package/dist/components/relativeTime.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -924,6 +924,19 @@ OpenAI-compatible transport sends them as the `X-Platform-Tools` header
|
|
|
924
924
|
(`TOOLS_HEADER`), comma-separated and empty when every tool is off. A request
|
|
925
925
|
without a tool choice sends no header.
|
|
926
926
|
|
|
927
|
+
A tool that changes something sets `asksFirst`, which the menu shows, and its
|
|
928
|
+
calls wait for the reader. The gateway streams the call, then a `toolApproval`
|
|
929
|
+
chunk (`toolCallId`, optional `expiresAt`) in place of a result, and ends the
|
|
930
|
+
reply there. Chat shows a confirm under the call with Allow, Deny and Deny
|
|
931
|
+
with feedback, which expires at `expiresAt`. The answer goes back through
|
|
932
|
+
`useChat().answerToolApproval` as a request with no new user turn:
|
|
933
|
+
`StreamRequest.resume` (`messageId`, `toolCallId`, `allowed`, optional
|
|
934
|
+
`feedback`), sent as `resume` in the body. The gateway's `toolResult` for the
|
|
935
|
+
call lands on the reply that paused, and the model's answer streams as a new
|
|
936
|
+
reply under it. In a shared conversation only the person whose message
|
|
937
|
+
started the turn may answer; others see whom it waits on, and a turn someone
|
|
938
|
+
else started says whose access its tools ran with.
|
|
939
|
+
|
|
927
940
|
Tailwind v4 consumers add the chat rules (markdown rendering, streaming
|
|
928
941
|
animations) and streamdown's classes next to the theme contract:
|
|
929
942
|
|
|
@@ -24,6 +24,7 @@ function createOpenAICompatibleStream(config) {
|
|
|
24
24
|
messages: req.messages,
|
|
25
25
|
stream: true
|
|
26
26
|
};
|
|
27
|
+
if (req.resume && !useResponses) body["resume"] = req.resume;
|
|
27
28
|
const res = await (config.fetch ?? globalThis.fetch.bind(globalThis))(url, {
|
|
28
29
|
method: "POST",
|
|
29
30
|
headers: requestHeaders(config.buildHeaders?.(req), req.tools),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolCall, ToolCallDelta } from '../../types.js';
|
|
2
2
|
import type { PartDelta } from '../types.js';
|
|
3
|
-
import type { ToolResultChunk } from './wire.js';
|
|
3
|
+
import type { ToolApprovalChunk, ToolResultChunk } from './wire.js';
|
|
4
4
|
export type ParsedSSELine = {
|
|
5
5
|
id?: string | undefined;
|
|
6
6
|
messageId?: string | undefined;
|
|
@@ -8,6 +8,7 @@ export type ParsedSSELine = {
|
|
|
8
8
|
reasoningContent?: string | undefined;
|
|
9
9
|
toolCalls?: ToolCallDelta[] | undefined;
|
|
10
10
|
toolResult?: ToolResultChunk | undefined;
|
|
11
|
+
toolApproval?: ToolApprovalChunk | undefined;
|
|
11
12
|
finishReason?: string | null | undefined;
|
|
12
13
|
model?: string | undefined;
|
|
13
14
|
error?: string | undefined;
|
|
@@ -15,6 +15,7 @@ function parseSSELine(line) {
|
|
|
15
15
|
reasoningContent: choice?.delta?.reasoning_content,
|
|
16
16
|
toolCalls: choice?.delta?.tool_calls ?? void 0,
|
|
17
17
|
toolResult: chunk.toolResult,
|
|
18
|
+
toolApproval: chunk.toolApproval,
|
|
18
19
|
finishReason: choice?.finish_reason,
|
|
19
20
|
model: chunk.model
|
|
20
21
|
};
|
|
@@ -108,6 +109,25 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
|
|
|
108
109
|
...parsed.toolResult.isError ? { isError: true } : {}
|
|
109
110
|
});
|
|
110
111
|
}
|
|
112
|
+
if (parsed.toolApproval) {
|
|
113
|
+
const { toolCallId, expiresAt } = parsed.toolApproval;
|
|
114
|
+
answeredToolCalls.add(toolCallId);
|
|
115
|
+
onPart?.({
|
|
116
|
+
type: "tool_wait",
|
|
117
|
+
id: toolCallId
|
|
118
|
+
});
|
|
119
|
+
onPart?.({
|
|
120
|
+
type: "approval_request",
|
|
121
|
+
approval: {
|
|
122
|
+
kind: "approval",
|
|
123
|
+
id: toolCallId,
|
|
124
|
+
approvalKind: "confirm",
|
|
125
|
+
subject: "call",
|
|
126
|
+
canAllowDirectory: false,
|
|
127
|
+
...expiresAt ? { expiresAt } : {}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
111
131
|
}
|
|
112
132
|
}
|
|
113
133
|
}
|
|
@@ -31,9 +31,14 @@ export interface CompletionChunk {
|
|
|
31
31
|
created?: number;
|
|
32
32
|
messageId?: string;
|
|
33
33
|
toolResult?: ToolResultChunk;
|
|
34
|
+
toolApproval?: ToolApprovalChunk;
|
|
34
35
|
choices?: CompletionChunkChoice[] | null;
|
|
35
36
|
usage?: TokenUsage;
|
|
36
37
|
}
|
|
38
|
+
export interface ToolApprovalChunk {
|
|
39
|
+
toolCallId: string;
|
|
40
|
+
expiresAt?: string;
|
|
41
|
+
}
|
|
37
42
|
export interface ToolResultChunk {
|
|
38
43
|
toolCallId: string;
|
|
39
44
|
content: string;
|
|
@@ -21,6 +21,13 @@ export interface StreamRequest {
|
|
|
21
21
|
allocation?: string | null;
|
|
22
22
|
useResponses?: boolean;
|
|
23
23
|
tools?: string[];
|
|
24
|
+
resume?: ToolApprovalAnswer;
|
|
25
|
+
}
|
|
26
|
+
export interface ToolApprovalAnswer {
|
|
27
|
+
messageId: string;
|
|
28
|
+
toolCallId: string;
|
|
29
|
+
allowed: boolean;
|
|
30
|
+
feedback?: string;
|
|
24
31
|
}
|
|
25
32
|
export interface StreamHandlers {
|
|
26
33
|
onContent: (content: string) => void;
|
|
@@ -43,6 +50,9 @@ export type PartDelta = {
|
|
|
43
50
|
type: 'tool_update';
|
|
44
51
|
id: string;
|
|
45
52
|
args: string;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'tool_wait';
|
|
55
|
+
id: string;
|
|
46
56
|
} | {
|
|
47
57
|
type: 'tool_end';
|
|
48
58
|
id: string;
|
|
@@ -133,6 +143,7 @@ export interface ChatToolOption {
|
|
|
133
143
|
id: string;
|
|
134
144
|
label: string;
|
|
135
145
|
description?: string;
|
|
146
|
+
asksFirst?: boolean;
|
|
136
147
|
}
|
|
137
148
|
export interface ChatToolsAdapter {
|
|
138
149
|
list(): Promise<ChatToolOption[]>;
|
|
@@ -34,6 +34,11 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
|
|
|
34
34
|
const isTool = message.role === "tool";
|
|
35
35
|
const otherAuthor = isUser && showAuthor && message.author && currentUsername && message.author.username !== currentUsername ? message.author : void 0;
|
|
36
36
|
const isOtherUser = !!otherAuthor;
|
|
37
|
+
const asker = !isUser && showAuthor && currentUsername ? turnAuthor(message, allMessages) : void 0;
|
|
38
|
+
const otherAsker = asker && asker.username !== currentUsername ? asker : void 0;
|
|
39
|
+
const askerName = otherAsker ? otherAsker.name || otherAsker.username : void 0;
|
|
40
|
+
const partsMessage = askerName ? waitingOn(message, askerName) : message;
|
|
41
|
+
const approvalAnswer = askerName ? void 0 : onApprovalAnswer;
|
|
37
42
|
const isRightAligned = isUser && !isOtherUser;
|
|
38
43
|
const hasError = !!message.error;
|
|
39
44
|
const hasPastes = !!message.pastes?.length;
|
|
@@ -105,9 +110,9 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
|
|
|
105
110
|
return /* @__PURE__ */ jsxs("div", {
|
|
106
111
|
className: cx("group py-3 w-full", !flush && "px-4"),
|
|
107
112
|
children: [/* @__PURE__ */ jsx(AgentMessageParts, {
|
|
108
|
-
message,
|
|
113
|
+
message: partsMessage,
|
|
109
114
|
isStreaming,
|
|
110
|
-
onApprovalAnswer
|
|
115
|
+
onApprovalAnswer: approvalAnswer
|
|
111
116
|
}), !isUser && !isStreaming && (said || onRegenerate || meta) && /* @__PURE__ */ jsxs("div", {
|
|
112
117
|
className: "chat-actions -ml-1.5 mt-2 flex items-center gap-0.5",
|
|
113
118
|
children: [
|
|
@@ -185,15 +190,15 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
|
|
|
185
190
|
className: "mb-2",
|
|
186
191
|
children: /* @__PURE__ */ jsx("div", {
|
|
187
192
|
className: "flex items-center gap-x-1.5 text-sm theme-muted-text",
|
|
188
|
-
children: /* @__PURE__ */ jsx("span", { children: message.reasoningDuration ? tThinking.thoughtFor(formatDuration(message.reasoningDuration)) : tThinking.ranToolCalls(toolCallCount) })
|
|
193
|
+
children: /* @__PURE__ */ jsx("span", { children: message.reasoningDuration ? tThinking.thoughtFor(formatDuration(message.reasoningDuration)) : askerName ? tThinking.ranToolCallsAs(toolCallCount, askerName) : tThinking.ranToolCalls(toolCallCount) })
|
|
189
194
|
})
|
|
190
195
|
}),
|
|
191
196
|
!isUser && hasParts && /* @__PURE__ */ jsx("div", {
|
|
192
197
|
className: "mb-1",
|
|
193
198
|
children: /* @__PURE__ */ jsx(AgentMessageParts, {
|
|
194
|
-
message,
|
|
199
|
+
message: partsMessage,
|
|
195
200
|
isStreaming,
|
|
196
|
-
onApprovalAnswer
|
|
201
|
+
onApprovalAnswer: approvalAnswer
|
|
197
202
|
})
|
|
198
203
|
}),
|
|
199
204
|
otherAuthor && /* @__PURE__ */ jsx("span", {
|
|
@@ -294,5 +299,25 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
|
|
|
294
299
|
});
|
|
295
300
|
}
|
|
296
301
|
var ChatMessage_default = memo(ChatMessage);
|
|
302
|
+
/** The author of the user message that started the turn a reply belongs to.
|
|
303
|
+
* A reply that resumes after an approval hangs under the reply it resumes. */
|
|
304
|
+
function turnAuthor(message, allMessages) {
|
|
305
|
+
let parentId = message.parentId;
|
|
306
|
+
for (let hops = 0; parentId && hops < allMessages.length; hops++) {
|
|
307
|
+
const parent = allMessages.find((m) => m.id === parentId);
|
|
308
|
+
if (parent?.role !== "assistant") return parent?.role === "user" ? parent.author : void 0;
|
|
309
|
+
parentId = parent.parentId;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function waitingOn(message, name) {
|
|
313
|
+
if (!message.parts?.some((p) => p.kind === "approval")) return message;
|
|
314
|
+
return {
|
|
315
|
+
...message,
|
|
316
|
+
parts: message.parts.map((p) => p.kind === "approval" ? {
|
|
317
|
+
...p,
|
|
318
|
+
waitingOn: name
|
|
319
|
+
} : p)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
297
322
|
//#endregion
|
|
298
323
|
export { ChatMessage_default as default };
|
|
@@ -3,13 +3,13 @@ import { useChat } from "../core/ChatProvider.js";
|
|
|
3
3
|
import BlockedGroupBanner from "./BlockedGroupBanner.js";
|
|
4
4
|
import { ComposerControls, ComposerUsage, ConnectToolsLink } from "./ComposerChrome.js";
|
|
5
5
|
import ProviderIssueBanner from "./ProviderIssueBanner.js";
|
|
6
|
-
import { ShareButton } from "./ShareDialog.js";
|
|
6
|
+
import { ShareButton, ToolResultsSharedNote } from "./ShareDialog.js";
|
|
7
7
|
import SessionView from "./SessionView.js";
|
|
8
8
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
9
9
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
10
10
|
//#region src/ai/components/ChatThread.tsx
|
|
11
11
|
function ChatThread({ conversationId }) {
|
|
12
|
-
const { adapter, currentUser, currentConversation, loadConversation, loadProviders, sendMessage, retryMessage, isLoadingConversation, navigateToBranch, queuedMessages, removeQueuedMessage } = useChat();
|
|
12
|
+
const { adapter, currentUser, currentConversation, loadConversation, loadProviders, sendMessage, retryMessage, answerToolApproval, tools, isLoadingConversation, navigateToBranch, queuedMessages, removeQueuedMessage } = useChat();
|
|
13
13
|
const { slots, strings } = useChatConfig();
|
|
14
14
|
const requestedConversationIdRef = useRef(null);
|
|
15
15
|
useEffect(() => {
|
|
@@ -52,6 +52,7 @@ function ChatThread({ conversationId }) {
|
|
|
52
52
|
onRetry: (id) => void retryMessage(id)
|
|
53
53
|
} : {},
|
|
54
54
|
onNavigateBranch: navigateToBranch,
|
|
55
|
+
...canWrite ? { onApprovalAnswer: answerToolApproval } : {},
|
|
55
56
|
showAuthor,
|
|
56
57
|
currentUsername: currentUser.username,
|
|
57
58
|
loading: isLoadingConversation && !currentConversation,
|
|
@@ -60,7 +61,7 @@ function ChatThread({ conversationId }) {
|
|
|
60
61
|
composer: {
|
|
61
62
|
disabled: isLoadingConversation || !canWrite,
|
|
62
63
|
...canWrite ? {} : { placeholder: strings.thread.viewOnly },
|
|
63
|
-
settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation && currentConversation.isOwner !== false
|
|
64
|
+
settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation && currentConversation.isOwner !== false ? /* @__PURE__ */ jsx(ShareButton, { conversationId }) : tools.length > 0 && /* @__PURE__ */ jsx(ToolResultsSharedNote, {}), /* @__PURE__ */ jsx(ConnectToolsLink, {})] }),
|
|
64
65
|
settingsRight: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ComposerUsage, { children: slots.composerUsage }), /* @__PURE__ */ jsx(ComposerControls, {})] })
|
|
65
66
|
}
|
|
66
67
|
});
|
|
@@ -46,12 +46,23 @@ function RowSwitch() {
|
|
|
46
46
|
children: /* @__PURE__ */ jsx("span", { className: "size-3 rounded-full bg-white shadow-sm transition-transform duration-150 group-data-checked:translate-x-3 rtl:group-data-checked:-translate-x-3 motion-reduce:transition-none" })
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
+
function AsksFirst({ tool }) {
|
|
50
|
+
const t = useChatConfig().strings.composerMenu;
|
|
51
|
+
if (!tool.asksFirst) return null;
|
|
52
|
+
return /* @__PURE__ */ jsx("span", {
|
|
53
|
+
className: "ml-1.5 shrink-0 text-xs theme-muted-text",
|
|
54
|
+
children: t.asksFirst
|
|
55
|
+
});
|
|
56
|
+
}
|
|
49
57
|
function ToolText({ tool, descriptionId }) {
|
|
50
58
|
return /* @__PURE__ */ jsxs("span", {
|
|
51
59
|
className: "min-w-0 flex-1",
|
|
52
|
-
children: [/* @__PURE__ */
|
|
53
|
-
className: "
|
|
54
|
-
children:
|
|
60
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
61
|
+
className: "flex min-w-0 items-baseline",
|
|
62
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
63
|
+
className: "truncate",
|
|
64
|
+
children: tool.label
|
|
65
|
+
}), /* @__PURE__ */ jsx(AsksFirst, { tool })]
|
|
55
66
|
}), tool.description && /* @__PURE__ */ jsx("span", {
|
|
56
67
|
id: descriptionId,
|
|
57
68
|
className: "block truncate text-xs theme-muted-text",
|
|
@@ -106,9 +117,9 @@ function ToolsModalRow({ tool, enabled, onChange }) {
|
|
|
106
117
|
className: "flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-(--theme-hover)",
|
|
107
118
|
children: [/* @__PURE__ */ jsxs("span", {
|
|
108
119
|
className: "min-w-0 flex-1",
|
|
109
|
-
children: [/* @__PURE__ */
|
|
110
|
-
className: "
|
|
111
|
-
children: tool.label
|
|
120
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
121
|
+
className: "flex items-baseline text-sm",
|
|
122
|
+
children: [tool.label, /* @__PURE__ */ jsx(AsksFirst, { tool })]
|
|
112
123
|
}), tool.description && /* @__PURE__ */ jsx("span", {
|
|
113
124
|
id: descriptionId,
|
|
114
125
|
className: "block text-xs theme-muted-text",
|
|
@@ -8,4 +8,7 @@ export declare function ShareButton({ conversationId, disabled, }: {
|
|
|
8
8
|
conversationId: string;
|
|
9
9
|
disabled?: boolean;
|
|
10
10
|
}): import("react").JSX.Element | null;
|
|
11
|
+
/** Says that tool results in a shared conversation are visible to the
|
|
12
|
+
* people it is shared with. */
|
|
13
|
+
export declare function ToolResultsSharedNote(): import("react").JSX.Element;
|
|
11
14
|
export {};
|
|
@@ -249,21 +249,57 @@ function ShareDialog({ conversationId, isOpen, onClose }) {
|
|
|
249
249
|
}
|
|
250
250
|
function ShareButton({ conversationId, disabled = false }) {
|
|
251
251
|
const { strings } = useChatConfig();
|
|
252
|
-
const { adapter } = useChat();
|
|
252
|
+
const { adapter, tools } = useChat();
|
|
253
253
|
const [isOpen, setIsOpen] = useState(false);
|
|
254
|
+
const shared = useShared(conversationId, tools.length > 0 && !isOpen);
|
|
254
255
|
if (!adapter.sharing) return null;
|
|
255
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
256
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
257
|
+
/* @__PURE__ */ jsxs("button", {
|
|
258
|
+
type: "button",
|
|
259
|
+
onClick: () => setIsOpen(true),
|
|
260
|
+
disabled,
|
|
261
|
+
className: "flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] theme-muted-text transition-colors hover:theme-muted-panel hover:theme-text disabled:opacity-50",
|
|
262
|
+
title: strings.shareConversation.shareButton,
|
|
263
|
+
children: [/* @__PURE__ */ jsx(ShareIcon, { className: "h-3 w-3" }), strings.shareConversation.shareButton]
|
|
264
|
+
}),
|
|
265
|
+
shared && /* @__PURE__ */ jsx(ToolResultsSharedNote, {}),
|
|
266
|
+
/* @__PURE__ */ jsx(ShareDialog, {
|
|
267
|
+
conversationId,
|
|
268
|
+
isOpen,
|
|
269
|
+
onClose: () => setIsOpen(false)
|
|
270
|
+
})
|
|
271
|
+
] });
|
|
272
|
+
}
|
|
273
|
+
/** Says that tool results in a shared conversation are visible to the
|
|
274
|
+
* people it is shared with. */
|
|
275
|
+
function ToolResultsSharedNote() {
|
|
276
|
+
const { strings } = useChatConfig();
|
|
277
|
+
return /* @__PURE__ */ jsx("span", {
|
|
278
|
+
className: "text-[11px] theme-muted-text",
|
|
279
|
+
children: strings.shareConversation.toolResultsShared
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Whether anyone else can read the conversation, checked while `check` is
|
|
283
|
+
* true: tool results in it are visible to them. */
|
|
284
|
+
function useShared(conversationId, check) {
|
|
285
|
+
const { adapter } = useChat();
|
|
286
|
+
const sharing = adapter.sharing;
|
|
287
|
+
const [shared, setShared] = useState(false);
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (!check || !sharing) return;
|
|
290
|
+
let current = true;
|
|
291
|
+
sharing.listPermissions(conversationId).then((permissions) => {
|
|
292
|
+
if (current) setShared(permissions.length > 0);
|
|
293
|
+
}, () => {});
|
|
294
|
+
return () => {
|
|
295
|
+
current = false;
|
|
296
|
+
};
|
|
297
|
+
}, [
|
|
263
298
|
conversationId,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
299
|
+
check,
|
|
300
|
+
sharing
|
|
301
|
+
]);
|
|
302
|
+
return shared;
|
|
267
303
|
}
|
|
268
304
|
//#endregion
|
|
269
|
-
export { ShareButton, ShareDialog as default };
|
|
305
|
+
export { ShareButton, ToolResultsSharedNote, ShareDialog as default };
|
|
@@ -19,6 +19,7 @@ import { chatProseClasses } from "../../ui/prose.js";
|
|
|
19
19
|
import { Reasoning, ReasoningBody } from "../Reasoning.js";
|
|
20
20
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
21
21
|
import { memo, useEffect, useId, useMemo, useState } from "react";
|
|
22
|
+
import { DateTime } from "luxon";
|
|
22
23
|
//#region src/ai/components/agent/AgentMessageParts.tsx
|
|
23
24
|
var COLLAPSED_RESULT_LINES = 12;
|
|
24
25
|
function LineSpans({ text, tokens, emphasis }) {
|
|
@@ -207,6 +208,13 @@ function ToolStatusDot({ part }) {
|
|
|
207
208
|
color: "var(--theme-muted-text-color)"
|
|
208
209
|
})
|
|
209
210
|
});
|
|
211
|
+
if (part.status === "waiting") return /* @__PURE__ */ jsx(StatusMark, {
|
|
212
|
+
status: t.approvalLabel,
|
|
213
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
214
|
+
className: "leading-none text-(--theme-accent)",
|
|
215
|
+
children: "●"
|
|
216
|
+
})
|
|
217
|
+
});
|
|
210
218
|
if (part.status === "error") return /* @__PURE__ */ jsx(StatusMark, {
|
|
211
219
|
status: t.failed,
|
|
212
220
|
children: /* @__PURE__ */ jsx("span", {
|
|
@@ -271,7 +279,7 @@ function ToolCallBlock({ part }) {
|
|
|
271
279
|
children: fullToolArgs(header.arg, part.args)
|
|
272
280
|
}), /* @__PURE__ */ jsx(ResultBody, { part })]
|
|
273
281
|
}),
|
|
274
|
-
!isDiff && !expanded && /* @__PURE__ */ jsx("div", {
|
|
282
|
+
!isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
|
|
275
283
|
className: cx("ml-5 mt-0.5 truncate text-[13px]", part.status === "error" ? "text-(--theme-error-text)" : "theme-muted-text"),
|
|
276
284
|
children: summary
|
|
277
285
|
}),
|
|
@@ -390,11 +398,31 @@ function SubagentCard({ part }) {
|
|
|
390
398
|
})]
|
|
391
399
|
});
|
|
392
400
|
}
|
|
401
|
+
/** Whether the ISO time has passed, turning true when it does. */
|
|
402
|
+
function useExpired(expiresAt) {
|
|
403
|
+
const [expired, setExpired] = useState(() => !!expiresAt && DateTime.fromISO(expiresAt) <= DateTime.now());
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!expiresAt || expired) return;
|
|
406
|
+
let timer;
|
|
407
|
+
const arm = () => {
|
|
408
|
+
const left = DateTime.fromISO(expiresAt).diffNow().toMillis();
|
|
409
|
+
if (left <= 0) {
|
|
410
|
+
setExpired(true);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
timer = setTimeout(arm, Math.min(left, 864e5));
|
|
414
|
+
};
|
|
415
|
+
arm();
|
|
416
|
+
return () => clearTimeout(timer);
|
|
417
|
+
}, [expiresAt, expired]);
|
|
418
|
+
return expired;
|
|
419
|
+
}
|
|
393
420
|
function ConfirmApproval({ part, onAnswer }) {
|
|
394
421
|
const { strings } = useChatConfig();
|
|
395
422
|
const { common } = useStrings();
|
|
396
423
|
const t = strings.agentTranscript;
|
|
397
424
|
const [feedback, setFeedback] = useState(null);
|
|
425
|
+
const expired = useExpired(part.expiresAt);
|
|
398
426
|
const answer = (a) => onAnswer?.(part.id, a);
|
|
399
427
|
const [allowLabel, denyLabel] = {
|
|
400
428
|
command: [t.allowOnce, t.deny],
|
|
@@ -411,9 +439,12 @@ function ConfirmApproval({ part, onAnswer }) {
|
|
|
411
439
|
className: "text-xs theme-muted-text",
|
|
412
440
|
children: part.reason
|
|
413
441
|
}),
|
|
414
|
-
|
|
442
|
+
expired ? /* @__PURE__ */ jsx("div", {
|
|
415
443
|
className: "text-xs theme-muted-text",
|
|
416
|
-
children: t.
|
|
444
|
+
children: t.approvalRequestExpired
|
|
445
|
+
}) : !onAnswer ? /* @__PURE__ */ jsx("div", {
|
|
446
|
+
className: "text-xs theme-muted-text",
|
|
447
|
+
children: part.waitingOn ? t.approvalWaitingOn(part.waitingOn) : t.approvalPending
|
|
417
448
|
}) : feedback === null ? /* @__PURE__ */ jsxs("div", {
|
|
418
449
|
className: "flex flex-wrap gap-2",
|
|
419
450
|
children: [
|
|
@@ -793,7 +824,9 @@ function buildRenderItems(parts) {
|
|
|
793
824
|
emitThoughts(run.slice(end));
|
|
794
825
|
run = [];
|
|
795
826
|
};
|
|
827
|
+
const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
|
|
796
828
|
parts.forEach((part, index) => {
|
|
829
|
+
if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
|
|
797
830
|
if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && isGroupableTool(part.name)) {
|
|
798
831
|
run.push({
|
|
799
832
|
part,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { ChatAdapter, ChatToolOption } from '../adapter/types.js';
|
|
3
|
-
import type { AttachmentMeta, ChatModel, ChatNavigation, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
|
|
3
|
+
import type { ApprovalAnswerValue, AttachmentMeta, ChatModel, ChatNavigation, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
|
|
4
4
|
import { type ChatUIConfig } from './config.js';
|
|
5
5
|
import { type SidebarPresentation, type SidebarState } from './sidebarState.js';
|
|
6
6
|
interface ChatContextValue {
|
|
@@ -41,6 +41,8 @@ interface ChatContextValue {
|
|
|
41
41
|
deleteConversation: (id: string) => Promise<boolean>;
|
|
42
42
|
sendMessage: (content: string, attachmentIds?: string[], parentId?: string, attachmentMeta?: AttachmentMeta) => Promise<boolean>;
|
|
43
43
|
retryMessage: (messageId: string) => Promise<boolean>;
|
|
44
|
+
/** Answers a tool call waiting for approval, resuming the reply it paused. */
|
|
45
|
+
answerToolApproval: (approvalId: string, answer: ApprovalAnswerValue) => Promise<void>;
|
|
44
46
|
setSelectedProvider: (providerId: string | null) => void;
|
|
45
47
|
setSelectedAllocation: (allocation: string | null) => void;
|
|
46
48
|
setToolEnabled: (id: string, enabled: boolean) => void;
|
|
@@ -44,6 +44,20 @@ function heldSendArgs(held) {
|
|
|
44
44
|
preceding.length > 0 ? preceding : void 0
|
|
45
45
|
];
|
|
46
46
|
}
|
|
47
|
+
/** The wire history for the messages shown, oldest first. */
|
|
48
|
+
function historyMessages(messages, selectedProvider) {
|
|
49
|
+
return messages.filter((m) => m.content || m.toolCalls?.length || m.parts?.length).flatMap((m) => {
|
|
50
|
+
const msg = {
|
|
51
|
+
role: m.role,
|
|
52
|
+
content: m.content || null
|
|
53
|
+
};
|
|
54
|
+
if (m.role === "assistant" && m.reasoning) msg.reasoning_content = m.reasoning;
|
|
55
|
+
if (m.role === "assistant" && m.toolCalls?.length) msg.tool_calls = m.toolCalls;
|
|
56
|
+
if (m.role === "assistant" && m.responsesOutput?.length && m.model === selectedProvider) msg.responses_output = m.responsesOutput;
|
|
57
|
+
if (m.role === "tool" && m.toolCallId) msg.tool_call_id = m.toolCallId;
|
|
58
|
+
return m.role === "assistant" ? toolTurnMessages(m.parts, msg) : [msg];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
47
61
|
var NO_TOOLS = [];
|
|
48
62
|
var ChatContext = createContext(void 0);
|
|
49
63
|
var ChatStreamContext = createContext(void 0);
|
|
@@ -291,6 +305,124 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
291
305
|
state.currentConversation,
|
|
292
306
|
navigation
|
|
293
307
|
]);
|
|
308
|
+
const streamReply = useCallback(async (targetConversationId, request, parentId, onFailure, routePart) => {
|
|
309
|
+
backgroundStreamsRef.current.set(targetConversationId, {
|
|
310
|
+
content: "",
|
|
311
|
+
reasoning: "",
|
|
312
|
+
parts: [],
|
|
313
|
+
messageId: null,
|
|
314
|
+
isActive: true
|
|
315
|
+
});
|
|
316
|
+
dispatch({
|
|
317
|
+
type: "SET_STREAMING",
|
|
318
|
+
isStreaming: true,
|
|
319
|
+
conversationId: targetConversationId
|
|
320
|
+
});
|
|
321
|
+
dispatch({
|
|
322
|
+
type: "CLEAR_STREAMING_CONTENT",
|
|
323
|
+
conversationId: targetConversationId
|
|
324
|
+
});
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
abortControllerRef.current = controller;
|
|
327
|
+
try {
|
|
328
|
+
let thinkingStart = null;
|
|
329
|
+
let thinkingEnd = null;
|
|
330
|
+
const markStreamStart = () => {
|
|
331
|
+
if (thinkingStart === null) thinkingStart = Date.now();
|
|
332
|
+
};
|
|
333
|
+
let liveParts = [];
|
|
334
|
+
const { content: assistantContent, messageId: assistantMessageId, toolCalls, model: resolvedModel, reasoning: assistantReasoning, responsesOutput } = await adapter.streamCompletion(request, {
|
|
335
|
+
onContent: (chunk) => {
|
|
336
|
+
markStreamStart();
|
|
337
|
+
onStreamingChunk(chunk);
|
|
338
|
+
},
|
|
339
|
+
onReasoning: (reasoning) => {
|
|
340
|
+
markStreamStart();
|
|
341
|
+
thinkingEnd = Date.now();
|
|
342
|
+
dispatch({
|
|
343
|
+
type: "APPEND_STREAMING_REASONING",
|
|
344
|
+
reasoning,
|
|
345
|
+
conversationId: targetConversationId
|
|
346
|
+
});
|
|
347
|
+
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
348
|
+
if (bgStream) bgStream.reasoning += reasoning;
|
|
349
|
+
},
|
|
350
|
+
onMessageId: (messageId) => {
|
|
351
|
+
markStreamStart();
|
|
352
|
+
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
353
|
+
if (bgStream) bgStream.messageId = messageId;
|
|
354
|
+
},
|
|
355
|
+
onPart: (delta) => {
|
|
356
|
+
markStreamStart();
|
|
357
|
+
if (routePart?.(delta)) return;
|
|
358
|
+
liveParts = applyPartDelta(liveParts, delta);
|
|
359
|
+
dispatch({
|
|
360
|
+
type: "APPLY_STREAMING_PART",
|
|
361
|
+
delta,
|
|
362
|
+
conversationId: targetConversationId
|
|
363
|
+
});
|
|
364
|
+
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
365
|
+
if (bgStream) bgStream.parts = liveParts;
|
|
366
|
+
}
|
|
367
|
+
}, controller.signal);
|
|
368
|
+
const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
|
|
369
|
+
const assistantMessage = {
|
|
370
|
+
id: assistantMessageId || crypto.randomUUID(),
|
|
371
|
+
parentId,
|
|
372
|
+
role: "assistant",
|
|
373
|
+
content: assistantContent || (toolCalls.length > 0 ? null : ""),
|
|
374
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
375
|
+
model: request.useResponses ? request.model : resolvedModel || request.model,
|
|
376
|
+
providerId: state.selectedProvider,
|
|
377
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
378
|
+
reasoning: assistantReasoning || void 0,
|
|
379
|
+
reasoningDuration: thinkingDuration ?? void 0,
|
|
380
|
+
responsesOutput: responsesOutput.length ? responsesOutput : void 0,
|
|
381
|
+
...liveParts.length ? { parts: finalizeParts(liveParts) } : {}
|
|
382
|
+
};
|
|
383
|
+
dispatch({
|
|
384
|
+
type: "ADD_MESSAGE",
|
|
385
|
+
message: assistantMessage,
|
|
386
|
+
conversationId: targetConversationId
|
|
387
|
+
});
|
|
388
|
+
return "sent";
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error instanceof DOMException && error.name === "AbortError") return "aborted";
|
|
391
|
+
console.error("Failed to send message:", error);
|
|
392
|
+
onFailure(errorText(error));
|
|
393
|
+
return "failed";
|
|
394
|
+
} finally {
|
|
395
|
+
abortControllerRef.current = null;
|
|
396
|
+
const { unrevealed } = stopAnimationLoop();
|
|
397
|
+
if (unrevealed) dispatch({
|
|
398
|
+
type: "APPEND_STREAMING_CONTENT",
|
|
399
|
+
content: unrevealed,
|
|
400
|
+
conversationId: targetConversationId
|
|
401
|
+
});
|
|
402
|
+
streamingConversationIdRef.current = null;
|
|
403
|
+
backgroundStreamsRef.current.delete(targetConversationId);
|
|
404
|
+
setTimeout(() => {
|
|
405
|
+
streamingToConversationRef.current = null;
|
|
406
|
+
}, 500);
|
|
407
|
+
dispatch({
|
|
408
|
+
type: "SET_STREAMING",
|
|
409
|
+
isStreaming: false,
|
|
410
|
+
conversationId: targetConversationId
|
|
411
|
+
});
|
|
412
|
+
dispatch({
|
|
413
|
+
type: "CLEAR_STREAMING_CONTENT",
|
|
414
|
+
conversationId: targetConversationId
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}, [
|
|
418
|
+
adapter,
|
|
419
|
+
state.selectedProvider,
|
|
420
|
+
onStreamingChunk,
|
|
421
|
+
stopAnimationLoop,
|
|
422
|
+
streamingConversationIdRef,
|
|
423
|
+
backgroundStreamsRef,
|
|
424
|
+
abortControllerRef
|
|
425
|
+
]);
|
|
294
426
|
const sendMessage = useCallback(async (content, attachmentIds, parentId, attachmentMeta, precedingMessages) => {
|
|
295
427
|
if (isSendingRef.current) {
|
|
296
428
|
const convId = currentConversationIdRef.current;
|
|
@@ -405,13 +537,6 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
405
537
|
message: userMessage,
|
|
406
538
|
conversationId: targetConversationId
|
|
407
539
|
});
|
|
408
|
-
backgroundStreamsRef.current.set(targetConversationId, {
|
|
409
|
-
content: "",
|
|
410
|
-
reasoning: "",
|
|
411
|
-
parts: [],
|
|
412
|
-
messageId: null,
|
|
413
|
-
isActive: true
|
|
414
|
-
});
|
|
415
540
|
const setMessageError = (errorMsg) => {
|
|
416
541
|
dispatch({
|
|
417
542
|
type: "UPDATE_MESSAGE",
|
|
@@ -421,17 +546,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
421
546
|
});
|
|
422
547
|
};
|
|
423
548
|
const apiMessages = [
|
|
424
|
-
...(isNewConversation ? [] : state.currentConversation?.messages || []
|
|
425
|
-
const msg = {
|
|
426
|
-
role: m.role,
|
|
427
|
-
content: m.content || null
|
|
428
|
-
};
|
|
429
|
-
if (m.role === "assistant" && m.reasoning) msg.reasoning_content = m.reasoning;
|
|
430
|
-
if (m.role === "assistant" && m.toolCalls?.length) msg.tool_calls = m.toolCalls;
|
|
431
|
-
if (m.role === "assistant" && m.responsesOutput?.length && m.model === state.selectedProvider) msg.responses_output = m.responsesOutput;
|
|
432
|
-
if (m.role === "tool" && m.toolCallId) msg.tool_call_id = m.toolCallId;
|
|
433
|
-
return m.role === "assistant" ? toolTurnMessages(m.parts, msg) : [msg];
|
|
434
|
-
}),
|
|
549
|
+
...historyMessages(isNewConversation ? [] : state.currentConversation?.messages || [], state.selectedProvider),
|
|
435
550
|
...precedingApiMessages,
|
|
436
551
|
{
|
|
437
552
|
role: "user",
|
|
@@ -439,28 +554,10 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
439
554
|
attachment_ids: attachmentIds?.length ? attachmentIds : void 0
|
|
440
555
|
}
|
|
441
556
|
];
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
isStreaming: true,
|
|
445
|
-
conversationId: targetConversationId
|
|
446
|
-
});
|
|
447
|
-
dispatch({
|
|
448
|
-
type: "CLEAR_STREAMING_CONTENT",
|
|
449
|
-
conversationId: targetConversationId
|
|
450
|
-
});
|
|
451
|
-
const controller = new AbortController();
|
|
452
|
-
abortControllerRef.current = controller;
|
|
453
|
-
let sendSucceeded = false;
|
|
454
|
-
let wasAborted = false;
|
|
557
|
+
const useResponses = state.models.find((m) => m.id === modelString)?.supports_responses === true;
|
|
558
|
+
let outcome = "failed";
|
|
455
559
|
try {
|
|
456
|
-
|
|
457
|
-
let thinkingStart = null;
|
|
458
|
-
let thinkingEnd = null;
|
|
459
|
-
const markStreamStart = () => {
|
|
460
|
-
if (thinkingStart === null) thinkingStart = Date.now();
|
|
461
|
-
};
|
|
462
|
-
let liveParts = [];
|
|
463
|
-
const { content: assistantContent, messageId: assistantMessageId, toolCalls, model: resolvedModel, reasoning: assistantReasoning, responsesOutput } = await adapter.streamCompletion({
|
|
560
|
+
outcome = await streamReply(targetConversationId, {
|
|
464
561
|
model: modelString,
|
|
465
562
|
messages: apiMessages,
|
|
466
563
|
conversationId,
|
|
@@ -469,94 +566,11 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
469
566
|
allocation: state.selectedAllocation,
|
|
470
567
|
useResponses,
|
|
471
568
|
...tools ? { tools: enabledToolIds } : {}
|
|
472
|
-
},
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
onStreamingChunk(chunk);
|
|
476
|
-
},
|
|
477
|
-
onReasoning: (reasoning) => {
|
|
478
|
-
markStreamStart();
|
|
479
|
-
thinkingEnd = Date.now();
|
|
480
|
-
dispatch({
|
|
481
|
-
type: "APPEND_STREAMING_REASONING",
|
|
482
|
-
reasoning,
|
|
483
|
-
conversationId: targetConversationId
|
|
484
|
-
});
|
|
485
|
-
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
486
|
-
if (bgStream) bgStream.reasoning += reasoning;
|
|
487
|
-
},
|
|
488
|
-
onMessageId: (messageId) => {
|
|
489
|
-
markStreamStart();
|
|
490
|
-
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
491
|
-
if (bgStream) bgStream.messageId = messageId;
|
|
492
|
-
},
|
|
493
|
-
onPart: (delta) => {
|
|
494
|
-
markStreamStart();
|
|
495
|
-
liveParts = applyPartDelta(liveParts, delta);
|
|
496
|
-
dispatch({
|
|
497
|
-
type: "APPLY_STREAMING_PART",
|
|
498
|
-
delta,
|
|
499
|
-
conversationId: targetConversationId
|
|
500
|
-
});
|
|
501
|
-
const bgStream = backgroundStreamsRef.current.get(targetConversationId);
|
|
502
|
-
if (bgStream) bgStream.parts = liveParts;
|
|
503
|
-
}
|
|
504
|
-
}, controller.signal);
|
|
505
|
-
const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
|
|
506
|
-
const assistantMessage = {
|
|
507
|
-
id: assistantMessageId || crypto.randomUUID(),
|
|
508
|
-
parentId: userMessageId,
|
|
509
|
-
role: "assistant",
|
|
510
|
-
content: assistantContent || (toolCalls.length > 0 ? null : ""),
|
|
511
|
-
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
512
|
-
model: useResponses ? modelString : resolvedModel || modelString,
|
|
513
|
-
providerId: state.selectedProvider,
|
|
514
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
515
|
-
reasoning: assistantReasoning || void 0,
|
|
516
|
-
reasoningDuration: thinkingDuration ?? void 0,
|
|
517
|
-
responsesOutput: responsesOutput.length ? responsesOutput : void 0,
|
|
518
|
-
...liveParts.length ? { parts: finalizeParts(liveParts) } : {}
|
|
519
|
-
};
|
|
520
|
-
dispatch({
|
|
521
|
-
type: "ADD_MESSAGE",
|
|
522
|
-
message: assistantMessage,
|
|
523
|
-
conversationId: targetConversationId
|
|
524
|
-
});
|
|
525
|
-
if (isNewConversation) loadConversations();
|
|
526
|
-
sendSucceeded = true;
|
|
527
|
-
return true;
|
|
528
|
-
} catch (error) {
|
|
529
|
-
if (error instanceof DOMException && error.name === "AbortError") {
|
|
530
|
-
wasAborted = true;
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
console.error("Failed to send message:", error);
|
|
534
|
-
setMessageError(errorText(error));
|
|
535
|
-
return false;
|
|
569
|
+
}, userMessageId, setMessageError);
|
|
570
|
+
if (outcome === "sent" && isNewConversation) loadConversations();
|
|
571
|
+
return outcome === "sent";
|
|
536
572
|
} finally {
|
|
537
|
-
|
|
538
|
-
const { unrevealed } = stopAnimationLoop();
|
|
539
|
-
if (unrevealed) dispatch({
|
|
540
|
-
type: "APPEND_STREAMING_CONTENT",
|
|
541
|
-
content: unrevealed,
|
|
542
|
-
conversationId: targetConversationId
|
|
543
|
-
});
|
|
544
|
-
streamingConversationIdRef.current = null;
|
|
545
|
-
backgroundStreamsRef.current.delete(targetConversationId);
|
|
546
|
-
setTimeout(() => {
|
|
547
|
-
streamingToConversationRef.current = null;
|
|
548
|
-
}, 500);
|
|
549
|
-
dispatch({
|
|
550
|
-
type: "SET_STREAMING",
|
|
551
|
-
isStreaming: false,
|
|
552
|
-
conversationId: targetConversationId
|
|
553
|
-
});
|
|
554
|
-
dispatch({
|
|
555
|
-
type: "CLEAR_STREAMING_CONTENT",
|
|
556
|
-
conversationId: targetConversationId
|
|
557
|
-
});
|
|
558
|
-
if (wasAborted) isSendingRef.current = false;
|
|
559
|
-
else if (sendSucceeded) {
|
|
573
|
+
if (outcome === "sent") {
|
|
560
574
|
const next = heldSendArgs(pendingQueueRef.current);
|
|
561
575
|
pendingQueueRef.current = [];
|
|
562
576
|
dispatch({ type: "CLEAR_QUEUED_MESSAGES" });
|
|
@@ -581,16 +595,84 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
581
595
|
createConversation,
|
|
582
596
|
loadConversations,
|
|
583
597
|
navigation,
|
|
584
|
-
onStreamingChunk,
|
|
585
598
|
startStreaming,
|
|
586
|
-
|
|
587
|
-
streamingConversationIdRef,
|
|
588
|
-
backgroundStreamsRef,
|
|
589
|
-
abortControllerRef
|
|
599
|
+
streamReply
|
|
590
600
|
]);
|
|
591
601
|
useEffect(() => {
|
|
592
602
|
sendMessageRef.current = sendMessage;
|
|
593
603
|
}, [sendMessage]);
|
|
604
|
+
const answerToolApproval = useCallback(async (approvalId, answer) => {
|
|
605
|
+
const conversation = state.currentConversation;
|
|
606
|
+
const modelString = state.selectedProvider;
|
|
607
|
+
if (answer.kind !== "confirm" || !conversation || !modelString || isSendingRef.current) return;
|
|
608
|
+
const pausedAt = conversation.messages.findIndex((m) => m.role === "assistant" && m.parts?.some((p) => p.kind === "approval" && p.id === approvalId && !p.resolved));
|
|
609
|
+
const paused = conversation.messages[pausedAt];
|
|
610
|
+
if (!paused) return;
|
|
611
|
+
isSendingRef.current = true;
|
|
612
|
+
const targetConversationId = conversation.id;
|
|
613
|
+
const t = resolvedConfig.strings.agentTranscript;
|
|
614
|
+
let pausedParts = paused.parts ?? [];
|
|
615
|
+
const updatePaused = (updates) => dispatch({
|
|
616
|
+
type: "UPDATE_MESSAGE",
|
|
617
|
+
messageId: paused.id,
|
|
618
|
+
updates,
|
|
619
|
+
conversationId: targetConversationId
|
|
620
|
+
});
|
|
621
|
+
const applyToPaused = (delta) => {
|
|
622
|
+
pausedParts = applyPartDelta(pausedParts, delta);
|
|
623
|
+
updatePaused({ parts: pausedParts });
|
|
624
|
+
};
|
|
625
|
+
applyToPaused({
|
|
626
|
+
type: "approval_resolved",
|
|
627
|
+
id: approvalId,
|
|
628
|
+
answer: answer.allowed ? t.approvalAllowed : t.approvalDenied
|
|
629
|
+
});
|
|
630
|
+
applyToPaused({
|
|
631
|
+
type: "tool_decision",
|
|
632
|
+
id: approvalId,
|
|
633
|
+
decision: {
|
|
634
|
+
status: answer.allowed ? "approved" : "denied",
|
|
635
|
+
by: "user",
|
|
636
|
+
name: currentUser.name || currentUser.username,
|
|
637
|
+
at: DateTime.now().toUTC().toISO()
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
streamingToConversationRef.current = targetConversationId;
|
|
641
|
+
startStreaming(targetConversationId);
|
|
642
|
+
try {
|
|
643
|
+
await streamReply(targetConversationId, {
|
|
644
|
+
model: modelString,
|
|
645
|
+
messages: historyMessages(conversation.messages.slice(0, pausedAt + 1), state.selectedProvider),
|
|
646
|
+
conversationId: targetConversationId,
|
|
647
|
+
parentMessageId: paused.id,
|
|
648
|
+
userMessageId: crypto.randomUUID(),
|
|
649
|
+
allocation: state.selectedAllocation,
|
|
650
|
+
...tools ? { tools: enabledToolIds } : {},
|
|
651
|
+
resume: {
|
|
652
|
+
messageId: paused.id,
|
|
653
|
+
toolCallId: approvalId,
|
|
654
|
+
allowed: answer.allowed,
|
|
655
|
+
...answer.denyMessage ? { feedback: answer.denyMessage } : {}
|
|
656
|
+
}
|
|
657
|
+
}, paused.id, (message) => updatePaused({ error: message }), (delta) => {
|
|
658
|
+
if (delta.type !== "tool_end" || delta.id !== approvalId) return false;
|
|
659
|
+
applyToPaused(delta);
|
|
660
|
+
return true;
|
|
661
|
+
});
|
|
662
|
+
} finally {
|
|
663
|
+
isSendingRef.current = false;
|
|
664
|
+
}
|
|
665
|
+
}, [
|
|
666
|
+
resolvedConfig,
|
|
667
|
+
state.currentConversation,
|
|
668
|
+
state.selectedProvider,
|
|
669
|
+
state.selectedAllocation,
|
|
670
|
+
tools,
|
|
671
|
+
enabledToolIds,
|
|
672
|
+
startStreaming,
|
|
673
|
+
streamReply,
|
|
674
|
+
currentUser
|
|
675
|
+
]);
|
|
594
676
|
const updateConversationTitle = useCallback(async (id, title) => {
|
|
595
677
|
try {
|
|
596
678
|
await adapter.conversations.rename(id, title);
|
|
@@ -807,6 +889,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
807
889
|
deleteConversation,
|
|
808
890
|
sendMessage,
|
|
809
891
|
retryMessage,
|
|
892
|
+
answerToolApproval,
|
|
810
893
|
setSelectedProvider,
|
|
811
894
|
setSelectedAllocation,
|
|
812
895
|
setToolEnabled,
|
|
@@ -854,6 +937,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
|
|
|
854
937
|
deleteConversation,
|
|
855
938
|
sendMessage,
|
|
856
939
|
retryMessage,
|
|
940
|
+
answerToolApproval,
|
|
857
941
|
setSelectedProvider,
|
|
858
942
|
setSelectedAllocation,
|
|
859
943
|
setToolEnabled,
|
package/dist/ai/core/parts.js
CHANGED
|
@@ -34,6 +34,10 @@ function applyPartDelta(parts, delta) {
|
|
|
34
34
|
...p,
|
|
35
35
|
args: p.args + delta.args
|
|
36
36
|
} : p);
|
|
37
|
+
case "tool_wait": return parts.map((p) => p.kind === "tool_call" && p.id === delta.id ? {
|
|
38
|
+
...p,
|
|
39
|
+
status: "waiting"
|
|
40
|
+
} : p);
|
|
37
41
|
case "tool_end": return parts.map((p) => p.kind === "tool_call" && p.id === delta.id ? {
|
|
38
42
|
...p,
|
|
39
43
|
status: delta.isError ? "error" : "ok",
|
|
@@ -99,7 +103,7 @@ function isStreamedText(part) {
|
|
|
99
103
|
* calls comes back as the answer alone. */
|
|
100
104
|
function toolTurnMessages(parts, answer) {
|
|
101
105
|
const calls = (parts ?? []).filter((p) => p.kind === "tool_call" && p.status !== "running" && p.result !== void 0);
|
|
102
|
-
if (calls.length === 0) return [answer];
|
|
106
|
+
if (calls.length === 0) return answer.content || answer.tool_calls?.length ? [answer] : [];
|
|
103
107
|
return [
|
|
104
108
|
{
|
|
105
109
|
role: "assistant",
|
package/dist/ai/strings.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ChatStrings {
|
|
|
15
15
|
addGroups: string;
|
|
16
16
|
info: ReactNode;
|
|
17
17
|
shareButton: string;
|
|
18
|
+
/** Beside the share button in a shared conversation that can use tools. */
|
|
19
|
+
toolResultsShared: string;
|
|
18
20
|
};
|
|
19
21
|
blockedGroup: {
|
|
20
22
|
title: (group: string) => string;
|
|
@@ -67,6 +69,8 @@ export interface ChatStrings {
|
|
|
67
69
|
turnAllOff: string;
|
|
68
70
|
/** Closes the tools modal; each switch takes effect as it is flipped. */
|
|
69
71
|
done: string;
|
|
72
|
+
/** Marks a tool whose calls wait for the reader's approval. */
|
|
73
|
+
asksFirst: string;
|
|
70
74
|
};
|
|
71
75
|
allocationPicker: {
|
|
72
76
|
choose: string;
|
|
@@ -121,6 +125,9 @@ export interface ChatStrings {
|
|
|
121
125
|
activity: string;
|
|
122
126
|
/** A finished turn's label when it ran tools without reasoning. */
|
|
123
127
|
ranToolCalls: (count: number) => string;
|
|
128
|
+
/** The same, in a shared conversation, for a turn someone else started:
|
|
129
|
+
* its tools ran with that person's access. */
|
|
130
|
+
ranToolCallsAs: (count: number, name: string) => string;
|
|
124
131
|
};
|
|
125
132
|
thread: {
|
|
126
133
|
viewOnly: string;
|
|
@@ -253,6 +260,13 @@ export interface ChatStrings {
|
|
|
253
260
|
approvalOptions: string;
|
|
254
261
|
/** Names a question the agent waits on that has no heading of its own. */
|
|
255
262
|
approvalLabel: string;
|
|
263
|
+
/** A call the reader approved or declined. */
|
|
264
|
+
approvalAllowed: string;
|
|
265
|
+
approvalDenied: string;
|
|
266
|
+
/** A call nobody answered in time. */
|
|
267
|
+
approvalRequestExpired: string;
|
|
268
|
+
/** A call only the person who asked for it may answer. */
|
|
269
|
+
approvalWaitingOn: (name: string) => string;
|
|
256
270
|
/** Names the field for the reason a request is denied. */
|
|
257
271
|
feedbackLabel: string;
|
|
258
272
|
/** Names the field for an answer of the reader's own. */
|
package/dist/ai/strings.js
CHANGED
|
@@ -20,7 +20,8 @@ var defaultChatStrings = {
|
|
|
20
20
|
/* @__PURE__ */ jsx("strong", { children: "Collaborate" }),
|
|
21
21
|
" permission allows adding messages."
|
|
22
22
|
] }),
|
|
23
|
-
shareButton: "Share conversation"
|
|
23
|
+
shareButton: "Share conversation",
|
|
24
|
+
toolResultsShared: "Tool results here are visible to the people it's shared with"
|
|
24
25
|
},
|
|
25
26
|
blockedGroup: {
|
|
26
27
|
title: (group) => `Billing group \u201c${group}\u201d is over its allocation`,
|
|
@@ -59,12 +60,13 @@ var defaultChatStrings = {
|
|
|
59
60
|
tools: "Tools",
|
|
60
61
|
someToolsOff: "Some tools are off",
|
|
61
62
|
toolsOn: (enabled, total) => `${enabled.toLocaleString()}/${total.toLocaleString()}`,
|
|
62
|
-
toolsDescription: "What the assistant may look up for you.",
|
|
63
|
+
toolsDescription: "What the assistant may look up or do for you.",
|
|
63
64
|
searchTools: "Search tools",
|
|
64
65
|
noToolsMatch: "No tools match",
|
|
65
66
|
turnAllOn: "Turn all on",
|
|
66
67
|
turnAllOff: "Turn all off",
|
|
67
|
-
done: "Done"
|
|
68
|
+
done: "Done",
|
|
69
|
+
asksFirst: "Asks first"
|
|
68
70
|
},
|
|
69
71
|
allocationPicker: {
|
|
70
72
|
choose: "Allocation",
|
|
@@ -150,7 +152,8 @@ var defaultChatStrings = {
|
|
|
150
152
|
thoughtFor: (duration) => `Thought for ${duration}`,
|
|
151
153
|
thoughtProcess: "Thought process",
|
|
152
154
|
activity: "Activity",
|
|
153
|
-
ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}
|
|
155
|
+
ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}`,
|
|
156
|
+
ranToolCallsAs: (count, name) => `Ran ${countOf(count, "tool call", "tool calls")} as ${name}`
|
|
154
157
|
},
|
|
155
158
|
thread: {
|
|
156
159
|
viewOnly: "You have view only access",
|
|
@@ -270,6 +273,10 @@ var defaultChatStrings = {
|
|
|
270
273
|
approvalPending: "Waiting for an answer in the terminal",
|
|
271
274
|
approvalOptions: "Offered choices",
|
|
272
275
|
approvalLabel: "Waiting for your answer",
|
|
276
|
+
approvalAllowed: "Allowed",
|
|
277
|
+
approvalDenied: "Declined",
|
|
278
|
+
approvalRequestExpired: "This request expired. Ask again to run it.",
|
|
279
|
+
approvalWaitingOn: (name) => `Waiting for ${name} to answer`,
|
|
273
280
|
feedbackLabel: "Feedback for the agent",
|
|
274
281
|
ownAnswerLabel: "Your answer",
|
|
275
282
|
todoPending: "To do",
|
package/dist/ai/types.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface TokenUsage {
|
|
|
45
45
|
completion_tokens?: number;
|
|
46
46
|
total_tokens?: number;
|
|
47
47
|
}
|
|
48
|
-
export type ToolCallStatus = 'running' | 'ok' | 'error';
|
|
48
|
+
export type ToolCallStatus = 'running' | 'waiting' | 'ok' | 'error';
|
|
49
49
|
export interface TextPart {
|
|
50
50
|
kind: 'text';
|
|
51
51
|
text: string;
|
|
@@ -122,6 +122,8 @@ export interface ApprovalPart {
|
|
|
122
122
|
options?: ApprovalOption[];
|
|
123
123
|
plan?: string;
|
|
124
124
|
canAllowDirectory?: boolean;
|
|
125
|
+
expiresAt?: string;
|
|
126
|
+
waitingOn?: string;
|
|
125
127
|
resolved?: boolean;
|
|
126
128
|
answer?: string;
|
|
127
129
|
}
|
|
@@ -65,7 +65,7 @@ function useRelativeTime(value, format = "long") {
|
|
|
65
65
|
const unit = unitOf(age);
|
|
66
66
|
const key = `${format}:${at}:${unit}:${Math.trunc(age / unit)}`;
|
|
67
67
|
if (last.current?.key !== key) {
|
|
68
|
-
const label = format === "compact" ? compactLabel(age, t) : DateTime.fromMillis(at).toRelative({ base: DateTime.fromMillis(current) }) ?? "";
|
|
68
|
+
const label = format === "compact" ? compactLabel(age, t) : Math.abs(age) < SECOND ? t.now : DateTime.fromMillis(at).toRelative({ base: DateTime.fromMillis(current) }) ?? "";
|
|
69
69
|
last.current = {
|
|
70
70
|
key,
|
|
71
71
|
label
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parallelworks/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "React UI components: a theme contract, icons, brand logos, primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|