diff --git a/package.json b/package.json index ca5fefc..e39f194 100644 --- a/package.json +++ b/package.json @@ -9,27 +9,29 @@ "lint": "next lint" }, "dependencies": { - "next": "14.2.3", - "next-auth": "5.0.0-beta.19", - "react": "^18", - "react-dom": "^18", - "typescript": "^5", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", - "tailwindcss": "^3.4.1", - "postcss": "^8", + "@types/ws": "^8.18.1", "autoprefixer": "^10.0.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "tailwind-merge": "^2.3.0", "lucide-react": "^0.379.0", - "@radix-ui/react-tabs": "^1.0.4", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-tooltip": "^1.0.7", - "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-slot": "^1.0.2", - "tailwindcss-animate": "^1.0.7" + "next": "14.2.3", + "next-auth": "5.0.0-beta.19", + "postcss": "^8", + "react": "^18", + "react-dom": "^18", + "tailwind-merge": "^2.3.0", + "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5", + "ws": "^8.20.0" }, "devDependencies": { "eslint": "^8", diff --git a/src/app/api/claude-sub/route.ts b/src/app/api/claude-sub/route.ts new file mode 100644 index 0000000..6f83f59 --- /dev/null +++ b/src/app/api/claude-sub/route.ts @@ -0,0 +1,119 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +const GATEWAY_URL = "ws://192.168.31.103:18789"; +const GATEWAY_TOKEN = "c55292f854e8308c4fed926c40c3a8995a7213fde79fed72"; + +async function gatewayRequest(method: string, params: object = {}): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("Gateway timeout")); + }, 8000); + + const ws = new (require("ws"))(`${GATEWAY_URL}`); + let connected = false; + let reqId = 1; + + ws.on("open", () => {}); + + ws.on("message", (data: Buffer) => { + try { + const msg = JSON.parse(data.toString()); + + // Auth challenge + if (msg.type === "event" && msg.event === "connect.challenge") { + ws.send(JSON.stringify({ + type: "request", + id: String(reqId++), + method: "connect", + params: { token: GATEWAY_TOKEN }, + })); + return; + } + + // Connect response + if (msg.type === "response" && !connected) { + if (msg.ok) { + connected = true; + // Send actual request + ws.send(JSON.stringify({ + type: "request", + id: String(reqId++), + method, + params, + })); + } else { + clearTimeout(timeout); + ws.close(); + reject(new Error("Auth failed")); + } + return; + } + + // Method response + if (msg.type === "response" && connected) { + clearTimeout(timeout); + ws.close(); + if (msg.ok) { + resolve(msg.payload); + } else { + reject(new Error(msg.error || "Request failed")); + } + } + } catch {} + }); + + ws.on("error", (err: Error) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +export async function GET() { + try { + const [statusResult, costResult] = await Promise.allSettled([ + gatewayRequest("usage.status", {}), + gatewayRequest("usage.cost", {}), + ]); + + const status = statusResult.status === "fulfilled" ? statusResult.value : null; + const cost = costResult.status === "fulfilled" ? costResult.value : null; + + // Извлечь данные Anthropic + const anthropic = (status as any)?.providers?.find((p: any) => p.provider === "anthropic"); + const windows = anthropic?.windows || []; + + const window5h = windows.find((w: any) => w.label?.includes("5") || w.label === "5h"); + const windowWeek = windows.find((w: any) => w.label?.toLowerCase().includes("week") || w.label === "Week"); + const windowSonnet = windows.find((w: any) => w.label?.toLowerCase().includes("sonnet")); + + const todayCost = (cost as any)?.daily?.[0]?.totalCost || 0; + const todayTokens = (cost as any)?.daily?.[0]?.totalTokens || 0; + const totalCost = (cost as any)?.totals?.totalCost || 0; + + return NextResponse.json({ + ok: true, + anthropic: { + window5h: window5h || null, + windowWeek: windowWeek || null, + windowSonnet: windowSonnet || null, + error: anthropic?.error || null, + }, + cost: { + today: todayCost, + todayTokens, + total: totalCost, + }, + raw: { status, cost }, + }); + } catch (e) { + return NextResponse.json({ + ok: false, + error: String(e), + anthropic: null, + cost: null, + }); + } +} diff --git a/src/components/widgets/ClaudeUsageWidget.tsx b/src/components/widgets/ClaudeUsageWidget.tsx index 447c3c2..01bfb07 100644 --- a/src/components/widgets/ClaudeUsageWidget.tsx +++ b/src/components/widgets/ClaudeUsageWidget.tsx @@ -1,49 +1,160 @@ "use client"; -import { RefreshCw, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState, useCallback } from "react"; +import { RefreshCw } from "lucide-react"; -export function ClaudeUsageWidget() { - const [refreshing, setRefreshing] = useState(false); +interface UsageWindow { + label: string; + usedPercent: number; + resetAt?: number; +} - const handleRefresh = () => { - setRefreshing(true); - setTimeout(() => setRefreshing(false), 1000); - }; +interface ClaudeData { + ok: boolean; + anthropic: { + window5h: UsageWindow | null; + windowWeek: UsageWindow | null; + windowSonnet: UsageWindow | null; + error: string | null; + } | null; + cost: { + today: number; + todayTokens: number; + total: number; + } | null; + error?: string; +} +function formatTimeLeft(ms: number): string { + const mins = Math.round(ms / 60000); + if (mins < 60) return `${mins}м`; + return `${Math.round(ms / 3600000)}ч`; +} + +function UsageBar({ pct, color }: { pct: number; color: string }) { + const p = Math.min(100, pct); + const barColor = p > 80 ? "#f43f5e" : p > 60 ? "#f59e0b" : color; return ( -
-
-
-
- -
-
-
Claude Sub
-
Usage
-
-
- -
- -
- {[ - { label: "Использовано", value: "—", color: "text-amber-400" }, - { label: "Лимит", value: "—", color: "text-white" }, - { label: "Сброс", value: "—", color: "text-white" }, - ].map((item) => ( -
- {item.label} - {item.value} -
- ))} -
- -
-
- Автополучение недоступно -
+
+
+
+ ); +} + +export function ClaudeUsageWidget() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/claude-sub", { cache: "no-store" }); + const json = await res.json(); + setData(json); + } catch { + setData({ ok: false, anthropic: null, cost: null, error: "Fetch failed" }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + const a = data?.anthropic; + const cost = data?.cost; + + return ( +
+ {/* Header */} +
+
+
+ Claude Subscription +
+ +
+ + {loading ? ( +
+ {[1,2,3].map(i =>
)} +
+ ) : !data?.ok || !a ? ( +
+ {data?.error || "Нет данных"}
+ Gateway недоступен +
+ ) : ( + <> + {/* Today cost */} + {cost && ( +
+ Сегодня +
+ ${cost.today.toFixed(2)} + / {(cost.todayTokens / 1000).toFixed(0)}k токенов +
+
+ )} + + {/* Error from anthropic */} + {a.error && ( +
+ ⚠️ {a.error} +
+ )} + + {/* 5h window */} + {a.window5h && ( +
+
+ Сессия (5ч) +
+ {a.window5h.usedPercent}% + {a.window5h.resetAt && ( + → {formatTimeLeft(a.window5h.resetAt - Date.now())} + )} +
+
+ +
+ )} + + {/* Week window */} + {a.windowWeek && ( +
+
+ Неделя +
+ {a.windowWeek.usedPercent}% + {a.windowWeek.resetAt && ( + → {formatTimeLeft(a.windowWeek.resetAt - Date.now())} + )} +
+
+ +
+ )} + + {/* Sonnet window */} + {a.windowSonnet && ( +
+
+ Sonnet + {a.windowSonnet.usedPercent}% +
+ +
+ )} + + {!a.window5h && !a.windowWeek && !a.windowSonnet && ( +
Данные о лимитах недоступны
+ )} + + )}
); }