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
9b1b5a504e
148 changed files with 19460 additions and 2708 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -18,14 +15,8 @@ import {
|
|||
|
||||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ["token-status"] });
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ export default function DesktopPermissionsPage() {
|
|||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">System Permissions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SurfSense needs two macOS permissions for Screenshot Assist and for desktop features that
|
||||
require focusing the app or the active application.
|
||||
SurfSense needs two macOS permissions for Screenshot Assist and for desktop features
|
||||
that require focusing the app or the active application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,14 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
|
|||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
|
||||
set(reportPanelAtom, {
|
||||
isOpen: false,
|
||||
reportId: null,
|
||||
title: null,
|
||||
wordCount: null,
|
||||
shareToken: null,
|
||||
contentType: "markdown",
|
||||
});
|
||||
});
|
||||
|
||||
/** Target comment ID to scroll to (from URL navigation or inbox click) */
|
||||
|
|
|
|||
45
surfsense_web/atoms/chat/premium-alert.atom.ts
Normal file
45
surfsense_web/atoms/chat/premium-alert.atom.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export type PremiumAlertState = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const premiumAlertByThreadAtom = atom<Record<number, PremiumAlertState>>({});
|
||||
|
||||
export const setPremiumAlertForThreadAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
payload: {
|
||||
threadId: number;
|
||||
message: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
) => {
|
||||
const storageKey = `surfsense-premium-alert-seen-v1:${payload.userId ?? "anonymous"}`;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const hasSeen = localStorage.getItem(storageKey) === "true";
|
||||
if (hasSeen) return;
|
||||
}
|
||||
|
||||
const current = get(premiumAlertByThreadAtom);
|
||||
set(premiumAlertByThreadAtom, {
|
||||
...current,
|
||||
[payload.threadId]: { message: payload.message },
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, "true");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const clearPremiumAlertForThreadAtom = atom(null, (get, set, threadId: number) => {
|
||||
const current = get(premiumAlertByThreadAtom);
|
||||
if (!(threadId in current)) return;
|
||||
const next = { ...current };
|
||||
delete next[threadId];
|
||||
set(premiumAlertByThreadAtom, next);
|
||||
});
|
||||
|
|
@ -8,7 +8,10 @@ const userQueryFn = () => userApiService.getMe();
|
|||
export const currentUserAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: USER_QUERY_KEY,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Live-changing numeric fields (pages_*, premium_tokens_*) are now
|
||||
// pushed via Zero (queries.user.me()), so /users/me only needs to
|
||||
// fire once per session for the static profile fields.
|
||||
staleTime: Infinity,
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: userQueryFn,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,16 +17,12 @@ import {
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface ActionLogItemProps {
|
||||
action: AgentAction;
|
||||
threadId: number;
|
||||
|
|
@ -43,7 +39,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
const hasError = action.error !== null && action.error !== undefined;
|
||||
|
||||
const Icon = getToolIcon(action.tool_name);
|
||||
const displayName = formatToolName(action.tool_name);
|
||||
const displayName = getToolDisplayName(action.tool_name);
|
||||
|
||||
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
|
||||
const truncatedArgs =
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -17,15 +17,9 @@ import {
|
|||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
const ACTION_LOG_PAGE_SIZE = 50;
|
||||
|
||||
function actionLogQueryKey(threadId: number) {
|
||||
return ["agent-actions", threadId] as const;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
|
|
@ -85,25 +79,17 @@ export function ActionLogSheet() {
|
|||
|
||||
const threadId = state.threadId;
|
||||
|
||||
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
|
||||
queryFn: () =>
|
||||
agentActionsApiService.listForThread(threadId as number, {
|
||||
page: 0,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
}),
|
||||
enabled: state.open && threadId !== null && actionLogEnabled,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
const { data, items, isLoading, isFetching, isError, error, refetch } = useAgentActionsQuery(
|
||||
threadId,
|
||||
{ enabled: state.open && actionLogEnabled }
|
||||
);
|
||||
|
||||
const handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
|
||||
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
|
||||
}
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -545,8 +548,10 @@ const AssistantMessageInner: FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 flex items-center gap-2">
|
||||
<AssistantActionBar />
|
||||
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 h-6">
|
||||
<div className="h-full opacity-100 transition-opacity">
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</div>
|
||||
</CitationMetadataProvider>
|
||||
);
|
||||
|
|
@ -639,35 +644,41 @@ export const AssistantMessage: FC = () => {
|
|||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
{/* Comment trigger — right-aligned, just below user query on all screen sizes */}
|
||||
{showCommentTrigger && (
|
||||
<div className="mr-2 mb-1 flex justify-end">
|
||||
<button
|
||||
ref={isDesktop ? commentTriggerRef : undefined}
|
||||
type="button"
|
||||
onClick={
|
||||
isDesktop ? () => setIsInlineOpen((prev) => !prev) : () => setIsSheetOpen(true)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||
isDesktop && isInlineOpen
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasComments
|
||||
? "text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
{hasComments ? (
|
||||
<span>
|
||||
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
) : (
|
||||
<span>Add comment</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Fixed trigger slot prevents any vertical reflow when visibility changes */}
|
||||
<div className="mr-2 mb-1 flex h-7 justify-end">
|
||||
<button
|
||||
ref={isDesktop ? commentTriggerRef : undefined}
|
||||
type="button"
|
||||
onClick={
|
||||
showCommentTrigger
|
||||
? isDesktop
|
||||
? () => setIsInlineOpen((prev) => !prev)
|
||||
: () => setIsSheetOpen(true)
|
||||
: undefined
|
||||
}
|
||||
aria-hidden={!showCommentTrigger}
|
||||
tabIndex={showCommentTrigger ? 0 : -1}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||
"opacity-0 pointer-events-none",
|
||||
showCommentTrigger && "opacity-100 pointer-events-auto",
|
||||
isDesktop && isInlineOpen
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasComments
|
||||
? "text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
{hasComments ? (
|
||||
<span>
|
||||
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
) : (
|
||||
<span>Add comment</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop floating comment panel — overlays on top of chat content */}
|
||||
{showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
|
||||
|
|
@ -699,6 +710,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 +761,9 @@ const AssistantActionBar: FC = () => {
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
<MessageInfoDropdown />
|
||||
<div className="ml-auto">
|
||||
<RevertTurnButton chatTurnId={chatTurnId} />
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
52
surfsense_web/components/assistant-ui/chat-viewport.tsx
Normal file
52
surfsense_web/components/assistant-ui/chat-viewport.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
||||
const ChatScrollToBottom: FC = () => (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
);
|
||||
|
||||
export interface ChatViewportProps {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
autoScroll
|
||||
scrollToBottomOnRunStart
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnThreadSwitch
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
|
||||
/>
|
||||
{children}
|
||||
{footer ? (
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<ChatScrollToBottom />
|
||||
{footer}
|
||||
</div>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
) : null}
|
||||
</ThreadPrimitive.Viewport>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -15,6 +15,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// Lazily load MarkdownViewer here to break the static import cycle:
|
||||
// `markdown-viewer.tsx` → `citation-renderer.tsx` → `inline-citation.tsx`
|
||||
// would otherwise pull `markdown-viewer.tsx` back in at module-init time.
|
||||
// Only `SurfsenseDocCitation` (popover body) ever renders this viewer, so
|
||||
// the lazy boundary is invisible to most call paths.
|
||||
const MarkdownViewer = dynamic(
|
||||
() => import("@/components/markdown-viewer").then((m) => m.MarkdownViewer),
|
||||
{ ssr: false, loading: () => <Spinner size="xs" /> }
|
||||
);
|
||||
|
||||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
isDocsChunk?: boolean;
|
||||
|
|
@ -172,7 +182,7 @@ const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
|||
</p>
|
||||
)}
|
||||
{!isLoading && !error && citedChunk?.content && (
|
||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
|
||||
<MarkdownViewer content={citedChunk.content} maxLength={1500} enableCitations />
|
||||
)}
|
||||
{!isLoading && !error && !citedChunk?.content && (
|
||||
<p className="py-4 text-xs text-muted-foreground">No content available.</p>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,14 +12,15 @@ import { ExternalLinkIcon } from "lucide-react";
|
|||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { createContext, memo, type ReactNode, useCallback, useContext, useRef } from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import { toast } from "sonner";
|
||||
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -30,6 +31,8 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MarkdownCodeBlockSkeleton() {
|
||||
|
|
@ -59,31 +62,30 @@ const LazyMarkdownCodeBlock = dynamic(
|
|||
}
|
||||
);
|
||||
|
||||
// Storage for URL citations replaced during preprocess to avoid GFM autolink interference.
|
||||
// Populated in preprocessMarkdown, consumed in parseTextWithCitations.
|
||||
let _pendingUrlCitations = new Map<string, string>();
|
||||
let _urlCiteIdx = 0;
|
||||
// Per-render URL placeholder map propagated to component overrides via
|
||||
// React Context. Replaces the previous module-level `_pendingUrlCitations`
|
||||
// state, which was unsafe under concurrent renders / SSR.
|
||||
type CitationUrlMapRef = { current: CitationUrlMap };
|
||||
const EMPTY_URL_MAP: CitationUrlMap = new Map();
|
||||
const CitationUrlMapContext = createContext<CitationUrlMapRef>({ current: EMPTY_URL_MAP });
|
||||
|
||||
function useCitationUrlMap(): CitationUrlMap {
|
||||
return useContext(CitationUrlMapContext).current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess raw markdown before it reaches the remark/rehype pipeline.
|
||||
* - Replaces URL-based citations with safe placeholders (prevents GFM autolinks)
|
||||
* - Normalises LaTeX delimiters to dollar-sign syntax for remark-math
|
||||
*/
|
||||
function preprocessMarkdown(content: string): string {
|
||||
function preprocessMarkdown(content: string, urlMapRef: CitationUrlMapRef): string {
|
||||
// Replace URL-based citations with safe placeholders BEFORE markdown parsing.
|
||||
// GFM autolinks would otherwise convert the https://... inside [citation:URL]
|
||||
// into an <a> element, splitting the text and preventing our citation regex
|
||||
// from matching the full pattern.
|
||||
_pendingUrlCitations = new Map();
|
||||
_urlCiteIdx = 0;
|
||||
content = content.replace(
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+)\s*\u200B?[\]】]/g,
|
||||
(_, url) => {
|
||||
const key = `urlcite${_urlCiteIdx++}`;
|
||||
_pendingUrlCitations.set(key, url.trim());
|
||||
return `[citation:${key}]`;
|
||||
}
|
||||
);
|
||||
const { content: rewritten, urlMap } = preprocessCitationMarkdown(content);
|
||||
urlMapRef.current = urlMap;
|
||||
content = rewritten;
|
||||
|
||||
// All math forms are normalised to $$...$$ so we can disable single-dollar
|
||||
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
|
||||
|
|
@ -116,113 +118,25 @@ function preprocessMarkdown(content: string): string {
|
|||
return content;
|
||||
}
|
||||
|
||||
// Matches [citation:...] with numeric IDs (incl. negative, doc- prefix, comma-separated),
|
||||
// URL-based IDs from live web search, or urlciteN placeholders from preprocess.
|
||||
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts.
|
||||
const CITATION_REGEX =
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
|
||||
|
||||
/**
|
||||
* Parses text and replaces [citation:XXX] patterns with citation components.
|
||||
* Supports:
|
||||
* - Numeric chunk IDs: [citation:123]
|
||||
* - Doc-prefixed IDs: [citation:doc-123]
|
||||
* - Comma-separated IDs: [citation:4149, 4150, 4151]
|
||||
* - URL-based citations from live search: [citation:https://example.com/page]
|
||||
*/
|
||||
function parseTextWithCitations(text: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let instanceIndex = 0;
|
||||
|
||||
CITATION_REGEX.lastIndex = 0;
|
||||
|
||||
match = CITATION_REGEX.exec(text);
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const captured = match[1];
|
||||
|
||||
if (captured.startsWith("http://") || captured.startsWith("https://")) {
|
||||
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={captured.trim()} />);
|
||||
instanceIndex++;
|
||||
} else if (captured.startsWith("urlcite")) {
|
||||
const url = _pendingUrlCitations.get(captured);
|
||||
if (url) {
|
||||
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={url} />);
|
||||
}
|
||||
instanceIndex++;
|
||||
} else {
|
||||
const rawIds = captured.split(",").map((s) => s.trim());
|
||||
for (const rawId of rawIds) {
|
||||
const isDocsChunk = rawId.startsWith("doc-");
|
||||
const chunkId = Number.parseInt(isDocsChunk ? rawId.slice(4) : rawId, 10);
|
||||
parts.push(
|
||||
<InlineCitation
|
||||
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
|
||||
chunkId={chunkId}
|
||||
isDocsChunk={isDocsChunk}
|
||||
/>
|
||||
);
|
||||
instanceIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
match = CITATION_REGEX.exec(text);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [text];
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const urlMapRef = useRef<CitationUrlMap>(EMPTY_URL_MAP);
|
||||
const preprocess = useCallback((content: string) => preprocessMarkdown(content, urlMapRef), []);
|
||||
return (
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
preprocess={preprocessMarkdown}
|
||||
/>
|
||||
<CitationUrlMapContext.Provider value={urlMapRef}>
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
preprocess={preprocess}
|
||||
/>
|
||||
</CitationUrlMapContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
|
||||
/**
|
||||
* Helper to process children and replace citation patterns with components
|
||||
*/
|
||||
function processChildrenWithCitations(children: ReactNode): ReactNode {
|
||||
if (typeof children === "string") {
|
||||
const parsed = parseTextWithCitations(children);
|
||||
return parsed.length === 1 && typeof parsed[0] === "string" ? children : parsed;
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child) => {
|
||||
if (typeof child === "string") {
|
||||
const parsed = parseTextWithCitations(child);
|
||||
return parsed.length === 1 && typeof parsed[0] === "string" ? (
|
||||
child
|
||||
) : (
|
||||
<span key={child}>{parsed}</span>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
|
@ -282,6 +196,85 @@ function isVirtualFilePathToken(value: string): boolean {
|
|||
return segments.length >= 2;
|
||||
}
|
||||
|
||||
function isStandaloneDocumentsPathText(node: ReactNode): string | null {
|
||||
if (typeof node !== "string") return null;
|
||||
const value = node.trim();
|
||||
if (!value.startsWith("/documents/")) return null;
|
||||
if (value.includes(" ")) return null;
|
||||
const normalized = value.replace(/\/+$/, "");
|
||||
const leaf = normalized.split("/").filter(Boolean).at(-1) ?? "";
|
||||
if (!leaf || !leaf.includes(".")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function FilePathLink({ path, className }: { path: string; className?: string }) {
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const electronAPI = useElectronAPI();
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80",
|
||||
className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void (async () => {
|
||||
if (electronAPI) {
|
||||
let resolvedLocalPath = path;
|
||||
if (electronAPI.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
|
||||
} catch {
|
||||
// Fall back to the raw path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
|
||||
try {
|
||||
const doc = await documentsApiService.getDocumentByVirtualPath({
|
||||
search_space_id: resolvedSearchSpaceId,
|
||||
virtual_path: path,
|
||||
});
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: doc.id,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Document not found in knowledge base.");
|
||||
}
|
||||
})();
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
||||
if (!src) return null;
|
||||
|
||||
|
|
@ -322,92 +315,127 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
|||
}
|
||||
|
||||
const defaultComponents = memoizeMarkdownComponents({
|
||||
h1: ({ className, children, ...props }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
"aui-md-h1 mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ className, children, ...props }) => (
|
||||
<h2
|
||||
className={cn(
|
||||
"aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ className, children, ...props }) => (
|
||||
<h3
|
||||
className={cn(
|
||||
"aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ className, children, ...props }) => (
|
||||
<h4
|
||||
className={cn(
|
||||
"aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ className, children, ...props }) => (
|
||||
<h5
|
||||
className={cn("aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ className, children, ...props }) => (
|
||||
<h6 className={cn("aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</h6>
|
||||
),
|
||||
p: ({ className, children, ...props }) => (
|
||||
<p className={cn("aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</p>
|
||||
),
|
||||
a: ({ className, children, ...props }) => (
|
||||
<a
|
||||
className={cn("aui-md-a font-medium text-primary underline underline-offset-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ className, children, ...props }) => (
|
||||
<blockquote className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</blockquote>
|
||||
),
|
||||
h1: function H1({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"aui-md-h1 mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: function H2({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: function H3({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4: function H4({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h4
|
||||
className={cn(
|
||||
"aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
h5: function H5({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h5
|
||||
className={cn("aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h5>
|
||||
);
|
||||
},
|
||||
h6: function H6({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<h6 className={cn("aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0", className)} {...props}>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
p: function P({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
const standalonePath = isStandaloneDocumentsPathText(children);
|
||||
return (
|
||||
<p className={cn("aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)} {...props}>
|
||||
{standalonePath ? (
|
||||
<FilePathLink path={standalonePath} />
|
||||
) : (
|
||||
processChildrenWithCitations(children, urlMap)
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
a: function A({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<a
|
||||
className={cn("aui-md-a font-medium text-primary underline underline-offset-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote: function Blockquote({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<blockquote className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)} {...props}>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
ul: ({ className, ...props }) => (
|
||||
<ul className={cn("aui-md-ul my-5 ml-6 list-disc [&>li]:mt-2", className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }) => (
|
||||
<ol className={cn("aui-md-ol my-5 ml-6 list-decimal [&>li]:mt-2", className)} {...props} />
|
||||
),
|
||||
li: ({ className, children, ...props }) => (
|
||||
<li className={cn("aui-md-li", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</li>
|
||||
),
|
||||
li: function Li({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<li className={cn("aui-md-li", className)} {...props}>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
hr: ({ className, ...props }) => (
|
||||
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
|
||||
),
|
||||
|
|
@ -422,28 +450,34 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
tbody: ({ className, ...props }) => (
|
||||
<TableBody className={cn("aui-md-tbody", className)} {...props} />
|
||||
),
|
||||
th: ({ className, children, ...props }) => (
|
||||
<TableHead
|
||||
className={cn(
|
||||
"aui-md-th bg-muted/50 whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</TableHead>
|
||||
),
|
||||
td: ({ className, children, ...props }) => (
|
||||
<TableCell
|
||||
className={cn(
|
||||
"aui-md-td whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children)}
|
||||
</TableCell>
|
||||
),
|
||||
th: function Th({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<TableHead
|
||||
className={cn(
|
||||
"aui-md-th bg-muted/50 whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</TableHead>
|
||||
);
|
||||
},
|
||||
td: function Td({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(
|
||||
"aui-md-td whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</TableCell>
|
||||
);
|
||||
},
|
||||
tr: ({ className, ...props }) => <TableRow className={cn("aui-md-tr", className)} {...props} />,
|
||||
sup: ({ className, ...props }) => (
|
||||
<sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} />
|
||||
|
|
@ -452,8 +486,6 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
code: function Code({ className, children, ...props }) {
|
||||
const isCodeBlock = useIsMarkdownCodeBlock();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const electronAPI = useElectronAPI();
|
||||
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
|
|
@ -470,53 +502,17 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
const isLikelyFolder =
|
||||
inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes(".");
|
||||
const isLocalPath =
|
||||
!!electronAPI &&
|
||||
isVirtualFilePathToken(inlineValue) &&
|
||||
!inlineValue.startsWith("//") &&
|
||||
!isLikelyFolder;
|
||||
const displayLocalPath = inlineValue.replace(/^\/+/, "");
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
(isVirtualFilePathToken(inlineValue) &&
|
||||
!inlineValue.startsWith("//") &&
|
||||
!isLikelyFolder &&
|
||||
!!electronAPI) ||
|
||||
(isVirtualFilePathToken(inlineValue) &&
|
||||
!inlineValue.startsWith("//") &&
|
||||
!isLikelyFolder &&
|
||||
!electronAPI &&
|
||||
inlineValue.startsWith("/documents/"));
|
||||
if (isLocalPath) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void (async () => {
|
||||
let resolvedLocalPath = inlineValue;
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
if (electronAPI?.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts);
|
||||
} catch {
|
||||
// Fall back to the raw inline path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
})();
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
{displayLocalPath}
|
||||
</button>
|
||||
);
|
||||
return <FilePathLink path={inlineValue} className="text-[0.9em]" />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
|
|
@ -552,16 +548,22 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
/>
|
||||
);
|
||||
},
|
||||
strong: ({ className, children, ...props }) => (
|
||||
<strong className={cn("aui-md-strong font-semibold", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</strong>
|
||||
),
|
||||
em: ({ className, children, ...props }) => (
|
||||
<em className={cn("aui-md-em", className)} {...props}>
|
||||
{processChildrenWithCitations(children)}
|
||||
</em>
|
||||
),
|
||||
strong: function Strong({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<strong className={cn("aui-md-strong font-semibold", className)} {...props}>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
em: function Em({ className, children, ...props }) {
|
||||
const urlMap = useCitationUrlMap();
|
||||
return (
|
||||
<em className={cn("aui-md-em", className)} {...props}>
|
||||
{processChildrenWithCitations(children, urlMap)}
|
||||
</em>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<MarkdownImage src={typeof src === "string" ? src : undefined} alt={alt} />
|
||||
),
|
||||
|
|
|
|||
24
surfsense_web/components/assistant-ui/nested-scroll.tsx
Normal file
24
surfsense_web/components/assistant-ui/nested-scroll.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { type ComponentPropsWithoutRef, forwardRef, type WheelEvent } from "react";
|
||||
|
||||
export type NestedScrollProps = ComponentPropsWithoutRef<"div">;
|
||||
|
||||
export const NestedScroll = forwardRef<HTMLDivElement, NestedScrollProps>(
|
||||
({ onWheel, ...props }, ref) => {
|
||||
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const canScrollUp = el.scrollTop > 0;
|
||||
const canScrollDown = el.scrollTop < el.scrollHeight - el.clientHeight - 1;
|
||||
const goingUp = event.deltaY < 0;
|
||||
const goingDown = event.deltaY > 0;
|
||||
if ((goingUp && canScrollUp) || (goingDown && canScrollDown)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
onWheel?.(event);
|
||||
};
|
||||
return <div ref={ref} onWheel={handleWheel} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
NestedScroll.displayName = "NestedScroll";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
213
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
213
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* "Revert turn" button rendered at the bottom of every completed
|
||||
* assistant turn that has at least one reversible action.
|
||||
*
|
||||
* The button reads from the unified ``useAgentActionsQuery`` cache
|
||||
* (the SAME react-query cache the agent-actions sheet and the inline
|
||||
* Revert button consume) filtered by ``chat_turn_id``. 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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import {
|
||||
applyRevertTurnResultsToCache,
|
||||
useAgentActionsQuery,
|
||||
} from "@/hooks/use-agent-actions-query";
|
||||
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;
|
||||
}
|
||||
|
||||
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const threadId = session?.threadId ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const { findByChatTurnId } = useAgentActionsQuery(threadId);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [resultsOpen, setResultsOpen] = useState(false);
|
||||
const [results, setResults] = useState<RevertTurnActionResult[]>([]);
|
||||
|
||||
const actions = useMemo(() => findByChatTurnId(chatTurnId), [findByChatTurnId, chatTurnId]);
|
||||
|
||||
const reversibleCount = useMemo(
|
||||
() =>
|
||||
actions.filter(
|
||||
(a) =>
|
||||
a.reversible &&
|
||||
(a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) &&
|
||||
!a.is_revert_action &&
|
||||
(a.error === null || a.error === undefined)
|
||||
).length,
|
||||
[actions]
|
||||
);
|
||||
const totalCount = useMemo(() => actions.filter((a) => !a.is_revert_action).length, [actions]);
|
||||
|
||||
if (!chatTurnId) return null;
|
||||
if (reversibleCount === 0) return null;
|
||||
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) {
|
||||
applyRevertTurnResultsToCache(queryClient, threadId, 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">
|
||||
{getToolDisplayName(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,18 +0,0 @@
|
|||
import { ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
||||
export const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,12 +5,10 @@ import {
|
|||
ThreadPrimitive,
|
||||
useAui,
|
||||
useAuiState,
|
||||
useThreadViewportStore,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
|
|
@ -37,10 +35,13 @@ import {
|
|||
toggleToolAtom,
|
||||
} from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import {
|
||||
clearPremiumAlertForThreadAtom,
|
||||
premiumAlertByThreadAtom,
|
||||
} from "@/atoms/chat/premium-alert.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
|
|
@ -52,6 +53,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import {
|
||||
|
|
@ -82,6 +84,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import {
|
||||
CONNECTOR_ICON_TO_TYPES,
|
||||
CONNECTOR_TOOL_ICON_PATHS,
|
||||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
|
|
@ -89,8 +92,8 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
|||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -108,10 +111,13 @@ const ThreadContent: FC = () => {
|
|||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
<ChatViewport
|
||||
footer={
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<PremiumQuotaPinnedAlert />
|
||||
<Composer />
|
||||
</AuiIf>
|
||||
}
|
||||
>
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
|
|
@ -124,36 +130,39 @@ const ThreadContent: FC = () => {
|
|||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="grow" />
|
||||
</AuiIf>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-2xl bg-main-panel pb-4 md:pb-6"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<Composer />
|
||||
</AuiIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ChatViewport>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
const PremiumQuotaPinnedAlert: FC = () => {
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const alertsByThread = useAtomValue(premiumAlertByThreadAtom);
|
||||
const clearPremiumAlertForThread = useSetAtom(clearPremiumAlertForThreadAtom);
|
||||
|
||||
const currentThreadId = currentThreadState?.id;
|
||||
if (!currentThreadId) return null;
|
||||
|
||||
const alert = alertsByThread[currentThreadId];
|
||||
if (!alert) return null;
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
<div className="mx-0 overflow-hidden rounded-2xl border-input bg-muted px-4 py-4 text-foreground select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">{alert.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Dismiss premium quota alert"
|
||||
onClick={() => clearPremiumAlertForThread(currentThreadId)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -373,23 +382,9 @@ const Composer: FC = () => {
|
|||
>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const viewportRef = useRef<Element | null>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const aui = useAui();
|
||||
const threadViewportStore = useThreadViewportStore();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
const submitCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
submitCleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Store viewport element reference on mount
|
||||
useEffect(() => {
|
||||
viewportRef.current = document.querySelector(".aui-thread-viewport");
|
||||
}, []);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||
|
|
@ -588,7 +583,6 @@ const Composer: FC = () => {
|
|||
[showDocumentPopover, showPromptPicker]
|
||||
);
|
||||
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isThreadRunning || isBlockedByOtherUser) return;
|
||||
if (showDocumentPopover || showPromptPicker) return;
|
||||
|
|
@ -600,50 +594,9 @@ const Composer: FC = () => {
|
|||
setClipboardInitialText(undefined);
|
||||
}
|
||||
|
||||
const viewportEl = viewportRef.current;
|
||||
const heightBefore = viewportEl?.scrollHeight ?? 0;
|
||||
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
|
||||
// With turnAnchor="top", ViewportSlack adds min-height to the last
|
||||
// assistant message so that scrolling-to-bottom actually positions the
|
||||
// user message at the TOP of the viewport. That slack height is
|
||||
// calculated asynchronously (ResizeObserver → style → layout).
|
||||
// Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes.
|
||||
const scrollToBottom = () =>
|
||||
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
|
||||
|
||||
let lastHeight = heightBefore;
|
||||
let frames = 0;
|
||||
let cancelled = false;
|
||||
const POLL_FRAMES = 30;
|
||||
|
||||
const pollAndScroll = () => {
|
||||
if (cancelled) return;
|
||||
const el = viewportRef.current;
|
||||
if (el) {
|
||||
const h = el.scrollHeight;
|
||||
if (h !== lastHeight) {
|
||||
lastHeight = h;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
if (++frames < POLL_FRAMES) {
|
||||
requestAnimationFrame(pollAndScroll);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(pollAndScroll);
|
||||
|
||||
const t1 = setTimeout(scrollToBottom, 100);
|
||||
const t2 = setTimeout(scrollToBottom, 300);
|
||||
|
||||
submitCleanupRef.current = () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
showPromptPicker,
|
||||
|
|
@ -652,7 +605,6 @@ const Composer: FC = () => {
|
|||
clipboardInitialText,
|
||||
aui,
|
||||
setMentionedDocuments,
|
||||
threadViewportStore,
|
||||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
|
|
@ -1317,12 +1269,14 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
);
|
||||
};
|
||||
|
||||
/** Convert snake_case tool names to human-readable labels */
|
||||
/**
|
||||
* Friendly tool name for display in the chat UI. Delegates to the
|
||||
* shared map in ``contracts/enums/toolIcons`` so unix-style identifiers
|
||||
* (``rm``, ``ls``, ``grep`` …) and snake_cased function names render as
|
||||
* plain English (e.g. "Delete file", "List files", "Search in files").
|
||||
*/
|
||||
function formatToolName(name: string): string {
|
||||
return name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
return getToolDisplayName(name);
|
||||
}
|
||||
|
||||
interface ToolGroup {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,277 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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 unified ``useAgentActionsQuery`` cache — the SAME
|
||||
* react-query cache the agent-actions sheet consumes. SSE events
|
||||
* (``data-action-log`` / ``data-action-log-updated``) and
|
||||
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
|
||||
* cache via ``setQueryData`` helpers, so the card and the sheet stay
|
||||
* in lockstep on every code path: page reload, navigation, live
|
||||
* stream, post-stream reversibility flip, and explicit revert clicks.
|
||||
*
|
||||
* Match key (in priority order):
|
||||
* 1. ``a.tool_call_id === toolCallId`` — direct hit in parity_v2 when
|
||||
* the model streamed ``tool_call_chunks`` so the card's synthetic
|
||||
* id IS the LangChain id.
|
||||
* 2. ``a.tool_call_id === langchainToolCallId`` — legacy mode (or
|
||||
* parity_v2 with provider-side chunk emission) where the card's
|
||||
* synthetic id is ``call_<run_id>`` and the LangChain id is
|
||||
* backfilled onto the part by ``tool-output-available``.
|
||||
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` — fallback
|
||||
* for cards whose synthetic id is ``call_<run_id>`` AND whose
|
||||
* ``langchainToolCallId`` never got backfilled (provider emitted
|
||||
* the tool_call as a single payload with no chunks AND streaming
|
||||
* pre-dated the ``tool-output-available langchainToolCallId``
|
||||
* backfill, e.g. older threads). Reads the parent message's
|
||||
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
|
||||
* match position-by-tool-name within the turn against the
|
||||
* action_log rows the server returned in ``created_at`` order.
|
||||
*/
|
||||
function ToolCardRevertButton({
|
||||
toolCallId,
|
||||
toolName,
|
||||
langchainToolCallId,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
langchainToolCallId?: string;
|
||||
}) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const threadId = session?.threadId ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
|
||||
|
||||
// Parent message metadata, read via the narrowest possible
|
||||
// selectors so this card doesn't re-render on every text-delta of
|
||||
// every other part in the same message during streaming.
|
||||
//
|
||||
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
|
||||
// returned slice's identity changes. Returning ``message?.content``
|
||||
// (an array) would re-render on every token because the runtime
|
||||
// rebuilds the parts array. Returning a PRIMITIVE (the position
|
||||
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
|
||||
// when the position hasn't actually moved — which is the common
|
||||
// case during text streaming, when only ``text``/``reasoning``
|
||||
// parts are mutating and the same-toolName tool-call ordering is
|
||||
// stable. (See Vercel React rule ``rerender-defer-reads``.)
|
||||
const chatTurnId = useAuiState(({ message }) => {
|
||||
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
|
||||
return meta?.custom?.chatTurnId ?? null;
|
||||
});
|
||||
const positionInTurn = useAuiState(({ message }) => {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) return -1;
|
||||
let n = -1;
|
||||
for (const part of content) {
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
(part as { type?: string }).type === "tool-call" &&
|
||||
(part as { toolName?: string }).toolName === toolName
|
||||
) {
|
||||
n += 1;
|
||||
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
const action = useMemo(() => {
|
||||
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
|
||||
// ~all parity_v2 streams and any legacy stream that backfilled
|
||||
// ``langchainToolCallId`` via ``tool-output-available``.
|
||||
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
|
||||
if (direct) return direct;
|
||||
// Tier 3: position-within-turn fallback. Only kicks in when the
|
||||
// card has a synthetic ``call_<run_id>`` id AND no
|
||||
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
|
||||
// was emitted as a single non-chunked payload AND streaming
|
||||
// pre-dated the on_tool_end backfill.
|
||||
if (!chatTurnId || positionInTurn < 0) return null;
|
||||
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
|
||||
return turnSameTool[positionInTurn] ?? null;
|
||||
}, [
|
||||
findByToolCallId,
|
||||
findByChatTurnAndTool,
|
||||
toolCallId,
|
||||
langchainToolCallId,
|
||||
chatTurnId,
|
||||
toolName,
|
||||
positionInTurn,
|
||||
]);
|
||||
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
if (!action) return null;
|
||||
if (!action.reversible) return null;
|
||||
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
|
||||
return null;
|
||||
if (action.is_revert_action) return null;
|
||||
if (action.error !== null && action.error !== undefined) return null;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
markActionRevertedInCache(queryClient, threadId, action.id, 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);
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? (
|
||||
// Spinner's typed props don't accept ``data-icon`` and
|
||||
// it renders an <output>, not an <svg>, so Button's
|
||||
// auto-sizing rule doesn't apply. Bare spinner +
|
||||
// Button's gap handle layout.
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
)}
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo{" "}
|
||||
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
|
||||
new entry to the history. Your chat is preserved — only the changes the agent made to
|
||||
your knowledge base or connected apps will be rolled back where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isReverting && <Spinner size="xs" />}
|
||||
Revert
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
/**
|
||||
* Compact tool-call card.
|
||||
*
|
||||
* shadcn composition note: we intentionally use ``Card`` as a visual
|
||||
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
|
||||
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
|
||||
* trigger; using ``Card`` alone preserves the rounded border, shadow,
|
||||
* and ``bg-card`` token (semantic colors) without forcing a layout
|
||||
* that doesn't fit. All status colors use semantic tokens — no manual
|
||||
* dark-mode overrides, no raw hex.
|
||||
*/
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
||||
const { toolCallId, toolName, argsText, result, status } = props;
|
||||
// ``langchainToolCallId`` is a SurfSense-specific extension the
|
||||
// streaming pipeline attaches to the tool-call content part so
|
||||
// the Revert button can resolve its ``AgentActionLog`` row even
|
||||
// when only the LC id is known. assistant-ui's
|
||||
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
|
||||
// spreads ``{...part}`` so the prop reaches us at runtime.
|
||||
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
|
||||
|
||||
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
|
||||
const isError = status?.type === "incomplete" && status.reason === "error";
|
||||
const isRunning = status?.type === "running" || status?.type === "requires-action";
|
||||
|
||||
/*
|
||||
Per-card expansion state. Initial value is ``isRunning`` so a
|
||||
card streaming in mounts already-expanded (no flash of
|
||||
collapsed → expanded on first paint), while a card loaded from
|
||||
history (status="complete") mounts collapsed. The useEffect
|
||||
below keeps this in lockstep with this card's own ``isRunning``
|
||||
when it transitions: false → true auto-expands (e.g. a tool
|
||||
that re-runs after edit), true → false auto-collapses once the
|
||||
tool finishes. Because the dep is per-card ``isRunning`` and
|
||||
not the chat-level streaming flag, sibling cards on the same
|
||||
assistant turn each manage their own expansion independently.
|
||||
Once ``isRunning`` is false the user controls expansion via
|
||||
``onOpenChange``.
|
||||
*/
|
||||
const [isExpanded, setIsExpanded] = useState(isRunning);
|
||||
useEffect(() => {
|
||||
setIsExpanded(isRunning);
|
||||
}, [isRunning]);
|
||||
const errorData = status?.type === "incomplete" ? status.error : undefined;
|
||||
const serializedError = useMemo(
|
||||
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
|
||||
|
|
@ -50,105 +297,207 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
: serializedError
|
||||
: null;
|
||||
|
||||
const Icon = getToolIcon(toolName);
|
||||
const displayName = formatToolName(toolName);
|
||||
const displayName = getToolDisplayName(toolName);
|
||||
const subtitle = errorReason ?? cancelledReason;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
className={cn(
|
||||
"my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none",
|
||||
"my-4 max-w-lg overflow-hidden",
|
||||
isCancelled && "opacity-60",
|
||||
isError && "border-destructive/20 bg-destructive/5"
|
||||
isError && "border-destructive/30"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
||||
{/*
|
||||
``group`` lets the chevron (rendered as a sibling of the
|
||||
main trigger button) read the Collapsible Root's
|
||||
``data-[state=open]`` for rotation. The Collapsible is
|
||||
fully controlled via ``isExpanded`` — the useEffect
|
||||
above syncs it to ``isRunning`` so the card auto-opens
|
||||
while a tool streams in and auto-collapses once it
|
||||
finishes. We deliberately DON'T pass ``disabled`` so
|
||||
both triggers stay clickable; ``onOpenChange`` is wired
|
||||
to a setter that no-ops while ``isRunning`` (see
|
||||
``handleOpenChange`` below) which keeps the card pinned
|
||||
open mid-stream without losing keyboard / pointer
|
||||
affordance the moment streaming ends.
|
||||
*/}
|
||||
<Collapsible
|
||||
className="group"
|
||||
open={isExpanded}
|
||||
onOpenChange={(next) => {
|
||||
// Block manual collapse while the tool is still
|
||||
// streaming — otherwise a stray click on either
|
||||
// trigger would close the card and hide the live
|
||||
// ``argsText`` panel mid-run. After streaming the
|
||||
// user has full control again.
|
||||
if (isRunning) return;
|
||||
setIsExpanded(next);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
) : isCancelled ? (
|
||||
<XCircleIcon className="size-4 text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Icon className="size-4 text-primary animate-pulse" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
Header row: main trigger on the left (icon + title
|
||||
col), Revert + chevron-trigger on the right as
|
||||
siblings of the main trigger. The chevron is wrapped
|
||||
in its OWN ``CollapsibleTrigger`` (Radix supports
|
||||
multiple triggers per Root) so clicking the chevron
|
||||
toggles the same state as clicking the title row.
|
||||
The Revert button stays a separate AlertDialog
|
||||
trigger and stops propagation in its onClick so it
|
||||
doesn't toggle the collapsible while opening the
|
||||
confirm dialog. Keeping these as flat siblings —
|
||||
rather than nesting Revert / chevron inside the
|
||||
title trigger — avoids invalid HTML
|
||||
(button-in-button) and lets the Revert button
|
||||
render in BOTH the collapsed and expanded states.
|
||||
*/}
|
||||
<div className="flex items-stretch transition-colors hover:bg-muted/50">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
|
||||
// Inset ring — Card's ``overflow-hidden`` would
|
||||
// clip an ``offset-2`` ring; ``ring-inset``
|
||||
// paints inside the button box.
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
) : isCancelled ? (
|
||||
<XCircleIcon className="size-4 text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Spinner size="sm" className="text-primary" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
isError
|
||||
? "text-destructive"
|
||||
: isCancelled
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{isRunning
|
||||
? displayName
|
||||
: isCancelled
|
||||
? `Cancelled: ${displayName}`
|
||||
: isError
|
||||
? `Failed: ${displayName}`
|
||||
: displayName}
|
||||
</p>
|
||||
{isRunning && <p className="text-xs text-muted-foreground mt-0.5">Running...</p>}
|
||||
{cancelledReason && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p>
|
||||
)}
|
||||
{errorReason && (
|
||||
<p className="text-xs text-destructive/80 mt-0.5 truncate">{errorReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold truncate",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
isError && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
{isRunning && <Badge variant="secondary">Running</Badge>}
|
||||
{isError && <Badge variant="destructive">Failed</Badge>}
|
||||
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs truncate",
|
||||
isError ? "text-destructive/80" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{!isRunning && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
)}
|
||||
{/*
|
||||
Right-side controls. The Revert button is
|
||||
visible whenever the matching action is
|
||||
reversible — including the collapsed state —
|
||||
but ``ToolCardRevertButton`` itself returns
|
||||
``null`` while a tool is still running because
|
||||
no action-log row exists yet, so it doesn't
|
||||
need an explicit ``isRunning`` gate here.
|
||||
*/}
|
||||
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
|
||||
<ToolCardRevertButton
|
||||
toolCallId={toolCallId}
|
||||
toolName={toolName}
|
||||
langchainToolCallId={langchainToolCallId}
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
"group-data-[state=open]:rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && !isRunning && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
{argsText && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Arguments</p>
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{argsText}
|
||||
</pre>
|
||||
{/*
|
||||
CollapsibleContent body — auto-open while streaming
|
||||
(see ``open`` prop above) so the live ``argsText``
|
||||
streams into the Inputs panel directly, no need for
|
||||
a separate "Live input" panel. Native
|
||||
``overflow-auto`` instead of ``ScrollArea`` because
|
||||
Radix's Viewport can let content bleed past
|
||||
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
|
||||
the column wrappers guarantees ``break-all`` wraps
|
||||
correctly within the bounded ``max-w-lg`` Card.
|
||||
*/}
|
||||
<CollapsibleContent>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3 px-5 py-3">
|
||||
{(argsText || isRunning) && (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
|
||||
{argsText ? (
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{argsText}
|
||||
</pre>
|
||||
) : (
|
||||
// Bridges the brief gap between
|
||||
// ``tool-input-start`` (creates the
|
||||
// card, ``argsText`` undefined) and
|
||||
// the first ``tool-input-delta``.
|
||||
<p className="px-3 py-2 text-xs italic text-muted-foreground">
|
||||
Waiting for input…
|
||||
</p>
|
||||
)}
|
||||
</NestedScroll>
|
||||
</div>
|
||||
)}
|
||||
{!isCancelled && result !== undefined && (
|
||||
<>
|
||||
<div className="h-px bg-border/30" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Result</p>
|
||||
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
</NestedScroll>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
AuiIf,
|
||||
MessagePrimitive,
|
||||
useAuiState,
|
||||
useMessagePartText,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
|
@ -7,6 +13,8 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
|||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { parseMentionSegments } from "@/lib/chat/parse-mention-segments";
|
||||
|
||||
interface AuthorMetadata {
|
||||
displayName: string | null;
|
||||
|
|
@ -47,23 +55,40 @@ const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const UserMessage: FC = () => {
|
||||
const UserTextPart: FC = () => {
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const messageText = useAuiState(({ message }) =>
|
||||
(message?.content ?? [])
|
||||
.map((part) =>
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
(part as { type?: string }).type === "text" &&
|
||||
"text" in part
|
||||
? String((part as { text?: string }).text ?? "")
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
const part = useMessagePartText();
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
|
||||
|
||||
const segments = parseMentionSegments(text, mentionedDocs);
|
||||
|
||||
return (
|
||||
<p style={{ whiteSpace: "pre-line" }} className="break-words">
|
||||
{segments.map((segment) =>
|
||||
segment.type === "text" ? (
|
||||
<span key={`txt-${segment.start}`}>{segment.value}</span>
|
||||
) : (
|
||||
<span
|
||||
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
||||
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-middle leading-none"
|
||||
title={segment.doc.title}
|
||||
>
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
|
||||
</span>
|
||||
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const userMessageParts = { Text: UserTextPart };
|
||||
|
||||
export const UserMessage: FC = () => {
|
||||
const metadata = useAuiState(({ message }) => message?.metadata);
|
||||
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
||||
const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
|
||||
|
|
@ -77,12 +102,8 @@ export const UserMessage: FC = () => {
|
|||
<div className="col-start-2 min-w-0">
|
||||
<div className="aui-user-message-content-wrapper flex items-end gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="aui-user-message-content wrap-break-word rounded-xl bg-muted px-4 py-2.5 text-foreground">
|
||||
{mentionedDocs && mentionedDocs.length > 0 ? (
|
||||
<UserMessageWithMentionChips text={messageText} mentionedDocs={mentionedDocs} />
|
||||
) : (
|
||||
<MessagePrimitive.Parts />
|
||||
)}
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<MessagePrimitive.Parts components={userMessageParts} />
|
||||
</div>
|
||||
<div className="absolute right-0 top-full mt-1 z-10 opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:transition-opacity md:duration-200 md:delay-300 md:group-hover/user-msg:opacity-100 md:group-hover/user-msg:delay-0 md:group-hover/user-msg:pointer-events-auto">
|
||||
<UserActionBar />
|
||||
|
|
@ -99,64 +120,6 @@ export const UserMessage: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const UserMessageWithMentionChips: FC<{
|
||||
text: string;
|
||||
mentionedDocs: { id: number; title: string; document_type: string }[];
|
||||
}> = ({ text, mentionedDocs }) => {
|
||||
type Segment =
|
||||
| { type: "text"; value: string; start: number }
|
||||
| { type: "mention"; doc: { id: number; title: string; document_type: string }; start: number };
|
||||
|
||||
const tokens = mentionedDocs
|
||||
.map((doc) => ({ doc, token: `@${doc.title}` }))
|
||||
.sort((a, b) => b.token.length - a.token.length);
|
||||
|
||||
const segments: Segment[] = [];
|
||||
let i = 0;
|
||||
let buffer = "";
|
||||
let bufferStart = 0;
|
||||
while (i < text.length) {
|
||||
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
|
||||
if (tokenMatch) {
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
buffer = "";
|
||||
}
|
||||
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
|
||||
i += tokenMatch.token.length;
|
||||
bufferStart = i;
|
||||
continue;
|
||||
}
|
||||
if (!buffer) bufferStart = i;
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{segments.map((segment) =>
|
||||
segment.type === "text" ? (
|
||||
<span key={`txt-${segment.start}`}>{segment.value}</span>
|
||||
) : (
|
||||
<span
|
||||
key={`mention-${segment.doc.document_type}:${segment.doc.id}-${segment.start}`}
|
||||
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-baseline"
|
||||
title={segment.doc.title}
|
||||
>
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
|
||||
</span>
|
||||
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const UserActionBar: FC = () => {
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
|
|||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<MarkdownViewer content={chunk.content} />
|
||||
<MarkdownViewer content={chunk.content} enableCitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
77
surfsense_web/components/citations/citation-renderer.tsx
Normal file
77
surfsense_web/components/citations/citation-renderer.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
type CitationToken,
|
||||
type CitationUrlMap,
|
||||
parseTextWithCitations,
|
||||
} from "@/lib/citations/citation-parser";
|
||||
|
||||
/**
|
||||
* Render a single parsed citation token as JSX.
|
||||
*
|
||||
* `ordinalKey` should be a stable per-render counter so duplicate identical
|
||||
* citations within the same parent don't collide on `key`. The previous
|
||||
* implementation in `markdown-text.tsx` used the source string itself as
|
||||
* the key, which produced React warnings when two segments rendered the
|
||||
* same `[citation:N]` text.
|
||||
*/
|
||||
export function renderCitationToken(token: CitationToken, ordinalKey: number): ReactNode {
|
||||
if (token.kind === "url") {
|
||||
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
|
||||
}
|
||||
return (
|
||||
<InlineCitation
|
||||
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}
|
||||
chunkId={token.chunkId}
|
||||
isDocsChunk={token.isDocsChunk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a `ReactNode` (string, array, or arbitrary node) and replace any
|
||||
* `[citation:...]` tokens inside string children with citation badges.
|
||||
*
|
||||
* Designed for use inside `Streamdown`/`react-markdown` `components`
|
||||
* overrides where the renderer hands you `children`. Non-string children
|
||||
* are returned untouched so block/phrasing structure is preserved.
|
||||
*/
|
||||
export function processChildrenWithCitations(
|
||||
children: ReactNode,
|
||||
urlMap: CitationUrlMap
|
||||
): ReactNode {
|
||||
if (typeof children === "string") {
|
||||
const segments = parseTextWithCitations(children, urlMap);
|
||||
if (segments.length === 1 && typeof segments[0] === "string") {
|
||||
return children;
|
||||
}
|
||||
let ordinal = 0;
|
||||
return segments.map((segment) =>
|
||||
typeof segment === "string" ? segment : renderCitationToken(segment, ordinal++)
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
let ordinal = 0;
|
||||
return children.map((child, childIndex) => {
|
||||
if (typeof child === "string") {
|
||||
const segments = parseTextWithCitations(child, urlMap);
|
||||
if (segments.length === 1 && typeof segments[0] === "string") {
|
||||
return child;
|
||||
}
|
||||
return (
|
||||
<span key={`citation-seg-${childIndex}`}>
|
||||
{segments.map((segment) =>
|
||||
typeof segment === "string" ? segment : renderCitationToken(segment, ordinal++)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ export function DocumentViewer({ title, content, trigger }: DocumentViewerProps)
|
|||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<MarkdownViewer content={content} />
|
||||
<MarkdownViewer content={content} enableCitations />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -652,7 +652,7 @@ export function EditorPanelContent({
|
|||
// Plate is heavy on multi-MB docs.
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
{largeDocAlert}
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
<MarkdownViewer content={editorDoc.source_markdown} enableCitations />
|
||||
</div>
|
||||
) : renderInPlateEditor ? (
|
||||
// Editable doc (FILE/NOTE) — Plate editing UX.
|
||||
|
|
@ -670,12 +670,17 @@ export function EditorPanelContent({
|
|||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="**:[[role=toolbar]]:bg-sidebar!"
|
||||
// Render `[citation:N]` badges in view mode only.
|
||||
// Edit mode keeps raw text so the user can edit/delete
|
||||
// tokens directly. `local_file` never reaches this branch
|
||||
// (handled by the source_code editor above).
|
||||
enableCitations={!isEditing && !isLocalFileMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
<MarkdownViewer content={editorDoc.source_markdown} enableCitations />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import { useEffect, useMemo, useRef } from "react";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { EditorSaveContext } from "@/components/editor/editor-save-context";
|
||||
import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit";
|
||||
import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
||||
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
||||
import { Editor, EditorContainer } from "@/components/ui/editor";
|
||||
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
|
||||
/** Live editor instance returned by `usePlateEditor`. */
|
||||
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
|
||||
|
|
@ -65,6 +67,14 @@ export interface PlateEditorProps {
|
|||
* without modifying the core editor component.
|
||||
*/
|
||||
extraPlugins?: AnyPluginConfig[];
|
||||
/**
|
||||
* Render `[citation:N]` and `[citation:URL]` tokens in the deserialized
|
||||
* markdown as interactive citation badges/popovers (mirrors chat). Only
|
||||
* meant for read-only views — when true, `onMarkdownChange` is suppressed
|
||||
* because the in-memory tree contains custom inline-void elements that
|
||||
* have no markdown serialize rule.
|
||||
*/
|
||||
enableCitations?: boolean;
|
||||
}
|
||||
|
||||
function PlateEditorContent({
|
||||
|
|
@ -103,6 +113,7 @@ export function PlateEditor({
|
|||
defaultEditing = false,
|
||||
preset = "full",
|
||||
extraPlugins = [],
|
||||
enableCitations = false,
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
const lastHtmlRef = useRef(html);
|
||||
|
|
@ -145,6 +156,8 @@ export function PlateEditor({
|
|||
...(onSave ? [SaveShortcutPlugin] : []),
|
||||
// Consumer-provided extra plugins
|
||||
...extraPlugins,
|
||||
// Citation void inline element (read-only document viewer).
|
||||
...(enableCitations ? CitationKit : []),
|
||||
MarkdownPlugin.configure({
|
||||
options: {
|
||||
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
|
||||
|
|
@ -154,8 +167,18 @@ export function PlateEditor({
|
|||
value: html
|
||||
? (editor) => editor.api.html.deserialize({ element: html }) as Value
|
||||
: markdown
|
||||
? (editor) =>
|
||||
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
|
||||
? (editor) => {
|
||||
if (!enableCitations) {
|
||||
return editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(markdown));
|
||||
}
|
||||
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
|
||||
const value = editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(rewritten));
|
||||
return injectCitationNodes(value as Descendant[], urlMap) as Value;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
|
|
@ -174,13 +197,22 @@ export function PlateEditor({
|
|||
useEffect(() => {
|
||||
if (!html && markdown !== undefined && markdown !== lastMarkdownRef.current) {
|
||||
lastMarkdownRef.current = markdown;
|
||||
const newValue = editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(markdown));
|
||||
let newValue: Descendant[];
|
||||
if (enableCitations) {
|
||||
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
|
||||
const deserialized = editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[];
|
||||
newValue = injectCitationNodes(deserialized, urlMap);
|
||||
} else {
|
||||
newValue = editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[];
|
||||
}
|
||||
editor.tf.reset();
|
||||
editor.tf.setValue(newValue);
|
||||
editor.tf.setValue(newValue as Value);
|
||||
}
|
||||
}, [html, markdown, editor]);
|
||||
}, [html, markdown, editor, enableCitations]);
|
||||
|
||||
// When not forced read-only, the user can toggle between editing/viewing.
|
||||
const canToggleMode = !readOnly && allowModeToggle;
|
||||
|
|
@ -205,6 +237,16 @@ export function PlateEditor({
|
|||
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
|
||||
{...(readOnly ? { readOnly: true } : {})}
|
||||
onChange={({ value }) => {
|
||||
// View-only citation mode: skip serialization. The custom
|
||||
// `citation` inline-void element has no markdown serialize
|
||||
// rule, so emitting changes here would overwrite
|
||||
// `lastMarkdownRef.current` (and downstream copy-to-clipboard
|
||||
// state in EditorPanelContent) with a tree that loses every
|
||||
// citation token. `enableCitations` is only ever set in
|
||||
// read-only paths, so user input cannot reach this branch
|
||||
// in practice — the guard exists for the initial Plate
|
||||
// normalize emit.
|
||||
if (enableCitations) return;
|
||||
if (onHtmlChange && html) {
|
||||
const serialized = slateToHtml(value as Descendant[]);
|
||||
onHtmlChange(serialized);
|
||||
|
|
|
|||
218
surfsense_web/components/editor/plugins/citation-kit.tsx
Normal file
218
surfsense_web/components/editor/plugins/citation-kit.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client";
|
||||
|
||||
import { type Descendant, KEYS } from "platejs";
|
||||
import { createPlatePlugin, type PlateElementProps } from "platejs/react";
|
||||
import type { FC } from "react";
|
||||
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
|
||||
import {
|
||||
CITATION_REGEX,
|
||||
type CitationUrlMap,
|
||||
parseTextWithCitations,
|
||||
} from "@/lib/citations/citation-parser";
|
||||
|
||||
/**
|
||||
* Plate inline-void node modeling a single `[citation:...]` reference.
|
||||
*
|
||||
* Modeled after the existing `MentionPlugin` pattern in
|
||||
* `inline-mention-editor.tsx` — the only confirmed pattern in this repo
|
||||
* for non-text inline UI. Inline-void elements satisfy Slate's invariant
|
||||
* that the editor renders both atomic widgets and surrounding text
|
||||
* cleanly without breaking selection / caret semantics.
|
||||
*/
|
||||
export type CitationElementNode = {
|
||||
type: "citation";
|
||||
kind: "chunk" | "doc" | "url";
|
||||
chunkId?: number;
|
||||
url?: string;
|
||||
/** Original `[citation:...]` substring for traceability/debugging. */
|
||||
rawText: string;
|
||||
children: [{ text: "" }];
|
||||
};
|
||||
|
||||
const CITATION_TYPE = "citation";
|
||||
|
||||
const CitationElement: FC<PlateElementProps<CitationElementNode>> = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
}) => {
|
||||
const isUrl = element.kind === "url";
|
||||
return (
|
||||
<span {...attributes} className="inline-flex align-baseline">
|
||||
<span contentEditable={false}>
|
||||
{isUrl && element.url ? (
|
||||
<UrlCitation url={element.url} />
|
||||
) : element.chunkId !== undefined ? (
|
||||
<InlineCitation chunkId={element.chunkId} isDocsChunk={element.kind === "doc"} />
|
||||
) : null}
|
||||
</span>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const CitationPlugin = createPlatePlugin({
|
||||
key: CITATION_TYPE,
|
||||
node: {
|
||||
isElement: true,
|
||||
isInline: true,
|
||||
isVoid: true,
|
||||
type: CITATION_TYPE,
|
||||
component: CitationElement,
|
||||
},
|
||||
});
|
||||
|
||||
/** Plugin kit shape used elsewhere in the editor. */
|
||||
export const CitationKit = [CitationPlugin];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slate value transform — runs after MarkdownPlugin.deserialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Structural shapes used by the value transform. We cannot use Plate's
|
||||
// generic Element / Text type predicates directly because `Descendant` is a
|
||||
// constrained union and our predicates would over-narrow. Casting through
|
||||
// these row types keeps the walker readable without fighting the types.
|
||||
type SlateText = { text: string } & Record<string, unknown>;
|
||||
type SlateElement = { type?: string; children: Descendant[] } & Record<string, unknown>;
|
||||
|
||||
function isText(node: Descendant): boolean {
|
||||
return typeof (node as { text?: unknown }).text === "string";
|
||||
}
|
||||
|
||||
function asText(node: Descendant): SlateText {
|
||||
return node as unknown as SlateText;
|
||||
}
|
||||
|
||||
function asElement(node: Descendant): SlateElement {
|
||||
return node as unknown as SlateElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element types whose subtrees we MUST NOT inject citation void elements
|
||||
* into. Each rationale documented in the citation plan:
|
||||
* - `KEYS.codeBlock` / `code_line` — Plate's schema rejects inline elements
|
||||
* inside code containers; the user expects literal text inside code.
|
||||
* - `KEYS.link` — `<button>` inside `<a>` is invalid HTML and the link
|
||||
* swallows the citation click. Mirrors the `<a>` skip in
|
||||
* `MarkdownViewer`.
|
||||
*/
|
||||
const SKIP_SUBTREE_TYPES = new Set<string>([KEYS.codeBlock, "code_line", KEYS.link]);
|
||||
|
||||
/**
|
||||
* Build the marks portion of a Slate text node so we can preserve formatting
|
||||
* (bold/italic/etc.) on the surrounding text fragments after we split.
|
||||
*/
|
||||
function copyMarks(textNode: SlateText): Record<string, unknown> {
|
||||
const { text: _text, ...marks } = textNode;
|
||||
return marks;
|
||||
}
|
||||
|
||||
function makeCitationElement(
|
||||
rawText: string,
|
||||
segment: { kind: "url"; url: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
|
||||
): CitationElementNode {
|
||||
if (segment.kind === "url") {
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
kind: "url",
|
||||
url: segment.url,
|
||||
rawText,
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: CITATION_TYPE,
|
||||
kind: segment.isDocsChunk ? "doc" : "chunk",
|
||||
chunkId: segment.chunkId,
|
||||
rawText,
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-extract the raw `[citation:...]` substrings that produced each parsed
|
||||
* segment, in source order. Lets us preserve the original literal for
|
||||
* `rawText` on the inline-void element.
|
||||
*/
|
||||
function extractRawCitationMatches(text: string): string[] {
|
||||
const matches: string[] = [];
|
||||
CITATION_REGEX.lastIndex = 0;
|
||||
let m: RegExpExecArray | null = CITATION_REGEX.exec(text);
|
||||
while (m !== null) {
|
||||
matches.push(m[0]);
|
||||
m = CITATION_REGEX.exec(text);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[] {
|
||||
const segments = parseTextWithCitations(node.text, urlMap);
|
||||
if (segments.length === 1 && typeof segments[0] === "string") {
|
||||
return [node as unknown as Descendant];
|
||||
}
|
||||
|
||||
const marks = copyMarks(node);
|
||||
const rawMatches = extractRawCitationMatches(node.text);
|
||||
const out: Descendant[] = [];
|
||||
let citationIdx = 0;
|
||||
let pendingText: string | null = null;
|
||||
|
||||
const flushText = () => {
|
||||
// Slate inline-void adjacency: emit an empty text node (with copied
|
||||
// marks) when the citation appears at the very start/end of the text
|
||||
// node so neighbours of the void always have a text sibling.
|
||||
out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant);
|
||||
pendingText = null;
|
||||
};
|
||||
|
||||
for (const segment of segments) {
|
||||
if (typeof segment === "string") {
|
||||
pendingText = (pendingText ?? "") + segment;
|
||||
} else {
|
||||
flushText();
|
||||
const raw = rawMatches[citationIdx] ?? "";
|
||||
out.push(makeCitationElement(raw, segment) as unknown as Descendant);
|
||||
citationIdx += 1;
|
||||
// Always reset pendingText so the next loop iteration emits a
|
||||
// trailing empty text node if no further plain text follows.
|
||||
pendingText = "";
|
||||
}
|
||||
}
|
||||
flushText();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function transformChildren(children: Descendant[], urlMap: CitationUrlMap): Descendant[] {
|
||||
const out: Descendant[] = [];
|
||||
for (const child of children) {
|
||||
if (isText(child)) {
|
||||
out.push(...transformTextNode(asText(child), urlMap));
|
||||
continue;
|
||||
}
|
||||
const elementChild = asElement(child);
|
||||
const elementType = (elementChild.type ?? "") as string;
|
||||
if (elementType && SKIP_SUBTREE_TYPES.has(elementType)) {
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
...elementChild,
|
||||
children: transformChildren(elementChild.children, urlMap),
|
||||
} as unknown as Descendant);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a deserialized Slate value and replace every `[citation:...]`
|
||||
* substring with a `citation` inline-void element. URL placeholders
|
||||
* created by `preprocessCitationMarkdown` are resolved through `urlMap`.
|
||||
*
|
||||
* Subtrees of `code_block`, `code_line`, and `link` are returned as-is —
|
||||
* see `SKIP_SUBTREE_TYPES` above.
|
||||
*/
|
||||
export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] {
|
||||
return transformChildren(value, urlMap);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
// break the MDX parser. This module sanitises them before deserialization.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
|
||||
import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
|
||||
|
||||
// Strip HTML comments that MDX cannot parse.
|
||||
// PDF converters emit <!-- PageHeader="..." -->, <!-- PageBreak -->, etc.
|
||||
|
|
|
|||
|
|
@ -104,7 +104,13 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
|||
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Stream error: ${response.status}`);
|
||||
const body = await response.text().catch(() => "");
|
||||
const errorCode = response.status === 409 ? "THREAD_BUSY" : "SERVER_ERROR";
|
||||
const message =
|
||||
errorCode === "THREAD_BUSY"
|
||||
? "A previous response is still stopping. Please try again in a moment."
|
||||
: `Stream error: ${response.status}`;
|
||||
throw Object.assign(new Error(body || message), { errorCode });
|
||||
}
|
||||
|
||||
for await (const event of readSSEStream(response)) {
|
||||
|
|
@ -115,10 +121,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
|||
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m))
|
||||
);
|
||||
} else if (event.type === "error") {
|
||||
const message =
|
||||
event.errorCode === "THREAD_BUSY"
|
||||
? "A previous response is still stopping. Please try again in a moment."
|
||||
: event.errorText;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: m.content || event.errorText } : m
|
||||
)
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content || message } : m))
|
||||
);
|
||||
} else if ("type" in event && event.type === "data-token-usage") {
|
||||
// After streaming completes, refresh quota
|
||||
|
|
|
|||
|
|
@ -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,14 @@ import {
|
|||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
appendToolInputDelta,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
FrameBatchedUpdater,
|
||||
readSSEStream,
|
||||
type ThinkingStepData,
|
||||
|
|
@ -32,7 +37,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. */
|
||||
|
|
@ -48,6 +55,48 @@ function parseCaptchaError(status: number, body: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function normalizeFreeChatErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "An unexpected error occurred";
|
||||
const code = (error as Error & { errorCode?: string }).errorCode;
|
||||
if (code === "THREAD_BUSY") {
|
||||
return "A previous response is still stopping. Please try again in a moment.";
|
||||
}
|
||||
return error.message || "An unexpected error occurred";
|
||||
}
|
||||
|
||||
function toFreeChatHttpError(status: number, body: string): Error & { errorCode?: string } {
|
||||
let errorCode: string | undefined;
|
||||
let message = body || `Server error: ${status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
const detail =
|
||||
typeof parsed.detail === "object" && parsed.detail !== null
|
||||
? (parsed.detail as Record<string, unknown>)
|
||||
: null;
|
||||
errorCode =
|
||||
(typeof detail?.error_code === "string" ? detail.error_code : undefined) ??
|
||||
(typeof detail?.errorCode === "string" ? detail.errorCode : undefined) ??
|
||||
(typeof parsed.error_code === "string" ? parsed.error_code : undefined) ??
|
||||
(typeof parsed.errorCode === "string" ? parsed.errorCode : undefined);
|
||||
message =
|
||||
(typeof detail?.message === "string" ? detail.message : undefined) ??
|
||||
(typeof parsed.message === "string" ? parsed.message : undefined) ??
|
||||
(typeof parsed.detail === "string" ? parsed.detail : undefined) ??
|
||||
message;
|
||||
} catch {
|
||||
// non-json response
|
||||
}
|
||||
|
||||
if (!errorCode) {
|
||||
if (status === 409) errorCode = "THREAD_BUSY";
|
||||
else if (status === 429) errorCode = "RATE_LIMITED";
|
||||
else if (status === 401 || status === 403) errorCode = "AUTH_EXPIRED";
|
||||
else errorCode = "SERVER_ERROR";
|
||||
}
|
||||
|
||||
return Object.assign(new Error(message), { errorCode });
|
||||
}
|
||||
|
||||
export function FreeChatPage() {
|
||||
const anonMode = useAnonymousMode();
|
||||
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
|
||||
|
|
@ -117,7 +166,7 @@ export function FreeChatPage() {
|
|||
const body = await response.text().catch(() => "");
|
||||
const captchaCode = parseCaptchaError(response.status, body);
|
||||
if (captchaCode) return "captcha";
|
||||
throw new Error(body || `Server error: ${response.status}`);
|
||||
throw toFreeChatHttpError(response.status, body);
|
||||
}
|
||||
|
||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||
|
|
@ -125,6 +174,7 @@ export function FreeChatPage() {
|
|||
const contentPartsState: ContentPartsState = {
|
||||
contentParts: [],
|
||||
currentTextPartIndex: -1,
|
||||
currentReasoningPartIndex: -1,
|
||||
toolCallIndices: new Map(),
|
||||
};
|
||||
const { toolCallIndices } = contentPartsState;
|
||||
|
|
@ -139,6 +189,10 @@ export function FreeChatPage() {
|
|||
);
|
||||
};
|
||||
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||
const forceFlush = () => {
|
||||
scheduleFlush();
|
||||
batcher.flush();
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const parsed of readSSEStream(response)) {
|
||||
|
|
@ -148,29 +202,74 @@ export function FreeChatPage() {
|
|||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||
batcher.flush();
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-available":
|
||||
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,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
forceFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-delta":
|
||||
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-available": {
|
||||
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
TOOLS_WITH_UI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {}
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
argsText: finalArgsText,
|
||||
});
|
||||
}
|
||||
batcher.flush();
|
||||
forceFlush();
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||
batcher.flush();
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
forceFlush();
|
||||
break;
|
||||
|
||||
case "data-thinking-step": {
|
||||
|
|
@ -187,7 +286,9 @@ export function FreeChatPage() {
|
|||
break;
|
||||
|
||||
case "error":
|
||||
throw new Error(parsed.errorText || "Server error");
|
||||
throw Object.assign(new Error(parsed.errorText || "Server error"), {
|
||||
errorCode: parsed.errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
batcher.flush();
|
||||
|
|
@ -277,7 +378,7 @@ export function FreeChatPage() {
|
|||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
console.error("[FreeChatPage] Chat error:", error);
|
||||
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
const errorText = normalizeFreeChatErrorMessage(error);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
|
|
@ -336,7 +437,7 @@ export function FreeChatPage() {
|
|||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
console.error("[FreeChatPage] Retry error:", error);
|
||||
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
|
||||
const errorText = normalizeFreeChatErrorMessage(error);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
|
|
@ -369,6 +470,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,11 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { AuiIf, ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
|
||||
import { EditComposer } from "@/components/assistant-ui/edit-composer";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { FreeComposer } from "./free-composer";
|
||||
|
||||
|
|
@ -24,20 +23,6 @@ const FreeThreadWelcome: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
);
|
||||
};
|
||||
|
||||
export const FreeThread: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
|
|
@ -46,10 +31,12 @@ export const FreeThread: FC = () => {
|
|||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
<ChatViewport
|
||||
footer={
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<FreeComposer />
|
||||
</AuiIf>
|
||||
}
|
||||
>
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||
<FreeThreadWelcome />
|
||||
|
|
@ -62,21 +49,7 @@ export const FreeThread: FC = () => {
|
|||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="grow" />
|
||||
</AuiIf>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<FreeComposer />
|
||||
</AuiIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ChatViewport>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import {
|
|||
type Tab,
|
||||
} from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -681,14 +681,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
|
||||
|
||||
// Page usage
|
||||
const pageUsage = user
|
||||
? {
|
||||
pagesUsed: user.pages_used,
|
||||
pagesLimit: user.pages_limit,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
|
|
@ -723,7 +715,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
onManageMembers={handleManageMembers}
|
||||
onUserSettings={handleUserSettings}
|
||||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
|
|
|
|||
|
|
@ -120,45 +120,22 @@ interface LayoutShellProps {
|
|||
|
||||
function MainContentPanel({
|
||||
isChatPage,
|
||||
isSidebarCollapsed,
|
||||
onTabSwitch,
|
||||
onNewChat,
|
||||
leftActions,
|
||||
showResizeHandle = false,
|
||||
onResizeMouseDown,
|
||||
children,
|
||||
}: {
|
||||
isChatPage: boolean;
|
||||
isSidebarCollapsed: boolean;
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onNewChat?: () => void;
|
||||
leftActions?: React.ReactNode;
|
||||
showResizeHandle?: boolean;
|
||||
onResizeMouseDown?: (e: React.MouseEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-1 flex-col min-w-0 -ml-2",
|
||||
isSidebarCollapsed ? "" : "border-l border-border/60"
|
||||
)}
|
||||
>
|
||||
{showResizeHandle && onResizeMouseDown && (
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Resize sidebar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={50}
|
||||
tabIndex={0}
|
||||
onMouseDown={onResizeMouseDown}
|
||||
className="absolute left-0 top-0 hidden md:block h-full w-2 -translate-x-1/2 cursor-col-resize z-30 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="relative isolate flex flex-1 flex-col min-w-0">
|
||||
<TabBar
|
||||
onTabSwitch={onTabSwitch}
|
||||
onNewChat={onNewChat}
|
||||
|
|
@ -538,14 +515,26 @@ export function LayoutShell({
|
|||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — negative margins eat the flex gap so spacing stays unchanged */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Resize sidebar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={50}
|
||||
tabIndex={0}
|
||||
onMouseDown={onResizeMouseDown}
|
||||
className="hidden md:block h-full cursor-col-resize z-30 focus:outline-none"
|
||||
style={{ width: 8, marginLeft: -8, marginRight: -8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content panel */}
|
||||
<MainContentPanel
|
||||
isChatPage={isChatPage}
|
||||
isSidebarCollapsed={isCollapsed}
|
||||
onTabSwitch={onTabSwitch}
|
||||
onNewChat={onNewChat}
|
||||
showResizeHandle={!isCollapsed}
|
||||
onResizeMouseDown={onResizeMouseDown}
|
||||
leftActions={
|
||||
isCollapsed ? (
|
||||
<SidebarCollapseButton isCollapsed={isCollapsed} onToggle={toggleCollapsed} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { queries } from "@/zero/queries";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
|
||||
export function AuthenticatedPageUsageDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
||||
if (isAnonymous || !me) return null;
|
||||
|
||||
return <PageUsageDisplay pagesUsed={me.pagesUsed} pagesLimit={me.pagesLimit} />;
|
||||
}
|
||||
|
|
@ -23,9 +23,7 @@ import { useTranslations } from "next-intl";
|
|||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
|
|
@ -74,12 +72,12 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
export function PremiumTokenUsageDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const { data: tokenStatus } = useQuery({
|
||||
queryKey: ["token-status"],
|
||||
queryFn: () => stripeApiService.getTokenStatus(),
|
||||
staleTime: 60_000,
|
||||
enabled: !isAnonymous,
|
||||
});
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
||||
if (!tokenStatus) return null;
|
||||
if (isAnonymous || !me) return null;
|
||||
|
||||
const usagePercentage = Math.min(
|
||||
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
|
||||
(me.premiumTokensUsed / Math.max(me.premiumTokensLimit, 1)) * 100,
|
||||
100
|
||||
);
|
||||
|
||||
|
|
@ -31,8 +26,7 @@ export function PremiumTokenUsageDisplay() {
|
|||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokens(tokenStatus.premium_tokens_used)} /{" "}
|
||||
{formatTokens(tokenStatus.premium_tokens_limit)} tokens
|
||||
{formatTokens(me.premiumTokensUsed)} / {formatTokens(me.premiumTokensLimit)} tokens
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { AuthenticatedPageUsageDisplay } from "./AuthenticatedPageUsageDisplay";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
|
|
@ -342,9 +342,7 @@ function SidebarUsageFooter({
|
|||
return (
|
||||
<div className="px-3 py-3 border-t border-border/60 space-y-3">
|
||||
<PremiumTokenUsageDisplay />
|
||||
{pageUsage && (
|
||||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||
)}
|
||||
<AuthenticatedPageUsageDisplay />
|
||||
<div className="space-y-0.5">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/more-pages`}
|
||||
|
|
|
|||
|
|
@ -316,10 +316,10 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<MarkdownViewer content={doc.source_markdown} />
|
||||
<MarkdownViewer content={doc.source_markdown} enableCitations />
|
||||
</>
|
||||
) : (
|
||||
<MarkdownViewer content={doc.source_markdown} />
|
||||
<MarkdownViewer content={doc.source_markdown} enableCitations />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { createMathPlugin } from "@streamdown/math";
|
|||
import { Streamdown, type StreamdownProps } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const code = createCodePlugin({
|
||||
|
|
@ -21,8 +24,21 @@ interface MarkdownViewerProps {
|
|||
content: string;
|
||||
className?: string;
|
||||
maxLength?: number;
|
||||
/**
|
||||
* When true, render `[citation:N]` / `[citation:URL]` tokens as the
|
||||
* interactive citation badges/popovers used in chat. Default `false`
|
||||
* so callers that don't need citations are unchanged.
|
||||
*
|
||||
* Note: we deliberately do NOT override `<a>` to inject citations into
|
||||
* link text — that would produce `<button>` inside `<a>` (invalid
|
||||
* HTML). A `[citation:N]` token literally placed inside markdown link
|
||||
* text stays as raw text.
|
||||
*/
|
||||
enableCitations?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_URL_MAP: CitationUrlMap = new Map();
|
||||
|
||||
/**
|
||||
* If the entire content is wrapped in a single ```markdown or ```md
|
||||
* code fence, strip the fence so the inner markdown renders properly.
|
||||
|
|
@ -85,14 +101,45 @@ function convertLatexDelimiters(content: string): string {
|
|||
return content;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className, maxLength }: MarkdownViewerProps) {
|
||||
export function MarkdownViewer({
|
||||
content,
|
||||
className,
|
||||
maxLength,
|
||||
enableCitations = false,
|
||||
}: MarkdownViewerProps) {
|
||||
const isTruncated = maxLength != null && content.length > maxLength;
|
||||
const displayContent = isTruncated ? content.slice(0, maxLength) : content;
|
||||
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(displayContent));
|
||||
|
||||
// Preprocess for URL placeholders BEFORE LaTeX so GFM autolinks don't
|
||||
// split `[citation:https://…]` apart. The preprocess is code-fence
|
||||
// aware so citations inside fenced code stay literal.
|
||||
const { processedContent, urlMap } = useMemo(() => {
|
||||
const stripped = stripOuterMarkdownFence(displayContent);
|
||||
if (!enableCitations) {
|
||||
return {
|
||||
processedContent: convertLatexDelimiters(stripped),
|
||||
urlMap: EMPTY_URL_MAP,
|
||||
};
|
||||
}
|
||||
const { content: rewritten, urlMap: map } = preprocessCitationMarkdown(stripped);
|
||||
return {
|
||||
processedContent: convertLatexDelimiters(rewritten),
|
||||
urlMap: map,
|
||||
};
|
||||
}, [displayContent, enableCitations]);
|
||||
|
||||
// Phrasing/block renderers wrap their string children through the
|
||||
// citation renderer when `enableCitations` is on. We deliberately do
|
||||
// NOT override `<a>` (would produce <button> inside <a>) and we do
|
||||
// NOT touch the inline/fenced `code` paths (citations stay literal
|
||||
// inside code, matching markdown-text.tsx behavior).
|
||||
const wrap = (children: React.ReactNode): React.ReactNode =>
|
||||
enableCitations ? processChildrenWithCitations(children, urlMap) : children;
|
||||
|
||||
const components: StreamdownProps["components"] = {
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="my-2" {...props}>
|
||||
{children}
|
||||
{wrap(children)}
|
||||
</p>
|
||||
),
|
||||
a: ({ children, ...props }) => (
|
||||
|
|
@ -105,31 +152,49 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
|
|||
{children}
|
||||
</a>
|
||||
),
|
||||
li: ({ children, ...props }) => <li {...props}>{children}</li>,
|
||||
li: ({ children, ...props }) => <li {...props}>{wrap(children)}</li>,
|
||||
ul: ({ ...props }) => <ul className="list-disc pl-5 my-2" {...props} />,
|
||||
ol: ({ ...props }) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
{wrap(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
|
||||
{children}
|
||||
{wrap(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
|
||||
{children}
|
||||
{wrap(children)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
|
||||
{children}
|
||||
{wrap(children)}
|
||||
</h4>
|
||||
),
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
|
||||
h5: ({ children, ...props }) => (
|
||||
<h5 className="text-sm font-bold mt-3 mb-1" {...props}>
|
||||
{wrap(children)}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<h6 className="text-xs font-bold mt-3 mb-1" {...props}>
|
||||
{wrap(children)}
|
||||
</h6>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="font-semibold" {...props}>
|
||||
{wrap(children)}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children, ...props }) => <em {...props}>{wrap(children)}</em>,
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props}>
|
||||
{wrap(children)}
|
||||
</blockquote>
|
||||
),
|
||||
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
|
||||
img: ({ src, alt, width: _w, height: _h, ...props }) => {
|
||||
|
|
@ -163,17 +228,21 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
|
|||
<table className="w-full divide-y divide-border" {...props} />
|
||||
</div>
|
||||
),
|
||||
th: ({ ...props }) => (
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{wrap(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ ...props }) => (
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{wrap(children)}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -236,6 +236,93 @@ interface DisplayItem {
|
|||
isAutoMode: boolean;
|
||||
}
|
||||
|
||||
const TruncatedNameWithTooltip: React.FC<{
|
||||
text: string;
|
||||
className?: string;
|
||||
enableTooltip: boolean;
|
||||
}> = ({ text, className, enableTooltip }) => {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const openTimerRef = useRef<number | undefined>(undefined);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const recalcTruncation = useCallback(() => {
|
||||
const el = textRef.current;
|
||||
if (!el) return;
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableTooltip) return;
|
||||
const el = textRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const raf = requestAnimationFrame(recalcTruncation);
|
||||
recalcTruncation();
|
||||
|
||||
const observer = new ResizeObserver(recalcTruncation);
|
||||
observer.observe(el);
|
||||
if (el.parentElement) observer.observe(el.parentElement);
|
||||
window.addEventListener("resize", recalcTruncation);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", recalcTruncation);
|
||||
};
|
||||
}, [enableTooltip, recalcTruncation]);
|
||||
|
||||
useEffect(() => {
|
||||
// Recompute when row text changes.
|
||||
void text;
|
||||
requestAnimationFrame(recalcTruncation);
|
||||
}, [text, recalcTruncation]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (openTimerRef.current) window.clearTimeout(openTimerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!enableTooltip) {
|
||||
return (
|
||||
<span ref={textRef} className={cn("block max-w-full", className)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (openTimerRef.current) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = undefined;
|
||||
}
|
||||
if (!nextOpen) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isTruncated) return;
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
setOpen(true);
|
||||
openTimerRef.current = undefined;
|
||||
}, 220);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={handleOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<span ref={textRef} className={cn("block max-w-full", className)}>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface ModelSelectorProps {
|
||||
|
|
@ -936,7 +1023,11 @@ export function ModelSelector({
|
|||
{/* Model info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||
<TruncatedNameWithTooltip
|
||||
text={config.name}
|
||||
enableTooltip={!isMobile}
|
||||
className="font-medium text-sm truncate"
|
||||
/>
|
||||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const demoPlans = [
|
|||
"Self Hostable",
|
||||
"500 pages included to start",
|
||||
"3 million premium tokens to start",
|
||||
"Earn up to 3,000+ bonus pages for free",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -44,20 +45,21 @@ export const PublicThread: FC<PublicThreadProps> = ({ footer }) => {
|
|||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnThreadSwitch
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 pb-6"
|
||||
>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage: PublicUserMessage,
|
||||
AssistantMessage: PublicAssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Spacer to ensure footer doesn't overlap last message */}
|
||||
<div className="h-24" />
|
||||
</ThreadPrimitive.Viewport>
|
||||
|
||||
{footer && (
|
||||
<div className="sticky bottom-0 z-20 border-t bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60">
|
||||
<div className="border-t bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -157,6 +159,7 @@ const PublicAssistantMessage: FC = () => {
|
|||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningMessagePart,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_podcast: GeneratePodcastToolUI,
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ export function ReportPanelContent({
|
|||
) : reportContent.content ? (
|
||||
isReadOnly ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={reportContent.content} />
|
||||
<MarkdownViewer content={reportContent.content} enableCitations />
|
||||
</div>
|
||||
) : (
|
||||
<PlateEditor
|
||||
|
|
@ -531,6 +531,9 @@ export function ReportPanelContent({
|
|||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
// Show citation badges in view mode; raw `[citation:N]`
|
||||
// text in edit mode so users can edit/delete tokens.
|
||||
enableCitations={!isEditing}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
|
@ -11,6 +12,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
const TOKEN_PACK_SIZE = 1_000_000;
|
||||
const PRICE_PER_PACK_USD = 1;
|
||||
|
|
@ -21,11 +23,15 @@ export function BuyTokensContent() {
|
|||
const searchSpaceId = Number(params?.search_space_id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
// Server config flag: stays on REST, not per-user.
|
||||
const { data: tokenStatus } = useQuery({
|
||||
queryKey: ["token-status"],
|
||||
queryFn: () => stripeApiService.getTokenStatus(),
|
||||
});
|
||||
|
||||
// Live per-user usage via Zero.
|
||||
const [me] = useZeroQuery(queries.user.me({}));
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: stripeApiService.createTokenCheckoutSession,
|
||||
onSuccess: (response) => {
|
||||
|
|
@ -54,12 +60,11 @@ export function BuyTokensContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const usagePercentage = tokenStatus
|
||||
? Math.min(
|
||||
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
|
||||
100
|
||||
)
|
||||
: 0;
|
||||
const used = me?.premiumTokensUsed ?? 0;
|
||||
const limit = me?.premiumTokensLimit ?? 0;
|
||||
// Mirrors the backend formula in stripe_routes.py:608 (max(0, limit - used)).
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const usagePercentage = me ? Math.min((used / Math.max(limit, 1)) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
|
|
@ -68,18 +73,17 @@ export function BuyTokensContent() {
|
|||
<p className="mt-1 text-sm text-muted-foreground">$1 per 1M tokens, pay as you go</p>
|
||||
</div>
|
||||
|
||||
{tokenStatus && (
|
||||
{me && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{tokenStatus.premium_tokens_used.toLocaleString()} /{" "}
|
||||
{tokenStatus.premium_tokens_limit.toLocaleString()} premium tokens
|
||||
{used.toLocaleString()} / {limit.toLocaleString()} premium tokens
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{tokenStatus.premium_tokens_remaining.toLocaleString()} tokens remaining
|
||||
{remaining.toLocaleString()} tokens remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
RefreshCw,
|
||||
ScanEye,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
|
|
@ -143,23 +143,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
}));
|
||||
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!savingRef.current) {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||
});
|
||||
}
|
||||
}, [
|
||||
preferences?.agent_llm_id,
|
||||
preferences?.document_summary_llm_id,
|
||||
preferences?.image_generation_config_id,
|
||||
preferences?.vision_llm_config_id,
|
||||
]);
|
||||
|
||||
const handleRoleAssignment = useCallback(
|
||||
async (prefKey: string, configId: string) => {
|
||||
|
|
@ -167,7 +150,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
|
||||
setSavingRole(prefKey);
|
||||
savingRef.current = true;
|
||||
|
||||
try {
|
||||
await updatePreferences({
|
||||
|
|
@ -177,7 +159,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
toast.success("Role assignment updated");
|
||||
} finally {
|
||||
setSavingRole(null);
|
||||
savingRef.current = false;
|
||||
}
|
||||
},
|
||||
[updatePreferences, searchSpaceId]
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, Mail } from "lucide-react";
|
||||
import { Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -33,7 +26,6 @@ export function MorePagesContent() {
|
|||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const [claimOpen, setClaimOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
|
|
@ -79,35 +71,10 @@ export function MorePagesContent() {
|
|||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Claim your free page offer and earn bonus pages
|
||||
Earn bonus pages by completing tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3k free offer */}
|
||||
<Card className="border-emerald-500/30 bg-emerald-500/5">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white text-xs font-bold">
|
||||
3k
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">Claim 3,000 Free Pages</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Limited offer. Schedule a meeting or email us to claim.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onClick={() => setClaimOpen(true)}
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Free tasks */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Earn Bonus Pages</h3>
|
||||
{isLoading ? (
|
||||
|
|
@ -182,7 +149,6 @@ export function MorePagesContent() {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Link to buy pages */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Need more?</p>
|
||||
{pageBuyingEnabled ? (
|
||||
|
|
@ -197,25 +163,6 @@ export function MorePagesContent() {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim 3k dialog */}
|
||||
<Dialog open={claimOpen} onOpenChange={setClaimOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Claim 3,000 Free Pages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send us an email to claim your free 3,000 pages. Include your account email and
|
||||
primary usecase for free pages.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button asChild className="w-full gap-2">
|
||||
<a href="mailto:rohan@surfsense.com?subject=Claim%203%2C000%20Free%20Pages&body=Hi%2C%20I'd%20like%20to%20claim%20the%203%2C000%20free%20pages%20offer.%0A%0AMy%20account%20email%3A%20">
|
||||
<Mail className="h-4 w-4" />
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
const content: Record<string, React.ReactNode> = {
|
||||
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
|
||||
models: <AgentModelManager searchSpaceId={searchSpaceId} />,
|
||||
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
||||
roles: <LLMRoleManager key={searchSpaceId} searchSpaceId={searchSpaceId} />,
|
||||
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
||||
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
|
||||
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
|
|
@ -77,7 +78,7 @@ function GenericApprovalCard({
|
|||
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const displayName = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const displayName = getToolDisplayName(toolName);
|
||||
|
||||
const mcpServer = interruptData.context?.mcp_server as string | undefined;
|
||||
const toolDescription = interruptData.context?.tool_description as string | undefined;
|
||||
|
|
@ -186,12 +187,11 @@ function GenericApprovalCard({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.keys(args).length > 0 && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Parameters</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
{phase === "pending" && isEditing ? (
|
||||
<ParamEditor
|
||||
params={editedParams}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,223 @@
|
|||
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 {
|
||||
return TOOL_ICONS[name] ?? Wrench;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly display names for tools shown in the chat UI.
|
||||
*
|
||||
* Most users aren't engineers; they shouldn't see raw unix-style
|
||||
* identifiers like ``rm`` / ``rmdir`` / ``ls`` / ``grep`` / ``glob`` or
|
||||
* snake_cased function names. The map below renders each tool with
|
||||
* plain English wording (verb + object) so non-technical users
|
||||
* understand what the agent is doing at a glance.
|
||||
*
|
||||
* Unmapped tool names fall back to a snake_case-to-Title-Case
|
||||
* conversion via :func:`getToolDisplayName`.
|
||||
*/
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
// Filesystem / knowledge base
|
||||
read_file: "Read file",
|
||||
write_file: "Write file",
|
||||
edit_file: "Edit file",
|
||||
move_file: "Move file",
|
||||
rm: "Delete file",
|
||||
rmdir: "Delete folder",
|
||||
mkdir: "Create folder",
|
||||
ls: "List files",
|
||||
glob: "Find files",
|
||||
grep: "Search in files",
|
||||
write_todos: "Plan tasks",
|
||||
save_document: "Save document",
|
||||
// Generators
|
||||
generate_podcast: "Generate podcast",
|
||||
generate_video_presentation: "Generate video presentation",
|
||||
generate_report: "Generate report",
|
||||
generate_resume: "Generate resume",
|
||||
generate_image: "Generate image",
|
||||
display_image: "Show image",
|
||||
// Web / search
|
||||
scrape_webpage: "Read webpage",
|
||||
web_search: "Search the web",
|
||||
search_surfsense_docs: "Search knowledge base",
|
||||
// Memory
|
||||
update_memory: "Update memory",
|
||||
// Calendar
|
||||
search_calendar_events: "Search calendar",
|
||||
create_calendar_event: "Create event",
|
||||
update_calendar_event: "Update event",
|
||||
delete_calendar_event: "Delete event",
|
||||
// Gmail
|
||||
search_gmail: "Search Gmail",
|
||||
read_gmail_email: "Read email",
|
||||
create_gmail_draft: "Draft email",
|
||||
update_gmail_draft: "Update draft",
|
||||
send_gmail_email: "Send email",
|
||||
trash_gmail_email: "Move email to trash",
|
||||
// Notion
|
||||
create_notion_page: "Create Notion page",
|
||||
update_notion_page: "Update Notion page",
|
||||
delete_notion_page: "Delete Notion page",
|
||||
// Confluence
|
||||
create_confluence_page: "Create Confluence page",
|
||||
update_confluence_page: "Update Confluence page",
|
||||
delete_confluence_page: "Delete Confluence page",
|
||||
// Linear
|
||||
create_linear_issue: "Create Linear issue",
|
||||
update_linear_issue: "Update Linear issue",
|
||||
delete_linear_issue: "Delete Linear issue",
|
||||
// Jira
|
||||
create_jira_issue: "Create Jira issue",
|
||||
update_jira_issue: "Update Jira issue",
|
||||
delete_jira_issue: "Delete Jira issue",
|
||||
// Drive-like file connectors
|
||||
create_google_drive_file: "Create Google Drive file",
|
||||
delete_google_drive_file: "Delete Google Drive file",
|
||||
create_dropbox_file: "Create Dropbox file",
|
||||
delete_dropbox_file: "Delete Dropbox file",
|
||||
create_onedrive_file: "Create OneDrive file",
|
||||
delete_onedrive_file: "Delete OneDrive file",
|
||||
// Discord
|
||||
list_discord_channels: "List Discord channels",
|
||||
read_discord_messages: "Read Discord messages",
|
||||
send_discord_message: "Send Discord message",
|
||||
// Teams
|
||||
list_teams_channels: "List Teams channels",
|
||||
read_teams_messages: "Read Teams messages",
|
||||
send_teams_message: "Send Teams message",
|
||||
// Luma
|
||||
list_luma_events: "List Luma events",
|
||||
read_luma_event: "Read Luma event",
|
||||
create_luma_event: "Create Luma event",
|
||||
// Misc
|
||||
get_connected_accounts: "Check connected accounts",
|
||||
execute: "Run command",
|
||||
execute_code: "Run code",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a tool's canonical (snake_case) name for display in the chat UI.
|
||||
*
|
||||
* Looks up :data:`TOOL_DISPLAY_NAMES` first; falls back to a
|
||||
* snake_case-to-Title-Case rewrite for tools that don't have a curated
|
||||
* label (e.g. dynamically registered MCP tools).
|
||||
*/
|
||||
export function getToolDisplayName(name: string): string {
|
||||
const friendly = TOOL_DISPLAY_NAMES[name];
|
||||
if (friendly) return friendly;
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export const CONNECTOR_TOOL_ICON_PATHS: Record<string, { src: string; alt: string }> = {
|
||||
gmail: { src: "/connectors/google-gmail.svg", alt: "Gmail" },
|
||||
google_calendar: { src: "/connectors/google-calendar.svg", alt: "Google Calendar" },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Raw message from database (real-time sync)
|
||||
* Raw message from database (real-time sync).
|
||||
*
|
||||
* ``turn_id`` is included so consumers (e.g. ``convertToThreadMessage``)
|
||||
* can populate ``metadata.custom.chatTurnId`` on the
|
||||
* ``ThreadMessageLike`` even after the live-collab Zero re-sync. The
|
||||
* inline Revert button's ``(chat_turn_id, tool_name, position)``
|
||||
* fallback in tool-fallback.tsx depends on it.
|
||||
*/
|
||||
export const rawMessage = z.object({
|
||||
id: z.number(),
|
||||
|
|
@ -10,6 +16,7 @@ export const rawMessage = z.object({
|
|||
content: z.unknown(),
|
||||
author_id: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
turn_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type RawMessage = z.infer<typeof rawMessage>;
|
||||
|
|
|
|||
395
surfsense_web/hooks/use-agent-actions-query.ts
Normal file
395
surfsense_web/hooks/use-agent-actions-query.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
type AgentAction,
|
||||
type AgentActionListResponse,
|
||||
agentActionsApiService,
|
||||
} from "@/lib/apis/agent-actions-api.service";
|
||||
|
||||
// =============================================================================
|
||||
// DIAGNOSTIC LOGGING — gated behind a single switch. Flip ``RevertDebug``
|
||||
// to ``true`` to trace the full SSE → cache → card → button pipeline in
|
||||
// the browser console. Off by default so we don't spam production. The
|
||||
// infrastructure stays in place because the underlying id-mismatch
|
||||
// failure mode is rare-but-real and surfaces only at runtime.
|
||||
// =============================================================================
|
||||
const RevertDebug = false;
|
||||
const dbg = (...args: unknown[]) => {
|
||||
if (RevertDebug && typeof window !== "undefined") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[RevertDebug]", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified store for ``AgentActionLog`` rows scoped to one thread.
|
||||
*
|
||||
* Replaces the previous SSE side-channel atom mess
|
||||
* (``agentActionByLcIdAtom`` / ``agentActionByToolCallIdAtom`` /
|
||||
* ``agentActionsByChatTurnIdAtom``) and the standalone hydration hook.
|
||||
* One react-query cache entry is now the single source of truth for:
|
||||
*
|
||||
* * the inline Revert button on every tool-call card
|
||||
* * the per-turn "Revert turn" button under each assistant message
|
||||
* * the edit-from-position pre-flight that decides whether to show
|
||||
* the confirmation dialog
|
||||
* * the agent-actions sheet
|
||||
*
|
||||
* The cache is hydrated by ``GET /threads/{id}/actions`` (sized to
|
||||
* 200, the server max) and updated incrementally by helpers that turn
|
||||
* SSE events / revert RPC responses into ``setQueryData`` mutations.
|
||||
* That keeps the card and the sheet in lockstep on every code path —
|
||||
* page reload, navigation, live stream, post-stream reversibility flip,
|
||||
* and explicit revert clicks.
|
||||
*/
|
||||
|
||||
export const ACTION_LOG_PAGE_SIZE = 200;
|
||||
|
||||
/** Stable react-query key for the per-thread action list. */
|
||||
export function agentActionsQueryKey(threadId: number | null) {
|
||||
return threadId !== null
|
||||
? (["agent-actions", threadId] as const)
|
||||
: (["agent-actions", "none"] as const);
|
||||
}
|
||||
|
||||
/** Subset of the SSE ``data-action-log`` payload we care about. */
|
||||
export interface ActionLogSseEvent {
|
||||
id: number;
|
||||
lc_tool_call_id: string | null;
|
||||
chat_turn_id: string | null;
|
||||
tool_name: string;
|
||||
reversible: boolean;
|
||||
reverse_descriptor_present: boolean;
|
||||
error: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append or upsert a freshly-emitted ``AgentActionLog`` row into the
|
||||
* thread-scoped query cache.
|
||||
*
|
||||
* The SSE payload is a strict subset of ``AgentAction``; missing
|
||||
* fields (``args``, ``reverse_descriptor``, ``user_id``) are filled
|
||||
* with ``null`` placeholders. The next refetch (sheet open, user
|
||||
* focus, route stale) backfills them — but the inline Revert button
|
||||
* only reads the fields the SSE payload carries, so it lights up
|
||||
* immediately.
|
||||
*/
|
||||
export function applyActionLogSse(
|
||||
queryClient: QueryClient,
|
||||
threadId: number,
|
||||
searchSpaceId: number,
|
||||
event: ActionLogSseEvent
|
||||
): void {
|
||||
dbg("applyActionLogSse: incoming SSE event", {
|
||||
threadId,
|
||||
searchSpaceId,
|
||||
event,
|
||||
});
|
||||
queryClient.setQueryData<AgentActionListResponse>(agentActionsQueryKey(threadId), (prev) => {
|
||||
const placeholder: AgentAction = {
|
||||
id: event.id,
|
||||
thread_id: threadId,
|
||||
user_id: null,
|
||||
search_space_id: searchSpaceId,
|
||||
tool_name: event.tool_name,
|
||||
args: null,
|
||||
result_id: null,
|
||||
reversible: event.reversible,
|
||||
reverse_descriptor: event.reverse_descriptor_present ? {} : null,
|
||||
error: event.error ? {} : null,
|
||||
reverse_of: null,
|
||||
reverted_by_action_id: null,
|
||||
is_revert_action: false,
|
||||
tool_call_id: event.lc_tool_call_id,
|
||||
chat_turn_id: event.chat_turn_id,
|
||||
created_at: event.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
if (!prev) {
|
||||
return {
|
||||
items: [placeholder],
|
||||
total: 1,
|
||||
page: 0,
|
||||
page_size: ACTION_LOG_PAGE_SIZE,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
const existingIdx = prev.items.findIndex((a) => a.id === event.id);
|
||||
if (existingIdx >= 0) {
|
||||
const merged = [...prev.items];
|
||||
const existing = merged[existingIdx];
|
||||
if (existing) {
|
||||
merged[existingIdx] = {
|
||||
...existing,
|
||||
reversible: event.reversible,
|
||||
tool_call_id: event.lc_tool_call_id ?? existing.tool_call_id,
|
||||
chat_turn_id: event.chat_turn_id ?? existing.chat_turn_id,
|
||||
};
|
||||
}
|
||||
dbg("applyActionLogSse: merged into existing entry", {
|
||||
id: event.id,
|
||||
tool_call_id: merged[existingIdx]?.tool_call_id,
|
||||
reversible: merged[existingIdx]?.reversible,
|
||||
});
|
||||
return { ...prev, items: merged };
|
||||
}
|
||||
dbg("applyActionLogSse: appended new placeholder", {
|
||||
id: event.id,
|
||||
tool_call_id: placeholder.tool_call_id,
|
||||
tool_name: placeholder.tool_name,
|
||||
reversible: placeholder.reversible,
|
||||
cacheSizeAfter: prev.items.length + 1,
|
||||
});
|
||||
// REST returns newest-first — keep that ordering when
|
||||
// the server eventually refetches by prepending.
|
||||
return {
|
||||
...prev,
|
||||
items: [placeholder, ...prev.items],
|
||||
total: prev.total + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a post-SAVEPOINT reversibility flip
|
||||
* (``data-action-log-updated`` SSE event) to the cache.
|
||||
*/
|
||||
export function applyActionLogUpdatedSse(
|
||||
queryClient: QueryClient,
|
||||
threadId: number,
|
||||
id: number,
|
||||
reversible: boolean
|
||||
): void {
|
||||
dbg("applyActionLogUpdatedSse: reversibility flip", {
|
||||
threadId,
|
||||
id,
|
||||
reversible,
|
||||
});
|
||||
queryClient.setQueryData<AgentActionListResponse>(agentActionsQueryKey(threadId), (prev) => {
|
||||
if (!prev) {
|
||||
dbg("applyActionLogUpdatedSse: NO prev cache for thread; flip dropped", {
|
||||
threadId,
|
||||
id,
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
let mutated = false;
|
||||
const items = prev.items.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
mutated = true;
|
||||
return { ...a, reversible };
|
||||
});
|
||||
if (!mutated) {
|
||||
dbg("applyActionLogUpdatedSse: id not in cache; flip dropped", {
|
||||
threadId,
|
||||
id,
|
||||
cacheSize: prev.items.length,
|
||||
cacheIds: prev.items.map((a) => a.id),
|
||||
});
|
||||
}
|
||||
return mutated ? { ...prev, items } : prev;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically mark ``id`` as reverted.
|
||||
*
|
||||
* Used by the inline / per-turn Revert button immediately after the
|
||||
* server returns success so the UI flips to "Reverted" without
|
||||
* waiting for a refetch. ``newActionId`` is the id of the new
|
||||
* ``is_revert_action`` row the server inserted; pass ``null`` if the
|
||||
* server didn't return it.
|
||||
*/
|
||||
export function markActionRevertedInCache(
|
||||
queryClient: QueryClient,
|
||||
threadId: number,
|
||||
id: number,
|
||||
newActionId: number | null
|
||||
): void {
|
||||
queryClient.setQueryData<AgentActionListResponse>(agentActionsQueryKey(threadId), (prev) => {
|
||||
if (!prev) return prev;
|
||||
let mutated = false;
|
||||
const items = prev.items.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
mutated = true;
|
||||
// ``-1`` is a sentinel meaning "we know it was reverted
|
||||
// but the server didn't tell us the new row's id".
|
||||
return {
|
||||
...a,
|
||||
reverted_by_action_id: newActionId ?? -1,
|
||||
};
|
||||
});
|
||||
return mutated ? { ...prev, items } : prev;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a batch of revert results (per-turn revert response) to the
|
||||
* cache. Anything in the ``reverted`` / ``already_reverted`` buckets
|
||||
* gets its ``reverted_by_action_id`` set; other rows are left alone.
|
||||
*/
|
||||
export function applyRevertTurnResultsToCache(
|
||||
queryClient: QueryClient,
|
||||
threadId: number,
|
||||
entries: Array<{ id: number; newActionId: number | null }>
|
||||
): void {
|
||||
if (entries.length === 0) return;
|
||||
queryClient.setQueryData<AgentActionListResponse>(agentActionsQueryKey(threadId), (prev) => {
|
||||
if (!prev) return prev;
|
||||
const lookup = new Map(entries.map((e) => [e.id, e.newActionId]));
|
||||
let mutated = false;
|
||||
const items = prev.items.map((a) => {
|
||||
if (!lookup.has(a.id)) return a;
|
||||
mutated = true;
|
||||
const newActionId = lookup.get(a.id) ?? null;
|
||||
return { ...a, reverted_by_action_id: newActionId ?? -1 };
|
||||
});
|
||||
return mutated ? { ...prev, items } : prev;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-side hook used by the card, the turn button, the sheet, and
|
||||
* the edit-from-position pre-flight.
|
||||
*
|
||||
* Returns the raw query state plus convenience selectors so consumers
|
||||
* don't reach into ``data.items`` directly. ``enabled`` is the only
|
||||
* knob — pass ``false`` to keep the query dormant when the consumer
|
||||
* doesn't yet have a thread id.
|
||||
*/
|
||||
export function useAgentActionsQuery(threadId: number | null, options: { enabled?: boolean } = {}) {
|
||||
const enabled = (options.enabled ?? true) && threadId !== null;
|
||||
const query = useQuery({
|
||||
queryKey: agentActionsQueryKey(threadId),
|
||||
queryFn: async () => {
|
||||
dbg("useAgentActionsQuery: REST fetch START", {
|
||||
threadId,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
});
|
||||
const res = await agentActionsApiService.listForThread(threadId as number, {
|
||||
page: 0,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
});
|
||||
dbg("useAgentActionsQuery: REST fetch DONE", {
|
||||
threadId,
|
||||
total: res.total,
|
||||
returned: res.items.length,
|
||||
items: res.items.map((a) => ({
|
||||
id: a.id,
|
||||
tool_name: a.tool_name,
|
||||
tool_call_id: a.tool_call_id,
|
||||
reversible: a.reversible,
|
||||
reverted_by_action_id: a.reverted_by_action_id,
|
||||
is_revert_action: a.is_revert_action,
|
||||
})),
|
||||
});
|
||||
return res;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
|
||||
const items = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
|
||||
// Index ``items`` once per change so the lookups below are O(1)
|
||||
// instead of O(N) per card per render. With the cache sized to 200
|
||||
// rows and many tool cards visible at once, the unindexed scan was
|
||||
// the hottest path on every assistant text-delta. (Vercel React
|
||||
// rule ``js-index-maps`` / ``js-set-map-lookups``.)
|
||||
const byToolCallId = useMemo(() => {
|
||||
const m = new Map<string, AgentAction>();
|
||||
for (const a of items) {
|
||||
if (a.tool_call_id) m.set(a.tool_call_id, a);
|
||||
}
|
||||
return m;
|
||||
}, [items]);
|
||||
|
||||
// Pre-grouped + pre-sorted (oldest-first, the order the agent
|
||||
// actually executed them in) so the (chat_turn_id, tool_name,
|
||||
// position) fallback in ``tool-fallback.tsx`` is also O(1) per
|
||||
// card. Excludes ``is_revert_action`` rows so the position index
|
||||
// matches the agent's original execution order.
|
||||
const byTurnAndTool = useMemo(() => {
|
||||
const m = new Map<string, AgentAction[]>();
|
||||
for (const a of items) {
|
||||
if (!a.chat_turn_id || a.is_revert_action) continue;
|
||||
const key = `${a.chat_turn_id}::${a.tool_name}`;
|
||||
const bucket = m.get(key);
|
||||
if (bucket) bucket.push(a);
|
||||
else m.set(key, [a]);
|
||||
}
|
||||
for (const bucket of m.values()) {
|
||||
bucket.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
}
|
||||
return m;
|
||||
}, [items]);
|
||||
|
||||
// Snapshot the cache shape when its size changes — easiest way to
|
||||
// spot when the cache is empty or stale at the moment a card
|
||||
// mounts. Tracked on a ref so we don't re-run the diff on
|
||||
// reference-equal cache reads.
|
||||
const lastSnapshotRef = useRef<{ threadId: number | null; size: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const last = lastSnapshotRef.current;
|
||||
if (!last || last.threadId !== threadId || last.size !== items.length) {
|
||||
dbg("useAgentActionsQuery: cache snapshot", {
|
||||
threadId,
|
||||
enabled,
|
||||
itemCount: items.length,
|
||||
itemKeys: items.slice(0, 8).map((a) => ({
|
||||
id: a.id,
|
||||
tool_name: a.tool_name,
|
||||
tool_call_id: a.tool_call_id,
|
||||
chat_turn_id: a.chat_turn_id,
|
||||
reversible: a.reversible,
|
||||
})),
|
||||
});
|
||||
lastSnapshotRef.current = { threadId, size: items.length };
|
||||
}
|
||||
}, [threadId, enabled, items]);
|
||||
|
||||
const findByToolCallId = useCallback(
|
||||
(toolCallId: string | null | undefined): AgentAction | null => {
|
||||
if (!toolCallId) return null;
|
||||
const found = byToolCallId.get(toolCallId) ?? null;
|
||||
if (!found && items.length > 0) {
|
||||
dbg("findByToolCallId: MISS", {
|
||||
queriedToolCallId: toolCallId,
|
||||
itemCount: items.length,
|
||||
availableToolCallIds: Array.from(byToolCallId.keys()),
|
||||
});
|
||||
}
|
||||
return found;
|
||||
},
|
||||
[byToolCallId, items.length]
|
||||
);
|
||||
|
||||
const findByChatTurnId = useCallback(
|
||||
(chatTurnId: string | null | undefined): AgentAction[] => {
|
||||
if (!chatTurnId) return [];
|
||||
// Per-turn aggregation is uncommon enough (only the
|
||||
// "Revert turn" button uses it) that re-scanning is fine;
|
||||
// indexing it would just bloat memory.
|
||||
return items.filter((a) => a.chat_turn_id === chatTurnId);
|
||||
},
|
||||
[items]
|
||||
);
|
||||
|
||||
const findByChatTurnAndTool = useCallback(
|
||||
(chatTurnId: string | null | undefined, toolName: string | null | undefined): AgentAction[] => {
|
||||
if (!chatTurnId || !toolName) return [];
|
||||
return byTurnAndTool.get(`${chatTurnId}::${toolName}`) ?? [];
|
||||
},
|
||||
[byTurnAndTool]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
items,
|
||||
findByToolCallId,
|
||||
findByChatTurnId,
|
||||
findByChatTurnAndTool,
|
||||
};
|
||||
}
|
||||
|
|
@ -31,6 +31,14 @@ export function useMessagesSync(
|
|||
content: msg.content,
|
||||
author_id: msg.authorId ?? null,
|
||||
created_at: new Date(msg.createdAt).toISOString(),
|
||||
// Forward the per-turn correlation id so post-stream Zero
|
||||
// re-syncs preserve ``metadata.custom.chatTurnId`` on the
|
||||
// converted ``ThreadMessageLike``. Without this the inline
|
||||
// Revert button's ``(chat_turn_id, tool_name, position)``
|
||||
// fallback breaks the moment Zero overwrites the messages
|
||||
// state after a live stream completes (see
|
||||
// ``handleSyncedMessagesUpdate`` in the chat page).
|
||||
turn_id: msg.turnId ?? null,
|
||||
}));
|
||||
|
||||
onMessagesUpdateRef.current(mapped);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type DeleteDocumentRequest,
|
||||
deleteDocumentRequest,
|
||||
deleteDocumentResponse,
|
||||
documentTitleRead,
|
||||
type GetDocumentByChunkRequest,
|
||||
type GetDocumentChunksRequest,
|
||||
type GetDocumentRequest,
|
||||
|
|
@ -269,6 +270,17 @@ class DocumentsApiService {
|
|||
);
|
||||
};
|
||||
|
||||
getDocumentByVirtualPath = async (request: { search_space_id: number; virtual_path: string }) => {
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: String(request.search_space_id),
|
||||
virtual_path: request.virtual_path,
|
||||
});
|
||||
return baseApiService.get(
|
||||
`/api/v1/documents/by-virtual-path?${params.toString()}`,
|
||||
documentTitleRead
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get document type counts
|
||||
*/
|
||||
|
|
|
|||
305
surfsense_web/lib/chat/chat-error-classifier.ts
Normal file
305
surfsense_web/lib/chat/chat-error-classifier.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
export type ChatFlow = "new" | "resume" | "regenerate";
|
||||
|
||||
export type ChatErrorKind =
|
||||
| "premium_quota_exhausted"
|
||||
| "thread_busy"
|
||||
| "send_failed_pre_accept"
|
||||
| "auth_expired"
|
||||
| "rate_limited"
|
||||
| "network_offline"
|
||||
| "stream_interrupted"
|
||||
| "stream_parse_error"
|
||||
| "tool_execution_error"
|
||||
| "persist_message_failed"
|
||||
| "server_error"
|
||||
| "unknown";
|
||||
|
||||
export type ChatErrorChannel = "pinned_inline" | "toast" | "silent";
|
||||
export type ChatTelemetryEvent = "chat_blocked" | "chat_error";
|
||||
export type ChatErrorSeverity = "info" | "warn" | "error";
|
||||
|
||||
export interface NormalizedChatError {
|
||||
kind: ChatErrorKind;
|
||||
channel: ChatErrorChannel;
|
||||
severity: ChatErrorSeverity;
|
||||
telemetryEvent: ChatTelemetryEvent;
|
||||
isExpected: boolean;
|
||||
userMessage: string;
|
||||
assistantMessage?: string;
|
||||
rawMessage?: string;
|
||||
errorCode?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RawChatErrorInput {
|
||||
error: unknown;
|
||||
flow: ChatFlow;
|
||||
context?: {
|
||||
searchSpaceId?: number;
|
||||
threadId?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const PREMIUM_QUOTA_ASSISTANT_MESSAGE =
|
||||
"I can’t continue with the current premium model because your premium tokens are exhausted. Switch to a free model or buy more tokens to continue.";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(
|
||||
error: unknown,
|
||||
parsedJson: Record<string, unknown> | null
|
||||
): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
if (withCode.errorCode) return withCode.errorCode;
|
||||
if (withCode.code) return withCode.code;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const withCode = error as { errorCode?: unknown };
|
||||
if (typeof withCode.errorCode === "string" && withCode.errorCode) {
|
||||
return withCode.errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
const topLevelCode = parsedJson.errorCode;
|
||||
if (typeof topLevelCode === "string" && topLevelCode) {
|
||||
return topLevelCode;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseEmbeddedJson(text: string): Record<string, unknown> | null {
|
||||
const candidates = [text];
|
||||
const firstBraceIdx = text.indexOf("{");
|
||||
if (firstBraceIdx >= 0) {
|
||||
candidates.push(text.slice(firstBraceIdx));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferProviderErrorType(parsedJson: Record<string, unknown> | null): string | undefined {
|
||||
if (!parsedJson) return undefined;
|
||||
const topLevelType = parsedJson.type;
|
||||
if (typeof topLevelType === "string" && topLevelType) return topLevelType;
|
||||
const nestedError = parsedJson.error;
|
||||
if (typeof nestedError === "object" && nestedError !== null) {
|
||||
const nestedType = (nestedError as Record<string, unknown>).type;
|
||||
if (typeof nestedType === "string" && nestedType) return nestedType;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function classifyChatError(input: RawChatErrorInput): NormalizedChatError {
|
||||
const { error } = input;
|
||||
const rawMessage = getErrorMessage(error);
|
||||
const parsedJson = parseEmbeddedJson(rawMessage);
|
||||
const errorCode = getErrorCode(error, parsedJson);
|
||||
const providerErrorType = inferProviderErrorType(parsedJson);
|
||||
const providerTypeNormalized = providerErrorType?.toLowerCase() ?? "";
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
|
||||
if (errorName === "AbortError") {
|
||||
return {
|
||||
kind: "stream_interrupted",
|
||||
channel: "silent",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Request canceled.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {
|
||||
return {
|
||||
kind: "premium_quota_exhausted",
|
||||
channel: "pinned_inline",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Buy more tokens to continue with this model, or switch to a free model.",
|
||||
assistantMessage: PREMIUM_QUOTA_ASSISTANT_MESSAGE,
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PREMIUM_QUOTA_EXHAUSTED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TURN_CANCELLING") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "A previous response is still stopping. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TURN_CANCELLING",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "THREAD_BUSY") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"Another response is still finishing for this thread. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "THREAD_BUSY",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SEND_FAILED_PRE_ACCEPT") {
|
||||
return {
|
||||
kind: "send_failed_pre_accept",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Message not sent. Please retry.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SEND_FAILED_PRE_ACCEPT",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "AUTH_EXPIRED" || errorCode === "UNAUTHORIZED") {
|
||||
return {
|
||||
kind: "auth_expired",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Your session expired. Please sign in again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "AUTH_EXPIRED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "RATE_LIMITED" || providerTypeNormalized === "rate_limit_error") {
|
||||
return {
|
||||
kind: "rate_limited",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"This model is temporarily rate-limited. Please try again in a few seconds or switch models.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "RATE_LIMITED",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "NETWORK_ERROR") {
|
||||
return {
|
||||
kind: "network_offline",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Connection issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "NETWORK_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "STREAM_PARSE_ERROR") {
|
||||
return {
|
||||
kind: "stream_parse_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We hit a response formatting issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "STREAM_PARSE_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TOOL_EXECUTION_ERROR") {
|
||||
return {
|
||||
kind: "tool_execution_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "A tool failed while processing your request. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TOOL_EXECUTION_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PERSIST_MESSAGE_FAILED") {
|
||||
return {
|
||||
kind: "persist_message_failed",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "Response generated, but saving failed. Please retry once.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PERSIST_MESSAGE_FAILED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SERVER_ERROR") {
|
||||
return {
|
||||
kind: "server_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SERVER_ERROR",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unknown",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export async function toHttpResponseError(
|
||||
response: Response
|
||||
): Promise<Error & { errorCode?: string; retryAfterMs?: number }> {
|
||||
const statusDefaultCode =
|
||||
response.status === 409
|
||||
? "THREAD_BUSY"
|
||||
: response.status === 429
|
||||
? "RATE_LIMITED"
|
||||
: response.status === 401 || response.status === 403
|
||||
? "AUTH_EXPIRED"
|
||||
: "SERVER_ERROR";
|
||||
|
||||
let rawBody = "";
|
||||
try {
|
||||
rawBody = await response.text();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
let parsedBody: Record<string, unknown> | null = null;
|
||||
if (rawBody) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
parsedBody = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const detail = parsedBody?.detail;
|
||||
const detailObject =
|
||||
typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : null;
|
||||
const detailMessage = typeof detail === "string" ? detail : undefined;
|
||||
const topLevelMessage =
|
||||
typeof parsedBody?.message === "string" ? (parsedBody.message as string) : undefined;
|
||||
const detailNestedMessage =
|
||||
typeof detailObject?.message === "string" ? (detailObject.message as string) : undefined;
|
||||
|
||||
const topLevelCode =
|
||||
typeof parsedBody?.errorCode === "string"
|
||||
? parsedBody.errorCode
|
||||
: typeof parsedBody?.error_code === "string"
|
||||
? parsedBody.error_code
|
||||
: undefined;
|
||||
const detailCode =
|
||||
typeof detailObject?.errorCode === "string"
|
||||
? detailObject.errorCode
|
||||
: typeof detailObject?.error_code === "string"
|
||||
? detailObject.error_code
|
||||
: undefined;
|
||||
|
||||
const errorCode = detailCode ?? topLevelCode ?? statusDefaultCode;
|
||||
|
||||
const detailRetryAfterMs =
|
||||
typeof detailObject?.retry_after_ms === "number"
|
||||
? detailObject.retry_after_ms
|
||||
: typeof detailObject?.retryAfterMs === "number"
|
||||
? detailObject.retryAfterMs
|
||||
: undefined;
|
||||
const topRetryAfterMs =
|
||||
typeof parsedBody?.retry_after_ms === "number"
|
||||
? parsedBody.retry_after_ms
|
||||
: typeof parsedBody?.retryAfterMs === "number"
|
||||
? parsedBody.retryAfterMs
|
||||
: undefined;
|
||||
const headerRetryAfterMsRaw = response.headers.get("retry-after-ms");
|
||||
const headerRetryAfterMs = headerRetryAfterMsRaw ? Number.parseFloat(headerRetryAfterMsRaw) : NaN;
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfterSeconds = retryAfterHeader ? Number.parseFloat(retryAfterHeader) : NaN;
|
||||
const retryAfterMsFromHeader = Number.isFinite(headerRetryAfterMs)
|
||||
? Math.max(0, Math.round(headerRetryAfterMs))
|
||||
: Number.isFinite(retryAfterSeconds)
|
||||
? Math.max(0, Math.round(retryAfterSeconds * 1000))
|
||||
: undefined;
|
||||
const retryAfterMs = detailRetryAfterMs ?? topRetryAfterMs ?? retryAfterMsFromHeader ?? undefined;
|
||||
const message =
|
||||
detailNestedMessage ?? detailMessage ?? topLevelMessage ?? `Backend error: ${response.status}`;
|
||||
|
||||
return Object.assign(new Error(message), { errorCode, retryAfterMs });
|
||||
}
|
||||
|
||||
export function tagPreAcceptSendFailure(error: unknown): unknown {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
const existingCode = withCode.errorCode ?? withCode.code;
|
||||
const passthroughCodes = new Set([
|
||||
"PREMIUM_QUOTA_EXHAUSTED",
|
||||
"THREAD_BUSY",
|
||||
"TURN_CANCELLING",
|
||||
"AUTH_EXPIRED",
|
||||
"UNAUTHORIZED",
|
||||
"RATE_LIMITED",
|
||||
"NETWORK_ERROR",
|
||||
"STREAM_PARSE_ERROR",
|
||||
"TOOL_EXECUTION_ERROR",
|
||||
"PERSIST_MESSAGE_FAILED",
|
||||
"SERVER_ERROR",
|
||||
]);
|
||||
if (existingCode && passthroughCodes.has(existingCode)) {
|
||||
return Object.assign(error, { errorCode: existingCode });
|
||||
}
|
||||
return Object.assign(error, { errorCode: "SEND_FAILED_PRE_ACCEPT" });
|
||||
}
|
||||
|
||||
return Object.assign(new Error("Failed to send message before stream acceptance"), {
|
||||
errorCode: "SEND_FAILED_PRE_ACCEPT",
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
|
||||
export type MentionSegment =
|
||||
| { type: "text"; value: string; start: number }
|
||||
| { type: "mention"; doc: MentionedDocumentInfo; start: number };
|
||||
|
||||
/**
|
||||
* Tokenizes a user message into text and `@mention` segments.
|
||||
*
|
||||
* Pure: no React, no DOM, no side effects. Safe to unit-test and reuse.
|
||||
*
|
||||
* Mentions are matched greedily by longest title first so that a longer title
|
||||
* (e.g. `@Project Roadmap`) is never shadowed by a shorter prefix
|
||||
* (e.g. `@Project`).
|
||||
*/
|
||||
export function parseMentionSegments(
|
||||
text: string,
|
||||
docs: ReadonlyArray<MentionedDocumentInfo>
|
||||
): MentionSegment[] {
|
||||
if (text.length === 0) return [];
|
||||
if (docs.length === 0) return [{ type: "text", value: text, start: 0 }];
|
||||
|
||||
const tokens = docs
|
||||
.map((doc) => ({ doc, token: `@${doc.title}` }))
|
||||
.sort((a, b) => b.token.length - a.token.length);
|
||||
|
||||
const segments: MentionSegment[] = [];
|
||||
let i = 0;
|
||||
let buffer = "";
|
||||
let bufferStart = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
|
||||
if (tokenMatch) {
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
buffer = "";
|
||||
}
|
||||
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
|
||||
i += tokenMatch.token.length;
|
||||
bufferStart = i;
|
||||
continue;
|
||||
}
|
||||
if (!buffer) bufferStart = i;
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrameBatchedUpdater } from "@/lib/chat/streaming-state";
|
||||
|
||||
export function createStreamFlushHelpers(flushMessages: () => void): {
|
||||
batcher: FrameBatchedUpdater;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
} {
|
||||
const batcher = new FrameBatchedUpdater();
|
||||
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||
// Force-flush helper: ``batcher.flush()`` is a no-op when
|
||||
// ``dirty=false`` (e.g. a tool starts before any text streamed).
|
||||
// ``scheduleFlush(); batcher.flush()`` sets the dirty bit first so
|
||||
// terminal events render promptly without the throttle delay.
|
||||
const forceFlush = () => {
|
||||
scheduleFlush();
|
||||
batcher.flush();
|
||||
};
|
||||
return { batcher, scheduleFlush, forceFlush };
|
||||
}
|
||||
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
appendToolInputDelta,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
readSSEStream,
|
||||
type SSEEvent,
|
||||
type ThinkingStepData,
|
||||
type ToolUIGate,
|
||||
updateThinkingSteps,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
export type SharedStreamEventContext = {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolsWithUI: ToolUIGate;
|
||||
currentThinkingSteps: Map<string, ThinkingStepData>;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
onTokenUsage?: (data: Extract<SSEEvent, { type: "data-token-usage" }>["data"]) => void;
|
||||
onTurnStatus?: (data: Extract<SSEEvent, { type: "data-turn-status" }>["data"]) => void;
|
||||
onToolOutputAvailable?: (
|
||||
event: Extract<SSEEvent, { type: "tool-output-available" }>,
|
||||
context: {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolCallIndices: Map<string, number>;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a tool produces output, mark any previously-decided interrupt tool
|
||||
* calls as completed so the ApprovalCard can transition from shimmer to done.
|
||||
*/
|
||||
export function markInterruptsCompleted(
|
||||
contentParts: Array<{ type: string; result?: unknown }>
|
||||
): void {
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
(part.result as Record<string, unknown>).__interrupt__ === true &&
|
||||
(part.result as Record<string, unknown>).__decided__ &&
|
||||
!(part.result as Record<string, unknown>).__completed__
|
||||
) {
|
||||
part.result = { ...(part.result as Record<string, unknown>), __completed__: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistableContent(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
toolsWithUI: ToolUIGate
|
||||
) {
|
||||
return contentParts.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text.length > 0) ||
|
||||
(part.type === "reasoning" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" && (toolsWithUI === "all" || toolsWithUI.has(part.toolName)))
|
||||
);
|
||||
}
|
||||
|
||||
function toStreamTerminalError(
|
||||
event: Extract<SSEEvent, { type: "error" }>
|
||||
): Error & { errorCode?: string } {
|
||||
return Object.assign(new Error(event.errorText || "Server error"), {
|
||||
errorCode: event.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
export function processSharedStreamEvent(
|
||||
parsed: SSEEvent,
|
||||
context: SharedStreamEventContext
|
||||
): boolean {
|
||||
const { contentPartsState, toolsWithUI, currentThinkingSteps, scheduleFlush, forceFlush } =
|
||||
context;
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
appendText(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "finish-step":
|
||||
return true;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-delta":
|
||||
// High-frequency event: deltas can fire dozens of times per call,
|
||||
// so use throttled scheduleFlush (NOT forceFlush) to coalesce.
|
||||
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-available": {
|
||||
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
// addToolCall doesn't accept argsText today; backfill via
|
||||
// updateToolCall so the new card renders pretty-printed JSON.
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
argsText: finalArgsText,
|
||||
});
|
||||
}
|
||||
forceFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "data-thinking-step": {
|
||||
const stepData = parsed.data as ThinkingStepData;
|
||||
if (stepData?.id) {
|
||||
currentThinkingSteps.set(stepData.id, stepData);
|
||||
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||
if (didUpdate) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
context.onTokenUsage?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "data-turn-status":
|
||||
context.onTurnStatus?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "error":
|
||||
throw toStreamTerminalError(parsed);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeSseEvents(
|
||||
response: Response,
|
||||
onEvent: (event: SSEEvent) => void | Promise<void>
|
||||
): Promise<void> {
|
||||
for await (const parsed of readSSEStream(response)) {
|
||||
await onEvent(parsed);
|
||||
}
|
||||
}
|
||||
123
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
123
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import {
|
||||
addToolCall,
|
||||
type ContentPartsState,
|
||||
type ToolUIGate,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
type InterruptActionRequest = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EditedInterruptAction = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readInterruptActions(interruptData: Record<string, unknown>): InterruptActionRequest[] {
|
||||
return (interruptData.action_requests ?? []) as InterruptActionRequest[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an interrupt request payload to tool-call parts. Existing tool cards
|
||||
* are updated in-place; missing ones are upserted so approval UI always shows.
|
||||
*/
|
||||
export function applyInterruptRequestToContentParts(
|
||||
contentPartsState: ContentPartsState,
|
||||
toolsWithUI: ToolUIGate,
|
||||
interruptData: Record<string, unknown>
|
||||
): void {
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
const actionRequests = readInterruptActions(interruptData);
|
||||
for (const action of actionRequests) {
|
||||
const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||
const part = contentParts[idx];
|
||||
return part?.type === "tool-call" && part.toolName === action.name;
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
updateToolCall(contentPartsState, existingEntry[0], {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
} else {
|
||||
const toolCallId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, toolCallId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeEditedInterruptAction(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
editedAction: EditedInterruptAction | undefined
|
||||
): void {
|
||||
if (!editedAction) return;
|
||||
for (const part of contentParts) {
|
||||
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
||||
const mergedArgs = { ...part.args, ...editedAction.args };
|
||||
part.args = mergedArgs;
|
||||
// assistant-ui prefers argsText over JSON.stringify(args)
|
||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markInterruptDecisionOnContentParts(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
decisionType: "approve" | "reject" | undefined
|
||||
): void {
|
||||
if (!decisionType) return;
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
"__interrupt__" in (part.result as Record<string, unknown>)
|
||||
) {
|
||||
part.result = {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decisionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a streamed message is persisted, the backend returns the durable
|
||||
* turn_id; merge it into assistant-ui metadata for turn-scoped actions.
|
||||
*/
|
||||
export function mergeChatTurnIdIntoMessage(
|
||||
msg: ThreadMessageLike,
|
||||
turnId: string | null | undefined
|
||||
): ThreadMessageLike {
|
||||
if (!turnId) return msg;
|
||||
const existingMeta = (msg.metadata ?? {}) as { custom?: Record<string, unknown> };
|
||||
const existingCustom = existingMeta.custom ?? {};
|
||||
if ((existingCustom as { chatTurnId?: string }).chatTurnId === turnId) return msg;
|
||||
return {
|
||||
...msg,
|
||||
metadata: {
|
||||
...existingMeta,
|
||||
custom: { ...existingCustom, chatTurnId: turnId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readStreamedChatTurnId(data: unknown): string | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const value = (data as { chat_turn_id?: unknown }).chat_turn_id;
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function applyTurnIdToAssistantMessageList(
|
||||
messages: ThreadMessageLike[],
|
||||
assistantMsgId: string,
|
||||
turnId: string
|
||||
): ThreadMessageLike[] {
|
||||
return messages.map((m) => (m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, turnId) : m));
|
||||
}
|
||||
|
|
@ -9,21 +9,59 @@ 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;
|
||||
/**
|
||||
* Live / finalized JSON text for the tool's input arguments.
|
||||
*
|
||||
* - During streaming: accumulated partial JSON text from
|
||||
* ``tool-input-delta`` events (may be invalid JSON
|
||||
* mid-stream). assistant-ui's argsText parser tolerates
|
||||
* invalid JSON gracefully (changelog 0.7.32 / 0.7.78).
|
||||
* - On completion (``tool-input-available``): replaced with
|
||||
* ``JSON.stringify(input, null, 2)`` so the post-stream
|
||||
* card renders pretty-printed JSON instead of the
|
||||
* model's possibly-fragmented formatting.
|
||||
*
|
||||
* Per assistant-ui ``ThreadMessageLike`` precedence
|
||||
* (changelog 0.11.6 ``d318c83``), when ``argsText`` is
|
||||
* supplied it wins over ``JSON.stringify(args)``.
|
||||
*/
|
||||
argsText?: string;
|
||||
/**
|
||||
* 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 +112,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 +172,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,39 +190,161 @@ 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>;
|
||||
argsText?: string;
|
||||
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;
|
||||
// ``!== undefined`` (NOT a truthy check): an explicit empty
|
||||
// string CAN clear, and a finalization with
|
||||
// ``JSON.stringify({}, null, 2) === "{}"`` (truthy but
|
||||
// represents an empty-input call) still applies.
|
||||
if (update.argsText !== undefined) tc.argsText = update.argsText;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a streamed args-delta chunk to the active tool call's
|
||||
* ``argsText``. No-ops when no card has been registered yet for the
|
||||
* given ``toolCallId`` (the matching ``tool-input-start`` either lost
|
||||
* the wire race or this id never had a card — either way the deltas
|
||||
* have nowhere safe to land).
|
||||
*/
|
||||
export function appendToolInputDelta(
|
||||
state: ContentPartsState,
|
||||
toolCallId: string,
|
||||
delta: string
|
||||
): void {
|
||||
const idx = state.toolCallIndices.get(toolCallId);
|
||||
if (idx === undefined) return;
|
||||
const tc = state.contentParts[idx];
|
||||
if (tc?.type !== "tool-call") return;
|
||||
tc.argsText = (tc.argsText ?? "") + delta;
|
||||
}
|
||||
|
||||
function _hasInterruptResult(part: ContentPart): boolean {
|
||||
if (part.type !== "tool-call") return false;
|
||||
const r = (part as { result?: unknown }).result;
|
||||
|
|
@ -184,13 +353,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 +371,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 +400,142 @@ 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;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
type: "tool-input-delta";
|
||||
toolCallId: string;
|
||||
inputTextDelta: 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-turn-status";
|
||||
data: {
|
||||
status: "idle" | "busy" | "cancelling";
|
||||
retry_after_ms?: number;
|
||||
retry_after_at?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-token-usage";
|
||||
data: {
|
||||
|
|
@ -256,7 +554,7 @@ export type SSEEvent =
|
|||
}>;
|
||||
};
|
||||
}
|
||||
| { type: "error"; errorText: string };
|
||||
| { type: "error"; errorText: string; errorCode?: string };
|
||||
|
||||
/**
|
||||
* Async generator that reads an SSE stream and yields parsed JSON objects.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
130
surfsense_web/lib/citations/citation-parser.ts
Normal file
130
surfsense_web/lib/citations/citation-parser.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// Pure citation parsing for `[citation:...]` tokens emitted by SurfSense
|
||||
// agents. No React imports — consumed by both the React renderer
|
||||
// (markdown surfaces) and the Plate value transform (document viewer).
|
||||
//
|
||||
// The same logic previously lived inline in
|
||||
// `components/assistant-ui/markdown-text.tsx` with module-level mutable
|
||||
// state. This module exposes a per-call URL map so multiple concurrent
|
||||
// renderers / SSR contexts can't race each other.
|
||||
|
||||
import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
|
||||
|
||||
/**
|
||||
* Matches `[citation:...]` with numeric IDs (incl. negative, doc- prefix,
|
||||
* comma-separated), URL-based IDs from live web search, or `urlciteN`
|
||||
* placeholders produced by `preprocessCitationMarkdown`.
|
||||
*
|
||||
* Also matches Chinese brackets 【】 and zero-width spaces that LLMs
|
||||
* sometimes emit.
|
||||
*/
|
||||
export const CITATION_REGEX =
|
||||
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
|
||||
|
||||
/** A single parsed citation reference. */
|
||||
export type CitationToken =
|
||||
| { kind: "url"; url: string }
|
||||
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
|
||||
|
||||
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
|
||||
export type ParsedSegment = string | CitationToken;
|
||||
|
||||
/** Per-call URL placeholder map; key is `urlciteN`, value is the original URL. */
|
||||
export type CitationUrlMap = Map<string, string>;
|
||||
|
||||
/** Result of preprocessing raw markdown for downstream parsing. */
|
||||
export interface PreprocessedCitations {
|
||||
/** Markdown with `[citation:URL]` tokens rewritten to `[citation:urlciteN]`. */
|
||||
content: string;
|
||||
/** Lookup table to recover the original URL from each placeholder. */
|
||||
urlMap: CitationUrlMap;
|
||||
}
|
||||
|
||||
/** Pattern matching only URL-form citations (used during preprocessing). */
|
||||
const URL_CITATION_REGEX = /[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+)\s*\u200B?[\]】]/g;
|
||||
|
||||
/**
|
||||
* Replace `[citation:URL]` tokens with `[citation:urlciteN]` placeholders so
|
||||
* GFM autolinks don't split the URL out of the brackets during markdown
|
||||
* parsing. Returns both the rewritten content and a map for later lookup.
|
||||
*
|
||||
* Code-fence aware: skips fenced (``` ``` ```) and inline (`` ` ``) code
|
||||
* regions so citation-shaped strings inside example code remain literal.
|
||||
*
|
||||
* Known limitations: `~~~` fences, 4-space indented code, and LaTeX math
|
||||
* blocks are not skipped. Citation tokens inside those regions are rare in
|
||||
* practice; documented in the plan.
|
||||
*/
|
||||
export function preprocessCitationMarkdown(content: string): PreprocessedCitations {
|
||||
const urlMap: CitationUrlMap = new Map();
|
||||
let counter = 0;
|
||||
|
||||
// Splitting on a regex with one capture group puts code regions at odd
|
||||
// indexes (matched delimiters) and the surrounding text at even indexes.
|
||||
// Only transform the even-indexed parts.
|
||||
const parts = content.split(FENCED_OR_INLINE_CODE);
|
||||
const transformed = parts.map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(URL_CITATION_REGEX, (_match, url: string) => {
|
||||
const key = `urlcite${counter++}`;
|
||||
urlMap.set(key, url.trim());
|
||||
return `[citation:${key}]`;
|
||||
});
|
||||
});
|
||||
|
||||
return { content: transformed.join(""), urlMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string into an array of plain text segments and citation tokens.
|
||||
*
|
||||
* Pure data — no React. The renderer module is responsible for mapping
|
||||
* tokens to JSX. Negative chunk IDs are forwarded as-is so the consumer
|
||||
* can decide how to render anonymous documents.
|
||||
*/
|
||||
export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
CITATION_REGEX.lastIndex = 0;
|
||||
match = CITATION_REGEX.exec(text);
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const captured = match[1];
|
||||
|
||||
if (captured.startsWith("http://") || captured.startsWith("https://")) {
|
||||
segments.push({ kind: "url", url: captured.trim() });
|
||||
} else if (captured.startsWith("urlcite")) {
|
||||
const url = urlMap.get(captured);
|
||||
if (url) {
|
||||
segments.push({ kind: "url", url });
|
||||
}
|
||||
} else {
|
||||
const rawIds = captured.split(",").map((s) => s.trim());
|
||||
for (const rawId of rawIds) {
|
||||
const isDocsChunk = rawId.startsWith("doc-");
|
||||
const chunkId = Number.parseInt(isDocsChunk ? rawId.slice(4) : rawId, 10);
|
||||
if (!Number.isNaN(chunkId)) {
|
||||
segments.push({ kind: "chunk", chunkId, isDocsChunk });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
match = CITATION_REGEX.exec(text);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [text];
|
||||
}
|
||||
|
||||
/** Type guard for the citation branch of `ParsedSegment`. */
|
||||
export function isCitationToken(segment: ParsedSegment): segment is CitationToken {
|
||||
return typeof segment !== "string";
|
||||
}
|
||||
8
surfsense_web/lib/markdown/code-regions.ts
Normal file
8
surfsense_web/lib/markdown/code-regions.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Matches fenced (```...```) and inline (`...`) code regions. Used by MDX
|
||||
// escaping and citation preprocessing — single source of truth so future
|
||||
// edits stay in sync.
|
||||
//
|
||||
// String.split() with this capturing pattern places non-code parts at even
|
||||
// indexes and matched code regions at odd indexes — preserve odd-indexed
|
||||
// segments verbatim when transforming markdown.
|
||||
export const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import posthog from "posthog-js";
|
||||
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
|
||||
import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
|
||||
|
||||
/**
|
||||
* PostHog Analytics Event Definitions
|
||||
|
|
@ -139,6 +140,55 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st
|
|||
});
|
||||
}
|
||||
|
||||
export interface ChatFailureTelemetry {
|
||||
flow: ChatFlow;
|
||||
kind: ChatErrorKind;
|
||||
error_code?: string;
|
||||
severity: ChatErrorSeverity;
|
||||
is_expected: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function trackChatBlocked(
|
||||
searchSpaceId: number,
|
||||
chatId: number | null,
|
||||
payload: ChatFailureTelemetry
|
||||
) {
|
||||
safeCapture(
|
||||
"chat_blocked",
|
||||
compact({
|
||||
search_space_id: searchSpaceId,
|
||||
chat_id: chatId ?? undefined,
|
||||
flow: payload.flow,
|
||||
kind: payload.kind,
|
||||
error_code: payload.error_code,
|
||||
severity: payload.severity,
|
||||
is_expected: payload.is_expected,
|
||||
message: payload.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function trackChatErrorDetailed(
|
||||
searchSpaceId: number,
|
||||
chatId: number | null,
|
||||
payload: ChatFailureTelemetry
|
||||
) {
|
||||
safeCapture(
|
||||
"chat_error",
|
||||
compact({
|
||||
search_space_id: searchSpaceId,
|
||||
chat_id: chatId ?? undefined,
|
||||
flow: payload.flow,
|
||||
kind: payload.kind,
|
||||
error_code: payload.error_code,
|
||||
severity: payload.severity,
|
||||
is_expected: payload.is_expected,
|
||||
message: payload.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a message sent from the unauthenticated "free" / anonymous chat
|
||||
* flow. This is intentionally a separate event from `chat_message_sent`
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { chatSessionQueries, commentQueries, messageQueries } from "./chat";
|
|||
import { connectorQueries, documentQueries } from "./documents";
|
||||
import { folderQueries } from "./folders";
|
||||
import { notificationQueries } from "./inbox";
|
||||
import { userQueries } from "./user";
|
||||
|
||||
export const queries = defineQueries({
|
||||
notifications: notificationQueries,
|
||||
|
|
@ -12,4 +13,5 @@ export const queries = defineQueries({
|
|||
messages: messageQueries,
|
||||
comments: commentQueries,
|
||||
chatSession: chatSessionQueries,
|
||||
user: userQueries,
|
||||
});
|
||||
|
|
|
|||
11
surfsense_web/zero/queries/user.ts
Normal file
11
surfsense_web/zero/queries/user.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineQuery } from "@rocicorp/zero";
|
||||
import { z } from "zod";
|
||||
import { zql } from "../schema/index";
|
||||
|
||||
export const userQueries = {
|
||||
me: defineQuery(z.object({}), ({ ctx }) => {
|
||||
const userId = ctx?.userId;
|
||||
if (!userId) return zql.user.where("id", "__none__").one();
|
||||
return zql.user.where("id", userId).one();
|
||||
}),
|
||||
};
|
||||
|
|
@ -8,6 +8,13 @@ export const newChatMessageTable = table("new_chat_messages")
|
|||
threadId: number().from("thread_id"),
|
||||
authorId: string().optional().from("author_id"),
|
||||
createdAt: number().from("created_at"),
|
||||
// Per-turn correlation id sourced from ``configurable.turn_id``
|
||||
// at streaming time. Required by the inline Revert button's
|
||||
// (chat_turn_id, tool_name, position) fallback in tool-fallback.tsx
|
||||
// — without it the live-collab Zero sync would clobber the
|
||||
// metadata we set during streaming and the button would vanish
|
||||
// the moment Zero re-syncs after the stream finishes.
|
||||
turnId: string().optional().from("turn_id"),
|
||||
})
|
||||
.primaryKey("id");
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { chatCommentTable, chatSessionStateTable, newChatMessageTable } from "./
|
|||
import { documentTable, searchSourceConnectorTable } from "./documents";
|
||||
import { folderTable } from "./folders";
|
||||
import { notificationTable } from "./inbox";
|
||||
import { userTable } from "./user";
|
||||
|
||||
const chatCommentRelationships = relationships(chatCommentTable, ({ one }) => ({
|
||||
message: one({
|
||||
|
|
@ -34,6 +35,7 @@ export const schema = createSchema({
|
|||
newChatMessageTable,
|
||||
chatCommentTable,
|
||||
chatSessionStateTable,
|
||||
userTable,
|
||||
],
|
||||
relationships: [chatCommentRelationships, newChatMessageRelationships],
|
||||
});
|
||||
|
|
|
|||
11
surfsense_web/zero/schema/user.ts
Normal file
11
surfsense_web/zero/schema/user.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { number, string, table } from "@rocicorp/zero";
|
||||
|
||||
export const userTable = table("user")
|
||||
.columns({
|
||||
id: string(),
|
||||
pagesLimit: number().from("pages_limit"),
|
||||
pagesUsed: number().from("pages_used"),
|
||||
premiumTokensLimit: number().from("premium_tokens_limit"),
|
||||
premiumTokensUsed: number().from("premium_tokens_used"),
|
||||
})
|
||||
.primaryKey("id");
|
||||
Loading…
Add table
Add a link
Reference in a new issue