import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import { AlertCircle, MessageSquareText, Upload, Loader2, Play, ArrowUpRight, } from 'lucide-react'; import type { ChatMessage, AgentSuggestedAction, SubmitOnDemandAction, FollowUpMessageAction, FollowUpContext, } from '../../types/jamiePullAgent'; import { ActivityTimeline, ResponseMetadata } from './JamiePullAgentResultCards.tsx'; import { API_URL } from '../../constants/constants.ts'; import { InlineCardMention, type AnalysisCardJson } from '../UnifiedSidePanel.tsx'; import { createClipShareUrl } from '../../utils/urlUtils.ts'; import TryJamieService from '../../services/tryJamieService.ts'; // ─── Clip metadata fetching & cache ───────────────────────────────────────── export interface ClipMeta { pineconeId: string; episodeTitle: string; episodeImage: string; creator: string; audioUrl: string; startTime: number; endTime: number; text: string; publishedDate?: string; } const CACHE_STORAGE_KEY = 'workflow_clip_meta_cache'; const CACHE_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes function hydrateCache(): Map { const map = new Map(); try { const raw = sessionStorage.getItem(CACHE_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as { ts: number; entries: [string, ClipMeta][] }; if (Date.now() - parsed.ts < CACHE_MAX_AGE_MS) { for (const [k, v] of parsed.entries) map.set(k, v); } else { sessionStorage.removeItem(CACHE_STORAGE_KEY); } } } catch { /* ignore corrupt storage */ } return map; } function persistCache(cache: Map) { try { sessionStorage.setItem( CACHE_STORAGE_KEY, JSON.stringify({ ts: Date.now(), entries: [...cache.entries()] }) ); } catch { /* storage full — non-critical */ } } export const clipMetaCache = hydrateCache(); const clipMetaInFlight = new Set(); const clipMetaFailed = new Set(); const MAX_CONCURRENT = 1; const REQUEST_DELAY_MS = 500; let activeRequests = 0; const pendingQueue: (() => void)[] = []; function drainQueue() { if (activeRequests >= MAX_CONCURRENT || pendingQueue.length === 0) return; activeRequests++; pendingQueue.shift()!(); } const MAX_RETRIES = 2; const INITIAL_RETRY_DELAY_MS = 1000; async function fetchClipMeta(pineconeId: string): Promise { return new Promise(resolve => { const run = async () => { const cachedHit = clipMetaCache.get(pineconeId); if (cachedHit) { resolve(cachedHit); return; } for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { const res = await fetch( `${API_URL}/api/get-hierarchy?paragraphId=${encodeURIComponent(pineconeId)}` ); if (!res.ok) { resolve(null); return; } const data = await res.json(); const h = data.hierarchy; const para = h?.paragraph?.metadata; const ep = h?.episode?.metadata; const meta: ClipMeta = { pineconeId, episodeTitle: ep?.title || para?.episode || 'Unknown episode', episodeImage: ep?.imageUrl || para?.episodeImage || '', creator: ep?.creator || para?.creator || '', audioUrl: para?.audioUrl || ep?.audioUrl || '', startTime: para?.start_time ?? 0, endTime: para?.end_time ?? 0, text: para?.text || '', publishedDate: ep?.publishedDate || para?.publishedDate || undefined, }; clipMetaCache.set(pineconeId, meta); persistCache(clipMetaCache); resolve(meta); return; } catch { if (attempt < MAX_RETRIES) { await new Promise(r => setTimeout(r, INITIAL_RETRY_DELAY_MS * (attempt + 1))); continue; } resolve(null); } } }; const cleanup = async () => { try { await run(); } finally { activeRequests--; setTimeout(drainQueue, REQUEST_DELAY_MS); } }; pendingQueue.push(cleanup); drainQueue(); }); } function useClipMetadata(pineconeIds: string[]): Map { const [rev, setRev] = useState(0); const idsKey = pineconeIds.join(','); useEffect(() => { if (!pineconeIds.length) return; let cancelled = false; const toFetch = pineconeIds.filter( id => !clipMetaCache.has(id) && !clipMetaInFlight.has(id) && !clipMetaFailed.has(id) ); for (const id of toFetch) { clipMetaInFlight.add(id); fetchClipMeta(id).then(meta => { clipMetaInFlight.delete(id); if (cancelled) return; if (meta) { clipMetaCache.set(id, meta); } else { clipMetaFailed.add(id); } setRev(r => r + 1); }); } return () => { cancelled = true; }; }, [idsKey]); return clipMetaCache; } // ─── Clip token regex ──────────────────────────────────────────────────────── const CLIP_TOKEN_RE = /\{\{clip:([^}]+)\}\}/g; export function extractClipIds(text: string): string[] { const ids: string[] = []; for (const m of text.matchAll(CLIP_TOKEN_RE)) ids.push(m[1]); return [...new Set(ids)]; } // ─── Build markdown with [[CLIP:n]] placeholders ──────────────────────────── function buildMarkdownWithClipPlaceholders(text: string): { markdown: string; clipsByIndex: Record; } { const clipsByIndex: Record = {}; let idx = 0; const markdown = text.replace(CLIP_TOKEN_RE, (_match, pineconeId) => { clipsByIndex[idx] = pineconeId; return ` [[CLIP:${idx++}]] `; }); return { markdown, clipsByIndex }; } // ─── Nebula thumbnail for research session cards ──────────────────────────── const NEBULA_SIZE = 48; const NEBULA_DPR = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1; function seededRand(seed: number) { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; } export const NebulaThumbnail: React.FC<{ size?: number }> = React.memo(({ size = NEBULA_SIZE }) => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const s = size * NEBULA_DPR; canvas.width = s; canvas.height = s; const rand = seededRand(42); ctx.fillStyle = '#08080c'; ctx.fillRect(0, 0, s, s); // Nebula clouds — warm orange/amber blobs const clouds = [ { x: 0.3, y: 0.35, r: 0.5, color: 'rgba(240,139,71,0.22)' }, { x: 0.7, y: 0.6, r: 0.45, color: 'rgba(204,68,0,0.15)' }, { x: 0.5, y: 0.5, r: 0.35, color: 'rgba(255,180,100,0.12)' }, ]; for (const c of clouds) { const g = ctx.createRadialGradient(c.x * s, c.y * s, 0, c.x * s, c.y * s, c.r * s); g.addColorStop(0, c.color); g.addColorStop(1, 'transparent'); ctx.fillStyle = g; ctx.fillRect(0, 0, s, s); } // Scattered stars ctx.globalCompositeOperation = 'screen'; for (let i = 0; i < 30; i++) { const x = rand() * s; const y = rand() * s; const brightness = 0.3 + rand() * 0.5; const radius = (0.3 + rand() * 0.8) * NEBULA_DPR; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(255,${200 + Math.floor(rand() * 55)},${180 + Math.floor(rand() * 75)},${brightness})`; ctx.fill(); } // Bright core star with glow const cx = s * 0.42; const cy = s * 0.40; const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, s * 0.18); glow.addColorStop(0, 'rgba(255,220,180,0.8)'); glow.addColorStop(0.4, 'rgba(240,139,71,0.3)'); glow.addColorStop(1, 'transparent'); ctx.fillStyle = glow; ctx.fillRect(0, 0, s, s); const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, s * 0.05); core.addColorStop(0, 'rgba(255,255,240,0.95)'); core.addColorStop(1, 'transparent'); ctx.fillStyle = core; ctx.fillRect(0, 0, s, s); ctx.globalCompositeOperation = 'source-over'; }, [size]); return ( ); }); // ─── Time formatter ───────────────────────────────────────────────────────── function fmtTime(seconds: number): string { const s = Math.round(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const mm = String(m).padStart(2, '0'); const ss = String(sec).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`; } // ─── Build display title for a clip pill based on context ──────────────────── function clipPillTitle(meta: ClipMeta | undefined): string { if (!meta) return 'Loading…'; return `${fmtTime(meta.startTime)} — ${meta.episodeTitle}`; } // ─── Inject clip pills into ReactMarkdown output ──────────────────────────── function injectClipCards( node: React.ReactNode, clipsByIndex: Record, metaCache: Map, onCardClick: (pineconeId: string) => void, onCopyLink: (pineconeId: string) => void, activeClipId?: string ): React.ReactNode { const tokenRe = /\[\[CLIP:(\d+)\]\]/g; if (typeof node === 'string') { const out: React.ReactNode[] = []; let last = 0; let m: RegExpExecArray | null; while ((m = tokenRe.exec(node)) !== null) { const [full, idxStr] = m; const clipIdx = Number(idxStr); const start = m.index; if (start > last) out.push(node.slice(last, start)); const pineconeId = clipsByIndex[clipIdx]; if (pineconeId) { const meta = metaCache.get(pineconeId); const card: AnalysisCardJson = { pineconeId, episodeImage: meta?.episodeImage, title: clipPillTitle(meta), // Transcript text powers the "explore in Galaxy" arrow — opens // /app?view=galaxy&q= so the user can see neighbors. quote: meta?.text, }; out.push( ); } else { out.push(full); } last = start + full.length; } if (last < node.length) out.push(node.slice(last)); return out.length === 1 ? out[0] : out; } if (Array.isArray(node)) { return node.map((child) => injectClipCards(child, clipsByIndex, metaCache, onCardClick, onCopyLink, activeClipId) ); } if (React.isValidElement(node)) { const children = (node.props as any)?.children; if (!children) return node; return React.cloneElement(node as any, { ...(node.props as any), children: injectClipCards(children, clipsByIndex, metaCache, onCardClick, onCopyLink, activeClipId), }); } return node; } // ─── Detect paragraphs that contain only a card pill ──────────────────────── function isCardOnlyParagraph(node: React.ReactNode): boolean { const isWhitespace = (x: unknown) => typeof x === 'string' && (x as string).trim() === ''; if (React.isValidElement(node)) { return (node.type as any) === InlineCardMention; } if (Array.isArray(node)) { const filtered = node.filter(n => !isWhitespace(n)); return ( filtered.length === 1 && React.isValidElement(filtered[0]) && (filtered[0] as any).type === InlineCardMention ); } return false; } // ─── Markdown renderer — mirrors UnifiedSidePanel's AI Analysis rendering ─── const MarkdownWithClips: React.FC<{ text: string; clipsByIndex: Record; metaCache: Map; onCardClick: (pineconeId: string) => void; onCopyLink: (pineconeId: string) => void; activeClipId?: string; }> = ({ text, clipsByIndex, metaCache, onCardClick, onCopyLink, activeClipId }) => { const inject = useCallback( (children: React.ReactNode) => injectClipCards(children, clipsByIndex, metaCache, onCardClick, onCopyLink, activeClipId), [clipsByIndex, metaCache, onCardClick, onCopyLink, activeClipId] ); const components = useMemo( () => ({ p: ({ children }: any) => { const injected = inject(children); const isCardOnly = isCardOnlyParagraph(injected); return

{injected}

; }, ul: ({ children }: any) => (
    {children}
), ol: ({ children }: any) => (
    {children}
), li: ({ children }: any) => (
  • {inject(children)}
  • ), blockquote: ({ children }: any) => (
    Quote
    {inject(children)}
    ), strong: ({ children }: any) => {inject(children)}, em: ({ children }: any) => {inject(children)}, a: ({ href, children }: any) => { if (href && /[?&]researchSessionId=/.test(href)) { const titleText = typeof children === 'string' ? children : Array.isArray(children) ? children.filter((c: unknown) => typeof c === 'string').join('') : 'Research Session'; return ( {titleText} Research Session ); } return ( {children} ); }, h1: ({ children }: any) => (

    {inject(children)}

    ), h2: ({ children }: any) => (

    {inject(children)}

    ), h3: ({ children }: any) => (

    {inject(children)}

    ), }), [inject] ); return (
    {text}
    ); }; // ─── Suggested action chips ───────────────────────────────────────────────── const FOUNTAIN_API = 'https://rss-extractor-app-yufbq.ondigitalocean.app/getFountainLink'; type SubmitState = 'idle' | 'submitting' | 'polling' | 'done' | 'error'; const POLL_INTERVAL_MS = 5000; const SubmitOnDemandChip: React.FC<{ action: SubmitOnDemandAction; originalQuery?: string; onFollowUp?: (message: string) => void; }> = ({ action, originalQuery, onFollowUp }) => { const episodeTitle = action.episodeTitle || ''; const episodeImage = action.image; const [fountainUrl, setFountainUrl] = useState(null); const [submitState, setSubmitState] = useState('idle'); const [errorMsg, setErrorMsg] = useState(null); const [jobProgress, setJobProgress] = useState(null); const pollRef = useRef | null>(null); useEffect(() => { if (!action.guid) return; let cancelled = false; fetch(FOUNTAIN_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guid: action.guid }), }) .then(r => r.json()) .then(data => { if (!cancelled && data.success) setFountainUrl(data.fountainLink); }) .catch(() => {}); return () => { cancelled = true; }; }, [action.guid]); useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); const handleTranscribe = async () => { if (!action.guid || !action.feedGuid || !action.feedId) return; setSubmitState('submitting'); setErrorMsg(null); try { const res = await TryJamieService.submitOnDemandRun({ message: `Transcribe: ${episodeTitle || action.guid}`, parameters: {}, episodes: [{ guid: action.guid, feedGuid: action.feedGuid, feedId: Number(action.feedId), }], }); setSubmitState('polling'); setJobProgress('Queued…'); pollRef.current = setInterval(async () => { try { const status = await TryJamieService.getOnDemandJobStatus(res.jobId); const { stats } = status; setJobProgress(`${stats.episodesProcessed}/${stats.totalEpisodes} processed`); if (status.status === 'complete') { if (pollRef.current) clearInterval(pollRef.current); setSubmitState('done'); setJobProgress(null); } else if (status.status === 'failed') { if (pollRef.current) clearInterval(pollRef.current); setSubmitState('error'); setErrorMsg('Transcription failed'); setJobProgress(null); } } catch { if (pollRef.current) clearInterval(pollRef.current); setSubmitState('error'); setErrorMsg('Lost connection to job'); } }, POLL_INTERVAL_MS); } catch (err: any) { setSubmitState('error'); setErrorMsg(err.message || 'Submission failed'); } }; const isBusy = submitState === 'submitting' || submitState === 'polling'; return (
    {episodeImage ? ( {episodeTitle} ) : (
    )}

    Transcribe this episode

    {episodeTitle && (

    {episodeTitle}

    )}

    {action.reason}

    {jobProgress && (

    {jobProgress}

    )} {submitState === 'error' && errorMsg && (

    {errorMsg}

    )}
    {submitState === 'done' ? ( Submitted ) : ( )} {fountainUrl && ( e.stopPropagation()} className="flex items-center gap-1 px-2 py-1 text-[10px] text-gray-500 rounded-md border border-gray-800 hover:text-gray-300 hover:border-gray-700 transition-colors" title="Listen on Fountain.fm" > Preview )}
    {submitState === 'done' && onFollowUp && originalQuery && ( )}
    ); }; const FOLLOW_UP_MAX_CHARS = 60; // Backend may omit `label` or send an empty string. In that case fall back to // a truncated `message` so the chip stays compact and doesn't render a wall of // text. Never use `reason` here — it's an internal explanation string, not // user-facing copy. The full `message` is still what gets POSTed on click. const getFollowUpDisplayText = (action: FollowUpMessageAction): string => { const label = action.label?.trim(); if (label) return label; const msg = action.message?.trim() ?? ''; if (msg.length <= FOLLOW_UP_MAX_CHARS) return msg; return `${msg.slice(0, FOLLOW_UP_MAX_CHARS).trimEnd()}…`; }; const FollowUpChip: React.FC<{ action: FollowUpMessageAction; onSend: (message: string, context?: FollowUpContext) => void; }> = ({ action, onSend }) => { const displayText = getFollowUpDisplayText(action); return ( ); }; const SuggestedActions: React.FC<{ actions: AgentSuggestedAction[]; onFollowUp: (message: string, context?: FollowUpContext) => void; originalQuery?: string; }> = ({ actions, onFollowUp, originalQuery }) => { if (!actions.length) return null; return (
    {actions.map((action, i) => { switch (action.type) { case 'submit-on-demand': return ( ); case 'follow-up-message': return ; default: return null; } })}
    ); }; // ─── Message component ────────────────────────────────────────────────────── interface JamiePullAgentMessageProps { message: ChatMessage; onPlayClip?: (meta: ClipMeta) => void; onFollowUp?: (message: string, context?: FollowUpContext) => void; originalQuery?: string; activeClipId?: string; } export const JamiePullAgentMessage: React.FC = ({ message, onPlayClip, onFollowUp, originalQuery, activeClipId }) => { const { statusMessages, toolCalls, toolResults, suggestedActions, text, donePayload, error, loading } = message; const clipIds = useMemo( () => (text ? extractClipIds(text) : []), [text] ); const metaCache = useClipMetadata(clipIds); const { markdown, clipsByIndex } = useMemo(() => { if (!text) return { markdown: '', clipsByIndex: {} }; return buildMarkdownWithClipPlaceholders(text); }, [text]); const handleCardClick = useCallback( (pineconeId: string) => { if (!onPlayClip) return; const cached = clipMetaCache.get(pineconeId); if (cached) { onPlayClip(cached); return; } void fetchClipMeta(pineconeId).then((meta) => { if (meta) onPlayClip(meta); }); }, [onPlayClip] ); const handleCopyLink = useCallback((pineconeId: string) => { const url = createClipShareUrl(pineconeId); navigator.clipboard.writeText(url).catch(() => {}); }, []); if (message.role === 'user') { return ( // User bubble anchored to the right via justify-end. Capped at 70% // on desktop so the bubble is bounded and the left side gets a // generous gutter — symmetric (mirrored) with the agent bubble's // right gutter. Mobile keeps the more generous 80% so short // questions don't look orphaned in the corner of a narrow screen.

    {message.content}

    ); } return ( // Agent bubble anchored to the left via justify-start. Capped at 70% // on desktop so the bubble is bounded and the right side gets a // generous gutter — symmetric (mirrored) with the user bubble's left // gutter. Mobile uses full width of the padded thread so horizontal // gutters come only from the parent (narrow px-2.5) instead of max-w-[90%] // widening the inset on only one side.
    {(statusMessages.length > 0 || toolCalls.length > 0) && ( )} {text && (
    {message.textPaused && !message.streamComplete && (
    Still working...
    )}
    )} {suggestedActions.length > 0 && onFollowUp && ( )} {donePayload && } {error && (

    {error}

    )} {loading && !toolCalls.length && !statusMessages.length && (
    Thinking...
    )}
    ); };