diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index a3872ec..9a221aa 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useRef, useMemo } from "react" -import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal } from "lucide-react" +import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal, Search } from "lucide-react" import { Badge } from "@/components/ui/badge" import { BoostButton } from "@/components/boost/boost-button" @@ -23,7 +23,7 @@ import { cn, displayNodeType, formatCompactNumber } from "@/lib/utils" import { pickString, unescapeText, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { getStatusBadge, isBlockedStatus, isInProgress } from "@/lib/node-status" import type { GraphNode, GraphData, StakworkRun } from "@/lib/graph-api" -import { triggerDeepResearch, getLatestStakworkRun, getNode, isGraphData } from "@/lib/graph-api" +import { triggerDeepResearch, triggerEnrich, getLatestStakworkRun, getNode, isGraphData } from "@/lib/graph-api" import { getWatches, watchNode, unwatchNode } from "@/lib/watch-api" import { cookieStorage } from "@/lib/cookie-storage" import type { SchemaNode } from "@/lib/schema-types" @@ -35,6 +35,7 @@ import { formatDateAbsolute, formatDateRelative } from "@/lib/date-format" import { useGraphStore } from "@/stores/graph-store" const DEEP_RESEARCH_NODE_TYPES = ["Topic"] +const ENRICH_NODE_TYPES = ["Person", "Organization", "Product", "Location", "Topic"] as const const INTERNAL_FIELDS = new Set([ "ref_id", "pubkey", "owner_reference_id", "node_type", "date_added_to_graph", "status", "project_id", @@ -793,6 +794,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const [deepResearchLoading, setDeepResearchLoading] = useState(false) const deepResearchPollRef = useRef | null>(null) + // Enrich state + const [enrichLoading, setEnrichLoading] = useState(false) + const [enrichStatus, setEnrichStatus] = useState(null) + const [enrichRunProjectId, setEnrichRunProjectId] = useState(null) + const enrichPollRef = useRef | null>(null) + function isDeepResearchInFlight(status: DeepResearchStatus): boolean { return status === "PENDING" || status === "RUNNING" } @@ -848,6 +855,10 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp clearInterval(deepResearchPollRef.current) deepResearchPollRef.current = null } + if (enrichPollRef.current) { + clearInterval(enrichPollRef.current) + enrichPollRef.current = null + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentNode.ref_id]) @@ -866,6 +877,40 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp } } + function startEnrichPoll(refId: string) { + if (enrichPollRef.current) clearInterval(enrichPollRef.current) + enrichPollRef.current = setInterval(async () => { + try { + const run = await getLatestStakworkRun(refId, "web_search_enrich") + if (!run) return + const mapped = mapRunStatus(run.status) + setEnrichStatus(mapped) + if (run.project_id) setEnrichRunProjectId(run.project_id) + if (mapped !== "PENDING" && mapped !== "RUNNING") { + if (enrichPollRef.current) clearInterval(enrichPollRef.current) + enrichPollRef.current = null + if (mapped === "COMPLETED") setProbeNonce((n) => n + 1) + } + } catch { + // silent — keep polling + } + }, 5000) + } + + async function handleEnrich() { + if (enrichLoading || isDeepResearchInFlight(enrichStatus)) return + setEnrichLoading(true) + setEnrichStatus("PENDING") + try { + await triggerEnrich(currentNode.ref_id) + startEnrichPoll(currentNode.ref_id) + } catch { + setEnrichStatus("FAILED") + } finally { + setEnrichLoading(false) + } + } + // Full reset (currentNode + history + scroll) only when a genuinely different // node is selected. useEffect(() => { @@ -1463,6 +1508,54 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp )} + {/* Enrich — admin-only, Person/Organization/Product/Location/Topic */} + {isAdmin && ENRICH_NODE_TYPES.includes(currentNode.node_type as typeof ENRICH_NODE_TYPES[number]) && ( +
+ + {enrichRunProjectId && ( + + + View Enrich run + + )} +
+ )} + {/* Preview / Loading / Unlocked / Error */} {unlockState === "preview" && (
diff --git a/src/lib/__tests__/graph-api-enrich.test.ts b/src/lib/__tests__/graph-api-enrich.test.ts new file mode 100644 index 0000000..507080c --- /dev/null +++ b/src/lib/__tests__/graph-api-enrich.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +// Mock sphinx helpers so api.ts can be imported without side-effects +const { getL402Mock, getSignedMessageMock } = vi.hoisted(() => ({ + getL402Mock: vi.fn(), + getSignedMessageMock: vi.fn(), +})) + +vi.mock("@/lib/sphinx", () => ({ + getL402: getL402Mock, + getSignedMessage: getSignedMessageMock, +})) + +// Disable mocks mode so the real API paths are exercised +vi.mock("@/lib/mock-data", () => ({ + isMocksEnabled: () => false, + MOCK_REVIEWS: [], + MOCK_WORKFLOW_MARKETPLACE: [], +})) + +import { triggerEnrich, getLatestStakworkRun } from "@/lib/graph-api" + +const originalFetch = global.fetch + +beforeEach(() => { + getSignedMessageMock.mockResolvedValue({ signature: "", message: "" }) + getL402Mock.mockResolvedValue(null) +}) + +afterEach(() => { + global.fetch = originalFetch + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// triggerEnrich +// --------------------------------------------------------------------------- +describe("triggerEnrich", () => { + it("POSTs to /api/v2/nodes/:refId/enrich and returns stakwork_run_ref_id", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, stakwork_run_ref_id: "enrich-run-abc" }), + }) as unknown as typeof fetch + + const result = await triggerEnrich("person-123") + + expect(result).toEqual({ success: true, stakwork_run_ref_id: "enrich-run-abc" }) + const [[url, options]] = (global.fetch as ReturnType).mock.calls + expect(url).toContain("/api/v2/nodes/person-123/enrich") + expect((options as RequestInit).method).toBe("POST") + }) + + it("throws on non-2xx response (500)", async () => { + const mockResponse = { ok: false, status: 500, json: async () => ({}) } + global.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch + + const err = await triggerEnrich("person-500").catch((e) => e) + expect(err).toBeDefined() + expect(err.status).toBe(500) + }) + + it("throws 400 when node type is unsupported (backend rejects)", async () => { + const mockResponse = { + ok: false, + status: 400, + json: async () => ({ message: "Unsupported node type" }), + } + global.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch + + const err = await triggerEnrich("episode-999").catch((e) => e) + expect(err).toBeDefined() + expect(err.status).toBe(400) + }) + + it("throws 409 when a run is already in-flight", async () => { + const mockResponse = { + ok: false, + status: 409, + json: async () => ({ message: "Run already in-flight" }), + } + global.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch + + const err = await triggerEnrich("person-inflight").catch((e) => e) + expect(err).toBeDefined() + expect(err.status).toBe(409) + }) +}) + +// --------------------------------------------------------------------------- +// getLatestStakworkRun with web_search_enrich job type +// --------------------------------------------------------------------------- +describe("getLatestStakworkRun (web_search_enrich)", () => { + it("GETs /api/v2/stakwork-runs/latest with job_type=web_search_enrich", async () => { + const mockRun = { + ref_id: "enrich-run-1", + job_type: "web_search_enrich", + status: "RUNNING", + created_at: 1700000000, + project_id: 12345, + } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockRun, + }) as unknown as typeof fetch + + const result = await getLatestStakworkRun("person-xyz", "web_search_enrich") + + expect(result).toEqual(mockRun) + const [[url]] = (global.fetch as ReturnType).mock.calls + expect(url).toContain("/api/v2/stakwork-runs/latest") + expect(url).toContain("ref_id=person-xyz") + expect(url).toContain("job_type=web_search_enrich") + }) + + it("returns run with project_id field", async () => { + const mockRun = { + ref_id: "enrich-run-2", + job_type: "web_search_enrich", + status: "COMPLETED", + created_at: 1700001000, + finished_at: 1700001500, + project_id: 99999, + } + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockRun, + }) as unknown as typeof fetch + + const result = await getLatestStakworkRun("person-ok", "web_search_enrich") + + expect(result).not.toBeNull() + expect(result?.status).toBe("COMPLETED") + expect(result?.project_id).toBe(99999) + }) + + it("returns null when the server responds with 404", async () => { + const mockResponse = { ok: false, status: 404, json: async () => ({}) } + global.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch + + const result = await getLatestStakworkRun("person-404", "web_search_enrich") + + expect(result).toBeNull() + }) + + it("rethrows non-404 errors", async () => { + const mockResponse = { ok: false, status: 500, json: async () => ({}) } + global.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch + + const err = await getLatestStakworkRun("person-500", "web_search_enrich").catch((e) => e) + expect(err).toBeDefined() + expect(err.status).toBe(500) + }) +}) diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index dc42453..ece5f5d 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -25,15 +25,17 @@ vi.mock("@/lib/api", () => ({ api: { get: (...args: unknown[]) => mockApiGet(...args) }, })) -// --- mock graph-api deep research helpers --- -const { mockTriggerDeepResearch, mockGetLatestStakworkRun, mockGetNode, mockGetAttachables } = vi.hoisted(() => ({ +// --- mock graph-api deep research + enrich helpers --- +const { mockTriggerDeepResearch, mockTriggerEnrich, mockGetLatestStakworkRun, mockGetNode, mockGetAttachables } = vi.hoisted(() => ({ mockTriggerDeepResearch: vi.fn(), + mockTriggerEnrich: vi.fn(), mockGetLatestStakworkRun: vi.fn(), mockGetNode: vi.fn().mockResolvedValue(null), mockGetAttachables: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), })) vi.mock("@/lib/graph-api", () => ({ triggerDeepResearch: (...args: unknown[]) => mockTriggerDeepResearch(...args), + triggerEnrich: (...args: unknown[]) => mockTriggerEnrich(...args), getLatestStakworkRun: (...args: unknown[]) => mockGetLatestStakworkRun(...args), getNode: (...args: unknown[]) => mockGetNode(...args), getAttachables: (...args: unknown[]) => mockGetAttachables(...args), @@ -2173,3 +2175,140 @@ describe("NodePreviewPanel – TranscriptChatWidget visibility", () => { expect(screen.queryByTestId("transcript-chat-widget")).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// Enrich button — eligibility, states, polling, admin-gating +// --------------------------------------------------------------------------- +describe("NodePreviewPanel – Enrich button", () => { + const ENRICH_TYPES = ["Person", "Organization", "Product", "Location", "Topic"] as const + + const makeEnrichNode = (node_type: string): GraphNode => ({ + ref_id: `enrich-${node_type.toLowerCase()}`, + node_type, + properties: { name: `Test ${node_type}` }, + }) + + beforeEach(() => { + vi.clearAllMocks() + userStoreOverrides = { pubKey: "03admin", routeHint: "", isAdmin: true } + mockGetLatestStakworkRun.mockResolvedValue(null) + mockTriggerEnrich.mockResolvedValue({ stakwork_run_ref_id: "mock-enrich-run-abc" }) + }) + + it.each(ENRICH_TYPES)("renders Enrich button for %s nodes when isAdmin", async (nodeType) => { + const node = makeEnrichNode(nodeType) + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByTestId("enrich-button")).toBeInTheDocument()) + expect(screen.getByTestId("enrich-button")).toHaveTextContent("Enrich") + }) + + it("does NOT render Enrich button when isAdmin is false", async () => { + userStoreOverrides = { pubKey: "03user", routeHint: "", isAdmin: false } + const node = makeEnrichNode("Person") + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByText("Test Person")).toBeInTheDocument()) + expect(screen.queryByTestId("enrich-button")).toBeNull() + }) + + it("does NOT render Enrich button for unsupported node types even when isAdmin", async () => { + const node: GraphNode = { + ref_id: "ep-unsupported", + node_type: "Episode", + properties: { episode_title: "Some Episode", media_url: "https://example.com/a.mp3" }, + } + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByText("Some Episode")).toBeInTheDocument()) + expect(screen.queryByTestId("enrich-button")).toBeNull() + }) + + it("shows 'Enriching…' and is disabled after click while in-flight", async () => { + const node = makeEnrichNode("Person") + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByTestId("enrich-button")).toBeInTheDocument()) + + screen.getByTestId("enrich-button").click() + + await waitFor(() => { + expect(screen.getByTestId("enrich-button")).toHaveTextContent("Enriching…") + expect(screen.getByTestId("enrich-button")).toBeDisabled() + }) + }) + + it("calls triggerEnrich with the node ref_id on click", async () => { + const node = makeEnrichNode("Organization") + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByTestId("enrich-button")).toBeInTheDocument()) + + screen.getByTestId("enrich-button").click() + + expect(mockTriggerEnrich).toHaveBeenCalledWith(node.ref_id) + }) + + it("shows 'Enrich failed' when triggerEnrich throws", async () => { + mockTriggerEnrich.mockRejectedValueOnce(new Error("Network error")) + const node = makeEnrichNode("Product") + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByTestId("enrich-button")).toBeInTheDocument()) + + screen.getByTestId("enrich-button").click() + + await waitFor(() => + expect(screen.getByTestId("enrich-button")).toHaveTextContent("Enrich failed") + ) + expect(screen.getByTestId("enrich-button")).not.toBeDisabled() + }) + + it("shows 'View Enrich run' link once enrichRunProjectId is set via polling", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + mockGetLatestStakworkRun + .mockResolvedValueOnce({ + ref_id: "enrich-run-1", + job_type: "web_search_enrich", + status: "RUNNING", + created_at: 0, + project_id: 42000, + }) + + const node = makeEnrichNode("Location") + mockApiGet.mockResolvedValue(makeGraphData(node)) + render() + await waitFor(() => expect(screen.getByTestId("enrich-button")).toBeInTheDocument()) + + screen.getByTestId("enrich-button").click() + await vi.advanceTimersByTimeAsync(5000) + + await waitFor(() => + expect(screen.getByText("View Enrich run")).toBeInTheDocument() + ) + const link = screen.getByText("View Enrich run").closest("a") + expect(link).toHaveAttribute("href", "https://jobs.stakwork.com/admin/projects/42000") + } finally { + vi.useRealTimers() + } + }) + + it("on Topic node: both Deep Research and Enrich buttons are present for admin", async () => { + const node: GraphNode = { + ref_id: "topic-both", + node_type: "Topic", + properties: { name: "Dual Action Topic" }, + } + mockApiGet.mockResolvedValue(makeGraphData(node)) + mockTriggerDeepResearch.mockResolvedValue({ stakwork_run_ref_id: "mock-dr-run" }) + render() + + await waitFor(() => { + expect(screen.getByTestId("deep-research-button")).toBeInTheDocument() + expect(screen.getByTestId("enrich-button")).toBeInTheDocument() + }) + expect(screen.getByTestId("deep-research-button")).toHaveTextContent("Deep Research") + expect(screen.getByTestId("enrich-button")).toHaveTextContent("Enrich") + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 2d8846f..2217f48 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -432,6 +432,7 @@ export interface StakworkRun { created_at?: number started_at?: number finished_at?: number + project_id?: number } export async function getCronConfig( @@ -629,6 +630,23 @@ export async function triggerDeepResearch( ) } +// Admin-only enrich (web search) — no payment gate +export async function triggerEnrich( + refId: string, + signal?: AbortSignal +): Promise<{ stakwork_run_ref_id: string }> { + if (isMocksEnabled()) { + _mockDeepResearchPollCounts[refId + "_enrich"] = 0 + return { stakwork_run_ref_id: "mock-enrich-run-" + refId } + } + return api.post<{ stakwork_run_ref_id: string }>( + `/v2/nodes/${refId}/enrich`, + {}, + undefined, + signal + ) +} + // Free poll — returns null when no run exists (404) export async function getLatestStakworkRun( refId: string, @@ -636,18 +654,20 @@ export async function getLatestStakworkRun( signal?: AbortSignal ): Promise { if (isMocksEnabled()) { - const count = (_mockDeepResearchPollCounts[refId] ?? 0) + 1 - _mockDeepResearchPollCounts[refId] = count + const mockKey = jobType === "web_search_enrich" ? refId + "_enrich" : refId + const count = (_mockDeepResearchPollCounts[mockKey] ?? 0) + 1 + _mockDeepResearchPollCounts[mockKey] = count + const runPrefix = jobType === "web_search_enrich" ? "mock-enrich-run-" : "mock-deep-run-" if (count <= 2) { return { - ref_id: "mock-deep-run-" + refId, + ref_id: runPrefix + refId, job_type: jobType, status: "RUNNING", created_at: Math.floor(Date.now() / 1000), } } return { - ref_id: "mock-deep-run-" + refId, + ref_id: runPrefix + refId, job_type: jobType, status: "COMPLETED", created_at: Math.floor(Date.now() / 1000),