@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.
- package/dist/ai/adapter/memoryStore.js +7 -0
- package/dist/ai/adapter/mock/mockAdapter.js +52 -17
- package/dist/ai/adapter/openai/createOpenAIStream.js +1 -1
- package/dist/ai/adapter/openai/index.d.ts +1 -1
- package/dist/ai/adapter/openai/sse.d.ts +8 -2
- package/dist/ai/adapter/openai/sse.js +40 -4
- package/dist/ai/adapter/openai/wire.d.ts +6 -0
- package/dist/ai/core/ChatProvider.js +3 -3
- package/dist/ai/core/parts.d.ts +5 -0
- package/dist/ai/core/parts.js +28 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
{
|
package/dist/ai/core/parts.d.ts
CHANGED
|
@@ -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. */
|
package/dist/ai/core/parts.js
CHANGED
|
@@ -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.
|
|
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": {
|