mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 06:12:40 +02:00
Merge upstream/dev into feature/multi-agent
This commit is contained in:
commit
5119915f4f
278 changed files with 34669 additions and 8970 deletions
|
|
@ -127,7 +127,7 @@ const FAQ_ITEMS = [
|
|||
{
|
||||
question: "What happens after I use my free tokens?",
|
||||
answer:
|
||||
"After your free tokens, create a free SurfSense account to unlock 3 million more premium tokens. Additional tokens can be purchased at $1 per million. Non-premium models remain unlimited for registered users.",
|
||||
"After your free tokens, create a free SurfSense account to unlock $5 of premium credit. Additional credit can be topped up at $1 for $1 of credit, billed at the actual provider cost. Non-premium models remain unlimited for registered users.",
|
||||
},
|
||||
{
|
||||
question: "Is Claude AI available without login?",
|
||||
|
|
@ -329,7 +329,7 @@ export default async function FreeHubPage() {
|
|||
<section className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-2xl font-bold mb-3">Want More Features?</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
Create a free SurfSense account to unlock 3 million tokens, document uploads with
|
||||
Create a free SurfSense account to unlock $5 of premium credit, document uploads with
|
||||
citations, team collaboration, and integrations with Slack, Google Drive, Notion, and
|
||||
30+ more tools.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
|||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Search Plans",
|
||||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & 3M premium tokens. Use ChatGPT, Claude AI, and premium AI models. Pay-as-you-go tokens at $1 per million.",
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/pricing",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
const TABS = [
|
||||
{ id: "pages", label: "Pages" },
|
||||
{ id: "tokens", label: "Premium Tokens" },
|
||||
{ id: "tokens", label: "Premium Credit" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
|
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -178,6 +178,19 @@ const FLAG_GROUPS: FlagGroup[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "desktop",
|
||||
title: "Desktop",
|
||||
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_desktop_local_filesystem",
|
||||
label: "Local filesystem",
|
||||
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
|
||||
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ type UnifiedPurchase = {
|
|||
kind: PurchaseKind;
|
||||
created_at: string;
|
||||
status: PagePurchaseStatus;
|
||||
/**
|
||||
* Granted units. Interpretation depends on ``kind``:
|
||||
* - ``"pages"`` — integer number of indexed pages.
|
||||
* - ``"tokens"`` — integer micro-USD of credit (1_000_000 = $1.00).
|
||||
* The ``Granted`` column formats accordingly.
|
||||
*/
|
||||
granted: number;
|
||||
amount_total: number | null;
|
||||
currency: string | null;
|
||||
|
|
@ -58,7 +64,7 @@ const KIND_META: Record<
|
|||
iconClass: "text-sky-500",
|
||||
},
|
||||
tokens: {
|
||||
label: "Premium Tokens",
|
||||
label: "Premium Credit",
|
||||
icon: Coins,
|
||||
iconClass: "text-amber-500",
|
||||
},
|
||||
|
|
@ -97,12 +103,25 @@ function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase {
|
|||
kind: "tokens",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.tokens_granted,
|
||||
granted: p.credit_micros_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function formatGranted(p: UnifiedPurchase): string {
|
||||
if (p.kind === "tokens") {
|
||||
const dollars = p.granted / 1_000_000;
|
||||
// Premium credit packs are always whole dollars at the moment, but
|
||||
// future fractional grants (refunds, partial top-ups) shouldn't
|
||||
// silently round to "$0".
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
|
||||
return "$0 of credit";
|
||||
}
|
||||
return p.granted.toLocaleString();
|
||||
}
|
||||
|
||||
export function PurchaseHistoryContent() {
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
|
|
@ -143,7 +162,7 @@ export function PurchaseHistoryContent() {
|
|||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page and premium token purchases will appear here after checkout.
|
||||
Your page and premium credit purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -177,7 +196,7 @@ export function PurchaseHistoryContent() {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.granted.toLocaleString()}
|
||||
{formatGranted(p)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
|
|
|
|||
|
|
@ -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_credit_micros_*)
|
||||
// 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,10 +17,7 @@ import {
|
|||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
agentActionsQueryKey,
|
||||
useAgentActionsQuery,
|
||||
} from "@/hooks/use-agent-actions-query";
|
||||
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
function EmptyState() {
|
||||
|
|
|
|||
|
|
@ -400,6 +400,19 @@ function formatMessageDate(date: Date): string {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider USD cost (in micro-USD) for inline display next to a
|
||||
* token count. Falls back to ``"<$0.001"`` for sub-tenth-of-a-cent
|
||||
* costs so a real-but-tiny figure doesn't render as ``$0.000``.
|
||||
*/
|
||||
function formatTurnCost(micros: number): string {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars >= 0.01) return `$${dollars.toFixed(3)}`;
|
||||
if (dollars > 0) return "<$0.001";
|
||||
return "$0";
|
||||
}
|
||||
|
||||
const MessageInfoDropdown: FC = () => {
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||
|
|
@ -452,6 +465,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
{models.length > 0 ? (
|
||||
models.map(([model, counts]) => {
|
||||
const { name, icon } = resolveModel(model);
|
||||
const costMicros = counts.cost_micros;
|
||||
return (
|
||||
<ActionBarMorePrimitive.Item
|
||||
key={model}
|
||||
|
|
@ -464,6 +478,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{counts.total_tokens.toLocaleString()} tokens
|
||||
{costMicros && costMicros > 0 ? ` · ${formatTurnCost(costMicros)}` : ""}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
);
|
||||
|
|
@ -475,6 +490,9 @@ const MessageInfoDropdown: FC = () => {
|
|||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{usage.total_tokens.toLocaleString()} tokens
|
||||
{usage.cost_micros && usage.cost_micros > 0
|
||||
? ` · ${formatTurnCost(usage.cost_micros)}`
|
||||
: ""}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
)}
|
||||
|
|
@ -555,8 +573,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>
|
||||
);
|
||||
|
|
@ -649,35 +669,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 && (
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
|
|
@ -9,6 +9,16 @@
|
|||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"JIRA_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
},
|
||||
"CONFLUENCE_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
|
|
|
|||
|
|
@ -105,14 +105,14 @@ export const OAUTH_CONNECTORS = [
|
|||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "Search, read, and manage issues",
|
||||
description: "Rework in progress.",
|
||||
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/mcp/jira/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "Search documentation",
|
||||
description: "Rework in progress.",
|
||||
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/confluence/connector/add/",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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 {
|
||||
|
|
@ -90,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";
|
||||
|
||||
|
|
@ -109,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 />
|
||||
|
|
@ -125,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-3xl 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -374,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>();
|
||||
|
|
@ -589,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;
|
||||
|
|
@ -601,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,
|
||||
|
|
@ -653,7 +605,6 @@ const Composer: FC = () => {
|
|||
clipboardInitialText,
|
||||
aui,
|
||||
setMentionedDocuments,
|
||||
threadViewportStore,
|
||||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
|
|
|
|||
|
|
@ -13,13 +13,30 @@ export interface TokenUsageData {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
/**
|
||||
* Total provider USD cost for this assistant turn, in micro-USD
|
||||
* (1_000_000 = $1.00). Populated from LiteLLM's response_cost on
|
||||
* the backend. Optional because pre-cost-credits messages persisted
|
||||
* before the migration won't have it.
|
||||
*/
|
||||
cost_micros?: number;
|
||||
usage?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/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,
|
||||
|
|
@ -31,10 +29,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
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 { 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";
|
||||
|
|
@ -123,8 +118,7 @@ function ToolCardRevertButton({
|
|||
// 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);
|
||||
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
|
||||
|
|
@ -159,12 +153,7 @@ function ToolCardRevertButton({
|
|||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
markActionRevertedInCache(
|
||||
queryClient,
|
||||
threadId,
|
||||
action.id,
|
||||
response.new_action_id ?? null
|
||||
);
|
||||
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
|
||||
|
|
@ -475,7 +464,7 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
|||
{(argsText || isRunning) && (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
<div className="max-h-48 overflow-auto rounded-md bg-muted/40">
|
||||
<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}
|
||||
|
|
@ -489,7 +478,7 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
|||
Waiting for input…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</NestedScroll>
|
||||
</div>
|
||||
)}
|
||||
{!isCancelled && result !== undefined && (
|
||||
|
|
@ -497,11 +486,11 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
|||
<Separator />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Result</p>
|
||||
<div className="max-h-64 overflow-auto rounded-md bg-muted/40">
|
||||
<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>
|
||||
</div>
|
||||
</NestedScroll>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -78,11 +103,7 @@ export const UserMessage: FC = () => {
|
|||
<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-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
{mentionedDocs && mentionedDocs.length > 0 ? (
|
||||
<UserMessageWithMentionChips text={messageText} mentionedDocs={mentionedDocs} />
|
||||
) : (
|
||||
<MessagePrimitive.Parts />
|
||||
)}
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -55,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 : "";
|
||||
|
|
@ -124,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>();
|
||||
|
|
@ -244,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();
|
||||
|
|
@ -334,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
|
||||
|
|
@ -393,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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function QuotaWarningBanner({
|
|||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
You've used all {limit.toLocaleString()} free tokens. Create a free account to
|
||||
get 3 million tokens and access to all models.
|
||||
get $5 of premium credit and access to all models.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
|
|
@ -69,7 +69,7 @@ export function QuotaWarningBanner({
|
|||
<Link href="/register" className="font-medium underline hover:no-underline">
|
||||
Create an account
|
||||
</Link>{" "}
|
||||
for 5M free tokens.
|
||||
for $5 of premium credit.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ function MainContentPanel({
|
|||
const isDocumentTab = activeTab?.type === "document";
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col min-w-0">
|
||||
<div className="relative isolate flex flex-1 flex-col min-w-0">
|
||||
<TabBar
|
||||
onTabSwitch={onTabSwitch}
|
||||
onNewChat={onNewChat}
|
||||
|
|
|
|||
|
|
@ -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,8 @@ 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 { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.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 +73,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";
|
||||
|
|
@ -199,6 +198,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const { data: agentFlags } = useAtomValue(agentFlagsAtom);
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
|
||||
|
|
@ -211,6 +211,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true;
|
||||
const isElectron =
|
||||
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
|
|
@ -1038,9 +1039,12 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
|
||||
|
||||
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
|
||||
const showFilesystemTabs =
|
||||
!isMobile && !!electronAPI && !!filesystemSettings && localFilesystemEnabled;
|
||||
const currentFilesystemTab =
|
||||
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
localFilesystemEnabled && filesystemSettings?.mode === "desktop_local_folder"
|
||||
? "local"
|
||||
: "cloud";
|
||||
const showCloudSkeleton =
|
||||
currentFilesystemTab === "cloud" &&
|
||||
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
|
||||
|
|
|
|||
|
|
@ -1,38 +1,45 @@
|
|||
"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";
|
||||
|
||||
/**
|
||||
* Premium credit balance shown in the sidebar.
|
||||
*
|
||||
* Values come from Zero (live-replicated from Postgres) and are stored as
|
||||
* integer micro-USD (1_000_000 == $1.00). We render in dollars because
|
||||
* users top up at $1/pack and the credit gets debited at actual provider
|
||||
* cost.
|
||||
*/
|
||||
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.premiumCreditMicrosUsed / Math.max(me.premiumCreditMicrosLimit, 1)) * 100,
|
||||
100
|
||||
);
|
||||
|
||||
const formatTokens = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return n.toLocaleString();
|
||||
const formatUsd = (micros: number) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
// Sub-dollar balances need extra precision so the bar still tells the
|
||||
// user what's left ("$0.04 of credit") instead of rounding to "$0".
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
{formatUsd(me.premiumCreditMicrosUsed)} / {formatUsd(me.premiumCreditMicrosLimit)} of
|
||||
credit
|
||||
</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";
|
||||
|
|
@ -338,9 +338,7 @@ function SidebarUsageFooter({
|
|||
return (
|
||||
<div className="px-3 py-3 border-t 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>
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import type React from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
|
|
@ -236,6 +237,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 {
|
||||
|
|
@ -374,6 +462,18 @@ export function ModelSelector({
|
|||
const { data: visionUserConfigs, isLoading: visionUserLoading } =
|
||||
useAtomValue(visionLLMConfigsAtom);
|
||||
|
||||
// Pending image attachments on the composer. Used to surface an
|
||||
// amber "No image" hint on chat models the catalog reports as
|
||||
// non-vision (`supports_image_input=false`) when the next message
|
||||
// will carry an image. The hint is purely advisory: selection,
|
||||
// focus, and click handling are unaffected. The backend's safety
|
||||
// net (`is_known_text_only_chat_model`) is the actual block, and
|
||||
// it only fires when LiteLLM *explicitly* marks a model as
|
||||
// text-only — so a model that's secretly capable but hasn't been
|
||||
// annotated will still flow through to the provider.
|
||||
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
const hasPendingImages = pendingUserImageUrls.length > 0;
|
||||
|
||||
const isLoading =
|
||||
llmUserLoading ||
|
||||
llmGlobalLoading ||
|
||||
|
|
@ -897,6 +997,21 @@ export function ModelSelector({
|
|||
const isSelected = getSelectedId() === config.id;
|
||||
const isFocused = focusedIndex === index;
|
||||
const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
|
||||
// Chat-tab only: surface an amber "No image" hint when the
|
||||
// composer carries images and the catalog reports the model as
|
||||
// non-vision. This is purely advisory — selection is *not*
|
||||
// blocked. The backend's narrow safety net
|
||||
// (`is_known_text_only_chat_model`) is the source of truth for
|
||||
// rejecting image turns, and it only fires when LiteLLM
|
||||
// explicitly marks the model as text-only. A model surfaced as
|
||||
// `supports_image_input=false` here may still be capable in
|
||||
// practice (unknown / unmapped LiteLLM entry), so we let the
|
||||
// user pick it and the provider response decide.
|
||||
const isImageIncompatibleChatModel =
|
||||
activeTab === "llm" &&
|
||||
hasPendingImages &&
|
||||
"supports_image_input" in config &&
|
||||
(config as Record<string, unknown>).supports_image_input === false;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -905,6 +1020,11 @@ export function ModelSelector({
|
|||
role="option"
|
||||
tabIndex={isMobile ? -1 : 0}
|
||||
aria-selected={isSelected}
|
||||
title={
|
||||
isImageIncompatibleChatModel
|
||||
? "This model is reported as text-only. You can still pick it; the provider may reject image turns."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
onKeyDown={
|
||||
isMobile
|
||||
|
|
@ -918,9 +1038,8 @@ export function ModelSelector({
|
|||
}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
||||
"transition-all duration-150 mx-2",
|
||||
"hover:bg-accent/40",
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl",
|
||||
"transition-all duration-150 mx-2 cursor-pointer hover:bg-accent/40",
|
||||
isSelected && "bg-primary/6 dark:bg-primary/8",
|
||||
isFocused && "bg-accent/50"
|
||||
)}
|
||||
|
|
@ -936,7 +1055,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"
|
||||
|
|
@ -962,6 +1085,14 @@ export function ModelSelector({
|
|||
Free
|
||||
</Badge>
|
||||
) : null}
|
||||
{isImageIncompatibleChatModel && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300 border-0"
|
||||
>
|
||||
No image
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
|
|
|
|||
|
|
@ -12,12 +12,11 @@ const demoPlans = [
|
|||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "500 pages + 3M premium tokens included",
|
||||
billingText: "500 pages + $5 in premium credits included",
|
||||
features: [
|
||||
"Self Hostable",
|
||||
"500 pages included to start",
|
||||
"3 million premium tokens to start",
|
||||
"Earn up to 3,000+ bonus pages for free",
|
||||
"$5 in premium credits for paid AI models and premium AI features",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
|
|
@ -35,8 +34,7 @@ const demoPlans = [
|
|||
billingText: "No subscription, buy only when you need more",
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"Buy 1,000-page packs at $1 each",
|
||||
"Buy 1M premium token packs at $1 each",
|
||||
"Buy 1,000-page packs or $1 in premium credits at $1 each",
|
||||
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
|
|
@ -90,7 +88,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "What are Basic and Premium processing modes?",
|
||||
answer:
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. Premium costs 10 page credits per page but delivers significantly higher fidelity output for these specialized document types.",
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium processing mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. It costs 10 page credits per page and does not use your premium AI credits.",
|
||||
},
|
||||
{
|
||||
question: "How does the Pay As You Go plan work?",
|
||||
|
|
@ -130,27 +128,32 @@ const faqData: FAQSection[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: "Premium Tokens",
|
||||
title: "Premium Credits",
|
||||
items: [
|
||||
{
|
||||
question: 'What are "premium tokens"?',
|
||||
question: 'What are "premium credits"?',
|
||||
answer:
|
||||
"Premium tokens are the billing unit for using premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro in SurfSense. Each AI request consumes tokens based on the length of your conversation. Non-premium models (such as free-tier models available without login) do not consume premium tokens.",
|
||||
"Premium credits are your USD balance for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.",
|
||||
},
|
||||
{
|
||||
question: "How many premium tokens do I get for free?",
|
||||
question: "How many premium credits do I get for free?",
|
||||
answer:
|
||||
"Every registered SurfSense account starts with 3 million premium tokens at no cost. Anonymous users (no login) get 500,000 free tokens across all models. Once your free tokens are used up, you can purchase more at any time.",
|
||||
"Every registered SurfSense account starts with $5 in premium credits at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included premium credits run out, you can top up at any time.",
|
||||
},
|
||||
{
|
||||
question: "How does purchasing premium tokens work?",
|
||||
question: "How does buying premium credits work?",
|
||||
answer:
|
||||
"Just like pages, there's no subscription. You buy 1-million-token packs at $1 each whenever you need more. Purchased tokens are added to your account immediately. You can buy up to 100 packs at a time.",
|
||||
"Premium credit top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately. You can buy up to $100 at a time.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium tokens?",
|
||||
question: "Are premium credits the same as page credits?",
|
||||
answer:
|
||||
"When your premium token balance runs low (below 20%), you'll see a warning. Once you run out, premium model requests are paused until you purchase more tokens. You can always switch to non-premium models which don't consume premium tokens.",
|
||||
"No. Page credits pay for document indexing and file-based connector processing. Premium credits pay for paid AI usage, such as premium model chats and premium AI generation features. Premium document processing mode sounds similar, but it consumes page credits, not premium credits.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium credits?",
|
||||
answer:
|
||||
"When your premium credit balance runs low, you'll see a warning. Once you run out, paid model requests and premium AI features are paused until you top up. You can still use non-premium models and features that do not consume premium credits.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -158,9 +161,9 @@ const faqData: FAQSection[] = [
|
|||
title: "Self-Hosting",
|
||||
items: [
|
||||
{
|
||||
question: "Can I self-host SurfSense with unlimited pages and tokens?",
|
||||
question: "Can I self-host SurfSense with unlimited pages and credit?",
|
||||
answer:
|
||||
"Yes! When self-hosting, you have full control over your page and token limits. The default self-hosted setup gives you effectively unlimited pages and tokens, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
"Yes! When self-hosting, you have full control over your page and premium credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credits, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -251,8 +254,8 @@ function PricingFAQ() {
|
|||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything you need to know about SurfSense pages, premium tokens, and billing. Can't
|
||||
find what you need? Reach out at{" "}
|
||||
Everything you need to know about SurfSense pages, premium credits, and billing.
|
||||
Can't find what you need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
|
|
@ -336,7 +339,7 @@ function PricingBasic() {
|
|||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 500 pages & 3M premium tokens. Pay as you go."
|
||||
description="Start free with 500 pages & $5 in premium credits. Pay as you go."
|
||||
/>
|
||||
<PricingFAQ />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -45,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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,21 +12,39 @@ 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;
|
||||
// One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the
|
||||
// backend. Premium turns are debited at the actual provider cost
|
||||
// reported by LiteLLM, so $1 of credit always buys $1 of provider
|
||||
// usage at cost.
|
||||
const CREDIT_PER_PACK_MICROS = 1_000_000;
|
||||
const PRICE_PER_PACK_USD = 1;
|
||||
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
|
||||
|
||||
const formatUsd = (micros: number, options?: { compact?: boolean }) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (options?.compact && dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
};
|
||||
|
||||
export function BuyTokensContent() {
|
||||
const params = useParams();
|
||||
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 balance via Zero.
|
||||
const [me] = useZeroQuery(queries.user.me({}));
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: stripeApiService.createTokenCheckoutSession,
|
||||
onSuccess: (response) => {
|
||||
|
|
@ -40,46 +59,46 @@ export function BuyTokensContent() {
|
|||
},
|
||||
});
|
||||
|
||||
const totalTokens = quantity * TOKEN_PACK_SIZE;
|
||||
const totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS;
|
||||
const totalPrice = quantity * PRICE_PER_PACK_USD;
|
||||
|
||||
if (tokenStatus && !tokenStatus.token_buying_enabled) {
|
||||
return (
|
||||
<div className="w-full space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token purchases are temporarily unavailable.
|
||||
Credit purchases are temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercentage = tokenStatus
|
||||
? Math.min(
|
||||
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
|
||||
100
|
||||
)
|
||||
: 0;
|
||||
const used = me?.premiumCreditMicrosUsed ?? 0;
|
||||
const limit = me?.premiumCreditMicrosLimit ?? 0;
|
||||
// Mirrors the backend formula in stripe_routes.py (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">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">$1 per 1M tokens, pay as you go</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
$1 buys $1 of credit, billed at provider cost
|
||||
</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
|
||||
{formatUsd(used)} / {formatUsd(limit)} of credit
|
||||
</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
|
||||
{formatUsd(remaining)} of credit remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -95,7 +114,7 @@ export function BuyTokensContent() {
|
|||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="min-w-32 text-center text-lg font-semibold tabular-nums">
|
||||
{(totalTokens / 1_000_000).toFixed(0)}M tokens
|
||||
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -121,14 +140,14 @@ export function BuyTokensContent() {
|
|||
: "border-border hover:border-purple-500/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{m}M
|
||||
${m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{(totalTokens / 1_000_000).toFixed(0)}M premium tokens
|
||||
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
|
||||
</div>
|
||||
|
|
@ -145,7 +164,7 @@ export function BuyTokensContent() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
Buy {(totalTokens / 1_000_000).toFixed(0)}M Tokens for ${totalPrice}
|
||||
Buy ${(totalCreditMicros / 1_000_000).toFixed(0)} of credit for ${totalPrice}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -190,12 +191,98 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Image Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
|
||||
Global Image Models
|
||||
</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="border-border/60 bg-muted/20 overflow-hidden h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -390,6 +371,17 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</SelectLabel>
|
||||
{roleGlobalConfigs.map((config) => {
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
// Read billing_tier from the global config; default to "free"
|
||||
// for legacy YAMLs / Auto stub. Premium gets a purple badge,
|
||||
// free gets an emerald one — same palette as the chat
|
||||
// model selector so the meaning is consistent across
|
||||
// surfaces (issues E, H).
|
||||
const billingTier =
|
||||
("billing_tier" in config &&
|
||||
typeof config.billing_tier === "string" &&
|
||||
config.billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
|
|
@ -401,13 +393,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
{isAuto && (
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -78,36 +70,9 @@ export function MorePagesContent() {
|
|||
<div className="w-full space-y-5">
|
||||
<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
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">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 +147,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 +161,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} />,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -191,12 +192,98 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Vision Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
|
||||
Global Vision Models
|
||||
</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="border-border/60 bg-muted/20 overflow-hidden h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -416,9 +416,19 @@ export const GeneratePodcastToolUI = ({
|
|||
return <PodcastErrorState title={title} error={result.error || "Generation failed"} />;
|
||||
}
|
||||
|
||||
// Already generating - show simple warning, don't create another poller
|
||||
// The FIRST tool call will display the podcast when ready
|
||||
// (new: "generating", legacy: "already_generating")
|
||||
// Pending/generating rows have a stable podcast_id, so the card can poll
|
||||
// independently while the chat stream finishes.
|
||||
if (
|
||||
(result.status === "pending" ||
|
||||
result.status === "generating" ||
|
||||
result.status === "processing") &&
|
||||
result.podcast_id
|
||||
) {
|
||||
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// Legacy duplicate/no-ID result - show a simple warning, don't create
|
||||
// another poller. The first tool call will display the podcast when ready.
|
||||
if (result.status === "generating" || result.status === "already_generating") {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
|
|
@ -432,11 +442,6 @@ export const GeneratePodcastToolUI = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Pending - poll for completion (new: "pending" with podcast_id)
|
||||
if (result.status === "pending" && result.podcast_id) {
|
||||
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// Ready with podcast_id (new: "ready", legacy: "success")
|
||||
if ((result.status === "ready" || result.status === "success") && result.podcast_id) {
|
||||
return <PodcastPlayer podcastId={result.podcast_id} title={result.title || title} />;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function LoginGateProvider({ children }: { children: ReactNode }) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create a free account to {feature}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get 3 million tokens, save chat history, upload documents, use all AI tools, and
|
||||
Get $5 of premium credit, save chat history, upload documents, use all AI tools, and
|
||||
connect 30+ integrations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ export const newLLMConfig = z.object({
|
|||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
|
||||
// Capability flag — derived server-side at the route boundary from
|
||||
// LiteLLM's authoritative model map. There is no DB column. Default
|
||||
// `true` is the conservative-allow stance for unknown / unmapped
|
||||
// BYOK rows; the streaming-task safety net is the only place a
|
||||
// `false` actually blocks a request.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -74,11 +81,16 @@ export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
|
|||
|
||||
/**
|
||||
* Create NewLLMConfig
|
||||
*
|
||||
* `supports_image_input` is omitted because it is derived server-side
|
||||
* from LiteLLM's model map at read time — there is no DB column to
|
||||
* persist a client-supplied value into.
|
||||
*/
|
||||
export const createNewLLMConfigRequest = newLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
supports_image_input: true,
|
||||
});
|
||||
|
||||
export const createNewLLMConfigResponse = newLLMConfig;
|
||||
|
|
@ -114,6 +126,8 @@ export const updateNewLLMConfigRequest = z.object({
|
|||
created_at: true,
|
||||
search_space_id: true,
|
||||
user_id: true,
|
||||
// Derived server-side; not part of the writable surface.
|
||||
supports_image_input: true,
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
|
@ -172,6 +186,16 @@ export const globalNewLLMConfig = z.object({
|
|||
seo_title: z.string().nullable().optional(),
|
||||
seo_description: z.string().nullable().optional(),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
// Capability flag — true when the model can accept image inputs.
|
||||
// Resolved server-side (OpenRouter dynamic configs use the OR
|
||||
// `architecture.input_modalities` field; YAML / BYOK use LiteLLM's
|
||||
// authoritative `supports_vision` map). The chat selector renders
|
||||
// an amber "No image" hint when this is false and there are
|
||||
// pending image attachments, but does not block selection — the
|
||||
// backend safety net only rejects when LiteLLM *explicitly* marks
|
||||
// the model as text-only, so unknown / new models still flow
|
||||
// through. Default `true` matches that conservative-allow stance.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);
|
||||
|
|
@ -258,6 +282,11 @@ export const globalImageGenConfig = z.object({
|
|||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for image-gen too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_micros: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
||||
|
|
@ -338,6 +367,13 @@ export const globalVisionLLMConfig = z.object({
|
|||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for vision too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
input_cost_per_token: z.number().nullable().optional(),
|
||||
output_cost_per_token: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const getPagePurchasesResponse = z.object({
|
|||
purchases: z.array(pagePurchase),
|
||||
});
|
||||
|
||||
// Premium token purchases
|
||||
// Premium credit purchases
|
||||
export const createTokenCheckoutSessionRequest = z.object({
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
search_space_id: z.number().int().min(1),
|
||||
|
|
@ -42,11 +42,16 @@ export const createTokenCheckoutSessionResponse = z.object({
|
|||
checkout_url: z.string(),
|
||||
});
|
||||
|
||||
// Premium credit balance + purchase records.
|
||||
//
|
||||
// The unit is integer micro-USD (1_000_000 == $1.00). The schema names
|
||||
// kept the ``Token`` prefix for API back-compat with pinned clients;
|
||||
// the field names below are authoritative.
|
||||
export const tokenStripeStatusResponse = z.object({
|
||||
token_buying_enabled: z.boolean(),
|
||||
premium_tokens_used: z.number().default(0),
|
||||
premium_tokens_limit: z.number().default(0),
|
||||
premium_tokens_remaining: z.number().default(0),
|
||||
premium_credit_micros_used: z.number().default(0),
|
||||
premium_credit_micros_limit: z.number().default(0),
|
||||
premium_credit_micros_remaining: z.number().default(0),
|
||||
});
|
||||
|
||||
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum;
|
||||
|
|
@ -56,7 +61,7 @@ export const tokenPurchase = z.object({
|
|||
stripe_checkout_session_id: z.string(),
|
||||
stripe_payment_intent_id: z.string().nullable(),
|
||||
quantity: z.number(),
|
||||
tokens_granted: z.number(),
|
||||
credit_micros_granted: z.number(),
|
||||
amount_total: z.number().nullable(),
|
||||
currency: z.string().nullable(),
|
||||
status: tokenPurchaseStatusEnum,
|
||||
|
|
|
|||
|
|
@ -88,71 +88,68 @@ export function applyActionLogSse(
|
|||
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.
|
||||
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 {
|
||||
...prev,
|
||||
items: [placeholder, ...prev.items],
|
||||
total: prev.total + 1,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -170,33 +167,30 @@ export function applyActionLogUpdatedSse(
|
|||
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 };
|
||||
queryClient.setQueryData<AgentActionListResponse>(agentActionsQueryKey(threadId), (prev) => {
|
||||
if (!prev) {
|
||||
dbg("applyActionLogUpdatedSse: NO prev cache for thread; flip dropped", {
|
||||
threadId,
|
||||
id,
|
||||
});
|
||||
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;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -214,24 +208,21 @@ export function markActionRevertedInCache(
|
|||
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;
|
||||
}
|
||||
);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -245,21 +236,18 @@ export function applyRevertTurnResultsToCache(
|
|||
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;
|
||||
}
|
||||
);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -271,10 +259,7 @@ export function applyRevertTurnResultsToCache(
|
|||
* 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 } = {}
|
||||
) {
|
||||
export function useAgentActionsQuery(threadId: number | null, options: { enabled?: boolean } = {}) {
|
||||
const enabled = (options.enabled ?? true) && threadId !== null;
|
||||
const query = useQuery({
|
||||
queryKey: agentActionsQueryKey(threadId),
|
||||
|
|
@ -336,10 +321,7 @@ export function useAgentActionsQuery(
|
|||
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()
|
||||
);
|
||||
bucket.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
}
|
||||
return m;
|
||||
}, [items]);
|
||||
|
|
@ -396,10 +378,7 @@ export function useAgentActionsQuery(
|
|||
);
|
||||
|
||||
const findByChatTurnAndTool = useCallback(
|
||||
(
|
||||
chatTurnId: string | null | undefined,
|
||||
toolName: string | null | undefined
|
||||
): AgentAction[] => {
|
||||
(chatTurnId: string | null | undefined, toolName: string | null | undefined): AgentAction[] => {
|
||||
if (!chatTurnId || !toolName) return [];
|
||||
return byTurnAndTool.get(`${chatTurnId}::${toolName}`) ?? [];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export interface AgentFilesystemSelection {
|
|||
local_filesystem_mounts?: AgentFilesystemMountSelection[];
|
||||
}
|
||||
|
||||
export interface AgentFilesystemSelectionOptions {
|
||||
localFilesystemEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: AgentFilesystemSelection = {
|
||||
filesystem_mode: "cloud",
|
||||
client_platform: "web",
|
||||
|
|
@ -23,10 +27,15 @@ export function getClientPlatform(): ClientPlatform {
|
|||
}
|
||||
|
||||
export async function getAgentFilesystemSelection(
|
||||
searchSpaceId?: number | null
|
||||
searchSpaceId?: number | null,
|
||||
options?: AgentFilesystemSelectionOptions
|
||||
): Promise<AgentFilesystemSelection> {
|
||||
const platform = getClientPlatform();
|
||||
if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) {
|
||||
if (
|
||||
platform !== "desktop" ||
|
||||
!options?.localFilesystemEnabled ||
|
||||
!window.electronAPI?.getAgentFilesystemSettings
|
||||
) {
|
||||
return { ...DEFAULT_SELECTION, client_platform: platform };
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const AgentFeatureFlagsSchema = z.object({
|
|||
enable_plugin_loader: z.boolean(),
|
||||
|
||||
enable_otel: z.boolean(),
|
||||
|
||||
enable_desktop_local_filesystem: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentFeatureFlags = z.infer<typeof AgentFeatureFlagsSchema>;
|
||||
|
|
|
|||
|
|
@ -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 credit is exhausted. Switch to a free model or top up your credit to continue.";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCode(
|
||||
error: unknown,
|
||||
parsedJson: Record<string, unknown> | null
|
||||
): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
if (withCode.errorCode) return withCode.errorCode;
|
||||
if (withCode.code) return withCode.code;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const withCode = error as { errorCode?: unknown };
|
||||
if (typeof withCode.errorCode === "string" && withCode.errorCode) {
|
||||
return withCode.errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
const topLevelCode = parsedJson.errorCode;
|
||||
if (typeof topLevelCode === "string" && topLevelCode) {
|
||||
return topLevelCode;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseEmbeddedJson(text: string): Record<string, unknown> | null {
|
||||
const candidates = [text];
|
||||
const firstBraceIdx = text.indexOf("{");
|
||||
if (firstBraceIdx >= 0) {
|
||||
candidates.push(text.slice(firstBraceIdx));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferProviderErrorType(parsedJson: Record<string, unknown> | null): string | undefined {
|
||||
if (!parsedJson) return undefined;
|
||||
const topLevelType = parsedJson.type;
|
||||
if (typeof topLevelType === "string" && topLevelType) return topLevelType;
|
||||
const nestedError = parsedJson.error;
|
||||
if (typeof nestedError === "object" && nestedError !== null) {
|
||||
const nestedType = (nestedError as Record<string, unknown>).type;
|
||||
if (typeof nestedType === "string" && nestedType) return nestedType;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function classifyChatError(input: RawChatErrorInput): NormalizedChatError {
|
||||
const { error } = input;
|
||||
const rawMessage = getErrorMessage(error);
|
||||
const parsedJson = parseEmbeddedJson(rawMessage);
|
||||
const errorCode = getErrorCode(error, parsedJson);
|
||||
const providerErrorType = inferProviderErrorType(parsedJson);
|
||||
const providerTypeNormalized = providerErrorType?.toLowerCase() ?? "";
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
|
||||
if (errorName === "AbortError") {
|
||||
return {
|
||||
kind: "stream_interrupted",
|
||||
channel: "silent",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Request canceled.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {
|
||||
return {
|
||||
kind: "premium_quota_exhausted",
|
||||
channel: "pinned_inline",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Buy more tokens to continue with this model, or switch to a free model.",
|
||||
assistantMessage: PREMIUM_QUOTA_ASSISTANT_MESSAGE,
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PREMIUM_QUOTA_EXHAUSTED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TURN_CANCELLING") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "info",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "A previous response is still stopping. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TURN_CANCELLING",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "THREAD_BUSY") {
|
||||
return {
|
||||
kind: "thread_busy",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"Another response is still finishing for this thread. Please try again in a moment.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "THREAD_BUSY",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SEND_FAILED_PRE_ACCEPT") {
|
||||
return {
|
||||
kind: "send_failed_pre_accept",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage: "Message not sent. Please retry.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SEND_FAILED_PRE_ACCEPT",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "AUTH_EXPIRED" || errorCode === "UNAUTHORIZED") {
|
||||
return {
|
||||
kind: "auth_expired",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Your session expired. Please sign in again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "AUTH_EXPIRED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "RATE_LIMITED" || providerTypeNormalized === "rate_limit_error") {
|
||||
return {
|
||||
kind: "rate_limited",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_blocked",
|
||||
isExpected: true,
|
||||
userMessage:
|
||||
"This model is temporarily rate-limited. Please try again in a few seconds or switch models.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "RATE_LIMITED",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "NETWORK_ERROR") {
|
||||
return {
|
||||
kind: "network_offline",
|
||||
channel: "toast",
|
||||
severity: "warn",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: true,
|
||||
userMessage: "Connection issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "NETWORK_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "STREAM_PARSE_ERROR") {
|
||||
return {
|
||||
kind: "stream_parse_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We hit a response formatting issue. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "STREAM_PARSE_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "TOOL_EXECUTION_ERROR") {
|
||||
return {
|
||||
kind: "tool_execution_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "A tool failed while processing your request. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "TOOL_EXECUTION_ERROR",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "PERSIST_MESSAGE_FAILED") {
|
||||
return {
|
||||
kind: "persist_message_failed",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "Response generated, but saving failed. Please retry once.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "PERSIST_MESSAGE_FAILED",
|
||||
details: { flow: input.flow },
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === "SERVER_ERROR") {
|
||||
return {
|
||||
kind: "server_error",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode: errorCode ?? "SERVER_ERROR",
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unknown",
|
||||
channel: "toast",
|
||||
severity: "error",
|
||||
telemetryEvent: "chat_error",
|
||||
isExpected: false,
|
||||
userMessage: "We couldn’t complete this response right now. Please try again.",
|
||||
rawMessage,
|
||||
errorCode,
|
||||
details: { flow: input.flow, providerErrorType },
|
||||
};
|
||||
}
|
||||
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
110
surfsense_web/lib/chat/chat-request-errors.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export async function toHttpResponseError(
|
||||
response: Response
|
||||
): Promise<Error & { errorCode?: string; retryAfterMs?: number }> {
|
||||
const statusDefaultCode =
|
||||
response.status === 409
|
||||
? "THREAD_BUSY"
|
||||
: response.status === 429
|
||||
? "RATE_LIMITED"
|
||||
: response.status === 401 || response.status === 403
|
||||
? "AUTH_EXPIRED"
|
||||
: "SERVER_ERROR";
|
||||
|
||||
let rawBody = "";
|
||||
try {
|
||||
rawBody = await response.text();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
let parsedBody: Record<string, unknown> | null = null;
|
||||
if (rawBody) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
parsedBody = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const detail = parsedBody?.detail;
|
||||
const detailObject =
|
||||
typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : null;
|
||||
const detailMessage = typeof detail === "string" ? detail : undefined;
|
||||
const topLevelMessage =
|
||||
typeof parsedBody?.message === "string" ? (parsedBody.message as string) : undefined;
|
||||
const detailNestedMessage =
|
||||
typeof detailObject?.message === "string" ? (detailObject.message as string) : undefined;
|
||||
|
||||
const topLevelCode =
|
||||
typeof parsedBody?.errorCode === "string"
|
||||
? parsedBody.errorCode
|
||||
: typeof parsedBody?.error_code === "string"
|
||||
? parsedBody.error_code
|
||||
: undefined;
|
||||
const detailCode =
|
||||
typeof detailObject?.errorCode === "string"
|
||||
? detailObject.errorCode
|
||||
: typeof detailObject?.error_code === "string"
|
||||
? detailObject.error_code
|
||||
: undefined;
|
||||
|
||||
const errorCode = detailCode ?? topLevelCode ?? statusDefaultCode;
|
||||
|
||||
const detailRetryAfterMs =
|
||||
typeof detailObject?.retry_after_ms === "number"
|
||||
? detailObject.retry_after_ms
|
||||
: typeof detailObject?.retryAfterMs === "number"
|
||||
? detailObject.retryAfterMs
|
||||
: undefined;
|
||||
const topRetryAfterMs =
|
||||
typeof parsedBody?.retry_after_ms === "number"
|
||||
? parsedBody.retry_after_ms
|
||||
: typeof parsedBody?.retryAfterMs === "number"
|
||||
? parsedBody.retryAfterMs
|
||||
: undefined;
|
||||
const headerRetryAfterMsRaw = response.headers.get("retry-after-ms");
|
||||
const headerRetryAfterMs = headerRetryAfterMsRaw ? Number.parseFloat(headerRetryAfterMsRaw) : NaN;
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfterSeconds = retryAfterHeader ? Number.parseFloat(retryAfterHeader) : NaN;
|
||||
const retryAfterMsFromHeader = Number.isFinite(headerRetryAfterMs)
|
||||
? Math.max(0, Math.round(headerRetryAfterMs))
|
||||
: Number.isFinite(retryAfterSeconds)
|
||||
? Math.max(0, Math.round(retryAfterSeconds * 1000))
|
||||
: undefined;
|
||||
const retryAfterMs = detailRetryAfterMs ?? topRetryAfterMs ?? retryAfterMsFromHeader ?? undefined;
|
||||
const message =
|
||||
detailNestedMessage ?? detailMessage ?? topLevelMessage ?? `Backend error: ${response.status}`;
|
||||
|
||||
return Object.assign(new Error(message), { errorCode, retryAfterMs });
|
||||
}
|
||||
|
||||
export function tagPreAcceptSendFailure(error: unknown): unknown {
|
||||
if (error instanceof Error) {
|
||||
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||
const existingCode = withCode.errorCode ?? withCode.code;
|
||||
const passthroughCodes = new Set([
|
||||
"PREMIUM_QUOTA_EXHAUSTED",
|
||||
"THREAD_BUSY",
|
||||
"TURN_CANCELLING",
|
||||
"AUTH_EXPIRED",
|
||||
"UNAUTHORIZED",
|
||||
"RATE_LIMITED",
|
||||
"NETWORK_ERROR",
|
||||
"STREAM_PARSE_ERROR",
|
||||
"TOOL_EXECUTION_ERROR",
|
||||
"PERSIST_MESSAGE_FAILED",
|
||||
"SERVER_ERROR",
|
||||
]);
|
||||
if (existingCode && passthroughCodes.has(existingCode)) {
|
||||
return Object.assign(error, { errorCode: existingCode });
|
||||
}
|
||||
return Object.assign(error, { errorCode: "SEND_FAILED_PRE_ACCEPT" });
|
||||
}
|
||||
|
||||
return Object.assign(new Error("Failed to send message before stream acceptance"), {
|
||||
errorCode: "SEND_FAILED_PRE_ACCEPT",
|
||||
});
|
||||
}
|
||||
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
54
surfsense_web/lib/chat/parse-mention-segments.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
|
||||
export type MentionSegment =
|
||||
| { type: "text"; value: string; start: number }
|
||||
| { type: "mention"; doc: MentionedDocumentInfo; start: number };
|
||||
|
||||
/**
|
||||
* Tokenizes a user message into text and `@mention` segments.
|
||||
*
|
||||
* Pure: no React, no DOM, no side effects. Safe to unit-test and reuse.
|
||||
*
|
||||
* Mentions are matched greedily by longest title first so that a longer title
|
||||
* (e.g. `@Project Roadmap`) is never shadowed by a shorter prefix
|
||||
* (e.g. `@Project`).
|
||||
*/
|
||||
export function parseMentionSegments(
|
||||
text: string,
|
||||
docs: ReadonlyArray<MentionedDocumentInfo>
|
||||
): MentionSegment[] {
|
||||
if (text.length === 0) return [];
|
||||
if (docs.length === 0) return [{ type: "text", value: text, start: 0 }];
|
||||
|
||||
const tokens = docs
|
||||
.map((doc) => ({ doc, token: `@${doc.title}` }))
|
||||
.sort((a, b) => b.token.length - a.token.length);
|
||||
|
||||
const segments: MentionSegment[] = [];
|
||||
let i = 0;
|
||||
let buffer = "";
|
||||
let bufferStart = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
|
||||
if (tokenMatch) {
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
buffer = "";
|
||||
}
|
||||
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
|
||||
i += tokenMatch.token.length;
|
||||
bufferStart = i;
|
||||
continue;
|
||||
}
|
||||
if (!buffer) bufferStart = i;
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
19
surfsense_web/lib/chat/stream-flush.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrameBatchedUpdater } from "@/lib/chat/streaming-state";
|
||||
|
||||
export function createStreamFlushHelpers(flushMessages: () => void): {
|
||||
batcher: FrameBatchedUpdater;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
} {
|
||||
const batcher = new FrameBatchedUpdater();
|
||||
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||
// Force-flush helper: ``batcher.flush()`` is a no-op when
|
||||
// ``dirty=false`` (e.g. a tool starts before any text streamed).
|
||||
// ``scheduleFlush(); batcher.flush()`` sets the dirty bit first so
|
||||
// terminal events render promptly without the throttle delay.
|
||||
const forceFlush = () => {
|
||||
scheduleFlush();
|
||||
batcher.flush();
|
||||
};
|
||||
return { batcher, scheduleFlush, forceFlush };
|
||||
}
|
||||
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
200
surfsense_web/lib/chat/stream-pipeline.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
addStepSeparator,
|
||||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
appendToolInputDelta,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
readSSEStream,
|
||||
type SSEEvent,
|
||||
type ThinkingStepData,
|
||||
type ToolUIGate,
|
||||
updateThinkingSteps,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
export type SharedStreamEventContext = {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolsWithUI: ToolUIGate;
|
||||
currentThinkingSteps: Map<string, ThinkingStepData>;
|
||||
scheduleFlush: () => void;
|
||||
forceFlush: () => void;
|
||||
onTokenUsage?: (data: Extract<SSEEvent, { type: "data-token-usage" }>["data"]) => void;
|
||||
onTurnStatus?: (data: Extract<SSEEvent, { type: "data-turn-status" }>["data"]) => void;
|
||||
onToolOutputAvailable?: (
|
||||
event: Extract<SSEEvent, { type: "tool-output-available" }>,
|
||||
context: {
|
||||
contentPartsState: ContentPartsState;
|
||||
toolCallIndices: Map<string, number>;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a tool produces output, mark any previously-decided interrupt tool
|
||||
* calls as completed so the ApprovalCard can transition from shimmer to done.
|
||||
*/
|
||||
export function markInterruptsCompleted(
|
||||
contentParts: Array<{ type: string; result?: unknown }>
|
||||
): void {
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
(part.result as Record<string, unknown>).__interrupt__ === true &&
|
||||
(part.result as Record<string, unknown>).__decided__ &&
|
||||
!(part.result as Record<string, unknown>).__completed__
|
||||
) {
|
||||
part.result = { ...(part.result as Record<string, unknown>), __completed__: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistableContent(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
toolsWithUI: ToolUIGate
|
||||
) {
|
||||
return contentParts.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text.length > 0) ||
|
||||
(part.type === "reasoning" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" && (toolsWithUI === "all" || toolsWithUI.has(part.toolName)))
|
||||
);
|
||||
}
|
||||
|
||||
function toStreamTerminalError(
|
||||
event: Extract<SSEEvent, { type: "error" }>
|
||||
): Error & { errorCode?: string } {
|
||||
return Object.assign(new Error(event.errorText || "Server error"), {
|
||||
errorCode: event.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
export function processSharedStreamEvent(
|
||||
parsed: SSEEvent,
|
||||
context: SharedStreamEventContext
|
||||
): boolean {
|
||||
const { contentPartsState, toolsWithUI, currentThinkingSteps, scheduleFlush, forceFlush } =
|
||||
context;
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
appendText(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-delta":
|
||||
appendReasoning(contentPartsState, parsed.delta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "reasoning-end":
|
||||
endReasoning(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "start-step":
|
||||
addStepSeparator(contentPartsState);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "finish-step":
|
||||
return true;
|
||||
|
||||
case "tool-input-start":
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
{},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-delta":
|
||||
// High-frequency event: deltas can fire dozens of times per call,
|
||||
// so use throttled scheduleFlush (NOT forceFlush) to coalesce.
|
||||
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
|
||||
case "tool-input-available": {
|
||||
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
parsed.toolCallId,
|
||||
parsed.toolName,
|
||||
parsed.input || {},
|
||||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
// addToolCall doesn't accept argsText today; backfill via
|
||||
// updateToolCall so the new card renders pretty-printed JSON.
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
argsText: finalArgsText,
|
||||
});
|
||||
}
|
||||
forceFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
markInterruptsCompleted(contentParts);
|
||||
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });
|
||||
forceFlush();
|
||||
return true;
|
||||
|
||||
case "data-thinking-step": {
|
||||
const stepData = parsed.data as ThinkingStepData;
|
||||
if (stepData?.id) {
|
||||
currentThinkingSteps.set(stepData.id, stepData);
|
||||
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||
if (didUpdate) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case "data-token-usage":
|
||||
context.onTokenUsage?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "data-turn-status":
|
||||
context.onTurnStatus?.(parsed.data);
|
||||
return true;
|
||||
|
||||
case "error":
|
||||
throw toStreamTerminalError(parsed);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeSseEvents(
|
||||
response: Response,
|
||||
onEvent: (event: SSEEvent) => void | Promise<void>
|
||||
): Promise<void> {
|
||||
for await (const parsed of readSSEStream(response)) {
|
||||
await onEvent(parsed);
|
||||
}
|
||||
}
|
||||
59
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
59
surfsense_web/lib/chat/stream-side-effects.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* When a streamed message is persisted, the backend returns the durable
|
||||
* turn_id; merge it into assistant-ui metadata for turn-scoped actions.
|
||||
*/
|
||||
export function mergeChatTurnIdIntoMessage(
|
||||
msg: ThreadMessageLike,
|
||||
turnId: string | null | undefined
|
||||
): ThreadMessageLike {
|
||||
if (!turnId) return msg;
|
||||
const existingMeta = (msg.metadata ?? {}) as { custom?: Record<string, unknown> };
|
||||
const existingCustom = existingMeta.custom ?? {};
|
||||
if ((existingCustom as { chatTurnId?: string }).chatTurnId === turnId) return msg;
|
||||
return {
|
||||
...msg,
|
||||
metadata: {
|
||||
...existingMeta,
|
||||
custom: { ...existingCustom, chatTurnId: turnId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readStreamedChatTurnId(data: unknown): string | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const value = (data as { chat_turn_id?: unknown }).chat_turn_id;
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the payload of `data-user-message-id` / `data-assistant-message-id`
|
||||
* SSE events emitted by `stream_new_chat` and `stream_resume_chat` after
|
||||
* `persist_user_turn` / `persist_assistant_shell` resolve a canonical
|
||||
* `new_chat_messages.id`. Mirrors {@link readStreamedChatTurnId}.
|
||||
*
|
||||
* Returns `null` when the payload is malformed (missing or non-numeric
|
||||
* `message_id`); callers should treat this as "ignore the event" so a
|
||||
* malformed BE payload never overwrites the optimistic id with a bogus
|
||||
* value.
|
||||
*/
|
||||
export function readStreamedMessageId(
|
||||
data: unknown
|
||||
): { messageId: number; turnId: string | null } | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const obj = data as { message_id?: unknown; turn_id?: unknown };
|
||||
if (typeof obj.message_id !== "number" || !Number.isFinite(obj.message_id)) {
|
||||
return null;
|
||||
}
|
||||
const turnId = typeof obj.turn_id === "string" && obj.turn_id.length > 0 ? obj.turn_id : null;
|
||||
return { messageId: obj.message_id, turnId };
|
||||
}
|
||||
|
||||
export function applyTurnIdToAssistantMessageList(
|
||||
messages: ThreadMessageLike[],
|
||||
assistantMsgId: string,
|
||||
turnId: string
|
||||
): ThreadMessageLike[] {
|
||||
return messages.map((m) => (m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, turnId) : m));
|
||||
}
|
||||
|
|
@ -487,6 +487,37 @@ export type SSEEvent =
|
|||
type: "data-turn-info";
|
||||
data: { chat_turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AFTER ``data-turn-info`` /
|
||||
* ``data-turn-status`` and BEFORE any LLM streaming events,
|
||||
* once ``persist_user_turn`` has resolved the canonical
|
||||
* ``new_chat_messages.id`` for the user-side row of the
|
||||
* current turn. The frontend renames its optimistic
|
||||
* ``msg-user-XXX`` placeholder id to ``msg-{message_id}``
|
||||
* so DB-id-gated UI (comments, edit-from-this-message)
|
||||
* unlocks immediately. Not emitted by ``stream_resume_chat``
|
||||
* (resume reuses the original turn's user message).
|
||||
*/
|
||||
type: "data-user-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Emitted by ``stream_new_chat`` AND ``stream_resume_chat``
|
||||
* AFTER ``data-turn-info`` / ``data-turn-status`` and BEFORE
|
||||
* any LLM streaming events, once ``persist_assistant_shell``
|
||||
* has resolved the canonical ``new_chat_messages.id`` for
|
||||
* the assistant-side row of the current turn. The frontend
|
||||
* renames its optimistic ``msg-assistant-XXX`` placeholder
|
||||
* id, migrates the local ``tokenUsageStore`` and
|
||||
* ``pendingInterrupt`` references, and binds the running
|
||||
* mutable ``assistantMsgId`` closure variable to the
|
||||
* canonical id for the rest of the stream.
|
||||
*/
|
||||
type: "data-assistant-message-id";
|
||||
data: { message_id: number; turn_id: string };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Best-effort revert pass that ran BEFORE this regeneration.
|
||||
|
|
@ -528,25 +559,40 @@ export type SSEEvent =
|
|||
}>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-turn-status";
|
||||
data: {
|
||||
status: "idle" | "busy" | "cancelling";
|
||||
retry_after_ms?: number;
|
||||
retry_after_at?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "data-token-usage";
|
||||
data: {
|
||||
usage: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
call_details: Array<{
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| { type: "error"; errorText: string };
|
||||
| { type: "error"; errorText: string; errorCode?: string };
|
||||
|
||||
/**
|
||||
* Async generator that reads an SSE stream and yields parsed JSON objects.
|
||||
|
|
|
|||
|
|
@ -30,9 +30,20 @@ export interface TokenUsageSummary {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
/**
|
||||
* Total provider USD cost for this assistant turn, in micro-USD
|
||||
* (1_000_000 = $1.00). Optional because rows persisted before the
|
||||
* cost-credits migration won't have it.
|
||||
*/
|
||||
cost_micros?: number;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
> | null;
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +144,17 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
|
|||
* via ``data-turn-info``. Persisting it lets later edits locate the
|
||||
* matching LangGraph checkpoint without HumanMessage scanning. Older
|
||||
* callers can still omit it for back-compat.
|
||||
*
|
||||
* @deprecated Replaced by the SSE-based message ID handshake. The
|
||||
* streaming generator (`stream_new_chat` / `stream_resume_chat`) now
|
||||
* persists both the user and assistant rows server-side via
|
||||
* `persist_user_turn` / `persist_assistant_shell` and emits
|
||||
* `data-user-message-id` / `data-assistant-message-id` SSE events so
|
||||
* the UI renames its optimistic IDs in real time. The only remaining
|
||||
* caller is `persistAssistantErrorMessage` (pre-stream error fallback
|
||||
* for requests the server never accepted — the server has nothing to
|
||||
* persist in that case). After the legacy route is removed in a
|
||||
* follow-up PR this function will be deleted entirely.
|
||||
*/
|
||||
export async function appendMessage(
|
||||
threadId: number,
|
||||
|
|
|
|||
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`
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "8000",
|
||||
pathname: "/api/v1/image-generations/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.21",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}),
|
||||
};
|
||||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
20
surfsense_web/zero/schema/user.ts
Normal file
20
surfsense_web/zero/schema/user.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { number, string, table } from "@rocicorp/zero";
|
||||
|
||||
/**
|
||||
* Live-meter slice of the ``user`` table replicated through Zero.
|
||||
*
|
||||
* ``premiumCreditMicrosLimit`` / ``premiumCreditMicrosUsed`` are stored
|
||||
* as integer micro-USD (1_000_000 == $1.00). UI consumers divide by 1M
|
||||
* when displaying. Sensitive fields (email, hashed_password, oauth, etc.)
|
||||
* are intentionally omitted via the Postgres column-list publication so
|
||||
* they never enter WAL replication.
|
||||
*/
|
||||
export const userTable = table("user")
|
||||
.columns({
|
||||
id: string(),
|
||||
pagesLimit: number().from("pages_limit"),
|
||||
pagesUsed: number().from("pages_used"),
|
||||
premiumCreditMicrosLimit: number().from("premium_credit_micros_limit"),
|
||||
premiumCreditMicrosUsed: number().from("premium_credit_micros_used"),
|
||||
})
|
||||
.primaryKey("id");
|
||||
Loading…
Add table
Add a link
Reference in a new issue