mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
feat: improved agent streaming
This commit is contained in:
parent
afb4b09cde
commit
c110f5b955
60 changed files with 8068 additions and 303 deletions
|
|
@ -14,6 +14,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import {
|
||||
agentActionsByChatTurnIdAtom,
|
||||
markAgentActionRevertedAtom,
|
||||
resetAgentActionMapAtom,
|
||||
updateAgentActionReversibleAtom,
|
||||
upsertAgentActionAtom,
|
||||
} from "@/atoms/chat/agent-actions.atom";
|
||||
import {
|
||||
clearTargetCommentIdAtom,
|
||||
currentThreadAtom,
|
||||
|
|
@ -36,6 +43,11 @@ import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
EditMessageDialog,
|
||||
type EditMessageDialogChoice,
|
||||
} from "@/components/assistant-ui/edit-message-dialog";
|
||||
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
|
||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import {
|
||||
|
|
@ -55,14 +67,19 @@ import {
|
|||
setActivePodcastTaskId,
|
||||
} from "@/lib/chat/podcast-state";
|
||||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
buildContentForPersistence,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
FrameBatchedUpdater,
|
||||
findToolCallIdByLcId,
|
||||
readSSEStream,
|
||||
type ThinkingStepData,
|
||||
type ToolUIGate,
|
||||
updateThinkingSteps,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
|
@ -161,44 +178,38 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Tools that should render custom UI in the chat.
|
||||
* Every tool call renders a card. The legacy
|
||||
* ``BASE_TOOLS_WITH_UI`` allowlist used to drop unknown tool calls on the
|
||||
* floor; we now route everything through ``ToolFallback``. Persisted
|
||||
* payload size stays bounded because the backend's
|
||||
* ``format_thinking_step`` summarisation and the
|
||||
* ``result_length``-only default for unknown tools (see
|
||||
* ``stream_new_chat.py``) keep the JSON from ballooning.
|
||||
*/
|
||||
const BASE_TOOLS_WITH_UI = new Set([
|
||||
"web_search",
|
||||
"generate_podcast",
|
||||
"generate_report",
|
||||
"generate_resume",
|
||||
"generate_video_presentation",
|
||||
"display_image",
|
||||
"generate_image",
|
||||
"delete_notion_page",
|
||||
"create_notion_page",
|
||||
"update_notion_page",
|
||||
"create_linear_issue",
|
||||
"update_linear_issue",
|
||||
"delete_linear_issue",
|
||||
"create_google_drive_file",
|
||||
"delete_google_drive_file",
|
||||
"create_onedrive_file",
|
||||
"delete_onedrive_file",
|
||||
"create_dropbox_file",
|
||||
"delete_dropbox_file",
|
||||
"create_calendar_event",
|
||||
"update_calendar_event",
|
||||
"delete_calendar_event",
|
||||
"create_gmail_draft",
|
||||
"update_gmail_draft",
|
||||
"send_gmail_email",
|
||||
"trash_gmail_email",
|
||||
"create_jira_issue",
|
||||
"update_jira_issue",
|
||||
"delete_jira_issue",
|
||||
"create_confluence_page",
|
||||
"update_confluence_page",
|
||||
"delete_confluence_page",
|
||||
"execute",
|
||||
// "write_todos", // Disabled for now
|
||||
]);
|
||||
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() {
|
||||
const params = useParams();
|
||||
|
|
@ -215,7 +226,7 @@ export default function NewChatPage() {
|
|||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
const toolsWithUI = useMemo(() => new Set([...BASE_TOOLS_WITH_UI]), []);
|
||||
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
||||
|
||||
// Get disabled tools from the tool toggle UI
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
|
|
@ -235,6 +246,25 @@ export default function NewChatPage() {
|
|||
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
|
||||
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
// Agent action log SSE side-channel.
|
||||
const upsertAgentAction = useSetAtom(upsertAgentActionAtom);
|
||||
const updateAgentActionReversible = useSetAtom(updateAgentActionReversibleAtom);
|
||||
const markAgentActionReverted = useSetAtom(markAgentActionRevertedAtom);
|
||||
const resetAgentActionMap = useSetAtom(resetAgentActionMapAtom);
|
||||
// Chat-turn-keyed action map for the edit-from-position pre-flight
|
||||
// that decides whether to show the confirmation dialog.
|
||||
const agentActionsByChatTurnId = useAtomValue(agentActionsByChatTurnIdAtom);
|
||||
// Edit dialog state. Holds the message id being edited and
|
||||
// the (already extracted) regenerate args so we can resume the edit
|
||||
// after the user picks "revert all" / "continue" / "cancel".
|
||||
const [editDialogState, setEditDialogState] = useState<{
|
||||
fromMessageId: number;
|
||||
userQuery: string | null;
|
||||
userMessageContent: ThreadMessageLike["content"];
|
||||
userImages: NewChatUserImagePayload[];
|
||||
downstreamReversibleCount: number;
|
||||
downstreamTotalCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -327,6 +357,7 @@ export default function NewChatPage() {
|
|||
clearPlanOwnerRegistry();
|
||||
closeReportPanel();
|
||||
closeEditorPanel();
|
||||
resetAgentActionMap();
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
|
|
@ -395,6 +426,7 @@ export default function NewChatPage() {
|
|||
removeChatTab,
|
||||
searchSpaceId,
|
||||
tokenUsageStore,
|
||||
resetAgentActionMap,
|
||||
]);
|
||||
|
||||
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
||||
|
|
@ -655,11 +687,14 @@ export default function NewChatPage() {
|
|||
const contentPartsState: ContentPartsState = {
|
||||
contentParts: [],
|
||||
currentTextPartIndex: -1,
|
||||
currentReasoningPartIndex: -1,
|
||||
toolCallIndices: new Map(),
|
||||
};
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
let wasInterrupted = false;
|
||||
let tokenUsageData: Record<string, unknown> | null = null;
|
||||
// Captured from ``data-turn-info`` at stream start.
|
||||
let streamedChatTurnId: string | null = null;
|
||||
|
||||
// Add placeholder assistant message
|
||||
setMessages((prev) => [
|
||||
|
|
@ -752,21 +787,52 @@ export default function NewChatPage() {
|
|||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "finish-step":
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
case "tool-input-available": {
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {}
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
}
|
||||
batcher.flush();
|
||||
|
|
@ -774,7 +840,10 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
case "tool-output-available": {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||
|
|
@ -880,6 +949,50 @@ export default function NewChatPage() {
|
|||
break;
|
||||
}
|
||||
|
||||
case "data-action-log": {
|
||||
const al = parsed.data;
|
||||
const matchedToolCallId = al.lc_tool_call_id
|
||||
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
|
||||
: null;
|
||||
upsertAgentAction({
|
||||
action: {
|
||||
id: al.id,
|
||||
threadId: currentThreadId,
|
||||
lcToolCallId: al.lc_tool_call_id,
|
||||
chatTurnId: al.chat_turn_id,
|
||||
toolName: al.tool_name,
|
||||
reversible: al.reversible,
|
||||
reverseDescriptorPresent: al.reverse_descriptor_present,
|
||||
error: al.error,
|
||||
revertedByActionId: null,
|
||||
isRevertAction: false,
|
||||
createdAt: al.created_at,
|
||||
},
|
||||
toolCallId: matchedToolCallId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-action-log-updated": {
|
||||
updateAgentActionReversible({
|
||||
id: parsed.data.id,
|
||||
reversible: parsed.data.reversible,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-turn-info": {
|
||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
||||
if (streamedChatTurnId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
tokenUsageData = parsed.data;
|
||||
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||
|
|
@ -900,13 +1013,18 @@ export default function NewChatPage() {
|
|||
role: "assistant",
|
||||
content: finalContent,
|
||||
token_usage: tokenUsageData ?? undefined,
|
||||
turn_id: streamedChatTurnId,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID so comments work immediately
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
// Update pending interrupt with the new persisted message ID
|
||||
|
|
@ -929,7 +1047,9 @@ export default function NewChatPage() {
|
|||
const hasContent = contentParts.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
|
||||
(part.type === "reasoning" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" &&
|
||||
(toolsWithUI === "all" || toolsWithUI.has(part.toolName)))
|
||||
);
|
||||
if (hasContent && currentThreadId) {
|
||||
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
|
||||
|
|
@ -937,12 +1057,17 @@ export default function NewChatPage() {
|
|||
const savedMessage = await appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
turn_id: streamedChatTurnId,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
|
||||
: m
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to persist partial assistant message:", err);
|
||||
|
|
@ -1030,10 +1155,13 @@ export default function NewChatPage() {
|
|||
const contentPartsState: ContentPartsState = {
|
||||
contentParts: [],
|
||||
currentTextPartIndex: -1,
|
||||
currentReasoningPartIndex: -1,
|
||||
toolCallIndices: new Map(),
|
||||
};
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
let tokenUsageData: Record<string, unknown> | null = null;
|
||||
// Captured from ``data-turn-info`` at stream start.
|
||||
let streamedChatTurnId: string | null = null;
|
||||
|
||||
const existingMsg = messages.find((m) => m.id === assistantMsgId);
|
||||
if (existingMsg && Array.isArray(existingMsg.content)) {
|
||||
|
|
@ -1136,8 +1264,34 @@ export default function NewChatPage() {
|
|||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "finish-step":
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
|
|
@ -1145,6 +1299,7 @@ export default function NewChatPage() {
|
|||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
|
|
@ -1152,7 +1307,9 @@ export default function NewChatPage() {
|
|||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {}
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
}
|
||||
batcher.flush();
|
||||
|
|
@ -1161,6 +1318,7 @@ export default function NewChatPage() {
|
|||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
batcher.flush();
|
||||
|
|
@ -1222,6 +1380,50 @@ export default function NewChatPage() {
|
|||
break;
|
||||
}
|
||||
|
||||
case "data-action-log": {
|
||||
const al = parsed.data;
|
||||
const matchedToolCallId = al.lc_tool_call_id
|
||||
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
|
||||
: null;
|
||||
upsertAgentAction({
|
||||
action: {
|
||||
id: al.id,
|
||||
threadId: resumeThreadId,
|
||||
lcToolCallId: al.lc_tool_call_id,
|
||||
chatTurnId: al.chat_turn_id,
|
||||
toolName: al.tool_name,
|
||||
reversible: al.reversible,
|
||||
reverseDescriptorPresent: al.reverse_descriptor_present,
|
||||
error: al.error,
|
||||
revertedByActionId: null,
|
||||
isRevertAction: false,
|
||||
createdAt: al.created_at,
|
||||
},
|
||||
toolCallId: matchedToolCallId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-action-log-updated": {
|
||||
updateAgentActionReversible({
|
||||
id: parsed.data.id,
|
||||
reversible: parsed.data.reversible,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-turn-info": {
|
||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
||||
if (streamedChatTurnId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
tokenUsageData = parsed.data;
|
||||
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||
|
|
@ -1241,11 +1443,16 @@ export default function NewChatPage() {
|
|||
role: "assistant",
|
||||
content: finalContent,
|
||||
token_usage: tokenUsageData ?? undefined,
|
||||
turn_id: streamedChatTurnId,
|
||||
});
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
|
||||
: m
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to persist resumed assistant message:", err);
|
||||
|
|
@ -1340,6 +1547,12 @@ export default function NewChatPage() {
|
|||
editExtras?: {
|
||||
userMessageContent: ThreadMessageLike["content"];
|
||||
userImages: NewChatUserImagePayload[];
|
||||
},
|
||||
editFromPosition?: {
|
||||
/** Message id (numeric, parsed from ``msg-<n>``) to rewind to. */
|
||||
fromMessageId?: number | null;
|
||||
/** When true, revert reversible downstream actions before stream. */
|
||||
revertActions?: boolean;
|
||||
}
|
||||
) => {
|
||||
if (!threadId) {
|
||||
|
|
@ -1384,9 +1597,20 @@ export default function NewChatPage() {
|
|||
userQueryToDisplay = newUserQuery;
|
||||
}
|
||||
|
||||
// Remove the last two messages (user + assistant) from the UI immediately
|
||||
// The backend will also delete them from the database
|
||||
// Remove downstream messages from the UI immediately. The
|
||||
// backend will also delete them from the database.
|
||||
//
|
||||
// When an explicit ``fromMessageId`` is passed, slice from
|
||||
// that message forward; otherwise fall back to the legacy
|
||||
// "drop the last 2" behaviour.
|
||||
setMessages((prev) => {
|
||||
if (editFromPosition?.fromMessageId != null) {
|
||||
const targetId = `msg-${editFromPosition.fromMessageId}`;
|
||||
const sliceIndex = prev.findIndex((m) => m.id === targetId);
|
||||
if (sliceIndex >= 0) {
|
||||
return prev.slice(0, sliceIndex);
|
||||
}
|
||||
}
|
||||
if (prev.length >= 2) {
|
||||
return prev.slice(0, -2);
|
||||
}
|
||||
|
|
@ -1406,11 +1630,16 @@ export default function NewChatPage() {
|
|||
const contentPartsState: ContentPartsState = {
|
||||
contentParts: [],
|
||||
currentTextPartIndex: -1,
|
||||
currentReasoningPartIndex: -1,
|
||||
toolCallIndices: new Map(),
|
||||
};
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
const batcher = new FrameBatchedUpdater();
|
||||
let tokenUsageData: Record<string, unknown> | null = null;
|
||||
// Captured from ``data-turn-info`` at stream start; stamped
|
||||
// onto persisted messages so future edits can locate the
|
||||
// right LangGraph checkpoint.
|
||||
let streamedChatTurnId: string | null = null;
|
||||
|
||||
// Add placeholder messages to UI
|
||||
// Always add back the user message (with new query for edit, or original content for reload)
|
||||
|
|
@ -1449,6 +1678,16 @@ export default function NewChatPage() {
|
|||
if (isEdit) {
|
||||
requestBody.user_images = editExtras?.userImages ?? [];
|
||||
}
|
||||
// Explicit edit-from-arbitrary-position. Only send
|
||||
// ``from_message_id`` / ``revert_actions`` when the
|
||||
// caller asked for them; otherwise the backend keeps the
|
||||
// legacy "last 2 messages" behaviour for back-compat.
|
||||
if (editFromPosition?.fromMessageId != null) {
|
||||
requestBody.from_message_id = editFromPosition.fromMessageId;
|
||||
if (editFromPosition.revertActions) {
|
||||
requestBody.revert_actions = true;
|
||||
}
|
||||
}
|
||||
const response = await fetch(getRegenerateUrl(threadId), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -1481,28 +1720,62 @@ export default function NewChatPage() {
|
|||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "finish-step":
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
case "tool-input-available":
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {}
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
}
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||
|
|
@ -1528,6 +1801,82 @@ export default function NewChatPage() {
|
|||
break;
|
||||
}
|
||||
|
||||
case "data-action-log": {
|
||||
const al = parsed.data;
|
||||
const matchedToolCallId = al.lc_tool_call_id
|
||||
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
|
||||
: null;
|
||||
upsertAgentAction({
|
||||
action: {
|
||||
id: al.id,
|
||||
threadId,
|
||||
lcToolCallId: al.lc_tool_call_id,
|
||||
chatTurnId: al.chat_turn_id,
|
||||
toolName: al.tool_name,
|
||||
reversible: al.reversible,
|
||||
reverseDescriptorPresent: al.reverse_descriptor_present,
|
||||
error: al.error,
|
||||
revertedByActionId: null,
|
||||
isRevertAction: false,
|
||||
createdAt: al.created_at,
|
||||
},
|
||||
toolCallId: matchedToolCallId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-action-log-updated": {
|
||||
updateAgentActionReversible({
|
||||
id: parsed.data.id,
|
||||
reversible: parsed.data.reversible,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-turn-info": {
|
||||
streamedChatTurnId = parsed.data.chat_turn_id || null;
|
||||
if (streamedChatTurnId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-revert-results": {
|
||||
const summary = parsed.data;
|
||||
// failureCount must include every "not undone" bucket
|
||||
// (not_reversible, permission_denied, failed) so the
|
||||
// toast's "X could not be rolled back" math matches
|
||||
// the response invariant ``total === sum(counters)``.
|
||||
// ``skipped`` rows are batch revert artefacts (revert
|
||||
// rows themselves) and are not user-facing failures.
|
||||
const failureCount =
|
||||
summary.failed + summary.not_reversible + (summary.permission_denied ?? 0);
|
||||
if (failureCount > 0) {
|
||||
toast.warning(
|
||||
`Pre-revert: ${summary.reverted}/${summary.total} undone, ${failureCount} could not be rolled back.`
|
||||
);
|
||||
} else if (summary.reverted > 0) {
|
||||
toast.success(
|
||||
summary.reverted === 1
|
||||
? "Reverted 1 downstream action before regenerating."
|
||||
: `Reverted ${summary.reverted} downstream actions before regenerating.`
|
||||
);
|
||||
}
|
||||
for (const r of summary.results) {
|
||||
if (r.status === "reverted" || r.status === "already_reverted") {
|
||||
markAgentActionReverted({
|
||||
id: r.action_id,
|
||||
newActionId: r.new_action_id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
tokenUsageData = parsed.data;
|
||||
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||
|
|
@ -1552,12 +1901,17 @@ export default function NewChatPage() {
|
|||
const savedUserMessage = await appendMessage(threadId, {
|
||||
role: "user",
|
||||
content: userContentToPersist,
|
||||
turn_id: streamedChatTurnId,
|
||||
});
|
||||
|
||||
// Update user message ID to database ID
|
||||
const newUserMsgId = `msg-${savedUserMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
|
||||
prev.map((m) =>
|
||||
m.id === userMsgId
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, savedUserMessage.turn_id)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
// Persist assistant message
|
||||
|
|
@ -1565,12 +1919,17 @@ export default function NewChatPage() {
|
|||
role: "assistant",
|
||||
content: finalContent,
|
||||
token_usage: tokenUsageData ?? undefined,
|
||||
turn_id: streamedChatTurnId,
|
||||
});
|
||||
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
trackChatResponseReceived(searchSpaceId, threadId);
|
||||
|
|
@ -1608,7 +1967,14 @@ export default function NewChatPage() {
|
|||
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore, toolsWithUI]
|
||||
);
|
||||
|
||||
// Handle editing a message - truncates history and regenerates with new query
|
||||
// Handle editing a message - truncates history and regenerates with new query.
|
||||
//
|
||||
// When ``message.sourceId`` is set (the assistant-ui way to say
|
||||
// "this edit replaces an older message"), we pin
|
||||
// ``from_message_id`` so the backend rewinds to the right LangGraph
|
||||
// checkpoint instead of relying on the legacy "last 2 messages"
|
||||
// rewind. We also count downstream reversible actions and prompt the
|
||||
// user to revert / continue / cancel before regenerating.
|
||||
const onEdit = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
|
||||
|
|
@ -1619,9 +1985,95 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
|
||||
await handleRegenerate(queryForApi, { userMessageContent, userImages });
|
||||
|
||||
// ``sourceId`` per @assistant-ui/core's ``AppendMessage`` is
|
||||
// "the ID of the message that was edited". Parse the numeric
|
||||
// suffix so we can map it back to a DB row.
|
||||
const sourceId = (message as { sourceId?: string }).sourceId;
|
||||
const fromMessageId =
|
||||
sourceId && /^msg-\d+$/.test(sourceId)
|
||||
? Number.parseInt(sourceId.replace(/^msg-/, ""), 10)
|
||||
: null;
|
||||
|
||||
if (fromMessageId == null) {
|
||||
// No source id (or non-DB id) — fall back to today's
|
||||
// last-2 behaviour. The user gets the legacy edit flow.
|
||||
await handleRegenerate(queryForApi, { userMessageContent, userImages });
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-flight: count reversible downstream actions so we can
|
||||
// auto-skip the dialog for harmless edits.
|
||||
//
|
||||
// "Downstream" means messages AFTER the edited one. The
|
||||
// previous slice ``messages.slice(editedIndex)`` included
|
||||
// the edited message itself in both the total
|
||||
// count and the reversibility scan (any actions on the
|
||||
// edited turn would be double-counted). Slice from
|
||||
// ``editedIndex + 1`` so the dialog text matches reality:
|
||||
// "N downstream messages will be dropped".
|
||||
const editedIndex = messages.findIndex((m) => m.id === `msg-${fromMessageId}`);
|
||||
let downstreamReversibleCount = 0;
|
||||
let downstreamTotalCount = 0;
|
||||
if (editedIndex >= 0) {
|
||||
const downstream = messages.slice(editedIndex + 1);
|
||||
downstreamTotalCount = downstream.length;
|
||||
const seenTurns = new Set<string>();
|
||||
for (const m of downstream) {
|
||||
const meta = (m.metadata ?? {}) as { custom?: { chatTurnId?: string } };
|
||||
const tid = meta.custom?.chatTurnId;
|
||||
if (!tid || seenTurns.has(tid)) continue;
|
||||
seenTurns.add(tid);
|
||||
const turnActions = agentActionsByChatTurnId.get(tid) ?? [];
|
||||
for (const a of turnActions) {
|
||||
if (a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error) {
|
||||
downstreamReversibleCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downstreamReversibleCount === 0) {
|
||||
// Nothing to revert — submit silently.
|
||||
await handleRegenerate(
|
||||
queryForApi,
|
||||
{ userMessageContent, userImages },
|
||||
{ fromMessageId, revertActions: false }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditDialogState({
|
||||
fromMessageId,
|
||||
userQuery: queryForApi,
|
||||
userMessageContent,
|
||||
userImages,
|
||||
downstreamReversibleCount,
|
||||
downstreamTotalCount,
|
||||
});
|
||||
},
|
||||
[handleRegenerate]
|
||||
[handleRegenerate, messages, agentActionsByChatTurnId]
|
||||
);
|
||||
|
||||
const handleEditDialogChoice = useCallback(
|
||||
async (choice: EditMessageDialogChoice) => {
|
||||
const pending = editDialogState;
|
||||
if (!pending) return;
|
||||
setEditDialogState(null);
|
||||
if (choice === "cancel") return;
|
||||
await handleRegenerate(
|
||||
pending.userQuery,
|
||||
{
|
||||
userMessageContent: pending.userMessageContent,
|
||||
userImages: pending.userImages,
|
||||
},
|
||||
{
|
||||
fromMessageId: pending.fromMessageId,
|
||||
revertActions: choice === "revert",
|
||||
}
|
||||
);
|
||||
},
|
||||
[editDialogState, handleRegenerate]
|
||||
);
|
||||
|
||||
// Handle reloading/refreshing the last AI response
|
||||
|
|
@ -1671,6 +2123,7 @@ export default function NewChatPage() {
|
|||
<TokenUsageProvider store={tokenUsageStore}>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
|
|
@ -1679,6 +2132,15 @@ export default function NewChatPage() {
|
|||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
<EditMessageDialog
|
||||
open={editDialogState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditDialogState(null);
|
||||
}}
|
||||
downstreamReversibleCount={editDialogState?.downstreamReversibleCount ?? 0}
|
||||
downstreamTotalCount={editDialogState?.downstreamTotalCount ?? 0}
|
||||
onChoose={handleEditDialogChoice}
|
||||
/>
|
||||
</AssistantRuntimeProvider>
|
||||
</TokenUsageProvider>
|
||||
);
|
||||
|
|
|
|||
194
surfsense_web/atoms/chat/agent-actions.atom.ts
Normal file
194
surfsense_web/atoms/chat/agent-actions.atom.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
|
||||
/**
|
||||
* Minimal per-row projection of ``AgentActionLog`` that the tool card
|
||||
* needs to decide whether to render a Revert button.
|
||||
*
|
||||
* Fields are deliberately a subset of the full ``AgentAction`` so the
|
||||
* SSE side-channel (``data-action-log`` / ``data-action-log-updated``)
|
||||
* can populate them without depending on the REST endpoint
|
||||
* ``GET /threads/.../actions`` (which 503s when
|
||||
* ``SURFSENSE_ENABLE_ACTION_LOG`` is off).
|
||||
*/
|
||||
export interface AgentActionLite {
|
||||
id: number;
|
||||
threadId: number | null;
|
||||
lcToolCallId: string | null;
|
||||
chatTurnId: string | null;
|
||||
toolName: string;
|
||||
reversible: boolean;
|
||||
reverseDescriptorPresent: boolean;
|
||||
error: boolean;
|
||||
revertedByActionId: number | null;
|
||||
isRevertAction: boolean;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map keyed off the LangChain ``tool_call.id`` (mirrors ``ContentPart
|
||||
* tool-call.langchainToolCallId``).
|
||||
*/
|
||||
export const agentActionByLcIdAtom = atom<Map<string, AgentActionLite>>(new Map());
|
||||
|
||||
/**
|
||||
* Parallel map keyed off the synthetic chat-card ``toolCallId``
|
||||
* (``call_<run-id>``) so ``ToolFallback`` (which only receives the
|
||||
* synthetic id from assistant-ui) can join its card to the action log.
|
||||
*
|
||||
* Both maps are kept in sync by ``upsertAgentActionAtom``.
|
||||
*/
|
||||
export const agentActionByToolCallIdAtom = atom<Map<string, AgentActionLite>>(new Map());
|
||||
|
||||
/**
|
||||
* Index keyed by ``chat_turn_id`` so the per-turn revert UI can answer
|
||||
* "how many reversible actions does this assistant turn contain?" in
|
||||
* O(1). Each entry's array is ordered by insertion (which
|
||||
* for a single turn matches ``created_at`` because action-log writes
|
||||
* happen synchronously).
|
||||
*/
|
||||
export const agentActionsByChatTurnIdAtom = atom<Map<string, AgentActionLite[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Action to upsert one ``AgentActionLite`` row.
|
||||
*
|
||||
* ``toolCallId`` is the synthetic card id (``call_<run-id>`` from
|
||||
* ``stream_new_chat.py``). When provided alongside ``lcToolCallId``, the
|
||||
* action is indexed under BOTH ids so the tool card can perform the
|
||||
* lookup without going via the streaming state.
|
||||
*/
|
||||
export const upsertAgentActionAtom = atom(
|
||||
null,
|
||||
(_get, set, payload: { action: AgentActionLite; toolCallId?: string | null }) => {
|
||||
const { action, toolCallId } = payload;
|
||||
const upsertInto = (
|
||||
prev: Map<string, AgentActionLite>,
|
||||
key: string
|
||||
): Map<string, AgentActionLite> => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(key);
|
||||
next.set(key, {
|
||||
...action,
|
||||
// Preserve the local "reverted" bookkeeping if a reversibility
|
||||
// flip arrives AFTER the user already reverted via the REST
|
||||
// route. We never want a stale ``reversible=true`` event to
|
||||
// resurrect a Reverted card.
|
||||
revertedByActionId: existing?.revertedByActionId ?? action.revertedByActionId,
|
||||
isRevertAction: existing?.isRevertAction ?? action.isRevertAction,
|
||||
});
|
||||
return next;
|
||||
};
|
||||
if (action.lcToolCallId) {
|
||||
set(agentActionByLcIdAtom, (prev) => upsertInto(prev, action.lcToolCallId as string));
|
||||
}
|
||||
if (toolCallId) {
|
||||
set(agentActionByToolCallIdAtom, (prev) => upsertInto(prev, toolCallId));
|
||||
}
|
||||
if (action.chatTurnId) {
|
||||
set(agentActionsByChatTurnIdAtom, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const turnId = action.chatTurnId as string;
|
||||
const existing = next.get(turnId) ?? [];
|
||||
const priorEntry = existing.find((row) => row.id === action.id);
|
||||
const merged: AgentActionLite = {
|
||||
...action,
|
||||
revertedByActionId: priorEntry?.revertedByActionId ?? action.revertedByActionId,
|
||||
isRevertAction: priorEntry?.isRevertAction ?? action.isRevertAction,
|
||||
};
|
||||
const others = existing.filter((row) => row.id !== action.id);
|
||||
next.set(turnId, [...others, merged]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function mutateById(
|
||||
prev: Map<string, AgentActionLite>,
|
||||
id: number,
|
||||
mutator: (entry: AgentActionLite) => AgentActionLite
|
||||
): Map<string, AgentActionLite> {
|
||||
let mutated = false;
|
||||
const next = new Map(prev);
|
||||
for (const [key, value] of next) {
|
||||
if (value.id === id) {
|
||||
next.set(key, mutator(value));
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated ? next : prev;
|
||||
}
|
||||
|
||||
function mutateByIdInTurnIndex(
|
||||
prev: Map<string, AgentActionLite[]>,
|
||||
id: number,
|
||||
mutator: (entry: AgentActionLite) => AgentActionLite
|
||||
): Map<string, AgentActionLite[]> {
|
||||
let mutated = false;
|
||||
const next = new Map(prev);
|
||||
for (const [key, list] of next) {
|
||||
let listMutated = false;
|
||||
const updated = list.map((row) => {
|
||||
if (row.id === id) {
|
||||
listMutated = true;
|
||||
return mutator(row);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
if (listMutated) {
|
||||
next.set(key, updated);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated ? next : prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to flip an existing entry's ``reversible`` flag, keyed by the
|
||||
* AgentActionLog row id (the SSE ``data-action-log-updated`` payload
|
||||
* does NOT carry ``lcToolCallId``).
|
||||
*/
|
||||
export const updateAgentActionReversibleAtom = atom(
|
||||
null,
|
||||
(_get, set, payload: { id: number; reversible: boolean }) => {
|
||||
const apply = (entry: AgentActionLite): AgentActionLite => ({
|
||||
...entry,
|
||||
reversible: payload.reversible,
|
||||
});
|
||||
set(agentActionByLcIdAtom, (prev) => mutateById(prev, payload.id, apply));
|
||||
set(agentActionByToolCallIdAtom, (prev) => mutateById(prev, payload.id, apply));
|
||||
set(agentActionsByChatTurnIdAtom, (prev) => mutateByIdInTurnIndex(prev, payload.id, apply));
|
||||
}
|
||||
);
|
||||
|
||||
/** Action to mark an existing entry as reverted (post-revert call). */
|
||||
export const markAgentActionRevertedAtom = atom(
|
||||
null,
|
||||
(_get, set, payload: { id: number; newActionId: number | null }) => {
|
||||
const apply = (entry: AgentActionLite): AgentActionLite => ({
|
||||
...entry,
|
||||
revertedByActionId: payload.newActionId ?? -1,
|
||||
});
|
||||
set(agentActionByLcIdAtom, (prev) => mutateById(prev, payload.id, apply));
|
||||
set(agentActionByToolCallIdAtom, (prev) => mutateById(prev, payload.id, apply));
|
||||
set(agentActionsByChatTurnIdAtom, (prev) => mutateByIdInTurnIndex(prev, payload.id, apply));
|
||||
}
|
||||
);
|
||||
|
||||
/** Mark every action in a turn as reverted, given a list of (id, newActionId) pairs. */
|
||||
export const markAgentActionsRevertedBatchAtom = atom(
|
||||
null,
|
||||
(_get, set, payload: { entries: Array<{ id: number; newActionId: number | null }> }) => {
|
||||
for (const entry of payload.entries) {
|
||||
set(markAgentActionRevertedAtom, entry);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** Reset all maps (e.g. when the active thread changes). */
|
||||
export const resetAgentActionMapAtom = atom(null, (_get, set) => {
|
||||
set(agentActionByLcIdAtom, new Map());
|
||||
set(agentActionByToolCallIdAtom, new Map());
|
||||
set(agentActionsByChatTurnIdAtom, new Map());
|
||||
});
|
||||
|
|
@ -33,6 +33,8 @@ import {
|
|||
useAllCitationMetadata,
|
||||
} from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
||||
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
|
||||
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
|
@ -491,6 +493,7 @@ const AssistantMessageInner: FC = () => {
|
|||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningMessagePart,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_report: GenerateReportToolUI,
|
||||
|
|
@ -699,6 +702,13 @@ const AssistantActionBar: FC = () => {
|
|||
const isLast = useAuiState((s) => s.message.isLast);
|
||||
const aui = useAui();
|
||||
const api = useElectronAPI();
|
||||
// Surface the persisted ``chat_turn_id`` so the per-turn revert
|
||||
// affordance can scope to just this message's actions. Streamed
|
||||
// turns get their id once the assistant message is hydrated/finalised.
|
||||
const chatTurnId = useAuiState(({ message }) => {
|
||||
const meta = message?.metadata as { custom?: { chatTurnId?: string | null } } | undefined;
|
||||
return meta?.custom?.chatTurnId ?? null;
|
||||
});
|
||||
|
||||
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
|
||||
|
||||
|
|
@ -743,6 +753,9 @@ const AssistantActionBar: FC = () => {
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
<MessageInfoDropdown />
|
||||
<div className="ml-auto">
|
||||
<RevertTurnButton chatTurnId={chatTurnId} />
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
106
surfsense_web/components/assistant-ui/edit-message-dialog.tsx
Normal file
106
surfsense_web/components/assistant-ui/edit-message-dialog.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown when the user edits a message that has
|
||||
* reversible downstream actions. Three buttons:
|
||||
*
|
||||
* • "Revert all & resubmit" — POST regenerate with revert_actions=true
|
||||
* • "Continue without revert" — POST regenerate with revert_actions=false
|
||||
* • "Cancel" — abort the edit entirely
|
||||
*
|
||||
* The dialog is auto-skipped when zero reversible downstream actions
|
||||
* exist (the caller checks first via ``downstreamReversibleCount``).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type EditMessageDialogChoice = "revert" | "continue" | "cancel";
|
||||
|
||||
export interface EditMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
downstreamReversibleCount: number;
|
||||
downstreamTotalCount: number;
|
||||
onChoose: (choice: EditMessageDialogChoice) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EditMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
downstreamReversibleCount,
|
||||
downstreamTotalCount,
|
||||
onChoose,
|
||||
}: EditMessageDialogProps) {
|
||||
const [busy, setBusy] = useState<EditMessageDialogChoice | null>(null);
|
||||
|
||||
// The parent's ``handleEditDialogChoice`` calls
|
||||
// ``setEditDialogState(null)`` BEFORE awaiting ``handleRegenerate``.
|
||||
// That collapses the dialog (Radix unmounts it) while ``onChoose``
|
||||
// is still awaiting the long-running stream. Without this guard,
|
||||
// the ``finally { setBusy(null) }`` below ran after unmount and
|
||||
// produced a "state update on unmounted component" dev warning.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handle = async (choice: EditMessageDialogChoice) => {
|
||||
setBusy(choice);
|
||||
try {
|
||||
await onChoose(choice);
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Edit this message?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This edit drops {downstreamTotalCount} downstream message
|
||||
{downstreamTotalCount === 1 ? "" : "s"} from the thread. {downstreamReversibleCount}{" "}
|
||||
action
|
||||
{downstreamReversibleCount === 1 ? "" : "s"} (e.g. file writes, connector changes) can
|
||||
be rolled back. Pick how to handle them before regenerating.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="default" disabled={busy !== null} onClick={() => handle("revert")}>
|
||||
{busy === "revert"
|
||||
? "Reverting & resubmitting…"
|
||||
: `Revert ${downstreamReversibleCount} action${
|
||||
downstreamReversibleCount === 1 ? "" : "s"
|
||||
} & resubmit`}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={() => handle("continue")}>
|
||||
{busy === "continue" ? "Resubmitting…" : "Continue without reverting"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="sm:justify-start">
|
||||
<AlertDialogCancel disabled={busy !== null} onClick={() => handle("cancel")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import type { ReasoningMessagePartComponent } from "@assistant-ui/react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Renders the structured `reasoning` part emitted by the backend's
|
||||
* stream-parity v2 path (A1).
|
||||
*
|
||||
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
|
||||
* - collapsed by default;
|
||||
* - auto-expanded while the part is still `running`;
|
||||
* - auto-collapsed once status flips to `complete`.
|
||||
*
|
||||
* The component is registered via the `Reasoning` slot on
|
||||
* `MessagePrimitive.Parts` in `assistant-message.tsx` so it lives at the
|
||||
* exact ordinal position of the reasoning block in the message content
|
||||
* array (i.e. above the assistant text that follows it).
|
||||
*/
|
||||
export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, status }) => {
|
||||
const isRunning = status?.type === "running";
|
||||
const [isOpen, setIsOpen] = useState(() => isRunning);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
setIsOpen(true);
|
||||
} else if (status?.type === "complete") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isRunning, status?.type]);
|
||||
|
||||
const headerLabel = useMemo(() => {
|
||||
if (isRunning) return "Thinking";
|
||||
if (status?.type === "incomplete") return "Thinking interrupted";
|
||||
return "Thought";
|
||||
}, [isRunning, status?.type]);
|
||||
|
||||
if (!text || text.length === 0) {
|
||||
if (!isRunning) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<div className="rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isRunning ? (
|
||||
<TextShimmerLoader text={headerLabel} size="sm" />
|
||||
) : (
|
||||
<span>{headerLabel}</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-2 border-l border-muted-foreground/30 pl-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap wrap-break-word">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
232
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
232
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* "Revert turn" button rendered at the bottom of every completed
|
||||
* assistant turn that has at least one reversible action.
|
||||
*
|
||||
* The button reads the action map keyed by ``chat_turn_id`` from the
|
||||
* SSE side-channel (``data-action-log`` events). It shows a confirmation
|
||||
* dialog summarising "N reversible / M total" and, on confirm, calls
|
||||
* ``POST /threads/{id}/revert-turn/{chat_turn_id}``.
|
||||
*
|
||||
* The route returns a per-action result list and never collapses the
|
||||
* batch into a 4xx — so we render any failed/not_reversible rows inline
|
||||
* with their messages.
|
||||
*/
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentActionLite,
|
||||
agentActionsByChatTurnIdAtom,
|
||||
markAgentActionsRevertedBatchAtom,
|
||||
} from "@/atoms/chat/agent-actions.atom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
agentActionsApiService,
|
||||
type RevertTurnActionResult,
|
||||
} from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RevertTurnButtonProps {
|
||||
chatTurnId: string | null | undefined;
|
||||
}
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Empty-array sentinel so the per-turn ``selectAtom`` slice returns a
|
||||
// stable reference when the turn has no recorded actions yet. Without
|
||||
// this every render allocates a fresh ``[]`` and Jotai's
|
||||
// equality check would re-render the button on unrelated turn updates.
|
||||
const EMPTY_ACTIONS: readonly AgentActionLite[] = Object.freeze([]);
|
||||
|
||||
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const markRevertedBatch = useSetAtom(markAgentActionsRevertedBatchAtom);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [resultsOpen, setResultsOpen] = useState(false);
|
||||
const [results, setResults] = useState<RevertTurnActionResult[]>([]);
|
||||
|
||||
// Subscribe ONLY to the slice of the global action map that belongs
|
||||
// to ``chatTurnId``. Previously the button read the whole
|
||||
// ``agentActionsByChatTurnIdAtom``, which meant every action
|
||||
// upsert (one per tool call) re-rendered every Revert button on
|
||||
// the page. With ``selectAtom`` we re-render only when our turn's
|
||||
// list reference changes — and the upsert/mark atoms produce a
|
||||
// fresh list reference for the affected turn only.
|
||||
const sliceAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
agentActionsByChatTurnIdAtom,
|
||||
(turnIndex) => (chatTurnId ? turnIndex.get(chatTurnId) : undefined) ?? EMPTY_ACTIONS
|
||||
),
|
||||
[chatTurnId]
|
||||
);
|
||||
const actions = useAtomValue(sliceAtom);
|
||||
|
||||
const reversibleCount = useMemo(
|
||||
() =>
|
||||
actions.filter(
|
||||
(a) => a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error
|
||||
).length,
|
||||
[actions]
|
||||
);
|
||||
const totalCount = useMemo(() => actions.filter((a) => !a.isRevertAction).length, [actions]);
|
||||
|
||||
if (!chatTurnId) return null;
|
||||
if (reversibleCount === 0) return null;
|
||||
const threadId = session?.threadId;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevertTurn = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revertTurn(threadId, chatTurnId);
|
||||
setResults(response.results);
|
||||
const revertedEntries = response.results
|
||||
.filter((r) => r.status === "reverted" || r.status === "already_reverted")
|
||||
.map((r) => ({ id: r.action_id, newActionId: r.new_action_id ?? null }));
|
||||
if (revertedEntries.length > 0) {
|
||||
markRevertedBatch({ entries: revertedEntries });
|
||||
}
|
||||
if (response.status === "ok") {
|
||||
toast.success(
|
||||
response.reverted === 1 ? "Reverted 1 action." : `Reverted ${response.reverted} actions.`
|
||||
);
|
||||
} else {
|
||||
// Every "not undone" bucket counts as a failure for the
|
||||
// user-facing summary. ``skipped`` rows are batch
|
||||
// artefacts (revert rows themselves) and intentionally
|
||||
// excluded from the failure tally.
|
||||
const failureCount =
|
||||
response.failed + response.not_reversible + (response.permission_denied ?? 0);
|
||||
toast.warning(
|
||||
`Reverted ${response.reverted} of ${response.total}. ${failureCount} could not be undone.`
|
||||
);
|
||||
setResultsOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.status === 503) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert turn.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span>Revert turn</span>
|
||||
<span className="text-xs tabular-nums opacity-70">
|
||||
{reversibleCount}/{totalCount}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this turn?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo {reversibleCount} of {totalCount} action
|
||||
{totalCount === 1 ? "" : "s"} from this turn in reverse order. The chat history and
|
||||
any read-only actions are preserved. Some rows may not be reversible — partial success
|
||||
is normal.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevertTurn();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert turn"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={resultsOpen} onOpenChange={setResultsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert results</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Some actions could not be reverted. Review per-row outcomes below.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<ul className="max-h-72 overflow-y-auto space-y-2 text-sm">
|
||||
{results.map((r) => (
|
||||
<RevertResultRow key={r.action_id} result={r} />
|
||||
))}
|
||||
</ul>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setResultsOpen(false)}>Close</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevertResultRow({ result }: { result: RevertTurnActionResult }) {
|
||||
const isOk = result.status === "reverted" || result.status === "already_reverted";
|
||||
const Icon = isOk ? CheckIcon : XCircleIcon;
|
||||
return (
|
||||
<li className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Icon
|
||||
className={cn("size-4 mt-0.5 shrink-0", isOk ? "text-emerald-500" : "text-destructive")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">
|
||||
{formatToolName(result.tool_name)}{" "}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{result.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</p>
|
||||
{(result.message || result.error) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{result.error ?? result.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
27
surfsense_web/components/assistant-ui/step-separator.tsx
Normal file
27
surfsense_web/components/assistant-ui/step-separator.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantDataUI } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* Renders a thin horizontal divider between model steps within a single
|
||||
* assistant turn. The data part is pushed by `addStepSeparator` in
|
||||
* `streaming-state.ts` whenever a `start-step` SSE event arrives after
|
||||
* the message already has non-step content.
|
||||
*
|
||||
* Today the backend emits one `start-step` / `finish-step` pair per turn,
|
||||
* so most messages won't contain a separator. The renderer is wired up so
|
||||
* the planned per-model-step refactor (A2 follow-up) can light up without
|
||||
* touching the persistence path.
|
||||
*/
|
||||
function StepSeparatorDataRenderer() {
|
||||
return (
|
||||
<div className="mx-auto my-3 w-full max-w-(--thread-max-width) px-2">
|
||||
<div className="border-t border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const StepSeparatorDataUI = makeAssistantDataUI({
|
||||
name: "step-separator",
|
||||
render: StepSeparatorDataRenderer,
|
||||
});
|
||||
|
|
@ -1,12 +1,33 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
agentActionByToolCallIdAtom,
|
||||
markAgentActionRevertedAtom,
|
||||
} from "@/atoms/chat/agent-actions.atom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -14,7 +35,99 @@ function formatToolName(name: string): string {
|
|||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Revert button rendered on a tool card when the matching
|
||||
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
|
||||
* Reads from the SSE side-channel atom keyed by the synthetic
|
||||
* ``toolCallId`` so it lights up even when ``GET /threads/.../actions``
|
||||
* is gated behind ``SURFSENSE_ENABLE_ACTION_LOG=False`` (503).
|
||||
*/
|
||||
function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const actionMap = useAtomValue(agentActionByToolCallIdAtom);
|
||||
const markReverted = useSetAtom(markAgentActionRevertedAtom);
|
||||
const action = actionMap.get(toolCallId);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
if (!action) return null;
|
||||
if (!action.reversible) return null;
|
||||
if (action.revertedByActionId !== null) return null;
|
||||
if (action.isRevertAction) return null;
|
||||
if (action.error) return null;
|
||||
const threadId = session?.threadId;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
markReverted({ id: action.id, newActionId: response.new_action_id ?? null });
|
||||
toast.success(response.message || "Action reverted.");
|
||||
} catch (err) {
|
||||
// 503 means revert is gated off on this deployment — hide the
|
||||
// button silently rather than nagging the user. Any other error
|
||||
// is surfaced as a toast so the operator can investigate.
|
||||
if (err instanceof AppError && err.status === 503) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert action.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo <span className="font-medium">{formatToolName(action.toolName)}</span>{" "}
|
||||
and append a new audit entry. Chat history is preserved — only the tool's effects on
|
||||
your knowledge base or connectors will be reversed where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
||||
toolCallId,
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
|
|
@ -145,6 +258,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ToolCardRevertButton toolCallId={toolCallId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
|
||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||
import {
|
||||
createTokenUsageStore,
|
||||
|
|
@ -17,10 +18,13 @@ import {
|
|||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
FrameBatchedUpdater,
|
||||
readSSEStream,
|
||||
type ThinkingStepData,
|
||||
|
|
@ -32,7 +36,9 @@ import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
|||
import { FreeModelSelector } from "./free-model-selector";
|
||||
import { FreeThread } from "./free-thread";
|
||||
|
||||
const TOOLS_WITH_UI = new Set(["web_search", "document_qna"]);
|
||||
// Render all tool calls via ToolFallback; backend keeps persisted
|
||||
// payloads bounded by summarising / truncating outputs.
|
||||
const TOOLS_WITH_UI = "all" as const;
|
||||
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
/** Try to parse a CAPTCHA_REQUIRED or CAPTCHA_INVALID code from a non-ok response. */
|
||||
|
|
@ -125,6 +131,7 @@ export function FreeChatPage() {
|
|||
const contentPartsState: ContentPartsState = {
|
||||
contentParts: [],
|
||||
currentTextPartIndex: -1,
|
||||
currentReasoningPartIndex: -1,
|
||||
toolCallIndices: new Map(),
|
||||
};
|
||||
const { toolCallIndices } = contentPartsState;
|
||||
|
|
@ -148,28 +155,62 @@ export function FreeChatPage() {
|
|||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "finish-step":
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
TOOLS_WITH_UI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
case "tool-input-available":
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
TOOLS_WITH_UI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {}
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
}
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
batcher.flush();
|
||||
break;
|
||||
|
||||
|
|
@ -369,6 +410,7 @@ export function FreeChatPage() {
|
|||
<TokenUsageProvider store={tokenUsageStore}>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-4">
|
||||
<FreeModelSelector />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
|
||||
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
|
||||
import { Navbar } from "@/components/homepage/navbar";
|
||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||
|
|
@ -41,6 +42,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
|
|||
<Navbar scrolledBgClassName={navbarScrolledBg} />
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<div className="flex h-screen pt-16 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import Image from "next/image";
|
|||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
|
||||
|
|
@ -157,6 +158,7 @@ const PublicAssistantMessage: FC = () => {
|
|||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningMessagePart,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,112 @@
|
|||
import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
Calendar,
|
||||
Check,
|
||||
FileEdit,
|
||||
FilePlus,
|
||||
FileText,
|
||||
FileUser,
|
||||
FileX,
|
||||
Film,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Mail,
|
||||
MessagesSquare,
|
||||
Move,
|
||||
Plus,
|
||||
Podcast,
|
||||
ScanLine,
|
||||
Search,
|
||||
Send,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Every tool now renders a card via ``ToolFallback``. The icon map is
|
||||
* keyed on the canonical backend tool name (registered in
|
||||
* ``surfsense_backend/app/agents/new_chat/tools/registry.py``); unknown
|
||||
* names fall back to the generic ``Wrench`` icon so the card still
|
||||
* communicates "this is a tool call".
|
||||
*/
|
||||
const TOOL_ICONS: Record<string, LucideIcon> = {
|
||||
// Generators
|
||||
generate_podcast: Podcast,
|
||||
generate_video_presentation: Film,
|
||||
generate_report: FileText,
|
||||
generate_resume: FileUser,
|
||||
generate_image: ImageIcon,
|
||||
display_image: ImageIcon,
|
||||
// Web / search
|
||||
scrape_webpage: ScanLine,
|
||||
web_search: Globe,
|
||||
search_surfsense_docs: BookOpen,
|
||||
// Memory
|
||||
update_memory: Brain,
|
||||
// Filesystem (built-in deepagent + middleware)
|
||||
read_file: FileText,
|
||||
write_file: FilePlus,
|
||||
edit_file: FileEdit,
|
||||
move_file: Move,
|
||||
rm: FileX,
|
||||
rmdir: FolderX,
|
||||
mkdir: FolderPlus,
|
||||
ls: FolderTree,
|
||||
write_todos: ListTodo,
|
||||
// Calendar
|
||||
search_calendar_events: Search,
|
||||
create_calendar_event: Calendar,
|
||||
update_calendar_event: Calendar,
|
||||
delete_calendar_event: Calendar,
|
||||
// Gmail
|
||||
search_gmail: Search,
|
||||
read_gmail_email: Mail,
|
||||
create_gmail_draft: Mail,
|
||||
update_gmail_draft: FileEdit,
|
||||
send_gmail_email: Send,
|
||||
trash_gmail_email: Trash2,
|
||||
// Notion / Confluence pages
|
||||
create_notion_page: FilePlus,
|
||||
update_notion_page: FileEdit,
|
||||
delete_notion_page: FileX,
|
||||
create_confluence_page: FilePlus,
|
||||
update_confluence_page: FileEdit,
|
||||
delete_confluence_page: FileX,
|
||||
// Linear / Jira issues
|
||||
create_linear_issue: Plus,
|
||||
update_linear_issue: FileEdit,
|
||||
delete_linear_issue: Trash2,
|
||||
create_jira_issue: Plus,
|
||||
update_jira_issue: FileEdit,
|
||||
delete_jira_issue: Trash2,
|
||||
// Drive-like file connectors
|
||||
create_google_drive_file: FilePlus,
|
||||
delete_google_drive_file: FileX,
|
||||
create_dropbox_file: FilePlus,
|
||||
delete_dropbox_file: FileX,
|
||||
create_onedrive_file: FilePlus,
|
||||
delete_onedrive_file: FileX,
|
||||
// Chat connectors
|
||||
list_discord_channels: MessagesSquare,
|
||||
read_discord_messages: MessagesSquare,
|
||||
send_discord_message: Send,
|
||||
list_teams_channels: MessagesSquare,
|
||||
read_teams_messages: MessagesSquare,
|
||||
send_teams_message: Send,
|
||||
// Luma
|
||||
list_luma_events: Calendar,
|
||||
read_luma_event: Calendar,
|
||||
create_luma_event: Calendar,
|
||||
// Misc
|
||||
get_connected_accounts: Check,
|
||||
execute: Wrench,
|
||||
execute_code: Wrench,
|
||||
};
|
||||
|
||||
export function getToolIcon(name: string): LucideIcon {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ const AgentActionReadSchema = z.object({
|
|||
reverse_of: z.number().nullable(),
|
||||
reverted_by_action_id: z.number().nullable(),
|
||||
is_revert_action: z.boolean(),
|
||||
// Correlation ids added in migration 135. The LangChain
|
||||
// ``tool_call_id`` joins this row to the chat tool card via the
|
||||
// ``data-action-log.lc_tool_call_id`` SSE event, and
|
||||
// ``chat_turn_id`` keys the per-turn revert endpoint.
|
||||
tool_call_id: z.string().nullable().optional(),
|
||||
chat_turn_id: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
|
|
@ -38,6 +44,48 @@ const RevertResponseSchema = z.object({
|
|||
|
||||
export type RevertResponse = z.infer<typeof RevertResponseSchema>;
|
||||
|
||||
// Per-turn batch revert. The route never returns whole-batch 4xx;
|
||||
// partial success is the common case and surfaced as
|
||||
// ``status === "partial"`` with a per-action result list.
|
||||
const RevertTurnActionResultSchema = z.object({
|
||||
action_id: z.number(),
|
||||
tool_name: z.string(),
|
||||
status: z.enum([
|
||||
"reverted",
|
||||
"already_reverted",
|
||||
"not_reversible",
|
||||
"permission_denied",
|
||||
"failed",
|
||||
"skipped",
|
||||
]),
|
||||
message: z.string().nullable().optional(),
|
||||
new_action_id: z.number().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type RevertTurnActionResult = z.infer<typeof RevertTurnActionResultSchema>;
|
||||
|
||||
const RevertTurnResponseSchema = z.object({
|
||||
status: z.enum(["ok", "partial"]),
|
||||
chat_turn_id: z.string(),
|
||||
total: z.number(),
|
||||
reverted: z.number(),
|
||||
already_reverted: z.number(),
|
||||
not_reversible: z.number(),
|
||||
// ``permission_denied`` and ``skipped`` are first-class counters so
|
||||
// ``total === reverted + already_reverted +
|
||||
// not_reversible + permission_denied + failed + skipped`` always
|
||||
// holds. ``.default(0)`` keeps the schema backwards-compatible
|
||||
// with older deployments that haven't shipped the response model
|
||||
// update yet.
|
||||
permission_denied: z.number().default(0),
|
||||
failed: z.number(),
|
||||
skipped: z.number().default(0),
|
||||
results: z.array(RevertTurnActionResultSchema),
|
||||
});
|
||||
|
||||
export type RevertTurnResponse = z.infer<typeof RevertTurnResponseSchema>;
|
||||
|
||||
class AgentActionsApiService {
|
||||
listForThread = async (
|
||||
threadId: number,
|
||||
|
|
@ -59,6 +107,14 @@ class AgentActionsApiService {
|
|||
{ body: {} }
|
||||
);
|
||||
};
|
||||
|
||||
revertTurn = async (threadId: number, chatTurnId: string): Promise<RevertTurnResponse> => {
|
||||
return baseApiService.post(
|
||||
`/api/v1/threads/${threadId}/revert-turn/${encodeURIComponent(chatTurnId)}`,
|
||||
RevertTurnResponseSchema,
|
||||
{ body: {} }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentActionsApiService = new AgentActionsApiService();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
}
|
||||
|
||||
const metadata =
|
||||
msg.author_id || msg.token_usage
|
||||
msg.author_id || msg.token_usage || msg.turn_id
|
||||
? {
|
||||
custom: {
|
||||
...(msg.author_id && {
|
||||
|
|
@ -50,6 +50,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
},
|
||||
}),
|
||||
...(msg.token_usage && { usage: msg.token_usage }),
|
||||
// Surface ``chat_turn_id`` so the assistant message
|
||||
// footer can scope its "Revert turn" button to just
|
||||
// this turn's actions. Null on legacy rows.
|
||||
...(msg.turn_id && { chatTurnId: msg.turn_id }),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -9,21 +9,42 @@ export interface ThinkingStepData {
|
|||
|
||||
export type ContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| {
|
||||
type: "tool-call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
/**
|
||||
* Authoritative LangChain ``tool_call.id`` propagated by the backend
|
||||
* via ``langchainToolCallId`` on tool-input-start/available and
|
||||
* tool-output-available events. Used to join a card to the
|
||||
* matching ``AgentActionLog`` row exposed by
|
||||
* ``GET /threads/{id}/actions`` and the streamed
|
||||
* ``data-action-log`` events.
|
||||
*/
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
| {
|
||||
type: "data-thinking-steps";
|
||||
data: { steps: ThinkingStepData[] };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Between-step separator. Pushed by `addStepSeparator` when
|
||||
* a `start-step` SSE event arrives AFTER the message already
|
||||
* has non-step content. Rendered by `StepSeparatorDataUI`
|
||||
* (see assistant-ui/step-separator.tsx).
|
||||
*/
|
||||
type: "data-step-separator";
|
||||
data: { stepIndex: number };
|
||||
};
|
||||
|
||||
export interface ContentPartsState {
|
||||
contentParts: ContentPart[];
|
||||
currentTextPartIndex: number;
|
||||
currentReasoningPartIndex: number;
|
||||
toolCallIndices: Map<string, number>;
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +95,9 @@ export function updateThinkingSteps(
|
|||
if (state.currentTextPartIndex >= 0) {
|
||||
state.currentTextPartIndex += 1;
|
||||
}
|
||||
if (state.currentReasoningPartIndex >= 0) {
|
||||
state.currentReasoningPartIndex += 1;
|
||||
}
|
||||
for (const [id, idx] of state.toolCallIndices) {
|
||||
state.toolCallIndices.set(id, idx + 1);
|
||||
}
|
||||
|
|
@ -131,6 +155,12 @@ export class FrameBatchedUpdater {
|
|||
}
|
||||
|
||||
export function appendText(state: ContentPartsState, delta: string): void {
|
||||
// First text delta after a reasoning block: close the reasoning so
|
||||
// the assistant-ui renderer treats them as separate parts (the
|
||||
// reasoning block collapses; the answer streams below).
|
||||
if (state.currentReasoningPartIndex >= 0) {
|
||||
state.currentReasoningPartIndex = -1;
|
||||
}
|
||||
if (
|
||||
state.currentTextPartIndex >= 0 &&
|
||||
state.contentParts[state.currentTextPartIndex]?.type === "text"
|
||||
|
|
@ -143,36 +173,129 @@ export function appendText(state: ContentPartsState, delta: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function appendReasoning(state: ContentPartsState, delta: string): void {
|
||||
// Symmetric to appendText: open a fresh reasoning block on first
|
||||
// delta, then accumulate into it. ``endReasoning`` simply closes
|
||||
// the active block; subsequent reasoning deltas would open a new
|
||||
// one (matching ``text-start/end`` semantics on the wire).
|
||||
if (state.currentTextPartIndex >= 0) {
|
||||
state.currentTextPartIndex = -1;
|
||||
}
|
||||
if (
|
||||
state.currentReasoningPartIndex >= 0 &&
|
||||
state.contentParts[state.currentReasoningPartIndex]?.type === "reasoning"
|
||||
) {
|
||||
(
|
||||
state.contentParts[state.currentReasoningPartIndex] as {
|
||||
type: "reasoning";
|
||||
text: string;
|
||||
}
|
||||
).text += delta;
|
||||
} else {
|
||||
state.contentParts.push({ type: "reasoning", text: delta });
|
||||
state.currentReasoningPartIndex = state.contentParts.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function endReasoning(state: ContentPartsState): void {
|
||||
state.currentReasoningPartIndex = -1;
|
||||
}
|
||||
|
||||
export function addStepSeparator(state: ContentPartsState): void {
|
||||
// Push a divider between consecutive model steps within a single
|
||||
// assistant turn. We only emit it when the message already has
|
||||
// non-step content (so the FIRST step of a turn doesn't
|
||||
// generate a leading separator) and when the previous part isn't
|
||||
// itself a separator (defensive against duplicate `start-step`
|
||||
// events).
|
||||
const hasContent = state.contentParts.some(
|
||||
(p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call"
|
||||
);
|
||||
if (!hasContent) return;
|
||||
const last = state.contentParts[state.contentParts.length - 1];
|
||||
if (last && last.type === "data-step-separator") return;
|
||||
|
||||
const stepIndex = state.contentParts.filter((p) => p.type === "data-step-separator").length;
|
||||
state.contentParts.push({ type: "data-step-separator", data: { stepIndex } });
|
||||
state.currentTextPartIndex = -1;
|
||||
state.currentReasoningPartIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist of tool names that should produce a UI tool card. The
|
||||
* sentinel ``"all"`` matches every tool — we dropped the legacy
|
||||
* ``BASE_TOOLS_WITH_UI`` gate so that ALL tool calls render via the
|
||||
* generic ``ToolFallback``. The backend's ``format_thinking_step``
|
||||
* summarisation and the defensive ``result_length``-only default for
|
||||
* unknown tools keep persisted message JSON from ballooning.
|
||||
*/
|
||||
export type ToolUIGate = Set<string> | "all";
|
||||
|
||||
function _toolPasses(gate: ToolUIGate, toolName: string): boolean {
|
||||
return gate === "all" || gate.has(toolName);
|
||||
}
|
||||
|
||||
export function addToolCall(
|
||||
state: ContentPartsState,
|
||||
toolsWithUI: Set<string>,
|
||||
toolsWithUI: ToolUIGate,
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
force = false
|
||||
force = false,
|
||||
langchainToolCallId?: string
|
||||
): void {
|
||||
if (force || toolsWithUI.has(toolName)) {
|
||||
if (force || _toolPasses(toolsWithUI, toolName)) {
|
||||
state.contentParts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
...(langchainToolCallId ? { langchainToolCallId } : {}),
|
||||
});
|
||||
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
|
||||
state.currentTextPartIndex = -1;
|
||||
state.currentReasoningPartIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-lookup helper used by the SSE ``data-action-log`` handler:
|
||||
* given the LangChain ``tool_call.id`` (set on the content part as
|
||||
* ``langchainToolCallId``), return the synthetic ``toolCallId`` that
|
||||
* the chat tool card uses (``call_<run-id>``). Returns ``null`` when no
|
||||
* matching tool card has been seen yet — the action is still recorded
|
||||
* in the LC-id-keyed atom so the card can pick it up when it eventually
|
||||
* arrives.
|
||||
*/
|
||||
export function findToolCallIdByLcId(
|
||||
state: ContentPartsState,
|
||||
lcToolCallId: string
|
||||
): string | null {
|
||||
for (const part of state.contentParts) {
|
||||
if (part.type === "tool-call" && part.langchainToolCallId === lcToolCallId) {
|
||||
return part.toolCallId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateToolCall(
|
||||
state: ContentPartsState,
|
||||
toolCallId: string,
|
||||
update: { args?: Record<string, unknown>; result?: unknown }
|
||||
update: { args?: Record<string, unknown>; result?: unknown; langchainToolCallId?: string }
|
||||
): void {
|
||||
const index = state.toolCallIndices.get(toolCallId);
|
||||
if (index !== undefined && state.contentParts[index]?.type === "tool-call") {
|
||||
const tc = state.contentParts[index] as ContentPart & { type: "tool-call" };
|
||||
if (update.args) tc.args = update.args;
|
||||
if (update.result !== undefined) tc.result = update.result;
|
||||
// Only backfill langchainToolCallId if not already set — the
|
||||
// authoritative ``on_tool_end`` value should override an earlier
|
||||
// best-effort match, but a NULL late-arriving value should not
|
||||
// blow away a known good early one.
|
||||
if (update.langchainToolCallId && !tc.langchainToolCallId) {
|
||||
tc.langchainToolCallId = update.langchainToolCallId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,13 +307,15 @@ function _hasInterruptResult(part: ContentPart): boolean {
|
|||
|
||||
export function buildContentForUI(
|
||||
state: ContentPartsState,
|
||||
toolsWithUI: Set<string>
|
||||
toolsWithUI: ToolUIGate
|
||||
): ThreadMessageLike["content"] {
|
||||
const filtered = state.contentParts.filter((part) => {
|
||||
if (part.type === "text") return part.text.length > 0;
|
||||
if (part.type === "reasoning") return part.text.length > 0;
|
||||
if (part.type === "tool-call")
|
||||
return toolsWithUI.has(part.toolName) || _hasInterruptResult(part);
|
||||
return _toolPasses(toolsWithUI, part.toolName) || _hasInterruptResult(part);
|
||||
if (part.type === "data-thinking-steps") return true;
|
||||
if (part.type === "data-step-separator") return true;
|
||||
return false;
|
||||
});
|
||||
return filtered.length > 0
|
||||
|
|
@ -200,20 +325,28 @@ export function buildContentForUI(
|
|||
|
||||
export function buildContentForPersistence(
|
||||
state: ContentPartsState,
|
||||
toolsWithUI: Set<string>
|
||||
toolsWithUI: ToolUIGate
|
||||
): unknown[] {
|
||||
const parts: unknown[] = [];
|
||||
|
||||
for (const part of state.contentParts) {
|
||||
if (part.type === "text" && part.text.length > 0) {
|
||||
parts.push(part);
|
||||
} else if (part.type === "reasoning" && part.text.length > 0) {
|
||||
// Persist reasoning blocks so a chat reload re-renders the
|
||||
// collapsed thinking section instead of
|
||||
// silently dropping it (mirrors the data-thinking-steps
|
||||
// branch above).
|
||||
parts.push(part);
|
||||
} else if (
|
||||
part.type === "tool-call" &&
|
||||
(toolsWithUI.has(part.toolName) || _hasInterruptResult(part))
|
||||
(_toolPasses(toolsWithUI, part.toolName) || _hasInterruptResult(part))
|
||||
) {
|
||||
parts.push(part);
|
||||
} else if (part.type === "data-thinking-steps") {
|
||||
parts.push(part);
|
||||
} else if (part.type === "data-step-separator") {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,23 +354,122 @@ export function buildContentForPersistence(
|
|||
}
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: "text-delta"; delta: string }
|
||||
| { type: "tool-input-start"; toolCallId: string; toolName: string }
|
||||
| { type: "start"; messageId?: string }
|
||||
| { type: "finish" }
|
||||
| { type: "start-step" }
|
||||
| { type: "finish-step" }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-delta"; id?: string; delta: string }
|
||||
| { type: "text-end"; id: string }
|
||||
| { type: "reasoning-start"; id: string }
|
||||
| { type: "reasoning-delta"; id?: string; delta: string }
|
||||
| { type: "reasoning-end"; id: string }
|
||||
| {
|
||||
type: "tool-input-start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
/** Authoritative LangChain ``tool_call.id``. Optional. */
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
| {
|
||||
type: "tool-input-available";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
| {
|
||||
type: "tool-output-available";
|
||||
toolCallId: string;
|
||||
output: Record<string, unknown>;
|
||||
/** Authoritative LangChain ``tool_call.id`` extracted from
|
||||
* ``ToolMessage.tool_call_id`` at on_tool_end. Backfills cards
|
||||
* that didn't get the id at tool-input-start time. */
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
| { type: "data-thinking-step"; data: ThinkingStepData }
|
||||
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
||||
| { type: "data-interrupt-request"; data: Record<string, unknown> }
|
||||
| { type: "data-documents-updated"; data: Record<string, unknown> }
|
||||
| {
|
||||
/**
|
||||
* A freshly committed AgentActionLog row. Frontend stores
|
||||
* this in a Map keyed off ``lc_tool_call_id`` so the chat
|
||||
* tool card can light up its Revert button.
|
||||
*/
|
||||
type: "data-action-log";
|
||||
data: {
|
||||
id: number;
|
||||
lc_tool_call_id: string | null;
|
||||
chat_turn_id: string | null;
|
||||
tool_name: string;
|
||||
reversible: boolean;
|
||||
reverse_descriptor_present: boolean;
|
||||
created_at: string | null;
|
||||
error: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Reversibility flipped (filesystem op SAVEPOINT committed;
|
||||
* cf. ``kb_persistence._dispatch_reversibility_update``).
|
||||
*/
|
||||
type: "data-action-log-updated";
|
||||
data: { id: number; reversible: boolean };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted at the start of every stream so the frontend can
|
||||
* stamp the per-turn correlation id onto the in-flight
|
||||
* assistant message and replay it via
|
||||
* ``appendMessage``. Pure-text turns never produce
|
||||
* action-log events; this event guarantees the frontend
|
||||
* always learns the turn id.
|
||||
*/
|
||||
type: "data-turn-info";
|
||||
data: { chat_turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Best-effort revert pass that ran BEFORE this regeneration.
|
||||
* Per-action results are forwarded to the UI so the user
|
||||
* can see which downstream actions were rolled
|
||||
* back vs which couldn't be undone.
|
||||
*/
|
||||
type: "data-revert-results";
|
||||
data: {
|
||||
status: "ok" | "partial";
|
||||
chat_turn_ids: string[];
|
||||
total: number;
|
||||
reverted: number;
|
||||
already_reverted: number;
|
||||
not_reversible: number;
|
||||
/**
|
||||
* ``permission_denied`` and ``skipped`` are first-class
|
||||
* counters so the response invariant
|
||||
* ``total === sum(counters)`` always holds. Optional
|
||||
* for forward compatibility with older backends; the
|
||||
* frontend treats missing values as ``0``.
|
||||
*/
|
||||
permission_denied?: number;
|
||||
failed: number;
|
||||
skipped?: number;
|
||||
results: Array<{
|
||||
action_id: number;
|
||||
tool_name: string;
|
||||
status:
|
||||
| "reverted"
|
||||
| "already_reverted"
|
||||
| "not_reversible"
|
||||
| "permission_denied"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
message?: string | null;
|
||||
new_action_id?: number | null;
|
||||
error?: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-token-usage";
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export interface MessageRecord {
|
|||
author_display_name?: string | null;
|
||||
author_avatar_url?: string | null;
|
||||
token_usage?: TokenUsageSummary | null;
|
||||
// Per-turn correlation id from ``configurable.turn_id`` at streaming
|
||||
// time (added in migration 136). Used by the per-turn revert
|
||||
// endpoint and edit-from-arbitrary-position. Nullable on legacy
|
||||
// rows that predate the column.
|
||||
turn_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
|
|
@ -123,10 +128,20 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
|
|||
|
||||
/**
|
||||
* Append a message to a thread.
|
||||
*
|
||||
* ``turn_id`` is the per-turn correlation id streamed by the backend
|
||||
* 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.
|
||||
*/
|
||||
export async function appendMessage(
|
||||
threadId: number,
|
||||
message: { role: "user" | "assistant" | "system"; content: unknown; token_usage?: unknown }
|
||||
message: {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: unknown;
|
||||
token_usage?: unknown;
|
||||
turn_id?: string | null;
|
||||
}
|
||||
): Promise<MessageRecord> {
|
||||
return baseApiService.post<MessageRecord>(`/api/v1/threads/${threadId}/messages`, undefined, {
|
||||
body: message,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue