aigetwey 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +302 -0
  4. package/assets/logo.svg +8 -0
  5. package/assets/screenshot.png +0 -0
  6. package/assets/wordmark.svg +9 -0
  7. package/config.example.yaml +56 -0
  8. package/dashboard/.env.example +12 -0
  9. package/dashboard/next-env.d.ts +6 -0
  10. package/dashboard/next.config.ts +12 -0
  11. package/dashboard/package-lock.json +1771 -0
  12. package/dashboard/package.json +29 -0
  13. package/dashboard/postcss.config.mjs +5 -0
  14. package/dashboard/src/app/(console)/combos/page.tsx +10 -0
  15. package/dashboard/src/app/(console)/config/page.tsx +5 -0
  16. package/dashboard/src/app/(console)/console/page.tsx +92 -0
  17. package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
  18. package/dashboard/src/app/(console)/layout.tsx +17 -0
  19. package/dashboard/src/app/(console)/page.tsx +8 -0
  20. package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
  21. package/dashboard/src/app/(console)/providers/page.tsx +5 -0
  22. package/dashboard/src/app/(console)/quota/page.tsx +5 -0
  23. package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
  24. package/dashboard/src/app/(console)/tools/page.tsx +5 -0
  25. package/dashboard/src/app/(console)/usage/page.tsx +24 -0
  26. package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
  27. package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
  28. package/dashboard/src/app/api/login/route.ts +30 -0
  29. package/dashboard/src/app/api/logout/route.ts +9 -0
  30. package/dashboard/src/app/api/password/route.ts +34 -0
  31. package/dashboard/src/app/globals.css +340 -0
  32. package/dashboard/src/app/icon.svg +8 -0
  33. package/dashboard/src/app/layout.tsx +28 -0
  34. package/dashboard/src/app/login/page.tsx +60 -0
  35. package/dashboard/src/components/AreaChart.tsx +115 -0
  36. package/dashboard/src/components/Badge.tsx +32 -0
  37. package/dashboard/src/components/Button.tsx +60 -0
  38. package/dashboard/src/components/CapacityBadges.tsx +40 -0
  39. package/dashboard/src/components/Checkbox.tsx +40 -0
  40. package/dashboard/src/components/CliToolConfig.tsx +63 -0
  41. package/dashboard/src/components/ConfigEditor.tsx +199 -0
  42. package/dashboard/src/components/ConfirmModal.tsx +36 -0
  43. package/dashboard/src/components/CooldownTimer.tsx +42 -0
  44. package/dashboard/src/components/EndpointView.tsx +439 -0
  45. package/dashboard/src/components/Icon.tsx +25 -0
  46. package/dashboard/src/components/KeyReveal.tsx +78 -0
  47. package/dashboard/src/components/Lamp.tsx +8 -0
  48. package/dashboard/src/components/LogTable.tsx +223 -0
  49. package/dashboard/src/components/LogoutButton.tsx +20 -0
  50. package/dashboard/src/components/ModelPicker.tsx +121 -0
  51. package/dashboard/src/components/ModelSelectModal.tsx +126 -0
  52. package/dashboard/src/components/PasswordEditor.tsx +86 -0
  53. package/dashboard/src/components/PricingEditor.tsx +171 -0
  54. package/dashboard/src/components/ProviderDetail.tsx +566 -0
  55. package/dashboard/src/components/ProviderManager.tsx +311 -0
  56. package/dashboard/src/components/QuotaView.tsx +78 -0
  57. package/dashboard/src/components/Rail.tsx +82 -0
  58. package/dashboard/src/components/RichCard.tsx +46 -0
  59. package/dashboard/src/components/RoutingView.tsx +329 -0
  60. package/dashboard/src/components/ThemeProvider.tsx +36 -0
  61. package/dashboard/src/components/ToastProvider.tsx +58 -0
  62. package/dashboard/src/components/ToolDetail.tsx +475 -0
  63. package/dashboard/src/components/TopBar.tsx +128 -0
  64. package/dashboard/src/components/UsageView.tsx +151 -0
  65. package/dashboard/src/components/ui.tsx +54 -0
  66. package/dashboard/src/lib/capabilities.ts +318 -0
  67. package/dashboard/src/lib/cliTools.ts +120 -0
  68. package/dashboard/src/lib/client.ts +190 -0
  69. package/dashboard/src/lib/gateway.ts +269 -0
  70. package/dashboard/src/lib/session.ts +71 -0
  71. package/dashboard/src/middleware.ts +37 -0
  72. package/dashboard/tsconfig.json +21 -0
  73. package/dist/adapters/anthropic.js +289 -0
  74. package/dist/adapters/anthropic.js.map +1 -0
  75. package/dist/adapters/gemini.js +268 -0
  76. package/dist/adapters/gemini.js.map +1 -0
  77. package/dist/adapters/index.js +8 -0
  78. package/dist/adapters/index.js.map +1 -0
  79. package/dist/adapters/openai.js +13 -0
  80. package/dist/adapters/openai.js.map +1 -0
  81. package/dist/cli/tray/autostart.js +152 -0
  82. package/dist/cli/tray/autostart.js.map +1 -0
  83. package/dist/cli/tray/icon.js +4 -0
  84. package/dist/cli/tray/icon.js.map +1 -0
  85. package/dist/cli/tray/tray.js +141 -0
  86. package/dist/cli/tray/tray.js.map +1 -0
  87. package/dist/cli/tray/trayRuntime.js +91 -0
  88. package/dist/cli/tray/trayRuntime.js.map +1 -0
  89. package/dist/cli.js +361 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/config.js +728 -0
  92. package/dist/config.js.map +1 -0
  93. package/dist/core/authStore.js +78 -0
  94. package/dist/core/authStore.js.map +1 -0
  95. package/dist/core/canonical.js +9 -0
  96. package/dist/core/canonical.js.map +1 -0
  97. package/dist/core/console-buffer.js +25 -0
  98. package/dist/core/console-buffer.js.map +1 -0
  99. package/dist/core/fallback.js +62 -0
  100. package/dist/core/fallback.js.map +1 -0
  101. package/dist/core/handler.js +174 -0
  102. package/dist/core/handler.js.map +1 -0
  103. package/dist/core/keypool.js +105 -0
  104. package/dist/core/keypool.js.map +1 -0
  105. package/dist/core/quota.js +165 -0
  106. package/dist/core/quota.js.map +1 -0
  107. package/dist/core/state.js +52 -0
  108. package/dist/core/state.js.map +1 -0
  109. package/dist/db.js +193 -0
  110. package/dist/db.js.map +1 -0
  111. package/dist/headroom/compress.js +44 -0
  112. package/dist/headroom/compress.js.map +1 -0
  113. package/dist/headroom/detect.js +108 -0
  114. package/dist/headroom/detect.js.map +1 -0
  115. package/dist/headroom/process.js +158 -0
  116. package/dist/headroom/process.js.map +1 -0
  117. package/dist/inject/caveman.js +30 -0
  118. package/dist/inject/caveman.js.map +1 -0
  119. package/dist/inject/index.js +24 -0
  120. package/dist/inject/index.js.map +1 -0
  121. package/dist/inject/ponytail.js +19 -0
  122. package/dist/inject/ponytail.js.map +1 -0
  123. package/dist/middleware/auth.js +66 -0
  124. package/dist/middleware/auth.js.map +1 -0
  125. package/dist/providers/capabilities.js +246 -0
  126. package/dist/providers/capabilities.js.map +1 -0
  127. package/dist/providers/free.js +43 -0
  128. package/dist/providers/free.js.map +1 -0
  129. package/dist/providers/pricing.js +224 -0
  130. package/dist/providers/pricing.js.map +1 -0
  131. package/dist/providers/vertex.js +97 -0
  132. package/dist/providers/vertex.js.map +1 -0
  133. package/dist/routes/admin.js +622 -0
  134. package/dist/routes/admin.js.map +1 -0
  135. package/dist/routes/health.js +4 -0
  136. package/dist/routes/health.js.map +1 -0
  137. package/dist/routes/index.js +12 -0
  138. package/dist/routes/index.js.map +1 -0
  139. package/dist/routes/v1.js +75 -0
  140. package/dist/routes/v1.js.map +1 -0
  141. package/dist/rtk/detect.js +50 -0
  142. package/dist/rtk/detect.js.map +1 -0
  143. package/dist/rtk/filters.js +85 -0
  144. package/dist/rtk/filters.js.map +1 -0
  145. package/dist/rtk/index.js +39 -0
  146. package/dist/rtk/index.js.map +1 -0
  147. package/dist/server.js +100 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/stream/anthropic-stream.js +239 -0
  150. package/dist/stream/anthropic-stream.js.map +1 -0
  151. package/dist/stream/chunk.js +7 -0
  152. package/dist/stream/chunk.js.map +1 -0
  153. package/dist/stream/gemini-stream.js +135 -0
  154. package/dist/stream/gemini-stream.js.map +1 -0
  155. package/dist/stream/index.js +12 -0
  156. package/dist/stream/index.js.map +1 -0
  157. package/dist/stream/openai-stream.js +34 -0
  158. package/dist/stream/openai-stream.js.map +1 -0
  159. package/dist/stream/sse.js +64 -0
  160. package/dist/stream/sse.js.map +1 -0
  161. package/dist/translator/thinking.js +70 -0
  162. package/dist/translator/thinking.js.map +1 -0
  163. package/dist/translator/thinkingUnified.js +322 -0
  164. package/dist/translator/thinkingUnified.js.map +1 -0
  165. package/dist/upstream/client.js +120 -0
  166. package/dist/upstream/client.js.map +1 -0
  167. package/package.json +76 -0
  168. package/run.sh +27 -0
  169. package/src/adapters/anthropic.ts +377 -0
  170. package/src/adapters/gemini.ts +341 -0
  171. package/src/adapters/index.ts +17 -0
  172. package/src/adapters/openai.ts +22 -0
  173. package/src/cli/tray/autostart.ts +133 -0
  174. package/src/cli/tray/icon.ts +4 -0
  175. package/src/cli/tray/tray.ts +156 -0
  176. package/src/cli/tray/trayRuntime.ts +90 -0
  177. package/src/cli.ts +379 -0
  178. package/src/config.ts +777 -0
  179. package/src/core/authStore.ts +86 -0
  180. package/src/core/canonical.ts +93 -0
  181. package/src/core/console-buffer.ts +39 -0
  182. package/src/core/fallback.ts +116 -0
  183. package/src/core/handler.ts +236 -0
  184. package/src/core/keypool.ts +152 -0
  185. package/src/core/quota.ts +214 -0
  186. package/src/core/state.ts +65 -0
  187. package/src/db.ts +280 -0
  188. package/src/headroom/compress.ts +78 -0
  189. package/src/headroom/detect.ts +119 -0
  190. package/src/headroom/process.ts +166 -0
  191. package/src/inject/caveman.ts +35 -0
  192. package/src/inject/index.ts +46 -0
  193. package/src/inject/ponytail.ts +31 -0
  194. package/src/middleware/auth.ts +76 -0
  195. package/src/providers/capabilities.ts +297 -0
  196. package/src/providers/free.ts +53 -0
  197. package/src/providers/pricing.ts +261 -0
  198. package/src/providers/vertex.ts +117 -0
  199. package/src/routes/admin.ts +716 -0
  200. package/src/routes/health.ts +5 -0
  201. package/src/routes/index.ts +24 -0
  202. package/src/routes/v1.ts +87 -0
  203. package/src/rtk/detect.ts +55 -0
  204. package/src/rtk/filters.ts +94 -0
  205. package/src/rtk/index.ts +58 -0
  206. package/src/server.ts +108 -0
  207. package/src/stream/anthropic-stream.ts +310 -0
  208. package/src/stream/chunk.ts +46 -0
  209. package/src/stream/gemini-stream.ts +158 -0
  210. package/src/stream/index.ts +23 -0
  211. package/src/stream/openai-stream.ts +41 -0
  212. package/src/stream/sse.ts +72 -0
  213. package/src/translator/thinking.ts +64 -0
  214. package/src/translator/thinkingUnified.ts +319 -0
  215. package/src/upstream/client.ts +155 -0
  216. package/tsconfig.json +20 -0
@@ -0,0 +1,40 @@
1
+ import { Icon } from "./Icon";
2
+
3
+ /**
4
+ * Themed checkbox. Native checkboxes look out of place on the dark Haulix
5
+ * surface, so this is a button styled to match: filled with the accent + a
6
+ * check glyph when on, a bordered box when off. `indeterminate` renders a dash
7
+ * for "some selected" (drives the select-all row).
8
+ */
9
+ export function Checkbox({
10
+ checked,
11
+ indeterminate,
12
+ onChange,
13
+ className,
14
+ ariaLabel,
15
+ }: {
16
+ checked: boolean;
17
+ indeterminate?: boolean;
18
+ onChange: () => void;
19
+ className?: string;
20
+ ariaLabel?: string;
21
+ }) {
22
+ const on = checked || indeterminate;
23
+ return (
24
+ <button
25
+ type="button"
26
+ role="checkbox"
27
+ aria-checked={indeterminate ? "mixed" : checked}
28
+ aria-label={ariaLabel}
29
+ onClick={(e) => {
30
+ e.stopPropagation();
31
+ onChange();
32
+ }}
33
+ className={`flex h-[18px] w-[18px] flex-none items-center justify-center rounded-[5px] border transition-colors ${
34
+ on ? "border-accent bg-accent text-accent-ink" : "border-border bg-bg text-transparent hover:border-text-subtle"
35
+ }${className ? ` ${className}` : ""}`}
36
+ >
37
+ <Icon name={indeterminate ? "remove" : "check"} size={13} />
38
+ </button>
39
+ );
40
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import Link from "next/link";
5
+ import { Badge } from "@/components/Badge";
6
+ import { Icon } from "@/components/Icon";
7
+ import { Empty } from "@/components/ui";
8
+ import { CLI_TOOLS } from "@/lib/cliTools";
9
+ import type { EndpointPayload } from "@/lib/gateway";
10
+
11
+ /** Grid of CLI tool setup cards. Each links to a step-by-step detail page. */
12
+ export function CliToolConfig() {
13
+ const [port, setPort] = useState<number | null>(null);
14
+ const [error, setError] = useState("");
15
+
16
+ useEffect(() => {
17
+ void (async () => {
18
+ const res = await fetch("/api/gw/admin/endpoint");
19
+ if (!res.ok) {
20
+ setError("could not reach the gateway");
21
+ return;
22
+ }
23
+ setPort(((await res.json()) as EndpointPayload).port);
24
+ })();
25
+ }, []);
26
+
27
+ if (error) return <Empty>{error}</Empty>;
28
+
29
+ return (
30
+ <div>
31
+ <div className="mb-6">
32
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">CLI Tools</h1>
33
+ <p className="mt-1 text-[13px] text-text-muted">
34
+ Point your coding tools at the gateway. {port ? `Listening on port ${port}.` : ""}
35
+ </p>
36
+ </div>
37
+
38
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
39
+ {CLI_TOOLS.map((t) => (
40
+ <Link
41
+ key={t.id}
42
+ href={`/tools/${t.id}`}
43
+ className="group rounded-brand-lg border border-border bg-surface p-4 shadow-soft transition-colors hover:border-text-subtle"
44
+ >
45
+ <div className="flex items-center justify-between gap-2">
46
+ <span className="flex items-center gap-2.5">
47
+ <span className="flex h-8 w-8 items-center justify-center rounded-brand bg-surface-2 text-text-muted group-hover:text-text">
48
+ <Icon name={t.icon} size={18} />
49
+ </span>
50
+ <span className="text-[14px] font-semibold text-text">{t.name}</span>
51
+ </span>
52
+ <Badge tone="info">{t.format}</Badge>
53
+ </div>
54
+ <p className="mt-2 text-[12.5px] text-text-muted">{t.blurb}</p>
55
+ <span className="mt-3 inline-flex items-center gap-1 text-[12px] text-text-subtle group-hover:text-text">
56
+ Setup <Icon name="arrow_forward" size={14} />
57
+ </span>
58
+ </Link>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import { Button } from "@/components/Button";
5
+ import { Badge } from "@/components/Badge";
6
+ import { RichCard, CardTitle } from "@/components/RichCard";
7
+ import { Icon } from "@/components/Icon";
8
+ import { Empty } from "@/components/ui";
9
+ import { PricingEditor } from "@/components/PricingEditor";
10
+ import { PasswordEditor } from "@/components/PasswordEditor";
11
+ import { stringify } from "yaml";
12
+ import type { MaskedConfig } from "@/lib/gateway";
13
+
14
+ /**
15
+ * Settings — structured cards (instance summary, per-model pricing, backup) with
16
+ * the raw config editor tucked into an Advanced disclosure. Saving the YAML
17
+ * re-validates (zod) and hot-reloads on the gateway; an invalid edit is rejected
18
+ * with the message and the live config keeps serving. Masked keys (sk-…1234) left
19
+ * unchanged are restored server-side.
20
+ */
21
+ export function ConfigEditor() {
22
+ const [text, setText] = useState("");
23
+ const [original, setOriginal] = useState("");
24
+ const [info, setInfo] = useState<MaskedConfig["server"] | null>(null);
25
+ const [error, setError] = useState("");
26
+ const [saved, setSaved] = useState(false);
27
+ const [busy, setBusy] = useState(false);
28
+ const [loading, setLoading] = useState(true);
29
+
30
+ const reload = useCallback(async () => {
31
+ setLoading(true);
32
+ const res = await fetch("/api/gw/admin/config");
33
+ if (!res.ok) {
34
+ setError("could not reach the gateway");
35
+ setLoading(false);
36
+ return;
37
+ }
38
+ const cfg = (await res.json()) as MaskedConfig;
39
+ const yaml = stringify(cfg);
40
+ setText(yaml);
41
+ setOriginal(yaml);
42
+ setInfo(cfg.server);
43
+ setError("");
44
+ setLoading(false);
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ void reload();
49
+ }, [reload]);
50
+
51
+ async function save() {
52
+ setBusy(true);
53
+ setError("");
54
+ setSaved(false);
55
+ const res = await fetch("/api/gw/admin/config", {
56
+ method: "PUT",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ text }),
59
+ });
60
+ setBusy(false);
61
+ if (res.ok) {
62
+ setSaved(true);
63
+ setTimeout(() => setSaved(false), 2000);
64
+ await reload();
65
+ } else {
66
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
67
+ setError(body.error ?? "validation failed");
68
+ }
69
+ }
70
+
71
+ const fileInput = useRef<HTMLInputElement>(null);
72
+
73
+ // Export the UNMASKED backup straight through the proxy. The download attribute
74
+ // forces a save with our filename even though the proxy labels it as JSON.
75
+ function exportConfig() {
76
+ const a = document.createElement("a");
77
+ a.href = "/api/gw/admin/config/export";
78
+ a.download = "aigetwey-config.yaml";
79
+ document.body.appendChild(a);
80
+ a.click();
81
+ a.remove();
82
+ }
83
+
84
+ // Load a backup file INTO the editor (not a blind apply) so it goes through the
85
+ // same validate + hot-reload path on Save, and the operator reviews it first.
86
+ async function importFile(e: React.ChangeEvent<HTMLInputElement>) {
87
+ const file = e.target.files?.[0];
88
+ e.target.value = "";
89
+ if (!file) return;
90
+ setText(await file.text());
91
+ setError("");
92
+ }
93
+
94
+ const dirty = text !== original;
95
+ const keyCount = info?.api_keys.length ?? 0;
96
+
97
+ return (
98
+ <div>
99
+ <div className="mb-6">
100
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Settings</h1>
101
+ <p className="mt-1 text-[13px] text-text-muted">Instance details, model pricing, and backup.</p>
102
+ </div>
103
+
104
+ <div className="grid gap-4">
105
+ <RichCard header={<CardTitle title="Instance" sub="read-only" />}>
106
+ {info ? (
107
+ <div className="space-y-2.5 text-[13px]">
108
+ <Row label="Listen address">
109
+ <span className="tnum text-text">{info.host}:{info.port}</span>
110
+ </Row>
111
+ <Row label="Gateway auth">
112
+ <Badge tone={keyCount > 0 ? "live" : "warn"}>
113
+ {keyCount > 0 ? `${keyCount} key${keyCount > 1 ? "s" : ""}` : "disabled (localhost only)"}
114
+ </Badge>
115
+ </Row>
116
+ <Row label="Admin password">
117
+ <span className="text-text-subtle">seeded from AIGETWEY_ADMIN_PASSWORD — change it below</span>
118
+ </Row>
119
+ </div>
120
+ ) : (
121
+ <Empty>Loading…</Empty>
122
+ )}
123
+ </RichCard>
124
+
125
+ <PasswordEditor />
126
+
127
+ <PricingEditor />
128
+
129
+ <RichCard
130
+ header={
131
+ <>
132
+ <CardTitle title="Backup" sub="full config including real keys" />
133
+ <div className="flex items-center gap-2">
134
+ <input ref={fileInput} type="file" accept=".yaml,.yml,.json,text/*" className="hidden" onChange={importFile} />
135
+ <Button variant="ghost" disabled={busy} onClick={exportConfig} title="Download the full config (includes real keys)">
136
+ <Icon name="download" size={14} /> Export
137
+ </Button>
138
+ <Button variant="ghost" disabled={busy} onClick={() => fileInput.current?.click()} title="Load a backup file into the Advanced editor">
139
+ <Icon name="upload" size={14} /> Import
140
+ </Button>
141
+ </div>
142
+ </>
143
+ }
144
+ >
145
+ <p className="text-[12.5px] text-text-muted">
146
+ Export downloads the live config as YAML with unmasked keys — keep it safe. Import loads a file into
147
+ the raw editor below for review; it only applies when you Save there.
148
+ </p>
149
+ </RichCard>
150
+
151
+ <details className="group overflow-hidden rounded-brand-lg border border-border bg-surface shadow-soft">
152
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 [&::-webkit-details-marker]:hidden">
153
+ <CardTitle title="Advanced — raw config" sub="YAML, validated + hot-reloaded on save" />
154
+ <span className="flex items-center gap-2">
155
+ {dirty && <span className="text-[12px] text-warning">unsaved changes</span>}
156
+ {saved && (
157
+ <span className="flex items-center gap-1 text-[12px] text-success">
158
+ <Icon name="check" size={14} /> saved
159
+ </span>
160
+ )}
161
+ <Icon name="expand_more" size={18} className="text-text-subtle transition-transform group-open:rotate-180" />
162
+ </span>
163
+ </summary>
164
+
165
+ <div className="border-t border-border-subtle p-4">
166
+ <div className="mb-3 flex items-center justify-end gap-2">
167
+ <Button variant="ghost" disabled={!dirty || busy} onClick={() => setText(original)}>Revert</Button>
168
+ <Button disabled={!dirty || busy} onClick={save}>{busy ? "Saving…" : "Save & reload"}</Button>
169
+ </div>
170
+ {error && (
171
+ <pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-brand border border-danger/40 bg-danger/8 px-3 py-2 text-[12px] text-danger">
172
+ {error}
173
+ </pre>
174
+ )}
175
+ {loading ? (
176
+ <Empty>Loading…</Empty>
177
+ ) : (
178
+ <textarea
179
+ value={text}
180
+ onChange={(e) => setText(e.target.value)}
181
+ spellCheck={false}
182
+ className="h-[55vh] w-full resize-none rounded-brand border border-border bg-bg p-4 font-mono text-[12.5px] leading-relaxed text-text focus:border-accent focus:outline-none"
183
+ />
184
+ )}
185
+ </div>
186
+ </details>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ function Row({ label, children }: { label: string; children: React.ReactNode }) {
193
+ return (
194
+ <div className="flex items-center justify-between gap-3">
195
+ <span className="text-text-subtle">{label}</span>
196
+ {children}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/Button";
4
+ import { Icon } from "@/components/Icon";
5
+
6
+ interface ConfirmModalProps {
7
+ title: string;
8
+ message: string;
9
+ confirmLabel?: string;
10
+ onConfirm: () => void;
11
+ onCancel: () => void;
12
+ busy?: boolean;
13
+ }
14
+
15
+ export function ConfirmModal({ title, message, confirmLabel = "Delete", onConfirm, onCancel, busy }: ConfirmModalProps) {
16
+ return (
17
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={onCancel}>
18
+ <div
19
+ className="w-full max-w-sm rounded-brand-lg border border-border bg-surface p-5 shadow-elevated"
20
+ onClick={(e) => e.stopPropagation()}
21
+ >
22
+ <div className="mb-2 flex items-center gap-2">
23
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-danger/10 text-danger">
24
+ <Icon name="warning" size={16} />
25
+ </span>
26
+ <h2 className="text-[15px] font-semibold text-text">{title}</h2>
27
+ </div>
28
+ <p className="mb-4 text-[12.5px] text-text-muted">{message}</p>
29
+ <div className="flex justify-end gap-2">
30
+ <Button variant="ghost" onClick={onCancel}>Cancel</Button>
31
+ <Button variant="danger" disabled={busy} onClick={onConfirm}>{confirmLabel}</Button>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Icon } from "./Icon";
5
+ import { fmt } from "./ui";
6
+
7
+ /**
8
+ * Live countdown from a gateway-snapshot remaining-ms. Counts down locally each
9
+ * second from render. Used for both key cooldowns (danger tone) and quota window
10
+ * resets (muted tone). Renders nothing once it hits zero unless `keepZero`.
11
+ */
12
+ export function CooldownTimer({
13
+ ms,
14
+ tone = "danger",
15
+ icon = "timer",
16
+ keepZero = false,
17
+ }: {
18
+ ms: number;
19
+ tone?: "danger" | "muted";
20
+ icon?: string;
21
+ keepZero?: boolean;
22
+ }) {
23
+ const [until] = useState(() => Date.now() + ms);
24
+ const [remaining, setRemaining] = useState(() => Math.max(0, ms));
25
+
26
+ useEffect(() => {
27
+ const tick = () => setRemaining(Math.max(0, until - Date.now()));
28
+ tick();
29
+ const id = setInterval(tick, 1000);
30
+ return () => clearInterval(id);
31
+ }, [until]);
32
+
33
+ if (remaining <= 0 && !keepZero) return null;
34
+
35
+ const color = tone === "danger" ? "text-danger" : "text-text-muted";
36
+ return (
37
+ <span className={`inline-flex items-center gap-1 tnum text-[12px] ${color}`}>
38
+ <Icon name={icon} size={13} />
39
+ {remaining <= 0 ? "now" : fmt.duration(remaining)}
40
+ </span>
41
+ );
42
+ }