first
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
"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);
|
||||
}
|
||||
Reference in New Issue
Block a user