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.tsxBlame882 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.
6c0a85fClaude13 *
14 * Visual recipe (2026 polish — mirrors admin-integrations / error-page):
15 * - Gradient hairline strip across the top of the hero (purple→cyan)
16 * - Soft radial orb in the corner
17 * - Display headline with gradient-text on the title
18 * - Chat-style bubbles: user right-aligned with subtle background,
19 * AI left-aligned with gradient avatar
20 * - Composer with gradient submit button + focus ring
21 * - Scoped CSS — every class prefixed `.ask-*`
3ef4c9dClaude22 */
23
24import { Hono } from "hono";
25import { eq, and, desc } from "drizzle-orm";
26import { db } from "../db";
27import { aiChats, repositories, users } from "../db/schema";
28import { Layout } from "../views/layout";
29import { requireAuth, softAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import { chat, explainFile } from "../lib/ai-chat";
32import type { ChatMessage } from "../lib/ai-chat";
33import { getUnreadCount } from "../lib/unread";
34import { isAiAvailable } from "../lib/ai-client";
35
36const ask = new Hono<AuthEnv>();
37ask.use("*", softAuth);
38
39function loadMessages(raw: string | null | undefined): ChatMessage[] {
40 if (!raw) return [];
41 try {
42 const parsed = JSON.parse(raw);
43 if (Array.isArray(parsed)) {
44 return parsed.filter(
45 (m): m is ChatMessage =>
46 m && typeof m === "object" && typeof m.content === "string" &&
47 (m.role === "user" || m.role === "assistant")
48 );
49 }
50 } catch {
51 /* ignore */
52 }
53 return [];
54}
55
56function renderChatView(
57 c: any,
58 {
59 messages,
60 postUrl,
61 title,
62 subtitle,
63 placeholder,
64 recentChats,
65 user,
66 unreadCount,
67 }: {
68 messages: ChatMessage[];
69 postUrl: string;
70 title: string;
71 subtitle?: string;
72 placeholder: string;
73 recentChats?: Array<{ id: string; title: string | null; updatedAt: Date }>;
74 user: any;
75 unreadCount: number;
76 }
77) {
78 return c.html(
79 <Layout title={title} user={user} notificationCount={unreadCount}>
6c0a85fClaude80 <style dangerouslySetInnerHTML={{ __html: askCss }} />
81 <div class="ask-page">
82 {/* Hero (2026 polish: hairline + orb + grad headline) */}
83 <header class="ask-hero">
84 <div class="ask-hero-orb" aria-hidden="true" />
85 <div class="ask-hero-inner">
86 <div class="ask-eyebrow">
87 <span class="ask-eyebrow-pill" aria-hidden="true">
88 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
89 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
90 </svg>
91 </span>
92 AI chat {"·"} Claude Sonnet 4 {"·"} <span class="ask-eyebrow-who">{user.username}</span>
93 {!isAiAvailable() && (
94 <span class="ask-pill-warn">AI unavailable {"—"} set ANTHROPIC_API_KEY</span>
95 )}
96 </div>
97 <h1 class="ask-title">
98 <span class="ask-title-grad">{title}</span>
99 </h1>
100 {subtitle && <p class="ask-sub">{subtitle}</p>}
101 </div>
102 </header>
3ef4c9dClaude103
6c0a85fClaude104 {/* Chat log: user right-aligned, AI left-aligned with gradient avatar */}
105 <div class="ask-log" aria-live="polite">
3ef4c9dClaude106 {messages.length === 0 ? (
6c0a85fClaude107 <div class="ask-empty">
108 <div class="ask-empty-avatar" aria-hidden="true">
109 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
110 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
111 </svg>
112 </div>
113 <div>
114 <h2 class="ask-empty-title">Ask anything.</h2>
115 <p class="ask-empty-sub">
116 Reference files with{" "}
117 <code class="ask-cited">@path/to/file.ext</code>. Drop a
118 diff to review. Ask about GlueCron, your repos, anything.
119 </p>
120 </div>
3ef4c9dClaude121 </div>
122 ) : (
123 messages.map((m) => (
6c0a85fClaude124 <div class={`ask-msg ask-msg-${m.role}`}>
125 {m.role === "assistant" && (
126 <div class="ask-msg-avatar ask-msg-avatar-ai" aria-hidden="true">
127 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
128 <path d="M12 2l1.8 5.5L19 9l-4.5 3.5L16 18l-4-3-4 3 1.5-5.5L5 9l5.2-1.5z" />
129 </svg>
130 </div>
131 )}
132 <div class="ask-msg-bubble-wrap">
133 <div class="ask-msg-role">
134 {m.role === "user" ? "You" : "GlueCron AI"}
135 </div>
136 <div class="ask-msg-bubble">{m.content}</div>
137 </div>
138 {m.role === "user" && (
139 <div class="ask-msg-avatar ask-msg-avatar-user" aria-hidden="true">
140 {(user?.username || "?").slice(0, 1).toUpperCase()}
141 </div>
142 )}
3ef4c9dClaude143 </div>
144 ))
145 )}
146 </div>
147
6c0a85fClaude148 {/* Composer: elevated input with gradient submit button */}
149 <form method="post" action={postUrl} class="ask-composer">
150 <div class="ask-composer-shell">
151 <textarea
152 class="ask-composer-input"
153 name="message"
154 placeholder={placeholder}
155 required
156 autofocus
157 rows={3}
158 ></textarea>
159 <div class="ask-composer-foot">
160 <div class="ask-hint">
161 <span class="ask-kbd">{"↵"}</span>
162 Enter + Ctrl/Cmd to send {"·"} mention files with{" "}
163 <code class="ask-cited">@src/file.ts</code>
164 </div>
165 <button type="submit" class="ask-submit">
166 <span>Send</span>
167 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
168 <line x1="5" y1="12" x2="19" y2="12" />
169 <polyline points="12 5 19 12 12 19" />
170 </svg>
171 </button>
3ef4c9dClaude172 </div>
173 </div>
174 </form>
175
176 {recentChats && recentChats.length > 0 && (
6c0a85fClaude177 <section class="ask-recent">
178 <div class="ask-recent-head">
179 <span class="ask-recent-dot" aria-hidden="true" />
3ef4c9dClaude180 Recent chats
6c0a85fClaude181 </div>
182 <ul class="ask-recent-list">
3ef4c9dClaude183 {recentChats.map((ch) => (
6c0a85fClaude184 <li class="ask-recent-item">
185 <a class="ask-recent-link" href={`/ask/${ch.id}`}>
186 <span class="ask-recent-title">
3ef4c9dClaude187 {ch.title || "(untitled)"}
6c0a85fClaude188 </span>
189 <span class="ask-recent-when">
3ef4c9dClaude190 {new Date(ch.updatedAt).toLocaleString()}
6c0a85fClaude191 </span>
192 </a>
193 </li>
3ef4c9dClaude194 ))}
6c0a85fClaude195 </ul>
196 </section>
3ef4c9dClaude197 )}
198 </div>
199 </Layout>
200 );
201}
202
6c0a85fClaude203/* ─────────────────────────────────────────────────────────────────────────
204 * Scoped CSS — every class prefixed `.ask-*` so it can't bleed into other
205 * pages. Mirrors the gradient-hairline + orb hero pattern from
206 * admin-integrations / error-page / build-agent-spec.
207 * ───────────────────────────────────────────────────────────────────── */
208const askCss = `
209 .ask-page {
210 max-width: 880px;
211 margin: 0 auto;
212 padding: var(--space-6) var(--space-4) var(--space-12);
213 }
214
215 /* Hero */
216 .ask-hero {
217 position: relative;
218 margin-bottom: var(--space-5);
219 padding: clamp(24px, 3.5vw, 40px) clamp(24px, 4vw, 40px);
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 18px;
223 overflow: hidden;
224 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
225 }
226 .ask-hero::before {
227 content: '';
228 position: absolute;
229 top: 0; left: 0; right: 0;
230 height: 2px;
231 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
232 opacity: 0.78;
233 pointer-events: none;
234 z-index: 2;
235 }
236 .ask-hero-orb {
237 position: absolute;
238 inset: -30% -10% auto auto;
239 width: 460px; height: 460px;
240 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
241 filter: blur(80px);
242 opacity: 0.7;
243 pointer-events: none;
244 z-index: 0;
245 }
246 .ask-hero-inner { position: relative; z-index: 1; }
247
248 .ask-eyebrow {
249 display: inline-flex;
250 align-items: center;
251 gap: 8px;
252 font-family: var(--font-mono);
253 font-size: 11.5px;
254 text-transform: uppercase;
255 letter-spacing: 0.14em;
256 color: var(--text-muted);
257 font-weight: 600;
258 margin-bottom: 14px;
259 flex-wrap: wrap;
260 }
261 .ask-eyebrow-pill {
262 display: inline-flex;
263 align-items: center;
264 justify-content: center;
265 width: 18px; height: 18px;
266 border-radius: 6px;
267 background: rgba(140,109,255,0.14);
268 color: #b69dff;
269 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
270 }
271 .ask-eyebrow-who { color: var(--accent); font-weight: 700; text-transform: none; letter-spacing: 0; font-size: 12.5px; }
272 .ask-pill-warn {
273 display: inline-flex;
274 align-items: center;
275 gap: 6px;
276 padding: 3px 9px;
277 border-radius: 9999px;
278 background: rgba(251,191,36,0.10);
279 color: #fde68a;
280 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
281 font-size: 10.5px;
282 font-weight: 600;
283 text-transform: uppercase;
284 letter-spacing: 0.10em;
285 }
286
287 .ask-title {
288 font-family: var(--font-display);
289 font-size: clamp(26px, 4vw, 38px);
290 font-weight: 800;
291 letter-spacing: -0.028em;
292 line-height: 1.06;
293 margin: 0 0 10px;
294 color: var(--text-strong);
295 }
296 .ask-title-grad {
297 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
298 -webkit-background-clip: text;
299 background-clip: text;
300 -webkit-text-fill-color: transparent;
301 color: transparent;
302 }
303 .ask-sub {
304 font-size: 14.5px;
305 color: var(--text-muted);
306 margin: 0;
307 line-height: 1.55;
308 max-width: 620px;
309 }
310
311 /* Chat log */
312 .ask-log {
313 display: flex;
314 flex-direction: column;
315 gap: 14px;
316 margin-bottom: var(--space-5);
317 min-height: 120px;
318 }
319 .ask-empty {
320 display: flex;
321 gap: 14px;
322 padding: clamp(24px, 4vw, 36px);
323 background: var(--bg-elevated);
324 border: 1px dashed var(--border-strong, var(--border));
325 border-radius: 14px;
326 align-items: flex-start;
327 }
328 .ask-empty-avatar {
329 display: inline-flex;
330 align-items: center;
331 justify-content: center;
332 width: 36px; height: 36px;
333 border-radius: 10px;
334 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
335 color: #b69dff;
336 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
337 flex-shrink: 0;
338 }
339 .ask-empty-title {
340 font-family: var(--font-display);
341 font-size: 16px;
342 font-weight: 700;
343 color: var(--text-strong);
344 margin: 0 0 4px;
345 letter-spacing: -0.012em;
346 }
347 .ask-empty-sub {
348 margin: 0;
349 color: var(--text-muted);
350 font-size: 13.5px;
351 line-height: 1.55;
352 }
353
354 /* Message rows: AI on left, user on right */
355 .ask-msg {
356 display: flex;
357 gap: 10px;
358 align-items: flex-start;
359 max-width: 100%;
360 }
361 .ask-msg-assistant { justify-content: flex-start; }
362 .ask-msg-user { justify-content: flex-end; }
363 .ask-msg-avatar {
364 display: inline-flex;
365 align-items: center;
366 justify-content: center;
367 width: 32px; height: 32px;
368 border-radius: 9999px;
369 flex-shrink: 0;
370 font-family: var(--font-mono);
371 font-size: 12px;
372 font-weight: 700;
373 }
374 .ask-msg-avatar-ai {
375 background: linear-gradient(135deg, #8c6dff, #36c5d6);
376 color: #fff;
377 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 6px 18px -6px rgba(140,109,255,0.45);
378 }
379 .ask-msg-avatar-user {
380 background: rgba(255,255,255,0.05);
381 color: var(--text-strong);
382 box-shadow: inset 0 0 0 1px var(--border-strong, var(--border));
383 }
384 .ask-msg-bubble-wrap {
385 display: flex;
386 flex-direction: column;
387 gap: 4px;
388 max-width: min(640px, calc(100% - 48px));
389 min-width: 0;
390 }
391 .ask-msg-user .ask-msg-bubble-wrap { align-items: flex-end; }
392 .ask-msg-role {
393 font-family: var(--font-mono);
394 font-size: 10.5px;
395 text-transform: uppercase;
396 letter-spacing: 0.14em;
397 color: var(--text-muted);
398 font-weight: 700;
399 padding: 0 2px;
400 }
401 .ask-msg-bubble {
402 padding: 11px 14px;
403 border-radius: 14px;
404 font-size: 14px;
405 line-height: 1.55;
406 color: var(--text);
407 background: var(--bg-elevated);
408 border: 1px solid var(--border);
409 white-space: pre-wrap;
410 word-wrap: break-word;
411 overflow-wrap: anywhere;
412 }
413 .ask-msg-assistant .ask-msg-bubble {
414 border-top-left-radius: 4px;
415 background:
416 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
417 var(--bg-elevated);
418 border-color: rgba(140,109,255,0.22);
419 }
420 .ask-msg-user .ask-msg-bubble {
421 border-top-right-radius: 4px;
422 background: rgba(140,109,255,0.06);
423 border-color: rgba(140,109,255,0.20);
424 color: var(--text-strong);
425 }
426
427 /* Composer */
428 .ask-composer { margin-bottom: var(--space-5); }
429 .ask-composer-shell {
430 position: relative;
431 background: var(--bg-elevated);
432 border: 1px solid var(--border-strong, var(--border));
433 border-radius: 16px;
434 overflow: hidden;
435 transition: border-color 140ms ease, box-shadow 140ms ease;
436 }
437 .ask-composer-shell:focus-within {
438 border-color: rgba(140,109,255,0.55);
439 box-shadow: 0 0 0 4px rgba(140,109,255,0.16);
440 }
441 .ask-composer-shell::before {
442 content: '';
443 position: absolute;
444 top: 0; left: 0; right: 0;
445 height: 1px;
446 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
447 opacity: 0.6;
448 pointer-events: none;
449 }
450 .ask-composer-input {
451 display: block;
452 width: 100%;
453 box-sizing: border-box;
454 padding: 14px 16px 10px;
455 background: transparent;
456 border: 0;
457 outline: 0;
458 resize: vertical;
459 min-height: 84px;
460 color: var(--text);
461 font-family: inherit;
462 font-size: 14.5px;
463 line-height: 1.55;
464 }
465 .ask-composer-input::placeholder { color: var(--text-faint, var(--text-muted)); }
466 .ask-composer-foot {
467 display: flex;
468 align-items: center;
469 justify-content: space-between;
470 gap: 12px;
471 padding: 8px 12px 10px;
472 border-top: 1px solid var(--border);
473 background: rgba(255,255,255,0.012);
474 flex-wrap: wrap;
475 }
476 .ask-hint {
477 display: inline-flex;
478 align-items: center;
479 gap: 8px;
480 font-size: 12px;
481 color: var(--text-muted);
482 flex-wrap: wrap;
483 }
484 .ask-kbd {
485 display: inline-flex;
486 align-items: center;
487 justify-content: center;
488 min-width: 22px;
489 padding: 1px 6px;
490 font-family: var(--font-mono);
491 font-size: 11px;
492 color: var(--text);
493 background: rgba(255,255,255,0.04);
494 border: 1px solid var(--border);
495 border-radius: 4px;
496 }
497 .ask-cited {
498 font-family: var(--font-mono);
499 font-size: 12px;
500 color: var(--accent);
501 background: rgba(140,109,255,0.10);
502 border: 1px solid rgba(140,109,255,0.28);
503 padding: 1px 6px;
504 border-radius: 5px;
505 }
506 .ask-submit {
507 display: inline-flex;
508 align-items: center;
509 gap: 8px;
510 padding: 9px 16px;
511 font-size: 13.5px;
512 font-weight: 600;
513 color: #fff;
514 border: 1px solid transparent;
515 border-radius: 10px;
516 cursor: pointer;
517 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
518 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
519 transition: transform 120ms ease, box-shadow 120ms ease;
520 font-family: inherit;
521 line-height: 1;
522 }
523 .ask-submit:hover {
524 transform: translateY(-1px);
525 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
526 }
527 .ask-submit:active { transform: translateY(0); }
528
529 /* Recent chats */
530 .ask-recent {
531 margin-top: var(--space-6);
532 padding: var(--space-4) var(--space-5);
533 background: var(--bg-elevated);
534 border: 1px solid var(--border);
535 border-radius: 14px;
536 }
537 .ask-recent-head {
538 display: inline-flex;
539 align-items: center;
540 gap: 8px;
541 font-family: var(--font-mono);
542 font-size: 11px;
543 text-transform: uppercase;
544 letter-spacing: 0.14em;
545 color: var(--text-muted);
546 font-weight: 700;
547 margin-bottom: 12px;
548 }
549 .ask-recent-dot {
550 width: 7px; height: 7px;
551 border-radius: 9999px;
552 background: linear-gradient(135deg, #8c6dff, #36c5d6);
553 box-shadow: 0 0 0 3px rgba(140,109,255,0.16);
554 }
555 .ask-recent-list {
556 list-style: none;
557 padding: 0;
558 margin: 0;
559 display: flex;
560 flex-direction: column;
561 gap: 4px;
562 }
563 .ask-recent-link {
564 display: flex;
565 align-items: baseline;
566 gap: 10px;
567 padding: 9px 10px;
568 border-radius: 8px;
569 color: var(--text);
570 text-decoration: none;
571 transition: background 120ms ease, padding-left 120ms ease;
572 }
573 .ask-recent-link:hover {
574 background: rgba(140,109,255,0.06);
575 padding-left: 14px;
576 text-decoration: none;
577 }
578 .ask-recent-title {
579 flex: 1;
580 color: var(--text-strong);
581 font-size: 13.5px;
582 font-weight: 500;
583 overflow: hidden;
584 text-overflow: ellipsis;
585 white-space: nowrap;
586 min-width: 0;
587 }
588 .ask-recent-when {
589 font-family: var(--font-mono);
590 font-size: 11.5px;
591 color: var(--text-muted);
592 font-variant-numeric: tabular-nums;
593 flex-shrink: 0;
594 }
595
596 @media (max-width: 640px) {
597 .ask-msg-bubble-wrap { max-width: calc(100% - 44px); }
598 .ask-composer-foot { gap: 8px; }
599 .ask-submit { padding: 8px 14px; font-size: 13px; }
600 }
601`;
602
3ef4c9dClaude603// Backwards-compat alias retained so external imports keep working.
604const ChatView = renderChatView;
605
606async function resumeChat(
607 userId: string,
608 chatId: string
609): Promise<{
610 messages: ChatMessage[];
611 repoOwner: string | null;
612 repoName: string | null;
613} | null> {
614 try {
615 const [row] = await db
616 .select({
617 messages: aiChats.messages,
618 userId: aiChats.userId,
619 repositoryId: aiChats.repositoryId,
620 })
621 .from(aiChats)
622 .where(eq(aiChats.id, chatId))
623 .limit(1);
624 if (!row || row.userId !== userId) return null;
625
626 let repoOwner: string | null = null;
627 let repoName: string | null = null;
628 if (row.repositoryId) {
629 const [repo] = await db
630 .select({ name: repositories.name, username: users.username })
631 .from(repositories)
632 .innerJoin(users, eq(repositories.ownerId, users.id))
633 .where(eq(repositories.id, row.repositoryId))
634 .limit(1);
635 if (repo) {
636 repoOwner = repo.username;
637 repoName = repo.name;
638 }
639 }
640
641 return {
642 messages: loadMessages(row.messages),
643 repoOwner,
644 repoName,
645 };
646 } catch {
647 return null;
648 }
649}
650
651async function appendMessage(opts: {
652 userId: string;
653 chatId: string | null;
654 repositoryId: string | null;
655 userMessage: string;
656 aiReply: string;
657 history: ChatMessage[];
658 title: string;
659}): Promise<string> {
660 const newHistory: ChatMessage[] = [
661 ...opts.history,
662 { role: "user", content: opts.userMessage },
663 { role: "assistant", content: opts.aiReply },
664 ];
665 try {
666 if (opts.chatId) {
667 await db
668 .update(aiChats)
669 .set({ messages: JSON.stringify(newHistory), updatedAt: new Date() })
670 .where(eq(aiChats.id, opts.chatId));
671 return opts.chatId;
672 }
673 const [row] = await db
674 .insert(aiChats)
675 .values({
676 userId: opts.userId,
677 repositoryId: opts.repositoryId ?? undefined,
678 title: opts.title.slice(0, 80),
679 messages: JSON.stringify(newHistory),
680 })
681 .returning();
682 return row?.id || "";
683 } catch (err) {
684 console.error("[ask] persist failed:", err);
685 return opts.chatId || "";
686 }
687}
688
689// ---------- Global assistant ----------
690
691ask.get("/ask", requireAuth, async (c) => {
692 const user = c.get("user")!;
693 const unread = await getUnreadCount(user.id);
694 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
695 try {
696 recent = await db
697 .select({
698 id: aiChats.id,
699 title: aiChats.title,
700 updatedAt: aiChats.updatedAt,
701 })
702 .from(aiChats)
703 .where(eq(aiChats.userId, user.id))
704 .orderBy(desc(aiChats.updatedAt))
705 .limit(10);
706 } catch {
707 /* ignore */
708 }
709
710 return renderChatView(c, {
711 messages: [],
712 postUrl: "/ask",
713 title: "Ask AI",
714 subtitle:
715 "Ask about GlueCron, your repos, or paste a diff to review. Claude is grounded in your repo when visiting /:owner/:repo/ask.",
716 placeholder: "Ask anything...",
717 recentChats: recent,
718 user,
719 unreadCount: unread,
720 });
721});
722
723ask.get("/ask/:chatId", requireAuth, async (c) => {
724 const user = c.get("user")!;
725 const chatId = c.req.param("chatId");
726 const resumed = await resumeChat(user.id, chatId);
727 if (!resumed) return c.redirect("/ask");
728 const unread = await getUnreadCount(user.id);
729 return renderChatView(c, {
730 messages: resumed.messages,
731 postUrl:
732 resumed.repoOwner && resumed.repoName
733 ? `/${resumed.repoOwner}/${resumed.repoName}/ask?chatId=${chatId}`
734 : `/ask?chatId=${chatId}`,
735 title:
736 resumed.repoOwner && resumed.repoName
737 ? `${resumed.repoOwner}/${resumed.repoName} — AI chat`
738 : "Ask AI",
739 placeholder: "Continue the conversation...",
740 user,
741 unreadCount: unread,
742 });
743});
744
745ask.post("/ask", requireAuth, async (c) => {
746 const user = c.get("user")!;
747 const body = await c.req.parseBody();
748 const userMessage = String(body.message || "").trim();
749 const chatId = (c.req.query("chatId") || "").trim();
750 if (!userMessage) return c.redirect("/ask");
751
752 let history: ChatMessage[] = [];
753 if (chatId) {
754 const existing = await resumeChat(user.id, chatId);
755 if (existing) history = existing.messages;
756 }
757
758 const response = await chat(user.username, null, history, userMessage);
759 const nextId = await appendMessage({
760 userId: user.id,
761 chatId: chatId || null,
762 repositoryId: null,
763 userMessage,
764 aiReply: response.reply,
765 history,
766 title: userMessage,
767 });
768
769 return c.redirect(nextId ? `/ask/${nextId}` : "/ask");
770});
771
772// ---------- Repo-grounded assistant ----------
773
774ask.get("/:owner/:repo/ask", requireAuth, async (c) => {
775 const user = c.get("user")!;
776 const { owner, repo } = c.req.param();
777
778 // Verify repo exists
779 const [repoRow] = await db
780 .select({ id: repositories.id })
781 .from(repositories)
782 .innerJoin(users, eq(repositories.ownerId, users.id))
783 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
784 .limit(1);
785 if (!repoRow) return c.notFound();
786
787 const unread = await getUnreadCount(user.id);
788
789 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
790 try {
791 recent = await db
792 .select({
793 id: aiChats.id,
794 title: aiChats.title,
795 updatedAt: aiChats.updatedAt,
796 })
797 .from(aiChats)
798 .where(
799 and(
800 eq(aiChats.userId, user.id),
801 eq(aiChats.repositoryId, repoRow.id)
802 )
803 )
804 .orderBy(desc(aiChats.updatedAt))
805 .limit(10);
806 } catch {
807 /* ignore */
808 }
809
810 return renderChatView(c, {
811 messages: [],
812 postUrl: `/${owner}/${repo}/ask`,
813 title: `Ask about ${owner}/${repo}`,
814 subtitle:
815 "Claude has access to this repository's README, tree, and recent commits. Reference files with @path/to/file.",
816 placeholder: `Ask about ${repo}...`,
817 recentChats: recent,
818 user,
819 unreadCount: unread,
820 });
821});
822
823ask.post("/:owner/:repo/ask", requireAuth, async (c) => {
824 const user = c.get("user")!;
825 const { owner, repo } = c.req.param();
826 const body = await c.req.parseBody();
827 const userMessage = String(body.message || "").trim();
828 const chatId = (c.req.query("chatId") || "").trim();
829 if (!userMessage) return c.redirect(`/${owner}/${repo}/ask`);
830
831 const [repoRow] = await db
832 .select({ id: repositories.id })
833 .from(repositories)
834 .innerJoin(users, eq(repositories.ownerId, users.id))
835 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
836 .limit(1);
837 if (!repoRow) return c.notFound();
838
839 let history: ChatMessage[] = [];
840 if (chatId) {
841 const existing = await resumeChat(user.id, chatId);
842 if (existing) history = existing.messages;
843 }
844
845 const response = await chat(owner, repo, history, userMessage);
846 const nextId = await appendMessage({
847 userId: user.id,
848 chatId: chatId || null,
849 repositoryId: repoRow.id,
850 userMessage,
851 aiReply: response.reply,
852 history,
853 title: userMessage,
854 });
855
856 return c.redirect(
857 nextId ? `/ask/${nextId}` : `/${owner}/${repo}/ask`
858 );
859});
860
861// ---------- Explain-this-file helper ----------
862
863ask.post("/:owner/:repo/explain", requireAuth, async (c) => {
864 const { owner, repo } = c.req.param();
865 const body = await c.req.parseBody().catch(() => ({}));
866 const filePath = String((body as any).file || c.req.query("file") || "");
867 const ref = String((body as any).ref || c.req.query("ref") || "");
868 if (!filePath || !ref) {
869 return c.json({ error: "file and ref required" }, 400);
870 }
871
872 const { getBlob } = await import("../git/repository");
873 const blob = await getBlob(owner, repo, ref, filePath);
874 if (!blob || blob.isBinary) {
875 return c.json({ error: "file not found or binary" }, 404);
876 }
877
878 const explanation = await explainFile(owner, repo, filePath, blob.content);
879 return c.json({ explanation });
880});
881
882export default ask;