This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CircleStop,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
Send,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { get } from '@/lib/api';
|
||||
import {
|
||||
streamPiggyChat,
|
||||
type PiggyChatContext,
|
||||
type PiggyChatEvent,
|
||||
type PiggyChatTurn,
|
||||
type PiggyStatus,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { Badge, Button, EmptyState, cn } from './ui';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from './ui/drawer';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from './ui/sheet';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TranscriptMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
tools?: ToolStep[];
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export function PiggyAskButton({
|
||||
context,
|
||||
prompt,
|
||||
label = 'Ask Piggy',
|
||||
variant = 'outline',
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
prompt?: string;
|
||||
label?: string;
|
||||
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = usePiggyStatus();
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
disabled={Boolean(unavailable)}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MessageCircleMore aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<ResponsivePiggyChat
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
context={context}
|
||||
initialPrompt={prompt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PiggyChatWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
if (status.isLoading) return <div className="h-96 animate-pulse rounded-xl bg-surface-2" />;
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bot />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable Piggy and connect the internal service.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
|
||||
}
|
||||
|
||||
function ResponsivePiggyChat({
|
||||
open,
|
||||
onOpenChange,
|
||||
context,
|
||||
initialPrompt,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
}) {
|
||||
const desktop = useDesktop();
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-border px-5 py-4">
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[92dvh]">
|
||||
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function PiggyChatPanel({
|
||||
context,
|
||||
initialPrompt = '',
|
||||
className,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const [running, setRunning] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: running ? 'auto' : 'smooth' }), [messages, running]);
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || running) return;
|
||||
const user: TranscriptMessage = { id: crypto.randomUUID(), role: 'user', content: message };
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history: PiggyChatTurn[] = messages
|
||||
.filter((entry) => entry.content.trim())
|
||||
.slice(-20)
|
||||
.map((entry) => ({ role: entry.role, content: entry.content }));
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
user,
|
||||
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
|
||||
]);
|
||||
setDraft('');
|
||||
setRunning(true);
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId ? applyEvent(entry, event) : entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId
|
||||
? { ...entry, pending: false, error: error instanceof Error ? error.message : 'Piggy chat failed.' }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
|
||||
).map((suggestion) => (
|
||||
<button key={suggestion} type="button" className="min-h-11 rounded-lg border border-border px-3 text-left text-sm hover:bg-surface-2" onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="border-t border-border bg-surface p-3 sm:p-4" onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {context.label ?? context.type.replaceAll('_', ' ')}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 max-h-36 resize-none"
|
||||
placeholder="Ask about capacity, margin, paper or next actions…"
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={() => abortRef.current?.abort()}><CircleStop aria-hidden /></Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] text-muted">Check source records before acting on material terms.</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-accent px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg"><Bot aria-hidden /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.reasoning ? (
|
||||
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2 font-medium"><Brain aria-hidden /> Reasoning</summary>
|
||||
<p className="whitespace-pre-wrap px-3 pb-3">{message.reasoning}</p>
|
||||
</details>
|
||||
) : null}
|
||||
{message.tools?.length ? <ToolTimeline tools={message.tools} /> : null}
|
||||
{message.content ? <p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p> : null}
|
||||
{message.pending && !message.content ? <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div> : null}
|
||||
{message.error ? <div className="mt-2 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolTimeline({ tools }: { tools: ToolStep[] }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{tools.map((tool) => (
|
||||
<details key={tool.id} className="rounded-lg border border-border text-xs">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2">
|
||||
{tool.state === 'running' ? <Loader2 className="animate-spin text-muted" aria-hidden /> : tool.state === 'succeeded' ? <CheckCircle2 className="text-positive" aria-hidden /> : <XCircle className="text-danger" aria-hidden />}
|
||||
<span className="font-medium">{toolLabel(tool.name)}</span>
|
||||
<span className="ml-auto text-muted">{tool.state === 'running' ? 'Running' : tool.state === 'succeeded' ? 'Complete' : 'Failed'}</span>
|
||||
</summary>
|
||||
<pre className="overflow-x-auto border-t border-border p-3 text-[11px] text-muted">{tool.error ?? JSON.stringify(tool.arguments, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
|
||||
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
|
||||
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
|
||||
if (event.type === 'tool_call') return { ...message, tools: [...(message.tools ?? []), { id: event.id, name: event.name, arguments: event.arguments, state: 'running' }] };
|
||||
if (event.type === 'tool_result') return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', error: event.error } : tool) };
|
||||
if (event.type === 'done') return { ...message, pending: false };
|
||||
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
||||
return message;
|
||||
}
|
||||
|
||||
function usePiggyStatus() {
|
||||
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
|
||||
}
|
||||
|
||||
function useDesktop(): boolean {
|
||||
const [desktop, setDesktop] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(min-width: 768px)');
|
||||
const update = () => setDesktop(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
return desktop;
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user