@hienlh/ppm 0.7.8 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +46 -0
- package/LICENSE +21 -0
- package/README.md +34 -1
- package/bun.lock +1 -0
- package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
- package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
- package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
- package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
- package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
- package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
- package/dist/web/assets/index-qElHXk-7.js +28 -0
- package/dist/web/assets/index-sMxUHxFZ.css +2 -0
- package/dist/web/assets/input-CVIzrYsH.js +41 -0
- package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
- package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
- package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
- package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
- package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +16 -5
- package/docs/system-architecture.md +20 -2
- package/package.json +4 -1
- package/src/lib/account-crypto.ts +53 -0
- package/src/providers/claude-agent-sdk.ts +77 -3
- package/src/server/index.ts +8 -0
- package/src/server/routes/accounts.ts +165 -0
- package/src/server/routes/chat.ts +2 -0
- package/src/services/account-selector.service.ts +109 -0
- package/src/services/account.service.ts +411 -0
- package/src/services/claude-usage.service.ts +186 -124
- package/src/services/db.service.ts +117 -3
- package/src/types/chat.ts +2 -0
- package/src/web/app.tsx +0 -4
- package/src/web/components/chat/chat-history-bar.tsx +3 -0
- package/src/web/components/chat/usage-badge.tsx +86 -12
- package/src/web/components/settings/accounts-settings-section.tsx +358 -0
- package/src/web/components/settings/settings-tab.tsx +11 -0
- package/src/web/components/ui/badge.tsx +36 -0
- package/src/web/components/ui/switch.tsx +27 -0
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-settings.ts +65 -0
- package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
- package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
- package/dist/web/assets/index-4pPCbWJp.css +0 -2
- package/dist/web/assets/index-DaQYRomz.js +0 -29
- package/dist/web/assets/input-P_K5CUiy.js +0 -41
- package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
- package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
- package/src/web/hooks/use-health-check.ts +0 -95
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
1
2
|
import { Activity, RefreshCw } from "lucide-react";
|
|
2
3
|
import type { UsageInfo, LimitBucket } from "../../../types/chat";
|
|
4
|
+
import { getAllAccountUsages, type AccountUsageEntry } from "../../lib/api-settings";
|
|
3
5
|
|
|
4
6
|
interface UsageBadgeProps {
|
|
5
7
|
usage: UsageInfo;
|
|
@@ -56,7 +58,6 @@ interface UsageDetailPanelProps {
|
|
|
56
58
|
|
|
57
59
|
function formatResetTime(bucket?: LimitBucket): string | null {
|
|
58
60
|
if (!bucket) return null;
|
|
59
|
-
// Compute total minutes from whichever field is available
|
|
60
61
|
let totalMins: number | null = null;
|
|
61
62
|
if (bucket.resetsInMinutes != null) {
|
|
62
63
|
totalMins = bucket.resetsInMinutes;
|
|
@@ -113,14 +114,70 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
|
|
|
113
114
|
return `${mins}m ago`;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function AccountUsageCard({ entry, isActive }: {
|
|
118
|
+
entry: AccountUsageEntry;
|
|
119
|
+
isActive: boolean;
|
|
120
|
+
}) {
|
|
121
|
+
const { usage } = entry;
|
|
122
|
+
const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={`rounded-md border p-2 space-y-1.5 ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
|
|
126
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
127
|
+
<span className="text-xs font-medium truncate flex-1 min-w-0">
|
|
128
|
+
{entry.accountLabel ?? entry.accountId.slice(0, 8)}
|
|
129
|
+
</span>
|
|
130
|
+
{isActive && (
|
|
131
|
+
<span className="text-[9px] px-1 py-0 rounded bg-primary/20 text-primary font-medium shrink-0">In use</span>
|
|
132
|
+
)}
|
|
133
|
+
{!entry.isOAuth && (
|
|
134
|
+
<span className="text-[9px] text-text-subtle shrink-0">API key</span>
|
|
135
|
+
)}
|
|
136
|
+
{entry.accountStatus === "disabled" && (
|
|
137
|
+
<span className="text-[9px] text-text-subtle shrink-0">disabled</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
{hasBuckets ? (
|
|
141
|
+
<div className="space-y-1.5">
|
|
142
|
+
<BucketRow label="5-Hour Session" bucket={usage.session} />
|
|
143
|
+
<BucketRow label="Weekly" bucket={usage.weekly} />
|
|
144
|
+
<BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
|
|
145
|
+
<BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<p className="text-[10px] text-text-subtle">
|
|
149
|
+
{entry.isOAuth ? "No usage data yet" : "Usage tracking not available for API keys"}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{usage.lastFetchedAt && (
|
|
153
|
+
<p className="text-[9px] text-text-subtle">
|
|
154
|
+
Updated: {formatLastUpdated(new Date(usage.lastFetchedAt).getTime())}
|
|
155
|
+
</p>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
116
161
|
export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastFetchedAt }: UsageDetailPanelProps) {
|
|
162
|
+
const [allUsages, setAllUsages] = useState<AccountUsageEntry[]>([]);
|
|
163
|
+
const [loadingAll, setLoadingAll] = useState(false);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!visible) return;
|
|
167
|
+
setLoadingAll(true);
|
|
168
|
+
getAllAccountUsages()
|
|
169
|
+
.then(setAllUsages)
|
|
170
|
+
.catch(() => {})
|
|
171
|
+
.finally(() => setLoadingAll(false));
|
|
172
|
+
}, [visible]);
|
|
173
|
+
|
|
117
174
|
if (!visible) return null;
|
|
118
175
|
|
|
119
176
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
120
|
-
const
|
|
177
|
+
const hasMultipleAccounts = allUsages.length > 0;
|
|
121
178
|
|
|
122
179
|
return (
|
|
123
|
-
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5">
|
|
180
|
+
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
|
|
124
181
|
<div className="flex items-center justify-between">
|
|
125
182
|
<div className="flex items-center gap-2">
|
|
126
183
|
<span className="text-xs font-semibold text-text-primary">Usage Limits</span>
|
|
@@ -148,17 +205,34 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
148
205
|
</div>
|
|
149
206
|
</div>
|
|
150
207
|
|
|
151
|
-
{
|
|
152
|
-
<div className="
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
208
|
+
{hasMultipleAccounts ? (
|
|
209
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-1.5">
|
|
210
|
+
{loadingAll ? (
|
|
211
|
+
<p className="text-[10px] text-text-subtle">Loading accounts...</p>
|
|
212
|
+
) : (
|
|
213
|
+
allUsages.map((entry) => (
|
|
214
|
+
<AccountUsageCard
|
|
215
|
+
key={entry.accountId}
|
|
216
|
+
entry={entry}
|
|
217
|
+
isActive={entry.accountId === usage.activeAccountId}
|
|
218
|
+
/>
|
|
219
|
+
))
|
|
220
|
+
)}
|
|
157
221
|
</div>
|
|
158
222
|
) : (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
223
|
+
// Fallback: single-account view (legacy or no accounts configured)
|
|
224
|
+
<>
|
|
225
|
+
{usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet ? (
|
|
226
|
+
<div className="space-y-2.5">
|
|
227
|
+
<BucketRow label="5-Hour Session" bucket={usage.session} />
|
|
228
|
+
<BucketRow label="Weekly" bucket={usage.weekly} />
|
|
229
|
+
<BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
|
|
230
|
+
<BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
|
|
231
|
+
</div>
|
|
232
|
+
) : (
|
|
233
|
+
<p className="text-xs text-text-subtle">No usage data available</p>
|
|
234
|
+
)}
|
|
235
|
+
</>
|
|
162
236
|
)}
|
|
163
237
|
|
|
164
238
|
{hasCost && (
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
import { Switch } from "@/components/ui/switch";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Label } from "@/components/ui/label";
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
8
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
9
|
+
import {
|
|
10
|
+
getAccounts,
|
|
11
|
+
getActiveAccount,
|
|
12
|
+
addAccount,
|
|
13
|
+
deleteAccount,
|
|
14
|
+
patchAccount,
|
|
15
|
+
getAccountSettings,
|
|
16
|
+
updateAccountSettings,
|
|
17
|
+
getAllAccountUsages,
|
|
18
|
+
type AccountInfo,
|
|
19
|
+
type AccountSettings,
|
|
20
|
+
type AccountUsageEntry,
|
|
21
|
+
} from "../../lib/api-settings";
|
|
22
|
+
|
|
23
|
+
function detectTokenType(token: string): string {
|
|
24
|
+
if (token.startsWith("sk-ant-oat")) return "OAuth token (Claude Max/Pro)";
|
|
25
|
+
if (token.startsWith("sk-ant-api")) return "API key";
|
|
26
|
+
return "Unknown format";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function miniBarColor(pct: number): string {
|
|
30
|
+
if (pct >= 90) return "bg-red-500";
|
|
31
|
+
if (pct >= 70) return "bg-amber-500";
|
|
32
|
+
return "bg-green-500";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function miniPctColor(pct: number): string {
|
|
36
|
+
if (pct >= 90) return "text-red-500";
|
|
37
|
+
if (pct >= 70) return "text-amber-500";
|
|
38
|
+
return "text-green-500";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function MiniBar({ label, value }: { label: string; value: number }) {
|
|
42
|
+
const pct = Math.round(value * 100);
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex items-center gap-1">
|
|
45
|
+
<span className="text-[9px] text-muted-foreground w-5 shrink-0">{label}</span>
|
|
46
|
+
<div className="w-12 h-1.5 rounded-full bg-border overflow-hidden">
|
|
47
|
+
<div className={`h-full rounded-full ${miniBarColor(pct)}`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
48
|
+
</div>
|
|
49
|
+
<span className={`text-[9px] tabular-nums w-7 ${miniPctColor(pct)}`}>{pct}%</span>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function CompactUsageBars({ usage }: { usage: AccountUsageEntry["usage"] }) {
|
|
55
|
+
if (!usage.session && !usage.weekly) return null;
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-col gap-0.5 mt-1">
|
|
58
|
+
{usage.session && <MiniBar label="5h" value={usage.session.utilization} />}
|
|
59
|
+
{usage.weekly && <MiniBar label="Wk" value={usage.weekly.utilization} />}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function AccountsSettingsSection() {
|
|
65
|
+
const [accounts, setAccounts] = useState<AccountInfo[]>([]);
|
|
66
|
+
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
|
|
67
|
+
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
|
68
|
+
const [usageMap, setUsageMap] = useState<Map<string, AccountUsageEntry["usage"]>>(new Map());
|
|
69
|
+
const [loading, setLoading] = useState(true);
|
|
70
|
+
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
71
|
+
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
72
|
+
const [newToken, setNewToken] = useState("");
|
|
73
|
+
const [newLabel, setNewLabel] = useState("");
|
|
74
|
+
const [adding, setAdding] = useState(false);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
refresh();
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
async function refresh() {
|
|
81
|
+
setLoading(true);
|
|
82
|
+
try {
|
|
83
|
+
const [accs, cfg, active, usages] = await Promise.all([
|
|
84
|
+
getAccounts(), getAccountSettings(), getActiveAccount(), getAllAccountUsages(),
|
|
85
|
+
]);
|
|
86
|
+
setAccounts(accs);
|
|
87
|
+
setSettings(cfg);
|
|
88
|
+
setActiveAccountId(active?.id ?? null);
|
|
89
|
+
setUsageMap(new Map(usages.map((u) => [u.accountId, u.usage])));
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
setLoading(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function handleAddAccount() {
|
|
97
|
+
if (!newToken.trim()) return;
|
|
98
|
+
setAdding(true);
|
|
99
|
+
try {
|
|
100
|
+
await addAccount({ apiKey: newToken.trim(), label: newLabel.trim() || undefined });
|
|
101
|
+
setMessage({ type: "success", text: "Account added successfully!" });
|
|
102
|
+
setShowAddDialog(false);
|
|
103
|
+
setNewToken("");
|
|
104
|
+
setNewLabel("");
|
|
105
|
+
refresh();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setMessage({ type: "error", text: (e as Error).message });
|
|
108
|
+
}
|
|
109
|
+
setAdding(false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleToggle(id: string, currentStatus: string) {
|
|
113
|
+
const newStatus = currentStatus === "disabled" ? "active" : "disabled";
|
|
114
|
+
await patchAccount(id, { status: newStatus });
|
|
115
|
+
refresh();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleDelete(id: string, email: string | null) {
|
|
119
|
+
if (!confirm(`Remove account ${email ?? id}?`)) return;
|
|
120
|
+
await deleteAccount(id);
|
|
121
|
+
refresh();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatLastUsed(ts: number | null): string {
|
|
125
|
+
if (!ts) return "Never";
|
|
126
|
+
const diff = Math.floor(Date.now() / 1000 - ts);
|
|
127
|
+
if (diff < 60) return "Just now";
|
|
128
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
129
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
130
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatCooldown(cooldownUntil: number | null): string {
|
|
134
|
+
if (!cooldownUntil) return "";
|
|
135
|
+
const remaining = cooldownUntil - Math.floor(Date.now() / 1000);
|
|
136
|
+
if (remaining <= 0) return "";
|
|
137
|
+
if (remaining < 60) return `${remaining}s`;
|
|
138
|
+
return `${Math.floor(remaining / 60)}m ${remaining % 60}s`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function statusBadge(acc: AccountInfo) {
|
|
142
|
+
if (acc.status === "active") return <Badge variant="default" className="text-[10px] px-1.5 py-0">Active</Badge>;
|
|
143
|
+
if (acc.status === "disabled") return <Badge variant="secondary" className="text-[10px] px-1.5 py-0">Disabled</Badge>;
|
|
144
|
+
const cd = formatCooldown(acc.cooldownUntil);
|
|
145
|
+
return <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Cooldown{cd ? ` (${cd})` : ""}</Badge>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function handleExport() {
|
|
149
|
+
window.location.href = "/api/accounts/export";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
|
|
153
|
+
const file = e.target.files?.[0];
|
|
154
|
+
if (!file) return;
|
|
155
|
+
const text = await file.text();
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch("/api/accounts/import", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: text,
|
|
161
|
+
});
|
|
162
|
+
const json = await res.json() as { ok: boolean; data?: { imported: number }; error?: string };
|
|
163
|
+
if (json.ok) {
|
|
164
|
+
setMessage({ type: "success", text: `Imported ${json.data?.imported ?? 0} account(s).` });
|
|
165
|
+
refresh();
|
|
166
|
+
} else {
|
|
167
|
+
setMessage({ type: "error", text: json.error ?? "Import failed" });
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
setMessage({ type: "error", text: "Import failed" });
|
|
171
|
+
}
|
|
172
|
+
e.target.value = "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const tokenHint = newToken.trim() ? detectTokenType(newToken.trim()) : "";
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="space-y-4">
|
|
179
|
+
<div>
|
|
180
|
+
<p className="text-[11px] text-muted-foreground mb-3">
|
|
181
|
+
Connect multiple Claude accounts. PPM rotates between them automatically to avoid rate limits.
|
|
182
|
+
</p>
|
|
183
|
+
|
|
184
|
+
{message && (
|
|
185
|
+
<div className={`text-[11px] mb-3 p-2 rounded ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
|
186
|
+
{message.text}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<div className="space-y-1.5 mb-3">
|
|
191
|
+
{loading && <p className="text-[11px] text-muted-foreground">Loading...</p>}
|
|
192
|
+
{!loading && accounts.length === 0 && (
|
|
193
|
+
<p className="text-[11px] text-muted-foreground">No accounts connected.</p>
|
|
194
|
+
)}
|
|
195
|
+
{accounts.map((acc) => (
|
|
196
|
+
<div key={acc.id} className={`flex items-center justify-between p-2.5 rounded-lg border bg-card gap-2 ${acc.id === activeAccountId ? "ring-1 ring-primary/50 border-primary/30" : ""}`}>
|
|
197
|
+
<div className="flex-1 min-w-0">
|
|
198
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
199
|
+
<span className="text-xs font-medium truncate">{acc.label ?? acc.email ?? acc.id.slice(0, 8)}</span>
|
|
200
|
+
{statusBadge(acc)}
|
|
201
|
+
{acc.id === activeAccountId && <Badge variant="outline" className="text-[9px] px-1 py-0 border-primary/40 text-primary">In use</Badge>}
|
|
202
|
+
</div>
|
|
203
|
+
<div className="text-[11px] text-muted-foreground mt-0.5 flex gap-2 flex-wrap">
|
|
204
|
+
{acc.email && acc.label && <span>{acc.email}</span>}
|
|
205
|
+
<span>{acc.totalRequests} reqs</span>
|
|
206
|
+
<span>Last: {formatLastUsed(acc.lastUsedAt)}</span>
|
|
207
|
+
</div>
|
|
208
|
+
{usageMap.get(acc.id) && <CompactUsageBars usage={usageMap.get(acc.id)!} />}
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
211
|
+
<Switch
|
|
212
|
+
checked={acc.status !== "disabled"}
|
|
213
|
+
onCheckedChange={() => handleToggle(acc.id, acc.status)}
|
|
214
|
+
disabled={acc.status === "cooldown"}
|
|
215
|
+
className="scale-75"
|
|
216
|
+
/>
|
|
217
|
+
<Button
|
|
218
|
+
size="sm"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
|
221
|
+
onClick={() => handleDelete(acc.id, acc.email)}
|
|
222
|
+
>
|
|
223
|
+
✕
|
|
224
|
+
</Button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
231
|
+
<Button size="sm" className="h-7 text-xs" onClick={() => setShowAddDialog(true)}>
|
|
232
|
+
+ Add Account
|
|
233
|
+
</Button>
|
|
234
|
+
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleExport}>
|
|
235
|
+
Export
|
|
236
|
+
</Button>
|
|
237
|
+
<label>
|
|
238
|
+
<Button size="sm" variant="outline" className="h-7 text-xs" asChild>
|
|
239
|
+
<span>Import</span>
|
|
240
|
+
</Button>
|
|
241
|
+
<input type="file" accept=".json" className="hidden" onChange={handleImport} />
|
|
242
|
+
</label>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{settings && (
|
|
247
|
+
<div className="border-t pt-3 space-y-2">
|
|
248
|
+
<p className="text-[11px] font-medium text-muted-foreground">Rotation Settings</p>
|
|
249
|
+
<div className="flex items-center gap-2">
|
|
250
|
+
<label className="text-[11px] text-muted-foreground w-20 shrink-0">Strategy</label>
|
|
251
|
+
<Select
|
|
252
|
+
value={settings.strategy}
|
|
253
|
+
onValueChange={async (v) => {
|
|
254
|
+
const updated = await updateAccountSettings({ strategy: v as "round-robin" | "fill-first" });
|
|
255
|
+
setSettings(updated);
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<SelectTrigger className="w-32 h-7 text-xs">
|
|
259
|
+
<SelectValue />
|
|
260
|
+
</SelectTrigger>
|
|
261
|
+
<SelectContent>
|
|
262
|
+
<SelectItem value="round-robin">Round-robin</SelectItem>
|
|
263
|
+
<SelectItem value="fill-first">Fill-first</SelectItem>
|
|
264
|
+
</SelectContent>
|
|
265
|
+
</Select>
|
|
266
|
+
</div>
|
|
267
|
+
<div className="flex items-center gap-2">
|
|
268
|
+
<label className="text-[11px] text-muted-foreground w-20 shrink-0">Max retry</label>
|
|
269
|
+
<input
|
|
270
|
+
type="number"
|
|
271
|
+
min={0}
|
|
272
|
+
value={settings.maxRetry}
|
|
273
|
+
className="w-14 h-7 text-xs border rounded px-2 bg-background"
|
|
274
|
+
onChange={async (e) => {
|
|
275
|
+
const v = parseInt(e.target.value, 10);
|
|
276
|
+
if (!isNaN(v) && v >= 0) {
|
|
277
|
+
const updated = await updateAccountSettings({ maxRetry: v });
|
|
278
|
+
setSettings(updated);
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
<span className="text-[11px] text-muted-foreground">(0 = try all)</span>
|
|
283
|
+
</div>
|
|
284
|
+
<p className="text-[11px] text-muted-foreground">
|
|
285
|
+
Active accounts: {settings.activeCount}
|
|
286
|
+
</p>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
291
|
+
<DialogContent className="sm:max-w-md">
|
|
292
|
+
<DialogHeader>
|
|
293
|
+
<DialogTitle className="text-sm">Add Claude Account</DialogTitle>
|
|
294
|
+
<DialogDescription className="text-xs leading-relaxed">
|
|
295
|
+
Supports both Claude Max/Pro session tokens and API keys. Token is encrypted and stored locally.
|
|
296
|
+
</DialogDescription>
|
|
297
|
+
</DialogHeader>
|
|
298
|
+
<div className="space-y-3">
|
|
299
|
+
<div className="space-y-1.5">
|
|
300
|
+
<Label htmlFor="token" className="text-xs">Token</Label>
|
|
301
|
+
<Input
|
|
302
|
+
id="token"
|
|
303
|
+
type="password"
|
|
304
|
+
placeholder="sk-ant-..."
|
|
305
|
+
value={newToken}
|
|
306
|
+
onChange={(e) => setNewToken(e.target.value)}
|
|
307
|
+
className="text-xs h-8 font-mono"
|
|
308
|
+
/>
|
|
309
|
+
{tokenHint && (
|
|
310
|
+
<p className="text-[10px] text-muted-foreground">
|
|
311
|
+
Detected: {tokenHint}
|
|
312
|
+
</p>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
<div className="space-y-1.5">
|
|
316
|
+
<Label htmlFor="label" className="text-xs">Label (optional)</Label>
|
|
317
|
+
<Input
|
|
318
|
+
id="label"
|
|
319
|
+
placeholder="e.g. Personal, Work"
|
|
320
|
+
value={newLabel}
|
|
321
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
322
|
+
className="text-xs h-8"
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
<div className="rounded-md bg-muted/50 p-2.5 space-y-1.5">
|
|
326
|
+
<p className="text-[10px] font-medium text-muted-foreground">How to get your token:</p>
|
|
327
|
+
<div className="text-[10px] text-muted-foreground space-y-1">
|
|
328
|
+
<p><span className="font-medium">Claude Max/Pro:</span> Run in terminal:</p>
|
|
329
|
+
<code className="block bg-background rounded px-1.5 py-1 text-[10px] font-mono select-all">
|
|
330
|
+
claude setup-token
|
|
331
|
+
</code>
|
|
332
|
+
<p className="text-[9px]">Follow the prompts to generate a long-lived token (valid for 1 year).</p>
|
|
333
|
+
<p className="mt-1"><span className="font-medium">API key:</span>{" "}
|
|
334
|
+
<a
|
|
335
|
+
href="https://console.anthropic.com/settings/keys"
|
|
336
|
+
target="_blank"
|
|
337
|
+
rel="noopener noreferrer"
|
|
338
|
+
className="text-primary underline hover:no-underline"
|
|
339
|
+
>
|
|
340
|
+
console.anthropic.com/settings/keys
|
|
341
|
+
</a>
|
|
342
|
+
</p>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
<DialogFooter>
|
|
347
|
+
<Button size="sm" variant="outline" className="text-xs h-7" onClick={() => setShowAddDialog(false)}>
|
|
348
|
+
Cancel
|
|
349
|
+
</Button>
|
|
350
|
+
<Button size="sm" className="text-xs h-7" onClick={handleAddAccount} disabled={!newToken.trim() || adding}>
|
|
351
|
+
{adding ? "Adding..." : "Add Account"}
|
|
352
|
+
</Button>
|
|
353
|
+
</DialogFooter>
|
|
354
|
+
</DialogContent>
|
|
355
|
+
</Dialog>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
|
|
6
6
|
import { AISettingsSection } from "./ai-settings-section";
|
|
7
7
|
import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
|
|
8
8
|
import { TelegramSettingsSection } from "./telegram-settings-section";
|
|
9
|
+
import { AccountsSettingsSection } from "./accounts-settings-section";
|
|
9
10
|
import { usePushNotification } from "@/hooks/use-push-notification";
|
|
10
11
|
|
|
11
12
|
const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
|
|
@@ -137,6 +138,16 @@ export function SettingsTab() {
|
|
|
137
138
|
</AccordionContent>
|
|
138
139
|
</AccordionItem>
|
|
139
140
|
|
|
141
|
+
{/* Accounts */}
|
|
142
|
+
<AccordionItem value="accounts">
|
|
143
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
144
|
+
Accounts
|
|
145
|
+
</AccordionTrigger>
|
|
146
|
+
<AccordionContent className="pb-2">
|
|
147
|
+
<AccountsSettingsSection />
|
|
148
|
+
</AccordionContent>
|
|
149
|
+
</AccordionItem>
|
|
150
|
+
|
|
140
151
|
{/* Keyboard Shortcuts */}
|
|
141
152
|
<AccordionItem value="shortcuts">
|
|
142
153
|
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const badgeVariants = cva(
|
|
7
|
+
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default:
|
|
12
|
+
"border-transparent bg-primary text-primary-foreground shadow",
|
|
13
|
+
secondary:
|
|
14
|
+
"border-transparent bg-secondary text-secondary-foreground",
|
|
15
|
+
destructive:
|
|
16
|
+
"border-transparent bg-destructive text-destructive-foreground shadow",
|
|
17
|
+
outline: "text-foreground",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
variant: "default",
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export interface BadgeProps
|
|
27
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
28
|
+
VariantProps<typeof badgeVariants> {}
|
|
29
|
+
|
|
30
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const Switch = React.forwardRef<
|
|
7
|
+
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
|
8
|
+
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
9
|
+
>(({ className, ...props }, ref) => (
|
|
10
|
+
<SwitchPrimitives.Root
|
|
11
|
+
className={cn(
|
|
12
|
+
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
13
|
+
className
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
ref={ref}
|
|
17
|
+
>
|
|
18
|
+
<SwitchPrimitives.Thumb
|
|
19
|
+
className={cn(
|
|
20
|
+
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
|
21
|
+
)}
|
|
22
|
+
/>
|
|
23
|
+
</SwitchPrimitives.Root>
|
|
24
|
+
))
|
|
25
|
+
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
26
|
+
|
|
27
|
+
export { Switch }
|
|
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { getAuthToken, projectUrl } from "@/lib/api-client";
|
|
3
3
|
import type { UsageInfo } from "../../types/chat";
|
|
4
4
|
|
|
5
|
-
const POLL_INTERVAL =
|
|
5
|
+
const POLL_INTERVAL = 120_000; // read cache every 2min
|
|
6
6
|
|
|
7
7
|
interface UseUsageReturn {
|
|
8
8
|
usageInfo: UsageInfo;
|
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
import { api } from "./api-client";
|
|
2
2
|
|
|
3
|
+
export interface AccountInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string | null;
|
|
6
|
+
email: string | null;
|
|
7
|
+
expiresAt: number | null;
|
|
8
|
+
status: "active" | "cooldown" | "disabled";
|
|
9
|
+
cooldownUntil: number | null;
|
|
10
|
+
priority: number;
|
|
11
|
+
totalRequests: number;
|
|
12
|
+
lastUsedAt: number | null;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AccountSettings {
|
|
17
|
+
strategy: "round-robin" | "fill-first";
|
|
18
|
+
maxRetry: number;
|
|
19
|
+
activeCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getAccounts(): Promise<AccountInfo[]> {
|
|
23
|
+
return api.get<AccountInfo[]>("/api/accounts");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getActiveAccount(): Promise<AccountInfo | null> {
|
|
27
|
+
return api.get<AccountInfo | null>("/api/accounts/active");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addAccount(params: { apiKey: string; label?: string }): Promise<AccountInfo> {
|
|
31
|
+
return api.post<AccountInfo>("/api/accounts", params);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deleteAccount(id: string): Promise<void> {
|
|
35
|
+
return api.del(`/api/accounts/${id}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function patchAccount(id: string, updates: { status: string }): Promise<AccountInfo | null> {
|
|
39
|
+
return api.patch<AccountInfo | null>(`/api/accounts/${id}`, updates);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAccountSettings(): Promise<AccountSettings> {
|
|
43
|
+
return api.get<AccountSettings>("/api/accounts/settings");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function updateAccountSettings(s: Partial<Omit<AccountSettings, "activeCount">>): Promise<AccountSettings> {
|
|
47
|
+
return api.put<AccountSettings>("/api/accounts/settings", s);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AccountUsageEntry {
|
|
51
|
+
accountId: string;
|
|
52
|
+
accountLabel: string | null;
|
|
53
|
+
accountStatus: string;
|
|
54
|
+
isOAuth: boolean;
|
|
55
|
+
usage: {
|
|
56
|
+
lastFetchedAt?: string;
|
|
57
|
+
session?: import("../../types/chat").LimitBucket;
|
|
58
|
+
weekly?: import("../../types/chat").LimitBucket;
|
|
59
|
+
weeklyOpus?: import("../../types/chat").LimitBucket;
|
|
60
|
+
weeklySonnet?: import("../../types/chat").LimitBucket;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getAllAccountUsages(): Promise<AccountUsageEntry[]> {
|
|
65
|
+
return api.get<AccountUsageEntry[]>("/api/accounts/usage");
|
|
66
|
+
}
|
|
67
|
+
|
|
3
68
|
export interface AIProviderSettings {
|
|
4
69
|
type?: string;
|
|
5
70
|
api_key_env?: string;
|