From 1fcb4c84e52718c27a2daccc6ba21bf8c4d1457f Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 26 Jul 2026 07:31:03 +0700 Subject: [PATCH] feat: ontology page agent --- .../admin/ontology/ontology-agent-panel.tsx | 264 ++++++++++++++++++ src/app/admin/ontology/page.tsx | 23 +- src/lib/graph-api.ts | 102 ++++++- 3 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 src/app/admin/ontology/ontology-agent-panel.tsx diff --git a/src/app/admin/ontology/ontology-agent-panel.tsx b/src/app/admin/ontology/ontology-agent-panel.tsx new file mode 100644 index 0000000..b0e41c2 --- /dev/null +++ b/src/app/admin/ontology/ontology-agent-panel.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { Sparkles, X, ArrowUp, Loader2 } from "lucide-react" +import { + triggerOntologyAgent, + getStakworkRun, + listReviews, + type OntologyAgentMessage, + type Review, + type StakworkRun, +} from "@/lib/graph-api" +import { ReviewRow } from "@/components/admin/review-row" +import { useSchemaStore } from "@/stores/schema-store" +import type { SchemaNode } from "@/lib/schema-types" + +type TurnStatus = "running" | "completed" | "failed" + +interface AgentTurn { + id: string + runRef?: string + status: TurnStatus + reviews: Review[] + error?: string +} + +interface UserTurn { + id: string + text: string +} + +type Turn = ({ role: "user" } & UserTurn) | ({ role: "agent" } & AgentTurn) + +const POLL_INTERVAL_MS = 5000 + +// Normalize any backend status casing to the three states the panel tracks. +function mapRunStatus(status: StakworkRun["status"]): TurnStatus { + const s = String(status).toUpperCase() + if (s === "COMPLETED") return "completed" + if (s === "FAILED" || s === "ERROR" || s === "HALTED") return "failed" + return "running" +} + +export function OntologyAgentPanel({ onClose }: { onClose: () => void }) { + const schemas = useSchemaStore((s) => s.schemas) + const [turns, setTurns] = useState([]) + const [input, setInput] = useState("") + const [busy, setBusy] = useState(false) + const pollRef = useRef | null>(null) + const scrollRef = useRef(null) + + // Clear any in-flight poll on unmount. + useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) + + // Keep the transcript scrolled to the latest turn. + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) + }, [turns]) + + const patchAgentTurn = useCallback((id: string, patch: Partial) => { + setTurns((prev) => + prev.map((t) => (t.role === "agent" && t.id === id ? { ...t, ...patch } : t)) + ) + }, []) + + // Re-fetch this run's pending proposals (called after an approve/dismiss) and + // refresh the schema store so the graph + type list reflect any applied change. + const refreshTurn = useCallback( + async (turnId: string, runRef: string) => { + try { + const res = await listReviews({ run_ref_id: runRef, status: "pending" }) + patchAgentTurn(turnId, { reviews: res.reviews }) + } catch { + /* keep existing cards on transient failure */ + } + useSchemaStore.getState().fetchAll() + }, + [patchAgentTurn] + ) + + const startPoll = useCallback( + (turnId: string, runRef: string) => { + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = setInterval(async () => { + try { + const run = await getStakworkRun(runRef) + const status = run ? mapRunStatus(run.status) : "running" + if (status === "running") return + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = null + setBusy(false) + if (status === "failed") { + patchAgentTurn(turnId, { status: "failed", error: run?.error_message ?? "The agent run failed." }) + return + } + const res = await listReviews({ run_ref_id: runRef, status: "pending" }) + patchAgentTurn(turnId, { status: "completed", reviews: res.reviews }) + } catch { + /* swallow — keep polling until a terminal state or unmount */ + } + }, POLL_INTERVAL_MS) + }, + [patchAgentTurn] + ) + + const handleSubmit = useCallback(async () => { + const instruction = input.trim() + if (!instruction || busy) return + setBusy(true) + setInput("") + + const history: OntologyAgentMessage[] = turns.map((t) => + t.role === "user" + ? { role: "user", content: t.text } + : { role: "agent", content: agentSummary(t) } + ) + + const userId = `u-${Date.now()}` + const agentId = `a-${Date.now()}` + setTurns((prev) => [ + ...prev, + { role: "user", id: userId, text: instruction }, + { role: "agent", id: agentId, status: "running", reviews: [] }, + ]) + + try { + const { stakwork_run_ref_id } = await triggerOntologyAgent(instruction, history) + patchAgentTurn(agentId, { runRef: stakwork_run_ref_id }) + startPoll(agentId, stakwork_run_ref_id) + } catch { + setBusy(false) + patchAgentTurn(agentId, { status: "failed", error: "Could not start the ontology agent." }) + } + }, [input, busy, turns, patchAgentTurn, startPoll]) + + return ( +
+ {/* Header */} +
+
+ +
+

Edit ontology with AI

+

Describe a change; approve the proposals.

+
+
+ +
+ + {/* Transcript */} +
+ {turns.length === 0 && ( +
+ +

+ e.g. “Add a Podcast type that extends Media with an episode_count number, and a + HOSTED_BY relationship to Person.” +

+
+ )} + + {turns.map((t) => + t.role === "user" ? ( +
+
+ {t.text} +
+
+ ) : ( + t.runRef && refreshTurn(t.id, t.runRef)} + /> + ) + )} +
+ + {/* Composer */} +
+
+