@parallelworks/ui 0.40.1 → 0.41.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.
@@ -1,4 +1,5 @@
1
1
  import { ChatAdapterError } from "./types.js";
2
+ import { applyPartDelta, finalizeParts } from "../core/parts.js";
2
3
  //#region src/ai/adapter/memoryStore.ts
3
4
  function newId() {
4
5
  return crypto.randomUUID();
@@ -103,6 +104,7 @@ function createMemoryConversationStore(options) {
103
104
  return async (req, handlers, signal) => {
104
105
  let partialContent = "";
105
106
  let partialReasoning = "";
107
+ let parts = [];
106
108
  const wrappedHandlers = {
107
109
  ...handlers,
108
110
  onContent: (chunk) => {
@@ -112,6 +114,10 @@ function createMemoryConversationStore(options) {
112
114
  onReasoning: (chunk) => {
113
115
  partialReasoning += chunk;
114
116
  handlers.onReasoning?.(chunk);
117
+ },
118
+ onPart: (delta) => {
119
+ parts = applyPartDelta(parts, delta);
120
+ handlers.onPart?.(delta);
115
121
  }
116
122
  };
117
123
  recordUser(req);
@@ -126,6 +132,7 @@ function createMemoryConversationStore(options) {
126
132
  ...result.reasoning ? { reasoning: result.reasoning } : {},
127
133
  ...result.responsesOutput.length ? { responsesOutput: result.responsesOutput } : {},
128
134
  ...result.toolCalls.length ? { toolCalls: result.toolCalls } : {},
135
+ ...parts.length ? { parts: finalizeParts(parts) } : {},
129
136
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
130
137
  });
131
138
  return {
@@ -5,6 +5,7 @@ var CANNED_REPLIES = [
5
5
  "Everything you see is generated locally. The mock adapter implements the same `ChatAdapter` interface as the OpenAI-compatible adapter and any backend's own, so the UI can't tell the difference.\n\n```ts\nconst adapter = createMockChatAdapter()\n```\n\nMarkdown, `inline code`, and code blocks render through the same pipeline.",
6
6
  "Branching works too: edit one of your earlier messages or hit regenerate on a reply, then use the arrows to flip between branches."
7
7
  ];
8
+ var TOOL_REPLY = "Two clusters are running: **gpu-a** with 4 nodes, and **cpu-batch**, idle and scaled to zero.";
8
9
  var THINKING_PREAMBLE = "Thinking about the question, weighing a few canned options, and picking the next reply in the rotation...";
9
10
  function sleep(ms, signal) {
10
11
  return new Promise((resolve, reject) => {
@@ -30,21 +31,32 @@ function createMockChatAdapter(options) {
30
31
  conversations: store.conversations,
31
32
  models: { async list() {
32
33
  return {
33
- models: [{
34
- id: "mock:local/canned-small",
35
- object: "model",
36
- created: 0,
37
- owned_by: "mock",
38
- provider: "Mock Provider",
39
- tool_calling_mode: "none"
40
- }, {
41
- id: "mock:local/canned-thinking",
42
- object: "model",
43
- created: 0,
44
- owned_by: "mock",
45
- provider: "Mock Provider",
46
- tool_calling_mode: "none"
47
- }],
34
+ models: [
35
+ {
36
+ id: "mock:local/canned-small",
37
+ object: "model",
38
+ created: 0,
39
+ owned_by: "mock",
40
+ provider: "Mock Provider",
41
+ tool_calling_mode: "none"
42
+ },
43
+ {
44
+ id: "mock:local/canned-thinking",
45
+ object: "model",
46
+ created: 0,
47
+ owned_by: "mock",
48
+ provider: "Mock Provider",
49
+ tool_calling_mode: "none"
50
+ },
51
+ {
52
+ id: "mock:local/canned-tools",
53
+ object: "model",
54
+ created: 0,
55
+ owned_by: "mock",
56
+ provider: "Mock Provider",
57
+ tool_calling_mode: "native"
58
+ }
59
+ ],
48
60
  unreachableSessions: [],
49
61
  providerIssues: []
50
62
  };
@@ -57,8 +69,31 @@ function createMockChatAdapter(options) {
57
69
  reasoning += word;
58
70
  handlers.onReasoning?.(word);
59
71
  }
60
- const reply = CANNED_REPLIES[replyIndex % CANNED_REPLIES.length] ?? "";
61
- replyIndex++;
72
+ const usesTools = req.model.includes("tools");
73
+ if (usesTools) {
74
+ const id = `call-${crypto.randomUUID()}`;
75
+ handlers.onPart?.({
76
+ type: "tool_start",
77
+ id,
78
+ name: "list_clusters",
79
+ args: "{}"
80
+ });
81
+ await sleep(900, signal);
82
+ handlers.onPart?.({
83
+ type: "tool_end",
84
+ id,
85
+ result: JSON.stringify([{
86
+ name: "gpu-a",
87
+ status: "on",
88
+ nodes: 4
89
+ }, {
90
+ name: "cpu-batch",
91
+ status: "on",
92
+ nodes: 0
93
+ }])
94
+ });
95
+ }
96
+ const reply = usesTools ? TOOL_REPLY : CANNED_REPLIES[replyIndex++ % CANNED_REPLIES.length] ?? "";
62
97
  await sleep(250, signal);
63
98
  let content = "";
64
99
  for (const word of reply.split(/(?<= )/)) {
@@ -27,7 +27,7 @@ function createOpenAICompatibleStream(config) {
27
27
  if (!res.ok) throw new ChatAdapterError(await errorMessageFromResponse(res), { status: res.status });
28
28
  const reader = res.body?.getReader();
29
29
  if (!reader) throw new ChatAdapterError("No response body");
30
- return processSSEStream(reader, handlers.onContent, void 0, void 0, handlers.onReasoning, handlers.onMessageId, useResponses ? parseResponsesSSELine : void 0);
30
+ return processSSEStream(reader, handlers.onContent, void 0, void 0, handlers.onReasoning, handlers.onMessageId, useResponses ? parseResponsesSSELine : void 0, handlers.onPart);
31
31
  };
32
32
  }
33
33
  //#endregion
@@ -3,4 +3,4 @@ export { apiErrorMessage, errorMessageFromResponse, statusFallbackMessage, } fro
3
3
  export { createOpenAIChatAdapter, type OpenAIChatAdapterOptions, } from './openaiAdapter.js';
4
4
  export { buildResponsesRequestBody, parseResponsesSSELine, } from './responses.js';
5
5
  export { type ParsedSSELine, parseSSELine, processSSEStream, type SSELineParser, } from './sse.js';
6
- export type { CompletionChunk, CompletionChunkChoice, CompletionMessage, CompletionRequest, ModelsWireResponse, } from './wire.js';
6
+ export type { CompletionChunk, CompletionChunkChoice, CompletionMessage, CompletionRequest, ModelsWireResponse, ToolResultChunk, } from './wire.js';
@@ -1,10 +1,13 @@
1
1
  import type { ToolCall, ToolCallDelta } from '../../types.js';
2
+ import type { PartDelta } from '../types.js';
3
+ import type { ToolResultChunk } from './wire.js';
2
4
  export type ParsedSSELine = {
3
5
  id?: string | undefined;
4
6
  messageId?: string | undefined;
5
7
  content?: string | undefined;
6
8
  reasoningContent?: string | undefined;
7
9
  toolCalls?: ToolCallDelta[] | undefined;
10
+ toolResult?: ToolResultChunk | undefined;
8
11
  finishReason?: string | null | undefined;
9
12
  model?: string | undefined;
10
13
  error?: string | undefined;
@@ -12,8 +15,11 @@ export type ParsedSSELine = {
12
15
  } | null;
13
16
  export type SSELineParser = (line: string) => ParsedSSELine;
14
17
  export declare function parseSSELine(line: string): ParsedSSELine;
15
- /** Process an SSE stream from a ReadableStream reader */
16
- export declare function processSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>, onContent: (content: string) => void, onToolCall?: (toolCalls: ToolCall[]) => void, onError?: (error: string) => void, onReasoning?: (reasoning: string) => void, onMessageId?: (messageId: string) => void, parseLine?: SSELineParser): Promise<{
18
+ /** Process an SSE stream from a ReadableStream reader. With `onPart`, tool
19
+ * calls stream as parts; a call the gateway answered with a result is left
20
+ * off the returned `toolCalls`, which then hold only calls the caller must
21
+ * run. */
22
+ export declare function processSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>, onContent: (content: string) => void, onToolCall?: (toolCalls: ToolCall[]) => void, onError?: (error: string) => void, onReasoning?: (reasoning: string) => void, onMessageId?: (messageId: string) => void, parseLine?: SSELineParser, onPart?: (delta: PartDelta) => void): Promise<{
17
23
  content: string;
18
24
  messageId: string;
19
25
  toolCalls: ToolCall[];
@@ -14,6 +14,7 @@ function parseSSELine(line) {
14
14
  content: choice?.delta?.content,
15
15
  reasoningContent: choice?.delta?.reasoning_content,
16
16
  toolCalls: choice?.delta?.tool_calls ?? void 0,
17
+ toolResult: chunk.toolResult,
17
18
  finishReason: choice?.finish_reason,
18
19
  model: chunk.model
19
20
  };
@@ -21,8 +22,11 @@ function parseSSELine(line) {
21
22
  return null;
22
23
  }
23
24
  }
24
- /** Process an SSE stream from a ReadableStream reader */
25
- async function processSSEStream(reader, onContent, onToolCall, onError, onReasoning, onMessageId, parseLine = parseSSELine) {
25
+ /** Process an SSE stream from a ReadableStream reader. With `onPart`, tool
26
+ * calls stream as parts; a call the gateway answered with a result is left
27
+ * off the returned `toolCalls`, which then hold only calls the caller must
28
+ * run. */
29
+ async function processSSEStream(reader, onContent, onToolCall, onError, onReasoning, onMessageId, parseLine = parseSSELine, onPart) {
26
30
  const decoder = new TextDecoder();
27
31
  let assistantContent = "";
28
32
  let assistantReasoning = "";
@@ -31,6 +35,19 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
31
35
  let resolvedModel = null;
32
36
  let responsesOutput = [];
33
37
  const toolCallsMap = /* @__PURE__ */ new Map();
38
+ const startedToolCalls = /* @__PURE__ */ new Set();
39
+ const answeredToolCalls = /* @__PURE__ */ new Set();
40
+ const startToolCall = (index) => {
41
+ const tc = toolCallsMap.get(index);
42
+ if (!onPart || !tc?.id || !tc.name || startedToolCalls.has(index)) return;
43
+ startedToolCalls.add(index);
44
+ onPart({
45
+ type: "tool_start",
46
+ id: tc.id,
47
+ name: tc.name,
48
+ args: tc.arguments
49
+ });
50
+ };
34
51
  let lineBuffer = "";
35
52
  while (true) {
36
53
  const result = await reader.read();
@@ -64,13 +81,32 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
64
81
  if (tc.index === void 0) continue;
65
82
  const existing = toolCallsMap.get(tc.index);
66
83
  if (existing) {
67
- if (tc.function?.arguments) existing.arguments += tc.function.arguments;
84
+ existing.id ||= tc.id || "";
85
+ existing.name ||= tc.function?.name || "";
86
+ if (tc.function?.arguments) {
87
+ existing.arguments += tc.function.arguments;
88
+ if (startedToolCalls.has(tc.index)) onPart?.({
89
+ type: "tool_update",
90
+ id: existing.id,
91
+ args: tc.function.arguments
92
+ });
93
+ }
68
94
  } else toolCallsMap.set(tc.index, {
69
95
  id: tc.id || "",
70
96
  type: tc.type || "function",
71
97
  name: tc.function?.name || "",
72
98
  arguments: tc.function?.arguments || ""
73
99
  });
100
+ startToolCall(tc.index);
101
+ }
102
+ if (parsed.toolResult) {
103
+ answeredToolCalls.add(parsed.toolResult.toolCallId);
104
+ onPart?.({
105
+ type: "tool_end",
106
+ id: parsed.toolResult.toolCallId,
107
+ result: parsed.toolResult.content,
108
+ ...parsed.toolResult.isError ? { isError: true } : {}
109
+ });
74
110
  }
75
111
  }
76
112
  }
@@ -89,7 +125,7 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
89
125
  if (parsed.finishReason) finishReason = parsed.finishReason;
90
126
  }
91
127
  }
92
- const toolCalls = Array.from(toolCallsMap.values()).map((tc) => ({
128
+ const toolCalls = Array.from(toolCallsMap.values()).filter((tc) => !answeredToolCalls.has(tc.id)).map((tc) => ({
93
129
  id: tc.id,
94
130
  type: tc.type,
95
131
  function: {
@@ -30,9 +30,15 @@ export interface CompletionChunk {
30
30
  object?: string;
31
31
  created?: number;
32
32
  messageId?: string;
33
+ toolResult?: ToolResultChunk;
33
34
  choices?: CompletionChunkChoice[] | null;
34
35
  usage?: TokenUsage;
35
36
  }
37
+ export interface ToolResultChunk {
38
+ toolCallId: string;
39
+ content: string;
40
+ isError?: boolean;
41
+ }
36
42
  export interface ModelsWireResponse {
37
43
  object: string;
38
44
  data: ChatModel[] | null;
@@ -1,4 +1,4 @@
1
- import { applyPartDelta, finalizeParts } from "./parts.js";
1
+ import { applyPartDelta, finalizeParts, toolTurnMessages } from "./parts.js";
2
2
  import { chatReducer, initialState } from "./chatReducer.js";
3
3
  import { ChatConfigProvider, resolveChatConfig } from "./config.js";
4
4
  import { providerIssueFor } from "./providerIssues.js";
@@ -392,7 +392,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
392
392
  });
393
393
  };
394
394
  const apiMessages = [
395
- ...(isNewConversation ? [] : state.currentConversation?.messages || []).filter((m) => m.content || m.toolCalls?.length).map((m) => {
395
+ ...(isNewConversation ? [] : state.currentConversation?.messages || []).filter((m) => m.content || m.toolCalls?.length || m.parts?.length).flatMap((m) => {
396
396
  const msg = {
397
397
  role: m.role,
398
398
  content: m.content || null
@@ -401,7 +401,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
401
401
  if (m.role === "assistant" && m.toolCalls?.length) msg.tool_calls = m.toolCalls;
402
402
  if (m.role === "assistant" && m.responsesOutput?.length && m.model === state.selectedProvider) msg.responses_output = m.responsesOutput;
403
403
  if (m.role === "tool" && m.toolCallId) msg.tool_call_id = m.toolCallId;
404
- return msg;
404
+ return m.role === "assistant" ? toolTurnMessages(m.parts, msg) : [msg];
405
405
  }),
406
406
  ...precedingApiMessages,
407
407
  {
@@ -1,3 +1,4 @@
1
+ import type { CompletionMessage } from '../adapter/openai/wire.js';
1
2
  import type { PartDelta } from '../adapter/types.js';
2
3
  import type { MessagePart } from '../types.js';
3
4
  /** Fold one streamed PartDelta into a parts timeline. Pure: returns a new
@@ -5,6 +6,10 @@ import type { MessagePart } from '../types.js';
5
6
  * and the provider's local accumulation. Unknown target ids are dropped
6
7
  * rather than invented — an end without a start has nothing to render. */
7
8
  export declare function applyPartDelta(parts: MessagePart[], delta: PartDelta): MessagePart[];
9
+ /** The wire history for an assistant turn whose tool calls the gateway ran:
10
+ * the calls, each result, then the answer. A turn with no finished tool
11
+ * calls comes back as the answer alone. */
12
+ export declare function toolTurnMessages(parts: MessagePart[] | null | undefined, answer: CompletionMessage): CompletionMessage[];
8
13
  /** A turn can end (completed or stopped) with tool calls still marked
9
14
  * running — the transport died before their tool_end arrived. A stored
10
15
  * message must not spin forever, so close them out as interrupted. */
@@ -90,6 +90,33 @@ function applyPartDelta(parts, delta) {
90
90
  function isStreamedText(part) {
91
91
  return part?.kind === "text" || part?.kind === "reasoning";
92
92
  }
93
+ /** The wire history for an assistant turn whose tool calls the gateway ran:
94
+ * the calls, each result, then the answer. A turn with no finished tool
95
+ * calls comes back as the answer alone. */
96
+ function toolTurnMessages(parts, answer) {
97
+ const calls = (parts ?? []).filter((p) => p.kind === "tool_call" && p.status !== "running" && p.result !== void 0);
98
+ if (calls.length === 0) return [answer];
99
+ return [
100
+ {
101
+ role: "assistant",
102
+ content: null,
103
+ tool_calls: calls.map((c) => ({
104
+ id: c.id,
105
+ type: "function",
106
+ function: {
107
+ name: c.name,
108
+ arguments: c.args
109
+ }
110
+ }))
111
+ },
112
+ ...calls.map((c) => ({
113
+ role: "tool",
114
+ tool_call_id: c.id,
115
+ content: c.result
116
+ })),
117
+ ...answer.content ? [answer] : []
118
+ ];
119
+ }
93
120
  /** A turn can end (completed or stopped) with tool calls still marked
94
121
  * running — the transport died before their tool_end arrived. A stored
95
122
  * message must not spin forever, so close them out as interrupted. */
@@ -101,4 +128,4 @@ function finalizeParts(parts) {
101
128
  } : p);
102
129
  }
103
130
  //#endregion
104
- export { applyPartDelta, finalizeParts };
131
+ export { applyPartDelta, finalizeParts, toolTurnMessages };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelworks/ui",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "React UI components: a theme contract, icons, 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": {