station-kit 1.0.0

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useState, type FormEvent } from "react";
4
+ import { login } from "../hooks/use-api";
5
+ import { useLoginCallback } from "./auth-provider";
6
+
7
+ export function LoginPage() {
8
+ const onSuccess = useLoginCallback();
9
+ const [username, setUsername] = useState("");
10
+ const [password, setPassword] = useState("");
11
+ const [error, setError] = useState("");
12
+ const [loading, setLoading] = useState(false);
13
+
14
+ async function handleSubmit(e: FormEvent) {
15
+ e.preventDefault();
16
+ setError("");
17
+ setLoading(true);
18
+ try {
19
+ const ok = await login(username, password);
20
+ if (ok) {
21
+ onSuccess();
22
+ } else {
23
+ setError("Invalid credentials.");
24
+ }
25
+ } catch {
26
+ setError("Connection failed.");
27
+ }
28
+ setLoading(false);
29
+ }
30
+
31
+ return (
32
+ <div className="login-page">
33
+ <div className="login-card">
34
+ <div className="login-header">
35
+ <svg width="28" height="28" viewBox="0 0 100 100" fill="none" aria-hidden="true">
36
+ <path d="M50 2 L39 25 L27 50 L18 70 L10 88 L90 88 L82 70 L73 50 L61 25 Z" stroke="currentColor" strokeWidth="1.5" />
37
+ <line x1="50" y1="2" x2="50" y2="88" stroke="currentColor" strokeWidth="1" />
38
+ <line x1="16" y1="70" x2="84" y2="70" stroke="currentColor" strokeWidth="1.2" />
39
+ <line x1="39" y1="25" x2="61" y2="25" stroke="currentColor" strokeWidth="0.8" />
40
+ <line x1="27" y1="50" x2="73" y2="50" stroke="currentColor" strokeWidth="0.8" />
41
+ <rect x="43" y="88" width="14" height="5" fill="currentColor" opacity="0.5" />
42
+ <circle cx="50" cy="2" r="1.5" fill="currentColor" />
43
+ </svg>
44
+ <h1>Station</h1>
45
+ </div>
46
+ <form onSubmit={handleSubmit}>
47
+ <div className="login-field">
48
+ <label htmlFor="username">Username</label>
49
+ <input
50
+ id="username"
51
+ type="text"
52
+ value={username}
53
+ onChange={(e) => setUsername(e.target.value)}
54
+ autoComplete="username"
55
+ autoFocus
56
+ required
57
+ />
58
+ </div>
59
+ <div className="login-field">
60
+ <label htmlFor="password">Password</label>
61
+ <input
62
+ id="password"
63
+ type="password"
64
+ value={password}
65
+ onChange={(e) => setPassword(e.target.value)}
66
+ autoComplete="current-password"
67
+ required
68
+ />
69
+ </div>
70
+ {error && <div className="login-error">{error}</div>}
71
+ <button type="submit" className="btn btn--primary login-btn" disabled={loading}>
72
+ {loading ? "Signing in..." : "Sign in"}
73
+ </button>
74
+ </form>
75
+ </div>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,158 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import Link from "next/link";
5
+ import { StatusBadge } from "./status-badge";
6
+ import { JsonViewer } from "./json-viewer";
7
+
8
+ interface LogEntry {
9
+ level: string;
10
+ message: string;
11
+ timestamp: string;
12
+ }
13
+
14
+ interface NodeDetailProps {
15
+ node: {
16
+ id: string;
17
+ broadcastRunId: string;
18
+ nodeName: string;
19
+ signalName: string;
20
+ signalRunId?: string;
21
+ status: string;
22
+ skipReason?: string;
23
+ input?: string;
24
+ output?: string;
25
+ error?: string;
26
+ startedAt?: string;
27
+ completedAt?: string;
28
+ };
29
+ logs?: LogEntry[];
30
+ }
31
+
32
+ function duration(startedAt?: string, completedAt?: string): string {
33
+ if (!startedAt) return "-";
34
+ const start = new Date(startedAt).getTime();
35
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
36
+ const ms = end - start;
37
+ if (ms < 1000) return `${ms}ms`;
38
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
39
+ return `${(ms / 60_000).toFixed(1)}m`;
40
+ }
41
+
42
+ function CollapsibleSection({ label, children }: { label: string; children: React.ReactNode }) {
43
+ const [open, setOpen] = useState(false);
44
+ return (
45
+ <div className="detail-section">
46
+ <button
47
+ className="collapsible-header"
48
+ onClick={() => setOpen(!open)}
49
+ >
50
+ <span className="collapsible-chevron">{open ? "\u25BE" : "\u25B8"}</span>
51
+ {label}
52
+ </button>
53
+ {open && <div className="collapsible-content">{children}</div>}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export function NodeDetail({ node, logs }: NodeDetailProps) {
59
+ const logEndRef = useRef<HTMLDivElement>(null);
60
+
61
+ useEffect(() => {
62
+ logEndRef.current?.scrollIntoView({ behavior: "smooth" });
63
+ }, [logs?.length]);
64
+
65
+ return (
66
+ <div className="workflow-panel">
67
+ <div className="node-detail-header">
68
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
69
+ <span className="node-detail-title">{node.nodeName}</span>
70
+ <StatusBadge status={node.status as "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped"} />
71
+ </div>
72
+ <span className="node-detail-meta">
73
+ {node.signalName} {"\u00B7"} {duration(node.startedAt, node.completedAt)}
74
+ </span>
75
+ </div>
76
+
77
+ {node.status === "skipped" && node.skipReason && (
78
+ <div className="detail-section">
79
+ <div
80
+ style={{
81
+ fontFamily: "var(--font-mono)",
82
+ fontSize: "0.8125rem",
83
+ color: "var(--muted)",
84
+ padding: "0.5rem 0",
85
+ }}
86
+ >
87
+ Skipped: {node.skipReason}
88
+ </div>
89
+ </div>
90
+ )}
91
+
92
+ {node.error && (
93
+ <div className="detail-section">
94
+ <div className="error-block">{node.error}</div>
95
+ </div>
96
+ )}
97
+
98
+ {/* Logs — primary content */}
99
+ <div className="detail-section">
100
+ <div className="detail-section-label">Logs</div>
101
+ <div className="log-container" style={{ maxHeight: "none", minHeight: "120px" }}>
102
+ {(!logs || logs.length === 0) ? (
103
+ <div style={{
104
+ padding: "1rem",
105
+ color: "var(--muted)",
106
+ fontFamily: "var(--font-mono)",
107
+ fontSize: "0.75rem",
108
+ }}>
109
+ {node.status === "pending" ? "Waiting for execution..." :
110
+ node.status === "skipped" ? "Node was skipped." :
111
+ "No log output captured."}
112
+ </div>
113
+ ) : (
114
+ logs.map((log, i) => (
115
+ <div
116
+ key={i}
117
+ className="log-line"
118
+ style={{
119
+ color: log.level === "stderr" ? "var(--rust)" : "var(--charcoal)",
120
+ }}
121
+ >
122
+ <span className="log-timestamp">
123
+ {new Date(log.timestamp).toLocaleTimeString()}
124
+ </span>
125
+ <span className="log-level" data-level={log.level}>
126
+ {log.level === "stderr" ? "ERR" : "OUT"}
127
+ </span>
128
+ <span className="log-message">{log.message}</span>
129
+ </div>
130
+ ))
131
+ )}
132
+ <div ref={logEndRef} />
133
+ </div>
134
+ </div>
135
+
136
+ {/* Collapsible I/O */}
137
+ {node.input && (
138
+ <CollapsibleSection label="Input">
139
+ <JsonViewer data={node.input} />
140
+ </CollapsibleSection>
141
+ )}
142
+
143
+ {node.output && (
144
+ <CollapsibleSection label="Output">
145
+ <JsonViewer data={node.output} />
146
+ </CollapsibleSection>
147
+ )}
148
+
149
+ {node.signalRunId && (
150
+ <div className="detail-section" style={{ paddingTop: "0.25rem" }}>
151
+ <Link href={`/runs/${node.signalRunId}`} className="meta-value--link" style={{ fontSize: "0.8125rem" }}>
152
+ View signal run &rarr;
153
+ </Link>
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,8 @@
1
+ export function PulseDot({ connected }: { connected: boolean }) {
2
+ return (
3
+ <div
4
+ className={`pulse-dot ${connected ? "" : "pulse-dot--disconnected"}`}
5
+ title={connected ? "Connected" : "Disconnected"}
6
+ />
7
+ );
8
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ function format(date: string): string {
6
+ const ms = Date.now() - new Date(date).getTime();
7
+ if (ms < 0) return "just now";
8
+ const seconds = Math.floor(ms / 1000);
9
+ if (seconds < 5) return "just now";
10
+ if (seconds < 60) return `${seconds}s ago`;
11
+ const minutes = Math.floor(seconds / 60);
12
+ if (minutes < 60) return `${minutes}m ago`;
13
+ const hours = Math.floor(minutes / 60);
14
+ if (hours < 24) return `${hours}h ago`;
15
+ const days = Math.floor(hours / 24);
16
+ return `${days}d ago`;
17
+ }
18
+
19
+ export function RelativeTime({ date }: { date: string }) {
20
+ const [text, setText] = useState(() => format(date));
21
+
22
+ useEffect(() => {
23
+ const interval = setInterval(() => {
24
+ setText(format(date));
25
+ }, 5000);
26
+ return () => clearInterval(interval);
27
+ }, [date]);
28
+
29
+ return (
30
+ <span className="mono" title={new Date(date).toLocaleString()} style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>
31
+ {text}
32
+ </span>
33
+ );
34
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { StatusBadge } from "./status-badge";
5
+ import { RelativeTime } from "./relative-time";
6
+
7
+ interface Run {
8
+ id: string;
9
+ signalName: string;
10
+ status: string;
11
+ attempts: number;
12
+ maxAttempts: number;
13
+ createdAt: string;
14
+ startedAt?: string;
15
+ completedAt?: string;
16
+ error?: string;
17
+ }
18
+
19
+ function duration(startedAt?: string, completedAt?: string): string {
20
+ if (!startedAt) return "-";
21
+ const start = new Date(startedAt).getTime();
22
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
23
+ const ms = end - start;
24
+ if (ms < 1000) return `${ms}ms`;
25
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
26
+ return `${(ms / 60_000).toFixed(1)}m`;
27
+ }
28
+
29
+ function truncateError(error: string | undefined, maxLen: number): string {
30
+ if (!error) return "-";
31
+ if (error.length <= maxLen) return error;
32
+ return error.slice(0, maxLen) + "\u2026";
33
+ }
34
+
35
+ export function RunTable({ runs }: { runs: Run[] }) {
36
+ const router = useRouter();
37
+
38
+ if (runs.length === 0) {
39
+ return (
40
+ <div className="empty-state">
41
+ <p className="empty-state-text">No runs recorded.</p>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <table className="station-table">
48
+ <thead>
49
+ <tr>
50
+ <th>Status</th>
51
+ <th>Signal</th>
52
+ <th>Run ID</th>
53
+ <th>Duration</th>
54
+ <th>Created</th>
55
+ <th>Error</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {runs.map((run, i) => (
60
+ <tr
61
+ key={run.id}
62
+ className="reveal-item clickable-row"
63
+ style={{ animationDelay: `${i * 40}ms` }}
64
+ onClick={() => router.push(`/runs/${run.id}`)}
65
+ >
66
+ <td>
67
+ <StatusBadge status={run.status as "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped"} />
68
+ </td>
69
+ <td className="mono">{run.signalName}</td>
70
+ <td className="mono truncate" title={run.id}>
71
+ {run.id.slice(0, 8)}
72
+ </td>
73
+ <td className="mono">{duration(run.startedAt, run.completedAt)}</td>
74
+ <td>
75
+ <RelativeTime date={run.createdAt} />
76
+ </td>
77
+ <td
78
+ style={{
79
+ color: run.error ? "var(--rust)" : "var(--muted)",
80
+ fontFamily: "var(--font-mono)",
81
+ fontSize: "0.75rem",
82
+ maxWidth: "200px",
83
+ overflow: "hidden",
84
+ textOverflow: "ellipsis",
85
+ whiteSpace: "nowrap",
86
+ }}
87
+ title={run.error ?? undefined}
88
+ >
89
+ {truncateError(run.error, 60)}
90
+ </td>
91
+ </tr>
92
+ ))}
93
+ </tbody>
94
+ </table>
95
+ );
96
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ interface SchemaField {
6
+ type: string;
7
+ required: boolean;
8
+ properties?: Record<string, SchemaField>;
9
+ items?: SchemaField;
10
+ values?: string[];
11
+ }
12
+
13
+ interface SchemaFormProps {
14
+ schema: SchemaField | null;
15
+ value: string;
16
+ onChange: (v: string) => void;
17
+ }
18
+
19
+ function generateTemplate(field: SchemaField): unknown {
20
+ switch (field.type) {
21
+ case "string":
22
+ return "";
23
+ case "number":
24
+ case "integer":
25
+ return 0;
26
+ case "boolean":
27
+ return false;
28
+ case "array":
29
+ return [];
30
+ case "enum":
31
+ if (field.values && field.values.length > 0) {
32
+ return field.values[0];
33
+ }
34
+ return "";
35
+ case "object": {
36
+ if (!field.properties) return {};
37
+ const obj: Record<string, unknown> = {};
38
+ for (const [key, childField] of Object.entries(field.properties)) {
39
+ obj[key] = generateTemplate(childField);
40
+ }
41
+ return obj;
42
+ }
43
+ default:
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function SchemaReference({ schema }: { schema: SchemaField }) {
49
+ if (schema.type !== "object" || !schema.properties) return null;
50
+
51
+ const entries = Object.entries(schema.properties);
52
+ if (entries.length === 0) return null;
53
+
54
+ return (
55
+ <div className="schema-ref">
56
+ <div className="schema-ref-title">Expected Input</div>
57
+ {entries.map(([name, field]) => (
58
+ <div key={name} className="schema-field">
59
+ <span className="schema-field-name">{name}</span>
60
+ <span
61
+ className={`schema-field-type${!field.required ? " schema-field-type--optional" : ""}`}
62
+ >
63
+ {field.type}
64
+ </span>
65
+ </div>
66
+ ))}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export function SchemaForm({ schema, value, onChange }: SchemaFormProps) {
72
+ const [parseError, setParseError] = useState<string | null>(null);
73
+ const [initialized, setInitialized] = useState(false);
74
+
75
+ useEffect(() => {
76
+ if (initialized) return;
77
+ if (schema && value === "{}") {
78
+ const template = generateTemplate(schema);
79
+ const templateStr = JSON.stringify(template, null, 2);
80
+ if (templateStr !== "{}") {
81
+ onChange(templateStr);
82
+ }
83
+ }
84
+ setInitialized(true);
85
+ }, [schema, value, onChange, initialized]);
86
+
87
+ function handleChange(newValue: string) {
88
+ onChange(newValue);
89
+ if (newValue.trim() === "") {
90
+ setParseError(null);
91
+ return;
92
+ }
93
+ try {
94
+ JSON.parse(newValue);
95
+ setParseError(null);
96
+ } catch (err: unknown) {
97
+ if (err instanceof SyntaxError) {
98
+ setParseError(err.message);
99
+ } else {
100
+ setParseError("Invalid JSON");
101
+ }
102
+ }
103
+ }
104
+
105
+ return (
106
+ <div>
107
+ {schema && schema.type === "object" && schema.properties && (
108
+ <SchemaReference schema={schema} />
109
+ )}
110
+ <textarea
111
+ className="input-textarea"
112
+ value={value}
113
+ onChange={(e) => handleChange(e.target.value)}
114
+ rows={6}
115
+ spellCheck={false}
116
+ placeholder="{}"
117
+ />
118
+ {parseError && <div className="json-parse-error">{parseError}</div>}
119
+ </div>
120
+ );
121
+ }