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