313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Sparkles,
|
|
Send,
|
|
X,
|
|
Loader2,
|
|
MessageCircle,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} from "lucide-react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { askAsf, jobs } from "@/lib/api";
|
|
import { useAppStore } from "@/stores/useAppStore";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
|
import { cn, fmtDate, truncate } from "@/lib/utils";
|
|
import type { Job } from "@/lib/types";
|
|
|
|
interface ChatMessage {
|
|
id: string;
|
|
role: "user" | "system" | "assistant";
|
|
kind?: "question" | "info" | "success" | "error";
|
|
content: string;
|
|
ts: Date;
|
|
jobId?: string;
|
|
}
|
|
|
|
export function AskAsfCommandBar() {
|
|
const open = useAppStore((s) => s.asfOpen);
|
|
const closeAsf = useAppStore((s) => s.closeAsf);
|
|
const openAsf = useAppStore((s) => s.openAsf);
|
|
|
|
const [q, setQ] = useState("");
|
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
|
{
|
|
id: "welcome",
|
|
role: "system",
|
|
kind: "info",
|
|
content:
|
|
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
|
|
ts: new Date(),
|
|
},
|
|
]);
|
|
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// open via global event + ⌘K
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
const meta = e.metaKey || e.ctrlKey;
|
|
if (meta && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
if (open) closeAsf(); else openAsf();
|
|
}
|
|
if (e.key === "Escape" && open) closeAsf();
|
|
}
|
|
function onOpenEv() { openAsf(); }
|
|
window.addEventListener("keydown", onKey);
|
|
window.addEventListener("asf:open-command", onOpenEv);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("asf:open-command", onOpenEv);
|
|
};
|
|
}, [open, openAsf, closeAsf]);
|
|
|
|
useEffect(() => {
|
|
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
|
|
}, [open]);
|
|
|
|
const submitMutation = useMutation({
|
|
mutationFn: (question: string) =>
|
|
askAsf.query({ question }),
|
|
onMutate: (question) => {
|
|
setMessages((m) => [
|
|
...m,
|
|
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
|
|
]);
|
|
},
|
|
onSuccess: (resp) => {
|
|
setMessages((m) => [
|
|
...m,
|
|
{
|
|
id: "subm" + Date.now(),
|
|
role: "system",
|
|
kind: "info",
|
|
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
|
|
ts: new Date(),
|
|
jobId: resp.job_id,
|
|
},
|
|
]);
|
|
setPollingJobId(resp.job_id);
|
|
},
|
|
onError: (err: Error) => {
|
|
setMessages((m) => [
|
|
...m,
|
|
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
|
|
]);
|
|
},
|
|
});
|
|
|
|
// long-poll the accepted job until terminal
|
|
useQuery({
|
|
queryKey: ["ask-job", pollingJobId],
|
|
enabled: !!pollingJobId,
|
|
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
|
|
queryFn: async (): Promise<Job | undefined> => {
|
|
if (!pollingJobId) return undefined;
|
|
const job = await jobs.get(pollingJobId);
|
|
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
|
|
setPollingJobId(null);
|
|
setMessages((m) => {
|
|
// avoid adding duplicate result messages
|
|
if (m.some(x => x.id === `res-${job.id}`)) return m;
|
|
if (job.status === "completed" && job.result) {
|
|
const answer =
|
|
(job.result as Record<string, unknown>).answer as string ||
|
|
JSON.stringify(job.result, null, 2);
|
|
return [
|
|
...m,
|
|
{
|
|
id: `res-${job.id}`,
|
|
role: "assistant",
|
|
kind: "success",
|
|
content: answer,
|
|
ts: new Date(),
|
|
jobId: job.id,
|
|
},
|
|
];
|
|
}
|
|
if (job.status === "failed") {
|
|
return [
|
|
...m,
|
|
{
|
|
id: `fail-${job.id}`,
|
|
role: "system",
|
|
kind: "error",
|
|
content: job.error || "Unknown AI job failure.",
|
|
ts: new Date(),
|
|
jobId: job.id,
|
|
},
|
|
];
|
|
}
|
|
return m;
|
|
});
|
|
}
|
|
return job;
|
|
},
|
|
});
|
|
|
|
function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!q.trim()) return;
|
|
submitMutation.mutate(q.trim());
|
|
setQ("");
|
|
}
|
|
|
|
const [expanded, setExpanded] = useState(true);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
|
|
onClick={closeAsf}
|
|
>
|
|
<div
|
|
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* header */}
|
|
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
|
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
|
|
<Sparkles className="w-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
|
|
<div className="text-[11px] text-slate-500 leading-tight">
|
|
Context-aware queries backed by the ASF AI Gateway
|
|
</div>
|
|
</div>
|
|
<Badge tone="violet">
|
|
<MessageCircle className="w-3 h-3 mr-1" />
|
|
mock provider
|
|
</Badge>
|
|
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
|
|
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* messages */}
|
|
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
|
{messages.map((m) => (
|
|
<MessageBubble key={m.id} m={m} />
|
|
))}
|
|
{submitMutation.isPending && (
|
|
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
Submitting query to ASF AI job queue…
|
|
</div>
|
|
)}
|
|
{pollingJobId && (
|
|
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* toggle for expanded mini-chat */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
|
|
>
|
|
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
|
|
</button>
|
|
|
|
{/* input */}
|
|
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
|
|
<textarea
|
|
ref={textareaRef}
|
|
rows={2}
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
|
|
}}
|
|
placeholder={
|
|
expanded
|
|
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
|
|
: "Ask ASF… (⌘↩ to send)"
|
|
}
|
|
className="asf-input resize-none"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
className="self-end shrink-0"
|
|
disabled={!q.trim() || submitMutation.isPending}
|
|
>
|
|
{submitMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Send className="w-4 h-4" />
|
|
)}
|
|
<span className="hidden md:inline">Send</span>
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
|
|
{[
|
|
"Show requirements without tests.",
|
|
"Why did yesterday's build fail?",
|
|
"Which services implement REQ-441?",
|
|
"Generate tests for TASK-921.",
|
|
].map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setQ(s)}
|
|
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MessageBubble({ m }: { m: ChatMessage }) {
|
|
const Icon =
|
|
m.kind === "success" ? CheckCircle2 :
|
|
m.kind === "error" ? AlertCircle :
|
|
m.kind === "question" ? MessageCircle : MessageCircle;
|
|
const tone =
|
|
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
|
|
m.role === "user" ? "bg-slate-50 border-slate-200" :
|
|
m.kind === "error" ? "bg-red-50 border-red-100" :
|
|
"bg-white border-slate-200";
|
|
const toneIcon =
|
|
m.kind === "success" ? "text-emerald-600" :
|
|
m.kind === "error" ? "text-red-600" :
|
|
m.role === "user" ? "text-slate-600" :
|
|
"text-brand-600";
|
|
return (
|
|
<div className={cn("flex gap-3 animate-fade-in")}>
|
|
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
|
|
<Icon className={cn("w-4 h-4", toneIcon)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
|
<span className="font-medium capitalize text-slate-600">{m.role}</span>
|
|
<span>· {fmtDate(m.ts)}</span>
|
|
{m.jobId && <JobStatusBadge status={"queued"} />}
|
|
</div>
|
|
<div className={cn(
|
|
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
|
|
tone,
|
|
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
|
|
)}>
|
|
{truncate(m.content, 1200)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|