Files
2026-09-08 07:21:34 -04:00

675 lines
24 KiB
TypeScript

"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<Exclude<ChatStatus, "idle">, "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<HTMLDivElement>(null);
const [input, setInput] = useState("");
const [projectId, setProjectId] = useState<string>("all");
const [ctxIds, setCtxIds] = useState<string[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [pendingJob, setPendingJob] = useState<{ jobId: string; msgId: string; polls: number } | null>(null);
const [copied, setCopied] = useState<string>("");
const projectsQ = useMemo(() => ({ isFetched: false, data: [] as Project[], error: null }), []);
const [projectsList, setProjectsList] = useState<Project[]>([]);
const [artifactsList, setArtifactsList] = useState<Artifact[]>([]);
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 (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<div className="flex items-start gap-3 flex-wrap">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-accent to-brand-600 shadow-soft grid place-items-center text-white shrink-0">
<Sparkles className="w-5.5 h-5.5 w-[22px] h-[22px]" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
Ask ASF
<Badge tone="green" className="uppercase text-[10px] tracking-wide">
AI Live
</Badge>
</h1>
<p className="mt-1 text-sm text-slate-500 max-w-2xl">
Natural-language interface to the ASF graph. Questions are enqueued as{" "}
<span className="asf-monospace text-slate-700 bg-slate-100 rounded px-1">ai_interactive</span> jobs and
answered by the ASF Worker calling the asf-ai Gateway.
</p>
</div>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-4 xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm">Context & Scope</CardTitle>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0 space-y-4 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Project scope</label>
<select
className="asf-input text-sm"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!pendingJob}
>
<option value="all">All projects (tenant-wide)</option>
{projectsList.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-500">Anchor artifacts</label>
{ctxIds.length > 0 && (
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-600"
onClick={() => setCtxIds([])}
>
Clear ({ctxIds.length})
</button>
)}
</div>
<ul className="max-h-60 overflow-auto divide-y divide-slate-100 border border-slate-100 rounded-lg text-xs">
{artifactsList.length === 0 && (
<li className="p-3 text-slate-400">No artifacts loaded yet.</li>
)}
{artifactsList.map((a) => {
const on = ctxIds.includes(String(a.id));
return (
<li
key={a.id}
className={cn(
"flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors",
on ? "bg-brand-50/60" : "hover:bg-slate-50",
)}
onClick={() => toggleCtxArtifact(String(a.id))}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border grid place-items-center shrink-0",
on ? "bg-brand-500 border-brand-500 text-white" : "border-slate-300 bg-white",
)}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<ArtifactTypeBadge type={a.type} />
<div className="min-w-0 flex-1">
<div className="truncate text-slate-700 font-medium">
{a.external_id ?? shortId(a.id)}
</div>
<div className="truncate text-slate-500">{truncate(a.title, 44)}</div>
</div>
</li>
);
})}
</ul>
</div>
<div className="pt-2 border-t border-slate-100">
<p className="text-xs font-semibold text-slate-500 mb-2 uppercase tracking-wide">Try asking</p>
<div className="space-y-1.5">
{suggestions.map((s) => (
<button
key={s}
type="button"
disabled={!!pendingJob}
onClick={() => {
setInput(s);
setTimeout(() => handleSubmit(), 0);
}}
className="w-full text-left text-xs p-2 rounded-md border border-slate-100 hover:border-brand-200 hover:bg-brand-50/30 text-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<ChevronRight className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate">{s}</span>
</button>
))}
</div>
</div>
</CardBody>
</Card>
<Card className="lg:col-span-8 xl:col-span-9 flex flex-col min-h-0">
<CardHeader className="flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="!text-sm flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-slate-400" />
Conversation
{pendingJob && (
<Badge tone={statusTone[(jobsStatusByJobId(pendingJob.jobId, messages) as Exclude<ChatStatus, "idle">) ?? "running"]} className="animate-pulse">
<Loader2 className="w-3 h-3 inline mr-1 animate-spin" />
Processing…
</Badge>
)}
</CardTitle>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={messages.length === 0}
onClick={() => {
setMessages([]);
setPendingJob(null);
}}
>
<X className="w-3.5 h-3.5 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-auto px-5 py-4 space-y-5 bg-slate-50/40 border-y border-slate-100"
>
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<EmptyState
title="Ask anything about your ASF graph"
description="The AI worker will reason over projects, artifacts, relationships and jobs. Start by typing a question above or pick a suggestion."
icon={Sparkles}
/>
</div>
) : (
messages.map((m) => (
<MessageBubble
key={m.id}
m={m}
artifacts={artifactsList}
projects={projectsList}
onCopy={() => handleCopy(m.id, m.content)}
copied={copied === m.id}
onJumpArtifact={(id) => {
const u = `/context?artifact=${encodeURIComponent(id)}`;
window.open(u, "_blank", "noopener");
}}
/>
))
)}
</div>
<form
onSubmit={handleSubmit}
className="p-3 border-t border-slate-100 bg-white rounded-b-xl flex flex-col gap-2"
>
<div className="flex gap-2 items-end">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
rows={2}
placeholder="Ask ASF: Which services implement REQ-103?… (Enter to send, Shift+Enter for newline)"
className="asf-input pr-24 resize-none min-h-[60px]"
disabled={!!pendingJob}
/>
<div className="absolute bottom-2.5 right-2.5 text-[11px] text-slate-400 pointer-events-none select-none">
{projectId !== "all" && (
<span className="asf-chip !py-0.5 !px-2 mr-1">Project: {projectsList.find((p) => String(p.id) === projectId)?.name ?? shortId(projectId)}</span>
)}
{ctxIds.length > 0 && (
<span className="asf-chip !py-0.5 !px-2">{ctxIds.length} anchors</span>
)}
</div>
</div>
<Button
type="submit"
disabled={!input.trim() || !!pendingJob}
className="h-[60px] !px-4"
>
{pendingJob ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Working…
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Send
</>
)}
</Button>
</div>
</form>
</Card>
</div>
</div>
);
}
function MessageBubble({
m,
artifacts,
projects,
onCopy,
copied,
onJumpArtifact,
}: {
m: ChatMessage;
artifacts: Artifact[];
projects: Project[];
onCopy: () => void;
copied: boolean;
onJumpArtifact: (id: string) => void;
}) {
const isUser = m.role === "user";
const projMap = useMemo(() => {
const mp = new Map<string, Project>();
for (const p of projects) mp.set(String(p.id), p);
return mp;
}, [projects]);
const artMap = useMemo(() => {
const mp = new Map<string, Artifact>();
for (const a of artifacts) mp.set(String(a.id), a);
return mp;
}, [artifacts]);
return (
<div className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"w-8 h-8 shrink-0 rounded-lg grid place-items-center border",
isUser
? "order-2 bg-slate-900 text-white border-slate-900"
: "bg-white border-slate-200 text-accent shadow-sm",
)}
>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={cn("max-w-[82%] flex flex-col gap-1.5", isUser ? "items-end" : "items-start")}>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<span className="font-semibold text-slate-500">{isUser ? "You" : "ASF AI"}</span>
<span>· {fmtDate(m.timestamp)}</span>
{m.jobId && (
<>
<GitBranch className="w-3 h-3 ml-1" />
<span className="asf-monospace">{shortId(m.jobId)}</span>
</>
)}
{m.status && m.status !== "idle" && (
<Badge tone={statusTone[m.status as Exclude<ChatStatus, "idle">] ?? "neutral"}>
{statusIcon(m.status)} {m.status}
</Badge>
)}
</div>
<div
className={cn(
"px-4 py-3 rounded-2xl border text-sm leading-relaxed",
isUser
? "bg-brand-600 text-white rounded-tr-sm border-brand-600"
: "bg-white text-slate-800 border-slate-200 rounded-tl-sm shadow-sm",
)}
>
{m.status === "queued" || m.status === "running" ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-slate-600">
{m.status === "queued"
? "Query queued as ai_interactive job. Waiting for ASF Worker to claim it…"
: "Worker claimed the job. Reasoning over graph and calling asf-ai…"}
</span>
</div>
) : (
<>
<div className="whitespace-pre-wrap break-words">
{m.status === "failed" || m.error ? (
<div className="flex items-start gap-2 text-rose-700 bg-rose-50/60 -mx-1 px-3 py-2 rounded-lg">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<div className="font-semibold mb-0.5">AI execution failed</div>
<div className="text-[12px] text-rose-600">{m.error ?? "Unknown error"}</div>
</div>
</div>
) : (
<ContentAutoLink text={m.content} artMap={artMap} projMap={projMap} onJump={onJumpArtifact} />
)}
</div>
{m.job && (
<JobMeta job={m.job} />
)}
</>
)}
</div>
<div className="flex gap-1">
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 px-2 py-0.5 rounded-md hover:bg-slate-100"
>
{copied ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
</div>
);
}
function ContentAutoLink({
text,
artMap,
projMap,
onJump,
}: {
text: string;
artMap: Map<string, Artifact>;
projMap: Map<string, Project>;
onJump: (id: string) => void;
}) {
const pattern = /\b(REQ|TASK|PR|TEST|BUILD|DEP|DOC|SRC|SEC|INC|EPIC)[-#]?\d*[A-Za-z0-9]*\b/g;
const parts: Array<string | JSX.Element> = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = pattern.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
const token = m[0];
const foundArt = Array.from(artMap.values()).find(
(a) => (a.external_id && a.external_id.toLowerCase() === token.toLowerCase()) || false,
);
if (foundArt) {
parts.push(
<button
key={`link-${key++}`}
type="button"
onClick={() => onJump(String(foundArt.id))}
className="asf-monospace inline-flex items-center gap-1 mx-0.5 -my-0.5 px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 border border-brand-200 hover:bg-brand-100 transition-colors align-baseline"
>
{token}
<ChevronRight className="w-3 h-3" />
</button>,
);
} else {
parts.push(token);
}
last = m.index + token.length;
}
if (last < text.length) parts.push(text.slice(last));
return <>{parts}</>;
}
function JobMeta({ job }: { job: Job }) {
const usage = job.usage;
return (
<div className="mt-3 pt-3 border-t border-slate-100 text-[11px] text-slate-500 grid grid-cols-2 sm:grid-cols-4 gap-2">
<JobMetaPill label="Status" tone={statusTone[job.status as Exclude<ChatStatus, "idle">] ?? "neutral"} value={job.status} />
<JobMetaPill label="Started" value={job.started_at ? fmtDate(job.started_at) : "—"} />
<JobMetaPill label="Completed" value={job.completed_at ? fmtDate(job.completed_at) : "—"} />
<JobMetaPill label="Worker" value={job.worker ?? "asf-worker"} />
{usage?.input_tokens !== undefined && <JobMetaPill label="Input tok." value={String(usage.input_tokens)} />}
{usage?.output_tokens !== undefined && <JobMetaPill label="Output tok." value={String(usage.output_tokens)} />}
{usage?.cpu_seconds !== undefined && <JobMetaPill label="CPU s." value={usage.cpu_seconds.toFixed(2)} />}
{usage?.estimated_cost !== undefined && <JobMetaPill label="Cost" value={`$${usage.estimated_cost.toFixed(4)}`} />}
</div>
);
}
function JobMetaPill({ label, value, tone }: { label: string; value: string; tone?: "brand" | "green" | "red" | "neutral" | "amber" }) {
const toneCls = tone
? {
brand: "bg-brand-50 text-brand-700 border-brand-100",
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
red: "bg-rose-50 text-rose-700 border-rose-100",
neutral: "bg-slate-50 text-slate-600 border-slate-200",
amber: "bg-amber-50 text-amber-700 border-amber-100",
}[tone]
: "bg-slate-50 text-slate-600 border-slate-200";
return (
<div className={cn("rounded-md border px-2 py-1", toneCls)}>
<div className="uppercase tracking-wide text-[9px] opacity-70">{label}</div>
<div className="asf-monospace font-semibold truncate">{value}</div>
</div>
);
}
function statusIcon(s: ChatStatus) {
switch (s) {
case "queued":
return <Clock className="w-3 h-3 inline mr-1" />;
case "running":
return <Loader2 className="w-3 h-3 inline mr-1 animate-spin" />;
case "completed":
return <CheckCircle2 className="w-3 h-3 inline mr-1" />;
case "failed":
return <AlertTriangle className="w-3 h-3 inline mr-1" />;
case "cancelled":
return <X className="w-3 h-3 inline mr-1" />;
default:
return null;
}
}
function jobsStatusByJobId(jid: string, msgs: ChatMessage[]): JobStatus | null {
const m = msgs.find((x) => x.jobId === jid);
return (m?.job?.status ?? m?.status) as JobStatus | null;
}
function pickAnswer(j: Job, fallback: string): string {
if (j.result) {
const r = j.result as Record<string, unknown>;
for (const k of ["message", "answer", "summary", "response", "text"]) {
if (typeof r[k] === "string" && (r[k] as string).length > 0) return r[k] as string;
}
try {
return JSON.stringify(j.result, null, 2);
} catch {
/* noop */
}
}
if (j.status === "failed") {
return j.error ?? "ASF AI could not answer this question.";
}
if (j.status === "completed") {
return "Done. (AI result was returned with no message field.)";
}
return fallback;
}
function cryptoId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return "id-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
}