2026-10-01 16:20:16 -05:00
|
|
|
import { getErrorMessage } from "./errors";
|
2026-08-19 14:12:06 -05:00
|
|
|
import { useCallback, useRef, useState } from "react";
|
2026-10-01 16:20:16 -05:00
|
|
|
import { aiApp } from "./app-ref";
|
|
|
|
|
import type { AiProposal } from "./ai-api";
|
2026-08-19 14:12:06 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Drives the chat stream.
|
|
|
|
|
*
|
|
|
|
|
* EventSource cannot POST, and the request carries a message body, so this
|
|
|
|
|
* reads the SSE frames off a fetch response by hand.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export interface ToolActivity {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
arguments: Record<string, unknown>;
|
|
|
|
|
result?: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StreamState {
|
|
|
|
|
streaming: boolean;
|
|
|
|
|
assistantText: string;
|
|
|
|
|
tools: ToolActivity[];
|
|
|
|
|
proposals: AiProposal[];
|
|
|
|
|
error: string | null;
|
|
|
|
|
conversationId: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const INITIAL: StreamState = {
|
|
|
|
|
streaming: false,
|
|
|
|
|
assistantText: "",
|
|
|
|
|
tools: [],
|
|
|
|
|
proposals: [],
|
|
|
|
|
error: null,
|
|
|
|
|
conversationId: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function useAiStream() {
|
|
|
|
|
const [state, setState] = useState<StreamState>(INITIAL);
|
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
|
|
|
|
|
|
const reset = useCallback(() => {
|
|
|
|
|
setState(INITIAL);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-10-01 16:20:16 -05:00
|
|
|
// The run's own cleanup clears abortRef, after it has handed its steps over.
|
2026-08-19 14:12:06 -05:00
|
|
|
const stop = useCallback(() => {
|
|
|
|
|
abortRef.current?.abort();
|
|
|
|
|
setState((prev) => ({ ...prev, streaming: false }));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const send = useCallback(
|
|
|
|
|
async (input: {
|
|
|
|
|
message: string;
|
|
|
|
|
providerId: number;
|
|
|
|
|
model?: string;
|
|
|
|
|
conversationId?: number | null;
|
|
|
|
|
activeTab?: string | null;
|
2026-10-01 16:20:16 -05:00
|
|
|
/**
|
|
|
|
|
* Called however the run ends, with its steps, which are then cleared
|
|
|
|
|
* from the live state. Keeping them is up to the caller.
|
|
|
|
|
*/
|
|
|
|
|
onComplete?: (
|
|
|
|
|
conversationId: number | null,
|
|
|
|
|
reply: string,
|
|
|
|
|
tools: ToolActivity[],
|
|
|
|
|
) => void;
|
2026-08-19 14:12:06 -05:00
|
|
|
}) => {
|
|
|
|
|
abortRef.current?.abort();
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
abortRef.current = controller;
|
|
|
|
|
|
|
|
|
|
setState({
|
|
|
|
|
streaming: true,
|
|
|
|
|
assistantText: "",
|
|
|
|
|
tools: [],
|
|
|
|
|
proposals: [],
|
|
|
|
|
error: null,
|
|
|
|
|
conversationId: input.conversationId ?? null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let conversationId = input.conversationId ?? null;
|
|
|
|
|
let replyText = "";
|
|
|
|
|
let toolSequence = 0;
|
2026-10-01 16:20:16 -05:00
|
|
|
const runTools: ToolActivity[] = [];
|
|
|
|
|
|
|
|
|
|
const finish = (error?: string | null) => {
|
|
|
|
|
// A newer send took over; this run's state is already gone.
|
|
|
|
|
if (abortRef.current !== controller) return;
|
|
|
|
|
input.onComplete?.(conversationId, replyText, runTools);
|
|
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
streaming: false,
|
|
|
|
|
assistantText: "",
|
|
|
|
|
tools: [],
|
|
|
|
|
...(error ? { error } : {}),
|
|
|
|
|
}));
|
|
|
|
|
};
|
2026-08-19 14:12:06 -05:00
|
|
|
|
|
|
|
|
try {
|
2026-10-01 16:20:16 -05:00
|
|
|
const response = await aiApp().fetch("chat/stream", {
|
2026-08-19 14:12:06 -05:00
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
message: input.message,
|
|
|
|
|
providerId: input.providerId,
|
|
|
|
|
model: input.model,
|
|
|
|
|
conversationId: input.conversationId ?? undefined,
|
|
|
|
|
activeTab: input.activeTab ?? undefined,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
let message = "The assistant could not be reached";
|
|
|
|
|
try {
|
|
|
|
|
message = (await response.json()).error ?? message;
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep the generic message.
|
|
|
|
|
}
|
|
|
|
|
setState((prev) => ({ ...prev, streaming: false, error: message }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reader = response.body?.getReader();
|
|
|
|
|
if (!reader) {
|
|
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
streaming: false,
|
|
|
|
|
error: "The assistant returned an empty response",
|
|
|
|
|
}));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let buffer = "";
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
|
|
|
|
|
let newlineIndex = buffer.indexOf("\n");
|
|
|
|
|
while (newlineIndex !== -1) {
|
|
|
|
|
const line = buffer.slice(0, newlineIndex).trim();
|
|
|
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
|
|
|
newlineIndex = buffer.indexOf("\n");
|
|
|
|
|
|
|
|
|
|
if (!line.startsWith("data:")) continue;
|
|
|
|
|
|
|
|
|
|
let event: any;
|
|
|
|
|
try {
|
|
|
|
|
event = JSON.parse(line.slice(5).trim());
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.type === "conversation") {
|
|
|
|
|
conversationId = event.conversationId;
|
|
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
conversationId: event.conversationId,
|
|
|
|
|
}));
|
|
|
|
|
} else if (event.type === "token") {
|
|
|
|
|
replyText += event.text;
|
|
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
assistantText: prev.assistantText + event.text,
|
|
|
|
|
}));
|
|
|
|
|
} else if (event.type === "tool_call") {
|
|
|
|
|
// The id is assigned here rather than inside the updater: React
|
|
|
|
|
// may run an updater more than once, and deriving the id from
|
|
|
|
|
// prev.tools.length appended a duplicate entry that no result
|
|
|
|
|
// could ever match, leaving it stuck on "running".
|
|
|
|
|
const activityId = `tool-${toolSequence++}`;
|
2026-10-01 16:20:16 -05:00
|
|
|
runTools.push({
|
|
|
|
|
id: activityId,
|
|
|
|
|
name: event.name,
|
|
|
|
|
arguments: event.arguments ?? {},
|
|
|
|
|
});
|
2026-08-19 14:12:06 -05:00
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
tools: [
|
|
|
|
|
...prev.tools,
|
|
|
|
|
{
|
|
|
|
|
id: activityId,
|
|
|
|
|
name: event.name,
|
|
|
|
|
arguments: event.arguments ?? {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
} else if (event.type === "tool_result") {
|
2026-10-01 16:20:16 -05:00
|
|
|
for (let i = runTools.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
if (
|
|
|
|
|
runTools[i].name === event.name &&
|
|
|
|
|
!("result" in runTools[i])
|
|
|
|
|
) {
|
|
|
|
|
runTools[i] = { ...runTools[i], result: event.result };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-19 14:12:06 -05:00
|
|
|
setState((prev) => {
|
|
|
|
|
const tools = [...prev.tools];
|
|
|
|
|
// Attach to the most recent call of this tool awaiting a result.
|
|
|
|
|
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
if (tools[i].name === event.name && !("result" in tools[i])) {
|
|
|
|
|
tools[i] = { ...tools[i], result: event.result };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, tools };
|
|
|
|
|
});
|
|
|
|
|
} else if (event.type === "proposal") {
|
|
|
|
|
setState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
proposals: [...prev.proposals, event.proposal],
|
|
|
|
|
}));
|
|
|
|
|
} else if (event.type === "error") {
|
|
|
|
|
setState((prev) => ({ ...prev, error: event.message }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 16:20:16 -05:00
|
|
|
finish();
|
2026-08-19 14:12:06 -05:00
|
|
|
} catch (error) {
|
2026-10-01 16:20:16 -05:00
|
|
|
finish(
|
|
|
|
|
controller.signal.aborted
|
|
|
|
|
? null
|
|
|
|
|
: getErrorMessage(error, "The assistant stopped unexpectedly"),
|
|
|
|
|
);
|
2026-08-19 14:12:06 -05:00
|
|
|
} finally {
|
2026-10-01 16:20:16 -05:00
|
|
|
if (abortRef.current === controller) abortRef.current = null;
|
2026-08-19 14:12:06 -05:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { state, send, stop, reset, setState };
|
|
|
|
|
}
|