2026-05-14 20:05:38 +03:00
|
|
|
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
2026-02-27 19:07:30 +03:00
|
|
|
|
|
|
|
|
export type StreamChatCallbacks = {
|
2026-06-12 15:48:43 +03:00
|
|
|
/** Streamed delta of the assistant's final answer text. */
|
|
|
|
|
onContentDelta: (delta: string) => void;
|
|
|
|
|
/** Streamed delta of the assistant's reasoning trace. */
|
|
|
|
|
onReasoningDelta: (delta: string) => void;
|
|
|
|
|
/** The full conversation chain so far (system message, history, this turn's
|
|
|
|
|
* tool-call turns, tool results, and — on the final emission — the final
|
|
|
|
|
* assistant message). */
|
|
|
|
|
onChain: (chain: ChatMessage[]) => void;
|
|
|
|
|
/** Token/timing stats for the turn. */
|
|
|
|
|
onStats: (stats: ChatStats) => void;
|
2026-02-27 19:07:30 +03:00
|
|
|
/** Called when the stream sends an error or fetch fails. */
|
|
|
|
|
onError: (message: string) => void;
|
|
|
|
|
/** Called when the stream finishes (success or error). */
|
|
|
|
|
onDone: () => void;
|
|
|
|
|
/** Message used when fetch throws and no server error is available. */
|
|
|
|
|
defaultErrorMessage?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 20:05:38 +03:00
|
|
|
type StatsChunk = {
|
|
|
|
|
type: "stats";
|
|
|
|
|
prompt_tokens?: number;
|
|
|
|
|
completion_tokens?: number;
|
|
|
|
|
completion_duration_ms?: number;
|
|
|
|
|
tokens_per_second?: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 19:07:30 +03:00
|
|
|
type StreamChunk =
|
|
|
|
|
| { type: "error"; error: string }
|
2026-06-12 15:48:43 +03:00
|
|
|
| { type: "messages"; messages: ChatMessage[] }
|
2026-05-14 20:05:38 +03:00
|
|
|
| { type: "content"; delta: string }
|
2026-05-19 21:03:57 +03:00
|
|
|
| { type: "reasoning"; delta: string }
|
2026-05-14 20:05:38 +03:00
|
|
|
| StatsChunk;
|
2026-02-27 19:07:30 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST to chat/completion with stream: true, parse NDJSON stream, and invoke
|
|
|
|
|
* callbacks so the caller can update UI (e.g. React state).
|
|
|
|
|
*/
|
2026-05-21 20:54:23 +03:00
|
|
|
export type StreamChatOptions = {
|
|
|
|
|
enableThinking?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 19:07:30 +03:00
|
|
|
export async function streamChatCompletion(
|
|
|
|
|
url: string,
|
|
|
|
|
headers: Record<string, string>,
|
2026-06-12 15:48:43 +03:00
|
|
|
apiMessages: ChatMessage[],
|
2026-02-27 19:07:30 +03:00
|
|
|
callbacks: StreamChatCallbacks,
|
2026-04-09 23:31:37 +03:00
|
|
|
signal?: AbortSignal,
|
2026-05-21 20:54:23 +03:00
|
|
|
options: StreamChatOptions = {},
|
2026-02-27 19:07:30 +03:00
|
|
|
): Promise<void> {
|
|
|
|
|
const {
|
2026-06-12 15:48:43 +03:00
|
|
|
onContentDelta,
|
|
|
|
|
onReasoningDelta,
|
|
|
|
|
onChain,
|
|
|
|
|
onStats,
|
2026-02-27 19:07:30 +03:00
|
|
|
onError,
|
|
|
|
|
onDone,
|
|
|
|
|
defaultErrorMessage = "Something went wrong. Please try again.",
|
|
|
|
|
} = callbacks;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-21 20:54:23 +03:00
|
|
|
const body: Record<string, unknown> = {
|
|
|
|
|
messages: apiMessages,
|
|
|
|
|
stream: true,
|
|
|
|
|
};
|
|
|
|
|
if (options.enableThinking !== undefined) {
|
|
|
|
|
body.enable_thinking = options.enableThinking;
|
|
|
|
|
}
|
2026-02-27 19:07:30 +03:00
|
|
|
const res = await fetch(url, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers,
|
2026-05-21 20:54:23 +03:00
|
|
|
body: JSON.stringify(body),
|
2026-04-09 23:31:37 +03:00
|
|
|
signal,
|
2026-02-27 19:07:30 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errBody = await res.json().catch(() => ({}));
|
|
|
|
|
const message = (errBody as { error?: string }).error ?? res.statusText;
|
|
|
|
|
onError(message);
|
|
|
|
|
onDone();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reader = res.body?.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
if (!reader) {
|
|
|
|
|
onError("No response body");
|
|
|
|
|
onDone();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let buffer = "";
|
|
|
|
|
let hadStreamError = false;
|
|
|
|
|
|
|
|
|
|
const applyChunk = (data: StreamChunk) => {
|
|
|
|
|
if (data.type === "error") {
|
|
|
|
|
onError(data.error);
|
|
|
|
|
return "break";
|
|
|
|
|
}
|
2026-06-12 15:48:43 +03:00
|
|
|
if (data.type === "messages") {
|
|
|
|
|
onChain(data.messages ?? []);
|
2026-02-27 19:07:30 +03:00
|
|
|
return "continue";
|
|
|
|
|
}
|
|
|
|
|
if (data.type === "content" && data.delta !== undefined) {
|
2026-06-12 15:48:43 +03:00
|
|
|
onContentDelta(data.delta);
|
2026-02-27 19:07:30 +03:00
|
|
|
return "continue";
|
|
|
|
|
}
|
2026-05-19 21:03:57 +03:00
|
|
|
if (data.type === "reasoning" && data.delta !== undefined) {
|
2026-06-12 15:48:43 +03:00
|
|
|
onReasoningDelta(data.delta);
|
2026-05-19 21:03:57 +03:00
|
|
|
return "continue";
|
|
|
|
|
}
|
2026-05-14 20:05:38 +03:00
|
|
|
if (data.type === "stats") {
|
2026-06-12 15:48:43 +03:00
|
|
|
onStats({
|
2026-05-14 20:05:38 +03:00
|
|
|
promptTokens: data.prompt_tokens,
|
|
|
|
|
completionTokens: data.completion_tokens,
|
|
|
|
|
completionDurationMs: data.completion_duration_ms,
|
|
|
|
|
tokensPerSecond: data.tokens_per_second,
|
|
|
|
|
});
|
|
|
|
|
return "continue";
|
|
|
|
|
}
|
2026-02-27 19:07:30 +03:00
|
|
|
return "continue";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
const lines = buffer.split("\n");
|
|
|
|
|
buffer = lines.pop() ?? "";
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
if (!trimmed) continue;
|
|
|
|
|
try {
|
2026-06-12 15:48:43 +03:00
|
|
|
const data = JSON.parse(trimmed) as StreamChunk;
|
|
|
|
|
if (applyChunk(data) === "break") {
|
2026-02-27 19:07:30 +03:00
|
|
|
hadStreamError = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// skip malformed JSON lines
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (hadStreamError) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flush remaining buffer
|
|
|
|
|
if (!hadStreamError && buffer.trim()) {
|
|
|
|
|
try {
|
2026-06-12 15:48:43 +03:00
|
|
|
const data = JSON.parse(buffer.trim()) as StreamChunk;
|
|
|
|
|
applyChunk(data);
|
2026-02-27 19:07:30 +03:00
|
|
|
} catch {
|
|
|
|
|
// ignore final malformed chunk
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-09 23:31:37 +03:00
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
|
|
|
// User stopped generation — not an error
|
|
|
|
|
} else {
|
|
|
|
|
onError(defaultErrorMessage);
|
|
|
|
|
}
|
2026-02-27 19:07:30 +03:00
|
|
|
} finally {
|
|
|
|
|
onDone();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 15:48:43 +03:00
|
|
|
/** Map each tool result message to its tool_call_id for response lookup. */
|
|
|
|
|
export function toolResponsesById(
|
|
|
|
|
messages: ChatMessage[],
|
|
|
|
|
): Map<string, string> {
|
|
|
|
|
const map = new Map<string, string>();
|
|
|
|
|
for (const m of messages) {
|
|
|
|
|
if (m.role === "tool" && typeof m.tool_call_id === "string") {
|
|
|
|
|
map.set(
|
|
|
|
|
m.tool_call_id,
|
|
|
|
|
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Derive the display tool calls for one assistant message. */
|
|
|
|
|
export function toolCallsForMessage(
|
|
|
|
|
message: ChatMessage,
|
|
|
|
|
responses: Map<string, string>,
|
|
|
|
|
): ToolCall[] {
|
|
|
|
|
if (!message.tool_calls?.length) return [];
|
|
|
|
|
return message.tool_calls.map((tc) => {
|
|
|
|
|
let args: Record<string, unknown> | undefined;
|
|
|
|
|
const raw = tc.function?.arguments;
|
|
|
|
|
if (typeof raw === "string") {
|
|
|
|
|
try {
|
|
|
|
|
args = JSON.parse(raw) as Record<string, unknown>;
|
|
|
|
|
} catch {
|
|
|
|
|
args = undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
name: tc.function?.name ?? "",
|
|
|
|
|
arguments: args,
|
|
|
|
|
response: responses.get(tc.id),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 19:07:30 +03:00
|
|
|
/**
|
|
|
|
|
* Parse search_objects tool call response(s) into event ids for thumbnails.
|
|
|
|
|
*/
|
|
|
|
|
export function getEventIdsFromSearchObjectsToolCalls(
|
|
|
|
|
toolCalls: ToolCall[] | undefined,
|
|
|
|
|
): { id: string }[] {
|
|
|
|
|
if (!toolCalls?.length) return [];
|
|
|
|
|
const results: { id: string }[] = [];
|
|
|
|
|
for (const tc of toolCalls) {
|
|
|
|
|
if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(tc.response) as unknown;
|
|
|
|
|
if (!Array.isArray(parsed)) continue;
|
|
|
|
|
for (const item of parsed) {
|
|
|
|
|
if (
|
|
|
|
|
item &&
|
|
|
|
|
typeof item === "object" &&
|
|
|
|
|
"id" in item &&
|
|
|
|
|
typeof (item as { id: unknown }).id === "string"
|
|
|
|
|
) {
|
|
|
|
|
results.push({ id: (item as { id: string }).id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore parse errors
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
2026-04-09 23:31:37 +03:00
|
|
|
|
|
|
|
|
const ATTACHED_EVENT_MARKER = /^\[attached_event:([A-Za-z0-9._-]+)\]\s*\n?/;
|
|
|
|
|
|
|
|
|
|
export function parseAttachedEvent(content: string): {
|
|
|
|
|
eventId: string | null;
|
|
|
|
|
body: string;
|
|
|
|
|
} {
|
|
|
|
|
if (!content) return { eventId: null, body: content };
|
|
|
|
|
const match = content.match(ATTACHED_EVENT_MARKER);
|
|
|
|
|
if (!match) return { eventId: null, body: content };
|
|
|
|
|
const body = content.slice(match[0].length).replace(/^\n+/, "");
|
|
|
|
|
return { eventId: match[1], body };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function prependAttachment(body: string, eventId: string): string {
|
|
|
|
|
return `[attached_event:${eventId}]\n\n${body}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type FindSimilarObjectsResult = {
|
|
|
|
|
anchor: { id: string } | null;
|
|
|
|
|
results: { id: string; score?: number }[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse find_similar_objects tool call response(s) into anchor + ranked results.
|
|
|
|
|
* Returns null if no find_similar_objects call is present so the caller can
|
|
|
|
|
* decide whether to render.
|
|
|
|
|
*/
|
|
|
|
|
export function getFindSimilarObjectsFromToolCalls(
|
|
|
|
|
toolCalls: ToolCall[] | undefined,
|
|
|
|
|
): FindSimilarObjectsResult | null {
|
|
|
|
|
if (!toolCalls?.length) return null;
|
|
|
|
|
for (const tc of toolCalls) {
|
|
|
|
|
if (tc.name !== "find_similar_objects" || !tc.response?.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(tc.response) as {
|
|
|
|
|
anchor?: { id?: unknown };
|
|
|
|
|
results?: unknown;
|
|
|
|
|
};
|
|
|
|
|
const anchorId =
|
|
|
|
|
parsed.anchor && typeof parsed.anchor.id === "string"
|
|
|
|
|
? parsed.anchor.id
|
|
|
|
|
: null;
|
|
|
|
|
const anchor = anchorId ? { id: anchorId } : null;
|
|
|
|
|
const results: { id: string; score?: number }[] = [];
|
|
|
|
|
if (Array.isArray(parsed.results)) {
|
|
|
|
|
for (const item of parsed.results) {
|
|
|
|
|
if (
|
|
|
|
|
item &&
|
|
|
|
|
typeof item === "object" &&
|
|
|
|
|
"id" in item &&
|
|
|
|
|
typeof (item as { id: unknown }).id === "string"
|
|
|
|
|
) {
|
|
|
|
|
const entry: { id: string; score?: number } = {
|
|
|
|
|
id: (item as { id: string }).id,
|
|
|
|
|
};
|
|
|
|
|
const rawScore = (item as { score?: unknown }).score;
|
|
|
|
|
if (typeof rawScore === "number") entry.score = rawScore;
|
|
|
|
|
results.push(entry);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { anchor, results };
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore parse errors
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|