"use client"; import React, { useRef, useState, useEffect, useMemo } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Sparkles, Send, Loader2, MessageSquare, Bot, User, ChevronRight, X, Copy, Check, Clock, AlertTriangle, CheckCircle2, GitBranch, } from "lucide-react"; import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge"; import { EmptyState } from "@/components/ui/EmptyState"; import { askAsf, jobs, projects, artifacts as apiArtifacts } from "@/lib/api"; import { cn, fmtDate, shortId, truncate } from "@/lib/utils"; import type { Job, JobStatus, Artifact, Project } from "@/lib/types"; type MessageRole = "user" | "assistant" | "system"; type ChatStatus = "idle" | "queued" | "running" | "completed" | "failed" | "cancelled"; interface ChatMessage { id: string; role: MessageRole; content: string; timestamp: string; status?: ChatStatus; jobId?: string; job?: Job | null; error?: string; } const SUGGESTIONS = [ "Which services implement REQ-103 Requisito RBAC?", "Summarize the current deployment chain from PR to PROD", "What is the traceability score of project ASF Bootstrap?", "List all tests that verify PR#88 feat(rbac)", "How healthy is my project last 24h?", ]; const POLL_MS = 2000; const MAX_POLLS = 120; const statusTone: Record, "brand" | "amber" | "green" | "red" | "neutral"> = { queued: "neutral", running: "brand", completed: "green", failed: "red", cancelled: "neutral", }; export default function AskAsfPage() { const qc = useQueryClient(); const scrollRef = useRef(null); const [input, setInput] = useState(""); const [projectId, setProjectId] = useState("all"); const [ctxIds, setCtxIds] = useState([]); const [messages, setMessages] = useState([]); const [pendingJob, setPendingJob] = useState<{ jobId: string; msgId: string; polls: number } | null>(null); const [copied, setCopied] = useState(""); const projectsQ = useMemo(() => ({ isFetched: false, data: [] as Project[], error: null }), []); const [projectsList, setProjectsList] = useState([]); const [artifactsList, setArtifactsList] = useState([]); useEffect(() => { (async () => { try { const [ps, as] = await Promise.all([ projects.list({ limit: 50 }), apiArtifacts.list({ limit: 100 }), ]); setProjectsList(ps); setArtifactsList(as); if (ps.length === 1) setProjectId(String(ps[0].id)); } catch { // ignore offline fallback: demo suggestions } })(); }, []); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages, pendingJob]); useEffect(() => { if (!pendingJob) return; let cancelled = false; let polls = pendingJob.polls; const tick = async () => { if (cancelled) return; polls += 1; try { const j = await jobs.get(pendingJob.jobId); setMessages((prev) => prev.map((m) => m.id === pendingJob.msgId ? { ...m, status: (j.status as ChatStatus) ?? m.status, job: j, content: pickAnswer(j, m.content), error: j.status === "failed" ? j.error ?? "AI job failed" : undefined, } : m, ), ); void qc.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined); if (j.status === "completed" || j.status === "failed" || j.status === "cancelled" || polls > MAX_POLLS) { if (polls > MAX_POLLS) { setMessages((prev) => prev.map((m) => m.id === pendingJob.msgId ? { ...m, status: "failed", error: "Timed out waiting for AI answer (max 240s).", } : m, ), ); } setPendingJob(null); return; } setPendingJob({ ...pendingJob, polls }); } catch { // keep retrying } if (!cancelled) setTimeout(tick, POLL_MS); }; const t = setTimeout(tick, POLL_MS); return () => { cancelled = true; clearTimeout(t); }; }, [pendingJob, qc]); const suggestions = useMemo(() => SUGGESTIONS, []); const toggleCtxArtifact = (id: string) => { setCtxIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); const q = input.trim(); if (!q || pendingJob) return; const userMsg: ChatMessage = { id: cryptoId(), role: "user", content: q, timestamp: new Date().toISOString(), }; const assistantMsgId = cryptoId(); const assistantMsg: ChatMessage = { id: assistantMsgId, role: "assistant", content: "Thinking…", timestamp: new Date().toISOString(), status: "queued", }; setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); try { const res = await askAsf.query({ question: q, project_id: projectId !== "all" ? projectId : undefined, context_artifact_ids: ctxIds.length ? ctxIds : undefined, }); setMessages((prev) => prev.map((m) => m.id === assistantMsgId ? { ...m, jobId: res.job_id, status: "queued" } : m, ), ); setPendingJob({ jobId: res.job_id, msgId: assistantMsgId, polls: 0 }); } catch (err) { setMessages((prev) => prev.map((m) => m.id === assistantMsgId ? { ...m, status: "failed", error: err instanceof Error ? err.message : "Failed to enquire ASF", } : m, ), ); } }; const handleCopy = (id: string, text: string) => { void navigator.clipboard?.writeText(text).then(() => { setCopied(id); setTimeout(() => setCopied(""), 1800); }); }; return (

Ask ASF AI Live

Natural-language interface to the ASF graph. Questions are enqueued as{" "} ai_interactive jobs and answered by the ASF Worker calling the asf-ai Gateway.

Context & Scope
{ctxIds.length > 0 && ( )}
    {artifactsList.length === 0 && (
  • No artifacts loaded yet.
  • )} {artifactsList.map((a) => { const on = ctxIds.includes(String(a.id)); return (
  • toggleCtxArtifact(String(a.id))} > {on && }
    {a.external_id ?? shortId(a.id)}
    {truncate(a.title, 44)}
  • ); })}

Try asking

{suggestions.map((s) => ( ))}
Conversation {pendingJob && ( ) ?? "running"]} className="animate-pulse"> Processing… )}
{messages.length === 0 ? (
) : ( messages.map((m) => ( handleCopy(m.id, m.content)} copied={copied === m.id} onJumpArtifact={(id) => { const u = `/context?artifact=${encodeURIComponent(id)}`; window.open(u, "_blank", "noopener"); }} /> )) )}