Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -793,6 +794,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const [deepResearchLoading, setDeepResearchLoading] = useState(false)
const deepResearchPollRef = useRef<ReturnType<typeof setInterval> | null>(null)

// Enrich state
const [enrichLoading, setEnrichLoading] = useState(false)
const [enrichStatus, setEnrichStatus] = useState<DeepResearchStatus>(null)
const [enrichRunProjectId, setEnrichRunProjectId] = useState<number | null>(null)
const enrichPollRef = useRef<ReturnType<typeof setInterval> | null>(null)

function isDeepResearchInFlight(status: DeepResearchStatus): boolean {
return status === "PENDING" || status === "RUNNING"
}
Expand Down Expand Up @@ -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])
Expand All @@ -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(() => {
Expand Down Expand Up @@ -1463,6 +1508,54 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
</div>
)}

{/* Enrich — admin-only, Person/Organization/Product/Location/Topic */}
{isAdmin && ENRICH_NODE_TYPES.includes(currentNode.node_type as typeof ENRICH_NODE_TYPES[number]) && (
<div className="pt-1 space-y-1.5">
<Button
size="sm"
variant="outline"
className="w-full"
disabled={enrichLoading || isDeepResearchInFlight(enrichStatus)}
onClick={handleEnrich}
data-testid="enrich-button"
title="Enrich this node via web search"
>
{isDeepResearchInFlight(enrichStatus) ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Enriching…
</>
) : enrichStatus === "COMPLETED" ? (
<>
<Search className="h-3.5 w-3.5 mr-1.5" />
Enriched
</>
) : (enrichStatus === "FAILED" || enrichStatus === "ERROR" || enrichStatus === "HALTED") ? (
<>
<Search className="h-3.5 w-3.5 mr-1.5" />
Enrich failed
</>
) : (
<>
<Search className="h-3.5 w-3.5 mr-1.5" />
Enrich
</>
)}
</Button>
{enrichRunProjectId && (
<a
href={`https://jobs.stakwork.com/admin/projects/${enrichRunProjectId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-primary underline underline-offset-2"
>
<ExternalLink className="h-3 w-3" />
View Enrich run
</a>
)}
</div>
)}

{/* Preview / Loading / Unlocked / Error */}
{unlockState === "preview" && (
<div className="space-y-3">
Expand Down
153 changes: 153 additions & 0 deletions src/lib/__tests__/graph-api-enrich.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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)
})
})
Loading
Loading