mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp
This commit is contained in:
commit
481bb406b6
757 changed files with 70989 additions and 4089 deletions
|
|
@ -5,6 +5,8 @@ import {
|
|||
type CreateTokenCheckoutSessionResponse,
|
||||
createCheckoutSessionResponse,
|
||||
createTokenCheckoutSessionResponse,
|
||||
type FinalizeCheckoutResponse,
|
||||
finalizeCheckoutResponse,
|
||||
type GetPagePurchasesResponse,
|
||||
type GetTokenPurchasesResponse,
|
||||
getPagePurchasesResponse,
|
||||
|
|
@ -54,6 +56,20 @@ class StripeApiService {
|
|||
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
|
||||
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronously fulfil a checkout session from the success page.
|
||||
*
|
||||
* Solves the race where the user lands on /purchase-success before
|
||||
* Stripe's checkout.session.completed webhook arrives. Idempotent —
|
||||
* safe to call concurrently with the webhook.
|
||||
*/
|
||||
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
|
||||
finalizeCheckoutResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const stripeApiService = new StripeApiService();
|
||||
|
|
|
|||
|
|
@ -60,12 +60,20 @@ export function handleUnauthorized(): void {
|
|||
const currentPath = pathname + window.location.search + window.location.hash;
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
||||
if (!excludedPaths.includes(pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
setRedirectPath(currentPath);
|
||||
}
|
||||
window.location.href = getLoginPath();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the path to redirect to after successful authentication.
|
||||
*/
|
||||
export function setRedirectPath(path: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stored redirect path and clears it from storage
|
||||
* Call this after successful login to redirect the user back
|
||||
|
|
@ -230,7 +238,7 @@ export function redirectToLogin(): void {
|
|||
// Don't save auth-related paths or home page
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
|
||||
if (!excludedPaths.includes(window.location.pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
setRedirectPath(currentPath);
|
||||
}
|
||||
|
||||
window.location.href = getLoginPath();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
type MentionKeyInput = {
|
||||
id: number;
|
||||
document_type?: string | null;
|
||||
kind?: "doc" | "folder";
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for a mention chip.
|
||||
*
|
||||
* The ``kind:document_type:id`` shape prevents a document and a folder
|
||||
* with the same integer id from colliding in the chip array (folders
|
||||
* use the ``FOLDER`` sentinel ``document_type``; the ``kind`` prefix
|
||||
* is the belt-and-braces guard).
|
||||
*/
|
||||
export function getMentionDocKey(doc: MentionKeyInput): string {
|
||||
return `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const kind = doc.kind ?? "doc";
|
||||
return `${kind}:${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,187 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type { MessageRecord } from "./thread-persistence";
|
||||
|
||||
/** Minimal shape used by the interrupt/resume reconciler. */
|
||||
interface AbortableMessage {
|
||||
id: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
turn_id?: string | null;
|
||||
}
|
||||
|
||||
function isAssistant(msg: AbortableMessage): boolean {
|
||||
return msg.role.toLowerCase() === "assistant";
|
||||
}
|
||||
|
||||
/** True when the row carries at least one tool-call with ``state: "aborted"``. */
|
||||
function hasAbortedToolCall(msg: AbortableMessage): boolean {
|
||||
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
|
||||
for (const part of msg.content) {
|
||||
if (typeof part !== "object" || part === null) continue;
|
||||
if ((part as { type?: string }).type !== "tool-call") continue;
|
||||
if ((part as { state?: unknown }).state === "aborted") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend message to assistant-ui ThreadMessageLike format.
|
||||
* Migrates legacy `thinking-steps` parts to `data-thinking-steps` (assistant-ui data parts).
|
||||
* True when EVERY tool-call on the row is aborted. The row is then a
|
||||
* frozen interrupt frame with no salvageable activity — safe to drop
|
||||
* outright.
|
||||
*/
|
||||
function isFullyAbortedAssistantMessage(msg: AbortableMessage): boolean {
|
||||
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
|
||||
let hasToolCalls = false;
|
||||
for (const part of msg.content) {
|
||||
if (typeof part !== "object" || part === null) continue;
|
||||
if ((part as { type?: string }).type !== "tool-call") continue;
|
||||
hasToolCalls = true;
|
||||
if ((part as { state?: unknown }).state !== "aborted") return false;
|
||||
}
|
||||
return hasToolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the resume row that supersedes ``messages[idx]``. The
|
||||
* ``stream_resume_chat`` flow allocates a fresh ``turn_id`` so we
|
||||
* can't pair on it; conversational adjacency (assistant → assistant
|
||||
* with no user row between) is the unique signature. Skips already-
|
||||
* dropped indices so chained interrupt-resumes still pair cleanly.
|
||||
*/
|
||||
function findResumeSuccessorIdx<T extends AbortableMessage>(
|
||||
messages: readonly T[],
|
||||
idx: number,
|
||||
dropped: ReadonlySet<number>
|
||||
): number | null {
|
||||
for (let i = idx + 1; i < messages.length; i++) {
|
||||
if (dropped.has(i)) continue;
|
||||
const role = messages[i].role.toLowerCase();
|
||||
if (role === "user") return null;
|
||||
if (role === "assistant") return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read ``data.steps`` from either ``data-thinking-steps`` (modern) or ``thinking-steps`` (legacy). */
|
||||
function extractStepsFromPart(part: unknown): unknown[] | null {
|
||||
if (typeof part !== "object" || part === null) return null;
|
||||
const p = part as { type?: unknown; data?: unknown; steps?: unknown };
|
||||
if (p.type === "data-thinking-steps") {
|
||||
const data = p.data as { steps?: unknown } | undefined;
|
||||
return Array.isArray(data?.steps) ? data.steps : [];
|
||||
}
|
||||
if (p.type === "thinking-steps") {
|
||||
return Array.isArray(p.steps) ? (p.steps as unknown[]) : [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Split a content array into (combined steps, all other parts in order). */
|
||||
function partitionContent(content: unknown): { steps: unknown[]; others: unknown[] } {
|
||||
if (!Array.isArray(content)) return { steps: [], others: [] };
|
||||
const steps: unknown[] = [];
|
||||
const others: unknown[] = [];
|
||||
for (const part of content) {
|
||||
const partSteps = extractStepsFromPart(part);
|
||||
if (partSteps !== null) {
|
||||
steps.push(...partSteps);
|
||||
continue;
|
||||
}
|
||||
others.push(part);
|
||||
}
|
||||
return { steps, others };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an interrupt-frame row's content into its resume successor so
|
||||
* the user sees one assistant turn instead of two stacked bubbles.
|
||||
* Successor's metadata wins (id, created_at, turn_id, token_usage,
|
||||
* author) — that's the row the per-turn revert button keys to.
|
||||
*
|
||||
* Order: combined ``data-thinking-steps`` (older steps then newer) at
|
||||
* index 0, followed by older's other parts in order, then newer's. The
|
||||
* older row's aborted ``task`` wrapper is preserved so the rejected
|
||||
* attempt remains visible alongside the successful retry; both spans
|
||||
* survive and ``groupItems`` renders them as sibling task branches in
|
||||
* one timeline.
|
||||
*/
|
||||
function mergeInterruptedIntoResume<T extends AbortableMessage>(older: T, newer: T): T {
|
||||
const olderParts = partitionContent(older.content);
|
||||
const newerParts = partitionContent(newer.content);
|
||||
|
||||
const mergedSteps = [...olderParts.steps, ...newerParts.steps];
|
||||
const mergedContent: unknown[] = [];
|
||||
if (mergedSteps.length > 0) {
|
||||
mergedContent.push({ type: "data-thinking-steps", data: { steps: mergedSteps } });
|
||||
}
|
||||
mergedContent.push(...olderParts.others, ...newerParts.others);
|
||||
|
||||
return { ...newer, content: mergedContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile interrupt-frame and resume rows so the UI shows one
|
||||
* assistant turn per user turn even when the backend persists them as
|
||||
* separate ``new_chat_messages`` rows.
|
||||
*
|
||||
* Two cases, both keyed on conversational adjacency (assistant →
|
||||
* assistant with no user row between):
|
||||
*
|
||||
* - **Fully aborted older row** (every tool-call ``state: "aborted"``,
|
||||
* no salvageable activity) → drop the older row.
|
||||
* - **Partially aborted older row** (mixed completed + aborted, e.g.
|
||||
* inner subagent tools ran before the interrupt) → fold its content
|
||||
* into the successor. Successor metadata wins.
|
||||
*
|
||||
* Never-resumed aborts (user navigated away mid-decision) survive so
|
||||
* the user still sees what happened.
|
||||
*
|
||||
* Pure: returns a new array with new merged objects when needed.
|
||||
* Caller passes messages in chronological order.
|
||||
*/
|
||||
export function reconcileInterruptedAssistantMessages<T extends AbortableMessage>(
|
||||
messages: readonly T[]
|
||||
): T[] {
|
||||
const dropped = new Set<number>();
|
||||
const mergeInto = new Map<number, number[]>();
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (dropped.has(i)) continue;
|
||||
const msg = messages[i];
|
||||
if (!hasAbortedToolCall(msg)) continue;
|
||||
|
||||
const successorIdx = findResumeSuccessorIdx(messages, i, dropped);
|
||||
if (successorIdx === null) continue;
|
||||
|
||||
dropped.add(i);
|
||||
if (!isFullyAbortedAssistantMessage(msg)) {
|
||||
const list = mergeInto.get(successorIdx) ?? [];
|
||||
list.push(i);
|
||||
mergeInto.set(successorIdx, list);
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (dropped.has(i)) continue;
|
||||
const olderIdxs = mergeInto.get(i);
|
||||
if (olderIdxs && olderIdxs.length > 0) {
|
||||
let merged = messages[i];
|
||||
for (const olderIdx of olderIdxs) {
|
||||
merged = mergeInterruptedIntoResume(messages[olderIdx], merged);
|
||||
}
|
||||
result.push(merged);
|
||||
continue;
|
||||
}
|
||||
result.push(messages[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend ``MessageRecord`` to assistant-ui's
|
||||
* ``ThreadMessageLike``. Also migrates legacy ``thinking-steps`` parts
|
||||
* to ``data-thinking-steps``.
|
||||
*/
|
||||
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||
let content: ThreadMessageLike["content"];
|
||||
|
|
@ -24,9 +202,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
"type" in part &&
|
||||
(part as { type: string }).type === "thinking-steps"
|
||||
) {
|
||||
const steps = (part as unknown as { steps?: unknown[] }).steps;
|
||||
return {
|
||||
type: "data-thinking-steps",
|
||||
data: { steps: (part as { steps: unknown[] }).steps ?? [] },
|
||||
data: { steps: Array.isArray(steps) ? steps : [] },
|
||||
};
|
||||
}
|
||||
return part;
|
||||
|
|
@ -50,9 +229,8 @@ 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.
|
||||
// Surfaced for the assistant footer's per-turn
|
||||
// "Revert turn" button. Null on legacy rows.
|
||||
...(msg.turn_id && { chatTurnId: msg.turn_id }),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,8 @@ export function processSharedStreamEvent(
|
|||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
parsed.langchainToolCallId,
|
||||
parsed.metadata
|
||||
);
|
||||
forceFlush();
|
||||
return true;
|
||||
|
|
@ -131,6 +132,7 @@ export function processSharedStreamEvent(
|
|||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
metadata: parsed.metadata,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
|
|
@ -140,7 +142,8 @@ export function processSharedStreamEvent(
|
|||
parsed.toolName,
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
parsed.langchainToolCallId,
|
||||
parsed.metadata
|
||||
);
|
||||
// addToolCall doesn't accept argsText today; backfill via
|
||||
// updateToolCall so the new card renders pretty-printed JSON.
|
||||
|
|
@ -156,6 +159,7 @@ export function processSharedStreamEvent(
|
|||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
metadata: parsed.metadata,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });
|
||||
|
|
|
|||
|
|
@ -1,91 +1,4 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import {
|
||||
addToolCall,
|
||||
type ContentPartsState,
|
||||
type ToolUIGate,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
type InterruptActionRequest = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EditedInterruptAction = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readInterruptActions(interruptData: Record<string, unknown>): InterruptActionRequest[] {
|
||||
return (interruptData.action_requests ?? []) as InterruptActionRequest[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an interrupt request payload to tool-call parts. Existing tool cards
|
||||
* are updated in-place; missing ones are upserted so approval UI always shows.
|
||||
*/
|
||||
export function applyInterruptRequestToContentParts(
|
||||
contentPartsState: ContentPartsState,
|
||||
toolsWithUI: ToolUIGate,
|
||||
interruptData: Record<string, unknown>
|
||||
): void {
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
const actionRequests = readInterruptActions(interruptData);
|
||||
for (const action of actionRequests) {
|
||||
const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||
const part = contentParts[idx];
|
||||
return part?.type === "tool-call" && part.toolName === action.name;
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
updateToolCall(contentPartsState, existingEntry[0], {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
} else {
|
||||
const toolCallId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, toolCallId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeEditedInterruptAction(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
editedAction: EditedInterruptAction | undefined
|
||||
): void {
|
||||
if (!editedAction) return;
|
||||
for (const part of contentParts) {
|
||||
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
||||
const mergedArgs = { ...part.args, ...editedAction.args };
|
||||
part.args = mergedArgs;
|
||||
// assistant-ui prefers argsText over JSON.stringify(args)
|
||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markInterruptDecisionOnContentParts(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
decisionType: "approve" | "reject" | undefined
|
||||
): void {
|
||||
if (!decisionType) return;
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
"__interrupt__" in (part.result as Record<string, unknown>)
|
||||
) {
|
||||
part.result = {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decisionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a streamed message is persisted, the backend returns the durable
|
||||
|
|
@ -114,6 +27,29 @@ export function readStreamedChatTurnId(data: unknown): string | null {
|
|||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the payload of `data-user-message-id` / `data-assistant-message-id`
|
||||
* SSE events emitted by `stream_new_chat` and `stream_resume_chat` after
|
||||
* `persist_user_turn` / `persist_assistant_shell` resolve a canonical
|
||||
* `new_chat_messages.id`. Mirrors {@link readStreamedChatTurnId}.
|
||||
*
|
||||
* Returns `null` when the payload is malformed (missing or non-numeric
|
||||
* `message_id`); callers should treat this as "ignore the event" so a
|
||||
* malformed BE payload never overwrites the optimistic id with a bogus
|
||||
* value.
|
||||
*/
|
||||
export function readStreamedMessageId(
|
||||
data: unknown
|
||||
): { messageId: number; turnId: string | null } | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const obj = data as { message_id?: unknown; turn_id?: unknown };
|
||||
if (typeof obj.message_id !== "number" || !Number.isFinite(obj.message_id)) {
|
||||
return null;
|
||||
}
|
||||
const turnId = typeof obj.turn_id === "string" && obj.turn_id.length > 0 ? obj.turn_id : null;
|
||||
return { messageId: obj.message_id, turnId };
|
||||
}
|
||||
|
||||
export function applyTurnIdToAssistantMessageList(
|
||||
messages: ThreadMessageLike[],
|
||||
assistantMsgId: string,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export interface ThinkingStepData {
|
|||
title: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
items: string[];
|
||||
/**
|
||||
* Optional relay fields from ``data-thinking-step`` when present on the wire
|
||||
* (e.g. ``spanId``). Populated in a later slice; equality helpers ignore until wired.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ContentPart =
|
||||
|
|
@ -42,6 +47,11 @@ export type ContentPart =
|
|||
* ``data-action-log`` events.
|
||||
*/
|
||||
langchainToolCallId?: string;
|
||||
/**
|
||||
* Relay correlation from tool SSE (e.g. ``spanId``, ``thinkingStepId``).
|
||||
* Merged by ``mergeToolPartMetadata`` when events carry ``metadata``.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "data-thinking-steps";
|
||||
|
|
@ -63,6 +73,18 @@ export interface ContentPartsState {
|
|||
currentTextPartIndex: number;
|
||||
currentReasoningPartIndex: number;
|
||||
toolCallIndices: Map<string, number>;
|
||||
/**
|
||||
* Set by the resume flow's rehydration to suppress
|
||||
* ``data-step-separator`` for the rest of this turn. Without it,
|
||||
* the resume stream's first ``start-step`` fires
|
||||
* ``addStepSeparator`` while rehydrated OLD content already makes
|
||||
* ``hasContent`` true → a divider lands between OLD and NEW
|
||||
* content with no semantic value (OLD content is folded by
|
||||
* ``buildTimeline`` + ``reconcileInterruptedAssistantMessages``,
|
||||
* persisted state carries no separator, so the line vanishes on
|
||||
* reload).
|
||||
*/
|
||||
suppressStepSeparators?: boolean;
|
||||
}
|
||||
|
||||
function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
|
||||
|
|
@ -224,7 +246,9 @@ export function addStepSeparator(state: ContentPartsState): void {
|
|||
// 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).
|
||||
// events). Also skipped during a resume turn (see
|
||||
// ``suppressStepSeparators`` on ``ContentPartsState``).
|
||||
if (state.suppressStepSeparators) return;
|
||||
const hasContent = state.contentParts.some(
|
||||
(p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call"
|
||||
);
|
||||
|
|
@ -252,6 +276,23 @@ function _toolPasses(gate: ToolUIGate, toolName: string): boolean {
|
|||
return gate === "all" || gate.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merge relay ``metadata`` into a tool-call part (SSE → content part).
|
||||
* Keys already set on ``into`` are left unchanged so chunk vs canonical tool
|
||||
* events cannot reorder or overwrite ``spanId`` / ``thinkingStepId``.
|
||||
* Matches server ``AssistantContentBuilder`` merge semantics.
|
||||
*/
|
||||
function mergeToolPartMetadata(
|
||||
into: Record<string, unknown>,
|
||||
incoming: Record<string, unknown> | undefined
|
||||
): void {
|
||||
if (!incoming) return;
|
||||
for (const [k, v] of Object.entries(incoming)) {
|
||||
if (k === "__proto__" || k === "constructor") continue;
|
||||
if (!(k in into)) into[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
export function addToolCall(
|
||||
state: ContentPartsState,
|
||||
toolsWithUI: ToolUIGate,
|
||||
|
|
@ -259,15 +300,19 @@ export function addToolCall(
|
|||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
force = false,
|
||||
langchainToolCallId?: string
|
||||
langchainToolCallId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): void {
|
||||
if (force || _toolPasses(toolsWithUI, toolName)) {
|
||||
const relayMeta: Record<string, unknown> = {};
|
||||
mergeToolPartMetadata(relayMeta, metadata);
|
||||
state.contentParts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
...(langchainToolCallId ? { langchainToolCallId } : {}),
|
||||
...(Object.keys(relayMeta).length > 0 ? { metadata: relayMeta } : {}),
|
||||
});
|
||||
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
|
||||
state.currentTextPartIndex = -1;
|
||||
|
|
@ -304,6 +349,7 @@ export function updateToolCall(
|
|||
argsText?: string;
|
||||
result?: unknown;
|
||||
langchainToolCallId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): void {
|
||||
const index = state.toolCallIndices.get(toolCallId);
|
||||
|
|
@ -323,6 +369,11 @@ export function updateToolCall(
|
|||
if (update.langchainToolCallId && !tc.langchainToolCallId) {
|
||||
tc.langchainToolCallId = update.langchainToolCallId;
|
||||
}
|
||||
if (update.metadata && Object.keys(update.metadata).length > 0) {
|
||||
const md = (tc.metadata ?? {}) as Record<string, unknown>;
|
||||
mergeToolPartMetadata(md, update.metadata);
|
||||
tc.metadata = md;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,14 +467,15 @@ export type SSEEvent =
|
|||
toolName: string;
|
||||
/** Authoritative LangChain ``tool_call.id``. Optional. */
|
||||
langchainToolCallId?: string;
|
||||
/** Optional JSON object from tool SSE (same keys as persisted tool-call metadata). */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Live tool-call argument delta. Concatenated into
|
||||
* ``argsText`` on the matching ``tool-call`` content part
|
||||
* by ``appendToolInputDelta``. parity_v2 only — the legacy
|
||||
* code path emits ``tool-input-available`` without prior
|
||||
* deltas.
|
||||
* by ``appendToolInputDelta``. Some providers emit
|
||||
* ``tool-input-available`` without prior deltas.
|
||||
*/
|
||||
type: "tool-input-delta";
|
||||
toolCallId: string;
|
||||
|
|
@ -435,6 +487,7 @@ export type SSEEvent =
|
|||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
langchainToolCallId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool-output-available";
|
||||
|
|
@ -444,6 +497,7 @@ export type SSEEvent =
|
|||
* ``ToolMessage.tool_call_id`` at on_tool_end. Backfills cards
|
||||
* that didn't get the id at tool-input-start time. */
|
||||
langchainToolCallId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| { type: "data-thinking-step"; data: ThinkingStepData }
|
||||
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
||||
|
|
@ -487,6 +541,37 @@ export type SSEEvent =
|
|||
type: "data-turn-info";
|
||||
data: { chat_turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AFTER ``data-turn-info`` /
|
||||
* ``data-turn-status`` and BEFORE any LLM streaming events,
|
||||
* once ``persist_user_turn`` has resolved the canonical
|
||||
* ``new_chat_messages.id`` for the user-side row of the
|
||||
* current turn. The frontend renames its optimistic
|
||||
* ``msg-user-XXX`` placeholder id to ``msg-{message_id}``
|
||||
* so DB-id-gated UI (comments, edit-from-this-message)
|
||||
* unlocks immediately. Not emitted by ``stream_resume_chat``
|
||||
* (resume reuses the original turn's user message).
|
||||
*/
|
||||
type: "data-user-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AND ``stream_resume_chat``
|
||||
* AFTER ``data-turn-info`` / ``data-turn-status`` and BEFORE
|
||||
* any LLM streaming events, once ``persist_assistant_shell``
|
||||
* has resolved the canonical ``new_chat_messages.id`` for
|
||||
* the assistant-side row of the current turn. The frontend
|
||||
* renames its optimistic ``msg-assistant-XXX`` placeholder
|
||||
* id, migrates the local ``tokenUsageStore`` and
|
||||
* ``pendingInterrupt`` references, and binds the running
|
||||
* mutable ``assistantMsgId`` closure variable to the
|
||||
* canonical id for the rest of the stream.
|
||||
*/
|
||||
type: "data-assistant-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Best-effort revert pass that ran BEFORE this regeneration.
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
|
|||
* via ``data-turn-info``. Persisting it lets later edits locate the
|
||||
* matching LangGraph checkpoint without HumanMessage scanning. Older
|
||||
* callers can still omit it for back-compat.
|
||||
*
|
||||
* @deprecated Replaced by the SSE-based message ID handshake. The
|
||||
* streaming generator (`stream_new_chat` / `stream_resume_chat`) now
|
||||
* persists both the user and assistant rows server-side via
|
||||
* `persist_user_turn` / `persist_assistant_shell` and emits
|
||||
* `data-user-message-id` / `data-assistant-message-id` SSE events so
|
||||
* the UI renames its optimistic IDs in real time. The only remaining
|
||||
* caller is `persistAssistantErrorMessage` (pre-stream error fallback
|
||||
* for requests the server never accepted — the server has nothing to
|
||||
* persist in that case). After the legacy route is removed in a
|
||||
* follow-up PR this function will be deleted entirely.
|
||||
*/
|
||||
export async function appendMessage(
|
||||
threadId: number,
|
||||
|
|
|
|||
55
surfsense_web/lib/chat/virtual-path-display.ts
Normal file
55
surfsense_web/lib/chat/virtual-path-display.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Pure helpers for turning agent-facing virtual paths into human-friendly
|
||||
* chip labels.
|
||||
*
|
||||
* Why this lives in `lib` and not in the UI component:
|
||||
* - Pure function = trivial to unit-test (no React, no DOM).
|
||||
* - Used in two render sites today (the user-message chip and the AI-answer
|
||||
* `MentionChip`) and likely more (history search, share-card previews, etc).
|
||||
* Centralising the rules keeps the agent's path encoding and the UI's
|
||||
* decoding from drifting apart.
|
||||
*
|
||||
* The agent emits paths under `/documents/...` with two encoding rules
|
||||
* applied by `surfsense_backend/app/agents/new_chat/path_resolver.py`:
|
||||
*
|
||||
* 1. Every basename ends with `.xml` (so the LLM treats KB documents as XML
|
||||
* files). Display layer strips this — users think of the underlying
|
||||
* filename, not the LLM's wrapper.
|
||||
* 2. Title collisions get a ` (<doc_id>).xml` disambiguation suffix.
|
||||
* Display layer strips the parenthesised id since it's an implementation
|
||||
* detail, not user-facing identity.
|
||||
*/
|
||||
|
||||
const XML_EXTENSION_RE = /\.xml$/i;
|
||||
const DOC_ID_DISAMBIG_RE = /\s\(\d+\)$/;
|
||||
|
||||
export interface VirtualPathDisplay {
|
||||
/** Human-friendly leaf name with `.xml` and ` (<doc_id>)` suffixes stripped. */
|
||||
displayName: string;
|
||||
/** Whether the path points to a folder (trailing slash) rather than a file. */
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a virtual path into the label that should appear in chip UI.
|
||||
*
|
||||
* Folder detection uses the trailing-slash convention the agent already
|
||||
* follows in `<priority_documents>` and `KnowledgeTreeMiddleware`. Falls
|
||||
* back to the raw path if nothing else can be extracted (defensive — the
|
||||
* caller will at least show *something*).
|
||||
*/
|
||||
export function getVirtualPathDisplay(path: string): VirtualPathDisplay {
|
||||
const trimmed = (path ?? "").trim();
|
||||
if (!trimmed) return { displayName: "", isFolder: false };
|
||||
|
||||
const isFolder = trimmed.endsWith("/");
|
||||
const normalized = trimmed.replace(/\/+$/, "");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
const leaf = segments.at(-1);
|
||||
if (!leaf) return { displayName: trimmed, isFolder };
|
||||
|
||||
const withoutXml = leaf.replace(XML_EXTENSION_RE, "");
|
||||
const displayName = withoutXml.replace(DOC_ID_DISAMBIG_RE, "");
|
||||
|
||||
return { displayName: displayName || leaf, isFolder };
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export type {
|
||||
HitlDecision,
|
||||
InterruptActionRequest,
|
||||
InterruptResult,
|
||||
InterruptReviewConfig,
|
||||
} from "./types";
|
||||
export { isInterruptResult } from "./types";
|
||||
export { useHitlDecision } from "./use-hitl-decision";
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
|
||||
*
|
||||
* Every tool-ui component that handles interrupts should import from here
|
||||
* instead of defining its own `InterruptResult` / `isInterruptResult`.
|
||||
*/
|
||||
|
||||
export interface InterruptActionRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InterruptReviewConfig {
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}
|
||||
|
||||
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: InterruptActionRequest[];
|
||||
review_configs: InterruptReviewConfig[];
|
||||
interrupt_type?: string;
|
||||
context?: C;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
export interface HitlDecision {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* Shared hook for dispatching HITL decisions.
|
||||
*
|
||||
* All tool-ui components that handle approve/reject/edit should use this
|
||||
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { HitlDecision } from "./types";
|
||||
|
||||
export function useHitlDecision() {
|
||||
const dispatch = useCallback((decisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||
}, []);
|
||||
|
||||
return { dispatch };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue