@checkstack/ai-frontend 0.1.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/CHANGELOG.md +75 -0
- package/package.json +32 -0
- package/src/components/AppliedCardView.tsx +36 -0
- package/src/components/ConfirmCardView.tsx +117 -0
- package/src/components/DiffView.tsx +84 -0
- package/src/components/SideBySideDiff.tsx +120 -0
- package/src/index.tsx +22 -0
- package/src/lib/chat-state.test.ts +213 -0
- package/src/lib/chat-state.ts +231 -0
- package/src/lib/line-diff.test.ts +87 -0
- package/src/lib/line-diff.ts +206 -0
- package/src/lib/mode-toggle.logic.test.ts +64 -0
- package/src/lib/mode-toggle.logic.ts +57 -0
- package/src/lib/model-options.logic.test.ts +55 -0
- package/src/lib/model-options.logic.ts +31 -0
- package/src/lib/new-chat.logic.test.ts +84 -0
- package/src/lib/new-chat.logic.ts +62 -0
- package/src/lib/stream-parser.test.ts +241 -0
- package/src/lib/stream-parser.ts +286 -0
- package/src/lib/use-chat-turn.ts +163 -0
- package/src/pages/ChatPage.tsx +661 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
asAppliedCard,
|
|
4
|
+
asConfirmCard,
|
|
5
|
+
chunkToEvent,
|
|
6
|
+
parseSseBuffer,
|
|
7
|
+
readChatStream,
|
|
8
|
+
type ChatStreamEvent,
|
|
9
|
+
} from "./stream-parser";
|
|
10
|
+
|
|
11
|
+
describe("asConfirmCard", () => {
|
|
12
|
+
test("recognises a well-formed confirm card", () => {
|
|
13
|
+
const card = asConfirmCard({
|
|
14
|
+
__confirm: true,
|
|
15
|
+
toolName: "automation.propose",
|
|
16
|
+
effect: "mutate",
|
|
17
|
+
summary: "Create automation X",
|
|
18
|
+
token: "propose:abc.def",
|
|
19
|
+
payload: { name: "X" },
|
|
20
|
+
expiresAt: "2026-06-01T00:10:00Z",
|
|
21
|
+
});
|
|
22
|
+
expect(card?.toolName).toBe("automation.propose");
|
|
23
|
+
expect(card?.effect).toBe("mutate");
|
|
24
|
+
expect(card?.token).toBe("propose:abc.def");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects a non-card object", () => {
|
|
28
|
+
expect(asConfirmCard({ rows: [] })).toBeUndefined();
|
|
29
|
+
expect(asConfirmCard({ __confirm: true, toolName: 1 })).toBeUndefined();
|
|
30
|
+
expect(asConfirmCard(null)).toBeUndefined();
|
|
31
|
+
expect(asConfirmCard("text")).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parses an update card's before -> after diff", () => {
|
|
35
|
+
const card = asConfirmCard({
|
|
36
|
+
__confirm: true,
|
|
37
|
+
toolName: "healthcheck.update",
|
|
38
|
+
effect: "mutate",
|
|
39
|
+
summary: "Update X",
|
|
40
|
+
token: "propose:1.2",
|
|
41
|
+
payload: {},
|
|
42
|
+
diff: [{ path: "intervalSeconds", before: 60, after: 30 }],
|
|
43
|
+
expiresAt: "2026-06-01T00:10:00Z",
|
|
44
|
+
});
|
|
45
|
+
expect(card?.diff).toEqual([
|
|
46
|
+
{ path: "intervalSeconds", before: 60, after: 30 },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("asAppliedCard", () => {
|
|
52
|
+
test("recognises an auto-applied result with its diff", () => {
|
|
53
|
+
const card = asAppliedCard({
|
|
54
|
+
__applied: true,
|
|
55
|
+
toolName: "healthcheck.update",
|
|
56
|
+
summary: "Updated X",
|
|
57
|
+
result: { id: "hc1" },
|
|
58
|
+
diff: [{ path: "intervalSeconds", before: 60, after: 30 }],
|
|
59
|
+
});
|
|
60
|
+
expect(card?.toolName).toBe("healthcheck.update");
|
|
61
|
+
expect(card?.diff).toHaveLength(1);
|
|
62
|
+
expect(card?.result).toEqual({ id: "hc1" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("rejects a non-applied object", () => {
|
|
66
|
+
expect(asAppliedCard({ __confirm: true })).toBeUndefined();
|
|
67
|
+
expect(asAppliedCard({ __applied: true, toolName: 1 })).toBeUndefined();
|
|
68
|
+
expect(asAppliedCard(null)).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("chunkToEvent", () => {
|
|
73
|
+
test("maps text-delta and text chunks", () => {
|
|
74
|
+
expect(chunkToEvent({ type: "text-delta", delta: "Hi" })).toEqual({
|
|
75
|
+
type: "text-delta",
|
|
76
|
+
delta: "Hi",
|
|
77
|
+
});
|
|
78
|
+
expect(chunkToEvent({ type: "text", text: "Yo" })).toEqual({
|
|
79
|
+
type: "text-delta",
|
|
80
|
+
delta: "Yo",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("maps a tool-input-available chunk to a tool-call event", () => {
|
|
85
|
+
expect(
|
|
86
|
+
chunkToEvent({
|
|
87
|
+
type: "tool-input-available",
|
|
88
|
+
toolCallId: "c1",
|
|
89
|
+
toolName: "incident.list",
|
|
90
|
+
}),
|
|
91
|
+
).toEqual({ type: "tool-call", toolCallId: "c1", toolName: "incident.list" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("maps a tool-input-start chunk to a tool-call event", () => {
|
|
95
|
+
expect(
|
|
96
|
+
chunkToEvent({
|
|
97
|
+
type: "tool-input-start",
|
|
98
|
+
toolCallId: "c2",
|
|
99
|
+
toolName: "listCapabilities",
|
|
100
|
+
}),
|
|
101
|
+
).toEqual({ type: "tool-call", toolCallId: "c2", toolName: "listCapabilities" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("maps a tool output carrying a confirm card to a confirm-card event", () => {
|
|
105
|
+
const event = chunkToEvent({
|
|
106
|
+
type: "tool-output-available",
|
|
107
|
+
toolCallId: "c3",
|
|
108
|
+
output: {
|
|
109
|
+
__confirm: true,
|
|
110
|
+
toolName: "automation.propose",
|
|
111
|
+
effect: "mutate",
|
|
112
|
+
summary: "do it",
|
|
113
|
+
token: "propose:1.2",
|
|
114
|
+
payload: {},
|
|
115
|
+
expiresAt: "2026-06-01T00:10:00Z",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
expect(event).toMatchObject({ type: "confirm-card", toolCallId: "c3" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a read tool output (no card) yields a tool-result event", () => {
|
|
122
|
+
expect(
|
|
123
|
+
chunkToEvent({
|
|
124
|
+
type: "tool-output-available",
|
|
125
|
+
toolCallId: "c4",
|
|
126
|
+
output: { rows: [] },
|
|
127
|
+
}),
|
|
128
|
+
).toEqual({ type: "tool-result", toolCallId: "c4" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("maps a tool output carrying an auto-applied result to an applied-card event", () => {
|
|
132
|
+
const event = chunkToEvent({
|
|
133
|
+
type: "tool-output-available",
|
|
134
|
+
toolCallId: "c7",
|
|
135
|
+
output: {
|
|
136
|
+
__applied: true,
|
|
137
|
+
toolName: "automation.update",
|
|
138
|
+
summary: "Updated",
|
|
139
|
+
result: { id: "a1" },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(event).toMatchObject({ type: "applied-card", toolCallId: "c7" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("maps a tool-output-error chunk to a tool-error event", () => {
|
|
146
|
+
expect(
|
|
147
|
+
chunkToEvent({
|
|
148
|
+
type: "tool-output-error",
|
|
149
|
+
toolCallId: "c5",
|
|
150
|
+
errorText: "Not allowed to read the automation capability catalog",
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({
|
|
153
|
+
type: "tool-error",
|
|
154
|
+
toolCallId: "c5",
|
|
155
|
+
message: "Not allowed to read the automation capability catalog",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("maps a tool-input-error chunk to a tool-error event with the tool name", () => {
|
|
160
|
+
expect(
|
|
161
|
+
chunkToEvent({
|
|
162
|
+
type: "tool-input-error",
|
|
163
|
+
toolCallId: "c6",
|
|
164
|
+
toolName: "getCapabilitySchema",
|
|
165
|
+
errorText: "invalid input",
|
|
166
|
+
}),
|
|
167
|
+
).toEqual({
|
|
168
|
+
type: "tool-error",
|
|
169
|
+
toolCallId: "c6",
|
|
170
|
+
toolName: "getCapabilitySchema",
|
|
171
|
+
message: "invalid input",
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("maps an error chunk", () => {
|
|
176
|
+
expect(chunkToEvent({ type: "error", errorText: "boom" })).toEqual({
|
|
177
|
+
type: "error",
|
|
178
|
+
message: "boom",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("ignores unknown chunk types", () => {
|
|
183
|
+
expect(chunkToEvent({ type: "start" })).toBeUndefined();
|
|
184
|
+
expect(chunkToEvent(42)).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("parseSseBuffer", () => {
|
|
189
|
+
test("extracts complete data payloads and keeps the partial remainder", () => {
|
|
190
|
+
const buf =
|
|
191
|
+
'data: {"type":"text-delta","delta":"a"}\n\n' +
|
|
192
|
+
'data: {"type":"text-delta","delta":"b"}\n\n' +
|
|
193
|
+
'data: {"type":"text-delta","del'; // incomplete trailing event
|
|
194
|
+
const { payloads, rest } = parseSseBuffer(buf);
|
|
195
|
+
expect(payloads).toHaveLength(2);
|
|
196
|
+
expect(rest).toContain('"del');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("surfaces the [DONE] sentinel", () => {
|
|
200
|
+
const { payloads } = parseSseBuffer("data: [DONE]\n\n");
|
|
201
|
+
expect(payloads).toEqual(["[DONE]"]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** Build a ReadableStream of UTF-8 bytes from string chunks. */
|
|
206
|
+
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
|
207
|
+
const encoder = new TextEncoder();
|
|
208
|
+
let i = 0;
|
|
209
|
+
return new ReadableStream<Uint8Array>({
|
|
210
|
+
pull(controller) {
|
|
211
|
+
if (i < chunks.length) {
|
|
212
|
+
controller.enqueue(encoder.encode(chunks[i++]));
|
|
213
|
+
} else {
|
|
214
|
+
controller.close();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe("readChatStream (DOM-free)", () => {
|
|
221
|
+
test("yields text deltas, a confirm card, then done across chunk boundaries", async () => {
|
|
222
|
+
const stream = streamOf([
|
|
223
|
+
'data: {"type":"text-delta","delta":"Hello "}\n\n',
|
|
224
|
+
'data: {"type":"text-de', // split mid-event
|
|
225
|
+
'lta","delta":"world"}\n\n',
|
|
226
|
+
'data: {"type":"tool-output-available","output":{"__confirm":true,"toolName":"automation.propose","effect":"mutate","summary":"s","token":"propose:1.2","payload":{},"expiresAt":"2026-06-01T00:10:00Z"}}\n\n',
|
|
227
|
+
"data: [DONE]\n\n",
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const events: ChatStreamEvent[] = [];
|
|
231
|
+
for await (const e of readChatStream(stream)) events.push(e);
|
|
232
|
+
|
|
233
|
+
const text = events
|
|
234
|
+
.filter((e): e is { type: "text-delta"; delta: string } => e.type === "text-delta")
|
|
235
|
+
.map((e) => e.delta)
|
|
236
|
+
.join("");
|
|
237
|
+
expect(text).toBe("Hello world");
|
|
238
|
+
expect(events.some((e) => e.type === "confirm-card")).toBe(true);
|
|
239
|
+
expect(events.at(-1)?.type).toBe("done");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-free parser for the AI-SDK UI message SSE stream (Phase 4 chat).
|
|
3
|
+
*
|
|
4
|
+
* The backend streams the Vercel-AI-SDK UI message stream as Server-Sent
|
|
5
|
+
* Events: lines of `data: <json>` separated by blank lines. This module turns a
|
|
6
|
+
* byte stream into incremental, typed chat events WITHOUT touching the DOM, so
|
|
7
|
+
* the streaming logic is unit-testable under `bun test` (which CI runs from the
|
|
8
|
+
* repo root WITHOUT happy-dom). React components consume these events; they are
|
|
9
|
+
* never tested via render.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One changed field in a before -> after diff (mirrors ai-common AiFieldDiff). */
|
|
13
|
+
export interface FieldDiff {
|
|
14
|
+
path: string;
|
|
15
|
+
before: unknown;
|
|
16
|
+
after: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A confirm card emitted when a mutate/destructive tool was proposed. */
|
|
20
|
+
export interface ConfirmCard {
|
|
21
|
+
toolName: string;
|
|
22
|
+
effect: "mutate" | "destructive";
|
|
23
|
+
summary: string;
|
|
24
|
+
token: string;
|
|
25
|
+
payload: unknown;
|
|
26
|
+
/** Optional before -> after diff for an update proposal. */
|
|
27
|
+
diff?: FieldDiff[];
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An "applied" card emitted when a mutate tool AUTO-APPLIED (auto mode): the
|
|
33
|
+
* change already took effect, so it is read-only. Surfaced so the operator
|
|
34
|
+
* ALWAYS sees what changed/created, even when no confirmation was required.
|
|
35
|
+
*/
|
|
36
|
+
export interface AppliedCard {
|
|
37
|
+
toolName: string;
|
|
38
|
+
summary: string;
|
|
39
|
+
/** Optional before -> after diff for an auto-applied update. */
|
|
40
|
+
diff?: FieldDiff[];
|
|
41
|
+
/** The applied result (e.g. the created/updated object), for display. */
|
|
42
|
+
result: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Incremental events surfaced to the chat UI. Tool events carry the SDK's
|
|
47
|
+
* `toolCallId` so the reducer can correlate a tool call with its later result /
|
|
48
|
+
* error / confirm-card chunk and render them as one ordered, in-place part.
|
|
49
|
+
*/
|
|
50
|
+
export type ChatStreamEvent =
|
|
51
|
+
| { type: "text-delta"; delta: string }
|
|
52
|
+
| { type: "tool-call"; toolCallId: string; toolName: string }
|
|
53
|
+
| { type: "tool-result"; toolCallId: string }
|
|
54
|
+
| { type: "tool-error"; toolCallId: string; toolName?: string; message: string }
|
|
55
|
+
| { type: "confirm-card"; toolCallId: string; card: ConfirmCard }
|
|
56
|
+
| { type: "applied-card"; toolCallId: string; card: AppliedCard }
|
|
57
|
+
| { type: "error"; message: string }
|
|
58
|
+
| { type: "done" };
|
|
59
|
+
|
|
60
|
+
/** True when the value is a record (object), narrowing `unknown` safely. */
|
|
61
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return typeof value === "object" && value !== null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Narrow a parsed JSON value into a FieldDiff[] if it has the shape. */
|
|
66
|
+
function asFieldDiff(value: unknown): FieldDiff[] | undefined {
|
|
67
|
+
if (!Array.isArray(value)) return undefined;
|
|
68
|
+
const diffs: FieldDiff[] = [];
|
|
69
|
+
for (const entry of value) {
|
|
70
|
+
if (isRecord(entry) && typeof entry.path === "string") {
|
|
71
|
+
diffs.push({ path: entry.path, before: entry.before, after: entry.after });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return diffs.length > 0 ? diffs : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Narrow a parsed JSON object into a ConfirmCard if it has the shape. */
|
|
78
|
+
export function asConfirmCard(value: unknown): ConfirmCard | undefined {
|
|
79
|
+
if (!isRecord(value)) return undefined;
|
|
80
|
+
if (value.__confirm !== true) return undefined;
|
|
81
|
+
const { toolName, effect, summary, token, payload, expiresAt } = value;
|
|
82
|
+
if (
|
|
83
|
+
typeof toolName !== "string" ||
|
|
84
|
+
(effect !== "mutate" && effect !== "destructive") ||
|
|
85
|
+
typeof summary !== "string" ||
|
|
86
|
+
typeof token !== "string" ||
|
|
87
|
+
typeof expiresAt !== "string"
|
|
88
|
+
) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
toolName,
|
|
93
|
+
effect,
|
|
94
|
+
summary,
|
|
95
|
+
token,
|
|
96
|
+
payload,
|
|
97
|
+
diff: asFieldDiff(value.diff),
|
|
98
|
+
expiresAt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Narrow a parsed JSON object into an AppliedCard (auto-applied result). */
|
|
103
|
+
export function asAppliedCard(value: unknown): AppliedCard | undefined {
|
|
104
|
+
if (!isRecord(value)) return undefined;
|
|
105
|
+
if (value.__applied !== true) return undefined;
|
|
106
|
+
const { toolName, summary, result } = value;
|
|
107
|
+
if (typeof toolName !== "string" || typeof summary !== "string") {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return { toolName, summary, result, diff: asFieldDiff(value.diff) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read a chunk's `toolCallId` (empty string when absent — defensive). */
|
|
114
|
+
function asToolCallId(chunk: Record<string, unknown>): string {
|
|
115
|
+
return typeof chunk.toolCallId === "string" ? chunk.toolCallId : "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read a chunk's `errorText`, falling back to a generic message. */
|
|
119
|
+
function asErrorText(chunk: Record<string, unknown>, fallback: string): string {
|
|
120
|
+
return typeof chunk.errorText === "string" ? chunk.errorText : fallback;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Map a single decoded AI-SDK UI-message-stream chunk to a chat event.
|
|
125
|
+
*
|
|
126
|
+
* The AI SDK chunk shapes used here (v6 UI message stream):
|
|
127
|
+
* - `{ type: "text-delta", delta }` / `{ type: "text", text }` -> text
|
|
128
|
+
* - `{ type: "tool-input-start" | "tool-input-available", toolCallId, toolName }`
|
|
129
|
+
* -> a tool was called (de-duplicated by id in the reducer).
|
|
130
|
+
* - `{ type: "tool-output-available", toolCallId, output }` -> a tool result; a
|
|
131
|
+
* confirm-card return value is detected via `asConfirmCard`, otherwise it is a
|
|
132
|
+
* plain read result that just marks the tool call done.
|
|
133
|
+
* - `{ type: "tool-output-error", toolCallId, errorText }` -> a tool's `execute`
|
|
134
|
+
* THREW. Without this branch a failing read tool (e.g. `listCapabilities`)
|
|
135
|
+
* produced a silent EMPTY bubble: the SDK never routes a tool error through
|
|
136
|
+
* the stream's `onError` (that is for stream/provider failures), so it would
|
|
137
|
+
* be dropped entirely.
|
|
138
|
+
* - `{ type: "tool-input-error", toolCallId, toolName, errorText }` -> the model
|
|
139
|
+
* produced invalid tool arguments; surfaced the same way.
|
|
140
|
+
* - `{ type: "error", errorText }` -> a stream/provider error.
|
|
141
|
+
* Unknown chunk types yield `undefined` (ignored).
|
|
142
|
+
*/
|
|
143
|
+
export function chunkToEvent(chunk: unknown): ChatStreamEvent | undefined {
|
|
144
|
+
if (!isRecord(chunk)) return undefined;
|
|
145
|
+
const type = chunk.type;
|
|
146
|
+
|
|
147
|
+
if (type === "text-delta" && typeof chunk.delta === "string") {
|
|
148
|
+
return { type: "text-delta", delta: chunk.delta };
|
|
149
|
+
}
|
|
150
|
+
if (type === "text" && typeof chunk.text === "string") {
|
|
151
|
+
return { type: "text-delta", delta: chunk.text };
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
(type === "tool-input-available" ||
|
|
155
|
+
type === "tool-input-start" ||
|
|
156
|
+
type === "tool-call") &&
|
|
157
|
+
typeof chunk.toolName === "string"
|
|
158
|
+
) {
|
|
159
|
+
return {
|
|
160
|
+
type: "tool-call",
|
|
161
|
+
toolCallId: asToolCallId(chunk),
|
|
162
|
+
toolName: chunk.toolName,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// The model produced invalid tool arguments (input never became available).
|
|
166
|
+
if (type === "tool-input-error") {
|
|
167
|
+
return {
|
|
168
|
+
type: "tool-error",
|
|
169
|
+
toolCallId: asToolCallId(chunk),
|
|
170
|
+
toolName: typeof chunk.toolName === "string" ? chunk.toolName : undefined,
|
|
171
|
+
message: asErrorText(chunk, "Invalid tool input"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// Tool result chunks: a confirm card, otherwise a plain read result.
|
|
175
|
+
if (
|
|
176
|
+
type === "tool-output-available" ||
|
|
177
|
+
type === "tool-result" ||
|
|
178
|
+
type === "tool-output"
|
|
179
|
+
) {
|
|
180
|
+
const output =
|
|
181
|
+
"output" in chunk ? chunk.output : "result" in chunk ? chunk.result : undefined;
|
|
182
|
+
const card = asConfirmCard(output);
|
|
183
|
+
if (card) {
|
|
184
|
+
return { type: "confirm-card", toolCallId: asToolCallId(chunk), card };
|
|
185
|
+
}
|
|
186
|
+
const applied = asAppliedCard(output);
|
|
187
|
+
if (applied) {
|
|
188
|
+
return { type: "applied-card", toolCallId: asToolCallId(chunk), card: applied };
|
|
189
|
+
}
|
|
190
|
+
return { type: "tool-result", toolCallId: asToolCallId(chunk) };
|
|
191
|
+
}
|
|
192
|
+
// A tool's `execute` threw (e.g. an authz/read failure inside the tool).
|
|
193
|
+
if (type === "tool-output-error") {
|
|
194
|
+
return {
|
|
195
|
+
type: "tool-error",
|
|
196
|
+
toolCallId: asToolCallId(chunk),
|
|
197
|
+
message: asErrorText(chunk, "Tool failed"),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (type === "error") {
|
|
201
|
+
const message =
|
|
202
|
+
typeof chunk.errorText === "string"
|
|
203
|
+
? chunk.errorText
|
|
204
|
+
: typeof chunk.error === "string"
|
|
205
|
+
? chunk.error
|
|
206
|
+
: "Stream error";
|
|
207
|
+
return { type: "error", message };
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Split a buffer of SSE text into complete `data:` JSON payloads, returning the
|
|
214
|
+
* parsed payloads plus the unconsumed remainder (an incomplete trailing event).
|
|
215
|
+
* SSE events are separated by a blank line; each event has one or more
|
|
216
|
+
* `data: ...` lines. The `[DONE]` sentinel is surfaced as `done`.
|
|
217
|
+
*/
|
|
218
|
+
export function parseSseBuffer(buffer: string): {
|
|
219
|
+
payloads: Array<unknown | "[DONE]">;
|
|
220
|
+
rest: string;
|
|
221
|
+
} {
|
|
222
|
+
const payloads: Array<unknown | "[DONE]"> = [];
|
|
223
|
+
// Events end at a blank line (\n\n). Keep the trailing partial event in `rest`.
|
|
224
|
+
const parts = buffer.split("\n\n");
|
|
225
|
+
const rest = parts.pop() ?? "";
|
|
226
|
+
for (const event of parts) {
|
|
227
|
+
for (const line of event.split("\n")) {
|
|
228
|
+
const trimmed = line.trimStart();
|
|
229
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
230
|
+
const data = trimmed.slice("data:".length).trim();
|
|
231
|
+
if (data === "[DONE]") {
|
|
232
|
+
payloads.push("[DONE]");
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!data) continue;
|
|
236
|
+
try {
|
|
237
|
+
payloads.push(JSON.parse(data));
|
|
238
|
+
} catch {
|
|
239
|
+
// Ignore malformed JSON lines (defensive).
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { payloads, rest };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Consume a ReadableStream of SSE bytes, yielding {@link ChatStreamEvent}s. Pure
|
|
248
|
+
* w.r.t. the DOM — it only needs a `ReadableStream<Uint8Array>` (as returned by
|
|
249
|
+
* `fetch().body`) and `TextDecoder`, both available in node/bun and the browser.
|
|
250
|
+
*/
|
|
251
|
+
export async function* readChatStream(
|
|
252
|
+
stream: ReadableStream<Uint8Array>,
|
|
253
|
+
): AsyncGenerator<ChatStreamEvent> {
|
|
254
|
+
const reader = stream.getReader();
|
|
255
|
+
const decoder = new TextDecoder();
|
|
256
|
+
let buffer = "";
|
|
257
|
+
try {
|
|
258
|
+
for (;;) {
|
|
259
|
+
const { value, done } = await reader.read();
|
|
260
|
+
if (done) break;
|
|
261
|
+
buffer += decoder.decode(value, { stream: true });
|
|
262
|
+
const { payloads, rest } = parseSseBuffer(buffer);
|
|
263
|
+
buffer = rest;
|
|
264
|
+
for (const payload of payloads) {
|
|
265
|
+
if (payload === "[DONE]") {
|
|
266
|
+
yield { type: "done" };
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const event = chunkToEvent(payload);
|
|
270
|
+
if (event) yield event;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Flush any trailing complete event left in the buffer.
|
|
274
|
+
const { payloads } = parseSseBuffer(buffer + "\n\n");
|
|
275
|
+
for (const payload of payloads) {
|
|
276
|
+
if (payload === "[DONE]") {
|
|
277
|
+
yield { type: "done" };
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const event = chunkToEvent(payload);
|
|
281
|
+
if (event) yield event;
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
reader.releaseLock();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useApi, fetchApiRef } from "@checkstack/frontend-api";
|
|
3
|
+
import { pluginMetadata } from "@checkstack/ai-common";
|
|
4
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
5
|
+
import {
|
|
6
|
+
appendUserMessage,
|
|
7
|
+
startAssistantMessage,
|
|
8
|
+
applyStreamEvent,
|
|
9
|
+
finishAssistantMessage,
|
|
10
|
+
type ChatMessage,
|
|
11
|
+
} from "./chat-state";
|
|
12
|
+
import { readChatStream } from "./stream-parser";
|
|
13
|
+
|
|
14
|
+
/** Generate a client-side message id (crypto.randomUUID is widely available). */
|
|
15
|
+
function newId(): string {
|
|
16
|
+
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
17
|
+
? crypto.randomUUID()
|
|
18
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* React hook that drives ONE streaming chat turn against /api/ai/chat. The
|
|
23
|
+
* heavy lifting (SSE parsing, state folding) lives in DOM-free, unit-tested
|
|
24
|
+
* modules (`stream-parser`, `chat-state`); this hook only wires them to React
|
|
25
|
+
* state and the platform `fetch` (which carries auth + base URL). Credentials
|
|
26
|
+
* never reach the browser — the response is a token/tool-event stream only.
|
|
27
|
+
*/
|
|
28
|
+
export function useChatTurn({
|
|
29
|
+
initialMessages = [],
|
|
30
|
+
}: {
|
|
31
|
+
initialMessages?: ChatMessage[];
|
|
32
|
+
} = {}) {
|
|
33
|
+
const fetchApi = useApi(fetchApiRef);
|
|
34
|
+
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
|
35
|
+
const [streaming, setStreaming] = useState(false);
|
|
36
|
+
const [error, setError] = useState<string | undefined>();
|
|
37
|
+
const aborter = useRef<AbortController | undefined>();
|
|
38
|
+
|
|
39
|
+
// Shared streaming driver: POST `body` to /chat, start a fresh assistant
|
|
40
|
+
// message (optionally appending a user message first), and fold the SSE
|
|
41
|
+
// events into it. Used by both `send` (a user turn) and `sendDecision` (a
|
|
42
|
+
// confirm-card acknowledgment, which has no user bubble).
|
|
43
|
+
const runStream = useCallback(
|
|
44
|
+
async ({
|
|
45
|
+
body,
|
|
46
|
+
userText,
|
|
47
|
+
}: {
|
|
48
|
+
body: Record<string, unknown>;
|
|
49
|
+
userText?: string;
|
|
50
|
+
}) => {
|
|
51
|
+
setError(undefined);
|
|
52
|
+
setStreaming(true);
|
|
53
|
+
setMessages((prev) => {
|
|
54
|
+
const base =
|
|
55
|
+
userText === undefined
|
|
56
|
+
? prev
|
|
57
|
+
: appendUserMessage({ messages: prev, id: newId(), text: userText });
|
|
58
|
+
return startAssistantMessage({ messages: base, id: newId() });
|
|
59
|
+
});
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
aborter.current = controller;
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetchApi
|
|
64
|
+
.forPlugin(pluginMetadata.pluginId)
|
|
65
|
+
.fetch("/chat", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok || !response.body) {
|
|
72
|
+
const detail = await response.text().catch(() => "");
|
|
73
|
+
throw new Error(detail || `Chat failed (${response.status})`);
|
|
74
|
+
}
|
|
75
|
+
for await (const event of readChatStream(response.body)) {
|
|
76
|
+
// An in-stream error (the server's onError surfaced a provider/stream
|
|
77
|
+
// failure, e.g. a 400 `invalid_prompt`) arrives as a normal stream
|
|
78
|
+
// event, NOT a thrown exception, so the catch below never runs. Lift
|
|
79
|
+
// it into the durable `error` state so a post-turn message refetch
|
|
80
|
+
// (which re-hydrates from the persisted transcript that never captured
|
|
81
|
+
// this failed turn) cannot wipe it from view.
|
|
82
|
+
if (event.type === "error") setError(event.message);
|
|
83
|
+
setMessages((prev) => applyStreamEvent({ messages: prev, event }));
|
|
84
|
+
}
|
|
85
|
+
} catch (error_) {
|
|
86
|
+
const message = extractErrorMessage(error_, "Chat turn failed");
|
|
87
|
+
setError(message);
|
|
88
|
+
setMessages((prev) =>
|
|
89
|
+
applyStreamEvent({ messages: prev, event: { type: "error", message } }),
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
setMessages((prev) => finishAssistantMessage({ messages: prev }));
|
|
93
|
+
setStreaming(false);
|
|
94
|
+
aborter.current = undefined;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[fetchApi],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const send = useCallback(
|
|
101
|
+
({
|
|
102
|
+
conversationId,
|
|
103
|
+
connectionId,
|
|
104
|
+
model,
|
|
105
|
+
text,
|
|
106
|
+
}: {
|
|
107
|
+
conversationId: string;
|
|
108
|
+
connectionId: string;
|
|
109
|
+
model?: string;
|
|
110
|
+
text: string;
|
|
111
|
+
}) =>
|
|
112
|
+
runStream({
|
|
113
|
+
body: { conversationId, connectionId, model, message: text },
|
|
114
|
+
userText: text,
|
|
115
|
+
}),
|
|
116
|
+
[runStream],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Stream the model's acknowledgment after the operator applies/declines a
|
|
120
|
+
// confirm card. The actual apply ran via `applyTool`; this only makes the
|
|
121
|
+
// model react (no user bubble is added).
|
|
122
|
+
const sendDecision = useCallback(
|
|
123
|
+
({
|
|
124
|
+
conversationId,
|
|
125
|
+
connectionId,
|
|
126
|
+
model,
|
|
127
|
+
token,
|
|
128
|
+
decision,
|
|
129
|
+
}: {
|
|
130
|
+
conversationId: string;
|
|
131
|
+
connectionId: string;
|
|
132
|
+
model?: string;
|
|
133
|
+
token: string;
|
|
134
|
+
decision: "apply" | "decline";
|
|
135
|
+
}) =>
|
|
136
|
+
runStream({
|
|
137
|
+
body: {
|
|
138
|
+
conversationId,
|
|
139
|
+
connectionId,
|
|
140
|
+
model,
|
|
141
|
+
decision: { token, kind: decision },
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
[runStream],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const stop = useCallback(() => {
|
|
148
|
+
aborter.current?.abort();
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const clearError = useCallback(() => setError(undefined), []);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
messages,
|
|
155
|
+
setMessages,
|
|
156
|
+
streaming,
|
|
157
|
+
error,
|
|
158
|
+
send,
|
|
159
|
+
sendDecision,
|
|
160
|
+
stop,
|
|
161
|
+
clearError,
|
|
162
|
+
};
|
|
163
|
+
}
|