import { useEffect, useRef, useState } from 'react'; import type { PiggyApprovalDecision, PiggyChatContext, PiggyChatEvent, PiggyMode, PiggyModelOption, PiggyProposedChange, } from '@pig/core'; import { ApiError, get, getSupabase, post } from './api'; /** * Re-exported from @pig/core rather than declared here. The old local copy was * a fourth definition of a shape that already existed in the relay's zod * schema, the Piggy server's own interface and the model prompt — and widening * it for the docked panel meant widening it in all of them or getting a 400 * from whichever hop was missed. */ export type { PiggyChatContext }; export { PiggyContextProvider, usePiggyContext, usePiggyCurrentContext, } from './piggy-context'; export interface PiggyChatTurn { role: 'user' | 'assistant'; content: string; } /** * The event union comes from @pig/core now, and the local copy is gone. * * It was declared here as well, which was survivable while the server only ever * added fields, and stopped being survivable the moment a turn could pause on an * approval: a client that has not been told about `approval_required` folds it * into nothing, the card never appears, and the turn sits open until the agent's * five-minute timeout rejects a change the user was never shown. */ export type { PiggyChatEvent, PiggyMode, PiggyModelOption, PiggyProposedChange }; /** * What Piggy may do without being asked again. * * `read_only` is the default here for the same reason the relay defaults to it: * write tools are something the user turns on, never something a forgotten * field turns on for them. */ export const PIGGY_DEFAULT_MODE: PiggyMode = 'read_only'; export interface PiggyStatus { enabled: boolean; canUse: boolean; /** What a client with no stored preference should open in. */ mode: PiggyMode; /** The deployment's default model, or null when the agent cannot be asked. */ modelId: string | null; } /** * The relay's own cap (`message: z.string().trim().min(1).max(4_000)`). * Named here so the composer stops the user at the same number rather than * letting them write a long question and collecting a 400 for it. */ export const PIGGY_MESSAGE_MAX_LENGTH = 4_000; /** The relay's per-turn history cap. A longer turn is a 400 for the whole send. */ const HISTORY_CONTENT_MAX_LENGTH = 8_000; /** The relay accepts at most twenty prior turns. */ const HISTORY_MAX_TURNS = 20; // ------------------------------------------------------------- transcript /** * One tool round trip, as the transcript remembers it. * * `result` and `durationMs` are not decoration: the server already streams the * tool's payload and the timeline used to throw it away, so "where did that * number come from?" had no answer inside the UI. */ export interface ToolStep { id: string; name: string; arguments: unknown; state: 'running' | 'succeeded' | 'failed'; /** The tool's own payload, verbatim, so the answer can be checked against it. */ result?: unknown; error?: string; /** Wall-clock time the call took, filled in when its result arrives. */ durationMs?: number; /** * `performance.now()` at the `tool_call`. No event on the wire carries a * timestamp, so the only clock available to us is this one. */ startedAt: number; } /** * A write Piggy has proposed and not made, as the transcript holds it. * * The states are deliberately more than "pending or done". `submitting` exists * because the decision travels on a second request while the turn's own stream * stays open, so there is a real interval in which the user has answered and * nothing has happened yet; and `error` sits alongside `pending` rather than * replacing it, because a decision that did not reach the relay leaves the * change exactly as it was — still waiting, still answerable. */ export interface ApprovalStep { change: PiggyProposedChange; state: 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed'; /** What the user answered, once they have. */ decision?: PiggyApprovalDecision; /** Why the decision could not be delivered, or why the write itself failed. */ error?: string; } export interface TranscriptMessage { id: string; role: 'user' | 'assistant'; content: string; reasoning?: string; tools?: ToolStep[]; /** Writes this turn proposed, in the order they were proposed. */ approvals?: ApprovalStep[]; error?: string; pending?: boolean; /** The user pressed stop. The answer is as complete as it will ever be. */ stopped?: boolean; /** The response body closed without a `done` or an `error`. */ truncated?: boolean; /** A user turn the relay never accepted. It is in the transcript but not in the model's. */ failed?: boolean; /** * Epoch milliseconds before which re-sending this turn would be refused * again. Set only by a refusal that told us when it stops refusing — the * hourly rate limit — so that the transcript offers a wait rather than a * button whose one job is to collect the same 429. */ retryableAt?: number; /** From the `meta` event: which model actually answered. */ model?: string; /** From the `meta` event: what Piggy was allowed to do while answering. */ mode?: PiggyMode; inputTokens?: number | null; outputTokens?: number | null; /** Whole micro-cents this turn cost, when the provider reported usage. */ costMicroCents?: number | null; /** * The provider's own word for why the answer stopped. `length` means the * token budget cut it off mid-sentence, which is a different thing from the * connection dying and reads differently to the user. */ finishReason?: string; } /** * Fold one streamed event into the assistant turn. * * Exported because the transcript state model is shared with the components * that render it, and a second copy of this reducer would drift from the event * union it consumes. */ export function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage { if (event.type === 'meta') return { ...message, model: event.model, mode: event.mode }; 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') { const step: ToolStep = { id: event.id, name: event.name, arguments: event.arguments, state: 'running', startedAt: performance.now(), }; return { ...message, tools: [...(message.tools ?? []), step] }; } if (event.type === 'tool_result') { return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', result: event.result, error: event.error, durationMs: Math.round(performance.now() - tool.startedAt), } : tool, ), }; } if (event.type === 'approval_required') { // Parked, not applied. Nothing in the CRM has changed at this point and the // card must not suggest otherwise — the tool is holding its own turn open // waiting for the answer this event asks for. const already = (message.approvals ?? []).some((entry) => entry.change.id === event.change.id); if (already) return message; return { ...message, approvals: [...(message.approvals ?? []), { change: event.change, state: 'pending' }], }; } if (event.type === 'approval_resolved') { return { ...message, approvals: (message.approvals ?? []).map((entry) => entry.change.id === event.changeId ? settleApproval(entry, event) : entry, ), }; } if (event.type === 'done') { return { ...message, pending: false, inputTokens: event.inputTokens, outputTokens: event.outputTokens, costMicroCents: event.costMicroCents, finishReason: event.finishReason, /** * A turn cannot end with a write still waiting: the agent resolves every * pending change before it settles, and one still `pending` here means the * timeout rejected it. Leaving the card mid-flight would keep offering * buttons that no longer answer anything. */ approvals: message.approvals?.map((entry) => entry.state === 'pending' || entry.state === 'submitting' ? { ...entry, state: 'failed', error: 'This change expired before it was answered.' } : entry, ), }; } if (event.type === 'error') return { ...message, pending: false, error: event.message }; return message; } /** * The authoritative outcome of an approval: the agent has now either performed * the write or not, and says which. It is the only thing allowed to move a card * to `applied`, so no failure path can leave the transcript claiming a change * was saved. */ function settleApproval( entry: ApprovalStep, event: Extract, ): ApprovalStep { if (!event.ok) { return { ...entry, state: 'failed', decision: event.decision, error: event.error }; } return { ...entry, state: event.decision === 'apply' ? 'applied' : 'rejected', decision: event.decision, error: undefined, }; } /** * A turn worth offering a re-send for: one that ended without an answer * through no choice of the user's. A stopped turn is excluded deliberately — * the user asked for it to end. * * So is a turn the hourly limit refused, until the hour it named has passed. * Retry sends the identical request to the identical limiter, so before then * the button cannot do the one thing it offers; the wait is in the turn's error * sentence instead, and `usePiggyConversation` re-renders when it elapses so * the button comes back the moment it means something. */ export function isRetryable(message: TranscriptMessage): boolean { if (message.role !== 'assistant') return false; if (!message.error && !message.truncated) return false; // The clock is read here rather than taken as a defaulted second parameter: // `messages.filter(isRetryable)` would then hand it the array index, which // typechecks and quietly answers the wrong question. return message.retryableAt === undefined || Date.now() >= message.retryableAt; } /** * The turns worth replaying to the model. * * Two exclusions, both load-bearing rather than tidiness. A `failed` user turn * is one the relay refused, so the model has never seen it; replaying it asks * for an answer to a question the user has since retried, and after the retry * it would be in there twice. An `error`ed assistant turn is dropped because * anything it holds is a fragment the model never finished, and its visible * text is our own error copy — which it would read back as its own words. */ export function toChatHistory(messages: TranscriptMessage[]): PiggyChatTurn[] { return messages .filter((entry) => !entry.failed && !entry.error && entry.content.trim()) .slice(-HISTORY_MAX_TURNS) .map((entry) => ({ role: entry.role, content: entry.content.slice(0, HISTORY_CONTENT_MAX_LENGTH) })); } // ---------------------------------------------------------------- refusals /** The relay's code for a spent hourly quota, as `apps/api` writes it. */ const PIGGY_RATE_LIMITED = 'piggy_rate_limited'; /** * How long to sit out a 429 that arrived without a retry-after. * * Only an intermediary that dropped both the header and the body can produce * one, so this is a guess — kept short, because a wait invented here that is * longer than the real one strands a user who could have asked again. */ const UNKNOWN_WAIT_SECONDS = 60; /** * A refusal that carries when it stops being a refusal. * * `ApiError` is shared with the whole REST client and has nowhere to put the * relay's `retryAfterSeconds`, so the transcript used to see a rate limit as an * ordinary failed turn — indistinguishable from a dropped connection, and * offered the same Retry button, which spent the user's next request on the * identical refusal. */ export class PiggyRateLimitError extends ApiError { constructor( message: string, readonly retryAfterSeconds: number | null, ) { super(message, 429, PIGGY_RATE_LIMITED); this.name = 'PiggyRateLimitError'; } } interface Refusal { message: string; /** Epoch ms, when the failure named a time before which a retry is pointless. */ retryableAt?: number; } function describeFailure(error: unknown): Refusal { if (error instanceof PiggyRateLimitError) { const clearsAt = rateLimitClearsAt(error.retryAfterSeconds); return { message: rateLimitMessage(clearsAt, error.message), retryableAt: clearsAt.getTime() }; } return { message: error instanceof Error ? error.message : 'Piggy chat failed.' }; } /** * One moment, used for both the sentence and the return of the Retry button, so * that the two cannot disagree. * * Rounded up to the whole minute because the real window almost always ends * part-way through one: naming the minute it ends in would invite a retry a few * seconds early, and the limiter would refuse that too. */ function rateLimitClearsAt(retryAfterSeconds: number | null): Date { const seconds = retryAfterSeconds ?? UNKNOWN_WAIT_SECONDS; return new Date(Date.now() + Math.ceil(seconds / 60) * 60_000); } /** * A wait still worth reading ten minutes later. * * "Try again in twelve minutes" is written once and then goes quietly wrong as * it sits in the transcript, which is the same defect as a duration that reads * "0.0s": a number that stopped being a measurement. A clock time does not * drift, and the user's question is left on screen above it, so the sentence * says what will happen to it rather than only what went wrong. * * WHY the wait happened is the server's to say, not this function's. A 429 * reaching this client has two quite different causes — PIG's own per-person * hourly quota, and Prime Inference throttling the deployment upstream — and * this hardcoded the first one for both. In a deployment where the upstream is * the common case, that is the product telling a GTM lead they have exhausted * an allowance they have barely touched, and discarding a far better sentence * the relay had already written ("Prime Inference is rate limiting us, so this * question was never answered… none of this was charged to you"). So the * server's reason is quoted and only the deadline is composed here. */ function rateLimitMessage(clearsAt: Date, serverReason: string): string { const time = clearsAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const reason = serverReason.trim() || 'Piggy is rate limited right now.'; // A relayed sentence may or may not be punctuated; two full stops read as a // typo and none reads as a run-on. const stopped = /[.!?]$/.test(reason) ? reason : `${reason}.`; return `${stopped} Retry will work again at ${time}.`; } /** Both the body field and the header are integers of seconds, and both may be absent. */ function readRetryAfter(value: unknown): number | null { // `Number('')` is zero, which would print a limit that clears immediately. if (typeof value === 'string' && !value.trim()) return null; const seconds = typeof value === 'string' ? Number(value) : value; return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? seconds : null; } // ------------------------------------------------------------ conversation export interface PiggyConversation { messages: TranscriptMessage[]; draft: string; setDraft: (value: string) => void; running: boolean; /** * How far Piggy may act, and in which model. Owned here rather than by the * controls that set them, because they belong to the conversation: the panel * that draws the pickers is unmounted every time the sheet closes, and a mode * that reset itself to read-only behind a closed overlay would be a silent * change to what the next question is allowed to do. */ mode: PiggyMode; setMode: (mode: PiggyMode) => void; /** Undefined means "whatever the deployment's default is" — never a guess. */ modelId: string | undefined; setModelId: (modelId: string | undefined) => void; /** Assigned by the relay on the first turn; needed to answer an approval. */ conversationId: string | undefined; /** * Answer a proposed write. Resolves once the decision has been delivered, not * once the write has happened — the outcome arrives on the open stream as an * `approval_resolved` event, which is the only thing that marks a card applied. */ approve: (changeId: string, decision: PiggyApprovalDecision) => void; /** * Send `text`, or the composer draft when it is omitted — a suggestion chip * and the retry button both have something to say and no reason to make the * user press send afterwards. * * `from` is the transcript the history is built out of. Only `retry` passes * it, with the exchange being replaced already removed, because that * exchange is superseded rather than continued. */ send: (text?: string, from?: TranscriptMessage[]) => void; stop: () => void; /** Re-ask the question that produced this failed or truncated answer. */ retry: (assistantId: string) => void; } /** * The whole client side of a Piggy conversation, deliberately separable from * the panel that renders it. * * It lives outside the panel because the panel is destroyed and rebuilt more * often than the conversation should be: the sheet and the drawer unmount * their children on close, and a thread that evaporates because the user * dismissed an overlay to look at the record behind it is the single most * expensive thing this UI can do. Whoever stays mounted owns the hook and * passes the result down. */ export function usePiggyConversation({ context, initialPrompt = '', initialMode = PIGGY_DEFAULT_MODE, initialModelId, initialMessages, initialConversationId, }: { context?: PiggyChatContext; initialPrompt?: string; /** Seeds only. The conversation owns both afterwards; see `PiggyConversation`. */ initialMode?: PiggyMode; initialModelId?: string; /** * A transcript this conversation is resuming, read back from * `GET /api/piggy/conversations/:id`. * * Adopted until the first send of this session, not only at mount. It is * almost never available at mount: the workspace puts the thread on screen * as soon as the id is in the URL, and `GET /api/piggy/conversations/:id` is * a round trip behind it — so a state initialiser captured `[]` every time * and threw the stored transcript away as it arrived. The relay also builds * a turn's prompt from the `history` the client sends, so an unseeded hook * carried on a reopened thread having forgotten every word of it. */ initialMessages?: TranscriptMessage[]; /** * The stored conversation this thread continues, when it is not a new one. * * Sent with the first turn so the relay carries on the same conversation * rather than minting a second id for a thread the sidebar already lists, * and so an approval posted before any `meta` event has an id to travel with. */ initialConversationId?: string; } = {}): PiggyConversation { const [messages, setMessages] = useState(initialMessages ?? []); const [draft, setDraft] = useState(initialPrompt); const [running, setRunning] = useState(false); const [mode, setMode] = useState(initialMode); const [modelId, setModelId] = useState(initialModelId); const [conversationId, setConversationId] = useState(initialConversationId); /** * The id `approve` posts with. * * A ref as well as state because an approval can be answered in the same * frame the `meta` event arrived in — the card is drawn from a `setMessages` * that React may commit before it commits `setConversationId`, and posting an * approval with no conversation is a 400 the user reads as Piggy losing their * change. */ const conversationRef = useRef(initialConversationId); /** * The gate `send` actually reads, because `running` cannot close in time. * * A state flag only takes effect once React has re-rendered, so two Send * presses inside one frame — a double click, a held Enter key, a chip pressed * twice — both saw `running: false` and both opened a stream. Measured: three * clicks dispatched together produced three relay calls, three questions in * the transcript and three answers interleaving into it. That is three of the * user's thirty hourly messages spent at once, and only the last stream is * still reachable by Stop, since each one overwrites `abortRef`. A ref is * written synchronously, so the second press is refused by the first. */ const runningRef = useRef(false); /** * Raised by the first send of this session, and never lowered. * * It is what makes the late seed below safe. Once this conversation has said * anything, what is on screen is ahead of anything the store can hand back, * and adopting a fetch would delete the turn being read. */ const touched = useRef(false); const abortRef = useRef(null); // Bumped only to re-read the clock. `isRetryable` withholds the Retry button // while a rate limit holds, and nothing else in a transcript nobody is typing // into would ever re-render to bring it back. const [retryClock, setRetryClock] = useState(0); useEffect(() => () => abortRef.current?.abort(), []); /** * Put the stored transcript on screen when it lands. * * The store keeps every question, tool call, proposed change and answer, and * the client used to discard all of it: ~50 rows in the history rail opened * on a blank pane that said, in writing, that nothing was stored. This is the * half of that loop that was missing. * * Compared by identity rather than adopted outright because callers rebuild * the array on every render — `toTranscript(detail.data.messages)` is a fresh * object each time — and replacing state with an equal value would re-render * the whole transcript for nothing. */ useEffect(() => { if (touched.current || !initialMessages) return; setMessages((current) => (sameTurns(current, initialMessages) ? current : initialMessages)); }, [initialMessages]); useEffect(() => { const now = Date.now(); const waits = messages .map((entry) => entry.retryableAt) .filter((at): at is number => at !== undefined && at > now); if (!waits.length) return; // One timer for the soonest wait; the effect re-runs when it fires and arms // the next, so a transcript with several refusals still costs one timeout. const timer = setTimeout(() => setRetryClock(Date.now()), Math.min(...waits) - now); return () => clearTimeout(timer); }, [messages, retryClock]); const updateTurn = (id: string, change: (turn: TranscriptMessage) => TranscriptMessage) => setMessages((current) => current.map((entry) => (entry.id === id ? change(entry) : entry))); const send = async (text?: string, from?: TranscriptMessage[]) => { const message = (text ?? draft).trim(); if (!message || runningRef.current) return; // Claimed before the first await, so nothing else can enter this turn. runningRef.current = true; touched.current = true; const userId = crypto.randomUUID(); const assistantId = crypto.randomUUID(); const history = toChatHistory(from ?? messages); setMessages((current) => [ ...current, { id: userId, role: 'user', content: message }, { id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true }, ]); // Only the composer's own text is cleared. A suggestion or a retry has not // touched what the user was typing and must not throw it away. if (text === undefined) setDraft(''); setRunning(true); const abort = new AbortController(); abortRef.current = abort; // Tracked here rather than read back out of state: `messages` is a stale // closure by the time the stream finishes, and the question we need to // answer — did anything terminate this turn? — is about the events, not // about what React has committed. let settled = false; /** * Whether the relay ever started answering. * * The question below is marked "Not sent" only when this is false. A * connection that dies half-way through has still spent the turn — the * tokens are gone, the tools have run, and part of the answer is on screen * — so telling the user their question never left is both wrong and the * thing that makes them ask it again. */ let accepted = false; try { const request = { message, history, context, mode, modelId, // Sent from the second turn on, so the agent can keep one thread rather // than starting a fresh one under every question. conversationId: conversationRef.current, }; for await (const event of streamPiggyChat(request, abort.signal)) { accepted = true; if (event.type === 'meta' && event.conversationId !== conversationRef.current) { conversationRef.current = event.conversationId; setConversationId(event.conversationId); } if (event.type === 'done' || event.type === 'error') settled = true; updateTurn(assistantId, (turn) => applyEvent(turn, event)); } if (!settled) { // The body closed mid-answer. `readNdjson` returns normally when that // happens, so without this the turn stays `pending` forever and a dead // connection is indistinguishable from Piggy still thinking. updateTurn(assistantId, (turn) => strandApprovals({ ...turn, pending: false, truncated: true }), ); } } catch (error) { if (abort.signal.aborted) { // Aborting rejects the read, so neither `done` nor `error` ever // arrives and nothing else will clear `pending` — which left the // docked panel spinning across every subsequent navigation. updateTurn(assistantId, (turn) => strandApprovals({ ...turn, pending: false, stopped: true }), ); } else { const failure = describeFailure(error); setMessages((current) => current.map((entry) => { if (entry.id === assistantId) { return strandApprovals({ ...entry, pending: false, error: failure.message, retryableAt: failure.retryableAt, }); } // The question is marked, not deleted: the user's words stay on // screen to be re-sent, and `toChatHistory` knows to keep a turn // the relay refused out of the model's history. Only a turn the // relay never began answering is marked — see `accepted`. if (entry.id === userId && !accepted) return { ...entry, failed: true }; return entry; }), ); } } finally { abortRef.current = null; runningRef.current = false; setRunning(false); } }; const retry = (assistantId: string) => { if (runningRef.current) return; const index = messages.findIndex((entry) => entry.id === assistantId); const question = index > 0 ? messages[index - 1] : undefined; if (!question || question.role !== 'user') return; // Drop the failed exchange rather than leaving it above the new one: the // same question twice in the transcript reads as Piggy having been asked // twice, and a partial answer left in place would be replayed as history // for the very question it failed to answer. setMessages((current) => current.filter((entry) => entry.id !== question.id && entry.id !== assistantId)); void send(question.content, messages.slice(0, index - 1)); }; /** * Answer a proposed write. * * Optimistic only as far as honesty allows: the card moves to `submitting` so * the buttons stop inviting a second press, and no further. Only the * `approval_resolved` event that comes back on the open stream can say the * change was applied, because only the agent knows whether `executeMutation` * accepted it. A POST that fails puts the card back where it was, with the * reason attached — the change really is still pending at the agent, so * offering the buttons again is the truth rather than a courtesy. */ const approve = async (changeId: string, decision: PiggyApprovalDecision) => { const conversation = conversationRef.current; // Only the turn actually holding the card is rewritten. Mapping every // message would give the whole transcript new identities and re-render a // long conversation on each button press. const settleCard = (change: (entry: ApprovalStep) => ApprovalStep) => setMessages((current) => current.map((entry) => entry.approvals?.some((approval) => approval.change.id === changeId) ? { ...entry, approvals: entry.approvals.map((approval) => approval.change.id === changeId ? change(approval) : approval, ), } : entry, ), ); if (!conversation) { settleCard((entry) => ({ ...entry, error: 'Piggy has not identified this conversation yet.', })); return; } settleCard((entry) => ({ ...entry, state: 'submitting', decision, error: undefined })); try { await post<{ ok: boolean }>('/api/piggy/approve', { conversationId: conversation, changeId, decision, }); } catch (error) { /* * A 404 is not a delivery failure, it is the change being over. * * The relay answers `approval_not_pending` when the agent no longer holds * the card: the five-minute deadline rejected it, or the turn was * abandoned. Putting the card back to `pending` there — which is what * this did for every failure alike — leaves it reading "Needs you" with a * live Apply button over a decision that can never be delivered, so the * user presses it and gets the same 404 for ever. Every OTHER failure * really does leave the change pending at the agent, and for those * offering the buttons again is the truth rather than a courtesy. */ const settled = error instanceof ApiError && error.code === 'approval_not_pending'; settleCard((entry) => ({ ...entry, state: settled ? 'failed' : 'pending', decision: undefined, error: error instanceof Error ? error.message : 'That decision did not reach Piggy.', })); } }; return { messages, draft, setDraft, running, mode, setMode, modelId, setModelId, conversationId, approve: (changeId, decision) => void approve(changeId, decision), send: (text, fromTranscript) => void send(text, fromTranscript), stop: () => abortRef.current?.abort(), retry, }; } /** * A turn that ended without the agent's word on its pending writes. * * Stop, a dropped connection and a refused request all leave the stream that * `approval_resolved` would have arrived on closed for good. The change may * genuinely still be waiting at the agent until its five-minute timeout, but * nothing this client does can answer it any more, so the card says so instead * of showing buttons that post into a conversation nobody is reading. */ function strandApprovals(message: TranscriptMessage): TranscriptMessage { if (!message.approvals?.length) return message; return { ...message, approvals: message.approvals.map((entry) => entry.state === 'pending' || entry.state === 'submitting' ? { ...entry, state: 'failed', error: 'This turn ended before the change was answered.' } : entry, ), }; } /** * The models this deployment offers, for the picker. * * Served by the relay from the agent's own catalogue rather than a list kept * here, because the relay refuses any model id that is not in it — a hard-coded * option that has been retired upstream would be a menu entry whose only effect * is a 400. */ export function fetchPiggyModels(): Promise<{ models: PiggyModelOption[]; defaultModelId: string | null; }> { return get('/api/piggy/models'); } export interface PiggyChatRequest { message: string; history?: PiggyChatTurn[]; context?: PiggyChatContext; /** Omitted, the relay reads it as `read_only`. Sent explicitly all the same. */ mode?: PiggyMode; /** Must be one the relay's catalogue lists, or the turn is a 400. */ modelId?: string; conversationId?: string; } export async function* streamPiggyChat( request: PiggyChatRequest, signal?: AbortSignal, ): AsyncGenerator { const supabase = getSupabase(); const token = supabase ? (await supabase.auth.getSession()).data.session?.access_token : null; const response = await fetch('/api/piggy/chat', { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(request), signal, }); if (!response.ok) { let message = response.statusText; let code: string | undefined; let retryAfterSeconds: number | null = null; try { const body = (await response.json()) as { error?: string; code?: string; retryAfterSeconds?: unknown; }; message = body.error ?? message; code = body.code; retryAfterSeconds = readRetryAfter(body.retryAfterSeconds); } catch { // The authenticated proxy normally returns JSON, but an upstream proxy may not. } if (response.status === 429) { // The header is read as the fallback rather than the body's field alone: // an intermediary of its own may rate-limit us with a bare `Retry-After` // and no JSON at all, and a wait we cannot name is one the user is told // to guess at. throw new PiggyRateLimitError( message, retryAfterSeconds ?? readRetryAfter(response.headers.get('retry-after')), ); } throw new ApiError(message, response.status, code); } if (!response.body) throw new Error('Piggy returned no response stream.'); yield* readNdjson(response.body, signal); } export async function* readNdjson( stream: ReadableStream, signal?: AbortSignal, ): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { if (signal?.aborted) throw signal.reason; const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const event = parseNdjsonLine(line); if (event !== undefined) yield event; } if (done) { const event = parseNdjsonLine(buffer); if (event !== undefined) yield event; return; } } } finally { reader.releaseLock(); } } /** * One NDJSON record, or nothing at all. * * A body cut mid-line leaves a fragment behind, and parsing it threw out of the * generator: the transcript then showed `Unexpected end of JSON input` where * Piggy's answer had been — the partial text already streamed was replaced by * that sentence, and the question above it was labelled "Not sent" after the * tokens had been spent. A fragment is not an event. Dropping it lets `send` * see the stream end without a `done`, which is exactly what happened, and the * turn is marked truncated with everything that did arrive still on screen. */ function parseNdjsonLine(line: string): Value | undefined { if (!line.trim()) return undefined; try { return JSON.parse(line) as Value; } catch { return undefined; } } /** * Whether two transcripts are the same turns in the same order. * * Ids are enough: a stored row's id is its primary key and a live turn's is a * UUID minted once, so two arrays agreeing on every id are two readings of one * conversation. */ function sameTurns(a: TranscriptMessage[], b: TranscriptMessage[]): boolean { return a.length === b.length && a.every((entry, index) => entry.id === b[index]?.id); }