chore: remove tokenlens and token usage tracking (#1357)

This commit is contained in:
josh 2025-12-15 16:56:10 +00:00 committed by GitHub
parent 5f9e231788
commit 7942e97095
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 60 additions and 773 deletions

View file

@ -7,15 +7,11 @@ import {
stepCountIs,
streamText,
} from "ai";
import { unstable_cache as cache } from "next/cache";
import { after } from "next/server";
import {
createResumableStreamContext,
type ResumableStreamContext,
} from "resumable-stream";
import type { ModelCatalog } from "tokenlens/core";
import { fetchModels } from "tokenlens/fetch";
import { getUsage } from "tokenlens/helpers";
import { auth, type UserType } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/visibility-selector";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
@ -35,12 +31,11 @@ import {
getMessagesByChatId,
saveChat,
saveMessages,
updateChatLastContextById,
updateChatTitleById,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
@ -49,22 +44,6 @@ export const maxDuration = 60;
let globalStreamContext: ResumableStreamContext | null = null;
const getTokenlensCatalog = cache(
async (): Promise<ModelCatalog | undefined> => {
try {
return await fetchModels();
} catch (err) {
console.warn(
"TokenLens: catalog fetch failed, using default catalog",
err
);
return; // tokenlens helpers will fall back to defaultCatalog
}
},
["tokenlens-catalog"],
{ revalidate: 24 * 60 * 60 } // 24 hours
);
export function getStreamContext() {
if (!globalStreamContext) {
try {
@ -127,6 +106,7 @@ export async function POST(request: Request) {
const chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = [];
let titlePromise: Promise<string> | null = null;
if (chat) {
if (chat.userId !== session.user.id) {
@ -135,17 +115,16 @@ export async function POST(request: Request) {
// Only fetch messages if chat already exists
messagesFromDb = await getMessagesByChatId({ id });
} else {
const title = await generateTitleFromUserMessage({
message,
});
// Save chat immediately with placeholder title
await saveChat({
id,
userId: session.user.id,
title,
title: "New chat",
visibility: selectedVisibilityType,
});
// New chat - no need to fetch messages, it's empty
// Start title generation in parallel (don't await)
titlePromise = generateTitleFromUserMessage({ message });
}
const uiMessages = [...convertToUIMessages(messagesFromDb), message];
@ -175,10 +154,16 @@ export async function POST(request: Request) {
const streamId = generateUUID();
await createStreamId({ streamId, chatId: id });
let finalMergedUsage: AppUsage | undefined;
const stream = createUIMessageStream({
execute: ({ writer: dataStream }) => {
// Handle title generation in parallel
if (titlePromise) {
titlePromise.then((title) => {
updateChatTitleById({ chatId: id, title });
dataStream.write({ type: "data-chat-title", data: title });
});
}
const isReasoningModel =
selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking");
@ -219,37 +204,6 @@ export async function POST(request: Request) {
isEnabled: isProductionEnvironment,
functionId: "stream-text",
},
onFinish: async ({ usage }) => {
try {
const providers = await getTokenlensCatalog();
const modelId = getLanguageModel(selectedChatModel).modelId;
if (!modelId) {
finalMergedUsage = usage;
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
if (!providers) {
finalMergedUsage = usage;
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
const summary = getUsage({ modelId, usage, providers });
finalMergedUsage = { ...usage, ...summary, modelId } as AppUsage;
dataStream.write({ type: "data-usage", data: finalMergedUsage });
} catch (err) {
console.warn("TokenLens enrichment failed", err);
finalMergedUsage = usage;
dataStream.write({ type: "data-usage", data: finalMergedUsage });
}
},
});
result.consumeStream();
@ -272,17 +226,6 @@ export async function POST(request: Request) {
chatId: id,
})),
});
if (finalMergedUsage) {
try {
await updateChatLastContextById({
chatId: id,
context: finalMergedUsage,
});
} catch (err) {
console.warn("Unable to persist last usage for chat", id, err);
}
}
},
onError: () => {
return "Oops, an error occurred!";

View file

@ -22,7 +22,7 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const chat = await getChatById({ id });
if (!chat) {
notFound();
redirect("/");
}
const session = await auth();
@ -57,7 +57,6 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
autoResume={true}
id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
@ -73,7 +72,6 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
autoResume={true}
id={chat.id}
initialChatModel={chatModelFromCookie.value}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}

View file

@ -1,408 +0,0 @@
"use client";
import type { LanguageModelUsage } from "ai";
import { type ComponentProps, createContext, useContext } from "react";
import { getUsage } from "tokenlens";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
const PERCENT_MAX = 100;
const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2;
type ModelId = string;
type ContextSchema = {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: ModelId;
};
const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => {
const context = useContext(ContextContext);
if (!context) {
throw new Error("Context components must be used within Context");
}
return context;
};
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => (
<ContextContext.Provider
value={{
usedTokens,
maxTokens,
usage,
modelId,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent);
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
/>
</svg>
);
};
export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
return (
<HoverCardTrigger asChild>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">
{renderedPercent}
</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
);
};
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props}
/>
);
export type ContextContentHeaderProps = ComponentProps<"div">;
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const displayPct = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
const used = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(usedTokens);
const total = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(maxTokens);
return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
);
};
export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
);
export type ContextContentFooterProps = ComponentProps<"div">;
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue();
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined;
const totalCost = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(costUSD ?? 0);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
);
};
export type ContextInputUsageProps = ComponentProps<"div">;
export const ContextInputUsage = ({
className,
children,
...props
}: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0;
if (children) {
return children;
}
if (!inputTokens) {
return null;
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined;
const inputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(inputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
);
};
export type ContextOutputUsageProps = ComponentProps<"div">;
export const ContextOutputUsage = ({
className,
children,
...props
}: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0;
if (children) {
return children;
}
if (!outputTokens) {
return null;
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined;
const outputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(outputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
);
};
export type ContextReasoningUsageProps = ComponentProps<"div">;
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue();
const reasoningTokens = usage?.reasoningTokens ?? 0;
if (children) {
return children;
}
if (!reasoningTokens) {
return null;
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined;
const reasoningCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(reasoningCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
);
};
export type ContextCacheUsageProps = ComponentProps<"div">;
export const ContextCacheUsage = ({
className,
children,
...props
}: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue();
const cacheTokens = usage?.cachedInputTokens ?? 0;
if (children) {
return children;
}
if (!cacheTokens) {
return null;
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined;
const cacheCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cacheCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
);
};
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number;
costText?: string;
}) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span>
) : null}
</span>
);

View file

@ -49,8 +49,9 @@ export function AppSidebar({ user }: { user: User | undefined }) {
loading: "Deleting all chats...",
success: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
router.push("/");
setShowDeleteAllDialog(false);
router.replace("/");
router.refresh();
return "All chats deleted successfully";
},
error: "Failed to delete all chats",

View file

@ -23,7 +23,6 @@ import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
@ -40,7 +39,6 @@ export function Chat({
initialVisibilityType,
isReadonly,
autoResume,
initialLastContext,
}: {
id: string;
initialMessages: ChatMessage[];
@ -48,7 +46,6 @@ export function Chat({
initialVisibilityType: VisibilityType;
isReadonly: boolean;
autoResume: boolean;
initialLastContext?: AppUsage;
}) {
const router = useRouter();
@ -72,7 +69,6 @@ export function Chat({
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>("");
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId);
@ -111,9 +107,6 @@ export function Chat({
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
if (dataPart.type === "data-usage") {
setUsage(dataPart.data);
}
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
@ -204,7 +197,6 @@ export function Chat({
setMessages={setMessages}
status={status}
stop={stop}
usage={usage}
/>
)}
</div>

View file

@ -1,12 +1,16 @@
"use client";
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { getChatHistoryPaginationKey } from "./sidebar-history";
export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
const { artifact, setArtifact, setMetadata } = useArtifact();
@ -19,6 +23,11 @@ export function DataStreamHandler() {
setDataStream([]);
for (const delta of newDeltas) {
// Handle chat title updates
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
continue;
}
const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
@ -77,7 +86,7 @@ export function DataStreamHandler() {
}
});
}
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream]);
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]);
return null;
}

View file

@ -1,190 +0,0 @@
"use client";
import type { ComponentProps } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
export type ContextProps = ComponentProps<"button"> & {
/** Optional full usage payload to enable breakdown view */
usage?: AppUsage;
};
const _THOUSAND = 1000;
const _MILLION = 1_000_000;
const _BILLION = 1_000_000_000;
const PERCENT_MAX = 100;
// Lucide CircleIcon geometry
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_RADIUS = 10;
const ICON_STROKE_WIDTH = 2;
type ContextIconProps = {
percent: number; // 0 - 100
};
export const ContextIcon = ({ percent }: ContextIconProps) => {
const radius = ICON_RADIUS;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - percent / PERCENT_MAX);
return (
<svg
aria-label={`${percent.toFixed(2)}% of model context used`}
height="28"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="28"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={radius}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={radius}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
transform={`rotate(-90 ${ICON_CENTER} ${ICON_CENTER})`}
/>
</svg>
);
};
function InfoRow({
label,
tokens,
costText,
}: {
label: string;
tokens?: number;
costText?: string;
}) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right">
{tokens === undefined ? "—" : tokens.toLocaleString()}
</span>
{costText !== undefined &&
costText !== null &&
!Number.isNaN(Number.parseFloat(costText)) && (
<span className="text-muted-foreground">
${Number.parseFloat(costText).toFixed(6)}
</span>
)}
</div>
</div>
);
}
export const Context = ({ className, usage, ...props }: ContextProps) => {
const used = usage?.totalTokens ?? 0;
const max =
usage?.context?.totalMax ??
usage?.context?.combinedMax ??
usage?.context?.inputMax;
const hasMax = typeof max === "number" && Number.isFinite(max) && max > 0;
const usedPercent = hasMax ? Math.min(100, (used / max) * 100) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground",
"outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
type="button"
{...props}
>
<span className="hidden font-medium text-muted-foreground">
{usedPercent.toFixed(1)}%
</span>
<ContextIcon percent={usedPercent} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit p-3" side="top">
<div className="min-w-[240px] space-y-2">
<div className="flex items-start justify-between text-sm">
<span>{usedPercent.toFixed(1)}%</span>
<span className="text-muted-foreground">
{hasMax ? `${used} / ${max} tokens` : `${used} tokens`}
</span>
</div>
<div className="space-y-2">
<Progress className="h-2 bg-muted" value={usedPercent} />
</div>
<div className="mt-1 space-y-1">
{usage?.cachedInputTokens && usage.cachedInputTokens > 0 && (
<InfoRow
costText={usage?.costUSD?.cacheReadUSD?.toString()}
label="Cache Hits"
tokens={usage?.cachedInputTokens}
/>
)}
<InfoRow
costText={usage?.costUSD?.inputUSD?.toString()}
label="Input"
tokens={usage?.inputTokens}
/>
<InfoRow
costText={usage?.costUSD?.outputUSD?.toString()}
label="Output"
tokens={usage?.outputTokens}
/>
<InfoRow
costText={usage?.costUSD?.reasoningUSD?.toString()}
label="Reasoning"
tokens={
usage?.reasoningTokens && usage.reasoningTokens > 0
? usage.reasoningTokens
: undefined
}
/>
{usage?.costUSD?.totalUSD !== undefined && (
<>
<Separator className="mt-1" />
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-muted-foreground">Total cost</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right" />
<span>
{Number.isNaN(
Number.parseFloat(usage.costUSD.totalUSD.toString())
)
? "—"
: `$${Number.parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`}
</span>
</div>
</div>
</>
)}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -34,9 +34,7 @@ import {
modelsByProvider,
} from "@/lib/ai/models";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
import { Context } from "./elements/context";
import {
PromptInput,
PromptInputSubmit,
@ -71,7 +69,6 @@ function PureMultimodalInput({
selectedVisibilityType,
selectedModelId,
onModelChange,
usage,
}: {
chatId: string;
input: string;
@ -87,7 +84,6 @@ function PureMultimodalInput({
selectedVisibilityType: VisibilityType;
selectedModelId: string;
onModelChange?: (modelId: string) => void;
usage?: AppUsage;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
@ -204,13 +200,6 @@ function PureMultimodalInput({
}
}, []);
const contextProps = useMemo(
() => ({
usage,
}),
[usage]
);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
@ -374,8 +363,7 @@ function PureMultimodalInput({
ref={textareaRef}
rows={1}
value={input}
/>{" "}
<Context {...contextProps} />
/>
</div>
<PromptInputToolbar className="border-top-0! border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5">

View file

@ -2,7 +2,7 @@
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
@ -99,7 +99,8 @@ export function getChatHistoryPaginationKey(
export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar();
const { id } = useParams();
const pathname = usePathname();
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
const {
data: paginatedChatHistories,
@ -124,7 +125,12 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
: false;
const handleDelete = () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
const chatToDelete = deleteId;
const isCurrentChat = pathname === `/chat/${chatToDelete}`;
setShowDeleteDialog(false);
const deletePromise = fetch(`/api/chat?id=${chatToDelete}`, {
method: "DELETE",
});
@ -135,21 +141,22 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== deleteId),
chats: chatHistory.chats.filter(
(chat) => chat.id !== chatToDelete
),
}));
}
});
if (isCurrentChat) {
router.replace("/");
router.refresh();
}
return "Chat deleted successfully";
},
error: "Failed to delete chat",
});
setShowDeleteDialog(false);
if (deleteId === id) {
router.push("/");
}
};
if (!user) {

View file

@ -117,8 +117,10 @@ export const updateDocumentPrompt = (
${currentContent}`;
};
export const titlePrompt = `\n
- you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 80 characters long
- the title should be a summary of the user's message
- do not use quotes or colons`;
export const titlePrompt = `Generate a very short chat title (2-5 words max) based on the user's message.
Rules:
- Maximum 30 characters
- No quotes, colons, hashtags, or markdown
- Just the topic/intent, not a full sentence
- If the message is a greeting like "hi" or "hello", respond with just "New conversation"
- Be concise: "Weather in NYC" not "User asking about the weather in New York City"`;

View file

@ -17,7 +17,6 @@ import postgres from "postgres";
import type { ArtifactKind } from "@/components/artifact";
import type { VisibilityType } from "@/components/visibility-selector";
import { ChatSDKError } from "../errors";
import type { AppUsage } from "../usage";
import { generateUUID } from "../utils";
import {
type Chat,
@ -502,21 +501,17 @@ export async function updateChatVisibilityById({
}
}
export async function updateChatLastContextById({
export async function updateChatTitleById({
chatId,
context,
title,
}: {
chatId: string;
// Store merged server-enriched usage object
context: AppUsage;
title: string;
}) {
try {
return await db
.update(chat)
.set({ lastContext: context })
.where(eq(chat.id, chatId));
return await db.update(chat).set({ title }).where(eq(chat.id, chatId));
} catch (error) {
console.warn("Failed to update lastContext for chat", chatId, error);
console.warn("Failed to update title for chat", chatId, error);
return;
}
}

View file

@ -3,7 +3,6 @@ import {
boolean,
foreignKey,
json,
jsonb,
pgTable,
primaryKey,
text,
@ -11,7 +10,6 @@ import {
uuid,
varchar,
} from "drizzle-orm/pg-core";
import type { AppUsage } from "../usage";
export const user = pgTable("User", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
@ -31,7 +29,6 @@ export const chat = pgTable("Chat", {
visibility: varchar("visibility", { enum: ["public", "private"] })
.notNull()
.default("private"),
lastContext: jsonb("lastContext").$type<AppUsage | null>(),
});
export type Chat = InferSelectModel<typeof chat>;

View file

@ -6,7 +6,6 @@ import type { getWeather } from "./ai/tools/get-weather";
import type { requestSuggestions } from "./ai/tools/request-suggestions";
import type { updateDocument } from "./ai/tools/update-document";
import type { Suggestion } from "./db/schema";
import type { AppUsage } from "./usage";
export type DataPart = { type: "append-message"; message: string };
@ -42,7 +41,7 @@ export type CustomUIDataTypes = {
kind: ArtifactKind;
clear: null;
finish: null;
usage: AppUsage;
"chat-title": string;
};
export type ChatMessage = UIMessage<

View file

@ -1,5 +0,0 @@
import type { LanguageModelUsage } from "ai";
import type { UsageData } from "tokenlens/helpers";
// Server-merged usage: base usage + TokenLens summary + optional modelId
export type AppUsage = LanguageModelUsage & UsageData & { modelId?: string };

View file

@ -95,7 +95,6 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0",
"zod": "^3.25.76"

40
pnpm-lock.yaml generated
View file

@ -239,9 +239,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.16)
tokenlens:
specifier: 1.3.0
version: 1.3.0
use-stick-to-bottom:
specifier: ^1.1.1
version: 1.1.1(react@19.0.1)
@ -2281,18 +2278,6 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tokenlens/core@1.3.0':
resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==}
'@tokenlens/fetch@1.3.0':
resolution: {integrity: sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ==}
'@tokenlens/helpers@1.3.0':
resolution: {integrity: sha512-GWe4p9OHYppr7moqyM3Y4NH+kVGCOFpN+voaXgKgvvMBo6Z97Pu17fgCHet10Ls8r4X/tPkb/yHCWP7Ktssu/w==}
'@tokenlens/models@1.3.0':
resolution: {integrity: sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw==}
'@trpc/server@11.7.1':
resolution: {integrity: sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==}
peerDependencies:
@ -4259,9 +4244,6 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tokenlens@1.3.0:
resolution: {integrity: sha512-qrwHFO7CI8HEd+UvKjlL+veTzCVr3N4AYp3cquGL6z62Q/OtoEHbTv5uNqiiejP54y7eR+h8VVRRpZbm3t/99Q==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@ -6267,21 +6249,6 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.16
'@tokenlens/core@1.3.0': {}
'@tokenlens/fetch@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/helpers@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/fetch': 1.3.0
'@tokenlens/models@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@trpc/server@11.7.1(typescript@5.8.2)':
dependencies:
typescript: 5.8.2
@ -8690,13 +8657,6 @@ snapshots:
tinyspy@4.0.4: {}
tokenlens@1.3.0:
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/fetch': 1.3.0
'@tokenlens/helpers': 1.3.0
'@tokenlens/models': 1.3.0
trim-lines@3.0.1: {}
trough@2.2.0: {}

File diff suppressed because one or more lines are too long