mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
1044 lines
44 KiB
TypeScript
1044 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Check, Copy } from "lucide-react";
|
|
import type { AssistantEvent, Citation, EditAnnotation } from "../shared/types";
|
|
import { EditCard } from "./EditCard";
|
|
import { PreResponseWrapper } from "./PreResponseWrapper";
|
|
import { ResponseStatus, type StatusState } from "./message/ResponseStatus";
|
|
import { eventErrorMessage, toolCallLabel } from "./message/eventUtils";
|
|
import { preprocessCitations, internalCaseHref } from "./message/citationUtils";
|
|
import { useSmoothedReveal } from "./message/useSmoothedReveal";
|
|
import { MarkdownContent } from "./message/MarkdownContent";
|
|
import { CitationsBlock, buildCitationAppendix } from "./message/CitationSources";
|
|
import { EditCardsSection } from "./message/EditCardsSection";
|
|
import {
|
|
AskInputsBlock,
|
|
CourtListenerBlock,
|
|
DocCreatedBlock,
|
|
DocDownloadBlock,
|
|
DocEditedBlock,
|
|
DocFindBlock,
|
|
DocReadBlock,
|
|
DocReplicatedBlock,
|
|
EventBlock,
|
|
ReasoningBlock,
|
|
WorkflowAppliedBlock,
|
|
type CourtListenerBlockItem,
|
|
} from "./message/EventBlocks";
|
|
|
|
interface Props {
|
|
events?: AssistantEvent[];
|
|
isStreaming?: boolean;
|
|
isError?: boolean;
|
|
/** Human-readable error text rendered alongside the red Mike icon. */
|
|
errorMessage?: string;
|
|
citations?: Citation[];
|
|
citationStatus?: "started" | "partial" | "final";
|
|
onCitationClick?: (citation: Citation) => void;
|
|
onOpenCitationSource?: (citation: Citation) => void;
|
|
onCaseClick?: (
|
|
citation: Extract<AssistantEvent, { type: "case_citation" }>,
|
|
) => void;
|
|
minHeight?: string;
|
|
onWorkflowClick?: (workflowId: string) => void;
|
|
onEditViewClick?: (
|
|
ann: EditAnnotation,
|
|
filename: string,
|
|
changeNumber?: number,
|
|
) => void;
|
|
/**
|
|
* Opens the editor panel for a document without auto-highlighting any
|
|
* specific edit. Used by the download card click — opening a doc to
|
|
* read/download shouldn't jump the viewer to the first edit.
|
|
*/
|
|
onOpenDocument?: (args: {
|
|
documentId: string;
|
|
filename: string;
|
|
versionId: string | null;
|
|
versionNumber: number | null;
|
|
}) => void;
|
|
/**
|
|
* Fires immediately when the user clicks Accept / Reject (single card
|
|
* or the bulk "Accept all" / "Reject all"), before the backend call.
|
|
* Parents use this to flip download cards / editor viewers into a
|
|
* "saving" state for the duration of the round-trip.
|
|
*/
|
|
onEditResolveStart?: (args: {
|
|
editId: string;
|
|
documentId: string;
|
|
verb: "accept" | "reject";
|
|
}) => void;
|
|
onEditResolved?: (args: {
|
|
editId: string;
|
|
documentId: string;
|
|
status: "accepted" | "rejected";
|
|
versionId: string | null;
|
|
downloadUrl: string | null;
|
|
}) => void;
|
|
onEditError?: (args: {
|
|
editId: string;
|
|
documentId: string;
|
|
versionId: string | null;
|
|
message: string;
|
|
}) => void;
|
|
isDocReloading?: (documentId: string) => boolean;
|
|
/**
|
|
* True while an accept/reject request for this specific edit is in
|
|
* flight. Used to disable just that edit's Accept/Reject controls
|
|
* (sibling edits on the same doc stay clickable).
|
|
*/
|
|
isEditReloading?: (editId: string) => boolean;
|
|
/**
|
|
* External override for individual edit statuses. When present, an
|
|
* EditCard looks up its edit_id here and treats the mapped value
|
|
* ("accepted" / "rejected") as authoritative — used so bulk-resolved
|
|
* edits flip their per-card UI without per-card clicks.
|
|
*/
|
|
resolvedEditStatuses?: Record<string, "accepted" | "rejected">;
|
|
}
|
|
|
|
export function AssistantMessage({
|
|
events,
|
|
isStreaming = false,
|
|
isError = false,
|
|
errorMessage,
|
|
citations = [],
|
|
citationStatus,
|
|
onCitationClick,
|
|
onOpenCitationSource,
|
|
onCaseClick,
|
|
minHeight = "0px",
|
|
onWorkflowClick,
|
|
onEditViewClick,
|
|
onOpenDocument,
|
|
onEditResolveStart,
|
|
onEditResolved,
|
|
onEditError,
|
|
isDocReloading,
|
|
isEditReloading,
|
|
resolvedEditStatuses,
|
|
}: Props) {
|
|
const contentDivRef = useRef<HTMLDivElement | null>(null);
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
// Per-document override of the download URL, set as Accept/Reject resolves
|
|
// each tracked change and produces a new version.
|
|
const [resolvedOverrides, setResolvedOverrides] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
|
|
const handleEditResolved = (args: {
|
|
editId: string;
|
|
documentId: string;
|
|
status: "accepted" | "rejected";
|
|
versionId: string | null;
|
|
downloadUrl: string | null;
|
|
}) => {
|
|
if (args.downloadUrl) {
|
|
setResolvedOverrides((prev) => ({
|
|
...prev,
|
|
[args.documentId]: args.downloadUrl as string,
|
|
}));
|
|
}
|
|
onEditResolved?.(args);
|
|
};
|
|
|
|
const eventErrorMessages = (events ?? [])
|
|
.map(eventErrorMessage)
|
|
.filter((message): message is string => !!message);
|
|
const topLevelErrorMessage =
|
|
errorMessage ??
|
|
(
|
|
(events ?? []).find((event) => event.type === "error") as
|
|
| Extract<AssistantEvent, { type: "error" }>
|
|
| undefined
|
|
)?.message ??
|
|
null;
|
|
const effectiveErrorMessage =
|
|
topLevelErrorMessage ?? eventErrorMessages[0] ?? null;
|
|
const hasError = isError || !!effectiveErrorMessage;
|
|
const status: StatusState = hasError
|
|
? "error"
|
|
: isStreaming
|
|
? "active"
|
|
: null;
|
|
|
|
const isRenderableEvent = (event: AssistantEvent) =>
|
|
event.type !== "error" &&
|
|
event.type !== "ask_inputs_response" &&
|
|
event.type !== "case_citation" &&
|
|
event.type !== "case_opinions";
|
|
|
|
// Find the last content event so its raw text can be smoothed before
|
|
// citation preprocessing — slicing already-preprocessed text would risk
|
|
// chopping a `§N§` citation token in half.
|
|
const lastContentIdx = events
|
|
? events.reduce(
|
|
(last, e, idx) => (e.type === "content" ? idx : last),
|
|
-1,
|
|
)
|
|
: -1;
|
|
const lastContentEvent =
|
|
events && lastContentIdx >= 0
|
|
? (events[lastContentIdx] as Extract<
|
|
AssistantEvent,
|
|
{ type: "content" }
|
|
>)
|
|
: null;
|
|
// Only smooth while the content event is still the visible tail. The
|
|
// moment the model emits a follow-up (tool call, reasoning, another
|
|
// content block), that content's text is frozen on the server — keeping
|
|
// it half-revealed below would make a tool-call wrapper appear under
|
|
// prose that still looks like it's typing.
|
|
const lastRenderableIdx = events
|
|
? events.reduce(
|
|
(last, e, idx) => (isRenderableEvent(e) ? idx : last),
|
|
-1,
|
|
)
|
|
: -1;
|
|
const contentIsTail =
|
|
lastContentEvent !== null && lastContentIdx === lastRenderableIdx;
|
|
const smoothedLastText = useSmoothedReveal(
|
|
lastContentEvent?.text ?? "",
|
|
isStreaming && contentIsTail,
|
|
);
|
|
|
|
// Pre-process citations for all content events. Each [N] marker resolves
|
|
// to exactly one citation (models are instructed to use shared refs
|
|
// only for cross-page continuations via the [[PAGE_BREAK]] sentinel).
|
|
const inlineCitationTargets: Citation[] = [];
|
|
const caseCitations = new Map<
|
|
string,
|
|
Extract<AssistantEvent, { type: "case_citation" }>
|
|
>();
|
|
const caseOpinions = new Map<
|
|
number,
|
|
Extract<AssistantEvent, { type: "case_opinions" }>["case"]
|
|
>();
|
|
const processedTexts: string[] = [];
|
|
if (events) {
|
|
for (let i = 0; i < events.length; i++) {
|
|
const event = events[i];
|
|
if (event.type === "case_citation") {
|
|
const hrefKey = internalCaseHref(event.cluster_id);
|
|
if (hrefKey) caseCitations.set(hrefKey, event);
|
|
} else if (event.type === "case_opinions") {
|
|
caseOpinions.set(event.cluster_id, event.case);
|
|
}
|
|
processedTexts.push(
|
|
event.type === "content"
|
|
? preprocessCitations(
|
|
i === lastContentIdx ? smoothedLastText : event.text,
|
|
citations,
|
|
inlineCitationTargets,
|
|
)
|
|
: "",
|
|
);
|
|
}
|
|
}
|
|
const handleOpenCitationSource = (citation: Citation) => {
|
|
if (onOpenCitationSource) {
|
|
onOpenCitationSource(citation);
|
|
return;
|
|
}
|
|
if (citation.kind === "case" || !onOpenDocument) return;
|
|
onOpenDocument({
|
|
documentId: citation.document_id,
|
|
filename: citation.filename,
|
|
versionId: citation.version_id ?? null,
|
|
versionNumber: citation.version_number ?? null,
|
|
});
|
|
};
|
|
const canOpenCitationSource = (citation: Citation) =>
|
|
!!onOpenCitationSource ||
|
|
(citation.kind !== "case" && !!onOpenDocument);
|
|
const showCitationBlock =
|
|
!!citationStatus || (!isStreaming && citations.length > 0);
|
|
const handleCopy = async () => {
|
|
try {
|
|
let html = "";
|
|
let plainText = "";
|
|
if (contentDivRef.current) {
|
|
const clone = contentDivRef.current.cloneNode(
|
|
true,
|
|
) as HTMLElement;
|
|
clone.querySelectorAll("[data-citation-ref]").forEach((el) => {
|
|
const ref = el.getAttribute("data-citation-ref");
|
|
if (!ref) return;
|
|
const sup = document.createElement("sup");
|
|
sup.textContent = ref;
|
|
el.replaceWith(sup);
|
|
});
|
|
html = clone.innerHTML;
|
|
plainText = clone.textContent || "";
|
|
}
|
|
const appendix = buildCitationAppendix(citations);
|
|
html += appendix.html;
|
|
plainText += appendix.text;
|
|
const item = new ClipboardItem({
|
|
"text/html": new Blob([html], { type: "text/html" }),
|
|
"text/plain": new Blob([plainText], { type: "text/plain" }),
|
|
});
|
|
await navigator.clipboard.write([item]);
|
|
setIsCopied(true);
|
|
setTimeout(() => setIsCopied(false), 2000);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
// Walk events in chronological order and group consecutive non-content
|
|
// events into their own PreResponseWrapper. Content events render
|
|
// between wrappers, so reasoning/tool chatter that arrives after the
|
|
// model has already streamed some prose gets its own wrapper.
|
|
type EventGroup =
|
|
| { kind: "pre"; events: AssistantEvent[]; indices: number[] }
|
|
| {
|
|
kind: "content";
|
|
event: Extract<AssistantEvent, { type: "content" }>;
|
|
index: number;
|
|
};
|
|
|
|
const groups: EventGroup[] = [];
|
|
if (events) {
|
|
let current: Extract<EventGroup, { kind: "pre" }> | null = null;
|
|
events.forEach((e, i) => {
|
|
if (!isRenderableEvent(e)) return;
|
|
if (e.type === "content") {
|
|
if (current) {
|
|
groups.push(current);
|
|
current = null;
|
|
}
|
|
groups.push({ kind: "content", event: e, index: i });
|
|
} else {
|
|
if (!current)
|
|
current = { kind: "pre", events: [], indices: [] };
|
|
current.events.push(e);
|
|
current.indices.push(i);
|
|
}
|
|
});
|
|
if (current) groups.push(current);
|
|
}
|
|
|
|
const hasContentAfter = (groupIdx: number): boolean => {
|
|
for (let i = groupIdx + 1; i < groups.length; i++) {
|
|
const g = groups[i];
|
|
if (g.kind === "content" && g.event.text.length > 0) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const askInputsResponseFor = (askInputsIdx: number) => {
|
|
if (!events) return undefined;
|
|
for (let i = askInputsIdx + 1; i < events.length; i++) {
|
|
const candidate = events[i];
|
|
if (candidate.type === "ask_inputs") return undefined;
|
|
if (candidate.type === "ask_inputs_response") return candidate;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const hasPendingAskInput = (group: Extract<EventGroup, { kind: "pre" }>) =>
|
|
group.events.some(
|
|
(event, index) =>
|
|
event.type === "ask_inputs" &&
|
|
!askInputsResponseFor(group.indices[index]),
|
|
);
|
|
|
|
const renderEvent = (
|
|
event: AssistantEvent,
|
|
i: number,
|
|
allEvents: AssistantEvent[],
|
|
globalIdx: number,
|
|
) => {
|
|
const nextEvent = allEvents[i + 1];
|
|
const showConnector =
|
|
nextEvent !== undefined && nextEvent.type !== "content";
|
|
|
|
if (event.type === "reasoning") {
|
|
return (
|
|
<ReasoningBlock
|
|
key={globalIdx}
|
|
text={event.text}
|
|
isStreaming={!!event.isStreaming}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "tool_call_start") {
|
|
return (
|
|
<EventBlock
|
|
key={globalIdx}
|
|
showConnector={showConnector}
|
|
isStreaming
|
|
>
|
|
<span className="font-medium">
|
|
{toolCallLabel(event.name)}
|
|
</span>
|
|
</EventBlock>
|
|
);
|
|
}
|
|
if (event.type === "thinking") {
|
|
return (
|
|
<EventBlock
|
|
key={globalIdx}
|
|
showConnector={showConnector}
|
|
isStreaming
|
|
>
|
|
<span>Thinking...</span>
|
|
</EventBlock>
|
|
);
|
|
}
|
|
if (event.type === "mcp_tool_call") {
|
|
const isError = event.status === "error";
|
|
const label = event.connector_name
|
|
? `${event.connector_name}: ${event.tool_name}`
|
|
: toolCallLabel(event.openai_tool_name);
|
|
return (
|
|
<EventBlock
|
|
key={globalIdx}
|
|
showConnector={showConnector}
|
|
isStreaming={event.isStreaming}
|
|
dotColor={isError ? "red" : "gray"}
|
|
>
|
|
<span className="font-medium">
|
|
{event.isStreaming ? "Using connector..." : label}
|
|
</span>
|
|
{isError && event.error && (
|
|
<p className="mt-0.5 text-xs text-red-600">
|
|
{event.error}
|
|
</p>
|
|
)}
|
|
</EventBlock>
|
|
);
|
|
}
|
|
if (event.type === "doc_read") {
|
|
const ann = citations.find(
|
|
(a) => a.kind !== "case" && a.filename === event.filename,
|
|
);
|
|
return (
|
|
<DocReadBlock
|
|
key={globalIdx}
|
|
filename={event.filename}
|
|
isStreaming={event.isStreaming}
|
|
onClick={
|
|
!event.isStreaming && ann && onCitationClick
|
|
? () => onCitationClick(ann)
|
|
: undefined
|
|
}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "doc_find") {
|
|
return (
|
|
<DocFindBlock
|
|
key={globalIdx}
|
|
filename={event.filename}
|
|
query={event.query}
|
|
totalMatches={event.total_matches}
|
|
isStreaming={!!event.isStreaming}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "doc_created") {
|
|
return (
|
|
<DocCreatedBlock
|
|
key={globalIdx}
|
|
filename={event.filename}
|
|
isStreaming={event.isStreaming}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "doc_replicated") {
|
|
// The backend now does N copies in one tool call and reports
|
|
// count + copies on a single event, so no consecutive-event
|
|
// aggregation needed.
|
|
return (
|
|
<DocReplicatedBlock
|
|
key={globalIdx}
|
|
filename={event.filename}
|
|
count={event.count}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "doc_edited") {
|
|
return (
|
|
<DocEditedBlock
|
|
key={globalIdx}
|
|
filename={event.filename}
|
|
isStreaming={event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "workflow_applied") {
|
|
return (
|
|
<WorkflowAppliedBlock
|
|
key={globalIdx}
|
|
title={event.title}
|
|
showConnector={showConnector}
|
|
onClick={
|
|
onWorkflowClick
|
|
? () => onWorkflowClick(event.workflow_id)
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "ask_inputs") {
|
|
const response = askInputsResponseFor(globalIdx);
|
|
return (
|
|
<AskInputsBlock
|
|
key={globalIdx}
|
|
event={event}
|
|
response={response}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "courtlistener_search_case_law") {
|
|
const count = event.result_count ?? 0;
|
|
const detail = event.isStreaming
|
|
? event.query
|
|
? `for "${event.query}"`
|
|
: undefined
|
|
: event.error
|
|
? event.error
|
|
: `${count} ${count === 1 ? "result" : "results"}${event.query ? ` for "${event.query}"` : ""}`;
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? "Searching case law"
|
|
: event.error
|
|
? "Case law search failed"
|
|
: "Searched case law"
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "courtlistener_get_cases") {
|
|
const caseCount = event.case_count ?? event.cluster_ids.length;
|
|
const displayLabel = `${caseCount} ${
|
|
caseCount === 1 ? "case" : "cases"
|
|
}`;
|
|
const detail = event.error ? event.error : undefined;
|
|
const items: CourtListenerBlockItem[] =
|
|
event.cases?.map((caseItem) => ({
|
|
caseName: caseItem.case_name,
|
|
citation: caseItem.citation,
|
|
url: caseItem.url ?? null,
|
|
})) ??
|
|
event.cluster_ids.map((clusterId) => {
|
|
const citation = caseCitations.get(`us-case-${clusterId}`);
|
|
return {
|
|
caseName: citation?.case_name ?? null,
|
|
citation: citation?.citation ?? `Cluster ${clusterId}`,
|
|
url: citation?.url ?? null,
|
|
};
|
|
});
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? `Fetching ${displayLabel}`
|
|
: event.error
|
|
? "Case fetch failed"
|
|
: `Fetched ${displayLabel}`
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
items={items.length > 0 ? items : undefined}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "courtlistener_find_in_case") {
|
|
const searches = event.searches ?? [];
|
|
if (searches.length > 0) {
|
|
const matches =
|
|
event.total_matches ??
|
|
searches.reduce(
|
|
(sum, search) => sum + (search.total_matches ?? 0),
|
|
0,
|
|
);
|
|
const caseIds = new Set(
|
|
searches.map(
|
|
(search) =>
|
|
search.cluster_id ??
|
|
`${search.case_name ?? ""}|${search.citation ?? ""}`,
|
|
),
|
|
);
|
|
const caseCount = caseIds.size || searches.length;
|
|
const searchLabel = `${searches.length} ${
|
|
searches.length === 1 ? "search" : "searches"
|
|
} in ${caseCount} ${caseCount === 1 ? "case" : "cases"}`;
|
|
const detail = event.isStreaming
|
|
? undefined
|
|
: event.error
|
|
? event.error
|
|
: `(${matches} ${matches === 1 ? "match" : "matches"})`;
|
|
const items: CourtListenerBlockItem[] = searches.map(
|
|
(search) => ({
|
|
caseName: search.case_name ?? null,
|
|
citation:
|
|
search.citation ??
|
|
(search.cluster_id
|
|
? `Cluster ${search.cluster_id}`
|
|
: null),
|
|
url: null,
|
|
query: search.query,
|
|
totalMatches: search.total_matches ?? 0,
|
|
hasError: !!search.error,
|
|
}),
|
|
);
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? `Running ${searchLabel}`
|
|
: event.error
|
|
? "Case searches failed"
|
|
: `Ran ${searchLabel}`
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
items={items.length > 0 ? items : undefined}
|
|
/>
|
|
);
|
|
}
|
|
const matches = event.total_matches ?? 0;
|
|
const caseLabel =
|
|
[event.case_name, event.citation].filter(Boolean).join(", ") ||
|
|
(event.cluster_id ? `cluster ${event.cluster_id}` : "case");
|
|
const detail = event.isStreaming
|
|
? event.query
|
|
? `for "${event.query}" in ${caseLabel}`
|
|
: caseLabel
|
|
: event.error
|
|
? event.error
|
|
: `${matches} ${matches === 1 ? "match" : "matches"}${event.query ? ` for "${event.query}"` : ""} in ${caseLabel}`;
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? "Searching case"
|
|
: event.error
|
|
? "Case search failed"
|
|
: "Searched case"
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "courtlistener_read_case") {
|
|
const count = event.opinion_count ?? 0;
|
|
const caseLabel =
|
|
[event.case_name, event.citation].filter(Boolean).join(", ") ||
|
|
"case";
|
|
const detail = event.isStreaming
|
|
? undefined
|
|
: event.error
|
|
? event.error
|
|
: count > 0
|
|
? `(${count} ${count === 1 ? "opinion" : "opinions"})`
|
|
: undefined;
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? `Reading case ${caseLabel}`
|
|
: event.error
|
|
? `Case read failed ${caseLabel}`
|
|
: `Read case ${caseLabel}`
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
/>
|
|
);
|
|
}
|
|
if (event.type === "courtlistener_verify_citations") {
|
|
const citations = event.citation_count ?? 0;
|
|
const matches = event.match_count ?? 0;
|
|
const citationLabel = `${citations} ${citations === 1 ? "citation" : "citations"}`;
|
|
const detail = event.isStreaming
|
|
? undefined
|
|
: event.error
|
|
? event.error
|
|
: `(${matches} ${matches === 1 ? "match" : "matches"})`;
|
|
// Adjacent `case_citation` events are emitted between the start
|
|
// and final verify_citations events (one per matched citation) —
|
|
// collect them so the user can expand to see resolved cases.
|
|
const items: CourtListenerBlockItem[] = [];
|
|
if (events) {
|
|
for (let j = globalIdx + 1; j < events.length; j++) {
|
|
const e = events[j];
|
|
if (e.type !== "case_citation") break;
|
|
items.push({
|
|
caseName: e.case_name,
|
|
citation: e.citation,
|
|
url: e.url || null,
|
|
});
|
|
}
|
|
}
|
|
return (
|
|
<CourtListenerBlock
|
|
key={globalIdx}
|
|
label={
|
|
event.isStreaming
|
|
? `Verifying ${citationLabel}`
|
|
: event.error
|
|
? "Citation verification failed"
|
|
: `Verified ${citationLabel}`
|
|
}
|
|
detail={detail}
|
|
isStreaming={!!event.isStreaming}
|
|
hasError={!!event.error}
|
|
showConnector={showConnector}
|
|
items={items.length > 0 ? items : undefined}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div style={{ minHeight }}>
|
|
<ResponseStatus status={status} />
|
|
<div className="w-full font-inter relative mt-2">
|
|
{events && events.length > 0 ? (
|
|
<div className="flex flex-col gap-4">
|
|
{groups.map((g, gIdx) => {
|
|
if (g.kind === "content") {
|
|
const isLastContent =
|
|
g.index === lastContentIdx;
|
|
return (
|
|
<div key={`c-${g.index}`}>
|
|
<MarkdownContent
|
|
text={processedTexts[g.index]}
|
|
inlineCitationTargets={
|
|
inlineCitationTargets
|
|
}
|
|
caseCitations={caseCitations}
|
|
caseOpinions={caseOpinions}
|
|
onCitationClick={onCitationClick}
|
|
onCaseClick={onCaseClick}
|
|
divRef={
|
|
isLastContent
|
|
? contentDivRef
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
const subsequentContent = hasContentAfter(gIdx);
|
|
const pendingAskInput = hasPendingAskInput(g);
|
|
const wrapperIsStreaming =
|
|
g.events.some(
|
|
(event) =>
|
|
"isStreaming" in event &&
|
|
!!event.isStreaming,
|
|
) || pendingAskInput;
|
|
return (
|
|
<PreResponseWrapper
|
|
key={`p-${g.indices[0]}`}
|
|
stepCount={g.events.length}
|
|
shouldMinimize={
|
|
pendingAskInput
|
|
? false
|
|
: subsequentContent
|
|
}
|
|
isStreaming={wrapperIsStreaming}
|
|
forceOpen={pendingAskInput}
|
|
>
|
|
{g.events.map((event, i) =>
|
|
renderEvent(
|
|
event,
|
|
i,
|
|
g.events,
|
|
g.indices[i],
|
|
),
|
|
)}
|
|
</PreResponseWrapper>
|
|
);
|
|
})}
|
|
{/* Bulk accept/reject + per-edit cards — below the
|
|
response content, only after streaming stops,
|
|
rendered above the download card. */}
|
|
{!isStreaming &&
|
|
(() => {
|
|
const editedEvents = events.filter(
|
|
(e) =>
|
|
e.type === "doc_edited" &&
|
|
!e.isStreaming,
|
|
) as Extract<
|
|
AssistantEvent,
|
|
{ type: "doc_edited" }
|
|
>[];
|
|
const pending: {
|
|
annotation: EditAnnotation;
|
|
filename: string;
|
|
}[] = [];
|
|
const filenameByDocId = new Map<
|
|
string,
|
|
string
|
|
>();
|
|
// Effective status = external override if any, else the annotation's DB status.
|
|
const statusOf = (ann: EditAnnotation) =>
|
|
resolvedEditStatuses?.[ann.edit_id] ??
|
|
ann.status;
|
|
for (const e of editedEvents) {
|
|
filenameByDocId.set(
|
|
e.document_id,
|
|
e.filename,
|
|
);
|
|
for (const ann of e.annotations) {
|
|
if (statusOf(ann) === "pending") {
|
|
pending.push({
|
|
annotation: ann,
|
|
filename: e.filename,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let cardIndex = 0;
|
|
const cards = editedEvents.flatMap((e) =>
|
|
e.annotations.map((ann) => {
|
|
const changeNumber = ++cardIndex;
|
|
return (
|
|
<EditCard
|
|
key={`editcard-${ann.edit_id}`}
|
|
annotation={ann}
|
|
changeNumber={changeNumber}
|
|
resolvedStatus={
|
|
resolvedEditStatuses?.[
|
|
ann.edit_id
|
|
]
|
|
}
|
|
isReloading={
|
|
isEditReloading?.(
|
|
ann.edit_id,
|
|
) ?? false
|
|
}
|
|
onViewClick={(a) =>
|
|
onEditViewClick?.(
|
|
a,
|
|
e.filename,
|
|
changeNumber,
|
|
)
|
|
}
|
|
onResolveStart={
|
|
onEditResolveStart
|
|
}
|
|
onResolved={handleEditResolved}
|
|
onError={onEditError}
|
|
/>
|
|
);
|
|
}),
|
|
);
|
|
const resolvedCount = editedEvents.reduce(
|
|
(acc, e) =>
|
|
acc +
|
|
e.annotations.filter(
|
|
(a) => statusOf(a) !== "pending",
|
|
).length,
|
|
0,
|
|
);
|
|
// If there's only one edit total, skip the
|
|
// minimisable wrapper / bulk-actions UI and
|
|
// render the bare EditCard — no value in
|
|
// bulk controls for a single item.
|
|
if (cards.length <= 1) {
|
|
return cards;
|
|
}
|
|
return (
|
|
<EditCardsSection
|
|
pending={pending}
|
|
filenameByDocId={filenameByDocId}
|
|
cards={cards}
|
|
resolvedCount={resolvedCount}
|
|
onViewClick={onEditViewClick}
|
|
onResolveStart={onEditResolveStart}
|
|
onResolved={handleEditResolved}
|
|
onError={onEditError}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
) : null}
|
|
|
|
{topLevelErrorMessage && (
|
|
<p className="mt-2 text-base font-serif leading-7 text-red-700">
|
|
{topLevelErrorMessage}
|
|
</p>
|
|
)}
|
|
|
|
{/* Download card for each edited doc — only after streaming
|
|
stops, and deduped per document (keep the latest edit). */}
|
|
{events &&
|
|
!isStreaming &&
|
|
(() => {
|
|
const edited = events.filter(
|
|
(
|
|
e,
|
|
): e is Extract<
|
|
AssistantEvent,
|
|
{ type: "doc_edited" }
|
|
> =>
|
|
e.type === "doc_edited" &&
|
|
!e.isStreaming &&
|
|
!!e.download_url,
|
|
);
|
|
const latestByDoc = new Map<
|
|
string,
|
|
(typeof edited)[number]
|
|
>();
|
|
for (const e of edited)
|
|
latestByDoc.set(e.document_id, e);
|
|
return Array.from(latestByDoc.values()).map((e) => (
|
|
<div
|
|
key={`edited-download-${e.document_id}`}
|
|
className="flex flex-col gap-2 mt-2 mb-3"
|
|
>
|
|
<DocDownloadBlock
|
|
filename={e.filename}
|
|
download_url={
|
|
resolvedOverrides[e.document_id] ??
|
|
e.download_url
|
|
}
|
|
versionNumber={e.version_number ?? null}
|
|
onOpen={
|
|
onOpenDocument
|
|
? () =>
|
|
onOpenDocument({
|
|
documentId: e.document_id,
|
|
filename: e.filename,
|
|
versionId:
|
|
e.version_id ?? null,
|
|
versionNumber:
|
|
e.version_number ??
|
|
null,
|
|
})
|
|
: onEditViewClick &&
|
|
e.annotations[0]
|
|
? () =>
|
|
onEditViewClick(
|
|
e.annotations[0],
|
|
e.filename,
|
|
)
|
|
: undefined
|
|
}
|
|
isReloading={
|
|
isDocReloading?.(e.document_id) ?? false
|
|
}
|
|
/>
|
|
</div>
|
|
));
|
|
})()}
|
|
|
|
{/* Download cards for created docs — generated docs now
|
|
persist as first-class documents, so clicking opens
|
|
them in the DocPanel (like edited docs). */}
|
|
{events &&
|
|
!isStreaming &&
|
|
events.some(
|
|
(e) => e.type === "doc_created" && e.download_url,
|
|
) && (
|
|
<div className="flex flex-col gap-2 mt-2 mb-3">
|
|
{(
|
|
events.filter(
|
|
(e) =>
|
|
e.type === "doc_created" &&
|
|
e.download_url,
|
|
) as Extract<
|
|
AssistantEvent,
|
|
{ type: "doc_created" }
|
|
>[]
|
|
).map((e, i) => {
|
|
const documentId = e.document_id;
|
|
const versionId = e.version_id ?? null;
|
|
const versionNumber = e.version_number ?? null;
|
|
const canOpen =
|
|
!!onOpenDocument && !!documentId;
|
|
return (
|
|
<DocDownloadBlock
|
|
key={i}
|
|
filename={e.filename}
|
|
download_url={e.download_url}
|
|
versionNumber={versionNumber}
|
|
onOpen={
|
|
canOpen
|
|
? () =>
|
|
onOpenDocument!({
|
|
documentId:
|
|
documentId!,
|
|
filename: e.filename,
|
|
versionId,
|
|
versionNumber,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{showCitationBlock && (
|
|
<CitationsBlock
|
|
citations={citations}
|
|
onCitationClick={onCitationClick}
|
|
onOpenSource={handleOpenCitationSource}
|
|
canOpenSource={canOpenCitationSource}
|
|
showWhenEmpty={!!citationStatus}
|
|
isLoading={
|
|
citationStatus === "started" ||
|
|
citationStatus === "partial"
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Copy button */}
|
|
<div className="flex items-center gap-2 py-2 font-sans justify-start">
|
|
{!isStreaming && (
|
|
<button
|
|
className="p-1.5 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
|
onClick={handleCopy}
|
|
>
|
|
{isCopied ? (
|
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|