feat: add API key management components including ApiKeyForm, ApiKeyList, SecurityInfo, TipsCard, and TokenUsageChart. Enhance settings route to integrate these components for improved API key configuration and token usage tracking. Implement backend support for token usage history with new query options.
Browse files- apps/web/src/components/settings/ApiKeyForm.tsx +154 -0
- apps/web/src/components/settings/ApiKeyList.tsx +130 -0
- apps/web/src/components/settings/SecurityInfo.tsx +46 -0
- apps/web/src/components/settings/TipsCard.tsx +35 -0
- apps/web/src/components/settings/TokenUsageChart.tsx +174 -0
- apps/web/src/routes/settings.tsx +184 -424
- packages/api/src/routers/ai.ts +103 -0
apps/web/src/components/settings/ApiKeyForm.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 2 |
+
import { Button } from "@labas/ui/components/button";
|
| 3 |
+
import { Input } from "@labas/ui/components/input";
|
| 4 |
+
import { Label } from "@labas/ui/components/label";
|
| 5 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 6 |
+
import {
|
| 7 |
+
Select,
|
| 8 |
+
SelectContent,
|
| 9 |
+
SelectGroup,
|
| 10 |
+
SelectItem,
|
| 11 |
+
SelectTrigger,
|
| 12 |
+
SelectValue,
|
| 13 |
+
} from "@labas/ui/components/select";
|
| 14 |
+
|
| 15 |
+
type FormState = Omit<ApiKeyConfig, "id"> & { apiKey: string };
|
| 16 |
+
|
| 17 |
+
interface ProviderOption {
|
| 18 |
+
value: string;
|
| 19 |
+
label: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface ApiKeyFormProps {
|
| 23 |
+
isAdding: boolean;
|
| 24 |
+
isSaving: boolean;
|
| 25 |
+
form: FormState;
|
| 26 |
+
canSave: boolean;
|
| 27 |
+
providers: ProviderOption[];
|
| 28 |
+
onChange: (field: keyof FormState, value: string | number) => void;
|
| 29 |
+
onSave: () => void;
|
| 30 |
+
onCancel: () => void;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function ApiKeyForm({ isAdding, isSaving, form, canSave, providers, onChange, onSave, onCancel }: ApiKeyFormProps) {
|
| 34 |
+
return (
|
| 35 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
|
| 36 |
+
<CardHeader>
|
| 37 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 38 |
+
{isAdding ? "Tambah API Key Baru" : "Edit API Key"}
|
| 39 |
+
</CardTitle>
|
| 40 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 41 |
+
{isAdding
|
| 42 |
+
? "Tambahkan konfigurasi provider baru."
|
| 43 |
+
: "Kosongkan field API Key jika tidak ingin mengubahnya."}
|
| 44 |
+
</CardDescription>
|
| 45 |
+
</CardHeader>
|
| 46 |
+
<CardContent className="space-y-5">
|
| 47 |
+
<div className="space-y-2">
|
| 48 |
+
<Label htmlFor="name" className="text-[var(--clay-black)]">Nama Config</Label>
|
| 49 |
+
<Input
|
| 50 |
+
id="name"
|
| 51 |
+
value={form.name}
|
| 52 |
+
onChange={(e) => onChange("name", e.target.value)}
|
| 53 |
+
placeholder="Contoh: OpenAI GPT-4o"
|
| 54 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 55 |
+
/>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="grid grid-cols-2 gap-4">
|
| 59 |
+
<div className="space-y-2">
|
| 60 |
+
<Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
|
| 61 |
+
<Select
|
| 62 |
+
items={providers}
|
| 63 |
+
value={form.provider}
|
| 64 |
+
onValueChange={(v: string | null) => onChange("provider", v ?? "openai")}
|
| 65 |
+
>
|
| 66 |
+
<SelectTrigger id="provider">
|
| 67 |
+
<SelectValue placeholder="Pilih provider" />
|
| 68 |
+
</SelectTrigger>
|
| 69 |
+
<SelectContent>
|
| 70 |
+
<SelectGroup>
|
| 71 |
+
{providers.map((p) => (
|
| 72 |
+
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
| 73 |
+
))}
|
| 74 |
+
</SelectGroup>
|
| 75 |
+
</SelectContent>
|
| 76 |
+
</Select>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="space-y-2">
|
| 79 |
+
<Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
|
| 80 |
+
<Input
|
| 81 |
+
id="model"
|
| 82 |
+
value={form.modelName}
|
| 83 |
+
onChange={(e) => onChange("modelName", e.target.value)}
|
| 84 |
+
placeholder="gpt-4o-mini"
|
| 85 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 86 |
+
/>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div className="grid grid-cols-2 gap-4">
|
| 91 |
+
<div className="space-y-2">
|
| 92 |
+
<Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
|
| 93 |
+
<Input
|
| 94 |
+
id="baseUrl"
|
| 95 |
+
value={form.baseUrl}
|
| 96 |
+
onChange={(e) => onChange("baseUrl", e.target.value)}
|
| 97 |
+
placeholder="https://api.openai.com/v1"
|
| 98 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="space-y-2">
|
| 102 |
+
<Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
|
| 103 |
+
<Input
|
| 104 |
+
id="maxTokens"
|
| 105 |
+
type="number"
|
| 106 |
+
min={1}
|
| 107 |
+
max={1_000_000}
|
| 108 |
+
value={form.maxTokens}
|
| 109 |
+
onChange={(e) => onChange("maxTokens", Number(e.target.value))}
|
| 110 |
+
placeholder="16384"
|
| 111 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div className="space-y-2">
|
| 117 |
+
<Label htmlFor="apiKey" className="text-[var(--clay-black)]">
|
| 118 |
+
API Key
|
| 119 |
+
{!isAdding && (
|
| 120 |
+
<span className="text-[var(--warm-silver)] font-normal ml-1">
|
| 121 |
+
(kosongkan jika tidak diubah)
|
| 122 |
+
</span>
|
| 123 |
+
)}
|
| 124 |
+
</Label>
|
| 125 |
+
<Input
|
| 126 |
+
id="apiKey"
|
| 127 |
+
type="password"
|
| 128 |
+
value={form.apiKey}
|
| 129 |
+
onChange={(e) => onChange("apiKey", e.target.value)}
|
| 130 |
+
placeholder={isAdding ? "sk-..." : "•••••••• (kosongkan untuk tidak mengubah)"}
|
| 131 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 132 |
+
/>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div className="flex gap-3">
|
| 136 |
+
<Button
|
| 137 |
+
onClick={onSave}
|
| 138 |
+
disabled={!canSave || isSaving}
|
| 139 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
|
| 140 |
+
>
|
| 141 |
+
{isSaving ? "Menyimpan..." : isAdding ? "Simpan" : "Update"}
|
| 142 |
+
</Button>
|
| 143 |
+
<Button
|
| 144 |
+
variant="outline"
|
| 145 |
+
onClick={onCancel}
|
| 146 |
+
className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]"
|
| 147 |
+
>
|
| 148 |
+
Batal
|
| 149 |
+
</Button>
|
| 150 |
+
</div>
|
| 151 |
+
</CardContent>
|
| 152 |
+
</Card>
|
| 153 |
+
);
|
| 154 |
+
}
|
apps/web/src/components/settings/ApiKeyList.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 2 |
+
import { Button } from "@labas/ui/components/button";
|
| 3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 4 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
+
|
| 6 |
+
interface ProviderOption {
|
| 7 |
+
value: string;
|
| 8 |
+
label: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface ApiKeyListProps {
|
| 12 |
+
configs: ApiKeyConfig[];
|
| 13 |
+
isLoading: boolean;
|
| 14 |
+
editingId: string | null;
|
| 15 |
+
isFormOpen: boolean;
|
| 16 |
+
providers: ProviderOption[];
|
| 17 |
+
onStartAdd: () => void;
|
| 18 |
+
onStartEdit: (config: ApiKeyConfig) => void;
|
| 19 |
+
onRemove: (id: string) => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function ApiKeyList({
|
| 23 |
+
configs,
|
| 24 |
+
isLoading,
|
| 25 |
+
editingId,
|
| 26 |
+
isFormOpen,
|
| 27 |
+
providers,
|
| 28 |
+
onStartAdd,
|
| 29 |
+
onStartEdit,
|
| 30 |
+
onRemove,
|
| 31 |
+
}: ApiKeyListProps) {
|
| 32 |
+
return (
|
| 33 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 34 |
+
<CardHeader>
|
| 35 |
+
<div className="flex items-center justify-between">
|
| 36 |
+
<div className="flex items-center gap-3">
|
| 37 |
+
<MaterialIcon name="key" className="text-xl" />
|
| 38 |
+
<div>
|
| 39 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">API Keys</CardTitle>
|
| 40 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 41 |
+
{configs.length} konfigurasi tersimpan
|
| 42 |
+
</CardDescription>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
{!isFormOpen && (
|
| 46 |
+
<Button
|
| 47 |
+
onClick={onStartAdd}
|
| 48 |
+
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] clay-shadow clay-hover text-sm"
|
| 49 |
+
>
|
| 50 |
+
<MaterialIcon name="add" className="mr-1" />
|
| 51 |
+
Tambah
|
| 52 |
+
</Button>
|
| 53 |
+
)}
|
| 54 |
+
</div>
|
| 55 |
+
</CardHeader>
|
| 56 |
+
<CardContent className="space-y-4">
|
| 57 |
+
{isLoading ? (
|
| 58 |
+
<div className="h-20 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 59 |
+
) : configs.length === 0 ? (
|
| 60 |
+
<div className="text-center py-8">
|
| 61 |
+
<MaterialIcon name="key_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 62 |
+
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada API key</p>
|
| 63 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 64 |
+
Tambahkan minimal satu key untuk generate soal.
|
| 65 |
+
</p>
|
| 66 |
+
<Button
|
| 67 |
+
onClick={onStartAdd}
|
| 68 |
+
className="mt-4 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 69 |
+
>
|
| 70 |
+
<MaterialIcon name="add" className="mr-1" />
|
| 71 |
+
Tambah API Key
|
| 72 |
+
</Button>
|
| 73 |
+
</div>
|
| 74 |
+
) : (
|
| 75 |
+
<div className="space-y-3">
|
| 76 |
+
{configs.map((config) => {
|
| 77 |
+
const isEditing = editingId === config.id;
|
| 78 |
+
return (
|
| 79 |
+
<div
|
| 80 |
+
key={config.id}
|
| 81 |
+
className={`p-4 rounded-[var(--radius-lg)] border-2 transition-colors ${
|
| 82 |
+
isEditing
|
| 83 |
+
? "border-[var(--matcha-600)] bg-[var(--matcha-100)]"
|
| 84 |
+
: "border-[var(--oat-border)] bg-[var(--warm-cream)]"
|
| 85 |
+
}`}
|
| 86 |
+
>
|
| 87 |
+
<div className="flex items-center justify-between">
|
| 88 |
+
<div className="min-w-0">
|
| 89 |
+
<div className="flex items-center gap-2 mb-1">
|
| 90 |
+
<span className="font-semibold text-[var(--clay-black)] truncate">
|
| 91 |
+
{config.name}
|
| 92 |
+
</span>
|
| 93 |
+
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold">
|
| 94 |
+
{providers.find((p) => p.value === config.provider)?.label ?? config.provider}
|
| 95 |
+
</span>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 98 |
+
{config.modelName} · {config.baseUrl.replace("https://", "").replace("/v1", "")}
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="flex gap-2 shrink-0">
|
| 102 |
+
<button
|
| 103 |
+
onClick={() => onStartEdit(config)}
|
| 104 |
+
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--oat-light)] text-[var(--warm-charcoal)] transition-colors"
|
| 105 |
+
title="Edit"
|
| 106 |
+
>
|
| 107 |
+
<MaterialIcon name="edit" className="text-sm" />
|
| 108 |
+
</button>
|
| 109 |
+
<button
|
| 110 |
+
onClick={() => {
|
| 111 |
+
if (confirm("Yakin mau hapus konfigurasi ini?")) {
|
| 112 |
+
onRemove(config.id);
|
| 113 |
+
}
|
| 114 |
+
}}
|
| 115 |
+
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] transition-colors"
|
| 116 |
+
title="Hapus"
|
| 117 |
+
>
|
| 118 |
+
<MaterialIcon name="delete" className="text-sm" />
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
})}
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
</CardContent>
|
| 128 |
+
</Card>
|
| 129 |
+
);
|
| 130 |
+
}
|
apps/web/src/components/settings/SecurityInfo.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
|
| 4 |
+
export function SecurityInfo() {
|
| 5 |
+
return (
|
| 6 |
+
<div className="max-w-xl">
|
| 7 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 8 |
+
<CardHeader>
|
| 9 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Keamanan</CardTitle>
|
| 10 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 11 |
+
Cara penyimpanan API key Anda.
|
| 12 |
+
</CardDescription>
|
| 13 |
+
</CardHeader>
|
| 14 |
+
<CardContent className="space-y-4">
|
| 15 |
+
<div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 16 |
+
<MaterialIcon name="lock" className="text-xl text-[var(--matcha-800)]" />
|
| 17 |
+
<div>
|
| 18 |
+
<p className="font-medium text-[var(--matcha-800)]">Enkripsi Lokal</p>
|
| 19 |
+
<p className="text-sm text-[var(--matcha-800)]/70">
|
| 20 |
+
AES-GCM-256 dengan key di IndexedDB
|
| 21 |
+
</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div className="text-xs text-[var(--warm-charcoal)] space-y-2">
|
| 25 |
+
<p className="flex items-center gap-2">
|
| 26 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 27 |
+
API key tidak pernah dikirim ke server kami
|
| 28 |
+
</p>
|
| 29 |
+
<p className="flex items-center gap-2">
|
| 30 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 31 |
+
Data terenkripsi di localStorage browser Anda
|
| 32 |
+
</p>
|
| 33 |
+
<p className="flex items-center gap-2">
|
| 34 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 35 |
+
Key enkripsi tersimpan aman di IndexedDB
|
| 36 |
+
</p>
|
| 37 |
+
<p className="flex items-center gap-2">
|
| 38 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 39 |
+
BYOK — Bring Your Own Key, platform tidak menyimpan key
|
| 40 |
+
</p>
|
| 41 |
+
</div>
|
| 42 |
+
</CardContent>
|
| 43 |
+
</Card>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
apps/web/src/components/settings/TipsCard.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
|
| 4 |
+
export function TipsCard() {
|
| 5 |
+
return (
|
| 6 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
|
| 7 |
+
<CardHeader>
|
| 8 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Tips</CardTitle>
|
| 9 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 10 |
+
Panduan singkat mengelola API key.
|
| 11 |
+
</CardDescription>
|
| 12 |
+
</CardHeader>
|
| 13 |
+
<CardContent className="space-y-4">
|
| 14 |
+
<div className="text-xs text-[var(--warm-charcoal)] space-y-2">
|
| 15 |
+
<p className="flex items-start gap-2">
|
| 16 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 17 |
+
BYOK — bawa key milik Anda sendiri
|
| 18 |
+
</p>
|
| 19 |
+
<p className="flex items-start gap-2">
|
| 20 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 21 |
+
Platform tidak menyimpan key di server
|
| 22 |
+
</p>
|
| 23 |
+
<p className="flex items-start gap-2">
|
| 24 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 25 |
+
Key dienkripsi secara lokal di browser
|
| 26 |
+
</p>
|
| 27 |
+
<p className="flex items-start gap-2">
|
| 28 |
+
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 29 |
+
Anda bisa menambahkan beberapa config
|
| 30 |
+
</p>
|
| 31 |
+
</div>
|
| 32 |
+
</CardContent>
|
| 33 |
+
</Card>
|
| 34 |
+
);
|
| 35 |
+
}
|
apps/web/src/components/settings/TokenUsageChart.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
AreaChart,
|
| 4 |
+
Area,
|
| 5 |
+
XAxis,
|
| 6 |
+
YAxis,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Tooltip,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Legend,
|
| 11 |
+
} from "recharts";
|
| 12 |
+
import { useQuery } from "@tanstack/react-query";
|
| 13 |
+
import { trpc } from "@/utils/trpc";
|
| 14 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@labas/ui/components/card";
|
| 15 |
+
import {
|
| 16 |
+
Tabs,
|
| 17 |
+
TabsList,
|
| 18 |
+
TabsTrigger,
|
| 19 |
+
} from "@labas/ui/components/tabs";
|
| 20 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 21 |
+
|
| 22 |
+
type Period = "daily" | "weekly" | "monthly";
|
| 23 |
+
|
| 24 |
+
interface ChartDataPoint {
|
| 25 |
+
date: string;
|
| 26 |
+
label: string;
|
| 27 |
+
totalTokens: number;
|
| 28 |
+
jobCount: number;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function formatTokenValue(value: number): string {
|
| 32 |
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
| 33 |
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
| 34 |
+
return value.toString();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function TokenUsageChart() {
|
| 38 |
+
const [period, setPeriod] = useState<Period>("daily");
|
| 39 |
+
|
| 40 |
+
const { data, isLoading } = useQuery(
|
| 41 |
+
trpc.ai.tokenUsageHistory.queryOptions({ period }),
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
const chartData: ChartDataPoint[] = (data ?? []).map((d) => ({
|
| 45 |
+
date: d.date,
|
| 46 |
+
label: d.label,
|
| 47 |
+
totalTokens: d.totalTokens,
|
| 48 |
+
jobCount: d.jobCount,
|
| 49 |
+
}));
|
| 50 |
+
|
| 51 |
+
const hasData = chartData.some((d) => d.totalTokens > 0 || d.jobCount > 0);
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 55 |
+
<CardHeader>
|
| 56 |
+
<div className="flex items-center justify-between">
|
| 57 |
+
<div className="flex items-center gap-3">
|
| 58 |
+
<MaterialIcon name="show_chart" className="text-xl" />
|
| 59 |
+
<div>
|
| 60 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 61 |
+
Tren Penggunaan Token
|
| 62 |
+
</CardTitle>
|
| 63 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 64 |
+
{period === "daily" && "7 hari terakhir"}
|
| 65 |
+
{period === "weekly" && "4 minggu terakhir"}
|
| 66 |
+
{period === "monthly" && "6 bulan terakhir"}
|
| 67 |
+
</CardDescription>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<Tabs
|
| 71 |
+
value={period}
|
| 72 |
+
onValueChange={(v: string | null) => setPeriod((v as Period) ?? "daily")}
|
| 73 |
+
>
|
| 74 |
+
<TabsList variant="line" className="h-8">
|
| 75 |
+
<TabsTrigger value="daily" className="text-xs px-3">Harian</TabsTrigger>
|
| 76 |
+
<TabsTrigger value="weekly" className="text-xs px-3">Mingguan</TabsTrigger>
|
| 77 |
+
<TabsTrigger value="monthly" className="text-xs px-3">Bulanan</TabsTrigger>
|
| 78 |
+
</TabsList>
|
| 79 |
+
</Tabs>
|
| 80 |
+
</div>
|
| 81 |
+
</CardHeader>
|
| 82 |
+
<CardContent>
|
| 83 |
+
{isLoading ? (
|
| 84 |
+
<div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 85 |
+
) : !hasData ? (
|
| 86 |
+
<div className="h-64 flex flex-col items-center justify-center text-[var(--warm-charcoal)]">
|
| 87 |
+
<MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mb-3" />
|
| 88 |
+
<p className="font-semibold">Belum ada data penggunaan</p>
|
| 89 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 90 |
+
Generate soal untuk melihat statistik penggunaan token.
|
| 91 |
+
</p>
|
| 92 |
+
</div>
|
| 93 |
+
) : (
|
| 94 |
+
<ResponsiveContainer width="100%" height={256}>
|
| 95 |
+
<AreaChart data={chartData} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
|
| 96 |
+
<defs>
|
| 97 |
+
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
| 98 |
+
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.3} />
|
| 99 |
+
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0} />
|
| 100 |
+
</linearGradient>
|
| 101 |
+
<linearGradient id="jobGradient" x1="0" y1="0" x2="0" y2="1">
|
| 102 |
+
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
| 103 |
+
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0} />
|
| 104 |
+
</linearGradient>
|
| 105 |
+
</defs>
|
| 106 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
|
| 107 |
+
<XAxis
|
| 108 |
+
dataKey="label"
|
| 109 |
+
tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
|
| 110 |
+
axisLine={{ stroke: "var(--oat-border)" }}
|
| 111 |
+
tickLine={false}
|
| 112 |
+
/>
|
| 113 |
+
<YAxis
|
| 114 |
+
yAxisId="left"
|
| 115 |
+
tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
|
| 116 |
+
axisLine={false}
|
| 117 |
+
tickLine={false}
|
| 118 |
+
tickFormatter={formatTokenValue}
|
| 119 |
+
/>
|
| 120 |
+
<YAxis
|
| 121 |
+
yAxisId="right"
|
| 122 |
+
orientation="right"
|
| 123 |
+
tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
|
| 124 |
+
axisLine={false}
|
| 125 |
+
tickLine={false}
|
| 126 |
+
/>
|
| 127 |
+
<Tooltip
|
| 128 |
+
contentStyle={{
|
| 129 |
+
backgroundColor: "var(--pure-white)",
|
| 130 |
+
border: "2px solid var(--oat-border)",
|
| 131 |
+
borderRadius: "var(--radius-lg)",
|
| 132 |
+
fontSize: 13,
|
| 133 |
+
}}
|
| 134 |
+
formatter={(value: unknown, name: unknown) => {
|
| 135 |
+
const num = typeof value === "number" ? value : 0;
|
| 136 |
+
const n = typeof name === "string" ? name : "";
|
| 137 |
+
if (n === "totalTokens") return [num.toLocaleString("id-ID"), "Token"];
|
| 138 |
+
if (n === "jobCount") return [num, "Job"];
|
| 139 |
+
return [num, n];
|
| 140 |
+
}}
|
| 141 |
+
/>
|
| 142 |
+
<Legend
|
| 143 |
+
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
| 144 |
+
formatter={(v: string) => {
|
| 145 |
+
if (v === "totalTokens") return "Token";
|
| 146 |
+
if (v === "jobCount") return "Job";
|
| 147 |
+
return v;
|
| 148 |
+
}}
|
| 149 |
+
/>
|
| 150 |
+
<Area
|
| 151 |
+
yAxisId="left"
|
| 152 |
+
type="monotone"
|
| 153 |
+
dataKey="totalTokens"
|
| 154 |
+
stroke="var(--chart-1)"
|
| 155 |
+
strokeWidth={2}
|
| 156 |
+
fill="url(#tokenGradient)"
|
| 157 |
+
name="totalTokens"
|
| 158 |
+
/>
|
| 159 |
+
<Area
|
| 160 |
+
yAxisId="right"
|
| 161 |
+
type="monotone"
|
| 162 |
+
dataKey="jobCount"
|
| 163 |
+
stroke="var(--chart-2)"
|
| 164 |
+
strokeWidth={2}
|
| 165 |
+
fill="url(#jobGradient)"
|
| 166 |
+
name="jobCount"
|
| 167 |
+
/>
|
| 168 |
+
</AreaChart>
|
| 169 |
+
</ResponsiveContainer>
|
| 170 |
+
)}
|
| 171 |
+
</CardContent>
|
| 172 |
+
</Card>
|
| 173 |
+
);
|
| 174 |
+
}
|
apps/web/src/routes/settings.tsx
CHANGED
|
@@ -1,31 +1,29 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { useQuery } from "@tanstack/react-query";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 6 |
import { trpc } from "@/utils/trpc";
|
| 7 |
-
import { Button } from "@labas/ui/components/button";
|
| 8 |
-
import { Input } from "@labas/ui/components/input";
|
| 9 |
-
import { Label } from "@labas/ui/components/label";
|
| 10 |
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 11 |
import {
|
| 12 |
Tabs,
|
| 13 |
TabsList,
|
| 14 |
TabsTrigger,
|
| 15 |
TabsContent,
|
| 16 |
} from "@labas/ui/components/tabs";
|
| 17 |
-
import {
|
| 18 |
-
Select,
|
| 19 |
-
SelectContent,
|
| 20 |
-
SelectGroup,
|
| 21 |
-
SelectItem,
|
| 22 |
-
SelectTrigger,
|
| 23 |
-
SelectValue,
|
| 24 |
-
} from "@labas/ui/components/select";
|
| 25 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
export const Route = createFileRoute("/settings")({
|
| 28 |
component: RouteComponent,
|
|
|
|
|
|
|
|
|
|
| 29 |
beforeLoad: async () => {
|
| 30 |
const session = await authClient.getSession();
|
| 31 |
if (!session.data) {
|
|
@@ -54,6 +52,8 @@ function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
|
|
| 54 |
};
|
| 55 |
}
|
| 56 |
|
|
|
|
|
|
|
| 57 |
function TokenUsageSection() {
|
| 58 |
const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
|
| 59 |
|
|
@@ -61,130 +61,134 @@ function TokenUsageSection() {
|
|
| 61 |
const jobs = data?.jobs ?? [];
|
| 62 |
|
| 63 |
return (
|
| 64 |
-
<
|
| 65 |
-
<
|
| 66 |
-
<div className="flex items-center gap-3">
|
| 67 |
-
<MaterialIcon name="toll" className="text-xl" />
|
| 68 |
-
<div>
|
| 69 |
-
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 70 |
-
Penggunaan Token Hari Ini
|
| 71 |
-
</CardTitle>
|
| 72 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 73 |
-
Total token yang terpakai untuk generate soal hari ini.
|
| 74 |
-
</CardDescription>
|
| 75 |
-
</div>
|
| 76 |
-
</div>
|
| 77 |
-
</CardHeader>
|
| 78 |
-
<CardContent className="space-y-6">
|
| 79 |
-
<div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 80 |
-
<MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
|
| 81 |
-
<div>
|
| 82 |
-
<p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
|
| 83 |
-
<p className="text-3xl font-extrabold text-[var(--matcha-800)]">
|
| 84 |
-
{isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
|
| 85 |
-
</p>
|
| 86 |
-
</div>
|
| 87 |
-
</div>
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
)
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
| 105 |
</p>
|
| 106 |
</div>
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
<
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
<MaterialIcon name="hourglass_empty" className="text-xs" />
|
| 168 |
-
{job.status}
|
| 169 |
</span>
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
);
|
| 189 |
}
|
| 190 |
|
|
@@ -192,20 +196,36 @@ function RouteComponent() {
|
|
| 192 |
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 193 |
useApiKeys();
|
| 194 |
|
|
|
|
|
|
|
|
|
|
| 195 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 196 |
const [isAdding, setIsAdding] = useState(false);
|
| 197 |
const [isSaving, setIsSaving] = useState(false);
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
const resetForm = () => {
|
| 206 |
setForm({ ...defaultConfig(), apiKey: "" });
|
| 207 |
};
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
const startAdd = () => {
|
| 210 |
resetForm();
|
| 211 |
setIsAdding(true);
|
|
@@ -219,7 +239,7 @@ function RouteComponent() {
|
|
| 219 |
baseUrl: config.baseUrl,
|
| 220 |
modelName: config.modelName,
|
| 221 |
maxTokens: config.maxTokens ?? 16384,
|
| 222 |
-
apiKey: "",
|
| 223 |
});
|
| 224 |
setEditingId(config.id);
|
| 225 |
setIsAdding(false);
|
|
@@ -251,9 +271,9 @@ function RouteComponent() {
|
|
| 251 |
|
| 252 |
const isFormOpen = isAdding || editingId !== null;
|
| 253 |
const canSave =
|
| 254 |
-
form.name.trim() &&
|
| 255 |
-
form.baseUrl.trim() &&
|
| 256 |
-
form.modelName.trim() &&
|
| 257 |
(isAdding ? !!form.apiKey : true);
|
| 258 |
|
| 259 |
return (
|
|
@@ -272,7 +292,7 @@ function RouteComponent() {
|
|
| 272 |
</p>
|
| 273 |
</section>
|
| 274 |
|
| 275 |
-
<Tabs
|
| 276 |
<TabsList variant="line" className="mb-6">
|
| 277 |
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
| 278 |
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
|
|
@@ -282,255 +302,33 @@ function RouteComponent() {
|
|
| 282 |
<TabsContent value="api-keys" className="space-y-8">
|
| 283 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 284 |
<div className="lg:col-span-7 space-y-8">
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
</CardDescription>
|
| 296 |
-
</div>
|
| 297 |
-
</div>
|
| 298 |
-
{!isFormOpen && (
|
| 299 |
-
<Button
|
| 300 |
-
onClick={startAdd}
|
| 301 |
-
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] clay-shadow clay-hover text-sm"
|
| 302 |
-
>
|
| 303 |
-
<MaterialIcon name="add" className="mr-1" />
|
| 304 |
-
Tambah
|
| 305 |
-
</Button>
|
| 306 |
-
)}
|
| 307 |
-
</div>
|
| 308 |
-
</CardHeader>
|
| 309 |
-
<CardContent className="space-y-4">
|
| 310 |
-
{isLoading ? (
|
| 311 |
-
<div className="h-20 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 312 |
-
) : configs.length === 0 ? (
|
| 313 |
-
<div className="text-center py-8">
|
| 314 |
-
<MaterialIcon name="key_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 315 |
-
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada API key</p>
|
| 316 |
-
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 317 |
-
Tambahkan minimal satu key untuk generate soal.
|
| 318 |
-
</p>
|
| 319 |
-
<Button
|
| 320 |
-
onClick={startAdd}
|
| 321 |
-
className="mt-4 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
|
| 322 |
-
>
|
| 323 |
-
<MaterialIcon name="add" className="mr-1" />
|
| 324 |
-
Tambah API Key
|
| 325 |
-
</Button>
|
| 326 |
-
</div>
|
| 327 |
-
) : (
|
| 328 |
-
<div className="space-y-3">
|
| 329 |
-
{configs.map((config) => (
|
| 330 |
-
<div
|
| 331 |
-
key={config.id}
|
| 332 |
-
className={`p-4 rounded-[var(--radius-lg)] border-2 transition-colors ${
|
| 333 |
-
editingId === config.id
|
| 334 |
-
? "border-[var(--matcha-600)] bg-[var(--matcha-100)]"
|
| 335 |
-
: "border-[var(--oat-border)] bg-[var(--warm-cream)]"
|
| 336 |
-
}`}
|
| 337 |
-
>
|
| 338 |
-
<div className="flex items-center justify-between">
|
| 339 |
-
<div className="min-w-0">
|
| 340 |
-
<div className="flex items-center gap-2 mb-1">
|
| 341 |
-
<span className="font-semibold text-[var(--clay-black)] truncate">
|
| 342 |
-
{config.name}
|
| 343 |
-
</span>
|
| 344 |
-
<span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold">
|
| 345 |
-
{PROVIDERS.find((p) => p.value === config.provider)?.label ?? config.provider}
|
| 346 |
-
</span>
|
| 347 |
-
</div>
|
| 348 |
-
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 349 |
-
{config.modelName} · {config.baseUrl.replace("https://", "").replace("/v1", "")}
|
| 350 |
-
</div>
|
| 351 |
-
</div>
|
| 352 |
-
<div className="flex gap-2 shrink-0">
|
| 353 |
-
<button
|
| 354 |
-
onClick={() => startEdit(config)}
|
| 355 |
-
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--oat-light)] text-[var(--warm-charcoal)] transition-colors"
|
| 356 |
-
title="Edit"
|
| 357 |
-
>
|
| 358 |
-
<MaterialIcon name="edit" className="text-sm" />
|
| 359 |
-
</button>
|
| 360 |
-
<button
|
| 361 |
-
onClick={() => {
|
| 362 |
-
if (confirm("Yakin mau hapus konfigurasi ini?")) {
|
| 363 |
-
removeConfig(config.id);
|
| 364 |
-
}
|
| 365 |
-
}}
|
| 366 |
-
className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] transition-colors"
|
| 367 |
-
title="Hapus"
|
| 368 |
-
>
|
| 369 |
-
<MaterialIcon name="delete" className="text-sm" />
|
| 370 |
-
</button>
|
| 371 |
-
</div>
|
| 372 |
-
</div>
|
| 373 |
-
</div>
|
| 374 |
-
))}
|
| 375 |
-
</div>
|
| 376 |
-
)}
|
| 377 |
-
</CardContent>
|
| 378 |
-
</Card>
|
| 379 |
|
| 380 |
-
{/* Add/Edit Form */}
|
| 381 |
{isFormOpen && (
|
| 382 |
-
<
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
</CardHeader>
|
| 393 |
-
<CardContent className="space-y-5">
|
| 394 |
-
<div className="space-y-2">
|
| 395 |
-
<Label htmlFor="name" className="text-[var(--clay-black)]">Nama Config</Label>
|
| 396 |
-
<Input
|
| 397 |
-
id="name"
|
| 398 |
-
value={form.name}
|
| 399 |
-
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
| 400 |
-
placeholder="Contoh: OpenAI GPT-4o"
|
| 401 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 402 |
-
/>
|
| 403 |
-
</div>
|
| 404 |
-
|
| 405 |
-
<div className="grid grid-cols-2 gap-4">
|
| 406 |
-
<div className="space-y-2">
|
| 407 |
-
<Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
|
| 408 |
-
<Select
|
| 409 |
-
items={PROVIDERS}
|
| 410 |
-
value={form.provider}
|
| 411 |
-
onValueChange={(v: string | null) =>
|
| 412 |
-
setForm((f) => ({ ...f, provider: v ?? "openai" }))
|
| 413 |
-
}
|
| 414 |
-
>
|
| 415 |
-
<SelectTrigger id="provider">
|
| 416 |
-
<SelectValue placeholder="Pilih provider" />
|
| 417 |
-
</SelectTrigger>
|
| 418 |
-
<SelectContent>
|
| 419 |
-
<SelectGroup>
|
| 420 |
-
{PROVIDERS.map((p) => (
|
| 421 |
-
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
| 422 |
-
))}
|
| 423 |
-
</SelectGroup>
|
| 424 |
-
</SelectContent>
|
| 425 |
-
</Select>
|
| 426 |
-
</div>
|
| 427 |
-
<div className="space-y-2">
|
| 428 |
-
<Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
|
| 429 |
-
<Input
|
| 430 |
-
id="model"
|
| 431 |
-
value={form.modelName}
|
| 432 |
-
onChange={(e) => setForm((f) => ({ ...f, modelName: e.target.value }))}
|
| 433 |
-
placeholder="gpt-4o-mini"
|
| 434 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 435 |
-
/>
|
| 436 |
-
</div>
|
| 437 |
-
</div>
|
| 438 |
-
|
| 439 |
-
<div className="grid grid-cols-2 gap-4">
|
| 440 |
-
<div className="space-y-2">
|
| 441 |
-
<Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
|
| 442 |
-
<Input
|
| 443 |
-
id="baseUrl"
|
| 444 |
-
value={form.baseUrl}
|
| 445 |
-
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
|
| 446 |
-
placeholder="https://api.openai.com/v1"
|
| 447 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 448 |
-
/>
|
| 449 |
-
</div>
|
| 450 |
-
<div className="space-y-2">
|
| 451 |
-
<Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
|
| 452 |
-
<Input
|
| 453 |
-
id="maxTokens"
|
| 454 |
-
type="number"
|
| 455 |
-
min={1}
|
| 456 |
-
max={1_000_000}
|
| 457 |
-
value={form.maxTokens}
|
| 458 |
-
onChange={(e) => setForm((f) => ({ ...f, maxTokens: Number(e.target.value) }))}
|
| 459 |
-
placeholder="16384"
|
| 460 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 461 |
-
/>
|
| 462 |
-
</div>
|
| 463 |
-
</div>
|
| 464 |
-
|
| 465 |
-
<div className="space-y-2">
|
| 466 |
-
<Label htmlFor="apiKey" className="text-[var(--clay-black)]">
|
| 467 |
-
API Key
|
| 468 |
-
{!isAdding && (
|
| 469 |
-
<span className="text-[var(--warm-silver)] font-normal ml-1">
|
| 470 |
-
(kosongkan jika tidak diubah)
|
| 471 |
-
</span>
|
| 472 |
-
)}
|
| 473 |
-
</Label>
|
| 474 |
-
<Input
|
| 475 |
-
id="apiKey"
|
| 476 |
-
type="password"
|
| 477 |
-
value={form.apiKey}
|
| 478 |
-
onChange={(e) => setForm((f) => ({ ...f, apiKey: e.target.value }))}
|
| 479 |
-
placeholder={isAdding ? "sk-..." : "•••••••• (kosongkan untuk tidak mengubah)"}
|
| 480 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
|
| 481 |
-
/>
|
| 482 |
-
</div>
|
| 483 |
-
|
| 484 |
-
<div className="flex gap-3">
|
| 485 |
-
<Button
|
| 486 |
-
onClick={handleSave}
|
| 487 |
-
disabled={!canSave || isSaving}
|
| 488 |
-
className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
|
| 489 |
-
>
|
| 490 |
-
{isSaving ? "Menyimpan..." : isAdding ? "Simpan" : "Update"}
|
| 491 |
-
</Button>
|
| 492 |
-
<Button
|
| 493 |
-
variant="outline"
|
| 494 |
-
onClick={cancelEdit}
|
| 495 |
-
className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]"
|
| 496 |
-
>
|
| 497 |
-
Batal
|
| 498 |
-
</Button>
|
| 499 |
-
</div>
|
| 500 |
-
</CardContent>
|
| 501 |
-
</Card>
|
| 502 |
)}
|
| 503 |
</div>
|
| 504 |
|
| 505 |
<div className="lg:col-span-5">
|
| 506 |
-
<
|
| 507 |
-
<CardHeader>
|
| 508 |
-
<CardTitle className="font-headline text-[var(--clay-black)]">Tips</CardTitle>
|
| 509 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 510 |
-
Panduan singkat mengelola API key.
|
| 511 |
-
</CardDescription>
|
| 512 |
-
</CardHeader>
|
| 513 |
-
<CardContent className="space-y-4">
|
| 514 |
-
<div className="text-xs text-[var(--warm-charcoal)] space-y-2">
|
| 515 |
-
<p className="flex items-start gap-2">
|
| 516 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 517 |
-
BYOK — bawa key milik Anda sendiri
|
| 518 |
-
</p>
|
| 519 |
-
<p className="flex items-start gap-2">
|
| 520 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 521 |
-
Platform tidak menyimpan key di server
|
| 522 |
-
</p>
|
| 523 |
-
<p className="flex items-start gap-2">
|
| 524 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 525 |
-
Key dienkripsi secara lokal di browser
|
| 526 |
-
</p>
|
| 527 |
-
<p className="flex items-start gap-2">
|
| 528 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 529 |
-
Anda bisa menambahkan beberapa config
|
| 530 |
-
</p>
|
| 531 |
-
</div>
|
| 532 |
-
</CardContent>
|
| 533 |
-
</Card>
|
| 534 |
</div>
|
| 535 |
</div>
|
| 536 |
</TabsContent>
|
|
@@ -540,45 +338,7 @@ function RouteComponent() {
|
|
| 540 |
</TabsContent>
|
| 541 |
|
| 542 |
<TabsContent value="security">
|
| 543 |
-
<
|
| 544 |
-
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 545 |
-
<CardHeader>
|
| 546 |
-
<CardTitle className="font-headline text-[var(--clay-black)]">Keamanan</CardTitle>
|
| 547 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 548 |
-
Cara penyimpanan API key Anda.
|
| 549 |
-
</CardDescription>
|
| 550 |
-
</CardHeader>
|
| 551 |
-
<CardContent className="space-y-4">
|
| 552 |
-
<div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 553 |
-
<MaterialIcon name="lock" className="text-xl text-[var(--matcha-800)]" />
|
| 554 |
-
<div>
|
| 555 |
-
<p className="font-medium text-[var(--matcha-800)]">Enkripsi Lokal</p>
|
| 556 |
-
<p className="text-sm text-[var(--matcha-800)]/70">
|
| 557 |
-
AES-GCM-256 dengan key di IndexedDB
|
| 558 |
-
</p>
|
| 559 |
-
</div>
|
| 560 |
-
</div>
|
| 561 |
-
<div className="text-xs text-[var(--warm-charcoal)] space-y-2">
|
| 562 |
-
<p className="flex items-center gap-2">
|
| 563 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 564 |
-
API key tidak pernah dikirim ke server kami
|
| 565 |
-
</p>
|
| 566 |
-
<p className="flex items-center gap-2">
|
| 567 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 568 |
-
Data terenkripsi di localStorage browser Anda
|
| 569 |
-
</p>
|
| 570 |
-
<p className="flex items-center gap-2">
|
| 571 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 572 |
-
Key enkripsi tersimpan aman di IndexedDB
|
| 573 |
-
</p>
|
| 574 |
-
<p className="flex items-center gap-2">
|
| 575 |
-
<MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
|
| 576 |
-
BYOK — Bring Your Own Key, platform tidak menyimpan key
|
| 577 |
-
</p>
|
| 578 |
-
</div>
|
| 579 |
-
</CardContent>
|
| 580 |
-
</Card>
|
| 581 |
-
</div>
|
| 582 |
</TabsContent>
|
| 583 |
</Tabs>
|
| 584 |
</div>
|
|
|
|
| 1 |
+
import { useState, startTransition } from "react";
|
| 2 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { useQuery } from "@tanstack/react-query";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 6 |
import { trpc } from "@/utils/trpc";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import {
|
| 8 |
Tabs,
|
| 9 |
TabsList,
|
| 10 |
TabsTrigger,
|
| 11 |
TabsContent,
|
| 12 |
} from "@labas/ui/components/tabs";
|
| 13 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 15 |
+
import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
|
| 16 |
+
import { ApiKeyList } from "@/components/settings/ApiKeyList";
|
| 17 |
+
import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
|
| 18 |
+
import { TipsCard } from "@/components/settings/TipsCard";
|
| 19 |
+
import { SecurityInfo } from "@/components/settings/SecurityInfo";
|
| 20 |
+
import { z } from "zod";
|
| 21 |
|
| 22 |
export const Route = createFileRoute("/settings")({
|
| 23 |
component: RouteComponent,
|
| 24 |
+
validateSearch: z.object({
|
| 25 |
+
tab: z.enum(["api-keys", "token-usage", "security"]).optional(),
|
| 26 |
+
}).parse,
|
| 27 |
beforeLoad: async () => {
|
| 28 |
const session = await authClient.getSession();
|
| 29 |
if (!session.data) {
|
|
|
|
| 52 |
};
|
| 53 |
}
|
| 54 |
|
| 55 |
+
type Tab = "api-keys" | "token-usage" | "security";
|
| 56 |
+
|
| 57 |
function TokenUsageSection() {
|
| 58 |
const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
|
| 59 |
|
|
|
|
| 61 |
const jobs = data?.jobs ?? [];
|
| 62 |
|
| 63 |
return (
|
| 64 |
+
<div className="space-y-6">
|
| 65 |
+
<TokenUsageChart />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 68 |
+
<CardHeader>
|
| 69 |
+
<div className="flex items-center gap-3">
|
| 70 |
+
<MaterialIcon name="toll" className="text-xl" />
|
| 71 |
+
<div>
|
| 72 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 73 |
+
Penggunaan Token Hari Ini
|
| 74 |
+
</CardTitle>
|
| 75 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 76 |
+
Total token yang terpakai untuk generate soal hari ini.
|
| 77 |
+
</CardDescription>
|
| 78 |
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</CardHeader>
|
| 81 |
+
<CardContent className="space-y-6">
|
| 82 |
+
<div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 83 |
+
<MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
|
| 84 |
+
<div>
|
| 85 |
+
<p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
|
| 86 |
+
<p className="text-3xl font-extrabold text-[var(--matcha-800)]">
|
| 87 |
+
{isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
|
| 88 |
</p>
|
| 89 |
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div>
|
| 93 |
+
<h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
|
| 94 |
+
Riwayat Generate Hari Ini
|
| 95 |
+
</h3>
|
| 96 |
+
{isLoading ? (
|
| 97 |
+
<div className="space-y-2">
|
| 98 |
+
{[1, 2, 3].map((i) => (
|
| 99 |
+
<div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
) : jobs.length === 0 ? (
|
| 103 |
+
<div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
|
| 104 |
+
<MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 105 |
+
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
|
| 106 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 107 |
+
Generate soal baru untuk melihat penggunaan token.
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
) : (
|
| 111 |
+
<div className="overflow-x-auto">
|
| 112 |
+
<table className="w-full text-sm">
|
| 113 |
+
<thead>
|
| 114 |
+
<tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
|
| 115 |
+
<th className="text-left py-2 px-3 font-medium">Waktu</th>
|
| 116 |
+
<th className="text-left py-2 px-3 font-medium">Mode</th>
|
| 117 |
+
<th className="text-left py-2 px-3 font-medium">Status</th>
|
| 118 |
+
<th className="text-left py-2 px-3 font-medium">Ujian</th>
|
| 119 |
+
<th className="text-left py-2 px-3 font-medium">Section</th>
|
| 120 |
+
<th className="text-right py-2 px-3 font-medium">Soal</th>
|
| 121 |
+
<th className="text-right py-2 px-3 font-medium">Token</th>
|
| 122 |
+
</tr>
|
| 123 |
+
</thead>
|
| 124 |
+
<tbody>
|
| 125 |
+
{jobs.map((job) => {
|
| 126 |
+
const isFailed = job.status === "failed";
|
| 127 |
+
const isCancelled = job.status === "cancelled";
|
| 128 |
+
return (
|
| 129 |
+
<tr
|
| 130 |
+
key={job.id}
|
| 131 |
+
className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
|
| 132 |
+
isFailed || isCancelled ? "opacity-70" : ""
|
| 133 |
+
}`}
|
| 134 |
+
>
|
| 135 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
|
| 136 |
+
{new Date(job.createdAt).toLocaleTimeString("id-ID", {
|
| 137 |
+
hour: "2-digit",
|
| 138 |
+
minute: "2-digit",
|
| 139 |
+
})}
|
| 140 |
+
</td>
|
| 141 |
+
<td className="py-2.5 px-3">
|
| 142 |
+
<span
|
| 143 |
+
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
|
| 144 |
+
job.mode === "agentic"
|
| 145 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 146 |
+
: "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
|
| 147 |
+
}`}
|
| 148 |
+
>
|
| 149 |
+
{job.mode === "agentic" ? "Agentic" : "Quick"}
|
|
|
|
|
|
|
| 150 |
</span>
|
| 151 |
+
</td>
|
| 152 |
+
<td className="py-2.5 px-3">
|
| 153 |
+
{job.status === "completed" ? (
|
| 154 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
|
| 155 |
+
<MaterialIcon name="check_circle" className="text-xs" />
|
| 156 |
+
Selesai
|
| 157 |
+
</span>
|
| 158 |
+
) : isFailed ? (
|
| 159 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
|
| 160 |
+
<MaterialIcon name="error" className="text-xs" />
|
| 161 |
+
Gagal
|
| 162 |
+
</span>
|
| 163 |
+
) : isCancelled ? (
|
| 164 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
|
| 165 |
+
<MaterialIcon name="cancel" className="text-xs" />
|
| 166 |
+
Dibatalkan
|
| 167 |
+
</span>
|
| 168 |
+
) : (
|
| 169 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
|
| 170 |
+
<MaterialIcon name="hourglass_empty" className="text-xs" />
|
| 171 |
+
{job.status}
|
| 172 |
+
</span>
|
| 173 |
+
)}
|
| 174 |
+
</td>
|
| 175 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
|
| 176 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
|
| 177 |
+
<td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
|
| 178 |
+
<td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
|
| 179 |
+
{job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
|
| 180 |
+
</td>
|
| 181 |
+
</tr>
|
| 182 |
+
);
|
| 183 |
+
})}
|
| 184 |
+
</tbody>
|
| 185 |
+
</table>
|
| 186 |
+
</div>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
</CardContent>
|
| 190 |
+
</Card>
|
| 191 |
+
</div>
|
| 192 |
);
|
| 193 |
}
|
| 194 |
|
|
|
|
| 196 |
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 197 |
useApiKeys();
|
| 198 |
|
| 199 |
+
const search = Route.useSearch();
|
| 200 |
+
const navigate = Route.useNavigate();
|
| 201 |
+
|
| 202 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 203 |
const [isAdding, setIsAdding] = useState(false);
|
| 204 |
const [isSaving, setIsSaving] = useState(false);
|
| 205 |
|
| 206 |
+
const activeTab: Tab = search.tab ?? "api-keys";
|
| 207 |
+
|
| 208 |
+
const setTab = (tab: string) => {
|
| 209 |
+
startTransition(() => {
|
| 210 |
+
navigate({ search: { tab: tab as Tab } });
|
| 211 |
+
});
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
|
| 215 |
+
() => ({
|
| 216 |
+
...defaultConfig(),
|
| 217 |
+
apiKey: "",
|
| 218 |
+
}),
|
| 219 |
+
);
|
| 220 |
|
| 221 |
const resetForm = () => {
|
| 222 |
setForm({ ...defaultConfig(), apiKey: "" });
|
| 223 |
};
|
| 224 |
|
| 225 |
+
const handleFormChange = (field: keyof typeof form, value: string | number) => {
|
| 226 |
+
setForm((prev) => ({ ...prev, [field]: value }));
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
const startAdd = () => {
|
| 230 |
resetForm();
|
| 231 |
setIsAdding(true);
|
|
|
|
| 239 |
baseUrl: config.baseUrl,
|
| 240 |
modelName: config.modelName,
|
| 241 |
maxTokens: config.maxTokens ?? 16384,
|
| 242 |
+
apiKey: "",
|
| 243 |
});
|
| 244 |
setEditingId(config.id);
|
| 245 |
setIsAdding(false);
|
|
|
|
| 271 |
|
| 272 |
const isFormOpen = isAdding || editingId !== null;
|
| 273 |
const canSave =
|
| 274 |
+
!!form.name.trim() &&
|
| 275 |
+
!!form.baseUrl.trim() &&
|
| 276 |
+
!!form.modelName.trim() &&
|
| 277 |
(isAdding ? !!form.apiKey : true);
|
| 278 |
|
| 279 |
return (
|
|
|
|
| 292 |
</p>
|
| 293 |
</section>
|
| 294 |
|
| 295 |
+
<Tabs value={activeTab} onValueChange={setTab} className="w-full">
|
| 296 |
<TabsList variant="line" className="mb-6">
|
| 297 |
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
| 298 |
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
|
|
|
|
| 302 |
<TabsContent value="api-keys" className="space-y-8">
|
| 303 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 304 |
<div className="lg:col-span-7 space-y-8">
|
| 305 |
+
<ApiKeyList
|
| 306 |
+
configs={configs}
|
| 307 |
+
isLoading={isLoading}
|
| 308 |
+
editingId={editingId}
|
| 309 |
+
isFormOpen={isFormOpen}
|
| 310 |
+
providers={PROVIDERS}
|
| 311 |
+
onStartAdd={startAdd}
|
| 312 |
+
onStartEdit={startEdit}
|
| 313 |
+
onRemove={removeConfig}
|
| 314 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
|
|
|
| 316 |
{isFormOpen && (
|
| 317 |
+
<ApiKeyForm
|
| 318 |
+
isAdding={isAdding}
|
| 319 |
+
isSaving={isSaving}
|
| 320 |
+
form={form}
|
| 321 |
+
canSave={canSave}
|
| 322 |
+
providers={PROVIDERS}
|
| 323 |
+
onChange={handleFormChange}
|
| 324 |
+
onSave={handleSave}
|
| 325 |
+
onCancel={cancelEdit}
|
| 326 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
)}
|
| 328 |
</div>
|
| 329 |
|
| 330 |
<div className="lg:col-span-5">
|
| 331 |
+
<TipsCard />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
</div>
|
| 333 |
</div>
|
| 334 |
</TabsContent>
|
|
|
|
| 338 |
</TabsContent>
|
| 339 |
|
| 340 |
<TabsContent value="security">
|
| 341 |
+
<SecurityInfo />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
</TabsContent>
|
| 343 |
</Tabs>
|
| 344 |
</div>
|
packages/api/src/routers/ai.ts
CHANGED
|
@@ -129,6 +129,109 @@ export const aiRouter = router({
|
|
| 129 |
};
|
| 130 |
}),
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
retryJob: protectedProcedure
|
| 133 |
.input(z.object({ jobId: z.string().uuid() }))
|
| 134 |
.mutation(async ({ ctx, input }) => {
|
|
|
|
| 129 |
};
|
| 130 |
}),
|
| 131 |
|
| 132 |
+
tokenUsageHistory: protectedProcedure
|
| 133 |
+
.input(
|
| 134 |
+
z.object({
|
| 135 |
+
period: z.enum(["daily", "weekly", "monthly"]),
|
| 136 |
+
}),
|
| 137 |
+
)
|
| 138 |
+
.query(async ({ ctx, input }) => {
|
| 139 |
+
const userId = ctx.session.user.id;
|
| 140 |
+
const now = new Date();
|
| 141 |
+
|
| 142 |
+
let startDate: Date;
|
| 143 |
+
let dateFormat: (d: Date) => string;
|
| 144 |
+
|
| 145 |
+
switch (input.period) {
|
| 146 |
+
case "daily": {
|
| 147 |
+
startDate = new Date(now);
|
| 148 |
+
startDate.setDate(startDate.getDate() - 6);
|
| 149 |
+
startDate.setHours(0, 0, 0, 0);
|
| 150 |
+
dateFormat = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
| 151 |
+
break;
|
| 152 |
+
}
|
| 153 |
+
case "weekly": {
|
| 154 |
+
startDate = new Date(now);
|
| 155 |
+
startDate.setDate(startDate.getDate() - 27);
|
| 156 |
+
startDate.setHours(0, 0, 0, 0);
|
| 157 |
+
dateFormat = (d: Date) => {
|
| 158 |
+
const weekStart = new Date(d);
|
| 159 |
+
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
| 160 |
+
return `${weekStart.getFullYear()}-W${String(Math.ceil(((weekStart.getTime() - new Date(weekStart.getFullYear(), 0, 1).getTime()) / 86400000 + 1) / 7)).padStart(2, "0")}`;
|
| 161 |
+
};
|
| 162 |
+
break;
|
| 163 |
+
}
|
| 164 |
+
case "monthly": {
|
| 165 |
+
startDate = new Date(now);
|
| 166 |
+
startDate.setMonth(startDate.getMonth() - 5);
|
| 167 |
+
startDate.setDate(1);
|
| 168 |
+
startDate.setHours(0, 0, 0, 0);
|
| 169 |
+
dateFormat = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
| 170 |
+
break;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const rows = await db
|
| 175 |
+
.select({
|
| 176 |
+
date: sql<string>`date_trunc('day', ${generationJob.createdAt})::date`,
|
| 177 |
+
totalTokens: sql<number>`coalesce(sum(${generationJob.tokensUsed}), 0)`,
|
| 178 |
+
jobCount: sql<number>`count(*)`,
|
| 179 |
+
})
|
| 180 |
+
.from(generationJob)
|
| 181 |
+
.where(
|
| 182 |
+
and(
|
| 183 |
+
eq(generationJob.userId, userId),
|
| 184 |
+
gte(generationJob.createdAt, startDate),
|
| 185 |
+
),
|
| 186 |
+
)
|
| 187 |
+
.groupBy(sql`date_trunc('day', ${generationJob.createdAt})::date`)
|
| 188 |
+
.orderBy(sql`date_trunc('day', ${generationJob.createdAt})::date`);
|
| 189 |
+
|
| 190 |
+
const grouped: Record<string, { date: string; totalTokens: number; jobCount: number }> = {};
|
| 191 |
+
|
| 192 |
+
for (const row of rows) {
|
| 193 |
+
const d = new Date(row.date as string);
|
| 194 |
+
const key = dateFormat(d);
|
| 195 |
+
if (!grouped[key]) {
|
| 196 |
+
grouped[key] = { date: key, totalTokens: 0, jobCount: 0 };
|
| 197 |
+
}
|
| 198 |
+
grouped[key].totalTokens += Number(row.totalTokens);
|
| 199 |
+
grouped[key].jobCount += Number(row.jobCount);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const labels: { date: string; label: string }[] = [];
|
| 203 |
+
if (input.period === "daily") {
|
| 204 |
+
for (let i = 6; i >= 0; i--) {
|
| 205 |
+
const d = new Date(now);
|
| 206 |
+
d.setDate(d.getDate() - i);
|
| 207 |
+
labels.push({ date: dateFormat(d), label: `${d.getDate()}/${d.getMonth() + 1}` });
|
| 208 |
+
}
|
| 209 |
+
} else if (input.period === "weekly") {
|
| 210 |
+
for (let i = 3; i >= 0; i--) {
|
| 211 |
+
const d = new Date(now);
|
| 212 |
+
d.setDate(d.getDate() - i * 7);
|
| 213 |
+
const weekStart = new Date(d);
|
| 214 |
+
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
| 215 |
+
labels.push({ date: dateFormat(weekStart), label: `W${i === 0 ? "Ini" : `${i}`}` });
|
| 216 |
+
}
|
| 217 |
+
} else {
|
| 218 |
+
for (let i = 5; i >= 0; i--) {
|
| 219 |
+
const d = new Date(now);
|
| 220 |
+
d.setMonth(d.getMonth() - i);
|
| 221 |
+
const monthNames = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
|
| 222 |
+
labels.push({ date: dateFormat(d), label: `${monthNames[d.getMonth()]} ${d.getFullYear()}` });
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const result = labels.map((l) => ({
|
| 227 |
+
...l,
|
| 228 |
+
totalTokens: grouped[l.date]?.totalTokens ?? 0,
|
| 229 |
+
jobCount: grouped[l.date]?.jobCount ?? 0,
|
| 230 |
+
}));
|
| 231 |
+
|
| 232 |
+
return result;
|
| 233 |
+
}),
|
| 234 |
+
|
| 235 |
retryJob: protectedProcedure
|
| 236 |
.input(z.object({ jobId: z.string().uuid() }))
|
| 237 |
.mutation(async ({ ctx, input }) => {
|