Merge upstream/dev into feature/multi-agent

This commit is contained in:
CREDO23 2026-05-05 01:44:46 +02:00
commit 5119915f4f
278 changed files with 34669 additions and 8970 deletions

View file

@ -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() {

View file

@ -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 && (

View 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>
);

View file

@ -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": {

View file

@ -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/",
},

View file

@ -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

View file

@ -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} />
),

View 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";

View file

@ -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>
);
};

View file

@ -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(

View file

@ -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;
}
>;
}

View file

@ -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>
</>
)}

View file

@ -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);

View file

@ -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>
);

View 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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View 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);
}

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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>
);
};

View file

@ -40,7 +40,7 @@ export function QuotaWarningBanner({
</p>
<p className="text-xs text-red-600 dark:text-red-300">
You&apos;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"

View file

@ -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}

View file

@ -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}

View file

@ -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} />;
}

View file

@ -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");

View file

@ -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>

View file

@ -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`}

View file

@ -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>

View file

@ -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>
),
};

View file

@ -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">

View file

@ -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&apos;t
find what you need? Reach out at{" "}
Everything you need to know about SurfSense pages, premium credits, and billing.
Can&apos;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 />
</>

View file

@ -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>
)}

View file

@ -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}
/>
)
) : (

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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} />,

View file

@ -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">

View file

@ -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} />;