mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
refactor(chat): implement new error handling utilities and streamline interrupt request processing in NewChatPage for improved performance and maintainability
This commit is contained in:
parent
86f6b285ce
commit
d65a3fdf76
3 changed files with 246 additions and 208 deletions
|
|
@ -64,6 +64,10 @@ import {
|
||||||
classifyChatError,
|
classifyChatError,
|
||||||
type ChatFlow,
|
type ChatFlow,
|
||||||
} from "@/lib/chat/chat-error-classifier";
|
} from "@/lib/chat/chat-error-classifier";
|
||||||
|
import {
|
||||||
|
tagPreAcceptSendFailure,
|
||||||
|
toHttpResponseError,
|
||||||
|
} from "@/lib/chat/chat-request-errors";
|
||||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||||
import {
|
import {
|
||||||
isPodcastGenerating,
|
isPodcastGenerating,
|
||||||
|
|
@ -71,14 +75,12 @@ import {
|
||||||
setActivePodcastTaskId,
|
setActivePodcastTaskId,
|
||||||
} from "@/lib/chat/podcast-state";
|
} from "@/lib/chat/podcast-state";
|
||||||
import {
|
import {
|
||||||
addToolCall,
|
|
||||||
buildContentForPersistence,
|
buildContentForPersistence,
|
||||||
buildContentForUI,
|
buildContentForUI,
|
||||||
type ContentPartsState,
|
type ContentPartsState,
|
||||||
type FrameBatchedUpdater,
|
type FrameBatchedUpdater,
|
||||||
type ThinkingStepData,
|
type ThinkingStepData,
|
||||||
type ToolUIGate,
|
type ToolUIGate,
|
||||||
updateToolCall,
|
|
||||||
} from "@/lib/chat/streaming-state";
|
} from "@/lib/chat/streaming-state";
|
||||||
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
||||||
import {
|
import {
|
||||||
|
|
@ -86,6 +88,14 @@ import {
|
||||||
hasPersistableContent,
|
hasPersistableContent,
|
||||||
processSharedStreamEvent,
|
processSharedStreamEvent,
|
||||||
} from "@/lib/chat/stream-pipeline";
|
} from "@/lib/chat/stream-pipeline";
|
||||||
|
import {
|
||||||
|
applyTurnIdToAssistantMessageList,
|
||||||
|
applyInterruptRequestToContentParts,
|
||||||
|
mergeChatTurnIdIntoMessage,
|
||||||
|
mergeEditedInterruptAction,
|
||||||
|
markInterruptDecisionOnContentParts,
|
||||||
|
readStreamedChatTurnId,
|
||||||
|
} from "@/lib/chat/stream-side-effects";
|
||||||
import {
|
import {
|
||||||
appendMessage,
|
appendMessage,
|
||||||
createThread,
|
createThread,
|
||||||
|
|
@ -132,97 +142,6 @@ const MobileReportPanel = dynamic(
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
async function toHttpResponseError(response: Response): Promise<Error & { errorCode?: string }> {
|
|
||||||
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 message =
|
|
||||||
detailNestedMessage ??
|
|
||||||
detailMessage ??
|
|
||||||
topLevelMessage ??
|
|
||||||
`Backend error: ${response.status}`;
|
|
||||||
|
|
||||||
return Object.assign(new Error(message), { errorCode });
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
"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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod schema for mentioned document info (for type-safe parsing)
|
* Zod schema for mentioned document info (for type-safe parsing)
|
||||||
*/
|
*/
|
||||||
|
|
@ -264,29 +183,6 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
||||||
*/
|
*/
|
||||||
const TOOLS_WITH_UI_ALL: ToolUIGate = "all";
|
const TOOLS_WITH_UI_ALL: ToolUIGate = "all";
|
||||||
|
|
||||||
/**
|
|
||||||
* When a streamed message is persisted, the backend returns the durable
|
|
||||||
* ``turn_id`` (``configurable.turn_id`` from the agent run). Merge it
|
|
||||||
* into the assistant-ui message metadata so the per-turn "Revert turn"
|
|
||||||
* button can scope to this turn's actions even after a full chat reload.
|
|
||||||
*/
|
|
||||||
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 default function NewChatPage() {
|
export default function NewChatPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -1032,7 +928,7 @@ export default function NewChatPage() {
|
||||||
currentReasoningPartIndex: -1,
|
currentReasoningPartIndex: -1,
|
||||||
toolCallIndices: new Map(),
|
toolCallIndices: new Map(),
|
||||||
};
|
};
|
||||||
const { contentParts, toolCallIndices } = contentPartsState;
|
const { contentParts } = contentPartsState;
|
||||||
let wasInterrupted = false;
|
let wasInterrupted = false;
|
||||||
let tokenUsageData: TokenUsageData | null = null;
|
let tokenUsageData: TokenUsageData | null = null;
|
||||||
let newAccepted = false;
|
let newAccepted = false;
|
||||||
|
|
@ -1194,27 +1090,7 @@ export default function NewChatPage() {
|
||||||
case "data-interrupt-request": {
|
case "data-interrupt-request": {
|
||||||
wasInterrupted = true;
|
wasInterrupted = true;
|
||||||
const interruptData = parsed.data as Record<string, unknown>;
|
const interruptData = parsed.data as Record<string, unknown>;
|
||||||
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData);
|
||||||
name: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
for (const action of actionRequests) {
|
|
||||||
const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
|
||||||
const part = contentParts[idx];
|
|
||||||
return part?.type === "tool-call" && part.toolName === action.name;
|
|
||||||
});
|
|
||||||
if (existingIdx) {
|
|
||||||
updateToolCall(contentPartsState, existingIdx[0], {
|
|
||||||
result: { __interrupt__: true, ...interruptData },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tcId = `interrupt-${action.name}`;
|
|
||||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
|
||||||
updateToolCall(contentPartsState, tcId, {
|
|
||||||
result: { __interrupt__: true, ...interruptData },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId
|
m.id === assistantMsgId
|
||||||
|
|
@ -1248,12 +1124,11 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "data-turn-info": {
|
case "data-turn-info": {
|
||||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
const turnId = readStreamedChatTurnId(parsed.data);
|
||||||
if (streamedChatTurnId) {
|
streamedChatTurnId = turnId;
|
||||||
|
if (turnId) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
|
||||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1469,37 +1344,12 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge edited args if present to fix race condition
|
// Merge edited args if present to fix race condition
|
||||||
if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) {
|
if (decisions.length > 0 && decisions[0].type === "edit") {
|
||||||
const editedAction = decisions[0].edited_action;
|
mergeEditedInterruptAction(contentParts, decisions[0].edited_action);
|
||||||
for (const part of contentParts) {
|
|
||||||
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
|
||||||
const mergedArgs = { ...part.args, ...editedAction.args };
|
|
||||||
part.args = mergedArgs;
|
|
||||||
// Sync argsText so the rendered card shows the
|
|
||||||
// edited inputs — assistant-ui prefers caller-
|
|
||||||
// supplied argsText over JSON.stringify(args).
|
|
||||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decisionType = decisions[0]?.type as "approve" | "reject" | undefined;
|
const decisionType = decisions[0]?.type as "approve" | "reject" | undefined;
|
||||||
if (decisionType) {
|
markInterruptDecisionOnContentParts(contentParts, decisionType);
|
||||||
for (const part of contentParts) {
|
|
||||||
if (
|
|
||||||
part.type === "tool-call" &&
|
|
||||||
typeof part.result === "object" &&
|
|
||||||
part.result !== null &&
|
|
||||||
"__interrupt__" in (part.result as Record<string, unknown>)
|
|
||||||
) {
|
|
||||||
part.result = {
|
|
||||||
...(part.result as Record<string, unknown>),
|
|
||||||
__decided__: decisionType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
@ -1556,33 +1406,7 @@ export default function NewChatPage() {
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "data-interrupt-request": {
|
case "data-interrupt-request": {
|
||||||
const interruptData = parsed.data as Record<string, unknown>;
|
const interruptData = parsed.data as Record<string, unknown>;
|
||||||
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData);
|
||||||
name: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
for (const action of actionRequests) {
|
|
||||||
const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
|
||||||
const part = contentParts[idx];
|
|
||||||
return part?.type === "tool-call" && part.toolName === action.name;
|
|
||||||
});
|
|
||||||
if (existingIdx) {
|
|
||||||
updateToolCall(contentPartsState, existingIdx[0], {
|
|
||||||
result: {
|
|
||||||
__interrupt__: true,
|
|
||||||
...interruptData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tcId = `interrupt-${action.name}`;
|
|
||||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
|
||||||
updateToolCall(contentPartsState, tcId, {
|
|
||||||
result: {
|
|
||||||
__interrupt__: true,
|
|
||||||
...interruptData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId
|
m.id === assistantMsgId
|
||||||
|
|
@ -1614,12 +1438,11 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "data-turn-info": {
|
case "data-turn-info": {
|
||||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
const turnId = readStreamedChatTurnId(parsed.data);
|
||||||
if (streamedChatTurnId) {
|
streamedChatTurnId = turnId;
|
||||||
|
if (turnId) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
|
||||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1987,12 +1810,11 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "data-turn-info": {
|
case "data-turn-info": {
|
||||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
const turnId = readStreamedChatTurnId(parsed.data);
|
||||||
if (streamedChatTurnId) {
|
streamedChatTurnId = turnId;
|
||||||
|
if (turnId) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
|
||||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
89
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
89
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
export async function toHttpResponseError(
|
||||||
|
response: Response
|
||||||
|
): Promise<Error & { errorCode?: string }> {
|
||||||
|
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 message =
|
||||||
|
detailNestedMessage ??
|
||||||
|
detailMessage ??
|
||||||
|
topLevelMessage ??
|
||||||
|
`Backend error: ${response.status}`;
|
||||||
|
|
||||||
|
return Object.assign(new Error(message), { errorCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"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",
|
||||||
|
});
|
||||||
|
}
|
||||||
127
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
127
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||||
|
import {
|
||||||
|
addToolCall,
|
||||||
|
type ContentPartsState,
|
||||||
|
type ToolUIGate,
|
||||||
|
updateToolCall,
|
||||||
|
} from "@/lib/chat/streaming-state";
|
||||||
|
|
||||||
|
type InterruptActionRequest = {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditedInterruptAction = {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readInterruptActions(
|
||||||
|
interruptData: Record<string, unknown>
|
||||||
|
): InterruptActionRequest[] {
|
||||||
|
return (interruptData.action_requests ?? []) as InterruptActionRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an interrupt request payload to tool-call parts. Existing tool cards
|
||||||
|
* are updated in-place; missing ones are upserted so approval UI always shows.
|
||||||
|
*/
|
||||||
|
export function applyInterruptRequestToContentParts(
|
||||||
|
contentPartsState: ContentPartsState,
|
||||||
|
toolsWithUI: ToolUIGate,
|
||||||
|
interruptData: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
const actionRequests = readInterruptActions(interruptData);
|
||||||
|
for (const action of actionRequests) {
|
||||||
|
const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||||
|
const part = contentParts[idx];
|
||||||
|
return part?.type === "tool-call" && part.toolName === action.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEntry) {
|
||||||
|
updateToolCall(contentPartsState, existingEntry[0], {
|
||||||
|
result: { __interrupt__: true, ...interruptData },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const toolCallId = `interrupt-${action.name}`;
|
||||||
|
addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true);
|
||||||
|
updateToolCall(contentPartsState, toolCallId, {
|
||||||
|
result: { __interrupt__: true, ...interruptData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEditedInterruptAction(
|
||||||
|
contentParts: ContentPartsState["contentParts"],
|
||||||
|
editedAction: EditedInterruptAction | undefined
|
||||||
|
): void {
|
||||||
|
if (!editedAction) return;
|
||||||
|
for (const part of contentParts) {
|
||||||
|
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
||||||
|
const mergedArgs = { ...part.args, ...editedAction.args };
|
||||||
|
part.args = mergedArgs;
|
||||||
|
// assistant-ui prefers argsText over JSON.stringify(args)
|
||||||
|
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markInterruptDecisionOnContentParts(
|
||||||
|
contentParts: ContentPartsState["contentParts"],
|
||||||
|
decisionType: "approve" | "reject" | undefined
|
||||||
|
): void {
|
||||||
|
if (!decisionType) return;
|
||||||
|
for (const part of contentParts) {
|
||||||
|
if (
|
||||||
|
part.type === "tool-call" &&
|
||||||
|
typeof part.result === "object" &&
|
||||||
|
part.result !== null &&
|
||||||
|
"__interrupt__" in (part.result as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
part.result = {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decisionType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTurnIdToAssistantMessageList(
|
||||||
|
messages: ThreadMessageLike[],
|
||||||
|
assistantMsgId: string,
|
||||||
|
turnId: string
|
||||||
|
): ThreadMessageLike[] {
|
||||||
|
return messages.map((m) =>
|
||||||
|
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, turnId) : m
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue