Files
openobserve/web/src/composables/useChatStream.ts
T
fd4c42b067 fix(ai): make AI credit consumption and exhaustion clear (#15170)
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-15 12:42:26 +00:00

1223 lines
44 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 { nextTick, reactive, ref, watch, type Ref } from "vue";
import type { Router } from "vue-router";
import type { Store } from "vuex";
import { chatErrorMessage } from "@/components/O2AIChat.content";
import { extractFrames, extractTailFrames } from "@/components/O2AIChat.framing";
import {
buildNavigationRoute,
generateNavigationFromToolResult as generateNavigation,
navigationPageName,
} from "@/components/O2AIChat.navigation";
import {
reduce,
stoppedMarker,
type ReducerCtx,
type StreamEffect,
type StreamPhase,
type StreamState,
} from "@/components/O2AIChat.reducer";
import { isChatPersistenceEnabled } from "@/components/ai-assistant/share/chatShare";
import useAiChat from "@/composables/useAiChat";
import {
isPaidOverageConsentError,
usePaidOverageConsent,
} from "@/composables/usePaidOverageConsent";
import { useAiDashboardEvents } from "@/composables/useAiDashboardEvents";
import type { useAutoNavigationPreferences } from "@/composables/useAutoNavigationPreferences";
import type { useChatHistory } from "@/composables/useChatHistory";
import type { useChatScroll } from "@/composables/useChatScroll";
import type { useTypewriter } from "@/composables/useTypewriter";
import { toast } from "@/lib/feedback/Toast/useToast";
import { aiCreditsRemedy } from "@/utils/aiCredits";
import analytics from "@/services/product_analytics";
import type {
ChatHistoryEntry,
ChatMessage,
ContentBlock,
ImageAttachment,
NavigationAction,
} from "@/ts/interfaces/chat";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import { notifyChatListChanged } from "@/utils/chatListRevision";
import { getUUIDv7 } from "@/utils/zincutils";
const { fetchAiChat, cancelAiChat } = useAiChat();
// Module scope is safe: the controller closes over module-level singleton state.
const { promptForConsent } = usePaidOverageConsent();
const { emit: emitDashboardEvent } = useAiDashboardEvents();
// Module scope, not setup(): O2AIChat mounts in both HomeView and MainLayout, and a stream handed off between them must share this state.
const backgroundStreams = new Set<AbortController>();
const MAX_BACKGROUND_STREAMS = 3;
// loadChat swaps chatMessages.value back to the live msgs so processStream's isActive() identity check writes to the UI again.
const backgroundStreamMap = new Map<
string,
{
msgs: ChatMessage[];
controller: AbortController;
chatId: number | null;
}
>();
// Module scope: processStream resets isLoading only on the instance that started it, so a re-attached instance watches this to clear its spinner.
const sessionStreamingState = reactive<Record<string, boolean>>({});
// Keyed by turn controller: a Stop racing the stream's end, or a halt before the restore retry, must not count a turn twice.
const answerOutcomeTracked = new WeakSet<AbortController>();
// Detached streams outlive their component; call only when the turn loses authorization (org switch, logout), never on navigation.
export const abortBackgroundStreams = () => {
for (const controller of backgroundStreams) controller.abort();
backgroundStreams.clear();
backgroundStreamMap.clear();
for (const key of Object.keys(sessionStreamingState)) {
delete sessionStreamingState[key];
}
};
const trackAnswerOutcome = (turn: AbortController | null, event: string, properties?: object) => {
if (!turn || answerOutcomeTracked.has(turn)) return;
answerOutcomeTracked.add(turn);
analytics.track(event, properties);
};
/** How a turn ended for the caller: `turnLimit` means the server refused it before it started; `creditsExhausted` (402) hands the draft back to the input. */
export type TurnOutcome = "done" | "turnLimit" | "creditsExhausted";
// The server refuses a turn past its concurrency caps with 429 before anything is stored.
export const isTurnLimitResponse = (status: number | undefined, body: unknown): boolean => {
if (status !== 429) return false;
const b = (body ?? {}) as { error_code?: unknown; code?: unknown };
return b.error_code === undefined || b.error_code === "turn_limit" || b.code === "turn_limit";
};
type Typewriter = ReturnType<typeof useTypewriter>;
type AutoNavigation = ReturnType<typeof useAutoNavigationPreferences>;
type ChatScroll = ReturnType<typeof useChatScroll>;
export interface UseChatStreamOptions
extends
Pick<
Typewriter,
| "startAnalyzingRotation"
| "stopAnalyzingRotation"
| "aiGeneratedTitle"
| "animateTitle"
| "displayedStreamingContent"
| "typewriterAnimationId"
| "resetTypewriterState"
| "animateStreamingText"
>,
Pick<
AutoNavigation,
| "autoNavigationPreferences"
| "pendingAutoNavigation"
| "isAutoNavigationEnabled"
| "saveAutoNavigationPreferences"
>,
Pick<ChatScroll, "scrollToBottom" | "scrollToLoadingIndicator"> {
chatMessages: Ref<ChatMessage[]>;
currentChatId: Ref<number | null>;
currentTextSegment: Ref<string>;
dbSaveToHistory: ReturnType<typeof useChatHistory>["saveToHistory"];
store: Store<any>;
router: Router;
t: TranslateFn;
}
/** Stream lifecycle of one chat instance; the detached-stream registry above is shared by every instance. */
export function useChatStream(options: UseChatStreamOptions) {
const {
chatMessages,
currentChatId,
currentTextSegment,
dbSaveToHistory,
store,
router,
t,
scrollToBottom,
scrollToLoadingIndicator,
autoNavigationPreferences,
pendingAutoNavigation,
isAutoNavigationEnabled,
saveAutoNavigationPreferences,
startAnalyzingRotation,
stopAnalyzingRotation,
aiGeneratedTitle,
animateTitle,
displayedStreamingContent,
typewriterAnimationId,
resetTypewriterState,
animateStreamingText,
} = options;
const isLoading = ref(false);
const currentStreamingMessage = ref("");
const currentSessionId = ref<string | null>(null);
const lastTraceId = ref<string | null>(null); // OTEL trace_id from last workflow for feedback correlation
const saveHistoryLoading = ref(false);
const pendingConfirmation = ref<{
tool: string;
args: Record<string, any>;
message: I18nText;
navAction?: NavigationAction;
} | null>(null);
const activeToolCall = ref<{
tool: string;
message: I18nText;
context: Record<string, any>;
call_id?: string;
} | null>(null);
const currentAbortController = ref<AbortController | null>(null);
// Throttle save during streaming to prevent data loss on page reload
const lastStreamingSaveTime = ref<number>(0);
const STREAMING_SAVE_INTERVAL = 3000;
// Set by processStream when the owning replica is gone; the stream already returned 200, so sendMessage reads it after it ends.
const streamOwnerUnavailable = ref(false);
// The Stop's final save; the aborted turn's post-turn reload must read it, not an earlier throttled save.
let stopSave: Promise<void> | null = null;
// The turn id the server knows the current request by, so a save made as it ends can name it.
const currentTurnId = ref<string | null>(null);
const persistenceOn = () => isChatPersistenceEnabled(store.state.zoConfig);
// Steps that already ran survive the Stop; the turn ends with the stopped marker either way.
const markStopped = () => {
const msgs = chatMessages.value;
if (msgs.length === 0) return;
const lastMessage = msgs[msgs.length - 1];
if (lastMessage.role !== "assistant") {
msgs.push({ role: "assistant", content: raw(""), contentBlocks: [stoppedMarker(t)] });
return;
}
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = lastMessage.content
? [{ type: "text", text: lastMessage.content }]
: [];
}
const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
if (currentStreamingMessage.value && lastBlock?.type === "text") {
lastBlock.text = currentTextSegment.value;
}
lastMessage.contentBlocks.push(stoppedMarker(t));
};
const cancelCurrentRequest = async () => {
if (currentAbortController.value) {
trackAnswerOutcome(currentAbortController.value, "ai_assistant_answer_aborted");
// A persisted turn outlives this request, so aborting alone would leave it running; fire-and-forget.
if (currentSessionId.value && persistenceOn()) {
cancelAiChat(store.state.selectedOrganization.identifier, currentSessionId.value).catch(
(e: unknown) => console.debug("AI chat cancel request failed", e),
);
}
currentAbortController.value.abort();
currentAbortController.value = null;
toast({
message: t("toastMessages.components.responseGenerationStopped"),
variant: "info",
});
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
displayedStreamingContent.value = currentTextSegment.value;
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
// A trailing render flush would overwrite the stopped marker appended below.
disposeRenderFlush();
markStopped();
currentStreamingMessage.value = "";
currentTextSegment.value = "";
displayedStreamingContent.value = "";
stopSave = saveToHistory(currentTurnId.value ?? undefined);
await stopSave;
await scrollToBottom();
}
};
const processStream = async (
reader: ReadableStreamDefaultReader<Uint8Array>,
turn: AbortController,
turnId: string | null = null,
) => {
const decoder = new TextDecoder();
let buffer = "";
let messageComplete = false;
// Set only by the server's terminal `complete` frame; EOF without it is a truncated answer.
let sawCompleteFrame = false;
// Captured array: isActive() is identity against it, so a detached stream keeps writing its own array after a session switch.
const msgs = chatMessages.value;
let ctxSessionId = currentSessionId.value;
let ctxChatId = currentChatId.value;
let ctxTitle: string | undefined = aiGeneratedTitle.value || undefined;
// Local streaming accumulators (synced to refs only when active)
let streamingMsg = currentStreamingMessage.value;
let textSegment = currentTextSegment.value;
const isActive = () => chatMessages.value === msgs;
const syncStreamingRefs = () => {
if (isActive()) {
currentStreamingMessage.value = streamingMsg;
currentTextSegment.value = textSegment;
}
};
const saveCtx = async (endedTurnId?: string) => {
if (msgs.length === 0) return;
if (!ctxSessionId) {
ctxSessionId = getUUIDv7();
if (isActive()) currentSessionId.value = ctxSessionId;
}
const title = isActive() ? aiGeneratedTitle.value || undefined : ctxTitle;
const chatId = isActive() ? currentChatId.value : ctxChatId;
const resultId = await dbSaveToHistory(msgs, ctxSessionId, title, chatId, endedTurnId);
if (!chatId && resultId) {
if (isActive()) {
currentChatId.value = resultId;
// Persist the actual value (ON by default) so an explicit user disable is honored.
autoNavigationPreferences.value.set(resultId, pendingAutoNavigation.value);
saveAutoNavigationPreferences();
} else {
ctxChatId = resultId;
}
}
};
let lastSaveTime = lastStreamingSaveTime.value;
const throttledSaveCtx = async (force = false) => {
const now = Date.now();
if (force || now - lastSaveTime >= STREAMING_SAVE_INTERVAL) {
lastSaveTime = now;
if (isActive()) lastStreamingSaveTime.value = now;
await saveCtx();
}
};
const postConfirmation = async (approved: boolean) => {
try {
const orgId = store.state.selectedOrganization.identifier;
const res = await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${ctxSessionId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved }),
},
);
// A silent failure leaves the agent paused with nobody left to answer it.
if (!res.ok) {
console.error(
approved
? `Auto-approval not registered (HTTP ${res.status}) for session ${ctxSessionId}`
: `Auto-deny not registered (HTTP ${res.status}) for background stream ${ctxSessionId}`,
);
}
} catch (error) {
console.error(
approved
? "Error auto-confirming navigation:"
: "Error auto-denying confirmation for background stream:",
error,
);
}
};
const seedState = (): StreamState => ({
messages: msgs,
activeToolCall: activeToolCall.value,
textSegment,
streamingMsg,
title: ctxTitle,
lastTraceId: lastTraceId.value,
ownerUnavailable: streamOwnerUnavailable.value,
pendingConfirmation: pendingConfirmation.value,
messageComplete,
halted: false,
});
// A detached stream must not write a ref the original would have left untouched.
const setIfChanged = <T>(target: Ref<T>, value: T) => {
if (target.value !== value) target.value = value;
};
const commitState = (state: StreamState) => {
textSegment = state.textSegment;
streamingMsg = state.streamingMsg;
ctxTitle = state.title;
messageComplete = state.messageComplete;
setIfChanged(activeToolCall, state.activeToolCall);
setIfChanged(lastTraceId, state.lastTraceId);
setIfChanged(streamOwnerUnavailable, state.ownerUnavailable);
setIfChanged(pendingConfirmation, state.pendingConfirmation);
};
const buildCtx = (phase: StreamPhase): ReducerCtx => ({
isActive: isActive(),
autoNavigationEnabled: isAutoNavigationEnabled.value,
phase,
t,
generateNavigation: generateNavigationFromToolResult,
});
const runEffects = async (effects: StreamEffect[]) => {
for (const effect of effects) {
switch (effect.kind) {
case "scroll":
if (!effect.whenActive || isActive()) await scrollToBottom();
break;
case "save":
await saveCtx();
break;
case "throttledSave":
await throttledSaveCtx(effect.force);
break;
case "navigate":
await handleNavigationAction(effect.action);
break;
case "confirmPost":
await postConfirmation(effect.approved);
break;
case "dashboardEvent":
emitDashboardEvent(effect.payload);
break;
case "animateTitle":
aiGeneratedTitle.value = effect.title;
animateTitle(effect.title);
break;
case "syncSegments":
syncStreamingRefs();
break;
case "animateText":
if (!typewriterAnimationId.value) animateStreamingText();
break;
case "finalizeText":
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
displayedStreamingContent.value = "";
syncStreamingRefs();
break;
}
}
};
const applyEvent = async (data: any, phase: StreamPhase) => {
if (data?.type === "complete") sawCompleteFrame = true;
const state = seedState();
const effects = reduce(state, data, buildCtx(phase));
commitState(state);
await runEffects(effects);
if (state.halted) trackAnswerOutcome(turn, "ai_assistant_answer_failed", { stage: "stream" });
return state.halted;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = extractFrames(buffer);
buffer = rest;
for (const jsonStr of events) {
try {
const data = JSON.parse(jsonStr);
if (await applyEvent(data, "stream")) return;
} catch (jsonError) {
console.debug("JSON parse error:", jsonError, "for line:", jsonStr);
continue;
}
}
}
for (const jsonStr of extractTailFrames(buffer)) {
try {
const data = JSON.parse(jsonStr);
if (await applyEvent(data, "tailFlush")) return;
} catch (e) {
console.debug("Error processing remaining buffer:", e);
continue;
}
}
// An owner-unavailable stream is replayed by runTurn, so only its replay counts.
if (!streamOwnerUnavailable.value) {
if (sawCompleteFrame) trackAnswerOutcome(turn, "ai_assistant_answer_completed");
else trackAnswerOutcome(turn, "ai_assistant_answer_failed", { stage: "stream" });
}
if (messageComplete) {
if (isActive()) {
displayedStreamingContent.value = textSegment;
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
}
const lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant" && lastMessage.contentBlocks) {
const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
if (lastBlock && lastBlock.type === "text") {
lastBlock.text = textSegment;
}
}
await saveCtx(sawCompleteFrame && turnId ? turnId : undefined);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// User cancel is expected; a detached stream still needs its final save.
if (!isActive() && msgs.length > 0 && ctxSessionId) {
await dbSaveToHistory(msgs, ctxSessionId, ctxTitle, ctxChatId, turnId ?? undefined);
}
return;
} else {
console.error("Error reading stream:", error);
trackAnswerOutcome(turn, "ai_assistant_answer_failed", { stage: "stream" });
}
}
};
const saveToHistory = async (endedTurnId?: string) => {
saveHistoryLoading.value = true;
if (chatMessages.value.length === 0) {
saveHistoryLoading.value = false;
return;
}
try {
if (!currentSessionId.value) {
currentSessionId.value = getUUIDv7();
}
const title = aiGeneratedTitle.value || undefined;
const chatId = await dbSaveToHistory(
chatMessages.value,
currentSessionId.value,
title,
currentChatId.value,
endedTurnId,
);
if (!currentChatId.value && chatId) {
currentChatId.value = chatId;
// Persist the actual value (ON by default) so an explicit user disable is honored.
autoNavigationPreferences.value.set(chatId, pendingAutoNavigation.value);
saveAutoNavigationPreferences();
}
} catch (error) {
console.error("Error saving chat history:", error);
} finally {
saveHistoryLoading.value = false;
}
};
/** Detach the stream so it keeps writing its captured array in the background and saves via saveCtx() when done. */
const detachCurrentStream = () => {
if (!currentAbortController.value) return;
if (backgroundStreams.size >= MAX_BACKGROUND_STREAMS) {
const oldest = backgroundStreams.values().next().value;
if (oldest) {
oldest.abort();
backgroundStreams.delete(oldest);
}
}
const detachedController = currentAbortController.value;
backgroundStreams.add(detachedController);
currentAbortController.value = null;
// loadChat re-attaches by swapping chatMessages.value to this live array.
if (currentSessionId.value) {
backgroundStreamMap.set(currentSessionId.value, {
msgs: chatMessages.value,
controller: detachedController,
chatId: currentChatId.value,
});
}
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
currentStreamingMessage.value = "";
currentTextSegment.value = "";
displayedStreamingContent.value = "";
};
// Logout must kill the foreground turn too; MainLayout.signout() sends a window event because its Options API half can't reach setup scope.
const abortAllStreams = () => {
abortBackgroundStreams();
if (currentAbortController.value) {
currentAbortController.value.abort();
currentAbortController.value = null;
}
};
const resolveConfirmationBlock = (approved: boolean) => {
for (const msg of chatMessages.value) {
if (msg.contentBlocks) {
for (const block of msg.contentBlocks) {
if (block.pendingConfirmation) {
block.pendingConfirmation = false;
if (!approved) {
block.success = false;
block.resultMessage = t("aiAssistant.aiChat.actionCancelledByUser");
}
return;
}
}
}
}
};
// Shown after a restore: only the dialogue came back, not tool results, files or permission decisions.
const RESTORED_NOTICE =
"This conversation was interrupted and has been restored. Earlier messages are preserved, but any files, queries or other actions from before the interruption were not carried over.";
// Keyed on the explicit server code, never a generic failure: restoring abandons the current session.
const isSessionOwnerUnavailable = (errorBody: unknown): boolean => {
// `unknown`, not `any` — narrow before reading, or a non-object body throws.
if (typeof errorBody !== "object" || errorBody === null) return false;
const body = errorBody as { code?: unknown; detail?: { code?: unknown } };
const code = body.detail?.code ?? body.code;
return code === "session_owner_unavailable";
};
const appendErrorBlock = (message: string, recoverable = false) =>
appendBlock({ type: "error", message: raw(message), recoverable });
/** A 402 carries its remedy; ephemeral so a stale "out of credits" never reloads from history. */
const appendCreditsExhaustedBlock = (errorBody: any) => {
const remedy = errorBody?.remedy;
const block: ContentBlock = {
type: "error",
message: t("aiAssistant.creditsExhausted"),
errorType: "ai_credits_exhausted",
ephemeral: true,
suggestion: aiCreditsRemedy(errorBody, t),
recoverable: remedy === "retry",
};
if (remedy === "subscribe") {
block.navigationAction = {
resource_type: "billing",
action: "navigate_direct",
label: t("billing.plansLabel"),
target: { path: "/billings/plans" },
};
}
appendBlock(block);
};
const appendBlock = (block: ContentBlock) => {
const msgs = chatMessages.value;
const last = msgs[msgs.length - 1];
if (last && last.role === "assistant") {
if (!last.contentBlocks) last.contentBlocks = [];
last.contentBlocks.push(block);
} else {
msgs.push({ role: "assistant", content: raw(""), contentBlocks: [block] });
}
};
/** POST a confirmation answer and report whether it landed, so a 404 from a replica without the pending confirmation isn't silent. */
const sendConfirmation = async (sessionId: string, approved: boolean): Promise<boolean> => {
try {
const orgId = store.state.selectedOrganization.identifier;
const res = await fetch(`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved }),
});
if (!res.ok) {
console.error(`Confirmation not registered (HTTP ${res.status}) for session ${sessionId}`);
appendErrorBlock(
approved
? "Your approval could not be delivered — the assistant may have already cancelled this action. Please check the result before retrying."
: "Your response could not be delivered — the assistant may have already cancelled this action.",
);
return false;
}
analytics.track("ai_assistant_tool_call_answered", { approved });
return true;
} catch (error) {
console.error("Error sending confirmation:", error);
appendErrorBlock(
"Your response could not be delivered. Please check your connection and try again.",
);
return false;
}
};
const handleToolConfirm = async () => {
resolveConfirmationBlock(true);
if (pendingConfirmation.value?.tool === "navigation_action") {
const navAction = pendingConfirmation.value?.navAction;
if (navAction) {
await handleNavigationAction(navAction);
}
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
await sendConfirmation(currentSessionId.value, true);
pendingConfirmation.value = null;
};
const handleToolCancel = async () => {
resolveConfirmationBlock(false);
if (pendingConfirmation.value?.tool === "navigation_action") {
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
await sendConfirmation(currentSessionId.value, false);
pendingConfirmation.value = null;
};
const handleToolAlwaysConfirm = async () => {
isAutoNavigationEnabled.value = true;
resolveConfirmationBlock(true);
if (pendingConfirmation.value?.tool === "navigation_action") {
const navAction = pendingConfirmation.value?.navAction;
if (navAction) {
await handleNavigationAction(navAction);
}
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
await sendConfirmation(currentSessionId.value, true);
pendingConfirmation.value = null;
};
const generateNavigationFromToolResult = (
toolName: string,
callArgs: any,
responseBody: any,
): NavigationAction | null => generateNavigation(toolName, callArgs, responseBody, t);
const handleNavigationAction = async (action: NavigationAction) => {
// No detach here: onUnmounted detaches without aborting, so a surviving instance keeps owning its turn and its indicator.
const pageName = navigationPageName(action);
const target = buildNavigationRoute(action, store.state.selectedOrganization.identifier);
if (target) {
await router.push({ path: target.path, query: target.query });
}
// Leaving for billing is not an answer to the chat; saving one would outlive the notice that offered it.
if (action.resource_type === "billing") return;
// Use setTimeout to add message AFTER navigation fully completes and settles
setTimeout(async () => {
try {
const successMessage = t("aiAssistant.navigatedTo", { page: pageName });
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
chatMessages.value.push({
role: "assistant",
content: raw(successMessage),
contentBlocks: [{ type: "text", text: successMessage }],
});
} else {
if (lastMessage.content) {
lastMessage.content = raw(lastMessage.content + "\n\n" + successMessage);
} else {
lastMessage.content = raw(successMessage);
}
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = [];
}
lastMessage.contentBlocks.push({
type: "text",
text: successMessage,
});
}
await saveToHistory();
await scrollToBottom();
} catch (error) {
console.error("Error adding navigation success message:", error);
}
}, 500);
};
const tryReattach = (chat: ChatHistoryEntry, chatId: number): boolean => {
// Re-attach to a live background stream: assigning its array makes processStream's isActive() identity true again.
const bgCtx = chat.sessionId ? backgroundStreamMap.get(chat.sessionId) : null;
if (bgCtx) {
chatMessages.value = bgCtx.msgs;
currentChatId.value = bgCtx.chatId || chatId;
currentSessionId.value = chat.sessionId || null;
currentAbortController.value = bgCtx.controller;
backgroundStreams.delete(bgCtx.controller);
backgroundStreamMap.delete(chat.sessionId!);
isLoading.value = true;
startAnalyzingRotation();
// Prime the typewriter with text streamed before re-attach; processStream only syncs on the next chunk, so the backlog would stay invisible.
const lastMsg = chatMessages.value[chatMessages.value.length - 1];
if (lastMsg?.role === "assistant" && lastMsg.contentBlocks?.length) {
const lastBlock = lastMsg.contentBlocks[lastMsg.contentBlocks.length - 1];
if (lastBlock?.type === "text" && lastBlock.text) {
currentStreamingMessage.value = lastMsg.content || lastBlock.text;
currentTextSegment.value = lastBlock.text;
displayedStreamingContent.value = lastBlock.text;
}
}
return true;
}
return false;
};
const runTurn = async (
hasImages: boolean,
messagesToSend: ImageAttachment[],
): Promise<TurnOutcome> => {
let outcome: TurnOutcome = "done";
const isNewSession = !currentSessionId.value;
// Mint the session id before the try so every exit path's cleanup clears the SAME id, or a re-attached instance spins forever.
if (!currentSessionId.value) {
currentSessionId.value = getUUIDv7();
}
const streamSessionId = currentSessionId.value;
// Before isLoading: the registry watcher clears a spinner whose session has no live turn.
sessionStreamingState[streamSessionId] = true;
stopSave = null;
isLoading.value = true;
currentStreamingMessage.value = "";
currentTextSegment.value = "";
resetTypewriterState();
startAnalyzingRotation();
currentAbortController.value = new AbortController();
// Captured now: a detach during the request nulls currentAbortController, but cleanup still needs this turn's controller.
const turnController = currentAbortController.value;
// At most one restore attempt per turn; the notice shows only once the replacement request succeeds.
let hasReseeded = false;
let reseedNotice = false;
let hasRetriedAfterConsent = false;
// A consent retry must resend the SAME turn, so its identity is pinned here.
const turnOrgId = store.state.selectedOrganization.identifier;
const turnChatId = currentChatId.value;
const turnMessages = chatMessages.value;
const fetchTurn = () => {
currentTurnId.value = getUUIDv7();
return fetchAiChat(
chatMessages.value,
"",
turnOrgId,
turnController.signal,
undefined,
currentSessionId.value ?? undefined,
hasImages ? messagesToSend : undefined,
currentTurnId.value,
);
};
// Clear any flag left by a turn that threw or aborted early; a stale `true` abandons a healthy session.
streamOwnerUnavailable.value = false;
try {
// Don't add empty assistant message here - wait for actual content
await scrollToLoadingIndicator();
let response: any;
try {
response = await fetchTurn();
} catch (error) {
console.error("Error fetching AI chat:", error);
trackAnswerOutcome(turnController, "ai_assistant_answer_failed", { stage: "request" });
return outcome;
}
if (response && response.cancelled) {
return outcome;
}
// Reseed and consent each get one retry; the loop lets either follow the other.
while (response && !response.ok) {
let errorBody: any = null;
try {
errorBody = await response.json();
} catch (_) {
// body may not be JSON
}
// Nothing was stored, so the composer gets the prompt back instead of an error turn.
if (isTurnLimitResponse(response.status, errorBody)) {
outcome = "turnLimit";
trackAnswerOutcome(turnController, "ai_assistant_answer_failed", { stage: "request" });
return outcome;
}
// Session gone but transcript remains: resend for the server to restore it; this code only, once only.
if (isSessionOwnerUnavailable(errorBody) && !hasReseeded) {
hasReseeded = true;
console.warn(
`Session ${currentSessionId.value} is no longer available; restoring the conversation.`,
);
// A persisted chat is restored under its own id; otherwise the old id would be refused again.
if (!persistenceOn()) {
currentSessionId.value = getUUIDv7();
reseedNotice = true;
}
response = await fetchTurn();
// A Stop during the retry returns the cancelled envelope, which has no `ok` and would render as a server error.
if (response && response.cancelled) {
return outcome;
}
continue;
}
if (isPaidOverageConsentError(response.status, errorBody) && !hasRetriedAfterConsent) {
hasRetriedAfterConsent = true;
const accepted = await promptForConsent(
turnOrgId,
"ai_credits",
errorBody.consent,
turnController.signal,
"chat",
);
// The denied request never started, so a decline or a superseded turn just stops: nothing to unwind.
const turnStillActive =
!turnController.signal.aborted &&
store.state.selectedOrganization.identifier === turnOrgId &&
chatMessages.value === turnMessages &&
currentChatId.value === turnChatId;
if (!accepted || !turnStillActive) {
if (!accepted && turnStillActive) {
appendErrorBlock(t("paidUsage.declinedNotice"));
}
return outcome;
}
response = await fetchTurn();
if (response && response.cancelled) {
return outcome;
}
continue;
}
// Not thrown: the catch below would persist the raw server text into history.
if (response.status === 402) {
appendCreditsExhaustedBlock(errorBody);
outcome = "creditsExhausted";
return outcome;
}
const err: any = new Error(
errorBody?.message ||
t("aiAssistant.aiChat.serverErrorStatus", { status: response.status }),
);
err.status = response.status;
err.errorBody = errorBody;
throw err;
}
// Announce before content arrives; silence hides that earlier tool results and file state were lost.
if (reseedNotice) {
reseedNotice = false;
appendErrorBlock(RESTORED_NOTICE, true);
}
if (!response.body) {
throw new Error("No response body");
}
analytics.track("ai_assistant_message_sent", {
has_images: hasImages,
new_session: isNewSession,
});
const reader = response.body.getReader();
const streamMsgs = chatMessages.value;
await processStream(reader, turnController, currentTurnId.value);
// A streaming 409 arrives as an SSE event inside a 200; restore only while this turn is on screen, or it clobbers another chat's session.
const stillOnScreen = chatMessages.value === streamMsgs;
if (streamOwnerUnavailable.value && !hasReseeded && stillOnScreen) {
await restoreAfterOwnerLoss(turnController, streamSessionId, hasImages, messagesToSend);
hasReseeded = true;
}
// Still set here means no restore ran (already used, or the chat left the screen), so the turn ends unanswered.
if (streamOwnerUnavailable.value) {
trackAnswerOutcome(turnController, "ai_assistant_answer_failed", { stage: "stream" });
}
streamOwnerUnavailable.value = false;
if (turnController.signal.aborted && stopSave) await stopSave;
stopSave = null;
// Only update UI/store if stream was NOT detached (session is still the same)
const wasDetached = chatMessages.value !== streamMsgs;
if (!wasDetached) {
store.dispatch("setCurrentChatTimestamp", currentChatId.value);
store.dispatch("setChatUpdated", true);
}
} catch (error: any) {
if (
chatMessages.value.length > 0 &&
chatMessages.value[chatMessages.value.length - 1].role === "assistant" &&
!chatMessages.value[chatMessages.value.length - 1].content &&
// Steps that already ran must survive the failure; only an empty streaming placeholder is dropped.
!chatMessages.value[chatMessages.value.length - 1].contentBlocks?.length
) {
chatMessages.value.pop();
}
trackAnswerOutcome(turnController, "ai_assistant_answer_failed", { stage: "request" });
const errorMessage = chatErrorMessage(error, t);
chatMessages.value.push({
role: "assistant",
content: raw(errorMessage),
});
await saveToHistory();
} finally {
// finally, not after the catch: the early returns and a throw must not leave the spinner or the registry behind.
backgroundStreams.delete(turnController);
// Only this turn's entry; a re-attach or restore may have replaced it with another controller.
if (backgroundStreamMap.get(streamSessionId)?.controller === turnController) {
backgroundStreamMap.delete(streamSessionId);
}
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
// Clear by the id captured before the request, not currentSessionId, which may belong to another chat by an early failure.
sessionStreamingState[streamSessionId] = false;
currentAbortController.value = null;
if (outcome !== "turnLimit") notifyChatListChanged();
await scrollToBottom();
}
return outcome;
};
// A persisted chat is restored by the server under its own id; a browser-only one needs a fresh session.
const restoreAfterOwnerLoss = async (
turnController: AbortController,
streamSessionId: string,
hasImages: boolean,
messagesToSend: ImageAttachment[],
) => {
streamOwnerUnavailable.value = false;
const keepSession = persistenceOn();
backgroundStreams.delete(turnController);
if (!keepSession) backgroundStreamMap.delete(streamSessionId);
// The streaming registry must follow a new id, or a re-attaching instance never sees this stream finish.
const restoredSessionId = keepSession ? streamSessionId : getUUIDv7();
if (!keepSession) {
// Marked before the id is published, or the registry watcher sees a session with no live turn and clears the spinner.
sessionStreamingState[restoredSessionId] = true;
currentSessionId.value = restoredSessionId;
}
currentTurnId.value = getUUIDv7();
const retry: any = await fetchAiChat(
chatMessages.value,
"",
store.state.selectedOrganization.identifier,
currentAbortController.value?.signal,
undefined,
currentSessionId.value ?? undefined,
hasImages ? messagesToSend : undefined,
currentTurnId.value,
);
if (retry && !retry.cancelled && retry.ok && retry.body) {
// Announced only once the replacement is accepted, or the claim can turn out false.
if (!keepSession) appendErrorBlock(RESTORED_NOTICE, true);
await processStream(retry.body.getReader(), turnController, currentTurnId.value);
} else if (!(retry && retry.cancelled)) {
// No further attempt follows, so explain instead of ending silently; a cancel stays silent.
trackAnswerOutcome(turnController, "ai_assistant_answer_failed", { stage: "request" });
appendErrorBlock(
"This conversation was interrupted and could not be restored. Please try sending your message again.",
);
}
if (keepSession) return;
// Clear the restored turn's entry either way, or a re-attaching instance spins forever.
sessionStreamingState[restoredSessionId] = false;
backgroundStreamMap.delete(restoredSessionId);
};
// Throttle the reactive write: each one re-parses and re-highlights the whole markdown, which is O(n^2) at typewriter tick rate.
const STREAMING_RENDER_INTERVAL = 80;
let lastStreamingRenderTime = 0;
let pendingStreamingRenderContent: string | null = null;
let streamingRenderFlushTimer: ReturnType<typeof setTimeout> | null = null;
const flushStreamingRenderNow = (content: string) => {
lastStreamingRenderTime = Date.now();
pendingStreamingRenderContent = null;
if (streamingRenderFlushTimer) {
clearTimeout(streamingRenderFlushTimer);
streamingRenderFlushTimer = null;
}
const lastMessage = chatMessages.value[chatMessages.value.length - 1];
if (lastMessage && lastMessage.role === "assistant" && lastMessage.contentBlocks) {
const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
if (lastBlock && lastBlock.type === "text") {
// Additive-only: the typewriter reveals a prefix of text already written here, so a lagging or reset reveal must never shorten it.
if ((content?.length || 0) >= (lastBlock.text?.length || 0)) {
lastBlock.text = content;
}
}
}
};
const disposeRenderFlush = () => {
if (streamingRenderFlushTimer) {
clearTimeout(streamingRenderFlushTimer);
streamingRenderFlushTimer = null;
}
pendingStreamingRenderContent = null;
};
watch(displayedStreamingContent, (newContent) => {
if (!isLoading.value) return;
// An empty displayedStreamingContent means a new segment starts, not that previous content should be cleared.
if (!newContent) return;
const now = Date.now();
if (now - lastStreamingRenderTime >= STREAMING_RENDER_INTERVAL) {
flushStreamingRenderNow(newContent);
return;
}
// Trailing edge: the latest content must land even if ticks keep arriving faster than the interval.
pendingStreamingRenderContent = newContent;
if (!streamingRenderFlushTimer) {
const delay = STREAMING_RENDER_INTERVAL - (now - lastStreamingRenderTime);
streamingRenderFlushTimer = setTimeout(() => {
streamingRenderFlushTimer = null;
if (pendingStreamingRenderContent !== null) {
flushStreamingRenderNow(pendingStreamingRenderContent);
}
}, delay);
}
});
watch(
() => [
currentSessionId.value ? sessionStreamingState[currentSessionId.value] : undefined,
isLoading.value,
],
([isStreaming]) => {
// Only judge a named session: without one there is no turn to look up, and runTurn names it before raising the spinner.
if (!currentSessionId.value) return;
// Anything but `true` means no live turn owns this session, so a spinner here is stranded whatever stripped the controller.
if (isStreaming !== true && isLoading.value) {
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
// The owning instance's processStream already wrote the final text, so only clear this instance's typewriter UI.
resetTypewriterState();
// Publish the finished turn so a later mount reads it, not a mid-stream snapshot.
store.dispatch("setCurrentChatTimestamp", currentChatId.value);
nextTick(() => scrollToBottom());
}
},
);
return {
isLoading,
currentSessionId,
lastTraceId,
saveHistoryLoading,
pendingConfirmation,
activeToolCall,
currentAbortController,
streamOwnerUnavailable,
cancelCurrentRequest,
saveToHistory,
detachCurrentStream,
abortAllStreams,
handleToolConfirm,
handleToolCancel,
handleToolAlwaysConfirm,
handleNavigationAction,
sendConfirmation,
isSessionOwnerUnavailable,
appendErrorBlock,
runTurn,
tryReattach,
disposeRenderFlush,
};
}