chore: remove tokenlens and token usage tracking (#1357)
This commit is contained in:
parent
5f9e231788
commit
7942e97095
17 changed files with 60 additions and 773 deletions
|
|
@ -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!";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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
40
pnpm-lock.yaml
generated
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue