diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 0069f3853a..29b8711ea0 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -7874,7 +7874,10 @@ admin.openapi(getDevpassSubscribers, async (c) => { const [countRow] = await countSelect.where(whereClause); const total = Number(countRow?.count ?? 0); - // KPI strip — always over the full active subscriber base, unfiltered + // KPI strip — always over the full active subscriber base, unfiltered. + // Matches Stripe's "active" filter, which includes cancel-at-period-end + // subscriptions until the period actually ends. The `cancelledPending` + // count below breaks out how many of these are flagged to cancel. const activeRows = await db .select({ tier: tables.organization.devPlan, @@ -7884,7 +7887,6 @@ admin.openapi(getDevpassSubscribers, async (c) => { .where( and( ne(tables.organization.devPlan, "none"), - eq(tables.organization.devPlanCancelled, false), or( isNull(tables.organization.devPlanExpiresAt), sql`${tables.organization.devPlanExpiresAt} > NOW()`, diff --git a/apps/code/src/app/dashboard/components/AgentModelUsageChart.tsx b/apps/code/src/app/dashboard/components/AgentModelUsageChart.tsx new file mode 100644 index 0000000000..99776a4166 --- /dev/null +++ b/apps/code/src/app/dashboard/components/AgentModelUsageChart.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { + addDays, + addHours, + format, + parseISO, + startOfDay, + startOfHour, +} from "date-fns"; +import { Loader2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useApi } from "@/lib/fetch-client"; + +import type { paths } from "@/lib/api/v1"; +import type { TooltipProps } from "recharts"; + +type ApiLog = + paths["/logs"]["get"]["responses"][200]["content"]["application/json"]["logs"][number]; + +export type AgentChartTimeRange = "1h" | "4h" | "24h" | "7d" | "30d"; + +const TIME_RANGES: { value: AgentChartTimeRange; label: string }[] = [ + { value: "1h", label: "1h" }, + { value: "4h", label: "4h" }, + { value: "24h", label: "1d" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, +]; + +type Metric = "requests" | "cost" | "tokens"; + +const METRIC_OPTIONS: { value: Metric; label: string }[] = [ + { value: "requests", label: "Requests" }, + { value: "cost", label: "Cost" }, + { value: "tokens", label: "Tokens" }, +]; + +const MODEL_COLORS = [ + "#6366f1", + "#0ea5e9", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#06b6d4", + "#84cc16", + "#f97316", +]; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +const RANGE_OFFSET_MS: Record = { + "1h": HOUR_MS, + "4h": 4 * HOUR_MS, + "24h": 24 * HOUR_MS, + "7d": 7 * DAY_MS, + "30d": 30 * DAY_MS, +}; + +function getRangeStart(range: AgentChartTimeRange): Date { + return new Date(Date.now() - RANGE_OFFSET_MS[range]); +} + +function getGranularity(range: AgentChartTimeRange): "hourly" | "daily" { + if (range === "1h" || range === "4h" || range === "24h") { + return "hourly"; + } + return "daily"; +} + +function getSlots(range: AgentChartTimeRange): Date[] { + const granularity = getGranularity(range); + const now = new Date(); + const slots: Date[] = []; + if (granularity === "hourly") { + const totalHours = range === "1h" ? 1 : range === "4h" ? 4 : 24; + const end = startOfHour(now); + const start = addHours(end, -(totalHours - 1)); + for (let i = 0; i < totalHours; i++) { + slots.push(addHours(start, i)); + } + } else { + const totalDays = range === "7d" ? 7 : 30; + const end = startOfDay(now); + const start = addDays(end, -(totalDays - 1)); + for (let i = 0; i < totalDays; i++) { + slots.push(addDays(start, i)); + } + } + return slots; +} + +function formatSlotKey(date: Date, granularity: "hourly" | "daily"): string { + if (granularity === "hourly") { + return format(date, "yyyy-MM-dd'T'HH:00:00"); + } + return format(date, "yyyy-MM-dd"); +} + +function bucketLogToSlot(log: ApiLog, granularity: "hourly" | "daily"): string { + const date = new Date(log.createdAt); + if (granularity === "hourly") { + return formatSlotKey(startOfHour(date), granularity); + } + return formatSlotKey(startOfDay(date), granularity); +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1)}K`; + } + return n.toLocaleString(); +} + +interface AgentModelUsageChartProps { + sources: string[]; +} + +interface SlotData { + slot: string; + totalRequests: number; + totalCost: number; + totalTokens: number; + models: Record; +} + +function buildChartData( + logs: ApiLog[], + range: AgentChartTimeRange, +): { rows: SlotData[]; models: string[] } { + const granularity = getGranularity(range); + const slots = getSlots(range); + const slotMap = new Map(); + for (const date of slots) { + const key = formatSlotKey(date, granularity); + slotMap.set(key, { + slot: key, + totalRequests: 0, + totalCost: 0, + totalTokens: 0, + models: {}, + }); + } + + const rangeStart = getRangeStart(range).getTime(); + const modelCostTotals = new Map(); + + for (const log of logs) { + const ts = new Date(log.createdAt).getTime(); + if (ts < rangeStart) { + continue; + } + const key = bucketLogToSlot(log, granularity); + const slot = slotMap.get(key); + if (!slot) { + continue; + } + const modelId = log.usedModel || log.requestedModel || "unknown"; + const cost = log.cost ?? 0; + const tokens = Number(log.totalTokens ?? 0); + slot.totalRequests += 1; + slot.totalCost += cost; + slot.totalTokens += tokens; + const entry = slot.models[modelId] ?? { + requests: 0, + cost: 0, + tokens: 0, + }; + entry.requests += 1; + entry.cost += cost; + entry.tokens += tokens; + slot.models[modelId] = entry; + modelCostTotals.set(modelId, (modelCostTotals.get(modelId) ?? 0) + cost); + } + + const models = Array.from(modelCostTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id]) => id); + const rows = Array.from(slotMap.values()); + return { rows, models }; +} + +interface ChartRow { + slot: string; + formattedDate: string; + totalRequests: number; + totalCost: number; + totalTokens: number; + [key: string]: string | number; +} + +interface ChartTooltipPayload { + dataKey?: string; + name?: string; + value?: number; + color?: string; + payload?: ChartRow; +} + +function ChartTooltipContent({ + active, + payload, + label, + metric, + hourly, +}: TooltipProps & { + metric: Metric; + hourly: boolean; +}) { + if (!active || !payload || payload.length === 0) { + return null; + } + const typed = payload as unknown as ChartTooltipPayload[]; + const first = typed[0]?.payload; + if (!first) { + return null; + } + const dateLabel = label + ? format(parseISO(label), hourly ? "MMM d, HH:mm" : "MMM d, yyyy") + : ""; + return ( +
+
{dateLabel}
+
+
+ + {first.totalRequests.toLocaleString()} + {" "} + requests +
+
+ + {formatTokens(first.totalTokens)} + {" "} + tokens +
+
+ + ${first.totalCost.toFixed(4)} + {" "} + cost +
+
+
+ {typed + .filter((p) => typeof p.value === "number" && (p.value as number) > 0) + .map((p, i) => ( +
+ + {p.name} + + {metric === "cost" + ? `$${Number(p.value).toFixed(4)}` + : metric === "tokens" + ? formatTokens(Number(p.value)) + : Number(p.value).toLocaleString()} + +
+ ))} +
+
+ ); +} + +export function AgentModelUsageChart({ sources }: AgentModelUsageChartProps) { + const [range, setRange] = useState("24h"); + const [metric, setMetric] = useState("cost"); + const api = useApi(); + const { startDate, endDate } = useMemo(() => { + const end = new Date(); + const start = new Date(end.getTime() - RANGE_OFFSET_MS[range]); + return { startDate: start.toISOString(), endDate: end.toISOString() }; + }, [range]); + const sourceParam = useMemo(() => sources.join(","), [sources]); + + const { data, isLoading, isFetching } = api.useQuery( + "get", + "/logs", + { + params: { + query: { + orderBy: "createdAt_desc", + limit: "100", + source: sourceParam, + startDate, + endDate, + }, + }, + }, + { + enabled: sources.length > 0, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + ); + + const logs = useMemo(() => data?.logs ?? [], [data]); + const { rows, models } = useMemo( + () => buildChartData(logs, range), + [logs, range], + ); + + const granularity = getGranularity(range); + const hourly = granularity === "hourly"; + + const chartData: ChartRow[] = useMemo( + () => + rows.map((row) => { + const base: ChartRow = { + slot: row.slot, + formattedDate: hourly + ? format(parseISO(row.slot), "HH:mm") + : format(parseISO(row.slot), "MMM d"), + totalRequests: row.totalRequests, + totalCost: row.totalCost, + totalTokens: row.totalTokens, + }; + for (const modelId of models) { + const m = row.models[modelId]; + if (!m) { + base[modelId] = 0; + continue; + } + if (metric === "cost") { + base[modelId] = Number(m.cost.toFixed(6)); + } else if (metric === "tokens") { + base[modelId] = m.tokens; + } else { + base[modelId] = m.requests; + } + } + return base; + }), + [rows, models, metric, hourly], + ); + + const visibleModels = models.slice(0, 10); + + const subtitleLabel = + range === "1h" + ? "last hour" + : range === "4h" + ? "last 4 hours" + : range === "24h" + ? "last 24 hours" + : range === "7d" + ? "last 7 days" + : "last 30 days"; + + return ( +
+
+
+
+

+ Model Usage Overview +

+ {isFetching && !isLoading ? ( + + ) : null} +
+

+ Stacked model{" "} + {metric === "cost" + ? "cost" + : metric === "tokens" + ? "tokens" + : "requests"}{" "} + over {subtitleLabel} +

+
+
+
+ {TIME_RANGES.map((r) => ( + + ))} +
+ +
+
+
+ {isLoading ? ( +
+ + Loading… +
+ ) : models.length === 0 ? ( +
+ No activity in this range. +
+ ) : ( + <> + {visibleModels.length > 0 ? ( +
+ {visibleModels.map((model, i) => ( +
+ + {model} +
+ ))} + {models.length > visibleModels.length ? ( + + +{models.length - visibleModels.length} more + + ) : null} +
+ ) : null} + + + + { + try { + return hourly + ? format(parseISO(value), "HH:mm") + : format(parseISO(value), "MMM d"); + } catch { + return value; + } + }} + stroke="currentColor" + className="text-muted-foreground" + fontSize={11} + tickLine={false} + axisLine={false} + minTickGap={hourly ? 24 : 16} + /> + { + if (metric === "cost") { + return `$${value.toFixed(2)}`; + } + if (metric === "tokens") { + return formatTokens(value); + } + return value.toLocaleString(); + }} + /> + + } + /> + {models.map((modelId, i) => ( + + ))} + + + + )} +
+
+ ); +} diff --git a/apps/code/src/app/dashboard/components/CodingAgents.tsx b/apps/code/src/app/dashboard/components/CodingAgents.tsx index 28632323c3..7dffabe71a 100644 --- a/apps/code/src/app/dashboard/components/CodingAgents.tsx +++ b/apps/code/src/app/dashboard/components/CodingAgents.tsx @@ -26,6 +26,8 @@ import { SoulForgeIcon, } from "@llmgateway/shared/components"; +import { AgentModelUsageChart } from "./AgentModelUsageChart"; + import type { paths } from "@/lib/api/v1"; import type { ComponentType, SVGProps } from "react"; @@ -503,6 +505,7 @@ function AgentDetail({ +
diff --git a/apps/code/src/components/ui/select.tsx b/apps/code/src/components/ui/select.tsx new file mode 100644 index 0000000000..6e46491087 --- /dev/null +++ b/apps/code/src/components/ui/select.tsx @@ -0,0 +1,185 @@ +"use client"; + +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Select({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/playground/src/app/share/[shareId]/opengraph-image.tsx b/apps/playground/src/app/share/[shareId]/opengraph-image.tsx new file mode 100644 index 0000000000..9e52288fb2 --- /dev/null +++ b/apps/playground/src/app/share/[shareId]/opengraph-image.tsx @@ -0,0 +1,288 @@ +import { ImageResponse } from "next/og"; + +import { getConfig } from "@/lib/config-server"; + +export const size = { + width: 1200, + height: 630, +}; +export const contentType = "image/png"; + +interface SharedMessage { + id: string; + role: "user" | "assistant" | "system"; + content: string | null; + images: string | null; + reasoning: string | null; + tools: string | null; + sequence: number; + createdAt: string; +} + +interface SharedChatResponse { + share: { + id: string; + title: string; + model: string; + createdAt: string; + messages: SharedMessage[]; + }; +} + +interface OgImageProps { + params: Promise<{ shareId: string }>; +} + +function flatten(text: string | null | undefined): string { + if (!text) { + return ""; + } + return text.replace(/\s+/g, " ").trim(); +} + +function clip(text: string, max: number): string { + if (text.length <= max) { + return text; + } + return `${text.slice(0, max - 1).trimEnd()}…`; +} + +function pickPromptAndResponse(messages: SharedMessage[]): { + prompt: string; + response: string; +} { + const userMessage = messages.find((m) => m.role === "user"); + const assistantMessage = messages.find((m) => m.role === "assistant"); + return { + prompt: flatten(userMessage?.content), + response: flatten(assistantMessage?.content), + }; +} + +export default async function ShareOgImage({ params }: OgImageProps) { + try { + const { shareId } = await params; + const config = getConfig(); + const response = await fetch( + `${config.apiBackendUrl}/public/chats/share/${shareId}`, + { cache: "no-store" }, + ); + + const data = response.ok + ? ((await response.json()) as SharedChatResponse) + : null; + + const title = clip(flatten(data?.share.title) || "Shared chat", 80); + const { prompt, response: responseText } = data + ? pickPromptAndResponse(data.share.messages) + : { prompt: "", response: "" }; + const promptText = clip(prompt || title, 180); + const previewResponse = clip(responseText, 220); + const model = flatten(data?.share.model); + + return new ImageResponse( + ( +
+
+ + + + +
+ + LLM Gateway + + + Shared chat + +
+
+ +
+ + {promptText} + + {previewResponse ? ( +
+ + Response preview + + + {previewResponse} + +
+ ) : null} +
+ +
+
+ {model ? ( + + {clip(model, 40)} + + ) : null} +
+ + chat.llmgateway.io + +
+
+ ), + size, + ); + } catch (error) { + console.error("Error generating share OpenGraph image:", error); + return new ImageResponse( + ( +
+ LLM Gateway · Shared chat +
+ ), + size, + ); + } +} diff --git a/apps/playground/src/app/share/[shareId]/page.tsx b/apps/playground/src/app/share/[shareId]/page.tsx index dfa0b3372f..f16ee2fac3 100644 --- a/apps/playground/src/app/share/[shareId]/page.tsx +++ b/apps/playground/src/app/share/[shareId]/page.tsx @@ -42,11 +42,54 @@ export async function generateMetadata({ params: Promise<{ shareId: string }>; }): Promise { const { shareId } = await params; + const config = getConfig(); + + let title = "Shared Chat"; + let description = + "A shared snapshot of an LLM Gateway chat — open it to see the full conversation."; + + try { + const response = await fetch( + `${config.apiBackendUrl}/public/chats/share/${shareId}`, + { cache: "no-store" }, + ); + if (response.ok) { + const data = (await response.json()) as SharedChatResponse; + const flatTitle = data.share.title?.replace(/\s+/g, " ").trim(); + if (flatTitle) { + title = + flatTitle.length > 80 ? `${flatTitle.slice(0, 80)}…` : flatTitle; + } + const userMessage = data.share.messages.find((m) => m.role === "user"); + const userText = userMessage?.content?.replace(/\s+/g, " ").trim(); + if (userText) { + description = + userText.length > 160 ? `${userText.slice(0, 160)}…` : userText; + } + } + } catch { + // Fall back to defaults if the API call fails. + } + + const url = `/share/${shareId}`; return { - title: "Shared Chat - LLM Gateway", + title: `${title} · LLM Gateway`, + description, alternates: { - canonical: `/share/${shareId}`, + canonical: url, + }, + openGraph: { + title, + description, + url, + type: "article", + siteName: "LLM Gateway", + }, + twitter: { + card: "summary_large_image", + title, + description, }, }; } diff --git a/apps/playground/src/components/playground/chat-header.tsx b/apps/playground/src/components/playground/chat-header.tsx index 2f026b7dcc..d033a30491 100644 --- a/apps/playground/src/components/playground/chat-header.tsx +++ b/apps/playground/src/components/playground/chat-header.tsx @@ -36,6 +36,8 @@ interface ChatHeaderProps { hasTemporaryMessages: boolean; currentChatId: string | null; shareId: string | null; + chatTitle?: string | null; + previewPrompt?: string | null; } export const ChatHeader = ({ @@ -57,6 +59,8 @@ export const ChatHeader = ({ hasTemporaryMessages, currentChatId, shareId, + chatTitle, + previewPrompt, }: ChatHeaderProps) => { return (
@@ -82,7 +86,12 @@ export const ChatHeader = ({ hasTemporaryMessages={hasTemporaryMessages} /> {isTemporaryChat || !currentChatId ? null : ( - + )} p.type === "text" && typeof p.text === "string") + .map((p) => p.text as string) + .join(" ") + .trim(); + if (text) { + return text; + } + } + return null; +} + interface ChatPageClientProps { models: ApiModel[]; providers: ApiProvider[]; @@ -1092,6 +1111,8 @@ export default function ChatPageClient({ hasTemporaryMessages={hasTemporaryMessages} currentChatId={currentChatId} shareId={currentChatData?.chat?.shareId ?? null} + chatTitle={currentChatData?.chat?.title ?? null} + previewPrompt={getFirstUserMessageText(messages)} />
{comparisonEnabled && !isTemporaryChat ? ( diff --git a/apps/playground/src/components/playground/share-chat-dialog.tsx b/apps/playground/src/components/playground/share-chat-dialog.tsx index 0ceae3d82e..6659e295fe 100644 --- a/apps/playground/src/components/playground/share-chat-dialog.tsx +++ b/apps/playground/src/components/playground/share-chat-dialog.tsx @@ -1,6 +1,14 @@ "use client"; -import { Check, Copy, Info, Loader2, Share, Trash2 } from "lucide-react"; +import { + Check, + Copy, + Info, + Linkedin, + Loader2, + Share, + Trash2, +} from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -8,7 +16,6 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -23,11 +30,100 @@ import { useDeleteChatShare, useShareChat } from "@/hooks/useChats"; interface ShareChatDialogProps { currentChatId: string; shareId: string | null; + chatTitle?: string | null; + previewPrompt?: string | null; +} + +const URL_DISPLAY_PREFIX = "https://chat.llmgateway.io/share/"; + +function truncateShareUrl(url: string): string { + if (!url) { + return ""; + } + try { + const parsed = new URL(url); + const segments = parsed.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? ""; + const shortId = last.length > 8 ? `${last.slice(0, 6)}…` : last; + const builtPrefix = `${parsed.origin}/${segments.slice(0, -1).join("/")}/`; + const displayPrefix = + builtPrefix.length > URL_DISPLAY_PREFIX.length + ? URL_DISPLAY_PREFIX + : builtPrefix; + return `${displayPrefix}${shortId}`; + } catch { + return url.length > 40 ? `${url.slice(0, 37)}…` : url; + } +} + +function XIcon(props: React.SVGProps) { + return ( + + + + ); +} + +function RedditIcon(props: React.SVGProps) { + return ( + + + + ); +} + +interface ShareSocialButtonProps { + href: string; + label: string; + children: React.ReactNode; +} + +function ShareSocialButton({ href, label, children }: ShareSocialButtonProps) { + return ( + + + {children} + + + {label} + + + ); +} + +function clipPreview( + text: string | null | undefined, + max: number, +): string | null { + if (!text) { + return null; + } + const flat = text.replace(/\s+/g, " ").trim(); + if (!flat) { + return null; + } + return flat.length > max ? `${flat.slice(0, max)}…` : flat; } export function ShareChatDialog({ currentChatId, shareId, + chatTitle, + previewPrompt, }: ShareChatDialogProps) { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -42,6 +138,39 @@ export function ShareChatDialog({ return `${window.location.origin}/share/${shareId}`; }, [shareId]); const activeShareUrl = createdShareUrl ?? shareUrl; + const truncatedDisplayUrl = useMemo( + () => truncateShareUrl(activeShareUrl), + [activeShareUrl], + ); + + const previewTitle = useMemo(() => { + const trimmed = chatTitle?.trim(); + if (trimmed) { + return trimmed; + } + return clipPreview(previewPrompt, 80) ?? "Shared chat"; + }, [chatTitle, previewPrompt]); + + const previewText = useMemo( + () => clipPreview(previewPrompt, 160), + [previewPrompt], + ); + + const shareTitle = previewTitle; + const encodedShareUrl = activeShareUrl + ? encodeURIComponent(activeShareUrl) + : ""; + const encodedShareTitle = encodeURIComponent(shareTitle); + + const xUrl = activeShareUrl + ? `https://x.com/intent/tweet?url=${encodedShareUrl}&text=${encodedShareTitle}` + : ""; + const linkedinUrl = activeShareUrl + ? `https://www.linkedin.com/sharing/share-offsite/?url=${encodedShareUrl}` + : ""; + const redditUrl = activeShareUrl + ? `https://www.reddit.com/submit?url=${encodedShareUrl}&title=${encodedShareTitle}` + : ""; useEffect(() => { if (shareUrl) { @@ -107,27 +236,41 @@ export function ShareChatDialog({

{isShared ? "Public link active" : "Share chat"}

- - - - {isShared ? "Shareable public link" : "Share chat"} + + + + {previewTitle} -
- {isShared && activeShareUrl ? ( -
+ {isShared && activeShareUrl ? ( +
+
+ {previewText ? ( +

+ {previewText} +

+ ) : ( +

+ A snapshot of this chat will be visible to anyone with the + link. +

+ )} +
+
- {activeShareUrl} + {truncatedDisplayUrl} +
+
+ + + + + + + + + + +
+
+ +

+ Anyone with this link can open the snapshot. Avoid sharing + private details, and remove the link when it should no longer be + available. +

+
+
+
- ) : ( +
+ ) : ( +

- Only messages up to this point will be shared. -

- )} -
- -

- Anyone with this link can open the snapshot. Avoid sharing private - details, and remove the link when it should no longer be - available. + Only messages up to this point will be shared. Anyone with the + link will be able to see the snapshot.

+
+ +

+ Avoid sharing private details — once the link is created you can + copy it and share it on X, LinkedIn, or Reddit. +

+
+
+ +
-
- - {isShared ? ( - - ) : ( - - )} - {isShared ? null : ( - - )} - + )} ); diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/page.tsx index c151d5b4a3..6c26c7a0c7 100644 --- a/apps/ui/src/app/dashboard/[orgId]/[projectId]/page.tsx +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/page.tsx @@ -1,6 +1,8 @@ import { subDays, format } from "date-fns"; +import { cookies } from "next/headers"; import { DashboardClient } from "@/components/dashboard/dashboard-client"; +import { DEVPASS_CARD_COLLAPSED_COOKIE } from "@/lib/cookies"; import { fetchServerData } from "@/lib/server-api"; import type { ActivitT } from "@/types/activity"; @@ -38,7 +40,14 @@ export default async function Dashboard({ }, ); + const cookieStore = await cookies(); + const devPassCollapsed = + cookieStore.get(DEVPASS_CARD_COLLAPSED_COOKIE)?.value === "1"; + return ( - + ); } diff --git a/apps/ui/src/components/dashboard/dashboard-client.tsx b/apps/ui/src/components/dashboard/dashboard-client.tsx index bd9fc5845d..ae876ae020 100644 --- a/apps/ui/src/components/dashboard/dashboard-client.tsx +++ b/apps/ui/src/components/dashboard/dashboard-client.tsx @@ -62,9 +62,13 @@ import type { ActivitT } from "@/types/activity"; interface DashboardClientProps { initialActivityData?: ActivitT; + initialDevPassCollapsed?: boolean; } -export function DashboardClient({ initialActivityData }: DashboardClientProps) { +export function DashboardClient({ + initialActivityData, + initialDevPassCollapsed, +}: DashboardClientProps) { const router = useRouter(); const searchParams = useSearchParams(); const { buildUrl, buildOrgUrl } = useDashboardNavigation(); @@ -323,7 +327,7 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { - + diff --git a/apps/ui/src/components/dashboard/devpass-card.tsx b/apps/ui/src/components/dashboard/devpass-card.tsx index 1bbe44d502..5a0763e3db 100644 --- a/apps/ui/src/components/dashboard/devpass-card.tsx +++ b/apps/ui/src/components/dashboard/devpass-card.tsx @@ -1,6 +1,9 @@ "use client"; -import { ArrowUpRight, Terminal } from "lucide-react"; +import { ArrowUpRight, ChevronDown, ChevronUp, Terminal } from "lucide-react"; +import { useState } from "react"; + +import { DEVPASS_CARD_COLLAPSED_COOKIE } from "@/lib/cookies"; import { DEV_PLAN_PRICES } from "@llmgateway/shared"; @@ -15,69 +18,117 @@ const plans = [ { name: "Max", price: `$${DEV_PLAN_PRICES.max}` }, ] as const; -export function DevPassCard() { +interface DevPassCardProps { + defaultCollapsed?: boolean; +} + +export function DevPassCard({ defaultCollapsed = false }: DevPassCardProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + + const toggle = () => { + const next = !collapsed; + setCollapsed(next); + const maxAge = 60 * 60 * 24 * 365; + document.cookie = `${DEVPASS_CARD_COLLAPSED_COOKIE}=${next ? "1" : "0"}; path=/; max-age=${maxAge}; samesite=lax`; + }; + return ( - +
-
-
-
- + {collapsed ? ( +
+
+ +
+
+

DevPass

+ + Separate product +
-
-
-

- DevPass -

- - Separate product - + +
+ ) : ( +
); } diff --git a/apps/ui/src/lib/cookies.ts b/apps/ui/src/lib/cookies.ts new file mode 100644 index 0000000000..21bd5b99bd --- /dev/null +++ b/apps/ui/src/lib/cookies.ts @@ -0,0 +1 @@ +export const DEVPASS_CARD_COLLAPSED_COOKIE = "devpass_card_collapsed"; diff --git a/ee/admin/src/app/devpass/page.tsx b/ee/admin/src/app/devpass/page.tsx index b3b158d03a..175aba659c 100644 --- a/ee/admin/src/app/devpass/page.tsx +++ b/ee/admin/src/app/devpass/page.tsx @@ -465,6 +465,15 @@ export default async function DevpassPage({
Lite {kpis.activeByTier.lite} · Pro {kpis.activeByTier.pro} · Max{" "} {kpis.activeByTier.max} + {kpis.cancelledPending > 0 ? ( + <> + {" "} + ·{" "} + + {kpis.cancelledPending} cancelling + + + ) : null}
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 3a31720eeb..ec9ee3ad1a 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -1275,106 +1275,185 @@ async function seed() { }); // Realistic per-agent activity for the DevPass dashboard and the public - // /apps page. Weights are renormalized internally so it's safe to add or - // reorder entries here without recomputing the totals. - const DEVPASS_AGENTS = [ + // /apps page. Each agent has its own mix of models so per-agent charts show + // a breakdown across multiple models. Weights are renormalized internally + // so it's safe to add or reorder entries. + interface DevpassAgentModel { + model: string; + provider: string; + weight: number; + } + const DEVPASS_AGENTS: Array<{ + source: string; + weight: number; + models: DevpassAgentModel[]; + }> = [ { source: "claude.com/claude-code", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.22, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.7 }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.2 }, + { model: "claude-3-opus", provider: "anthropic", weight: 0.1 }, + ], + }, + { + source: "cursor", + weight: 0.14, + models: [ + { model: "gpt-4o", provider: "openai", weight: 0.45 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.35 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.15 }, + { model: "o1", provider: "openai", weight: 0.05 }, + ], }, - { source: "cursor", model: "gpt-4o", provider: "openai", weight: 0.14 }, { source: "cline", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.1, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.7 }, + { model: "gpt-4o", provider: "openai", weight: 0.2 }, + { model: "deepseek-chat", provider: "deepseek", weight: 0.1 }, + ], + }, + { + source: "codex", + weight: 0.08, + models: [ + { model: "o1", provider: "openai", weight: 0.55 }, + { model: "o3-mini", provider: "openai", weight: 0.3 }, + { model: "gpt-4o", provider: "openai", weight: 0.15 }, + ], }, - { source: "codex", model: "o1", provider: "openai", weight: 0.08 }, { source: "opencode", - model: "claude-3-haiku", - provider: "anthropic", weight: 0.07, + models: [ + { model: "claude-3-haiku", provider: "anthropic", weight: 0.5 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.3 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.2 }, + ], }, { source: "aider", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.06, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.55 }, + { model: "gpt-4o", provider: "openai", weight: 0.3 }, + { model: "deepseek-chat", provider: "deepseek", weight: 0.15 }, + ], }, { source: "continue.dev", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.05, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.5 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.3 }, + { model: "deepseek-chat", provider: "deepseek", weight: 0.2 }, + ], }, { source: "windsurf", - model: "gpt-4o", - provider: "openai", weight: 0.05, + models: [ + { model: "gpt-4o", provider: "openai", weight: 0.5 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.35 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.15 }, + ], }, { source: "roo-cline", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.04, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.65 }, + { model: "gpt-4o", provider: "openai", weight: 0.25 }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.1 }, + ], }, { source: "kilo-code", - model: "deepseek-chat", - provider: "deepseek", weight: 0.03, + models: [ + { model: "deepseek-chat", provider: "deepseek", weight: 0.6 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.25 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.15 }, + ], }, { source: "zed", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.03, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.6 }, + { model: "gpt-4o", provider: "openai", weight: 0.25 }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.15 }, + ], }, { source: "bolt.new", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.03, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.65 }, + { model: "gpt-4o", provider: "openai", weight: 0.25 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.1 }, + ], }, { source: "v0.dev", - model: "gpt-4o", - provider: "openai", weight: 0.025, + models: [ + { model: "gpt-4o", provider: "openai", weight: 0.55 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.35 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.1 }, + ], }, { source: "lovable.dev", - model: "claude-3.5-sonnet", - provider: "anthropic", weight: 0.025, + models: [ + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.6 }, + { model: "gpt-4o", provider: "openai", weight: 0.3 }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.1 }, + ], }, { source: "autohand", - model: "deepseek-chat", - provider: "deepseek", weight: 0.02, + models: [ + { model: "deepseek-chat", provider: "deepseek", weight: 0.55 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.3 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.15 }, + ], }, { source: "soulforge", - model: "gemini-2.0-flash", - provider: "google-ai-studio", weight: 0.02, + models: [ + { + model: "gemini-2.0-flash", + provider: "google-ai-studio", + weight: 0.6, + }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.25 }, + { model: "gemini-1.5-pro", provider: "google-ai-studio", weight: 0.15 }, + ], }, { source: "openclaw", - model: "claude-3-haiku", - provider: "anthropic", weight: 0.015, + models: [ + { model: "claude-3-haiku", provider: "anthropic", weight: 0.55 }, + { model: "claude-3.5-sonnet", provider: "anthropic", weight: 0.3 }, + { model: "gpt-4o-mini", provider: "openai", weight: 0.15 }, + ], }, { source: "n8n", - model: "gpt-4o-mini", - provider: "openai", weight: 0.015, + models: [ + { model: "gpt-4o-mini", provider: "openai", weight: 0.55 }, + { model: "gpt-4o", provider: "openai", weight: 0.3 }, + { model: "claude-3-haiku", provider: "anthropic", weight: 0.15 }, + ], }, ]; const DEVPASS_LOG_COUNT = 1800; @@ -1392,7 +1471,12 @@ async function seed() { acc += a.weight; return r <= acc; }) ?? DEVPASS_AGENTS[0]; - const modelDef = MODELS.find((m) => m.model === agent.model) ?? MODELS[0]; + const agentModel = weightedRandomChoice(agent.models); + const modelDef = + MODELS.find( + (m) => + m.model === agentModel.model && m.provider === agentModel.provider, + ) ?? MODELS[0]; const finishDef = weightedRandomChoice(FINISH_REASONS); const isError = finishDef.unified === "upstream_error" ||