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

ask.tsx

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

ask.tsxBlame425 lines · 1 contributor
3ef4c9dClaude1/**
2 * AI chat assistant — global + per-repo.
3 *
4 * GET /ask — global assistant (platform Q&A)
5 * GET /ask/:chatId — resume a saved chat
6 * POST /ask — send a message (global)
7 * GET /:owner/:repo/ask — repo-grounded chat
8 * POST /:owner/:repo/ask — send a repo-grounded message
9 * POST /:owner/:repo/explain — "Explain this file" helper
10 *
11 * Chats are persisted to `ai_chats` so users can return to them.
12 * Form-based — works even with JS disabled.
13 */
14
15import { Hono } from "hono";
16import { eq, and, desc } from "drizzle-orm";
17import { db } from "../db";
18import { aiChats, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { requireAuth, softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { chat, explainFile } from "../lib/ai-chat";
23import type { ChatMessage } from "../lib/ai-chat";
24import { getUnreadCount } from "../lib/unread";
25import { isAiAvailable } from "../lib/ai-client";
26
27const ask = new Hono<AuthEnv>();
28ask.use("*", softAuth);
29
30function loadMessages(raw: string | null | undefined): ChatMessage[] {
31 if (!raw) return [];
32 try {
33 const parsed = JSON.parse(raw);
34 if (Array.isArray(parsed)) {
35 return parsed.filter(
36 (m): m is ChatMessage =>
37 m && typeof m === "object" && typeof m.content === "string" &&
38 (m.role === "user" || m.role === "assistant")
39 );
40 }
41 } catch {
42 /* ignore */
43 }
44 return [];
45}
46
47function renderChatView(
48 c: any,
49 {
50 messages,
51 postUrl,
52 title,
53 subtitle,
54 placeholder,
55 recentChats,
56 user,
57 unreadCount,
58 }: {
59 messages: ChatMessage[];
60 postUrl: string;
61 title: string;
62 subtitle?: string;
63 placeholder: string;
64 recentChats?: Array<{ id: string; title: string | null; updatedAt: Date }>;
65 user: any;
66 unreadCount: number;
67 }
68) {
69 return c.html(
70 <Layout title={title} user={user} notificationCount={unreadCount}>
71 <div class="ask-container">
72 <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px">
73 <h2>{title}</h2>
74 {!isAiAvailable() && (
75 <span class="badge" style="color: var(--yellow); border-color: var(--yellow)">
76 AI unavailable — set ANTHROPIC_API_KEY
77 </span>
78 )}
79 </div>
80 {subtitle && (
81 <p style="color: var(--text-muted); margin-bottom: 16px">{subtitle}</p>
82 )}
83
84 <div class="chat-log">
85 {messages.length === 0 ? (
86 <div class="panel-empty">
87 Ask anything. Reference files with @path/to/file.ext.
88 </div>
89 ) : (
90 messages.map((m) => (
91 <div class={`chat-message ${m.role}`}>
92 <div class="role">{m.role === "user" ? "You" : "GlueCron AI"}</div>
93 {m.content}
94 </div>
95 ))
96 )}
97 </div>
98
b9968e3Claude99 <form method="post" action={postUrl} class="chat-form">
3ef4c9dClaude100 <textarea
101 name="message"
102 placeholder={placeholder}
103 required
104 autofocus
105 ></textarea>
106 <div
107 style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px"
108 >
109 <div class="chat-hint">
110 {"\u21B5"} Enter + Ctrl/Cmd to send. Mention files with
111 <span class="chat-cited" style="margin-left: 4px">@src/file.ts</span>
112 </div>
113 <button type="submit" class="btn btn-primary">
114 Send
115 </button>
116 </div>
117 </form>
118
119 {recentChats && recentChats.length > 0 && (
120 <div style="margin-top: 32px">
121 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px">
122 Recent chats
123 </h3>
124 <div class="panel">
125 {recentChats.map((ch) => (
126 <div class="panel-item">
127 <div class="dot blue"></div>
128 <div style="flex: 1">
129 <a href={`/ask/${ch.id}`}>
130 {ch.title || "(untitled)"}
131 </a>
132 <div class="meta">
133 {new Date(ch.updatedAt).toLocaleString()}
134 </div>
135 </div>
136 </div>
137 ))}
138 </div>
139 </div>
140 )}
141 </div>
142 </Layout>
143 );
144}
145
146// Backwards-compat alias retained so external imports keep working.
147const ChatView = renderChatView;
148
149async function resumeChat(
150 userId: string,
151 chatId: string
152): Promise<{
153 messages: ChatMessage[];
154 repoOwner: string | null;
155 repoName: string | null;
156} | null> {
157 try {
158 const [row] = await db
159 .select({
160 messages: aiChats.messages,
161 userId: aiChats.userId,
162 repositoryId: aiChats.repositoryId,
163 })
164 .from(aiChats)
165 .where(eq(aiChats.id, chatId))
166 .limit(1);
167 if (!row || row.userId !== userId) return null;
168
169 let repoOwner: string | null = null;
170 let repoName: string | null = null;
171 if (row.repositoryId) {
172 const [repo] = await db
173 .select({ name: repositories.name, username: users.username })
174 .from(repositories)
175 .innerJoin(users, eq(repositories.ownerId, users.id))
176 .where(eq(repositories.id, row.repositoryId))
177 .limit(1);
178 if (repo) {
179 repoOwner = repo.username;
180 repoName = repo.name;
181 }
182 }
183
184 return {
185 messages: loadMessages(row.messages),
186 repoOwner,
187 repoName,
188 };
189 } catch {
190 return null;
191 }
192}
193
194async function appendMessage(opts: {
195 userId: string;
196 chatId: string | null;
197 repositoryId: string | null;
198 userMessage: string;
199 aiReply: string;
200 history: ChatMessage[];
201 title: string;
202}): Promise<string> {
203 const newHistory: ChatMessage[] = [
204 ...opts.history,
205 { role: "user", content: opts.userMessage },
206 { role: "assistant", content: opts.aiReply },
207 ];
208 try {
209 if (opts.chatId) {
210 await db
211 .update(aiChats)
212 .set({ messages: JSON.stringify(newHistory), updatedAt: new Date() })
213 .where(eq(aiChats.id, opts.chatId));
214 return opts.chatId;
215 }
216 const [row] = await db
217 .insert(aiChats)
218 .values({
219 userId: opts.userId,
220 repositoryId: opts.repositoryId ?? undefined,
221 title: opts.title.slice(0, 80),
222 messages: JSON.stringify(newHistory),
223 })
224 .returning();
225 return row?.id || "";
226 } catch (err) {
227 console.error("[ask] persist failed:", err);
228 return opts.chatId || "";
229 }
230}
231
232// ---------- Global assistant ----------
233
234ask.get("/ask", requireAuth, async (c) => {
235 const user = c.get("user")!;
236 const unread = await getUnreadCount(user.id);
237 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
238 try {
239 recent = await db
240 .select({
241 id: aiChats.id,
242 title: aiChats.title,
243 updatedAt: aiChats.updatedAt,
244 })
245 .from(aiChats)
246 .where(eq(aiChats.userId, user.id))
247 .orderBy(desc(aiChats.updatedAt))
248 .limit(10);
249 } catch {
250 /* ignore */
251 }
252
253 return renderChatView(c, {
254 messages: [],
255 postUrl: "/ask",
256 title: "Ask AI",
257 subtitle:
258 "Ask about GlueCron, your repos, or paste a diff to review. Claude is grounded in your repo when visiting /:owner/:repo/ask.",
259 placeholder: "Ask anything...",
260 recentChats: recent,
261 user,
262 unreadCount: unread,
263 });
264});
265
266ask.get("/ask/:chatId", requireAuth, async (c) => {
267 const user = c.get("user")!;
268 const chatId = c.req.param("chatId");
269 const resumed = await resumeChat(user.id, chatId);
270 if (!resumed) return c.redirect("/ask");
271 const unread = await getUnreadCount(user.id);
272 return renderChatView(c, {
273 messages: resumed.messages,
274 postUrl:
275 resumed.repoOwner && resumed.repoName
276 ? `/${resumed.repoOwner}/${resumed.repoName}/ask?chatId=${chatId}`
277 : `/ask?chatId=${chatId}`,
278 title:
279 resumed.repoOwner && resumed.repoName
280 ? `${resumed.repoOwner}/${resumed.repoName} — AI chat`
281 : "Ask AI",
282 placeholder: "Continue the conversation...",
283 user,
284 unreadCount: unread,
285 });
286});
287
288ask.post("/ask", requireAuth, async (c) => {
289 const user = c.get("user")!;
290 const body = await c.req.parseBody();
291 const userMessage = String(body.message || "").trim();
292 const chatId = (c.req.query("chatId") || "").trim();
293 if (!userMessage) return c.redirect("/ask");
294
295 let history: ChatMessage[] = [];
296 if (chatId) {
297 const existing = await resumeChat(user.id, chatId);
298 if (existing) history = existing.messages;
299 }
300
301 const response = await chat(user.username, null, history, userMessage);
302 const nextId = await appendMessage({
303 userId: user.id,
304 chatId: chatId || null,
305 repositoryId: null,
306 userMessage,
307 aiReply: response.reply,
308 history,
309 title: userMessage,
310 });
311
312 return c.redirect(nextId ? `/ask/${nextId}` : "/ask");
313});
314
315// ---------- Repo-grounded assistant ----------
316
317ask.get("/:owner/:repo/ask", requireAuth, async (c) => {
318 const user = c.get("user")!;
319 const { owner, repo } = c.req.param();
320
321 // Verify repo exists
322 const [repoRow] = await db
323 .select({ id: repositories.id })
324 .from(repositories)
325 .innerJoin(users, eq(repositories.ownerId, users.id))
326 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
327 .limit(1);
328 if (!repoRow) return c.notFound();
329
330 const unread = await getUnreadCount(user.id);
331
332 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
333 try {
334 recent = await db
335 .select({
336 id: aiChats.id,
337 title: aiChats.title,
338 updatedAt: aiChats.updatedAt,
339 })
340 .from(aiChats)
341 .where(
342 and(
343 eq(aiChats.userId, user.id),
344 eq(aiChats.repositoryId, repoRow.id)
345 )
346 )
347 .orderBy(desc(aiChats.updatedAt))
348 .limit(10);
349 } catch {
350 /* ignore */
351 }
352
353 return renderChatView(c, {
354 messages: [],
355 postUrl: `/${owner}/${repo}/ask`,
356 title: `Ask about ${owner}/${repo}`,
357 subtitle:
358 "Claude has access to this repository's README, tree, and recent commits. Reference files with @path/to/file.",
359 placeholder: `Ask about ${repo}...`,
360 recentChats: recent,
361 user,
362 unreadCount: unread,
363 });
364});
365
366ask.post("/:owner/:repo/ask", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo } = c.req.param();
369 const body = await c.req.parseBody();
370 const userMessage = String(body.message || "").trim();
371 const chatId = (c.req.query("chatId") || "").trim();
372 if (!userMessage) return c.redirect(`/${owner}/${repo}/ask`);
373
374 const [repoRow] = await db
375 .select({ id: repositories.id })
376 .from(repositories)
377 .innerJoin(users, eq(repositories.ownerId, users.id))
378 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
379 .limit(1);
380 if (!repoRow) return c.notFound();
381
382 let history: ChatMessage[] = [];
383 if (chatId) {
384 const existing = await resumeChat(user.id, chatId);
385 if (existing) history = existing.messages;
386 }
387
388 const response = await chat(owner, repo, history, userMessage);
389 const nextId = await appendMessage({
390 userId: user.id,
391 chatId: chatId || null,
392 repositoryId: repoRow.id,
393 userMessage,
394 aiReply: response.reply,
395 history,
396 title: userMessage,
397 });
398
399 return c.redirect(
400 nextId ? `/ask/${nextId}` : `/${owner}/${repo}/ask`
401 );
402});
403
404// ---------- Explain-this-file helper ----------
405
406ask.post("/:owner/:repo/explain", requireAuth, async (c) => {
407 const { owner, repo } = c.req.param();
408 const body = await c.req.parseBody().catch(() => ({}));
409 const filePath = String((body as any).file || c.req.query("file") || "");
410 const ref = String((body as any).ref || c.req.query("ref") || "");
411 if (!filePath || !ref) {
412 return c.json({ error: "file and ref required" }, 400);
413 }
414
415 const { getBlob } = await import("../git/repository");
416 const blob = await getBlob(owner, repo, ref, filePath);
417 if (!blob || blob.isBinary) {
418 return c.json({ error: "file not found or binary" }, 404);
419 }
420
421 const explanation = await explainFile(owner, repo, filePath, blob.content);
422 return c.json({ explanation });
423});
424
425export default ask;