mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 22:32:39 +02:00
Merge upstream/dev into feature/multi-agent
This commit is contained in:
commit
5119915f4f
278 changed files with 34669 additions and 8970 deletions
305
surfsense_web/lib/chat/chat-error-classifier.ts
Normal file
305
surfsense_web/lib/chat/chat-error-classifier.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
export type ChatFlow = "new" | "resume" | "regenerate";
|
||||
|
||||
export type ChatErrorKind =
|
||||
| "premium_quota_exhausted"
|
||||
| "thread_busy"
|
||||
| "send_failed_pre_accept"
|
||||
| "auth_expired"
|
||||
| "rate_limited"
|
||||
| "network_offline"
|
||||
| "stream_interrupted"
|
||||
| "stream_parse_error"
|
||||
| "tool_execution_error"
|
||||
| "persist_message_failed"
|
||||
| "server_error"
|
||||
| "unknown";
|
||||
|
||||
export type ChatErrorChannel = "pinned_inline" | "toast" | "silent";
|
||||
export type ChatTelemetryEvent = "chat_blocked" | "chat_error";
|
||||
export type ChatErrorSeverity = "info" | "warn" | "error";
|
||||
|
||||
export interface NormalizedChatError {
|
||||
kind: ChatErrorKind;
|
||||
channel: ChatErrorChannel;
|
||||
severity: ChatErrorSeverity;
|
||||
telemetryEvent: ChatTelemetryEvent;
|
||||
isExpected: boolean;
|
||||
userMessage: string;
|
||||
assistantMessage?: string;
|
||||
rawMessage?: string;
|
||||
errorCode?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RawChatErrorInput {
|
||||
error: unknown;
|
||||
flow: ChatFlow;
|
||||
context?: {
|
||||
searchSpaceId?: number;
|
||||
threadId?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const PREMIUM_QUOTA_ASSISTANT_MESSAGE =
|
||||
"I can’t continue with the current premium model because your premium credit is exhausted. Switch to a free model or top up your credit to continue.";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(
|
||||
error: unknown,
|
||||
parsedJson: Record<string, unknown> | null
|
||||
): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
if (withCode.errorCode) return withCode.errorCode;
|
||||
if (withCode.code) return withCode.code;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const withCode = error as { errorCode?: unknown };
|
||||
if (typeof withCode.errorCode === "string" && withCode.errorCode) {
|
||||
return withCode.errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
const topLevelCode = parsedJson.errorCode;
|
||||
if (typeof topLevelCode === "string" && topLevelCode) {
|
||||
return topLevelCode;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseEmbeddedJson(text: string): Record<string, unknown> | null {
|
||||
const candidates = [text];
|
||||
const firstBraceIdx = text.indexOf("{");
|
||||
if (firstBraceIdx >= 0) {
|
||||
candidates.push(text.slice(firstBraceIdx));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferProviderErrorType(parsedJson: Record<string, unknown> | null): string | undefined {
|
||||
if (!parsedJson) return undefined;
|
||||
const topLevelType = parsedJson.type;
|
||||
if (typeof topLevelType === "string" && topLevelType) return topLevelType;
|
||||
const nestedError = parsedJson.error;
|
||||
if (typeof nestedError === "object" && nestedError !== null) {
|
||||
const nestedType = (nestedError as Record<string, unknown>).type;
|
||||
if (typeof nestedType === "string" && nestedType) return nestedType;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function classifyChatError(input: RawChatErrorInput): NormalizedChatError {
|
||||
const { error } = input;
|
||||
const rawMessage = getErrorMessage(error);
|
||||
const parsedJson = parseEmbeddedJson(rawMessage);
|
||||
const errorCode = getErrorCode(error, parsedJson);
|
||||
const providerErrorType = inferProviderErrorType(parsedJson);
|
||||
const providerTypeNormalized = providerErrorType?.toLowerCase() ?? "";
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
|
||||
if (errorName === "AbortError") {
|
||||
return {
|
||||
kind: "stream_interrupted",
|
||||
channel: "silent",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Request canceled.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {
|
||||
return {
|
||||
kind: "premium_quota_exhausted",
|
||||
channel: "pinned_inline",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Buy more tokens to continue with this model, or switch to a free model.",
|
||||
assistantMessage: PREMIUM_QUOTA_ASSISTANT_MESSAGE,
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PREMIUM_QUOTA_EXHAUSTED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TURN_CANCELLING") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "A previous response is still stopping. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TURN_CANCELLING",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "THREAD_BUSY") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"Another response is still finishing for this thread. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "THREAD_BUSY",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SEND_FAILED_PRE_ACCEPT") {
|
||||
return {
|
||||
kind: "send_failed_pre_accept",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Message not sent. Please retry.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SEND_FAILED_PRE_ACCEPT",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "AUTH_EXPIRED" || errorCode === "UNAUTHORIZED") {
|
||||
return {
|
||||
kind: "auth_expired",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Your session expired. Please sign in again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "AUTH_EXPIRED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "RATE_LIMITED" || providerTypeNormalized === "rate_limit_error") {
|
||||
return {
|
||||
kind: "rate_limited",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"This model is temporarily rate-limited. Please try again in a few seconds or switch models.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "RATE_LIMITED",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "NETWORK_ERROR") {
|
||||
return {
|
||||
kind: "network_offline",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Connection issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "NETWORK_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "STREAM_PARSE_ERROR") {
|
||||
return {
|
||||
kind: "stream_parse_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We hit a response formatting issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "STREAM_PARSE_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TOOL_EXECUTION_ERROR") {
|
||||
return {
|
||||
kind: "tool_execution_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "A tool failed while processing your request. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TOOL_EXECUTION_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PERSIST_MESSAGE_FAILED") {
|
||||
return {
|
||||
kind: "persist_message_failed",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "Response generated, but saving failed. Please retry once.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PERSIST_MESSAGE_FAILED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SERVER_ERROR") {
|
||||
return {
|
||||
kind: "server_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SERVER_ERROR",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unknown",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export async function toHttpResponseError(
|
||||
response: Response
|
||||
): Promise<Error & { errorCode?: string; retryAfterMs?: number }> {
|
||||
const statusDefaultCode =
|
||||
response.status === 409
|
||||
? "THREAD_BUSY"
|
||||
: response.status === 429
|
||||
? "RATE_LIMITED"
|
||||
: response.status === 401 || response.status === 403
|
||||
? "AUTH_EXPIRED"
|
||||
: "SERVER_ERROR";
|
||||
|
||||
let rawBody = "";
|
||||
try {
|
||||
rawBody = await response.text();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
let parsedBody: Record<string, unknown> | null = null;
|
||||
if (rawBody) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
parsedBody = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const detail = parsedBody?.detail;
|
||||
const detailObject =
|
||||
typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : null;
|
||||
const detailMessage = typeof detail === "string" ? detail : undefined;
|
||||
const topLevelMessage =
|
||||
typeof parsedBody?.message === "string" ? (parsedBody.message as string) : undefined;
|
||||
const detailNestedMessage =
|
||||
typeof detailObject?.message === "string" ? (detailObject.message as string) : undefined;
|
||||
|
||||
const topLevelCode =
|
||||
typeof parsedBody?.errorCode === "string"
|
||||
? parsedBody.errorCode
|
||||
: typeof parsedBody?.error_code === "string"
|
||||
? parsedBody.error_code
|
||||
: undefined;
|
||||
const detailCode =
|
||||
typeof detailObject?.errorCode === "string"
|
||||
? detailObject.errorCode
|
||||
: typeof detailObject?.error_code === "string"
|
||||
? detailObject.error_code
|
||||
: undefined;
|
||||
|
||||
const errorCode = detailCode ?? topLevelCode ?? statusDefaultCode;
|
||||
|
||||
const detailRetryAfterMs =
|
||||
typeof detailObject?.retry_after_ms === "number"
|
||||
? detailObject.retry_after_ms
|
||||
: typeof detailObject?.retryAfterMs === "number"
|
||||
? detailObject.retryAfterMs
|
||||
: undefined;
|
||||
const topRetryAfterMs =
|
||||
typeof parsedBody?.retry_after_ms === "number"
|
||||
? parsedBody.retry_after_ms
|
||||
: typeof parsedBody?.retryAfterMs === "number"
|
||||
? parsedBody.retryAfterMs
|
||||
: undefined;
|
||||
const headerRetryAfterMsRaw = response.headers.get("retry-after-ms");
|
||||
const headerRetryAfterMs = headerRetryAfterMsRaw ? Number.parseFloat(headerRetryAfterMsRaw) : NaN;
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfterSeconds = retryAfterHeader ? Number.parseFloat(retryAfterHeader) : NaN;
|
||||
const retryAfterMsFromHeader = Number.isFinite(headerRetryAfterMs)
|
||||
? Math.max(0, Math.round(headerRetryAfterMs))
|
||||
: Number.isFinite(retryAfterSeconds)
|
||||
? Math.max(0, Math.round(retryAfterSeconds * 1000))
|
||||
: undefined;
|
||||
const retryAfterMs = detailRetryAfterMs ?? topRetryAfterMs ?? retryAfterMsFromHeader ?? undefined;
|
||||
const message =
|
||||
detailNestedMessage ?? detailMessage ?? topLevelMessage ?? `Backend error: ${response.status}`;
|
||||
|
||||
return Object.assign(new Error(message), { errorCode, retryAfterMs });
|
||||
}
|
||||
|
||||
export function tagPreAcceptSendFailure(error: unknown): unknown {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
const existingCode = withCode.errorCode ?? withCode.code;
|
||||
const passthroughCodes = new Set([
|
||||
"PREMIUM_QUOTA_EXHAUSTED",
|
||||
"THREAD_BUSY",
|
||||
"TURN_CANCELLING",
|
||||
"AUTH_EXPIRED",
|
||||
"UNAUTHORIZED",
|
||||
"RATE_LIMITED",
|
||||
"NETWORK_ERROR",
|
||||
"STREAM_PARSE_ERROR",
|
||||
"TOOL_EXECUTION_ERROR",
|
||||
"PERSIST_MESSAGE_FAILED",
|
||||
"SERVER_ERROR",
|
||||
]);
|
||||
if (existingCode && passthroughCodes.has(existingCode)) {
|
||||
return Object.assign(error, { errorCode: existingCode });
|
||||
}
|
||||
return Object.assign(error, { errorCode: "SEND_FAILED_PRE_ACCEPT" });
|
||||
}
|
||||
|
||||
return Object.assign(new Error("Failed to send message before stream acceptance"), {
|
||||
errorCode: "SEND_FAILED_PRE_ACCEPT",
|
||||
});
|
||||
}
|
||||
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
|
||||
export type MentionSegment =
|
||||
| { type: "text"; value: string; start: number }
|
||||
| { type: "mention"; doc: MentionedDocumentInfo; start: number };
|
||||
|
||||
/**
|
||||
* Tokenizes a user message into text and `@mention` segments.
|
||||
*
|
||||
* Pure: no React, no DOM, no side effects. Safe to unit-test and reuse.
|
||||
*
|
||||
* Mentions are matched greedily by longest title first so that a longer title
|
||||
* (e.g. `@Project Roadmap`) is never shadowed by a shorter prefix
|
||||
* (e.g. `@Project`).
|
||||
*/
|
||||
export function parseMentionSegments(
|
||||
text: string,
|
||||
docs: ReadonlyArray<MentionedDocumentInfo>
|
||||
): MentionSegment[] {
|
||||
if (text.length === 0) return [];
|
||||
if (docs.length === 0) return [{ type: "text", value: text, start: 0 }];
|
||||
|
||||
const tokens = docs
|
||||
.map((doc) => ({ doc, token: `@${doc.title}` }))
|
||||
.sort((a, b) => b.token.length - a.token.length);
|
||||
|
||||
const segments: MentionSegment[] = [];
|
||||
let i = 0;
|
||||
let buffer = "";
|
||||
let bufferStart = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
|
||||
if (tokenMatch) {
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
buffer = "";
|
||||
}
|
||||
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
|
||||
i += tokenMatch.token.length;
|
||||
bufferStart = i;
|
||||
continue;
|
||||
}
|
||||
if (!buffer) bufferStart = i;
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrameBatchedUpdater } from "@/lib/chat/streaming-state";
|
||||
|
||||
export function createStreamFlushHelpers(flushMessages: () => void): {
|
||||
batcher: FrameBatchedUpdater;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
} {
|
||||
const batcher = new FrameBatchedUpdater();
|
||||
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||
// Force-flush helper: ``batcher.flush()`` is a no-op when
|
||||
// ``dirty=false`` (e.g. a tool starts before any text streamed).
|
||||
// ``scheduleFlush(); batcher.flush()`` sets the dirty bit first so
|
||||
// terminal events render promptly without the throttle delay.
|
||||
const forceFlush = () => {
|
||||
scheduleFlush();
|
||||
batcher.flush();
|
||||
};
|
||||
return { batcher, scheduleFlush, forceFlush };
|
||||
}
|
||||
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
appendToolInputDelta,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
readSSEStream,
|
||||
type SSEEvent,
|
||||
type ThinkingStepData,
|
||||
type ToolUIGate,
|
||||
updateThinkingSteps,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
export type SharedStreamEventContext = {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolsWithUI: ToolUIGate;
|
||||
currentThinkingSteps: Map<string, ThinkingStepData>;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
onTokenUsage?: (data: Extract<SSEEvent, { type: "data-token-usage" }>["data"]) => void;
|
||||
onTurnStatus?: (data: Extract<SSEEvent, { type: "data-turn-status" }>["data"]) => void;
|
||||
onToolOutputAvailable?: (
|
||||
event: Extract<SSEEvent, { type: "tool-output-available" }>,
|
||||
context: {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolCallIndices: Map<string, number>;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a tool produces output, mark any previously-decided interrupt tool
|
||||
* calls as completed so the ApprovalCard can transition from shimmer to done.
|
||||
*/
|
||||
export function markInterruptsCompleted(
|
||||
contentParts: Array<{ type: string; result?: unknown }>
|
||||
): void {
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
(part.result as Record<string, unknown>).__interrupt__ === true &&
|
||||
(part.result as Record<string, unknown>).__decided__ &&
|
||||
!(part.result as Record<string, unknown>).__completed__
|
||||
) {
|
||||
part.result = { ...(part.result as Record<string, unknown>), __completed__: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistableContent(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
toolsWithUI: ToolUIGate
|
||||
) {
|
||||
return contentParts.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text.length > 0) ||
|
||||
(part.type === "reasoning" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" && (toolsWithUI === "all" || toolsWithUI.has(part.toolName)))
|
||||
);
|
||||
}
|
||||
|
||||
function toStreamTerminalError(
|
||||
event: Extract<SSEEvent, { type: "error" }>
|
||||
): Error & { errorCode?: string } {
|
||||
return Object.assign(new Error(event.errorText || "Server error"), {
|
||||
errorCode: event.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
export function processSharedStreamEvent(
|
||||
parsed: SSEEvent,
|
||||
context: SharedStreamEventContext
|
||||
): boolean {
|
||||
const { contentPartsState, toolsWithUI, currentThinkingSteps, scheduleFlush, forceFlush } =
|
||||
context;
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
appendText(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "finish-step":
|
||||
return true;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-delta":
|
||||
// High-frequency event: deltas can fire dozens of times per call,
|
||||
// so use throttled scheduleFlush (NOT forceFlush) to coalesce.
|
||||
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-available": {
|
||||
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
// addToolCall doesn't accept argsText today; backfill via
|
||||
// updateToolCall so the new card renders pretty-printed JSON.
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
argsText: finalArgsText,
|
||||
});
|
||||
}
|
||||
forceFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "data-thinking-step": {
|
||||
const stepData = parsed.data as ThinkingStepData;
|
||||
if (stepData?.id) {
|
||||
currentThinkingSteps.set(stepData.id, stepData);
|
||||
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||
if (didUpdate) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
context.onTokenUsage?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "data-turn-status":
|
||||
context.onTurnStatus?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "error":
|
||||
throw toStreamTerminalError(parsed);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeSseEvents(
|
||||
response: Response,
|
||||
onEvent: (event: SSEEvent) => void | Promise<void>
|
||||
): Promise<void> {
|
||||
for await (const parsed of readSSEStream(response)) {
|
||||
await onEvent(parsed);
|
||||
}
|
||||
}
|
||||
59
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
59
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* When a streamed message is persisted, the backend returns the durable
|
||||
* turn_id; merge it into assistant-ui metadata for turn-scoped actions.
|
||||
*/
|
||||
export function mergeChatTurnIdIntoMessage(
|
||||
msg: ThreadMessageLike,
|
||||
turnId: string | null | undefined
|
||||
): ThreadMessageLike {
|
||||
if (!turnId) return msg;
|
||||
const existingMeta = (msg.metadata ?? {}) as { custom?: Record<string, unknown> };
|
||||
const existingCustom = existingMeta.custom ?? {};
|
||||
if ((existingCustom as { chatTurnId?: string }).chatTurnId === turnId) return msg;
|
||||
return {
|
||||
...msg,
|
||||
metadata: {
|
||||
...existingMeta,
|
||||
custom: { ...existingCustom, chatTurnId: turnId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readStreamedChatTurnId(data: unknown): string | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const value = (data as { chat_turn_id?: unknown }).chat_turn_id;
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the payload of `data-user-message-id` / `data-assistant-message-id`
|
||||
* SSE events emitted by `stream_new_chat` and `stream_resume_chat` after
|
||||
* `persist_user_turn` / `persist_assistant_shell` resolve a canonical
|
||||
* `new_chat_messages.id`. Mirrors {@link readStreamedChatTurnId}.
|
||||
*
|
||||
* Returns `null` when the payload is malformed (missing or non-numeric
|
||||
* `message_id`); callers should treat this as "ignore the event" so a
|
||||
* malformed BE payload never overwrites the optimistic id with a bogus
|
||||
* value.
|
||||
*/
|
||||
export function readStreamedMessageId(
|
||||
data: unknown
|
||||
): { messageId: number; turnId: string | null } | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const obj = data as { message_id?: unknown; turn_id?: unknown };
|
||||
if (typeof obj.message_id !== "number" || !Number.isFinite(obj.message_id)) {
|
||||
return null;
|
||||
}
|
||||
const turnId = typeof obj.turn_id === "string" && obj.turn_id.length > 0 ? obj.turn_id : null;
|
||||
return { messageId: obj.message_id, turnId };
|
||||
}
|
||||
|
||||
export function applyTurnIdToAssistantMessageList(
|
||||
messages: ThreadMessageLike[],
|
||||
assistantMsgId: string,
|
||||
turnId: string
|
||||
): ThreadMessageLike[] {
|
||||
return messages.map((m) => (m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, turnId) : m));
|
||||
}
|
||||
|
|
@ -487,6 +487,37 @@ export type SSEEvent =
|
|||
type: "data-turn-info";
|
||||
data: { chat_turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AFTER ``data-turn-info`` /
|
||||
* ``data-turn-status`` and BEFORE any LLM streaming events,
|
||||
* once ``persist_user_turn`` has resolved the canonical
|
||||
* ``new_chat_messages.id`` for the user-side row of the
|
||||
* current turn. The frontend renames its optimistic
|
||||
* ``msg-user-XXX`` placeholder id to ``msg-{message_id}``
|
||||
* so DB-id-gated UI (comments, edit-from-this-message)
|
||||
* unlocks immediately. Not emitted by ``stream_resume_chat``
|
||||
* (resume reuses the original turn's user message).
|
||||
*/
|
||||
type: "data-user-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AND ``stream_resume_chat``
|
||||
* AFTER ``data-turn-info`` / ``data-turn-status`` and BEFORE
|
||||
* any LLM streaming events, once ``persist_assistant_shell``
|
||||
* has resolved the canonical ``new_chat_messages.id`` for
|
||||
* the assistant-side row of the current turn. The frontend
|
||||
* renames its optimistic ``msg-assistant-XXX`` placeholder
|
||||
* id, migrates the local ``tokenUsageStore`` and
|
||||
* ``pendingInterrupt`` references, and binds the running
|
||||
* mutable ``assistantMsgId`` closure variable to the
|
||||
* canonical id for the rest of the stream.
|
||||
*/
|
||||
type: "data-assistant-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Best-effort revert pass that ran BEFORE this regeneration.
|
||||
|
|
@ -528,25 +559,40 @@ export type SSEEvent =
|
|||
}>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-turn-status";
|
||||
data: {
|
||||
status: "idle" | "busy" | "cancelling";
|
||||
retry_after_ms?: number;
|
||||
retry_after_at?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-token-usage";
|
||||
data: {
|
||||
usage: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
call_details: Array<{
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| { type: "error"; errorText: string };
|
||||
| { type: "error"; errorText: string; errorCode?: string };
|
||||
|
||||
/**
|
||||
* Async generator that reads an SSE stream and yields parsed JSON objects.
|
||||
|
|
|
|||
|
|
@ -30,9 +30,20 @@ export interface TokenUsageSummary {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
/**
|
||||
* Total provider USD cost for this assistant turn, in micro-USD
|
||||
* (1_000_000 = $1.00). Optional because rows persisted before the
|
||||
* cost-credits migration won't have it.
|
||||
*/
|
||||
cost_micros?: number;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
> | null;
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +144,17 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
|
|||
* via ``data-turn-info``. Persisting it lets later edits locate the
|
||||
* matching LangGraph checkpoint without HumanMessage scanning. Older
|
||||
* callers can still omit it for back-compat.
|
||||
*
|
||||
* @deprecated Replaced by the SSE-based message ID handshake. The
|
||||
* streaming generator (`stream_new_chat` / `stream_resume_chat`) now
|
||||
* persists both the user and assistant rows server-side via
|
||||
* `persist_user_turn` / `persist_assistant_shell` and emits
|
||||
* `data-user-message-id` / `data-assistant-message-id` SSE events so
|
||||
* the UI renames its optimistic IDs in real time. The only remaining
|
||||
* caller is `persistAssistantErrorMessage` (pre-stream error fallback
|
||||
* for requests the server never accepted — the server has nothing to
|
||||
* persist in that case). After the legacy route is removed in a
|
||||
* follow-up PR this function will be deleted entirely.
|
||||
*/
|
||||
export async function appendMessage(
|
||||
threadId: number,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue