Files
Stirling-PDF/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
T
James Brunton d995471a55 Merge remote-tracking branch 'origin/main' into SaaS-update
# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
2026-06-12 09:58:40 +01:00

660 lines
20 KiB
TypeScript

import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { generateId } from "@app/utils/generateId";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
createStirlingFile,
type StirlingFileStub,
} from "@app/types/fileContext";
import type { ToolOperation } from "@app/types/file";
export enum ChatRole {
USER = "user",
ASSISTANT = "assistant",
}
export interface ChatMessage {
id: string;
role: ChatRole;
content: string;
timestamp: number;
/**
* Tool endpoint paths executed during this assistant turn (e.g.
* {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow
* ran one or more tools, in execution order. Undefined for user messages and for assistant
* turns that answered without running any tool.
*/
toolsUsed?: string[];
/**
* Full ordered progress log captured during the AI turn that produced this message.
* Only set on assistant messages; used to render the "Ran for X seconds" collapsed
* history dropdown above the response.
*/
progressLog?: AiWorkflowProgress[];
/** Wall-clock duration of the AI turn in milliseconds. Only set on assistant messages. */
durationMs?: number;
}
export enum AiWorkflowPhase {
ANALYZING = "analyzing",
CALLING_ENGINE = "calling_engine",
EXTRACTING_CONTENT = "extracting_content",
EXECUTING_TOOL = "executing_tool",
PROCESSING = "processing",
ENGINE_PROGRESS = "engine_progress",
}
/**
* Engine-side progress detail for ENGINE_PROGRESS events. Mirrors the Python
* {@code ProgressEvent} discriminated union (engine/src/stirling/contracts/progress.py)
* and the Java {@code AiEngineProgressDetail} sealed interface; the {@code phase}
* string is the discriminator. Field names are camelCase because the engine
* serialises by alias.
*/
export interface WholeDocReadStartedDetail {
phase: "whole_doc_read_started";
question: string;
pages: number;
slices: number;
}
export interface WholeDocSliceDoneDetail {
phase: "whole_doc_slice_done";
completed: number;
total: number;
/** Page-range label, e.g. "pages=1-5". */
pages: string;
durationMs: number;
excerpts: number;
facts: number;
}
export interface WholeDocCompressionRoundDetail {
phase: "whole_doc_compression_round";
roundNumber: number;
notesIn: number;
groups: number;
}
export interface WholeDocReadDoneDetail {
phase: "whole_doc_read_done";
completed: number;
slices: number;
durationSeconds: number;
}
export type EngineProgressDetail =
| WholeDocReadStartedDetail
| WholeDocSliceDoneDetail
| WholeDocCompressionRoundDetail
| WholeDocReadDoneDetail;
/**
* What we actually carry across the wire boundary: a known typed variant, or a
* forward-compat shape with just the discriminator string. The "unknown" arm
* exists so a new engine-side phase rolling out before a frontend update keeps
* rendering the generic processing message instead of crashing the union.
*/
export interface UnknownEngineProgressDetail {
phase: string;
}
export type AnyEngineProgressDetail =
| EngineProgressDetail
| UnknownEngineProgressDetail;
const KNOWN_ENGINE_PHASES = new Set<string>([
"whole_doc_read_started",
"whole_doc_slice_done",
"whole_doc_compression_round",
"whole_doc_read_done",
]);
export function isKnownEngineProgressDetail(
detail: AnyEngineProgressDetail,
): detail is EngineProgressDetail {
return KNOWN_ENGINE_PHASES.has(detail.phase);
}
export interface AiWorkflowProgress {
phase: AiWorkflowPhase;
/** Tool endpoint path currently executing, for EXECUTING_TOOL events. */
tool?: string;
/** 1-based step index, for EXECUTING_TOOL events. */
stepIndex?: number;
/** Total number of plan steps, for EXECUTING_TOOL events. */
stepCount?: number;
/**
* Engine-side event payload, for ENGINE_PROGRESS events. Typed sub-phase
* record (e.g. {@link WholeDocSliceDoneDetail}) the UI can render in detail.
*/
engineDetail?: AnyEngineProgressDetail;
}
type AiWorkflowOutcome =
| "answer"
| "not_found"
| "need_content"
| "plan"
| "need_clarification"
| "cannot_do"
| "tool_call"
| "completed"
| "unsupported_capability"
| "cannot_continue";
interface AiWorkflowResultFile {
/** Stirling file ID — download with /api/v1/general/files/{fileId}. */
fileId: string;
fileName: string;
contentType: string;
}
interface AiWorkflowResponse {
outcome: AiWorkflowOutcome;
answer?: string;
summary?: string;
rationale?: string;
reason?: string;
question?: string;
capability?: string;
message?: string;
evidence?: Array<{ pageNumber: number; text: string }>;
steps?: Array<Record<string, unknown>>;
/** Every file produced by the workflow (empty if the outcome has no files). */
resultFiles?: AiWorkflowResultFile[];
// Legacy single-file fields — mirror the first entry of resultFiles.
fileId?: string;
fileName?: string;
contentType?: string;
}
interface ChatState {
messages: ChatMessage[];
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
progressLog: AiWorkflowProgress[];
}
/**
* Maximum number of progress steps retained in the live buffer.
*/
export const PROGRESS_LOG_MAX = 4;
type ChatAction =
| { type: "ADD_MESSAGE"; message: ChatMessage }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
| { type: "CLEAR" };
function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "ADD_MESSAGE":
return { ...state, messages: [...state.messages, action.message] };
case "SET_LOADING":
// Reset the log on both start (true) and end (false) of a request.
return {
...state,
isLoading: action.loading,
progress: action.loading ? state.progress : null,
progressLog: [],
};
case "SET_PROGRESS":
return { ...state, progress: action.progress };
case "APPEND_PROGRESS":
// Cap the live buffer so each append copies at most PROGRESS_LOG_MAX elements
return {
...state,
progress: action.progress,
progressLog:
state.progressLog.length < PROGRESS_LOG_MAX
? [...state.progressLog, action.progress]
: [
...state.progressLog.slice(1 - PROGRESS_LOG_MAX),
action.progress,
],
};
case "CLEAR":
return {
...state,
messages: [],
isLoading: false,
progress: null,
progressLog: [],
};
}
}
type TranslateFn = ReturnType<typeof useTranslation>["t"];
function formatWorkflowResponse(
data: AiWorkflowResponse,
t: TranslateFn,
): string {
switch (data.outcome) {
case "answer":
case "completed":
return data.answer ?? data.summary ?? t("chat.responses.done");
case "need_clarification":
return data.question ?? t("chat.responses.need_clarification");
case "cannot_do":
return data.reason ?? t("chat.responses.cannot_do");
case "not_found":
return data.reason ?? t("chat.responses.not_found");
case "unsupported_capability":
return (
data.message ??
t("chat.responses.unsupported_capability", {
capability: data.capability ?? "unknown",
})
);
case "cannot_continue":
return data.reason ?? t("chat.responses.cannot_continue");
case "plan":
return data.rationale
? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}`
: JSON.stringify(data.steps, null, 2);
case "need_content":
case "tool_call":
return (
data.rationale ??
data.summary ??
t("chat.responses.processing", { outcome: data.outcome })
);
default:
return (
data.answer ?? data.summary ?? data.message ?? JSON.stringify(data)
);
}
}
/**
* Parses an SSE text stream and invokes callbacks for each named event.
*/
interface ProgressEvent {
phase: string;
timestamp: number;
tool?: string;
stepIndex?: number;
stepCount?: number;
engineDetail?: AnyEngineProgressDetail;
}
async function consumeSSEStream(
response: Response,
handlers: {
onProgress: (data: ProgressEvent) => void;
onResult: (data: AiWorkflowResponse) => void;
onError: (data: { message: string }) => void;
},
) {
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by double newlines
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let dataPayload = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataPayload += line.slice(5);
}
}
if (dataPayload) {
try {
const parsed = JSON.parse(dataPayload);
if (currentEvent === "progress") {
handlers.onProgress(parsed);
} else if (currentEvent === "result") {
handlers.onResult(parsed);
} else if (currentEvent === "error") {
handlers.onError(parsed);
}
} catch {
// Skip malformed JSON frames
}
}
currentEvent = "";
boundary = buffer.indexOf("\n\n");
}
}
} finally {
reader.releaseLock();
}
}
interface ChatContextValue {
messages: ChatMessage[];
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event for the current in-flight request. */
progressLog: AiWorkflowProgress[];
sendMessage: (content: string) => Promise<void>;
cancelMessage: () => void;
/** Abort any in-flight request and reset the chat to an empty conversation. */
clearChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
const initialState: ChatState = {
messages: [],
isLoading: false,
progress: null,
progressLog: [],
};
export function ChatProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const abortRef = useRef<AbortController | null>(null);
const messagesRef = useRef<ChatMessage[]>(state.messages);
messagesRef.current = state.messages;
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
async (descriptor: AiWorkflowResultFile): Promise<File> => {
const response = await apiClient.get<Blob>(
`/api/v1/general/files/${descriptor.fileId}`,
{ responseType: "blob" },
);
return new File([response.data], descriptor.fileName, {
type: descriptor.contentType ?? response.data.type,
});
},
[],
);
// Import the files produced by an AI workflow result into FileContext.
//
// If the workflow produced the same number of outputs as inputs, map each output to its
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
// add the outputs as new root files.
const importResultFile = useCallback(
async (
result: AiWorkflowResponse,
sourceStubs: StirlingFileStub[],
): Promise<void> => {
const descriptors = result.resultFiles?.length
? result.resultFiles
: result.fileId && result.fileName && result.contentType
? [
{
fileId: result.fileId,
fileName: result.fileName,
contentType: result.contentType,
} satisfies AiWorkflowResultFile,
]
: [];
if (descriptors.length === 0) return;
const files = await Promise.all(descriptors.map(downloadFile));
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping = files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
// on every render and breaking useFileWithUrl's memoisation (continuous PDF reloads).
await fileActions.addFiles(files, { selectFiles: true });
}
},
[fileActions, downloadFile],
);
const cancelMessage = useCallback(() => {
abortRef.current?.abort();
}, []);
const clearChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
dispatch({ type: "CLEAR" });
}, []);
const sendMessage = useCallback(
async (content: string) => {
// Abort any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const priorMessages = messagesRef.current;
const startTime = Date.now();
// Mirror every progress event locally so we can attach the full log to
// the assistant message when the result arrives — without needing a ref
// into the reducer state.
const progressLogLocal: AiWorkflowProgress[] = [];
const userMessage: ChatMessage = {
id: generateId(),
role: ChatRole.USER,
content,
timestamp: Date.now(),
};
dispatch({ type: "ADD_MESSAGE", message: userMessage });
dispatch({ type: "SET_LOADING", loading: true });
dispatch({ type: "SET_PROGRESS", progress: null });
try {
const formData = new FormData();
formData.append("userMessage", content);
activeFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});
priorMessages.forEach((message, i) => {
formData.append(`conversationHistory[${i}].role`, message.role);
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch(
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
{
method: "POST",
body: formData,
headers: await getAuthHeaders(),
credentials: "include",
signal: controller.signal,
},
);
if (!response.ok) {
let detail: string | undefined;
try {
const body = await response.json();
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
} catch {
// non-JSON body — ignore
}
throw new Error(
detail ?? `AI engine request failed: ${response.status}`,
);
}
let receivedResult = false;
const toolsUsed: string[] = [];
await consumeSSEStream(response, {
onProgress: (data) => {
if (
data.phase === AiWorkflowPhase.EXECUTING_TOOL &&
typeof data.tool === "string"
) {
toolsUsed.push(data.tool);
}
const progressItem: AiWorkflowProgress = {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
engineDetail: data.engineDetail,
};
progressLogLocal.push(progressItem);
dispatch({ type: "APPEND_PROGRESS", progress: progressItem });
},
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data, t);
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content: replyContent,
timestamp: Date.now(),
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
progressLog:
progressLogLocal.length > 0
? [...progressLogLocal]
: undefined,
durationMs: Date.now() - startTime,
},
});
if (data.fileId || data.resultFiles?.length) {
importResultFile(data, activeFileStubs).catch((err) => {
console.error("Failed to import AI result file", err);
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content:
"The file was processed but I couldn't download it.",
timestamp: Date.now(),
},
});
});
}
},
onError: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content: data.message || "Something went wrong.",
timestamp: Date.now(),
},
});
},
});
if (!receivedResult) {
throw new Error("Stream ended without a result");
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
const err = e as Error;
const isEngineError =
err.message.startsWith("AI engine request failed:") ||
err.message === "Stream ended without a result";
const content = isEngineError
? "Failed to get a response. The AI engine may not be available yet."
: (err.message ??
"Failed to get a response. The AI engine may not be available yet.");
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content,
timestamp: Date.now(),
},
});
} finally {
dispatch({ type: "SET_LOADING", loading: false });
if (abortRef.current === controller) {
abortRef.current = null;
}
}
},
[activeFiles, activeFileStubs, importResultFile],
);
return (
<ChatContext.Provider
value={{
messages: state.messages,
isLoading: state.isLoading,
progress: state.progress,
progressLog: state.progressLog,
sendMessage,
cancelMessage,
clearChat,
}}
>
{children}
</ChatContext.Provider>
);
}
export function useChat(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
}