Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useChat } from "@ai-sdk/react";
import ChatInput from "@/components/chat-input";
import type { MarketAgentUIMessage } from "@/agent/market-agent";
import MarketView from "@/components/market-view";
import CompareView from "@/components/compare-view";
import PriceHistoryView from "@/components/price-history-view";
import type { MarketUIToolInvocation } from "@/tool/market-tool";
import Image from "next/image";
import { Streamdown } from "streamdown";
Expand Down Expand Up @@ -253,6 +255,22 @@ export default function MarketChat() {
/>
);
}
case "tool-compare": {
const inv = part as { state: string; comparisonData?: string; toolCallId?: string; id?: string };
return inv.state === "ready" && inv.comparisonData ? (
<CompareView key={inv.toolCallId ?? inv.id ?? index} data={inv.comparisonData} />
) : (
<div key={index} className="text-[13px] text-zinc-300 mt-2">Comparing tokens…</div>
);
}
case "tool-priceHistory": {
const inv = part as { state: string; historyData?: string; toolCallId?: string; id?: string };
return inv.state === "ready" && inv.historyData ? (
<PriceHistoryView key={inv.toolCallId ?? inv.id ?? index} data={inv.historyData} />
) : (
<div key={index} className="text-[13px] text-zinc-300 mt-2">Fetching price history…</div>
);
}
}
})}
</div>
Expand Down
97 changes: 97 additions & 0 deletions components/compare-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
interface CompareToken {
id: string;
symbol: string;
name: string;
marketCapRank: number | null;
price?: number;
marketCap?: number;
volume24h?: number;
change24h?: number;
}

interface CompareData {
provider: string;
vsCurrency: string;
tokens: CompareToken[];
}

function fmt(n: number | undefined, currency: string): string {
if (n === undefined) return "—";
if (n >= 1_000_000_000_000) return `$${(n / 1_000_000_000_000).toFixed(2)}T`;
if (n >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(2)}B`;
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
}

function fmtChange(n: number | undefined): { text: string; positive: boolean } {
if (n === undefined) return { text: "—", positive: true };
const positive = n >= 0;
return { text: `${positive ? "+" : ""}${n.toFixed(2)}%`, positive };
}

export default function CompareView({ data }: { data: string }) {
let parsed: CompareData | null = null;
try {
parsed = JSON.parse(data);
} catch {
return (
<div className="rounded-2xl border border-red-900/60 bg-red-950/30 p-3 text-sm text-red-200">
Failed to parse comparison data.
</div>
);
}

if (!parsed) return null;
const { vsCurrency, tokens } = parsed;

return (
<div className="rounded-2xl border border-zinc-900/60 bg-zinc-950/20 p-3">
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex h-5 items-center rounded-full border border-zinc-900/60 bg-zinc-950/30 px-2 text-[11px] font-medium text-zinc-200">
tool
</span>
<span className="text-xs font-medium text-zinc-100">compare</span>
<span className="text-[11px] text-zinc-400">{tokens.map((t) => t.symbol.toUpperCase()).join(" · ")}</span>
</div>

<div className="overflow-x-auto">
<table className="w-full text-[12px] text-zinc-200">
<thead>
<tr className="border-b border-zinc-900/60 text-zinc-400">
<th className="pb-2 text-left font-medium">Token</th>
<th className="pb-2 text-right font-medium">Price</th>
<th className="pb-2 text-right font-medium">24h</th>
<th className="pb-2 text-right font-medium">Market Cap</th>
<th className="pb-2 text-right font-medium">Volume 24h</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const change = fmtChange(token.change24h);
return (
<tr key={token.id} className="border-b border-zinc-900/40 last:border-0">
<td className="py-2 pr-4">
<div className="font-medium">{token.symbol.toUpperCase()}</div>
<div className="text-[11px] text-zinc-400">{token.name}</div>
</td>
<td className="py-2 text-right font-medium">
{fmt(token.price, vsCurrency)}
</td>
<td className={`py-2 text-right font-medium ${change.positive ? "text-green-400" : "text-red-400"}`}>
{change.text}
</td>
<td className="py-2 text-right text-zinc-300">
{fmt(token.marketCap, vsCurrency)}
</td>
<td className="py-2 text-right text-zinc-300">
{fmt(token.volume24h, vsCurrency)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
109 changes: 109 additions & 0 deletions components/price-history-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
interface SparklinePoint {
t: number;
p: number;
}

interface HistoryData {
provider: string;
coinId: string;
token: string;
vsCurrency: string;
days: number;
dataPoints: number;
startPrice: number;
endPrice: number;
high: number;
low: number;
changePercent: number;
startTime: string;
endTime: string;
sparkline: SparklinePoint[];
}

function fmt(n: number, currency: string): string {
if (n >= 1_000_000_000_000) return `$${(n / 1_000_000_000_000).toFixed(2)}T`;
if (n >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(2)}B`;
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
}

function Sparkline({ points, positive }: { points: SparklinePoint[]; positive: boolean }) {
if (points.length < 2) return null;
const prices = points.map((p) => p.p);
const min = Math.min(...prices);
const max = Math.max(...prices);
const range = max - min || 1;
const w = 200;
const h = 40;
const coords = points.map((p, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - ((p.p - min) / range) * h;
return `${x},${y}`;
});
const color = positive ? "#4ade80" : "#f87171";
return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-10" preserveAspectRatio="none">
<polyline
points={coords.join(" ")}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}

export default function PriceHistoryView({ data }: { data: string }) {
let parsed: HistoryData | null = null;
try {
parsed = JSON.parse(data);
} catch {
return (
<div className="rounded-2xl border border-red-900/60 bg-red-950/30 p-3 text-sm text-red-200">
Failed to parse historical data.
</div>
);
}

if (!parsed) return null;
const positive = parsed.changePercent >= 0;
const changeColor = positive ? "text-green-400" : "text-red-400";
const changeText = `${positive ? "+" : ""}${parsed.changePercent.toFixed(2)}%`;

return (
<div className="rounded-2xl border border-zinc-900/60 bg-zinc-950/20 p-3">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="inline-flex h-5 items-center rounded-full border border-zinc-900/60 bg-zinc-950/30 px-2 text-[11px] font-medium text-zinc-200">
tool
</span>
<span className="text-xs font-medium text-zinc-100">priceHistory</span>
<span className="text-[11px] text-zinc-400">
{parsed.token.toUpperCase()} · {parsed.days}d
</span>
</div>
<span className={`text-sm font-semibold ${changeColor}`}>{changeText}</span>
</div>

<div className="mb-3">
<Sparkline points={parsed.sparkline} positive={positive} />
</div>

<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ label: "Start", value: fmt(parsed.startPrice, parsed.vsCurrency) },
{ label: "End", value: fmt(parsed.endPrice, parsed.vsCurrency) },
{ label: "High", value: fmt(parsed.high, parsed.vsCurrency) },
{ label: "Low", value: fmt(parsed.low, parsed.vsCurrency) },
].map(({ label, value }) => (
<div key={label} className="rounded-xl border border-zinc-900/60 bg-black/20 p-2">
<div className="text-[11px] text-zinc-400">{label}</div>
<div className="text-[13px] font-medium text-zinc-100">{value}</div>
</div>
))}
</div>
</div>
);
}