Files
Termix/plugins/ai/src/frontend/use-ai-stream.ts
T

241 lines
7.3 KiB
TypeScript
Raw Normal View History

2026-10-01 16:20:16 -05:00
import { getErrorMessage } from "./errors";
+5
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";
+5
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.
+5
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;
+5
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 } : {}),
}));
};
+5
2026-08-19 14:12:06 -05:00
try {
2026-10-01 16:20:16 -05:00
const response = await aiApp().fetch("chat/stream", {
+5
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 ?? {},
});
+5
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;
}
}
+5
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();
+5
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"),
);
+5
2026-08-19 14:12:06 -05:00
} finally {
2026-10-01 16:20:16 -05:00
if (abortRef.current === controller) abortRef.current = null;
+5
2026-08-19 14:12:06 -05:00
}
},
[],
);
return { state, send, stop, reset, setState };
}