@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +46 -0
  3. package/LICENSE +21 -0
  4. package/README.md +34 -1
  5. package/bun.lock +1 -0
  6. package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
  7. package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
  8. package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
  9. package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
  10. package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
  11. package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
  12. package/dist/web/assets/index-qElHXk-7.js +28 -0
  13. package/dist/web/assets/index-sMxUHxFZ.css +2 -0
  14. package/dist/web/assets/input-CVIzrYsH.js +41 -0
  15. package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
  17. package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
  18. package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
  19. package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
  20. package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
  21. package/dist/web/index.html +4 -4
  22. package/dist/web/sw.js +1 -1
  23. package/docs/codebase-summary.md +16 -5
  24. package/docs/system-architecture.md +20 -2
  25. package/package.json +4 -1
  26. package/src/lib/account-crypto.ts +53 -0
  27. package/src/providers/claude-agent-sdk.ts +77 -3
  28. package/src/server/index.ts +8 -0
  29. package/src/server/routes/accounts.ts +165 -0
  30. package/src/server/routes/chat.ts +2 -0
  31. package/src/services/account-selector.service.ts +109 -0
  32. package/src/services/account.service.ts +411 -0
  33. package/src/services/claude-usage.service.ts +186 -124
  34. package/src/services/db.service.ts +117 -3
  35. package/src/types/chat.ts +2 -0
  36. package/src/web/app.tsx +0 -4
  37. package/src/web/components/chat/chat-history-bar.tsx +3 -0
  38. package/src/web/components/chat/usage-badge.tsx +86 -12
  39. package/src/web/components/settings/accounts-settings-section.tsx +358 -0
  40. package/src/web/components/settings/settings-tab.tsx +11 -0
  41. package/src/web/components/ui/badge.tsx +36 -0
  42. package/src/web/components/ui/switch.tsx +27 -0
  43. package/src/web/hooks/use-usage.ts +1 -1
  44. package/src/web/lib/api-settings.ts +65 -0
  45. package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
  46. package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
  47. package/dist/web/assets/index-4pPCbWJp.css +0 -2
  48. package/dist/web/assets/index-DaQYRomz.js +0 -29
  49. package/dist/web/assets/input-P_K5CUiy.js +0 -41
  50. package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
  51. package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
  52. 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 hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
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
- {hasBuckets ? (
152
- <div className="space-y-2.5">
153
- <BucketRow label="5-Hour Session" bucket={usage.session} />
154
- <BucketRow label="Weekly" bucket={usage.weekly} />
155
- <BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
156
- <BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
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
- <p className="text-xs text-text-subtle">
160
- No data — run <code className="bg-surface-elevated px-1 rounded">bun install</code>
161
- </p>
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 = 30_000; // read cache every 30s
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;