mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
Sync CourtListener verification and document safety updates
- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior - Persist cancelled chat stream output and render cancellation as the final assistant message - Add document/version deletion safety fixes and shared warning/modal UI updates - Sync document panel, case law panel, and response UI styling refinements - Harden OSS sync script to preserve local env, dependency, and generated files
This commit is contained in:
parent
44e868eb42
commit
f32a194b33
24 changed files with 2494 additions and 1222 deletions
|
|
@ -23,6 +23,10 @@ alter table public.user_api_keys
|
|||
add constraint user_api_keys_provider_check
|
||||
check (provider in ('claude', 'gemini', 'openai', 'openrouter', 'courtlistener'));
|
||||
|
||||
alter table public.user_api_keys enable row level security;
|
||||
|
||||
drop policy if exists user_api_keys_own on public.user_api_keys;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Document metadata now lives on document_versions
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -123,6 +127,21 @@ alter table public.documents
|
|||
drop column if exists page_count,
|
||||
drop column if exists structure_tree;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'document_versions_doc_version_unique'
|
||||
and conrelid = 'public.document_versions'::regclass
|
||||
) then
|
||||
alter table public.document_versions
|
||||
add constraint document_versions_doc_version_unique
|
||||
unique (document_id, version_number);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- CourtListener bulk-data indexes
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -144,6 +163,10 @@ create index if not exists courtlistener_citation_lookup_idx
|
|||
create index if not exists courtlistener_citation_cluster_idx
|
||||
on public.courtlistener_citation_index(cluster_id);
|
||||
|
||||
alter table public.courtlistener_citation_index enable row level security;
|
||||
|
||||
drop policy if exists cl_citation_read on public.courtlistener_citation_index;
|
||||
|
||||
create table if not exists public.courtlistener_opinion_cluster_index (
|
||||
id bigint primary key,
|
||||
case_name text,
|
||||
|
|
@ -158,5 +181,9 @@ create table if not exists public.courtlistener_opinion_cluster_index (
|
|||
docket_id bigint
|
||||
);
|
||||
|
||||
alter table public.courtlistener_opinion_cluster_index enable row level security;
|
||||
|
||||
drop policy if exists cl_cluster_read on public.courtlistener_opinion_cluster_index;
|
||||
|
||||
revoke all on public.courtlistener_citation_index from anon, authenticated;
|
||||
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ create table if not exists public.user_api_keys (
|
|||
create index if not exists idx_user_api_keys_user
|
||||
on public.user_api_keys(user_id);
|
||||
|
||||
alter table public.user_api_keys enable row level security;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Projects and documents
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -142,6 +144,21 @@ create index if not exists document_versions_document_id_idx
|
|||
create index if not exists document_versions_doc_vnum_idx
|
||||
on public.document_versions(document_id, version_number);
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'document_versions_doc_version_unique'
|
||||
and conrelid = 'public.document_versions'::regclass
|
||||
) then
|
||||
alter table public.document_versions
|
||||
add constraint document_versions_doc_version_unique
|
||||
unique (document_id, version_number);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
alter table public.documents
|
||||
add column if not exists current_version_id uuid
|
||||
references public.document_versions(id) on delete set null;
|
||||
|
|
@ -361,6 +378,8 @@ create index if not exists courtlistener_citation_lookup_idx
|
|||
create index if not exists courtlistener_citation_cluster_idx
|
||||
on public.courtlistener_citation_index(cluster_id);
|
||||
|
||||
alter table public.courtlistener_citation_index enable row level security;
|
||||
|
||||
create table if not exists public.courtlistener_opinion_cluster_index (
|
||||
id bigint primary key,
|
||||
case_name text,
|
||||
|
|
@ -375,6 +394,8 @@ create table if not exists public.courtlistener_opinion_cluster_index (
|
|||
docket_id bigint
|
||||
);
|
||||
|
||||
alter table public.courtlistener_opinion_cluster_index enable row level security;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Direct client grant hardening
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ export type ChatMessage = {
|
|||
|
||||
export const SYSTEM_PROMPT = `You are Mike, an AI legal assistant that helps lawyers and legal professionals analyze documents, answer legal questions, and draft legal documents.
|
||||
|
||||
TOOL BUDGET:
|
||||
You have at most 10 tool-use rounds in a single response. Use tools deliberately, batch independent tool calls in the same round where possible, and reserve enough room to produce a final answer. Do not spend the final tool round gathering more information unless you can answer without another tool call afterward.
|
||||
|
||||
DOCUMENT CITATION INSTRUCTIONS:
|
||||
When you reference specific content from an uploaded/generated document, place a numbered marker [1], [2], etc. inline in your prose at the point of reference.
|
||||
These numbered [N] markers and the <CITATIONS> block are for evidence passages that the UI can open. Uploaded/generated document citations use the document entry shape below. Research tools may define additional source-specific citation entry shapes in their own instructions.
|
||||
|
|
@ -1972,7 +1975,6 @@ type CourtlistenerCaseRecord = {
|
|||
url: string | null;
|
||||
pdfUrl: string | null;
|
||||
dateFiled: string | null;
|
||||
judges: string | null;
|
||||
opinions?: unknown[];
|
||||
};
|
||||
|
||||
|
|
@ -1984,7 +1986,6 @@ type CourtlistenerCaseInput = {
|
|||
url?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
opinions?: unknown[];
|
||||
};
|
||||
|
||||
|
|
@ -2015,7 +2016,6 @@ function upsertCourtlistenerCases(
|
|||
url: null,
|
||||
pdfUrl: null,
|
||||
dateFiled: null,
|
||||
judges: null,
|
||||
};
|
||||
const nextCitations = [
|
||||
...current.citations,
|
||||
|
|
@ -2031,7 +2031,6 @@ function upsertCourtlistenerCases(
|
|||
url: current.url ?? nonEmpty(input.url),
|
||||
pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl),
|
||||
dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled),
|
||||
judges: current.judges ?? nonEmpty(input.judges),
|
||||
opinions: current.opinions ?? input.opinions,
|
||||
};
|
||||
state.casesByClusterId.set(clusterId, record);
|
||||
|
|
@ -2052,7 +2051,6 @@ function caseCitationEventFromRecord(
|
|||
url: record.url,
|
||||
pdfUrl: record.pdfUrl,
|
||||
dateFiled: record.dateFiled,
|
||||
judges: record.judges,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2104,7 +2102,6 @@ function courtlistenerCaseInputFromFetchedCase(
|
|||
url: stringField(record, "url"),
|
||||
pdfUrl: stringField(record, "pdfUrl"),
|
||||
dateFiled: stringField(record, "dateFiled"),
|
||||
judges: stringField(record, "judges"),
|
||||
opinions: Array.isArray(record?.opinions) ? record.opinions : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -2146,7 +2143,6 @@ function courtlistenerFetchedCaseMetadata(
|
|||
dateFiled: record.dateFiled,
|
||||
url: record.url,
|
||||
pdfUrl: record.pdfUrl,
|
||||
judges: record.judges,
|
||||
opinion_count: opinionCount,
|
||||
opinions: (record.opinions ?? [])
|
||||
.map(courtlistenerOpinionMetadata)
|
||||
|
|
@ -2884,7 +2880,6 @@ export async function runToolCalls(
|
|||
citations: record.citations,
|
||||
url: record.url,
|
||||
dateFiled: record.dateFiled,
|
||||
judges: record.judges,
|
||||
opinion_count: opinions.length,
|
||||
opinions: (record.opinions ?? [])
|
||||
.map(courtlistenerOpinionMetadata)
|
||||
|
|
@ -2933,7 +2928,6 @@ export async function runToolCalls(
|
|||
citations: record.citations,
|
||||
url: record.url,
|
||||
dateFiled: record.dateFiled,
|
||||
judges: record.judges,
|
||||
opinion_count: opinions.length,
|
||||
returned_opinion_count: selectedOpinions.length,
|
||||
opinions: selectedOpinions,
|
||||
|
|
@ -2944,16 +2938,13 @@ export async function runToolCalls(
|
|||
? args.citations.filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
: undefined;
|
||||
const citationCount =
|
||||
citations?.length ??
|
||||
(typeof args.text === "string" && args.text.trim() ? 1 : 0);
|
||||
: [];
|
||||
const citationCount = citations.length;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`,
|
||||
);
|
||||
try {
|
||||
const result = (await verifyCourtlistenerCitations({
|
||||
text: typeof args.text === "string" ? args.text : undefined,
|
||||
citations,
|
||||
db,
|
||||
apiToken: apiKeys?.courtlistener,
|
||||
|
|
@ -2964,7 +2955,6 @@ export async function runToolCalls(
|
|||
caseName?: string | null;
|
||||
dateFiled?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
judges?: string | null;
|
||||
url?: string | null;
|
||||
markdown?: string;
|
||||
}[];
|
||||
|
|
@ -2983,7 +2973,6 @@ export async function runToolCalls(
|
|||
url: link.url,
|
||||
pdfUrl: link.pdfUrl,
|
||||
dateFiled: link.dateFiled,
|
||||
judges: link.judges,
|
||||
})),
|
||||
);
|
||||
const recordsByClusterId = new Map(
|
||||
|
|
@ -3712,7 +3701,6 @@ function createCitationAnnotation(
|
|||
url: caseRecord?.url ?? null,
|
||||
pdfUrl: caseRecord?.pdfUrl ?? null,
|
||||
dateFiled: caseRecord?.dateFiled ?? null,
|
||||
judges: caseRecord?.judges ?? null,
|
||||
quotes: citation.quotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -3812,6 +3800,13 @@ export class AssistantStreamError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class AssistantStreamAbortError extends AssistantStreamError {
|
||||
constructor(fullText: string, events: AssistantEvent[]) {
|
||||
super("Stream aborted.", fullText, events);
|
||||
this.name = "AbortError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const record = error as { name?: unknown; message?: unknown };
|
||||
|
|
@ -3970,22 +3965,25 @@ export async function runLLMStream(params: {
|
|||
}
|
||||
};
|
||||
|
||||
const flushVisibleTail = () => {
|
||||
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
|
||||
const emit = opts.emit ?? true;
|
||||
if (citationsOpenSeen || !visibleTailBuffer) {
|
||||
visibleTailBuffer = "";
|
||||
return;
|
||||
}
|
||||
iterVisibleText += visibleTailBuffer;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
|
||||
);
|
||||
if (emit) {
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
|
||||
);
|
||||
}
|
||||
visibleTailBuffer = "";
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
const flushText = (opts: { emit?: boolean } = {}) => {
|
||||
if (!iterText) return;
|
||||
fullText += iterText;
|
||||
flushVisibleTail();
|
||||
flushVisibleTail(opts);
|
||||
if (iterVisibleText) {
|
||||
events.push({ type: "content", text: iterVisibleText });
|
||||
}
|
||||
|
|
@ -3997,6 +3995,14 @@ export async function runLLMStream(params: {
|
|||
streamedCitationCount = 0;
|
||||
};
|
||||
|
||||
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
|
||||
flushText(opts);
|
||||
if (iterReasoning) {
|
||||
events.push({ type: "reasoning", text: iterReasoning });
|
||||
iterReasoning = "";
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
|
||||
|
||||
try {
|
||||
|
|
@ -4161,8 +4167,11 @@ export async function runLLMStream(params: {
|
|||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
flushText();
|
||||
if (isAbortError(err)) {
|
||||
flushPartialTurn({ emit: false });
|
||||
throw new AssistantStreamAbortError(fullText, events);
|
||||
}
|
||||
flushPartialTurn();
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : "Stream error";
|
||||
events.push({ type: "error", message });
|
||||
|
|
@ -4208,6 +4217,24 @@ export function stripTransientAssistantEvents(events: AssistantEvent[]) {
|
|||
return events.filter((event) => event.type !== "case_opinions");
|
||||
}
|
||||
|
||||
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
|
||||
return [...events, { type: "content" as const, text: "Cancelled by user." }];
|
||||
}
|
||||
|
||||
export function buildCancelledAssistantMessage(args: {
|
||||
fullText: string;
|
||||
events: AssistantEvent[];
|
||||
buildAnnotations: (fullText: string, events: AssistantEvent[]) => unknown[];
|
||||
}) {
|
||||
const events = appendCancelledAssistantEvent(
|
||||
stripTransientAssistantEvents(args.events),
|
||||
);
|
||||
return {
|
||||
events,
|
||||
annotations: args.buildAnnotations(args.fullText, events),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document context builder (from message file attachments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ async function courtlistenerFetch<T>(
|
|||
});
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: init?.signal ?? AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...courtlistenerHeaders(apiToken),
|
||||
...(init?.headers ?? {}),
|
||||
|
|
@ -146,7 +147,6 @@ function compactCluster(raw: unknown) {
|
|||
id: null,
|
||||
caseName: null,
|
||||
dateFiled: null,
|
||||
judges: null,
|
||||
court: null,
|
||||
citations: [],
|
||||
url: null,
|
||||
|
|
@ -161,7 +161,6 @@ function compactCluster(raw: unknown) {
|
|||
asString(cluster.caseName) ??
|
||||
asString(cluster.name),
|
||||
dateFiled: asString(cluster.date_filed) ?? asString(cluster.dateFiled),
|
||||
judges: asString(cluster.judges),
|
||||
court:
|
||||
asString((cluster.docket as JsonRecord | undefined)?.court_id) ??
|
||||
asString(cluster.court) ??
|
||||
|
|
@ -208,14 +207,19 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: {
|
|||
includeFullText?: boolean;
|
||||
apiToken?: string | null;
|
||||
}) {
|
||||
const MAX_OPINION_PAGES = 10;
|
||||
const opinions: ReturnType<typeof compactOpinion>[] = [];
|
||||
const rawOpinions: JsonRecord[] = [];
|
||||
let nextUrl: string | null = `/opinions/?cluster=${args.clusterId}`;
|
||||
let pages = 0;
|
||||
let remainingChars = args.maxChars;
|
||||
|
||||
while (nextUrl) {
|
||||
while (nextUrl && pages < MAX_OPINION_PAGES && remainingChars > 0) {
|
||||
pages += 1;
|
||||
devLog("[courtlistener/opinions-endpoint] fetching page", {
|
||||
clusterId: args.clusterId,
|
||||
path: nextUrl,
|
||||
page: pages,
|
||||
});
|
||||
const data = await courtlistenerFetch<JsonRecord>(
|
||||
nextUrl,
|
||||
|
|
@ -226,21 +230,26 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: {
|
|||
const opinionMaxChars = args.includeFullText
|
||||
? Math.max(
|
||||
500,
|
||||
Math.floor(args.maxChars / Math.max(1, results.length)),
|
||||
Math.floor(remainingChars / Math.max(1, results.length)),
|
||||
)
|
||||
: 3000;
|
||||
: Math.min(3000, remainingChars);
|
||||
const pageOpinions = results.filter(
|
||||
(opinion): opinion is JsonRecord =>
|
||||
!!opinion &&
|
||||
typeof opinion === "object" &&
|
||||
!Array.isArray(opinion),
|
||||
);
|
||||
rawOpinions.push(...pageOpinions);
|
||||
opinions.push(
|
||||
...pageOpinions.map((opinion) =>
|
||||
compactOpinion(opinion, opinionMaxChars),
|
||||
),
|
||||
);
|
||||
for (const opinion of pageOpinions) {
|
||||
if (remainingChars <= 0) break;
|
||||
const compacted = compactOpinion(
|
||||
opinion,
|
||||
Math.max(1, Math.min(opinionMaxChars, remainingChars)),
|
||||
);
|
||||
rawOpinions.push(opinion);
|
||||
opinions.push(compacted);
|
||||
remainingChars -=
|
||||
(compacted.text?.length ?? 0) + (compacted.html?.length ?? 0);
|
||||
}
|
||||
nextUrl = asString(data.next);
|
||||
}
|
||||
|
||||
|
|
@ -481,7 +490,6 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) {
|
|||
asString(cluster.case_name_full) ??
|
||||
asString(cluster.case_name_short),
|
||||
dateFiled: asString(cluster.date_filed),
|
||||
judges: asString(cluster.judges),
|
||||
court: null,
|
||||
citations,
|
||||
url: clusterUrl(cluster),
|
||||
|
|
@ -490,29 +498,132 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) {
|
|||
};
|
||||
}
|
||||
|
||||
type CitationLookupCluster =
|
||||
| ReturnType<typeof compactCluster>
|
||||
| ReturnType<typeof compactBulkCluster>;
|
||||
|
||||
type CitationLookupRow = {
|
||||
citation: string | null;
|
||||
status: string;
|
||||
message: string | null;
|
||||
clusters: CitationLookupCluster[];
|
||||
};
|
||||
|
||||
type CitationLookupPayload = {
|
||||
citationsSubmitted?: number;
|
||||
citationLinks: {
|
||||
clusterId: number | null;
|
||||
citation: string | null;
|
||||
caseName: string | null;
|
||||
court: string | null;
|
||||
dateFiled: string | null;
|
||||
pdfUrl: string | null;
|
||||
url: string | null;
|
||||
markdown: string;
|
||||
}[];
|
||||
results: CitationLookupRow[];
|
||||
source?: string;
|
||||
};
|
||||
|
||||
function buildCitationLinks(results: CitationLookupRow[]) {
|
||||
return results.flatMap((result) =>
|
||||
result.clusters.flatMap((cluster) => {
|
||||
if (!cluster.url) return [];
|
||||
const label = [cluster.caseName, result.citation]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return [
|
||||
{
|
||||
clusterId: cluster.id,
|
||||
citation: result.citation,
|
||||
caseName: cluster.caseName,
|
||||
court: cluster.court,
|
||||
dateFiled: cluster.dateFiled,
|
||||
pdfUrl: cluster.pdfUrl,
|
||||
url: cluster.url,
|
||||
markdown: `[${label || cluster.url}](${cluster.url})`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function courtlistenerApiTokenAvailable(apiToken?: string | null) {
|
||||
return !!(apiToken?.trim() || process.env.COURTLISTENER_API_TOKEN?.trim());
|
||||
}
|
||||
|
||||
async function getBulkCitationLookup(args: {
|
||||
db?: ServerSupabase;
|
||||
citations: string[];
|
||||
}) {
|
||||
if (!args.db || !courtlistenerBulkDataEnabled()) return null;
|
||||
allowPartial?: boolean;
|
||||
}): Promise<CitationLookupPayload | null> {
|
||||
const parsed = args.citations.map((citation) => ({
|
||||
citation,
|
||||
parts: parseCitationParts(citation),
|
||||
}));
|
||||
if (!parsed.length || parsed.some((row) => !row.parts)) return null;
|
||||
devLog("[courtlistener/bulk-citation-lookup] candidates", {
|
||||
enabled: courtlistenerBulkDataEnabled(),
|
||||
hasDb: !!args.db,
|
||||
allowPartial: !!args.allowPartial,
|
||||
count: parsed.length,
|
||||
candidates: parsed.map((row) => ({
|
||||
citation: row.citation,
|
||||
parsed: row.parts
|
||||
? {
|
||||
volume: row.parts.volume,
|
||||
reporter: row.parts.reporter,
|
||||
page: row.parts.page,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
});
|
||||
if (!args.db || !courtlistenerBulkDataEnabled()) return null;
|
||||
if (!parsed.length) return null;
|
||||
if (!args.allowPartial && parsed.some((row) => !row.parts)) {
|
||||
devLog("[courtlistener/bulk-citation-lookup] skipped", {
|
||||
reason: "unparseable_candidate",
|
||||
unparseable: parsed
|
||||
.filter((row) => !row.parts)
|
||||
.map((row) => row.citation),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const results: {
|
||||
citation: string | null;
|
||||
status: string;
|
||||
message: string | null;
|
||||
clusters: ReturnType<typeof compactBulkCluster>[];
|
||||
}[] = [];
|
||||
const results: CitationLookupRow[] = [];
|
||||
|
||||
for (const row of parsed) {
|
||||
const parts = row.parts;
|
||||
if (!parts) return null;
|
||||
if (!parts) {
|
||||
devLog("[courtlistener/bulk-citation-lookup] skipped candidate", {
|
||||
citation: row.citation,
|
||||
reason: "unparseable_candidate",
|
||||
});
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: row.citation,
|
||||
status: "invalid",
|
||||
message: "Citation could not be parsed for bulk lookup.",
|
||||
clusters: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const verifiedCitation = citationPartsLabel(parts);
|
||||
if (!verifiedCitation) return null;
|
||||
if (!verifiedCitation) {
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: row.citation,
|
||||
status: "invalid",
|
||||
message: "Citation could not be normalized for bulk lookup.",
|
||||
clusters: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
devLog("[courtlistener/bulk-citation-lookup] citation query", {
|
||||
citation: row.citation,
|
||||
volume: parts.volume,
|
||||
reporter: parts.reporter,
|
||||
page: parts.page,
|
||||
});
|
||||
const { data: citationRows, error } = await args.db
|
||||
.from("courtlistener_citation_index")
|
||||
.select("cluster_id, volume, reporter, page")
|
||||
|
|
@ -520,7 +631,21 @@ async function getBulkCitationLookup(args: {
|
|||
.eq("reporter", parts.reporter)
|
||||
.eq("page", parts.page)
|
||||
.limit(20);
|
||||
if (error) return null;
|
||||
devLog("[courtlistener/bulk-citation-lookup] citation query result", {
|
||||
citation: row.citation,
|
||||
rowCount: citationRows?.length ?? 0,
|
||||
error: error?.message ?? null,
|
||||
});
|
||||
if (error) {
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: verifiedCitation,
|
||||
status: "error",
|
||||
message: error.message,
|
||||
clusters: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const clusterIds = [
|
||||
...new Set(
|
||||
(citationRows ?? [])
|
||||
|
|
@ -532,15 +657,43 @@ async function getBulkCitationLookup(args: {
|
|||
.filter((id) => Number.isFinite(id)),
|
||||
),
|
||||
];
|
||||
if (!clusterIds.length) return null;
|
||||
if (!clusterIds.length) {
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: verifiedCitation,
|
||||
status: "not_found",
|
||||
message: "Citation was not found in the bulk citation index.",
|
||||
clusters: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
devLog("[courtlistener/bulk-citation-lookup] cluster query", {
|
||||
citation: row.citation,
|
||||
clusterIds,
|
||||
});
|
||||
const { data: clusters, error: clusterError } = await args.db
|
||||
.from("courtlistener_opinion_cluster_index")
|
||||
.select(
|
||||
"id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard",
|
||||
"id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard",
|
||||
)
|
||||
.in("id", clusterIds);
|
||||
if (clusterError) return null;
|
||||
devLog("[courtlistener/bulk-citation-lookup] cluster query result", {
|
||||
citation: row.citation,
|
||||
requestedCount: clusterIds.length,
|
||||
rowCount: clusters?.length ?? 0,
|
||||
error: clusterError?.message ?? null,
|
||||
});
|
||||
if (clusterError) {
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: verifiedCitation,
|
||||
status: "error",
|
||||
message: clusterError.message,
|
||||
clusters: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const clustersById = new Map(
|
||||
(clusters ?? [])
|
||||
.map((cluster) => {
|
||||
|
|
@ -567,7 +720,16 @@ async function getBulkCitationLookup(args: {
|
|||
(cluster): cluster is ReturnType<typeof compactBulkCluster> =>
|
||||
!!cluster && !!cluster.caseName,
|
||||
);
|
||||
if (matchedClusters.length !== clusterIds.length) return null;
|
||||
if (matchedClusters.length !== clusterIds.length) {
|
||||
if (!args.allowPartial) return null;
|
||||
results.push({
|
||||
citation: verifiedCitation,
|
||||
status: matchedClusters.length ? "partial" : "not_found",
|
||||
message: "Some citation clusters were missing from the bulk cluster index.",
|
||||
clusters: matchedClusters,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
citation: verifiedCitation,
|
||||
|
|
@ -577,37 +739,62 @@ async function getBulkCitationLookup(args: {
|
|||
});
|
||||
}
|
||||
|
||||
const citationLinks = results.flatMap((result) =>
|
||||
result.clusters.flatMap((cluster) => {
|
||||
if (!cluster.url) return [];
|
||||
const label = [cluster.caseName, result.citation]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return [
|
||||
{
|
||||
clusterId: cluster.id,
|
||||
citation: result.citation,
|
||||
caseName: cluster.caseName,
|
||||
court: cluster.court,
|
||||
dateFiled: cluster.dateFiled,
|
||||
judges: cluster.judges,
|
||||
pdfUrl: cluster.pdfUrl,
|
||||
url: cluster.url,
|
||||
markdown: `[${label || cluster.url}](${cluster.url})`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
citationsSubmitted: args.citations.length || undefined,
|
||||
citationLinks,
|
||||
citationLinks: buildCitationLinks(results),
|
||||
results,
|
||||
source: "bulk",
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchCourtlistenerCitationLookup(args: {
|
||||
text: string;
|
||||
citationsSubmitted?: number;
|
||||
apiToken?: string | null;
|
||||
}): Promise<CitationLookupPayload> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("text", args.text.slice(0, 64000));
|
||||
const results = await courtlistenerFetch<unknown[]>(
|
||||
"/citation-lookup/",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
args.apiToken,
|
||||
);
|
||||
|
||||
const compactResults: CitationLookupRow[] = (Array.isArray(results)
|
||||
? results
|
||||
: []
|
||||
)
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const row = item as JsonRecord;
|
||||
return {
|
||||
citation:
|
||||
asString(row.citation) ??
|
||||
asString(row.normalized_citation) ??
|
||||
null,
|
||||
status: asString(row.status) ?? String(row.status ?? "unknown"),
|
||||
message: asString(row.message),
|
||||
clusters: Array.isArray(row.clusters)
|
||||
? row.clusters.map(compactCluster)
|
||||
: [],
|
||||
};
|
||||
})
|
||||
.filter((row): row is CitationLookupRow => !!row);
|
||||
|
||||
return {
|
||||
citationsSubmitted: args.citationsSubmitted,
|
||||
citationLinks: buildCitationLinks(compactResults),
|
||||
results: compactResults,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBulkCourtlistenerCaseOpinions(args: {
|
||||
db?: ServerSupabase;
|
||||
clusterId: number;
|
||||
|
|
@ -697,7 +884,7 @@ async function getBulkCourtlistenerCaseOpinions(args: {
|
|||
const { data: cluster, error } = await args.db
|
||||
.from("courtlistener_opinion_cluster_index")
|
||||
.select(
|
||||
"id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard",
|
||||
"id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard",
|
||||
)
|
||||
.eq("id", args.clusterId)
|
||||
.maybeSingle();
|
||||
|
|
@ -778,7 +965,6 @@ async function getBulkCourtlistenerCaseOpinions(args: {
|
|||
}
|
||||
|
||||
export async function verifyCourtlistenerCitations(args: {
|
||||
text?: string;
|
||||
citations?: string[];
|
||||
db?: ServerSupabase;
|
||||
apiToken?: string | null;
|
||||
|
|
@ -789,85 +975,80 @@ export async function verifyCourtlistenerCitations(args: {
|
|||
.filter(Boolean)
|
||||
.slice(0, 250)
|
||||
: [];
|
||||
const text =
|
||||
typeof args.text === "string" && args.text.trim()
|
||||
? args.text.trim()
|
||||
: citations.join("\n");
|
||||
if (!text) {
|
||||
return { error: "Provide text or at least one citation." };
|
||||
if (!citations.length) {
|
||||
return { error: "Provide at least one citation or case name." };
|
||||
}
|
||||
|
||||
const bulkCandidates = citations;
|
||||
const bulk = await getBulkCitationLookup({
|
||||
db: args.db,
|
||||
citations: citations.length
|
||||
? citations
|
||||
: text.split(/\n+/).filter(Boolean),
|
||||
citations: bulkCandidates,
|
||||
allowPartial: true,
|
||||
});
|
||||
if (bulk) return bulk;
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("text", text.slice(0, 64000));
|
||||
const results = await courtlistenerFetch<unknown[]>(
|
||||
"/citation-lookup/",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
args.apiToken,
|
||||
);
|
||||
|
||||
const compactResults = (Array.isArray(results) ? results : []).map(
|
||||
(item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const row = item as JsonRecord;
|
||||
return {
|
||||
citation:
|
||||
asString(row.citation) ??
|
||||
asString(row.normalized_citation) ??
|
||||
null,
|
||||
status: row.status ?? null,
|
||||
message: asString(row.message),
|
||||
clusters: Array.isArray(row.clusters)
|
||||
? row.clusters.map(compactCluster)
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const citationLinks = compactResults.flatMap((result) => {
|
||||
if (!result || typeof result !== "object") return [];
|
||||
const row = result as {
|
||||
citation?: string | null;
|
||||
clusters?: ReturnType<typeof compactCluster>[];
|
||||
};
|
||||
return (row.clusters ?? []).flatMap((cluster) => {
|
||||
if (!cluster.url) return [];
|
||||
const label = [cluster.caseName, row.citation]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return [
|
||||
{
|
||||
clusterId: cluster.id,
|
||||
citation: row.citation ?? null,
|
||||
caseName: cluster.caseName,
|
||||
court: cluster.court,
|
||||
dateFiled: cluster.dateFiled,
|
||||
judges: cluster.judges,
|
||||
pdfUrl: cluster.pdfUrl,
|
||||
url: cluster.url,
|
||||
markdown: `[${label || cluster.url}](${cluster.url})`,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
devLog("[courtlistener/bulk-citation-lookup] result", {
|
||||
hit: !!bulk,
|
||||
citationsSubmitted: citations.length || undefined,
|
||||
citationLinks,
|
||||
results: compactResults,
|
||||
};
|
||||
candidateCount: bulkCandidates.length,
|
||||
resultCount: Array.isArray(bulk?.results) ? bulk.results.length : 0,
|
||||
citationLinkCount: Array.isArray(bulk?.citationLinks)
|
||||
? bulk.citationLinks.length
|
||||
: 0,
|
||||
statuses: Array.isArray(bulk?.results)
|
||||
? bulk.results.map((result) => result.status)
|
||||
: [],
|
||||
source: bulk?.source ?? null,
|
||||
});
|
||||
if (bulk) {
|
||||
const apiFallbackInputs =
|
||||
citations.length > 0 && courtlistenerApiTokenAvailable(args.apiToken)
|
||||
? bulk.results
|
||||
.filter(
|
||||
(result) =>
|
||||
result.status === "not_found" ||
|
||||
result.status === "invalid",
|
||||
)
|
||||
.map((result) => result.citation)
|
||||
.filter((citation): citation is string => !!citation)
|
||||
: [];
|
||||
if (!apiFallbackInputs.length) return bulk;
|
||||
|
||||
devLog("[courtlistener/bulk-citation-lookup] api fallback", {
|
||||
candidateCount: apiFallbackInputs.length,
|
||||
candidates: apiFallbackInputs,
|
||||
});
|
||||
try {
|
||||
const apiFallback = await fetchCourtlistenerCitationLookup({
|
||||
text: apiFallbackInputs.join("\n"),
|
||||
citationsSubmitted: apiFallbackInputs.length,
|
||||
apiToken: args.apiToken,
|
||||
});
|
||||
const fallbackRows = [...apiFallback.results];
|
||||
const mergedResults = bulk.results.flatMap((result) => {
|
||||
if (result.status !== "not_found" && result.status !== "invalid") {
|
||||
return [result];
|
||||
}
|
||||
return [fallbackRows.shift() ?? result];
|
||||
});
|
||||
mergedResults.push(...fallbackRows);
|
||||
return {
|
||||
citationsSubmitted: bulk.citationsSubmitted,
|
||||
citationLinks: buildCitationLinks(mergedResults),
|
||||
results: mergedResults,
|
||||
source: "bulk+api",
|
||||
};
|
||||
} catch (err) {
|
||||
devLog("[courtlistener/bulk-citation-lookup] api fallback failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return bulk;
|
||||
}
|
||||
}
|
||||
|
||||
return fetchCourtlistenerCitationLookup({
|
||||
text: citations.join("\n"),
|
||||
citationsSubmitted: citations.length || undefined,
|
||||
apiToken: args.apiToken,
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchCourtlistenerCaseLaw(args: {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ export type CaseCitationEvent = {
|
|||
url: string;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
};
|
||||
|
||||
export const COURTLISTENER_TOOL_NAMES = {
|
||||
|
|
@ -72,10 +71,9 @@ export const COURTLISTENER_TOOL_NAMES = {
|
|||
|
||||
export const COURTLISTENER_SYSTEM_PROMPT = `LEGAL RESEARCH QUERIES:
|
||||
- When a user asks a question on US law, you are required to cite relevant case law in your answer. Always verify US case citations using the courtlistener_verify_citations tool.
|
||||
- If the user gives case names or reporter citations, use courtlistener_verify_citations for those names/citations.
|
||||
- CourtListener keyword/issue search is not available. Do not attempt to search CourtListener for new candidate cases by legal issue or keywords. Work only from cases/citations supplied by the user, cases found in the provided documents, or citations already present in the conversation.
|
||||
- The courtlistener_verify_citations tool accepts only a citations array of clean reporter citations. Do not pass case names to this tool. Correct: {"citations":["467 U.S. 837","323 U.S. 134"]}. Incorrect: {"citations":["Chevron U.S.A. v. NRDC","Skidmore v. Swift"]}. If you only have case names and no reporter citations, do not call courtlistener_verify_citations for those names.
|
||||
- If any CourtListener tool call reports that a CourtListener rate limit was exceeded, or returns a 429/throttled/rate-limit error, do not make any further CourtListener API/search calls in that turn. Do not retry, verify more citations, fetch more cases, or run additional CourtListener searches; answer with the information already available and briefly state that CourtListener is rate limiting requests.
|
||||
- For cases you may cite or materially rely on, follow this sequence: first use courtlistener_verify_citations for case names/citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text.
|
||||
- For cases you may cite or materially rely on, follow this sequence when reporter citations are available: first use courtlistener_verify_citations with clean reporter citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text.
|
||||
- Only cite cases whose underlying opinion text, or at least the specific relevant opinion passages, has been supplied to you in this turn. courtlistener_get_cases only fetches and caches opinions; it does NOT place full opinion text in your context. It returns text-free opinion metadata so you can choose which opinion(s) matter. After courtlistener_get_cases, use courtlistener_find_in_case for targeted keyword or phrase lookup inside that cached case. If those snippets are not enough, use courtlistener_read_case to read only the specific already-fetched opinion(s) you need. courtlistener_find_in_case and courtlistener_read_case require the case to have been fetched first.
|
||||
- When a fetched case has multiple opinions, do not read all opinions by default. Choose the specific opinion_id or opinion_ids needed from the metadata or search hits. Prefer the lead/majority/controlling opinion when it is sufficient; read concurrences, dissents, or combined opinions only when they are necessary for the user's question.
|
||||
- When using courtlistener_find_in_case, search for terms that are 1-3 words long and actually likely to appear exactly as written in the opinion text. Do not use long sentence-like phrases. Run courtlistener_find_in_case no more than 3 times in a single assistant turn; if those searches are insufficient, read the smallest needed opinion text with courtlistener_read_case or answer with the available information.
|
||||
|
|
@ -175,22 +173,18 @@ export const COURTLISTENER_TOOLS = [
|
|||
function: {
|
||||
name: COURTLISTENER_TOOL_NAMES.verifyCitations,
|
||||
description:
|
||||
"Verify legal case citations using CourtListener's citation lookup. Accepts raw text containing citations, or multiple citation strings. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.",
|
||||
"Verify legal case citations using CourtListener's citation lookup. Accepts only an array of clean reporter citations, not case names. Example: {\"citations\":[\"467 U.S. 837\",\"323 U.S. 134\"]}. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
"Raw text containing one or more legal citations. Max 64,000 characters sent to CourtListener.",
|
||||
},
|
||||
citations: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Optional list of citation strings. Up to 250 will be joined into the request text field.",
|
||||
"Required list of clean reporter citations only. Put each reporter citation in its own array item, e.g. [\"467 U.S. 837\", \"323 U.S. 134\"]. Do not include case names. Up to 250 items.",
|
||||
},
|
||||
},
|
||||
required: ["citations"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ export function logRawLlmStream(args: {
|
|||
label: string;
|
||||
payload: unknown;
|
||||
}) {
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
process.env.LOG_RAW_LLM_STREAM !== "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (process.env.LOG_RAW_LLM_STREAM !== "true") return;
|
||||
|
||||
console.log(
|
||||
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,
|
||||
|
|
|
|||
|
|
@ -30,23 +30,24 @@ const PROVIDERS: ApiKeyProvider[] = [
|
|||
];
|
||||
|
||||
function envApiKey(provider: ApiKeyProvider): string | null {
|
||||
if (provider === "claude") {
|
||||
return (
|
||||
process.env.ANTHROPIC_API_KEY?.trim() ||
|
||||
process.env.CLAUDE_API_KEY?.trim() ||
|
||||
null
|
||||
);
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return (
|
||||
process.env.ANTHROPIC_API_KEY?.trim() ||
|
||||
process.env.CLAUDE_API_KEY?.trim() ||
|
||||
null
|
||||
);
|
||||
case "gemini":
|
||||
return process.env.GEMINI_API_KEY?.trim() || null;
|
||||
case "openai":
|
||||
return process.env.OPENAI_API_KEY?.trim() || null;
|
||||
case "openrouter":
|
||||
return process.env.OPENROUTER_API_KEY?.trim() || null;
|
||||
case "courtlistener":
|
||||
return process.env.COURTLISTENER_API_TOKEN?.trim() || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return process.env.OPENAI_API_KEY?.trim() || null;
|
||||
}
|
||||
if (provider === "openrouter") {
|
||||
return process.env.OPENROUTER_API_KEY?.trim() || null;
|
||||
}
|
||||
if (provider === "courtlistener") {
|
||||
return process.env.COURTLISTENER_API_TOKEN?.trim() || null;
|
||||
}
|
||||
return process.env.GEMINI_API_KEY?.trim() || null;
|
||||
}
|
||||
|
||||
export function hasEnvApiKey(provider: ApiKeyProvider): boolean {
|
||||
|
|
@ -58,7 +59,7 @@ function encryptionKey(): Buffer {
|
|||
if (!secret) {
|
||||
throw new Error("USER_API_KEYS_ENCRYPTION_SECRET is not configured");
|
||||
}
|
||||
return crypto.createHash("sha256").update(secret).digest();
|
||||
return crypto.scryptSync(secret, "mike-user-api-keys-v1", 32);
|
||||
}
|
||||
|
||||
function encrypt(value: string): Omit<EncryptedKeyRow, "provider"> {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ caseLawRouter.post("/case-opinions", async (req, res) => {
|
|||
clusterId,
|
||||
});
|
||||
const db = createServerSupabase();
|
||||
const fetchKey = String(clusterId);
|
||||
const fetchKey = `${userId}:${clusterId}`;
|
||||
let fetchPromise = sidepanelOpinionFetches.get(fetchKey);
|
||||
if (fetchPromise) {
|
||||
devLog("[case-law/case-opinions] joining in-flight fetch", {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
enrichWithPriorEvents,
|
||||
buildWorkflowStore,
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
extractAnnotations,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
|
|
@ -614,6 +615,28 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
devLog("[chat/stream] client aborted stream", { chatId });
|
||||
if (err instanceof AssistantStreamError) {
|
||||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText, events) =>
|
||||
extractAnnotations(fullText, docIndex, events),
|
||||
});
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
: null,
|
||||
});
|
||||
if (saveError) {
|
||||
console.error(
|
||||
"[chat/stream] failed to save aborted stream",
|
||||
saveError,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("[chat/stream] error:", err);
|
||||
|
|
|
|||
|
|
@ -423,6 +423,15 @@ documentsRouter.post(
|
|||
const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db);
|
||||
if (!sourceAccess.ok)
|
||||
return void res.status(404).json({ detail: "Source document not found" });
|
||||
const willDeleteSource =
|
||||
sourceDoc.project_id &&
|
||||
targetDoc.project_id &&
|
||||
sourceDoc.project_id === targetDoc.project_id;
|
||||
if (willDeleteSource && !sourceAccess.isOwner) {
|
||||
return void res.status(403).json({
|
||||
detail: "Only the source document owner can move it into a version.",
|
||||
});
|
||||
}
|
||||
|
||||
const targetActive = await loadActiveVersion(documentId, db);
|
||||
const targetType = targetActive?.file_type ?? "";
|
||||
|
|
@ -548,11 +557,7 @@ documentsRouter.post(
|
|||
.json({ detail: "Failed to update document current version." });
|
||||
}
|
||||
|
||||
if (
|
||||
sourceDoc.project_id &&
|
||||
targetDoc.project_id &&
|
||||
sourceDoc.project_id === targetDoc.project_id
|
||||
) {
|
||||
if (willDeleteSource) {
|
||||
const { error: deleteErr } = await deleteDocumentAndVersionFiles(
|
||||
db,
|
||||
sourceDocumentId,
|
||||
|
|
@ -721,12 +726,21 @@ documentsRouter.post(
|
|||
.json({ detail: "Failed to record new version." });
|
||||
}
|
||||
|
||||
await db
|
||||
const { error: updateDocErr } = await db
|
||||
.from("documents")
|
||||
.update({
|
||||
current_version_id: versionRow.id,
|
||||
})
|
||||
.eq("id", documentId);
|
||||
if (updateDocErr) {
|
||||
console.error(
|
||||
"[versions/upload] current version update failed",
|
||||
updateDocErr,
|
||||
);
|
||||
return void res
|
||||
.status(500)
|
||||
.json({ detail: "Failed to update document current version." });
|
||||
}
|
||||
|
||||
res.status(201).json(versionRow);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
buildWorkflowStore,
|
||||
enrichWithPriorEvents,
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
extractAnnotations,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
|
|
@ -199,6 +200,28 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
|||
console.log("[project-chat/stream] client aborted stream", {
|
||||
chatId,
|
||||
});
|
||||
if (err instanceof AssistantStreamError) {
|
||||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText, events) =>
|
||||
extractAnnotations(fullText, docIndex, events),
|
||||
});
|
||||
const { error: saveError } = await db.from("chat_messages").insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
: null,
|
||||
});
|
||||
if (saveError) {
|
||||
console.error(
|
||||
"[project-chat/stream] failed to save aborted stream",
|
||||
saveError,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("[project-chat/stream] error:", err);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,37 @@ function normalizeDocumentFilename(nextName: unknown, currentName: string) {
|
|||
return `${trimmed}${ext}`;
|
||||
}
|
||||
|
||||
async function deleteProjectDocumentsAndVersionFiles(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
projectId: string,
|
||||
documentIds: string[],
|
||||
) {
|
||||
if (documentIds.length === 0) return null;
|
||||
const { data: versions, error: versionsError } = await db
|
||||
.from("document_versions")
|
||||
.select("storage_path, pdf_storage_path")
|
||||
.in("document_id", documentIds);
|
||||
if (versionsError) return versionsError;
|
||||
|
||||
const paths = new Set<string>();
|
||||
for (const v of versions ?? []) {
|
||||
if (typeof v.storage_path === "string" && v.storage_path.length > 0) {
|
||||
paths.add(v.storage_path);
|
||||
}
|
||||
if (typeof v.pdf_storage_path === "string" && v.pdf_storage_path.length > 0) {
|
||||
paths.add(v.pdf_storage_path);
|
||||
}
|
||||
}
|
||||
await Promise.all([...paths].map((p) => deleteFile(p).catch(() => {})));
|
||||
|
||||
const { error } = await db
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("project_id", projectId)
|
||||
.in("id", documentIds);
|
||||
return error ?? null;
|
||||
}
|
||||
|
||||
// GET /projects
|
||||
projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
|
|
@ -710,11 +741,48 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req,
|
|||
const access = await checkProjectAccess(projectId, userId, userEmail, db);
|
||||
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
|
||||
|
||||
const folder = await loadProjectFolder(db, projectId, folderId);
|
||||
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
|
||||
const { data: allFolders, error: foldersError } = await db
|
||||
.from("project_subfolders")
|
||||
.select("id, parent_folder_id")
|
||||
.eq("project_id", projectId);
|
||||
if (foldersError)
|
||||
return void res.status(500).json({ detail: foldersError.message });
|
||||
if (!(allFolders ?? []).some((f) => f.id === folderId))
|
||||
return void res.status(404).json({ detail: "Folder not found" });
|
||||
|
||||
// Move direct documents to root before cascade-deleting subfolders
|
||||
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId);
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const f of allFolders ?? []) {
|
||||
const parentId = f.parent_folder_id as string | null;
|
||||
if (!parentId) continue;
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
children.push(f.id as string);
|
||||
childrenByParent.set(parentId, children);
|
||||
}
|
||||
|
||||
const folderIds = new Set<string>();
|
||||
const stack = [folderId];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!;
|
||||
if (folderIds.has(id)) continue;
|
||||
folderIds.add(id);
|
||||
stack.push(...(childrenByParent.get(id) ?? []));
|
||||
}
|
||||
|
||||
const { data: docs, error: docsError } = await db
|
||||
.from("documents")
|
||||
.select("id")
|
||||
.eq("project_id", projectId)
|
||||
.in("folder_id", [...folderIds]);
|
||||
if (docsError) return void res.status(500).json({ detail: docsError.message });
|
||||
|
||||
const docIds = (docs ?? []).map((d) => d.id as string);
|
||||
const deleteDocsError = await deleteProjectDocumentsAndVersionFiles(
|
||||
db,
|
||||
projectId,
|
||||
docIds,
|
||||
);
|
||||
if (deleteDocsError)
|
||||
return void res.status(500).json({ detail: deleteDocsError.message });
|
||||
|
||||
const { error } = await db.from("project_subfolders")
|
||||
.delete().eq("id", folderId).eq("project_id", projectId);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { normalizeDocxZipPaths } from "../lib/convert";
|
||||
import {
|
||||
AssistantStreamError,
|
||||
buildCancelledAssistantMessage,
|
||||
isAbortError,
|
||||
runLLMStream,
|
||||
stripTransientAssistantEvents,
|
||||
|
|
@ -480,8 +481,6 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
const { reviewId } = req.params;
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (req.body.title != null) updates.title = req.body.title;
|
||||
if (req.body.columns_config != null)
|
||||
updates.columns_config = req.body.columns_config;
|
||||
const projectIdUpdateProvided = req.body.project_id !== undefined;
|
||||
const projectIdUpdate =
|
||||
req.body.project_id === null
|
||||
|
|
@ -534,6 +533,14 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
);
|
||||
if (!access.ok)
|
||||
return void res.status(404).json({ detail: "Review not found" });
|
||||
if (req.body.columns_config != null) {
|
||||
if (!access.isOwner) {
|
||||
return void res.status(403).json({
|
||||
detail: "Only the review owner can change columns",
|
||||
});
|
||||
}
|
||||
updates.columns_config = req.body.columns_config;
|
||||
}
|
||||
if (sharedWithUpdate !== undefined) {
|
||||
if (!access.isOwner)
|
||||
return void res
|
||||
|
|
@ -1365,8 +1372,9 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
|
|||
messages.filter((m) => m.role === "user").length === 1;
|
||||
|
||||
if (chatId) {
|
||||
// Either chat owner OR any project member of the parent review can
|
||||
// continue the chat. We've already verified review access above.
|
||||
// The chat must belong to this exact review and to the requester.
|
||||
// Review access alone is not enough: otherwise a user could reuse one
|
||||
// of their chats from a different review in this route.
|
||||
const { data: existing } = await db
|
||||
.from("tabular_review_chats")
|
||||
.select("id, title, review_id, user_id")
|
||||
|
|
@ -1374,7 +1382,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
|
|||
.single();
|
||||
const canUse =
|
||||
!!existing &&
|
||||
(existing.review_id === reviewId || existing.user_id === userId);
|
||||
existing.review_id === reviewId &&
|
||||
existing.user_id === userId;
|
||||
if (!canUse || !existing) chatId = null;
|
||||
else chatTitle = existing.title;
|
||||
}
|
||||
|
|
@ -1479,6 +1488,34 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
|
|||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
console.log("[tabular/chat] client aborted stream", { chatId });
|
||||
if (chatId && err instanceof AssistantStreamError) {
|
||||
const partial = buildCancelledAssistantMessage({
|
||||
fullText: err.fullText,
|
||||
events: err.events,
|
||||
buildAnnotations: (fullText) =>
|
||||
extractTabularAnnotations(fullText, tabularStore),
|
||||
});
|
||||
const { error: saveError } = await db
|
||||
.from("tabular_review_chat_messages")
|
||||
.insert({
|
||||
chat_id: chatId,
|
||||
role: "assistant",
|
||||
content: partial.events.length ? partial.events : null,
|
||||
annotations: partial.annotations.length
|
||||
? partial.annotations
|
||||
: null,
|
||||
});
|
||||
if (saveError) {
|
||||
console.error(
|
||||
"[tabular/chat] failed to save aborted stream",
|
||||
saveError,
|
||||
);
|
||||
}
|
||||
await db
|
||||
.from("tabular_review_chats")
|
||||
.update({ updated_at: new Date().toISOString() })
|
||||
.eq("id", chatId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("[tabular/chat] error", err);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ export type CaseTab = {
|
|||
url: string | null;
|
||||
dateFiled: string | null;
|
||||
pdfUrl: string | null;
|
||||
judges: string | null;
|
||||
quotes?: CaseCitationQuote[];
|
||||
opinions?: CaseLawOpinion[];
|
||||
};
|
||||
|
|
@ -281,7 +280,6 @@ export function CaseLawPanel({
|
|||
const citation = tab.citation;
|
||||
const courtlistenerUrl = tab.url;
|
||||
const filedDate = formatCaseDate(tab.dateFiled);
|
||||
const judges = tab.judges?.trim() || null;
|
||||
const orderedOpinions = orderOpinions(opinions);
|
||||
const activeOpinion = opinions.find(
|
||||
(opinion) => opinion.opinionId === activeOpinionId,
|
||||
|
|
@ -377,13 +375,9 @@ export function CaseLawPanel({
|
|||
<span className="text-gray-500">, {citation}</span>
|
||||
)}
|
||||
</h2>
|
||||
{filedDate || judges ? (
|
||||
{filedDate ? (
|
||||
<p className="mt-1 font-serif text-sm text-gray-600">
|
||||
{filedDate && <>Date: {filedDate}</>}
|
||||
{filedDate && judges && (
|
||||
<span className="mx-1.5 text-gray-300">|</span>
|
||||
)}
|
||||
{judges && <>Judges: {judges}</>}
|
||||
Date: {filedDate}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ export function ChatView({
|
|||
url: citation.url ?? null,
|
||||
dateFiled: citation.dateFiled ?? null,
|
||||
pdfUrl: citation.pdfUrl ?? null,
|
||||
judges: citation.judges ?? null,
|
||||
quotes: showQuotes ? citation.quotes : undefined,
|
||||
opinions: undefined,
|
||||
});
|
||||
|
|
@ -259,7 +258,6 @@ export function ChatView({
|
|||
url: citation.url,
|
||||
dateFiled: citation.dateFiled ?? null,
|
||||
pdfUrl: citation.pdfUrl ?? null,
|
||||
judges: citation.judges ?? null,
|
||||
quotes: undefined,
|
||||
opinions: citation.case?.opinions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { DocFileIcon } from "@/app/components/shared/FileDirectory";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import type { Document } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
|
|
@ -27,6 +26,10 @@ const MIN_DATA_COLUMN_WIDTH = 280;
|
|||
const DEFAULT_DATA_COLUMN_WIDTH = 340;
|
||||
const RESIZER_WIDTH = 6;
|
||||
const MAX_PANEL_WIDTH = 1180;
|
||||
const primaryGlassButtonClass =
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
||||
const dangerGlassButtonClass =
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
||||
|
||||
interface DocumentSidePanelProps {
|
||||
doc: Document | null;
|
||||
|
|
@ -48,10 +51,7 @@ interface DocumentSidePanelProps {
|
|||
versionId: string,
|
||||
filename: string,
|
||||
) => Promise<void> | void;
|
||||
onDeleteVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
) => Promise<void> | void;
|
||||
onDeleteVersion: (docId: string, versionId: string) => Promise<void> | void;
|
||||
onUploadNewVersion: (
|
||||
doc: Document,
|
||||
file: File,
|
||||
|
|
@ -69,7 +69,6 @@ export function DocumentSidePanel({
|
|||
onClose,
|
||||
onLoadVersions,
|
||||
onSelectVersion,
|
||||
onDownloadDocument,
|
||||
onDownloadVersion,
|
||||
onRenameVersion,
|
||||
onDeleteVersion,
|
||||
|
|
@ -84,7 +83,9 @@ export function DocumentSidePanel({
|
|||
const [savingName, setSavingName] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [extensionWarningOpen, setExtensionWarningOpen] = useState(false);
|
||||
const [deletingVersion, setDeletingVersion] = useState(false);
|
||||
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingDocument, setDeletingDocument] = useState(false);
|
||||
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
|
||||
useState(false);
|
||||
|
|
@ -142,8 +143,7 @@ export function DocumentSidePanel({
|
|||
orderedVersions[0] ??
|
||||
null;
|
||||
const selectedVersionId = selectedVersion?.id ?? versionId ?? null;
|
||||
const selectedFilename =
|
||||
selectedVersion?.filename?.trim() || doc.filename;
|
||||
const selectedFilename = selectedVersion?.filename?.trim() || doc.filename;
|
||||
const selectedFileType =
|
||||
selectedVersion != null
|
||||
? fileTypeForVersion(selectedVersion, doc.file_type)
|
||||
|
|
@ -207,15 +207,14 @@ export function DocumentSidePanel({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSelectedVersion() {
|
||||
if (!selectedVersionId) return;
|
||||
setDeletingVersion(true);
|
||||
async function handleDeleteVersion(versionIdToDelete: string) {
|
||||
setDeletingVersionId(versionIdToDelete);
|
||||
try {
|
||||
await onDeleteVersion(documentId, selectedVersionId);
|
||||
await onDeleteVersion(documentId, versionIdToDelete);
|
||||
} catch (err) {
|
||||
console.error("delete version failed", err);
|
||||
} finally {
|
||||
setDeletingVersion(false);
|
||||
setDeletingVersionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +260,8 @@ export function DocumentSidePanel({
|
|||
panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH,
|
||||
);
|
||||
const nextWidth =
|
||||
dragStartDataWidth.current + (dragStartX.current - event.clientX);
|
||||
dragStartDataWidth.current +
|
||||
(dragStartX.current - event.clientX);
|
||||
setDataColumnWidth(
|
||||
Math.min(
|
||||
maxDataWidth,
|
||||
|
|
@ -290,7 +290,8 @@ export function DocumentSidePanel({
|
|||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const nextWidth =
|
||||
dragStartPanelWidth.current + (dragStartX.current - event.clientX);
|
||||
dragStartPanelWidth.current +
|
||||
(dragStartX.current - event.clientX);
|
||||
setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth));
|
||||
};
|
||||
|
||||
|
|
@ -383,13 +384,13 @@ export function DocumentSidePanel({
|
|||
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col",
|
||||
"bg-white/25",
|
||||
"mb-3 ml-2 mr-3 flex min-h-0 flex-col overflow-hidden rounded-xl",
|
||||
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-4 pb-3 pt-0",
|
||||
"shrink-0 px-4 py-3",
|
||||
"border-b border-white/60",
|
||||
)}
|
||||
>
|
||||
|
|
@ -400,28 +401,30 @@ export function DocumentSidePanel({
|
|||
{editingName ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSaveName();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingName(false);
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSaveName();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingName(false);
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveName()}
|
||||
onClick={() =>
|
||||
void handleSaveName()
|
||||
}
|
||||
disabled={savingName}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Save name"
|
||||
|
|
@ -465,96 +468,57 @@ export function DocumentSidePanel({
|
|||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Document Data
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<DataRow label="Type" value={selectedFileType ?? "—"} />
|
||||
<DataRow
|
||||
label="Size"
|
||||
value={
|
||||
selectedSizeBytes != null
|
||||
? formatBytes(selectedSizeBytes)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Version"
|
||||
value={
|
||||
selectedVersionNumber != null
|
||||
? String(selectedVersionNumber)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDate(selectedUploadedAt)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{selectedPageCount != null && (
|
||||
<div className="rounded-xl bg-gray-100/70 px-3 py-3">
|
||||
<div className="space-y-1.5">
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={String(selectedPageCount)}
|
||||
label="Type"
|
||||
value={selectedFileType ?? "—"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleDeleteSelectedVersion()
|
||||
}
|
||||
disabled={
|
||||
!selectedVersionId ||
|
||||
versions.length <= 1 ||
|
||||
deletingVersion
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
<DataRow
|
||||
label="Size"
|
||||
value={
|
||||
selectedSizeBytes != null
|
||||
? formatBytes(selectedSizeBytes)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Version"
|
||||
value={
|
||||
selectedVersionNumber != null
|
||||
? String(selectedVersionNumber)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDate(selectedUploadedAt)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{selectedPageCount != null && (
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={String(selectedPageCount)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{deletingVersion ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Delete version
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectedVersionId
|
||||
? void onDownloadVersion(
|
||||
doc.id,
|
||||
selectedVersionId,
|
||||
selectedFilename,
|
||||
)
|
||||
: void onDownloadDocument(doc.id)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-900">
|
||||
Versions
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
|
||||
"border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]",
|
||||
"bg-gray-100 px-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 py-2 text-xs font-medium text-gray-900",
|
||||
"border-b border-white/60",
|
||||
)}
|
||||
>
|
||||
Versions
|
||||
</div>
|
||||
<div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
{versionsLoading && versions.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -565,59 +529,136 @@ export function DocumentSidePanel({
|
|||
No version history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{orderedVersions.map((version) => {
|
||||
const title =
|
||||
versionTitleFor(version);
|
||||
const filename =
|
||||
versionFilenameFor(version);
|
||||
const selected =
|
||||
selectedVersionId === version.id;
|
||||
const fileType =
|
||||
fileTypeForVersion(
|
||||
version,
|
||||
doc.file_type,
|
||||
);
|
||||
selectedVersionId ===
|
||||
version.id;
|
||||
const versionDeleting =
|
||||
deletingVersionId ===
|
||||
version.id;
|
||||
const fileType = fileTypeForVersion(
|
||||
version,
|
||||
doc.file_type,
|
||||
);
|
||||
const typeLabel =
|
||||
fileType === "pdf"
|
||||
? "PDF"
|
||||
: "DOCX";
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={version.id}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-white/55",
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !==
|
||||
"Enter" &&
|
||||
event.key !== " "
|
||||
) return;
|
||||
event.preventDefault();
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DocFileIcon
|
||||
fileType={
|
||||
fileType
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800">
|
||||
{title}
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-xs font-medium text-gray-800",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="truncate pl-[22px] text-[11px] text-gray-400">
|
||||
{filename}
|
||||
</div>
|
||||
<div className="truncate pl-[22px] text-[11px] text-gray-400">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] font-semibold tracking-normal",
|
||||
typeLabel ===
|
||||
"PDF"
|
||||
? "text-red-600"
|
||||
: "text-blue-600",
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{filename}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-[11px] text-gray-400">
|
||||
{version.created_at
|
||||
? new Date(
|
||||
version.created_at,
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
|
||||
selected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onDownloadVersion(
|
||||
doc.id,
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label={`Download ${title}`}
|
||||
title="Download version"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteVersion(
|
||||
version.id,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
versions.length <=
|
||||
1 ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Delete ${title}`}
|
||||
title="Delete version"
|
||||
>
|
||||
{versionDeleting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -650,12 +691,12 @@ export function DocumentSidePanel({
|
|||
type="button"
|
||||
onClick={requestDeleteDocument}
|
||||
disabled={deletingDocument}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className={dangerGlassButtonClass}
|
||||
>
|
||||
{deletingDocument ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
|
|
@ -663,12 +704,12 @@ export function DocumentSidePanel({
|
|||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className={primaryGlassButtonClass}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<Upload className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
Upload new version
|
||||
</button>
|
||||
|
|
@ -742,10 +783,7 @@ function versionFilenameFor(version: DocumentVersion) {
|
|||
return version.source === "upload" ? "Original" : "—";
|
||||
}
|
||||
|
||||
function fileTypeForVersion(
|
||||
version: DocumentVersion,
|
||||
fallback: string | null,
|
||||
) {
|
||||
function fileTypeForVersion(version: DocumentVersion, fallback: string | null) {
|
||||
const name = version.filename?.trim().toLowerCase() ?? "";
|
||||
if (name.endsWith(".pdf")) return "pdf";
|
||||
if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfirmStatus = "idle" | "loading" | "complete";
|
||||
|
|
@ -37,14 +37,20 @@ export function ConfirmPopup({
|
|||
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
|
||||
const normalizedConfirmLabel =
|
||||
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
|
||||
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
|
||||
const resolvedConfirmLabel =
|
||||
confirmStatus === "loading" ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
{progressiveLabel(normalizedConfirmLabel)}
|
||||
</span>
|
||||
) : confirmStatus === "complete" ? (
|
||||
completedLabel(normalizedConfirmLabel)
|
||||
) : isDeleteAction ? (
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Trash2 className="h-3 w-3 shrink-0" />
|
||||
{confirmLabel}
|
||||
</span>
|
||||
) : (
|
||||
confirmLabel
|
||||
);
|
||||
|
|
@ -53,17 +59,19 @@ export function ConfirmPopup({
|
|||
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
|
||||
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-medium text-gray-950">
|
||||
<div className="text-sm font-medium text-gray-950 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className={cn("text-xs text-gray-700", title && "mt-1")}>
|
||||
<div
|
||||
className={cn("text-xs text-gray-700", title && "mt-1")}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -71,7 +79,7 @@ export function ConfirmPopup({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:text-gray-950"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
|
@ -79,7 +87,12 @@ export function ConfirmPopup({
|
|||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={resolvedConfirmDisabled}
|
||||
className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center justify-center rounded-full px-3.5 text-xs font-medium leading-none text-white backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
|
||||
isDeleteAction
|
||||
? "border border-red-700/35 bg-red-600/90 shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] hover:bg-red-600"
|
||||
: "border border-gray-700/40 bg-gray-950/88 shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] hover:bg-gray-900/90",
|
||||
)}
|
||||
aria-busy={confirmBusy}
|
||||
>
|
||||
{resolvedConfirmLabel}
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ export function Modal({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-2xl shadow-2xl flex h-[600px] flex-col",
|
||||
"w-full rounded-2xl flex h-[600px] flex-col",
|
||||
sizeClassName[size],
|
||||
"border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-2xl",
|
||||
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -123,7 +123,7 @@ export function Modal({
|
|||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3",
|
||||
"flex items-center gap-3 p-4",
|
||||
secondaryAction || footerInfo
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
|
|
@ -181,14 +181,14 @@ function ModalActionButton({
|
|||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"inline-flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40",
|
||||
variant === "primary" &&
|
||||
"bg-gray-900 text-white hover:bg-gray-700",
|
||||
variant === "secondary" && "text-gray-600 hover:bg-gray-100",
|
||||
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "secondary" && "text-gray-600 hover:text-gray-950",
|
||||
fallbackVariant === "secondary" &&
|
||||
"border border-gray-200 hover:bg-gray-50",
|
||||
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
|
||||
variant === "danger" &&
|
||||
"bg-red-600 text-white hover:bg-red-700",
|
||||
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,7 @@ export function OwnerOnlyModal({
|
|||
onClose={onClose}
|
||||
title={title}
|
||||
message={body}
|
||||
icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />}
|
||||
primaryAction={{ label: "OK", onClick: onClose }}
|
||||
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
|
||||
>
|
||||
{ownerEmail && (
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export function WarningPopup({
|
|||
}: WarningPopupProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const warningIcon = icon ?? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
|
||||
<div
|
||||
|
|
@ -44,16 +48,21 @@ export function WarningPopup({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
{icon ?? (
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 self-center text-gray-900">
|
||||
<div className="min-w-0 flex-1 self-center text-red-600">
|
||||
{title && (
|
||||
<div className="font-medium text-gray-950">
|
||||
<div className="flex items-center gap-1.5 font-medium mb-1">
|
||||
{warningIcon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && <div>{message}</div>}
|
||||
{message && (
|
||||
<div
|
||||
className={cn(!title && "flex items-start gap-1.5")}
|
||||
>
|
||||
{!title && warningIcon}
|
||||
<span className="min-w-0">{message}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
|
|
@ -72,7 +81,7 @@ export function WarningPopup({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-gray-700 transition-colors hover:text-gray-950"
|
||||
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -207,7 +207,6 @@ export type AssistantEvent =
|
|||
url: string;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"];
|
||||
}
|
||||
| {
|
||||
|
|
@ -288,7 +287,6 @@ export type CaseCitationAnnotation = {
|
|||
url?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
quotes: CaseCitationQuote[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export function ShareWorkflowModal({
|
|||
const [existingShares, setExistingShares] = useState<Share[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
|
||||
|
||||
|
|
@ -55,13 +56,18 @@ export function ShareWorkflowModal({
|
|||
: pendingEmails;
|
||||
if (emails.length === 0) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await shareWorkflow(workflowId, { emails, allow_edit: allowEdit });
|
||||
const updated = await listWorkflowShares(workflowId);
|
||||
setExistingShares(updated);
|
||||
setPendingEmails([]);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: "Unable to share this workflow. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -90,6 +96,12 @@ export function ShareWorkflowModal({
|
|||
autoFocus
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Permission toggle */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
streamChat,
|
||||
|
|
@ -20,13 +20,6 @@ interface UseAssistantChatOptions {
|
|||
projectId?: string;
|
||||
}
|
||||
|
||||
function findLastContentIndex(events: AssistantEvent[]): number {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i].type === "content") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function readableStreamError(value: unknown): string {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
return "Sorry, something went wrong.";
|
||||
|
|
@ -161,21 +154,51 @@ export function useAssistantChat({
|
|||
});
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
setIsResponseLoading(false);
|
||||
setIsLoadingCitations(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Transient placeholder events (tool_call_start, thinking) fill the
|
||||
// latency gap between real SSE events so the wrapper doesn't look stuck.
|
||||
// Anytime a real event arrives, drop any streaming placeholder first.
|
||||
const isStreamingPlaceholder = (e: AssistantEvent) =>
|
||||
(e.type === "tool_call_start" || e.type === "thinking") && !!e.isStreaming;
|
||||
|
||||
const cancelStreamingEvents = (events: AssistantEvent[]) =>
|
||||
events
|
||||
.filter((event) => !isStreamingPlaceholder(event))
|
||||
.map((event) => {
|
||||
if (!("isStreaming" in event) || !event.isStreaming) return event;
|
||||
const rest = { ...event };
|
||||
delete (rest as { isStreaming?: boolean }).isStreaming;
|
||||
return rest as AssistantEvent;
|
||||
});
|
||||
|
||||
const appendCancellationEvent = (events: AssistantEvent[]) => {
|
||||
const cancelledEvents = cancelStreamingEvents(events);
|
||||
return [
|
||||
...cancelledEvents,
|
||||
{ type: "content" as const, text: "Cancelled by user." },
|
||||
];
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
const snapshot = cancelStreamingEvents(eventsRef.current);
|
||||
eventsRef.current = snapshot;
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: cancelStreamingEvents(last.events ?? snapshot),
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setIsResponseLoading(false);
|
||||
setIsLoadingCitations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearStreamingPlaceholders = () => {
|
||||
const before = eventsRef.current;
|
||||
const after = before.filter((e) => !isStreamingPlaceholder(e));
|
||||
|
|
@ -284,10 +307,10 @@ export function useAssistantChat({
|
|||
|
||||
eventsRef.current = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const apiMessages = newMessages.map((currentMessage) => ({
|
||||
role: currentMessage.role,
|
||||
content: currentMessage.content,
|
||||
|
|
@ -1114,43 +1137,29 @@ export function useAssistantChat({
|
|||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
finalizeStreamingContent();
|
||||
finalizeStreamingReasoning();
|
||||
eventsRef.current = appendCancellationEvent(eventsRef.current);
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
const updated = [...prev];
|
||||
const events = last.events ?? [];
|
||||
const idx = findLastContentIndex(events);
|
||||
const cancelText = "Cancelled by user";
|
||||
if (idx >= 0) {
|
||||
const newEvents = [...events];
|
||||
const existing = newEvents[idx] as {
|
||||
type: "content";
|
||||
text: string;
|
||||
};
|
||||
newEvents[idx] = {
|
||||
type: "content",
|
||||
text: existing.text
|
||||
? `${existing.text}\n\nCancelled by user`
|
||||
: cancelText,
|
||||
};
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: newEvents,
|
||||
};
|
||||
} else {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: [...events, { type: "content", text: cancelText }],
|
||||
};
|
||||
}
|
||||
const events = appendCancellationEvent(
|
||||
last.events ?? eventsRef.current,
|
||||
);
|
||||
eventsRef.current = events;
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events,
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
eventsRef.current = [{ type: "content", text: "Cancelled by user." }];
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
events: [{ type: "content", text: "Cancelled by user" }],
|
||||
events: [{ type: "content", text: "Cancelled by user." }],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
@ -1185,7 +1194,9 @@ export function useAssistantChat({
|
|||
setIsLoadingCitations(false);
|
||||
return null;
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
if (abortControllerRef.current === controller) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue