ai.ts236 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { env } from '../env.js';
5import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
6import { projectRateLimit } from '../middleware/rate-limit.js';
7import { explainCode, EXPLAIN_SYSTEM_PROMPT } from '../services/ai-explain.js';
8import { FUNCTION_SYSTEM_PROMPT, generateFunction } from '../services/ai-function-gen.js';
9import {
10 AiNotConfiguredError,
11 generateSchema,
12 SCHEMA_SYSTEM_PROMPT,
13} from '../services/ai-schema-gen.js';
14import { streamAiResponse } from '../services/ai-stream.js';
15import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
16
17export const aiRouter = new Hono<AppEnv>();
18
19const generateSchemaSchema = z.object({
20 prompt: z.string().min(1).max(4000),
21});
22
23const generateFunctionSchema = z.object({
24 prompt: z.string().min(1).max(4000),
25 // Optional — when the dashboard knows the project's current schema it
26 // passes it through so the model can reference real table/column
27 // names. Capped at 16 KB at the API layer; the service truncates to
28 // 8 KB before forwarding to Ollama (the service cap is the binding
29 // one; the wire cap is a defense-in-depth backstop).
30 schemaContext: z.string().max(16_384).optional(),
31});
32
33const explainCodeSchema = z.object({
34 // The code to explain. 8 KB matches the schema-context cap so we
35 // don't blow the model's context with a single long file.
36 code: z.string().min(1).max(8_192),
37 // Optional perspective shaper — "I'm new to briven", "I migrated from
38 // prisma", "explain like a senior engineer". Empty is fine.
39 perspective: z.string().max(512).optional(),
40});
41
42aiRouter.use('/v1/projects/:id/ai/*', projectRateLimit('mutate'));
43aiRouter.use('/v1/projects/:id/ai/*', requireProjectAuth());
44aiRouter.use('/v1/projects/:id/ai/*', requireProjectRole('developer'));
45
46/**
47 * Phase 3 AI schema generator. Forwards a NL prompt to the configured
48 * Ollama instance and returns the draft schema TS as a string the
49 * dashboard can paste into the editor. Returns 503 not_configured
50 * when BRIVEN_OLLAMA_URL is unset so the operator knows to wire
51 * Ollama before the feature works.
52 */
53aiRouter.post('/v1/projects/:id/ai/generate-schema', async (c) => {
54 const body = await c.req.json().catch(() => null);
55 const parsed = generateSchemaSchema.safeParse(body);
56 if (!parsed.success) {
57 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
58 }
59 try {
60 const result = await generateSchema({ prompt: parsed.data.prompt });
61 return c.json({
62 schema: result.schema,
63 model: result.model,
64 elapsedMs: result.elapsedMs,
65 });
66 } catch (err) {
67 if (err instanceof AiNotConfiguredError) {
68 return c.json(
69 {
70 code: 'not_configured',
71 message: 'AI features are disabled on this deployment (BRIVEN_OLLAMA_URL unset)',
72 },
73 503,
74 );
75 }
76 throw err;
77 }
78});
79
80/**
81 * Phase 3 AI function generator. Pairs with the schema generator above —
82 * given a natural-language description and optionally the project's
83 * current schema, returns a single TypeScript file the user can drop
84 * into briven/functions/<name>.ts. Same not_configured semantics.
85 */
86aiRouter.post('/v1/projects/:id/ai/generate-function', async (c) => {
87 const body = await c.req.json().catch(() => null);
88 const parsed = generateFunctionSchema.safeParse(body);
89 if (!parsed.success) {
90 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
91 }
92 try {
93 const result = await generateFunction({
94 prompt: parsed.data.prompt,
95 schemaContext: parsed.data.schemaContext,
96 });
97 return c.json({
98 function: result.function,
99 model: result.model,
100 elapsedMs: result.elapsedMs,
101 });
102 } catch (err) {
103 if (err instanceof AiNotConfiguredError) {
104 return c.json(
105 {
106 code: 'not_configured',
107 message: 'AI features are disabled on this deployment (BRIVEN_OLLAMA_URL unset)',
108 },
109 503,
110 );
111 }
112 throw err;
113 }
114});
115
116/**
117 * Phase 3 AI explain code. Third member of the AI trifecta — same
118 * Ollama-backed pattern as schema + function gen. Returns 503 when
119 * BRIVEN_OLLAMA_URL is unset so the dashboard can render an "AI
120 * offline" state without throwing.
121 */
122aiRouter.post('/v1/projects/:id/ai/explain-code', async (c) => {
123 const body = await c.req.json().catch(() => null);
124 const parsed = explainCodeSchema.safeParse(body);
125 if (!parsed.success) {
126 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
127 }
128 try {
129 const result = await explainCode({
130 code: parsed.data.code,
131 perspective: parsed.data.perspective,
132 });
133 return c.json({
134 explanation: result.explanation,
135 model: result.model,
136 elapsedMs: result.elapsedMs,
137 });
138 } catch (err) {
139 if (err instanceof AiNotConfiguredError) {
140 return c.json(
141 {
142 code: 'not_configured',
143 message: 'AI features are disabled on this deployment (BRIVEN_OLLAMA_URL unset)',
144 },
145 503,
146 );
147 }
148 throw err;
149 }
150});
151
152/**
153 * SSE streaming variants of the three AI features. Same input shape +
154 * same auth + same not_configured semantics as the non-streaming
155 * endpoints — just emit `event: token` / `event: done` over text/event-
156 * stream instead of waiting for the full response. Dashboard consumers
157 * use EventSource; the CLI uses --stream.
158 */
159aiRouter.post('/v1/projects/:id/ai/generate-schema/stream', async (c) => {
160 const body = await c.req.json().catch(() => null);
161 const parsed = generateSchemaSchema.safeParse(body);
162 if (!parsed.success) {
163 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
164 }
165 try {
166 const res = await streamAiResponse({
167 system: SCHEMA_SYSTEM_PROMPT,
168 prompt: parsed.data.prompt,
169 model: env.BRIVEN_OLLAMA_MODEL_SCHEMA ?? env.BRIVEN_OLLAMA_MODEL,
170 temperature: 0.2,
171 });
172 return res;
173 } catch (err) {
174 if (err instanceof AiNotConfiguredError) {
175 return c.json({ code: 'not_configured', message: 'BRIVEN_OLLAMA_URL unset' }, 503);
176 }
177 throw err;
178 }
179});
180
181aiRouter.post('/v1/projects/:id/ai/generate-function/stream', async (c) => {
182 const body = await c.req.json().catch(() => null);
183 const parsed = generateFunctionSchema.safeParse(body);
184 if (!parsed.success) {
185 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
186 }
187 const userMessage = parsed.data.schemaContext
188 ? `Current schema:\n\`\`\`ts\n${parsed.data.schemaContext.slice(0, 8192)}\n\`\`\`\n\nWrite a function that: ${parsed.data.prompt}`
189 : parsed.data.prompt;
190 try {
191 const res = await streamAiResponse({
192 system: FUNCTION_SYSTEM_PROMPT,
193 prompt: userMessage,
194 model: env.BRIVEN_OLLAMA_MODEL_FUNCTION ?? env.BRIVEN_OLLAMA_MODEL,
195 temperature: 0.3,
196 });
197 return res;
198 } catch (err) {
199 if (err instanceof AiNotConfiguredError) {
200 return c.json({ code: 'not_configured', message: 'BRIVEN_OLLAMA_URL unset' }, 503);
201 }
202 throw err;
203 }
204});
205
206aiRouter.post('/v1/projects/:id/ai/explain-code/stream', async (c) => {
207 const body = await c.req.json().catch(() => null);
208 const parsed = explainCodeSchema.safeParse(body);
209 if (!parsed.success) {
210 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
211 }
212 const userMessage =
213 parsed.data.perspective && parsed.data.perspective.trim().length > 0
214 ? `Perspective: ${parsed.data.perspective.trim()}\n\nExplain this code:\n\`\`\`ts\n${parsed.data.code}\n\`\`\``
215 : `Explain this code:\n\`\`\`ts\n${parsed.data.code}\n\`\`\``;
216 try {
217 const res = await streamAiResponse({
218 system: EXPLAIN_SYSTEM_PROMPT,
219 prompt: userMessage,
220 model: env.BRIVEN_OLLAMA_MODEL_EXPLAIN ?? env.BRIVEN_OLLAMA_MODEL,
221 temperature: 0.4,
222 });
223 return res;
224 } catch (err) {
225 if (err instanceof AiNotConfiguredError) {
226 return c.json({ code: 'not_configured', message: 'BRIVEN_OLLAMA_URL unset' }, 503);
227 }
228 throw err;
229 }
230});
231
232// Suppress no-unused-vars for the imports above — they're used by the
233// three stream routes appended at the end of this file.
234void explainCode;
235void generateFunction;
236void generateSchema;