Files
openobserve/web/src/composables/assistant/useAssistantClient.ts
T

308 lines
12 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import assistantService, {
type ConfirmBody,
type ConfirmResponse,
type FeedbackBody,
} from "@/services/assistant";
import { refreshSession as httpRefreshSession } from "@/services/http";
import incidentsService, { type Incident } from "@/services/incidents";
import store from "@/stores";
import type { ImageAttachment } from "@/ts/interfaces/chat";
import { getUUID, getUUIDv7 } from "@/utils/uuid";
import { ERROR_LEDGER, toAssistantError } from "./assistantErrors";
import type { AssistantError, AssistantSession, SendRequest, Sid } from "./assistantStore.types";
import type { AssistantFrame } from "./frames";
import { assistantClientId } from "./historyElsewhere";
import { parseStream, type StreamEnd } from "./sseParser";
/** B-13 returns the trace id on admission; until then it comes from `complete`. */
const TRACE_HEADER = "x-o2-trace-id";
/** One prior message of a browser-only chat; the server has no memory of it (constraint 18). */
export interface WireMessage {
role: "user" | "assistant";
content: string;
}
/** A `SendRequest` plus the transcript a chat without server persistence still has to carry. */
export type ClientSendRequest = SendRequest & { transcript?: WireMessage[] };
/** Where a stream's frames go; `send` and `tail` call it until `onEnd`. */
export interface FrameSink {
/** Called before the first frame, so admission is never reduced after a buffered frame. */
onAdmitted(result: Extract<SendResult, { ok: true }>): void;
/** `id` is the last SSE `id:` line: the seq the frame comes after, shared by every frame until the next one (WP-18). */
onFrame(frame: AssistantFrame, id: string | null): void;
/** An id-only message: every frame through seq `id` was sent, so the tail can resume from it (B-03). */
onBoundary?(id: string): void;
onKeepalive(): void;
/** An unknown frame type, already cut to 32 characters for `ai_frame_unknown`. */
onUnknown(type: string): void;
onEnd(reason: StreamEnd): void;
}
/** Resolves on admission; `null` never escapes (S&S D2, BE D2). */
export type SendResult =
| { ok: true; status: number; traceId: string | null }
| { ok: false; error: AssistantError; body?: unknown; aborted?: boolean };
export type TailResult = SendResult;
/** The session fields `stop` needs. */
export type StopTarget = Pick<AssistantSession, "sid" | "origin" | "record"> & {
turnId?: string | null;
};
/** `00` §5.1: the one client every assistant surface uses. */
export interface AssistantClient {
send(req: ClientSendRequest, sink: FrameSink, signal: AbortSignal): Promise<SendResult>;
tail(
org: string,
sid: Sid,
turnId: string,
fromSeq: number,
sink: FrameSink,
signal: AbortSignal,
): Promise<TailResult>;
stop(org: string, session: StopTarget): Promise<void>;
confirm(org: string, sid: Sid, body: ConfirmBody, timeoutMs?: number): Promise<ConfirmResponse>;
feedback(org: string, sid: Sid, body: FeedbackBody): Promise<void>;
}
export interface AssistantClientOptions {
fetchImpl?: typeof fetch;
/** The non-destructive refresh of `services/http.ts`; injected by specs. */
refreshSession?: () => Promise<"refreshed" | "expired">;
services?: Pick<typeof assistantService, "cancelTurn" | "confirm" | "feedback">;
/** API origin; the store's `API_ENDPOINT` by default. */
baseUrl?: () => string;
}
/** An incident and the report of the run its analysis names, as an incident chat attaches them. */
export interface IncidentReportRead {
incident: Pick<
Incident,
"id" | "title" | "severity" | "status" | "first_alert_at" | "last_alert_at" | "resolved_at"
>;
/** Null before any analysis finished, or when its run can't be read. */
run: IncidentReportRun | null;
}
export interface IncidentReportRun {
id: string;
/** The run's number in the incident's history, the oldest being 1; null when unlisted. */
number: number | null;
startedAt: number;
report: string;
}
/** The shared client; streams over `fetch`, everything else through `services/assistant.ts`. */
export function useAssistantClient(opts: AssistantClientOptions = {}): AssistantClient {
const fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
const refresh = opts.refreshSession ?? (() => httpRefreshSession());
const services = opts.services ?? assistantService;
const baseUrl = opts.baseUrl ?? (() => String(store.state.API_ENDPOINT ?? ""));
// One refresh per request and the same turn id on the resend (constraint 16); never the logout path.
const open = async (
request: () => Promise<Response>,
expiredCode: "session_expired" | "signed_out",
turnId: string,
): Promise<Response | SendResult> => {
let response = await request();
if (response.status === 401 && (await refresh()) === "refreshed") response = await request();
if (response.status !== 401) return response;
const error = toAssistantError({ status: 401, body: await readJson(response) }, { turnId });
return { ok: false, error: { ...error, code: expiredCode, ...ERROR_LEDGER[expiredCode] } };
};
const stream = async (
request: () => Promise<Response>,
sink: FrameSink,
signal: AbortSignal,
turnId: string,
expiredCode: "session_expired" | "signed_out",
): Promise<SendResult> => {
let opened: Response | SendResult;
try {
opened = await open(request, expiredCode, turnId);
} catch (err) {
return rejected(err, signal, turnId);
}
if (!(opened instanceof Response)) return opened;
if (!opened.ok) {
const body = await readJson(opened);
const error = toAssistantError({ status: opened.status, body }, { turnId });
return { ok: false, error, body };
}
const admitted = {
ok: true as const,
status: opened.status,
traceId: opened.headers.get(TRACE_HEADER),
};
sink.onAdmitted(admitted);
if (!opened.body) sink.onEnd("eof");
else void parseStream(opened.body, handlersOf(sink), signal).then((end) => sink.onEnd(end));
return admitted;
};
return {
send(req, sink, signal) {
const url = `${baseUrl()}/api/${enc(req.org)}/ai/chat_stream`;
const body = JSON.stringify(wireBody(req));
const request = () =>
fetchImpl(url, {
method: "POST",
body,
credentials: "include",
headers: { ...streamHeaders(req.sid, req.turnId, true), ...clientIdHeader(baseUrl()) },
signal,
});
return stream(request, sink, signal, req.turnId, "session_expired");
},
tail(org, sid, turnId, fromSeq, sink, signal) {
const url =
`${baseUrl()}/api/${enc(org)}/ai/chats/${enc(sid)}/turns/${enc(turnId)}/stream` +
`?from_seq=${fromSeq}`;
const request = () =>
fetchImpl(url, {
method: "GET",
credentials: "include",
headers: { ...streamHeaders(sid, turnId, false), "Last-Event-ID": String(fromSeq) },
signal,
});
// A 401 while a turn already runs means the user signed out elsewhere (`80` §1.3).
return stream(request, sink, signal, turnId, "signed_out");
},
async stop(org, session) {
await services.cancelTurn(org, session.sid, session.turnId ?? "");
},
confirm: (org, sid, body, timeoutMs) => services.confirm(org, sid, body, timeoutMs),
feedback: (org, sid, body) => services.feedback(org, sid, body),
};
}
/** Reads `incident.rca`'s report run, then that run's report; null when the incident has none yet. */
export async function readIncidentReport(
org: string,
incidentId: string,
): Promise<IncidentReportRead | null> {
const incident = (await incidentsService.get(org, incidentId)).data;
if (!incident?.id) return null;
return {
incident: {
id: incident.id,
title: incident.title,
severity: incident.severity,
status: incident.status,
first_alert_at: incident.first_alert_at,
last_alert_at: incident.last_alert_at,
resolved_at: incident.resolved_at,
},
run: await readReportRun(org, incident).catch(() => null),
};
}
async function readReportRun(org: string, incident: Incident): Promise<IncidentReportRun | null> {
const reportRun = incident.rca?.report_run;
if (!reportRun?.run_id) return null;
const run = (await incidentsService.getRcaRun(org, incident.id, reportRun.run_id)).data;
const report = typeof run?.report === "string" ? run.report : "";
if (!report.trim()) return null;
const history = Array.isArray(incident.rca?.history) ? incident.rca.history : [];
const index = history.findIndex((item) => item.run_id === reportRun.run_id);
return {
id: reportRun.run_id,
number: index < 0 ? null : history.length - index,
startedAt: reportRun.started_at,
report,
};
}
function wireBody(req: ClientSendRequest): Record<string, unknown> {
const images = req.message.images?.length ? toWireImages(req.message.images) : undefined;
const current: WireMessage = { role: "user", content: req.message.text };
return {
// With persistence the server holds the history, so only the new message goes (P-20, HT-07 AC 2).
messages: [...(req.transcript ?? []), current],
...(typeof req.knownSeq === "number" && { known_seq: req.knownSeq }),
context: req.context,
...(images && { images }),
...(req.mode && { mode: req.mode }),
...(req.ephemeral && { ephemeral: true }),
...(req.attachments?.length && { attachments: req.attachments }),
...(req.replyToApproval && { reply_to_approval: req.replyToApproval }),
...(req.agent && { agent: req.agent }),
...(req.handoffFrom && { handoff_from: req.handoffFrom }),
};
}
function toWireImages(images: ImageAttachment[]) {
return images.map((img) => ({ data: img.data, mime_type: img.mimeType, filename: img.filename }));
}
// A cross-origin preflight fails outright on a server whose CORS list predates this header, and the owner id is only an extra signal.
function clientIdHeader(base: string): Record<string, string> {
if (typeof location === "undefined" || new URL(base, location.href).origin !== location.origin)
return {};
return { "x-o2-assistant-client-id": assistantClientId() };
}
function streamHeaders(sid: Sid, turnId: string, json: boolean): Record<string, string> {
return {
...(json && { "Content-Type": "application/json" }),
traceparent: traceparent(),
"x-o2-assistant-session-id": sid,
"x-o2-assistant-turn-id": turnId,
};
}
function traceparent(): string {
return `00-${getUUIDv7(true)}-${getUUID().replace(/-/g, "").slice(0, 16)}-01`;
}
function handlersOf(sink: FrameSink) {
return {
frame: (frame: AssistantFrame, id: string | null) => sink.onFrame(frame, id),
boundary: (id: string) => sink.onBoundary?.(id),
keepalive: () => sink.onKeepalive(),
unknown: (type: string) => sink.onUnknown(type),
};
}
function rejected(err: unknown, signal: AbortSignal, turnId: string): SendResult {
// Only our own abort is a Stop; an AbortError from anything else is a failed request.
const aborted = signal.aborted;
const error = toAssistantError(err instanceof TypeError ? err : new TypeError(String(err)), {
turnId,
});
return { ok: false, error, ...(aborted && { aborted: true }) };
}
async function readJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
return null;
}
}
function enc(value: string): string {
return encodeURIComponent(value);
}