@hienlh/ppm 0.7.7 → 0.7.9
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 +19 -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-BkUbaCUF.js +7 -0
- package/dist/web/assets/{code-editor-B591tEtI.js → code-editor-BodXBGgt.js} +1 -1
- package/dist/web/assets/{database-viewer-DG0Ofsw7.js → database-viewer-CCBt8nNo.js} +1 -1
- package/dist/web/assets/{diff-viewer-DDyuFOVE.js → diff-viewer-BUSLkYnY.js} +1 -1
- package/dist/web/assets/{git-graph-B-8TBe6m.js → git-graph-DKMqsIs6.js} +1 -1
- package/dist/web/assets/index-BLfiBqwM.css +2 -0
- package/dist/web/assets/index-NvzSiZn3.js +29 -0
- package/dist/web/assets/input-CVIzrYsH.js +41 -0
- package/dist/web/assets/keybindings-store-DOnYn9Ke.js +1 -0
- package/dist/web/assets/{markdown-renderer-Cpc1AybG.js → markdown-renderer-C7YWwr-S.js} +1 -1
- package/dist/web/assets/{postgres-viewer-jbdGAx-9.js → postgres-viewer-BI1XWAmh.js} +1 -1
- package/dist/web/assets/settings-tab-DW_kVkV9.js +1 -0
- package/dist/web/assets/{sqlite-viewer-Mvx_kZtd.js → sqlite-viewer-BdQj2XN9.js} +1 -1
- package/dist/web/assets/{terminal-tab-BD3WVSdL.js → terminal-tab-DtINSbFR.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 +193 -124
- package/src/services/db.service.ts +117 -3
- package/src/types/chat.ts +2 -0
- package/src/web/components/chat/chat-history-bar.tsx +3 -0
- package/src/web/components/chat/tool-cards.tsx +1 -1
- package/src/web/components/chat/usage-badge.tsx +109 -13
- 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-DEXADHLT.js +0 -7
- package/dist/web/assets/index-4pPCbWJp.css +0 -2
- package/dist/web/assets/index-Cl7NsCk_.js +0 -29
- package/dist/web/assets/input-P_K5CUiy.js +0 -41
- package/dist/web/assets/keybindings-store-DYmHYaWm.js +0 -1
- package/dist/web/assets/settings-tab-BCyMNFHK.js +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Activity, RefreshCw, ChevronDown, ChevronRight } 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,91 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
|
|
|
113
114
|
return `${mins}m ago`;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function AccountUsageSection({ entry, isActive, defaultExpanded }: {
|
|
118
|
+
entry: AccountUsageEntry;
|
|
119
|
+
isActive: boolean;
|
|
120
|
+
defaultExpanded: boolean;
|
|
121
|
+
}) {
|
|
122
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
123
|
+
const { usage } = entry;
|
|
124
|
+
const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
|
|
125
|
+
|
|
126
|
+
// Summary: worst utilization for collapsed view
|
|
127
|
+
const worstPct = Math.max(
|
|
128
|
+
usage.session ? Math.round(usage.session.utilization * 100) : 0,
|
|
129
|
+
usage.weekly ? Math.round(usage.weekly.utilization * 100) : 0,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className={`rounded-md border ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => setExpanded(!expanded)}
|
|
136
|
+
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left"
|
|
137
|
+
>
|
|
138
|
+
{expanded ? <ChevronDown className="size-3 text-text-subtle shrink-0" /> : <ChevronRight className="size-3 text-text-subtle shrink-0" />}
|
|
139
|
+
<span className="text-xs font-medium truncate flex-1">
|
|
140
|
+
{entry.accountLabel ?? entry.accountId.slice(0, 8)}
|
|
141
|
+
</span>
|
|
142
|
+
{isActive && (
|
|
143
|
+
<span className="text-[9px] px-1 py-0 rounded bg-primary/20 text-primary font-medium shrink-0">In use</span>
|
|
144
|
+
)}
|
|
145
|
+
{!entry.isOAuth && (
|
|
146
|
+
<span className="text-[9px] text-text-subtle shrink-0">API key</span>
|
|
147
|
+
)}
|
|
148
|
+
{!expanded && hasBuckets && (
|
|
149
|
+
<span className={`text-[10px] font-medium tabular-nums shrink-0 ${pctColor(worstPct)}`}>
|
|
150
|
+
{worstPct}%
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
{entry.accountStatus === "disabled" && (
|
|
154
|
+
<span className="text-[9px] text-text-subtle shrink-0">disabled</span>
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
157
|
+
{expanded && (
|
|
158
|
+
<div className="px-2 pb-2 space-y-2">
|
|
159
|
+
{hasBuckets ? (
|
|
160
|
+
<>
|
|
161
|
+
<BucketRow label="5-Hour Session" bucket={usage.session} />
|
|
162
|
+
<BucketRow label="Weekly" bucket={usage.weekly} />
|
|
163
|
+
<BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
|
|
164
|
+
<BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
|
|
165
|
+
</>
|
|
166
|
+
) : (
|
|
167
|
+
<p className="text-[10px] text-text-subtle">
|
|
168
|
+
{entry.isOAuth ? "No usage data yet" : "Usage tracking not available for API keys"}
|
|
169
|
+
</p>
|
|
170
|
+
)}
|
|
171
|
+
{usage.lastFetchedAt && (
|
|
172
|
+
<p className="text-[9px] text-text-subtle">
|
|
173
|
+
Updated: {formatLastUpdated(new Date(usage.lastFetchedAt).getTime())}
|
|
174
|
+
</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
116
182
|
export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastFetchedAt }: UsageDetailPanelProps) {
|
|
183
|
+
const [allUsages, setAllUsages] = useState<AccountUsageEntry[]>([]);
|
|
184
|
+
const [loadingAll, setLoadingAll] = useState(false);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!visible) return;
|
|
188
|
+
setLoadingAll(true);
|
|
189
|
+
getAllAccountUsages()
|
|
190
|
+
.then(setAllUsages)
|
|
191
|
+
.catch(() => {})
|
|
192
|
+
.finally(() => setLoadingAll(false));
|
|
193
|
+
}, [visible]);
|
|
194
|
+
|
|
117
195
|
if (!visible) return null;
|
|
118
196
|
|
|
119
197
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
120
|
-
const
|
|
198
|
+
const hasMultipleAccounts = allUsages.length > 0;
|
|
121
199
|
|
|
122
200
|
return (
|
|
123
|
-
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5">
|
|
201
|
+
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
|
|
124
202
|
<div className="flex items-center justify-between">
|
|
125
203
|
<div className="flex items-center gap-2">
|
|
126
204
|
<span className="text-xs font-semibold text-text-primary">Usage Limits</span>
|
|
@@ -148,17 +226,35 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
148
226
|
</div>
|
|
149
227
|
</div>
|
|
150
228
|
|
|
151
|
-
{
|
|
152
|
-
<div className="space-y-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
229
|
+
{hasMultipleAccounts ? (
|
|
230
|
+
<div className="space-y-1.5">
|
|
231
|
+
{loadingAll ? (
|
|
232
|
+
<p className="text-[10px] text-text-subtle">Loading accounts...</p>
|
|
233
|
+
) : (
|
|
234
|
+
allUsages.map((entry) => (
|
|
235
|
+
<AccountUsageSection
|
|
236
|
+
key={entry.accountId}
|
|
237
|
+
entry={entry}
|
|
238
|
+
isActive={entry.accountId === usage.activeAccountId}
|
|
239
|
+
defaultExpanded={entry.accountId === usage.activeAccountId}
|
|
240
|
+
/>
|
|
241
|
+
))
|
|
242
|
+
)}
|
|
157
243
|
</div>
|
|
158
244
|
) : (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
245
|
+
// Fallback: single-account view (legacy or no accounts configured)
|
|
246
|
+
<>
|
|
247
|
+
{usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet ? (
|
|
248
|
+
<div className="space-y-2.5">
|
|
249
|
+
<BucketRow label="5-Hour Session" bucket={usage.session} />
|
|
250
|
+
<BucketRow label="Weekly" bucket={usage.weekly} />
|
|
251
|
+
<BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
|
|
252
|
+
<BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
|
|
253
|
+
</div>
|
|
254
|
+
) : (
|
|
255
|
+
<p className="text-xs text-text-subtle">No usage data available</p>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
162
258
|
)}
|
|
163
259
|
|
|
164
260
|
{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;
|