Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-editor.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-editor.tsBlame230 lines · 1 contributor
6f2809fClaude1/**
2 * AI editor API routes — inline suggestions, code explanation, and fix generation.
3 *
4 * All endpoints require an authenticated session and `ANTHROPIC_API_KEY`.
5 * Rate-limited to 30 suggestion requests per user per hour (in-memory).
6 *
7 * Routes:
8 * POST /api/ai/suggest — code completion (ghost text)
9 * POST /api/ai/explain — explain selected code
10 * POST /api/ai/fix — fix code given a GateTest / error message
11 */
12
13import { Hono } from "hono";
14import { requireAuth } from "../middleware/auth";
15import { isAiAvailable, getAnthropic, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
16import type { AuthEnv } from "../middleware/auth";
17
18const aiEditor = new Hono<AuthEnv>();
19
20// ─── Rate-limit store ────────────────────────────────────────────────────────
21// Simple in-memory map: userId → array of request timestamps (ms).
22// Cleaned lazily to avoid unbounded growth on high-traffic servers.
23const QUOTA_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
24const QUOTA_MAX = 30;
25
26const suggestQuota = new Map<number, number[]>();
27
28function checkQuota(userId: number): { allowed: boolean; resetInMinutes: number } {
29 const now = Date.now();
30 const cutoff = now - QUOTA_WINDOW_MS;
31 let timestamps = suggestQuota.get(userId) ?? [];
32 // Purge expired entries
33 timestamps = timestamps.filter((t) => t > cutoff);
34
35 if (timestamps.length >= QUOTA_MAX) {
36 const oldest = timestamps[0]!;
37 const resetInMs = oldest + QUOTA_WINDOW_MS - now;
38 const resetInMinutes = Math.ceil(resetInMs / 60_000);
39 suggestQuota.set(userId, timestamps);
40 return { allowed: false, resetInMinutes };
41 }
42
43 timestamps.push(now);
44 suggestQuota.set(userId, timestamps);
45 return { allowed: true, resetInMinutes: 0 };
46}
47
48// ─── POST /api/ai/suggest ────────────────────────────────────────────────────
49aiEditor.post("/api/ai/suggest", requireAuth, async (c) => {
50 if (!isAiAvailable()) {
51 return c.json(
52 { error: "AI suggestions unavailable — ANTHROPIC_API_KEY not configured." },
53 503
54 );
55 }
56
57 const user = c.get("user")!;
58 const quota = checkQuota(user.id);
59 if (!quota.allowed) {
60 return c.json(
61 {
62 error: `AI suggestion quota reached. Resets in ${quota.resetInMinutes} minute${quota.resetInMinutes === 1 ? "" : "s"}.`,
63 },
64 429
65 );
66 }
67
68 let body: { code?: string; language?: string; cursor?: number };
69 try {
70 body = await c.req.json();
71 } catch {
72 return c.json({ error: "Invalid JSON body." }, 400);
73 }
74
75 const code = String(body.code ?? "").slice(0, 2000);
76 const language = String(body.language ?? "plaintext");
77 const cursor = Number(body.cursor ?? code.length);
78
79 const beforeCursor = code.slice(0, cursor);
80 const afterCursor = code.slice(cursor);
81
82 const prompt =
83 `Language: ${language}\n\n` +
84 `Code before cursor:\n${beforeCursor}\n\n` +
85 (afterCursor.trim()
86 ? `Code after cursor (context only — do NOT repeat it):\n${afterCursor}\n\n`
87 : "") +
88 "Complete the code at the cursor position. Return ONLY the completion text to insert, no explanation, no markdown, no code fences.";
89
90 try {
91 const anthropic = getAnthropic();
92 const message = await anthropic.messages.create({
a5705cfClaude93 model: MODEL_SONNET,
6f2809fClaude94 max_tokens: 256,
95 system:
96 "You are a code completion AI. Complete the code snippet at the cursor. " +
97 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
98 "Keep completions concise (prefer single-line or a few lines). " +
99 "If you have nothing useful to add, return an empty string.",
100 messages: [{ role: "user", content: prompt }],
101 });
102
103 const suggestion = extractText(message).trimEnd();
104 return c.json({ suggestion });
105 } catch (err) {
106 const msg = err instanceof Error ? err.message : "AI request failed.";
107 return c.json({ error: msg }, 500);
108 }
109});
110
111// ─── POST /api/ai/explain ────────────────────────────────────────────────────
112aiEditor.post("/api/ai/explain", requireAuth, async (c) => {
113 if (!isAiAvailable()) {
114 return c.json(
115 { error: "AI explanations unavailable — ANTHROPIC_API_KEY not configured." },
116 503
117 );
118 }
119
120 let body: { code?: string; language?: string };
121 try {
122 body = await c.req.json();
123 } catch {
124 return c.json({ error: "Invalid JSON body." }, 400);
125 }
126
127 const code = String(body.code ?? "").slice(0, 4000);
128 const language = String(body.language ?? "plaintext");
129
130 if (!code.trim()) {
131 return c.json({ error: "No code provided." }, 400);
132 }
133
134 try {
135 const anthropic = getAnthropic();
136 const message = await anthropic.messages.create({
137 model: MODEL_SONNET,
138 max_tokens: 512,
139 system:
140 "You are a senior engineer explaining code to a fellow developer. " +
141 "Be concise and precise. Use plain text (no markdown headers). " +
142 "One or two short paragraphs maximum.",
143 messages: [
144 {
145 role: "user",
146 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
147 },
148 ],
149 });
150
151 const explanation = extractText(message).trim();
152 return c.json({ explanation });
153 } catch (err) {
154 const msg = err instanceof Error ? err.message : "AI request failed.";
155 return c.json({ error: msg }, 500);
156 }
157});
158
159// ─── POST /api/ai/fix ────────────────────────────────────────────────────────
160aiEditor.post("/api/ai/fix", requireAuth, async (c) => {
161 if (!isAiAvailable()) {
162 return c.json(
163 { error: "AI fix unavailable — ANTHROPIC_API_KEY not configured." },
164 503
165 );
166 }
167
168 let body: { code?: string; error?: string; language?: string };
169 try {
170 body = await c.req.json();
171 } catch {
172 return c.json({ error: "Invalid JSON body." }, 400);
173 }
174
175 const code = String(body.code ?? "").slice(0, 4000);
176 const errorMsg = String(body.error ?? "").slice(0, 1000);
177 const language = String(body.language ?? "plaintext");
178
179 if (!code.trim()) {
180 return c.json({ error: "No code provided." }, 400);
181 }
182
183 const prompt =
184 `Language: ${language}\n\n` +
185 `Error message:\n${errorMsg}\n\n` +
186 `Code with the problem:\n\`\`\`\n${code}\n\`\`\`\n\n` +
187 "Return a JSON object with two fields: " +
188 '"fix" (the corrected code, complete file content) and ' +
189 '"explanation" (one short sentence describing what was wrong).';
190
191 try {
192 const anthropic = getAnthropic();
193 const message = await anthropic.messages.create({
194 model: MODEL_SONNET,
195 max_tokens: 1024,
196 system:
197 "You are an expert code fixer. Given an error and code, return valid JSON with " +
198 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
199 "Return ONLY the JSON object, nothing else.",
200 messages: [{ role: "user", content: prompt }],
201 });
202
203 const raw = extractText(message).trim();
204
205 // Try to parse the JSON response
206 let fix = "";
207 let explanation = "";
208 try {
209 // Strip possible ```json fences
210 const cleaned = raw
211 .replace(/^```(?:json)?\s*/i, "")
212 .replace(/\s*```$/i, "")
213 .trim();
214 const parsed = JSON.parse(cleaned);
215 fix = String(parsed.fix ?? "");
216 explanation = String(parsed.explanation ?? "");
217 } catch {
218 // Fallback: return raw as fix text
219 fix = raw;
220 explanation = "AI returned a fix but could not parse structured response.";
221 }
222
223 return c.json({ fix, explanation });
224 } catch (err) {
225 const msg = err instanceof Error ? err.message : "AI request failed.";
226 return c.json({ error: msg }, 500);
227 }
228});
229
230export default aiEditor;