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
20 changes: 20 additions & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { NextRequest } from "next/server";
import { getDb } from "@/lib/db/schema";
import { getNextApiKey } from "@/lib/api-keys";
import { PROVIDER_URLS } from "@/lib/providers";

function estimateTokens(messages: Array<{ role: string; content: string }>): number {
return Math.ceil(JSON.stringify(messages).length / 3);
}

function trackTokenUsage(provider: string, modelId: string, inputTokens: number, outputTokens: number) {
try {
const db = getDb();
db.prepare(
"INSERT INTO token_usage (provider, model_id, input_tokens, output_tokens, estimated_cost_usd) VALUES (?, ?, ?, ?, 0)"
).run(provider, modelId, inputTokens, outputTokens);
} catch {
// non-critical
}
}

export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
Expand Down Expand Up @@ -34,6 +50,7 @@ export async function POST(req: NextRequest) {

// Simple messages format — only role + content string
const cleanMessages = messages.map(m => ({ role: m.role, content: m.content }));
const estimatedInputTokens = estimateTokens(cleanMessages);

const res = await fetch(url, {
method: "POST",
Expand Down Expand Up @@ -62,6 +79,7 @@ export async function POST(req: NextRequest) {
async start(controller) {
const decoder = new TextDecoder();
let buffer = "";
let outputChars = 0;
try {
while (true) {
const { done, value } = await reader.read();
Expand All @@ -79,6 +97,7 @@ export async function POST(req: NextRequest) {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) {
outputChars += content.length;
controller.enqueue(new TextEncoder().encode(content));
}
} catch { /* skip */ }
Expand All @@ -87,6 +106,7 @@ export async function POST(req: NextRequest) {
} catch (err) {
console.error("[chat] stream error:", err);
} finally {
trackTokenUsage(provider, modelId, estimatedInputTokens, Math.ceil(outputChars / 3));
controller.close();
}
},
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export async function GET(req: NextRequest) {
m.context_length as contextLength,
m.tier,
m.nickname,
m.supports_tools as supportsTools,
m.supports_vision as supportsVision,
m.first_seen as firstSeen,
m.last_seen as lastSeen,
h.status as healthStatus,
Expand Down Expand Up @@ -76,6 +78,8 @@ export async function GET(req: NextRequest) {
contextLength: number;
tier: string;
nickname: string | null;
supportsTools: number | null;
supportsVision: number | null;
firstSeen: string;
lastSeen: string;
healthStatus: string | null;
Expand Down Expand Up @@ -106,6 +110,8 @@ export async function GET(req: NextRequest) {
modelId: r.modelId,
contextLength: r.contextLength,
tier: r.tier,
supportsTools: r.supportsTools === 1 ? true : r.supportsTools === 0 ? false : null,
supportsVision: r.supportsVision === 1 ? true : r.supportsVision === 0 ? false : null,
health: {
status: healthStatusFinal,
latencyMs: r.latencyMs ?? 0,
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/db/schema";
import { getCached, setCache } from "@/lib/cache";
import { ensureWorkerStarted } from "@/lib/worker/startup";

export const dynamic = "force-dynamic";

ensureWorkerStarted();

export async function GET() {
try {
const cached = getCached<object>("api:status");
Expand Down
95 changes: 61 additions & 34 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,25 @@ function GatewayConfigCard() {
);
}

function fmtUsd(amount: number) {
if (amount === 0) return "$0.00";
if (amount < 0.01) return `$${amount.toFixed(4)}`;
if (amount < 1) return `$${amount.toFixed(3)}`;
return `$${amount.toFixed(2)}`;
}

function fmtThb(amount: number) {
if (amount === 0) return "฿0";
if (amount < 1) return `฿${amount.toFixed(2)}`;
return `฿${amount.toFixed(0)}`;
}

function fmtTokenSummary(tokens: number) {
if (tokens <= 0) return "0 tokens";
if (tokens < 1000) return `${tokens} tokens`;
return `${(tokens / 1000).toFixed(1)}K tokens`;
}

// ─── Main Dashboard ────────────────────────────────────────────────────────────

export default function Dashboard() {
Expand Down Expand Up @@ -478,58 +497,66 @@ export default function Dashboard() {
<GatewayConfigCard />

{/* Cost Savings Card */}
{costSavings && costSavings.totalTokens > 0 && (
{costSavings && (
<div className="mt-6 glass-bright rounded-2xl p-5 neon-border max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 gap-3">
<div className="flex items-center gap-2">
<span className="text-lg">💰</span>
<span className="font-bold text-white text-lg">เทียบต้นทุน</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-3 text-xs text-gray-500 flex-wrap justify-end">
<span>สะสม {costSavings.totalRequests.toLocaleString()} requests</span>
<span>|</span>
<span>วันนี้ {costSavings.todayRequests.toLocaleString()}</span>
</div>
</div>

{/* Token usage summary */}
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
<span className="text-gray-500">ใช้ไป:</span>
<span className="text-indigo-300 font-bold">{(costSavings.totalTokens / 1000).toFixed(0)}K tokens</span>
<span className="text-gray-600">(input {(costSavings.totalInputTokens / 1000).toFixed(0)}K + output {(costSavings.totalOutputTokens / 1000).toFixed(0)}K)</span>
<span className="text-indigo-300 font-bold">{fmtTokenSummary(costSavings.totalTokens)}</span>
<span className="text-gray-600">(input {fmtTokenSummary(costSavings.totalInputTokens)} + output {fmtTokenSummary(costSavings.totalOutputTokens)})</span>
</div>

{/* Cost comparison — dynamic from API */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 mb-3">
{(costSavings.providers ?? []).map((p) => (
<div key={p.id} className="glass rounded-lg p-3 text-center">
<div className="text-xs text-gray-500 mb-1 truncate">{p.label}</div>
<div className="text-lg font-bold text-red-400">${p.cost.toFixed(2)}</div>
<div className="text-xs text-red-500/60">฿{p.costThb.toFixed(0)}</div>
<div className="text-[10px] text-gray-600 mt-1">${p.inputPrice}/${p.outputPrice} /1M</div>
</div>
))}
<div className="glass rounded-lg p-3 text-center border border-emerald-500/30 bg-emerald-500/5">
<div className="text-xs text-emerald-400 mb-1">BCProxyAI</div>
<div className="text-lg font-bold text-emerald-300">$0.00</div>
<div className="text-xs text-emerald-500">ฟรี!</div>
<div className="text-[10px] text-emerald-600 mt-1">$0/$0 /1M</div>
{costSavings.totalTokens === 0 ? (
<div className="glass rounded-lg p-4 border border-white/10 text-sm text-gray-400">
ยังไม่มี usage ในฐานข้อมูลค่ะ — ตอนนี้ section นี้จะขึ้นเสมอ แม้ยังไม่มี token log
</div>
</div>
) : (
<>
{/* Cost comparison — dynamic from API */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 mb-3">
{(costSavings.providers ?? []).map((p) => (
<div key={p.id} className="glass rounded-lg p-3 text-center">
<div className="text-xs text-gray-500 mb-1 truncate">{p.label}</div>
<div className="text-lg font-bold text-red-400">{fmtUsd(p.cost)}</div>
<div className="text-xs text-red-500/60">{fmtThb(p.costThb)}</div>
<div className="text-[10px] text-gray-600 mt-1">${p.inputPrice}/${p.outputPrice} /1M</div>
</div>
))}
<div className="glass rounded-lg p-3 text-center border border-emerald-500/30 bg-emerald-500/5">
<div className="text-xs text-emerald-400 mb-1">BCProxyAI</div>
<div className="text-lg font-bold text-emerald-300">$0.00</div>
<div className="text-xs text-emerald-500">ฟรี!</div>
<div className="text-[10px] text-emerald-600 mt-1">$0/$0 /1M</div>
</div>
</div>

{/* Total saved highlight — compare all */}
<div className="glass rounded-lg p-3 border border-emerald-500/20 bg-emerald-500/5">
<div className="text-xs text-emerald-400 mb-2 font-semibold">ยอดสะสมที่ประหยัดได้ (เทียบแต่ละเจ้า):</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{(costSavings.providers ?? []).map((p) => (
<div key={p.id} className="text-center">
<div className="text-xs text-gray-500">vs {p.label}</div>
<div className="text-lg font-black text-emerald-300">${p.cost.toFixed(2)}</div>
<div className="text-xs text-emerald-500">฿{p.costThb.toFixed(0)}</div>
{/* Total saved highlight — compare all */}
<div className="glass rounded-lg p-3 border border-emerald-500/20 bg-emerald-500/5">
<div className="text-xs text-emerald-400 mb-2 font-semibold">ยอดสะสมที่ประหยัดได้ (เทียบแต่ละเจ้า):</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{(costSavings.providers ?? []).map((p) => (
<div key={p.id} className="text-center">
<div className="text-xs text-gray-500">vs {p.label}</div>
<div className="text-lg font-black text-emerald-300">{fmtUsd(p.cost)}</div>
<div className="text-xs text-emerald-500">{fmtThb(p.costThb)}</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
)}

Expand Down
28 changes: 25 additions & 3 deletions src/components/ModelGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,35 @@ export function ModelGrid({ sortedModels, availableCount, cooldownCount, unknown
<div className="mb-2" />
)}

{/* Capability badges */}
<div className="flex flex-wrap gap-1.5 mb-2">
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${
model.supportsTools === true
? "text-emerald-300 border-emerald-500/30 bg-emerald-500/10"
: model.supportsTools === false
? "text-red-300 border-red-500/30 bg-red-500/10"
: "text-gray-400 border-white/10 bg-white/5"
}`}>
{model.supportsTools === true ? "tools ✓" : model.supportsTools === false ? "tools ✕" : "tools ?"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${
model.supportsVision === true
? "text-cyan-300 border-cyan-500/30 bg-cyan-500/10"
: model.supportsVision === false
? "text-amber-300 border-amber-500/30 bg-amber-500/10"
: "text-gray-400 border-white/10 bg-white/5"
}`}>
{model.supportsVision === true ? "vision ✓" : model.supportsVision === false ? "vision ✕" : "vision ?"}
</span>
</div>

{/* Fun status line */}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-500 truncate">
{funStatus.emoji} {funStatus.text}
</span>
{gradeInfo && (
<span className={`text-xs ${gradeInfo.color}`}>
<span className={`text-xs shrink-0 ${gradeInfo.color}`}>
{gradeInfo.emoji} {gradeInfo.label}
</span>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/components/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface ModelData {
modelId: string;
contextLength: number;
tier: string;
supportsTools: boolean | null;
supportsVision: boolean | null;
health: HealthInfo;
benchmark: BenchmarkInfo | null;
firstSeen: string;
Expand Down